MDL-68390 aria: Add new core_aria module
[moodle.git] / lib / amd / src / local / aria / aria-hidden.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * ARIA helpers related to the aria-hidden attribute.
18  *
19  * @module     core/local/aria/aria-hidden.
20  * @class      aria
21  * @package    core
22  * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
25 import {getList} from 'core/normalise';
26 import Selectors from './selectors';
28 // The map of MutationObserver objects for an object.
29 const childObserverMap = new Map();
30 const siblingObserverMap = new Map();
32 /**
33  * Determine whether the browser supports the MutationObserver system.
34  *
35  * @returns {Bool}
36  */
37 const supportsMutationObservers = () => (MutationObserver && typeof MutationObserver === 'function');
39 /**
40  * Disable element focusability, disabling the tabindex for child elements which are normally focusable.
41  *
42  * @param {HTMLElement} target
43  */
44 const disableElementFocusability = target => {
45     if (!(target instanceof HTMLElement)) {
46         // This element is not an HTMLElement.
47         // This can happen for Text Nodes.
48         return;
49     }
51     if (target.matches(Selectors.elements.focusable)) {
52         disableAndStoreTabIndex(target);
53     }
55     target.querySelectorAll(Selectors.elements.focusable).forEach(disableAndStoreTabIndex);
56 };
58 /**
59  * Remove the current tab-index and store it for later restoration.
60  *
61  * @param {HTMLElement} element
62  */
63 const disableAndStoreTabIndex = element => {
64     if (typeof element.dataset.ariaHiddenTabIndex !== 'undefined') {
65         // This child already has a hidden attribute.
66         // Do not modify it as the original value will be lost.
67         return;
68     }
70     // Store the old tabindex in a data attribute.
71     if (element.getAttribute('tabindex')) {
72         element.dataset.ariaHiddenTabIndex = element.getAttribute('tabindex');
73     } else {
74         element.dataset.ariaHiddenTabIndex = '';
75     }
76     element.setAttribute('tabindex', -1);
77 };
79 /**
80  * Re-enable element focusability, restoring any tabindex.
81  *
82  * @param {HTMLElement} target
83  */
84 const enableElementFocusability = target => {
85     if (!(target instanceof HTMLElement)) {
86         // This element is not an HTMLElement.
87         // This can happen for Text Nodes.
88         return;
89     }
91     if (target.matches(Selectors.elements.focusableToUnhide)) {
92         restoreTabIndex(target);
93     }
95     target.querySelectorAll(Selectors.elements.focusableToUnhide).forEach(restoreTabIndex);
96 };
98 /**
99  * Restore the tab-index of the supplied element.
100  *
101  * When disabling focusability the current tab-index is stored in the ariaHiddenTabIndex data attribute.
102  * This is used to restore the tab-index, but only whilst the parent nodes remain unhidden.
103  *
104  * @param {HTMLElement} element
105  */
106 const restoreTabIndex = element => {
107     if (element.closest(Selectors.aria.hidden)) {
108         // This item still has a hidden parent, or is hidden itself. Do not unhide it.
109         return;
110     }
112     const oldTabIndex = element.dataset.ariaHiddenTabIndex;
113     if (oldTabIndex === '') {
114         element.removeAttribute('tabindex');
115     } else {
116         element.setAttribute('tabindex', oldTabIndex);
117     }
119     delete element.dataset.ariaHiddenTabIndex;
120 };
122 /**
123  * Update the supplied DOM Module to be hidden.
124  *
125  * @param {HTMLElement} target
126  * @returns {Array}
127  */
128 export const hide = target => getList(target).forEach(_hide);
130 const _hide = target => {
131     if (!(target instanceof HTMLElement)) {
132         // This element is not an HTMLElement.
133         // This can happen for Text Nodes.
134         return;
135     }
137     if (target.closest(Selectors.aria.hidden)) {
138         // This Element, or a parent Element, is already hidden.
139         // Stop processing.
140         return;
141     }
143     // Set the aria-hidden attribute to true.
144     target.setAttribute('aria-hidden', true);
146     // Based on advice from https://dequeuniversity.com/rules/axe/3.3/aria-hidden-focus, upon setting the aria-hidden
147     // attribute, all focusable elements underneath that element should be modified such that they are not focusable.
148     disableElementFocusability(target);
150     if (supportsMutationObservers()) {
151         // Add a MutationObserver to check for new children to the tree.
152         const newNodeObserver = new MutationObserver(mutationList => {
153             mutationList.forEach(mutation => {
154                 mutation.addedNodes.forEach(disableElementFocusability);
155             });
156         });
158         newNodeObserver.observe(target, {childList: true, subtree: true});
159         childObserverMap.set(target, newNodeObserver);
160     }
161 };
163 /**
164  * Reverse the effect of the hide action.
165  *
166  * @param {HTMLElement} target
167  * @returns {Array}
168  */
169 export const unhide = target => getList(target).forEach(_unhide);
171 const _unhide = target => {
172     if (!(target instanceof HTMLElement)) {
173         return;
174     }
176     // Note: The aria-hidden attribute should be removed, and not set to false.
177     // The presence of the attribute is sufficient for some browsers to treat it as being true, regardless of its value.
178     target.removeAttribute('aria-hidden');
180     // Restore the tabindex across all child nodes of the target.
181     enableElementFocusability(target);
183     // Remove the focusability MutationObserver watching this tree.
184     if (childObserverMap.has(target)) {
185         childObserverMap.get(target).disconnect();
186         childObserverMap.delete(target);
187     }
188 };
190 /**
191  * Correctly mark all siblings of the supplied target Element as hidden.
192  *
193  * @param {HTMLElement} target
194  * @returns {Array}
195  */
196 export const hideSiblings = target => getList(target).forEach(_hideSiblings);
198 const _hideSiblings = target => {
199     if (!(target instanceof HTMLElement)) {
200         return;
201     }
203     if (!target.parentElement) {
204         return;
205     }
207     target.parentElement.childNodes.forEach(node => {
208         if (node === target) {
209             // Skip self;
210             return;
211         }
213         hide(node);
214     });
216     if (supportsMutationObservers()) {
217         // Add a MutationObserver to check for new children to the tree.
218         const newNodeObserver = new MutationObserver(mutationList => {
219             mutationList.forEach(mutation => {
220                 mutation.addedNodes.forEach(node => {
221                     if (target.contains(node)) {
222                         // Skip self, and children of self.
223                         return;
224                     }
226                     hide(node);
227                 });
228             });
229         });
231         newNodeObserver.observe(target.parentElement, {childList: true, subtree: true});
232         siblingObserverMap.set(target.parentElement, newNodeObserver);
233     }
234 };
236 /**
237  * Correctly reverse the hide action of all children of the supplied target Element.
238  *
239  * @param {HTMLElement} target
240  * @returns {Array}
241  */
242 export const unhideSiblings = target => getList(target).forEach(_unhideSiblings);
244 const _unhideSiblings = target => {
245     if (!(target instanceof HTMLElement)) {
246         return;
247     }
249     if (!target.parentElement) {
250         return;
251     }
253     target.parentElement.childNodes.forEach(node => {
254         if (node === target) {
255             // Skip self;
256             return;
257         }
259         unhide(node);
260     });
262     // Remove the sibling MutationObserver watching this tree.
263     if (siblingObserverMap.has(target.parentElement)) {
264         siblingObserverMap.get(target.parentElement).disconnect();
265         siblingObserverMap.delete(target.parentElement);
266     }
267 };