// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* ARIA helpers related to the aria-hidden attribute.
*
* @module core/local/aria/aria-hidden.
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {getList} from 'core/normalise';
import Selectors from './selectors';
// The map of MutationObserver objects for an object.
const childObserverMap = new Map();
const siblingObserverMap = new Map();
/**
* Determine whether the browser supports the MutationObserver system.
*
* @method
* @returns {Bool}
*/
const supportsMutationObservers = () => (MutationObserver && typeof MutationObserver === 'function');
/**
* Disable element focusability, disabling the tabindex for child elements which are normally focusable.
*
* @method
* @param {HTMLElement} target
*/
const disableElementFocusability = target => {
if (!(target instanceof HTMLElement)) {
// This element is not an HTMLElement.
// This can happen for Text Nodes.
return;
}
if (target.matches(Selectors.elements.focusable)) {
disableAndStoreTabIndex(target);
}
target.querySelectorAll(Selectors.elements.focusable).forEach(disableAndStoreTabIndex);
};
/**
* Remove the current tab-index and store it for later restoration.
*
* @method
* @param {HTMLElement} element
*/
const disableAndStoreTabIndex = element => {
if (typeof element.dataset.ariaHiddenTabIndex !== 'undefined') {
// This child already has a hidden attribute.
// Do not modify it as the original value will be lost.
return;
}
// Store the old tabindex in a data attribute.
if (element.getAttribute('tabindex')) {
element.dataset.ariaHiddenTabIndex = element.getAttribute('tabindex');
} else {
element.dataset.ariaHiddenTabIndex = '';
}
element.setAttribute('tabindex', -1);
};
/**
* Re-enable element focusability, restoring any tabindex.
*
* @method
* @param {HTMLElement} target
*/
const enableElementFocusability = target => {
if (!(target instanceof HTMLElement)) {
// This element is not an HTMLElement.
// This can happen for Text Nodes.
return;
}
if (target.matches(Selectors.elements.focusableToUnhide)) {
restoreTabIndex(target);
}
target.querySelectorAll(Selectors.elements.focusableToUnhide).forEach(restoreTabIndex);
};
/**
* Restore the tab-index of the supplied element.
*
* When disabling focusability the current tab-index is stored in the ariaHiddenTabIndex data attribute.
* This is used to restore the tab-index, but only whilst the parent nodes remain unhidden.
*
* @method
* @param {HTMLElement} element
*/
const restoreTabIndex = element => {
if (element.closest(Selectors.aria.hidden)) {
// This item still has a hidden parent, or is hidden itself. Do not unhide it.
return;
}
const oldTabIndex = element.dataset.ariaHiddenTabIndex;
if (oldTabIndex === '') {
element.removeAttribute('tabindex');
} else {
element.setAttribute('tabindex', oldTabIndex);
}
delete element.dataset.ariaHiddenTabIndex;
};
/**
* Update the supplied DOM Module to be hidden.
*
* @method
* @param {HTMLElement} target
* @returns {Array}
*/
export const hide = target => getList(target).forEach(_hide);
const _hide = target => {
if (!(target instanceof HTMLElement)) {
// This element is not an HTMLElement.
// This can happen for Text Nodes.
return;
}
if (target.closest(Selectors.aria.hidden)) {
// This Element, or a parent Element, is already hidden.
// Stop processing.
return;
}
// Set the aria-hidden attribute to true.
target.setAttribute('aria-hidden', true);
// Based on advice from https://dequeuniversity.com/rules/axe/3.3/aria-hidden-focus, upon setting the aria-hidden
// attribute, all focusable elements underneath that element should be modified such that they are not focusable.
disableElementFocusability(target);
if (supportsMutationObservers()) {
// Add a MutationObserver to check for new children to the tree.
const mutationObserver = new MutationObserver(mutationList => {
mutationList.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(disableElementFocusability);
} else if (mutation.type === 'attributes') {
// The tabindex has been updated on a hidden attribute.
// Ensure that it is stored, ad set to -1 to prevent breakage.
const element = mutation.target;
const proposedTabIndex = element.getAttribute('tabindex');
if (proposedTabIndex !== "-1") {
element.dataset.ariaHiddenTabIndex = proposedTabIndex;
element.setAttribute('tabindex', -1);
}
}
});
});
mutationObserver.observe(target, {
// Watch for changes to the entire subtree.
subtree: true,
// Watch for new nodes.
childList: true,
// Watch for attribute changes to the tabindex.
attributes: true,
attributeFilter: ['tabindex'],
});
childObserverMap.set(target, mutationObserver);
}
};
/**
* Reverse the effect of the hide action.
*
* @method
* @param {HTMLElement} target
* @returns {Array}
*/
export const unhide = target => getList(target).forEach(_unhide);
const _unhide = target => {
if (!(target instanceof HTMLElement)) {
return;
}
// Note: The aria-hidden attribute should be removed, and not set to false.
// The presence of the attribute is sufficient for some browsers to treat it as being true, regardless of its value.
target.removeAttribute('aria-hidden');
// Restore the tabindex across all child nodes of the target.
enableElementFocusability(target);
// Remove the focusability MutationObserver watching this tree.
if (childObserverMap.has(target)) {
childObserverMap.get(target).disconnect();
childObserverMap.delete(target);
}
};
/**
* Correctly mark all siblings of the supplied target Element as hidden.
*
* @method
* @param {HTMLElement} target
* @returns {Array}
*/
export const hideSiblings = target => getList(target).forEach(_hideSiblings);
const _hideSiblings = target => {
if (!(target instanceof HTMLElement)) {
return;
}
if (!target.parentElement) {
return;
}
target.parentElement.childNodes.forEach(node => {
if (node === target) {
// Skip self;
return;
}
hide(node);
});
if (supportsMutationObservers()) {
// Add a MutationObserver to check for new children to the tree.
const newNodeObserver = new MutationObserver(mutationList => {
mutationList.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (target.contains(node)) {
// Skip self, and children of self.
return;
}
hide(node);
});
});
});
newNodeObserver.observe(target.parentElement, {childList: true, subtree: true});
siblingObserverMap.set(target.parentElement, newNodeObserver);
}
};
/**
* Correctly reverse the hide action of all children of the supplied target Element.
*
* @method
* @param {HTMLElement} target
* @returns {Array}
*/
export const unhideSiblings = target => getList(target).forEach(_unhideSiblings);
const _unhideSiblings = target => {
if (!(target instanceof HTMLElement)) {
return;
}
if (!target.parentElement) {
return;
}
target.parentElement.childNodes.forEach(node => {
if (node === target) {
// Skip self;
return;
}
unhide(node);
});
// Remove the sibling MutationObserver watching this tree.
if (siblingObserverMap.has(target.parentElement)) {
siblingObserverMap.get(target.parentElement).disconnect();
siblingObserverMap.delete(target.parentElement);
}
};