MDL-68390 aria: Add new core_aria module
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 7 Apr 2020 06:39:37 +0000 (14:39 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 15 Jul 2020 06:40:49 +0000 (14:40 +0800)
15 files changed:
lib/amd/build/aria.min.js [new file with mode: 0644]
lib/amd/build/aria.min.js.map [new file with mode: 0644]
lib/amd/build/local/aria/aria-hidden.min.js [new file with mode: 0644]
lib/amd/build/local/aria/aria-hidden.min.js.map [new file with mode: 0644]
lib/amd/build/local/aria/selectors.min.js [new file with mode: 0644]
lib/amd/build/local/aria/selectors.min.js.map [new file with mode: 0644]
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/normalise.min.js [new file with mode: 0644]
lib/amd/build/normalise.min.js.map [new file with mode: 0644]
lib/amd/src/aria.js [new file with mode: 0644]
lib/amd/src/local/aria/aria-hidden.js [new file with mode: 0644]
lib/amd/src/local/aria/selectors.js [new file with mode: 0644]
lib/amd/src/modal.js
lib/amd/src/normalise.js [new file with mode: 0644]

diff --git a/lib/amd/build/aria.min.js b/lib/amd/build/aria.min.js
new file mode 100644 (file)
index 0000000..f07ac09
Binary files /dev/null and b/lib/amd/build/aria.min.js differ
diff --git a/lib/amd/build/aria.min.js.map b/lib/amd/build/aria.min.js.map
new file mode 100644 (file)
index 0000000..c6a555d
Binary files /dev/null and b/lib/amd/build/aria.min.js.map differ
diff --git a/lib/amd/build/local/aria/aria-hidden.min.js b/lib/amd/build/local/aria/aria-hidden.min.js
new file mode 100644 (file)
index 0000000..f549360
Binary files /dev/null and b/lib/amd/build/local/aria/aria-hidden.min.js differ
diff --git a/lib/amd/build/local/aria/aria-hidden.min.js.map b/lib/amd/build/local/aria/aria-hidden.min.js.map
new file mode 100644 (file)
index 0000000..38aa7e2
Binary files /dev/null and b/lib/amd/build/local/aria/aria-hidden.min.js.map differ
diff --git a/lib/amd/build/local/aria/selectors.min.js b/lib/amd/build/local/aria/selectors.min.js
new file mode 100644 (file)
index 0000000..2d113d7
Binary files /dev/null and b/lib/amd/build/local/aria/selectors.min.js differ
diff --git a/lib/amd/build/local/aria/selectors.min.js.map b/lib/amd/build/local/aria/selectors.min.js.map
new file mode 100644 (file)
index 0000000..92e6872
Binary files /dev/null and b/lib/amd/build/local/aria/selectors.min.js.map differ
index 69414dc..f1816d7 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index 19c89db..297ee6c 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
diff --git a/lib/amd/build/normalise.min.js b/lib/amd/build/normalise.min.js
new file mode 100644 (file)
index 0000000..b31b2d0
Binary files /dev/null and b/lib/amd/build/normalise.min.js differ
diff --git a/lib/amd/build/normalise.min.js.map b/lib/amd/build/normalise.min.js.map
new file mode 100644 (file)
index 0000000..c9598b2
Binary files /dev/null and b/lib/amd/build/normalise.min.js.map differ
diff --git a/lib/amd/src/aria.js b/lib/amd/src/aria.js
new file mode 100644 (file)
index 0000000..394effe
--- /dev/null
@@ -0,0 +1,31 @@
+// 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/>.
+
+/**
+ * Helpers to perform ARIA compliance changes to the DOM.
+ *
+ * @module     core/aria
+ * @class      aria
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+export {
+    hide,
+    unhide,
+    hideSiblings,
+    unhideSiblings,
+} from './local/aria/aria-hidden';
diff --git a/lib/amd/src/local/aria/aria-hidden.js b/lib/amd/src/local/aria/aria-hidden.js
new file mode 100644 (file)
index 0000000..a5989a0
--- /dev/null
@@ -0,0 +1,267 @@
+// 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.
+ * @class      aria
+ * @package    core
+ * @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.
+ *
+ * @returns {Bool}
+ */
+const supportsMutationObservers = () => (MutationObserver && typeof MutationObserver === 'function');
+
+/**
+ * Disable element focusability, disabling the tabindex for child elements which are normally focusable.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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 newNodeObserver = new MutationObserver(mutationList => {
+            mutationList.forEach(mutation => {
+                mutation.addedNodes.forEach(disableElementFocusability);
+            });
+        });
+
+        newNodeObserver.observe(target, {childList: true, subtree: true});
+        childObserverMap.set(target, newNodeObserver);
+    }
+};
+
+/**
+ * Reverse the effect of the hide action.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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);
+    }
+};
diff --git a/lib/amd/src/local/aria/selectors.js b/lib/amd/src/local/aria/selectors.js
new file mode 100644 (file)
index 0000000..c7a45ef
--- /dev/null
@@ -0,0 +1,33 @@
+// 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/>.
+
+/**
+ * Selectors used for ARIA.
+ *
+ * @module     core/local/aria/selectors
+ * @class      selectors
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+export default {
+    aria: {
+        hidden: '[aria-hidden]',
+    },
+    elements: {
+        focusable: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
+        focusableToUnhide: '[data-aria-hidden-tab-index]',
+    },
+};
index 442c5b0..184c8d6 100644 (file)
@@ -33,7 +33,8 @@ define([
     'core/modal_events',
     'core/local/aria/focuslock',
     'core/pending',
-], function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, Event, ModalEvents, FocusLock, Pending) {
+    'core/aria',
+], function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, Event, ModalEvents, FocusLock, Pending, Aria) {
 
     var SELECTORS = {
         CONTAINER: '[data-region="modal-container"]',
@@ -732,30 +733,10 @@ define([
      * @method accessibilityShow
      */
     Modal.prototype.accessibilityShow = function() {
-        // We need to get a list containing each sibling element and the shallowest
-        // non-ancestral nodes in the DOM. We can shortcut this a little by leveraging
-        // the fact that this dialogue is always appended to the document body therefore
-        // it's siblings are the shallowest non-ancestral nodes. If that changes then
-        // this code should also be updated.
-        $('body').children().each(function(index, child) {
-            // Skip the current modal.
-            if (!this.root.is(child)) {
-                child = $(child);
-                var hidden = child.attr('aria-hidden');
-                // If they are already hidden we can ignore them.
-                if (hidden !== 'true') {
-                    // Save their current state.
-                    child.data('previous-aria-hidden', hidden);
-                    this.hiddenSiblings.push(child);
-
-                    // Hide this node from screen readers.
-                    child.attr('aria-hidden', 'true');
-                }
-            }
-        }.bind(this));
+        Aria.hideSiblings(this.root.get()[0]);
 
         // Make us visible to screen readers.
-        this.root.attr('aria-hidden', 'false');
+        this.root.removeAttr('aria-hidden');
     };
 
     /**
@@ -768,22 +749,7 @@ define([
     Modal.prototype.accessibilityHide = function() {
         this.root.attr('aria-hidden', 'true');
 
-        // Restore the sibling nodes back to their original values.
-        $.each(this.hiddenSiblings, function(index, sibling) {
-            sibling = $(sibling);
-            var previousValue = sibling.data('previous-aria-hidden');
-            // If the element didn't previously have an aria-hidden attribute
-            // then we can just remove the one we set.
-            if (typeof previousValue == 'undefined') {
-                sibling.removeAttr('aria-hidden');
-            } else {
-                // Otherwise set it back to the old value (which will be false).
-                sibling.attr('aria-hidden', previousValue);
-            }
-        });
-
-        // Clear the cache. No longer need to store these.
-        this.hiddenSiblings = [];
+        Aria.unhideSiblings(this.root.get()[0]);
     };
 
     /**
diff --git a/lib/amd/src/normalise.js b/lib/amd/src/normalise.js
new file mode 100644 (file)
index 0000000..e5afcaf
--- /dev/null
@@ -0,0 +1,51 @@
+// 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/>.
+
+/**
+ * Normalisation helpers.
+ *
+ * @module     core/normalise
+ * @class      normalise
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import jQuery from 'jquery';
+
+export const getList = nodes => {
+    if (nodes instanceof HTMLElement) {
+        // A single record to conver to a NodeList.
+        return [nodes];
+    }
+
+    if (nodes instanceof Array) {
+        // A single record to conver to a NodeList.
+        return nodes;
+    }
+
+    if (nodes instanceof NodeList) {
+        // Already a NodeList.
+        return Array.from(nodes);
+    }
+
+    if (nodes instanceof jQuery) {
+        // A jQuery object to a NodeList.
+        return nodes.get();
+    }
+
+    // Fallback to just having a go.
+    return Array.from(nodes);
+};