Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accessibility: Add focus lock for the infinite editor #8522

Merged
merged 40 commits into from
May 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d9f091b
Toggle the inert attribute when adding or removing the first/last editor
BatJan Jul 30, 2020
c44821c
Add focus lock directive for the editor
BatJan Jul 30, 2020
f6d9466
Increase timeout period so infinite editors transclusions will also h…
BatJan Jul 30, 2020
3db306d
Make sure elements containing .ng-hide are not part of the possible f…
BatJan Jul 30, 2020
27584df
Update comments
BatJan Aug 1, 2020
069ea99
Conditionally add umb-focus-lock and inert attributes
BatJan Aug 1, 2020
83f3cbb
Hook into the evenservice to reinitialize the onInit method if the la…
BatJan Aug 1, 2020
1749cf8
Don't try to add focus to something that does not exist
BatJan Aug 1, 2020
9561282
Minor code refactor placing some variables outside the init method
BatJan Aug 1, 2020
bdfc3e3
Refactoring code to make things a bit more clean
BatJan Aug 1, 2020
738172b
Remove the event listener when the directive is destroyed
BatJan Aug 1, 2020
d4fe46a
Add mutationObserver to watch for attribute changes and then trigger …
BatJan Aug 1, 2020
d8bc86b
Merge branch 'v8/contrib' into feature/infinite-editor-focus-lock
BatJan Aug 1, 2020
af11942
Fetch focusable elements on domChange
BatJan Aug 1, 2020
c2b8862
Ensure that args exists before trying to read the properties
BatJan Aug 1, 2020
d5600f7
Refactor to use mutationobserver when overlays are closed passing the…
BatJan Aug 2, 2020
b8da6f3
Change ng-hide to ng-if so the focusable elements inside the hidden d…
BatJan Aug 2, 2020
4d16703
Narrow attributes to look for down to the bare minimum of the umb-foc…
BatJan Aug 2, 2020
051c8f9
Refactor to using good ol' for loop (Fastest)
BatJan Aug 2, 2020
64ffc49
Disconnect the observer once the init function has been called - Mass…
BatJan Aug 2, 2020
1108a67
Event handler cleanup
BatJan Aug 3, 2020
f0239aa
Refactor the code to re-initialize the init method on destroy in case…
BatJan Aug 4, 2020
538600d
Align codestyle
BatJan Aug 4, 2020
4e7a577
Add logic to deal with "lastKnowFocused" elements in infinite editing…
BatJan Aug 6, 2020
4341c34
Merge branch 'v8/contrib' into feature/infinite-editor-focus-lock
BatJan Aug 22, 2020
18bbe34
Re-add attributes after merge with contrib branch
BatJan Aug 22, 2020
44bc965
Correct spelling mistake
BatJan Aug 24, 2020
b69af2a
Move onInit into the $includeContentLoaded event and set the timeout …
BatJan Sep 26, 2020
a7a4b9a
Merge branch 'v8/contrib' into feature/infinite-editor-focus-lock
BatJan Sep 27, 2020
e8c7156
Make sure to add focus to elements with role="button" as well
BatJan Sep 27, 2020
664a586
Add comments and remove timeout / delay settings
BatJan Sep 27, 2020
44c34e2
Debouce domObserver
BatJan Sep 27, 2020
5c02d18
Wrap init function in safeApply
BatJan Sep 27, 2020
7cf0fc2
Merge branch 'v8/contrib' into feature/infinite-editor-focus-lock
BatJan Oct 17, 2020
7fe7637
Add comments to help remember / understand what things are intended t…
BatJan Oct 17, 2020
863fdff
Adding more comments
BatJan Oct 17, 2020
ce41333
Move setting of first and last focusable elements into the setElement…
BatJan Oct 17, 2020
b75ab71
Remove todo
BatJan Oct 17, 2020
a408a38
Move the setup of first and last focusable elements back to where the…
BatJan Oct 17, 2020
0952fdb
Merge branch 'v8/contrib' into feature/infinite-editor-focus-lock
Apr 9, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
(function () {
'use strict';

function EditorsDirective($timeout, eventsService) {
function EditorsDirective($timeout, eventsService, focusLockService) {

function link(scope, el, attr, ctrl) {

Expand All @@ -27,6 +27,9 @@
if(isLeftColumnAbove){
$(sectionId).removeClass(aboveBackDropCssClass);
}

// Inert content in the #mainwrapper
focusLockService.addInertAttribute();
}

$timeout(() => {
Expand Down Expand Up @@ -54,6 +57,9 @@
}

isLeftColumnAbove = false;

// Remove the inert attribute from the #mainwrapper
focusLockService.removeInertAttribute();
}
}

Expand Down Expand Up @@ -96,7 +102,6 @@
iEditor.inFront = iEditor.level >= ceiling;
i++;
}

}

evts.push(eventsService.on("appState.editors.open", function (name, args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
(function() {
'use strict';

function FocusLock($timeout) {
function FocusLock($timeout, $rootScope, angularHelper) {

// If the umb-auto-focus directive is in use we respect that by leaving the default focus on it instead of choosing the first focusable element using this function
function getAutoFocusElement (elements) {
var elmentWithAutoFocus = null;

Expand All @@ -16,53 +17,212 @@
}

function link(scope, element) {
var target = element[0];
var focusableElements;
var firstFocusableElement;
var lastFocusableElement;
var infiniteEditorsWrapper;
var infiniteEditors;
var disconnectObserver = false;
var closingEditor = false;

function onInit() {
// List of elements that can be focusable within the focus lock
var focusableElementsSelector = 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled])';
var bodyElement = document.querySelector('body');

$timeout(function() {
var target = element[0];

var focusableElements = target.querySelectorAll(focusableElementsSelector);
var defaultFocusedElement = getAutoFocusElement(focusableElements);
var firstFocusableElement = focusableElements[0];
var lastFocusableElement = focusableElements[focusableElements.length -1];

// If there is no default focused element put focus on the first focusable element in the nodelist
if(defaultFocusedElement === null ){
if(!$rootScope.lastKnownFocusableElements){
$rootScope.lastKnownFocusableElements = [];
}

$rootScope.lastKnownFocusableElements.push(document.activeElement);

// List of elements that can be focusable within the focus lock
var focusableElementsSelector = '[role="button"], a[href]:not([disabled]):not(.ng-hide), button:not([disabled]):not(.ng-hide), textarea:not([disabled]):not(.ng-hide), input:not([disabled]):not(.ng-hide), select:not([disabled]):not(.ng-hide)';

// Grab the body element so we can add the tabbing class on it when needed
var bodyElement = document.querySelector('body');

function getDomNodes(){
infiniteEditorsWrapper = document.querySelector('.umb-editors');
infiniteEditors = Array.from(infiniteEditorsWrapper.querySelectorAll('.umb-editor'));
}

function getFocusableElements(targetElm) {
var elm = targetElm ? targetElm : target;
focusableElements = elm.querySelectorAll(focusableElementsSelector);
// Set first and last focusable elements
firstFocusableElement = focusableElements[0];
lastFocusableElement = focusableElements[focusableElements.length - 1];
}

function handleKeydown(event) {
var isTabPressed = (event.key === 'Tab' || event.keyCode === 9);

if (!isTabPressed){
return;
}

// If shift + tab key
if(event.shiftKey){
// Set focus on the last focusable element if shift+tab are pressed meaning we go backwards
if(document.activeElement === firstFocusableElement){
lastFocusableElement.focus();
event.preventDefault();
}
}
// Else only the tab key is pressed
else{
// Using only the tab key we set focus on the first focusable element mening we go forward
if (document.activeElement === lastFocusableElement) {
firstFocusableElement.focus();
event.preventDefault();
}
}
}

function clearLastKnownFocusedElements() {
$rootScope.lastKnownFocusableElements = [];
}

function setElementFocus() {
var defaultFocusedElement = getAutoFocusElement(focusableElements);
var lastKnownElement;

// If an inifite editor is being closed then we reset the focus to the element that triggered the the overlay
if(closingEditor){
var lastItemIndex = $rootScope.lastKnownFocusableElements.length - 1;
var editorInfo = infiniteEditors[0].querySelector('.editor-info');

// If there is only one editor open, search for the "editor-info" inside it and set focus on it
// This is relevant when a property editor has been selected and the editor where we selected it from
// is closed taking us back to the first layer
// Otherwise set it to the last element in the lastKnownFocusedElements array
if(infiniteEditors.length === 1 && editorInfo !== null){
lastKnownElement = editorInfo;

// Clear the array
clearLastKnownFocusedElements();
}

target.addEventListener('keydown', function(event){
var isTabPressed = (event.key === 'Tab' || event.keyCode === 9);

if (!isTabPressed){
return;
}

// If shift + tab key
if(event.shiftKey){
// Set focus on the last focusable element if shift+tab are pressed meaning we go backwards
if(document.activeElement === firstFocusableElement){
lastFocusableElement.focus();
event.preventDefault();
}
}
// Else only the tab key is pressed
else{
// Using only the tab key we set focus on the first focusable element mening we go forward
if (document.activeElement === lastFocusableElement) {
firstFocusableElement.focus();
event.preventDefault();
}
}
});
}, 250);
else {
lastKnownElement = $rootScope.lastKnownFocusableElements[lastItemIndex];

// Remove the last item from the array so we always set the correct lastKnowFocus for each layer
$rootScope.lastKnownFocusableElements.splice(lastItemIndex, 1);
}

// Update the lastknowelement variable here
closingEditor = false;
}

// 1st - we check for any last known element - Usually the element the trigger the opening of a new layer
// If it exists it will receive fous
// 2nd - We check to see if a default focus has been set using the umb-auto-focus directive. If not we set focus on
// the first focusable element
// 3rd - Otherwise put the focus on the default focused element
if(lastKnownElement){
lastKnownElement.focus();
}
else if(defaultFocusedElement === null ){
firstFocusableElement.focus();
}
else {
defaultFocusedElement.focus();
}
}

onInit();
function observeDomChanges() {
// Watch for DOM changes - so we can refresh the focusable elements if an element
// changes from being disabled to being enabled for instance
var observer = new MutationObserver(_.debounce(domChange, 200));

// Options for the observer (which mutations to observe)
var config = { attributes: true, childList: true, subtree: true};

// Whenever the DOM changes ensure the list of focused elements is updated
function domChange() {
getFocusableElements();
}

// Start observing the target node for configured mutations
observer.observe(target, config);

// Disconnect observer
if(disconnectObserver){
observer.disconnect();
}
}

function cleanupEventHandlers() {
var activeEditor = infiniteEditors[infiniteEditors.length - 1];
var inactiveEditors = infiniteEditors.filter(editor => editor !== activeEditor);

if(inactiveEditors.length > 0) {
for (var index = 0; index < inactiveEditors.length; index++) {
var inactiveEditor = inactiveEditors[index];

// Remove event handlers from inactive editors
inactiveEditor.removeEventListener('keydown', handleKeydown);
}
}
else {
// Remove event handlers from the active editor
activeEditor.removeEventListener('keydown', handleKeydown);
}
}

function onInit(targetElm) {

$timeout(() => {

// Fetch the DOM nodes we need
getDomNodes();

// Cleanup event handlers if we're in infinite editing mode
if(infiniteEditors.length > 0){
cleanupEventHandlers();
}

getFocusableElements(targetElm);

if(focusableElements.length > 0) {

observeDomChanges();

setElementFocus();

// Handle keydown
target.addEventListener('keydown', handleKeydown);
}

});
}

scope.$on('$includeContentLoaded', () => {
angularHelper.safeApply(scope, () => {
onInit();
});
});

// If more than one editor is still open then re-initialize otherwise remove the event listener
scope.$on('$destroy', function () {
// Make sure to disconnect the observer so we potentially don't end up with having many active ones
disconnectObserver = true;

// Pass the correct editor in order to find the focusable elements
var newTarget = infiniteEditors[infiniteEditors.length - 2];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nul800sebastiaan I've just experienced a problem with this line.
From reading this line there is an obvious mistake as we easily could have just one or zero infinite editors.

Lets me know if you need anything from me, but I think this will have to be dealt with before making the RC. :-)


if(infiniteEditors.length > 1){
// Setting closing till true will let us re-apply the last known focus to then opened layer that then becomes
// active
closingEditor = true;

onInit(newTarget);

return;
}

// Clear lastKnownFocusableElements
clearLastKnownFocusedElements();

// Cleanup event handler
target.removeEventListener('keydown', handleKeydown);
});
}

var directive = {
Expand All @@ -76,3 +236,6 @@
angular.module('umbraco.directives').directive('umbFocusLock', FocusLock);

})();


// TODO: Ensure the domObserver is NOT started when there is only one infinite overlay and it's being destroyed!
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<div class="umb-editors">

<umb-editor-repeater class="umb-editor"
ng-repeat="model in editors"
<umb-editor-repeater class="umb-editor"
ng-repeat="model in editors"
ng-attr-umb-focus-lock="{{$last || undefined}}"
ng-attr-inert="{{$last ? undefined : true}}"
ng-class="{'umb-editor--small': model.size === 'small',
'umb-editor--medium': model.size === 'medium',
'umb-editor--animating': model.animating,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<ul class="umb-tree" ng-class="{'hide-options': hideoptions === 'true'}">

<li ng-if="!tree.root.containsGroups">
<div class="umb-tree-root" data-element="tree-root" ng-class="getNodeCssClass(tree.root)" ng-hide="hideheader === 'true'" on-right-click="altSelect(tree.root, $event)">
<div class="umb-tree-root" data-element="tree-root" ng-class="getNodeCssClass(tree.root)" ng-if="hideheader !== 'true'" on-right-click="altSelect(tree.root, $event)">
<h5>
<a ng-href="#/{{section}}" ng-click="select(tree.root, $event)" class="umb-tree-root-link umb-outline" data-element="tree-root-link">
<umb-icon icon="icon-check"
Expand Down