Skip to content

Commit

Permalink
Accessibility: Add focus lock for the infinite editor (#8522)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
BatJan and Joe Glombek authored May 4, 2021
1 parent 727be42 commit a721f92
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 48 deletions.
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];

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

0 comments on commit a721f92

Please sign in to comment.