lib/amd/src/local/aria/aria-hidden.js

// 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);
    }
};