From a721f92aeda814314d240e7362bc3c4ad053bd65 Mon Sep 17 00:00:00 2001 From: Jan Skovgaard <1932158+BatJan@users.noreply.github.com> Date: Tue, 4 May 2021 11:08:02 +0200 Subject: [PATCH] Accessibility: Add focus lock for the infinite editor (#8522) * Toggle the inert attribute when adding or removing the first/last editor * Add focus lock directive for the editor * Increase timeout period so infinite editors transclusions will also have time to finish * Make sure elements containing .ng-hide are not part of the possible focusable elements * Update comments * Conditionally add umb-focus-lock and inert attributes * Hook into the evenservice to reinitialize the onInit method if the last editor has not been closed when the lock is used in infinite mode * Don't try to add focus to something that does not exist * Minor code refactor placing some variables outside the init method * Refactoring code to make things a bit more clean * Remove the event listener when the directive is destroyed * Add mutationObserver to watch for attribute changes and then trigger the getFocusableElements method * Fetch focusable elements on domChange * Ensure that args exists before trying to read the properties * Refactor to use mutationobserver when overlays are closed passing the correct target and re-initialising the directive to activate the focus lock * Change ng-hide to ng-if so the focusable elements inside the hidden div are not being selected * Narrow attributes to look for down to the bare minimum of the umb-focus-lock * Refactor to using good ol' for loop (Fastest) * Disconnect the observer once the init function has been called - Massive performance improvement * Event handler cleanup * Refactor the code to re-initialize the init method on destroy in case infinite editors still exists in the DOM * Align codestyle * Add logic to deal with "lastKnowFocused" elements in infinite editing mode * Re-add attributes after merge with contrib branch * Correct spelling mistake * Move onInit into the $includeContentLoaded event and set the timeout to 0 * Make sure to add focus to elements with role="button" as well * Add comments and remove timeout / delay settings * Debouce domObserver * Wrap init function in safeApply * Add comments to help remember / understand what things are intended to be doing and add missing event param as well as getting rid of some unused code * Adding more comments * Move setting of first and last focusable elements into the setElement function * Remove todo * Move the setup of first and last focusable elements back to where they were... Co-authored-by: Joe Glombek --- .../components/editor/umbeditors.directive.js | 9 +- .../forms/umbfocuslock.directive.js | 249 +++++++++++++++--- .../views/components/editor/umb-editors.html | 6 +- .../src/views/components/tree/umb-tree.html | 2 +- 4 files changed, 218 insertions(+), 48 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditors.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditors.directive.js index 20fba6eb6ed0..c3b8a6c148e2 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditors.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditors.directive.js @@ -1,7 +1,7 @@ (function () { 'use strict'; - function EditorsDirective($timeout, eventsService) { + function EditorsDirective($timeout, eventsService, focusLockService) { function link(scope, el, attr, ctrl) { @@ -27,6 +27,9 @@ if(isLeftColumnAbove){ $(sectionId).removeClass(aboveBackDropCssClass); } + + // Inert content in the #mainwrapper + focusLockService.addInertAttribute(); } $timeout(() => { @@ -54,6 +57,9 @@ } isLeftColumnAbove = false; + + // Remove the inert attribute from the #mainwrapper + focusLockService.removeInertAttribute(); } } @@ -96,7 +102,6 @@ iEditor.inFront = iEditor.level >= ceiling; i++; } - } evts.push(eventsService.on("appState.editors.open", function (name, args) { diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/forms/umbfocuslock.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/forms/umbfocuslock.directive.js index f7cd32217e11..03d376e36a18 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/forms/umbfocuslock.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/forms/umbfocuslock.directive.js @@ -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; @@ -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]; + + 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 = { @@ -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! diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editors.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editors.html index 9bb5a6161d78..4d6ceb2ffca1 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editors.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editors.html @@ -1,7 +1,9 @@
-
  • -
    +