Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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/>.
15
 
16
/**
17
 * Tab locking system.
18
 *
19
 * This is based on code and examples provided in the ARIA specification.
20
 * https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html
21
 *
22
 * @module     core/local/aria/focuslock
23
 * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
24
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25
 */
26
import Selectors from './selectors';
27
 
28
const lockRegionStack = [];
29
const initialFocusElementStack = [];
30
const finalFocusElementStack = [];
31
 
32
let lastFocus = null;
33
let ignoreFocusChanges = false;
34
let isLocked = false;
35
 
36
/**
37
 * The lock handler.
38
 *
39
 * This is the item that does a majority of the work.
40
 * The overall logic from this comes from the examles in the WCAG guidelines.
41
 *
42
 * The general idea is that if the focus is not held within by an Element within the lock region, then we replace focus
43
 * on the first element in the lock region. If the first element is the element previously selected prior to the
44
 * user-initiated focus change, then instead jump to the last element in the lock region.
45
 *
46
 * This gives us a solution which supports focus locking of any kind, which loops in both directions, and which
47
 * prevents the lock from escaping the modal entirely.
48
 *
1441 ariadna 49
 * If no event is supplied then this function can be used to focus the first element in the lock region, or the
50
 * last element if the first element is already focused.
51
 *
1 efrain 52
 * @method
1441 ariadna 53
 * @param {Event} [event] The event from the focus change
1 efrain 54
 */
55
const lockHandler = event => {
56
    if (ignoreFocusChanges) {
57
        // The focus change was made by an internal call to set focus.
58
        return;
59
    }
60
 
61
    // Find the current lock region.
62
    let lockRegion = getCurrentLockRegion();
63
    while (lockRegion) {
64
        if (document.contains(lockRegion)) {
65
            break;
66
        }
67
 
68
        // The lock region does not exist.
69
        // Perhaps it was removed without being untrapped.
70
        untrapFocus();
71
        lockRegion = getCurrentLockRegion();
72
    }
73
    if (!lockRegion) {
74
        return;
75
    }
76
 
1441 ariadna 77
    if (event && lockRegion.contains(event.target)) {
1 efrain 78
        lastFocus = event.target;
79
    } else {
80
        focusFirstDescendant();
81
        if (lastFocus == document.activeElement) {
82
            focusLastDescendant();
83
        }
84
        lastFocus = document.activeElement;
85
    }
86
};
87
 
88
/**
1441 ariadna 89
 * Gets all the focusable elements in the document that are not set to display:none. This is useful
90
 * because sometimes, a nested modal dialog may be left in the DOM but set to display:none, and you
91
 * can't actually focus display:none elements.
92
 *
93
 * @returns {HTMLElement[]} All focusable elements that aren't display:none, in DOM order
94
 */
95
const getAllFocusableElements = () => {
96
    const allFocusable = document.querySelectorAll(Selectors.elements.focusable);
97
    // The offsetParent check is a well-perfoming way to ensure that an element in the document
98
    // does not have display:none.
99
    return Array.from(allFocusable).filter(focusable => !!focusable.offsetParent);
100
};
101
 
102
/**
103
 * Catch event for any keydown during focus lock.
104
 *
105
 * This is used to detect situations when the user would be tabbing out to the browser UI. In that
106
 * case, no 'focus' event is generated, so we need to trap it before it happens via the keydown
107
 * event.
108
 *
109
 * @param {KeyboardEvent} event
110
 */
111
const keyDownHandler = event => {
112
    // We only care about Tab keypresses and only if there is a current lock region.
113
    if (event.key !== 'Tab' || !getCurrentLockRegion()) {
114
        return;
115
    }
116
 
117
    if (!event.shiftKey) {
118
        // Have they already focused the last focusable element in the document?
119
        const allFocusable = getAllFocusableElements();
120
        if (document.activeElement === allFocusable[allFocusable.length - 1]) {
121
            // When the last thing is focused, focus would go to browser UI next, instead use
122
            // lockHandler to put focus back on the first element in lock region.
123
            lockHandler();
124
            event.preventDefault();
125
        }
126
    } else {
127
        // Have they already focused the first focusable element in the lock region?
128
        const lockRegion = getCurrentLockRegion();
129
        const firstFocusable = lockRegion.querySelector(Selectors.elements.focusable);
130
        if (document.activeElement === firstFocusable) {
131
            // When the first thing is focused, use lockHandler which will focus the last element
132
            // in lock region. We do this here rather than using lockHandler to get the focus event
133
            // because (a) there would be no focus event if the current element is the first in
134
            // document, and (b) temporarily focusing outside the region could result in unexpected
135
            // scrolling.
136
            lockHandler();
137
            event.preventDefault();
138
        }
139
    }
140
};
141
 
142
/**
1 efrain 143
 * Focus the first descendant of the current lock region.
144
 *
145
 * @method
146
 * @returns {Bool} Whether a node was focused
147
 */
148
const focusFirstDescendant = () => {
149
    const lockRegion = getCurrentLockRegion();
150
 
151
    // Grab all elements in the lock region and attempt to focus each element until one is focused.
152
    // We can capture most of this in the query selector, but some cases may still reject focus.
153
    // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
154
    // to capture this.
155
    // The use of Array.some just ensures that we stop as soon as we have a successful focus.
156
    const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable));
157
 
158
    // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.
159
    // We must include it in the calculation of descendants to ensure that looping works correctly.
160
    focusableElements.unshift(lockRegion);
161
    return focusableElements.some(focusableElement => attemptFocus(focusableElement));
162
};
163
 
164
/**
165
 * Focus the last descendant of the current lock region.
166
 *
167
 * @method
168
 * @returns {Bool} Whether a node was focused
169
 */
170
const focusLastDescendant = () => {
171
    const lockRegion = getCurrentLockRegion();
172
 
173
    // Grab all elements in the lock region, reverse them, and attempt to focus each element until one is focused.
174
    // We can capture most of this in the query selector, but some cases may still reject focus.
175
    // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
176
    // to capture this.
177
    // The use of Array.some just ensures that we stop as soon as we have a successful focus.
178
    const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable)).reverse();
179
 
180
    // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.
181
    // We must include it in the calculation of descendants to ensure that looping works correctly.
182
    focusableElements.push(lockRegion);
183
    return focusableElements.some(focusableElement => attemptFocus(focusableElement));
184
};
185
 
186
/**
187
 * Check whether the supplied focusTarget is actually focusable.
188
 * There are cases where a normally focusable element can reject focus.
189
 *
190
 * Note: This example is a wholesale copy of the WCAG example.
191
 *
192
 * @method
193
 * @param {HTMLElement} focusTarget
194
 * @returns {Bool}
195
 */
196
const isFocusable = focusTarget => {
197
    if (focusTarget.tabIndex > 0 || (focusTarget.tabIndex === 0 && focusTarget.getAttribute('tabIndex') !== null)) {
198
        return true;
199
    }
200
 
201
    if (focusTarget.disabled) {
202
        return false;
203
    }
204
 
205
    switch (focusTarget.nodeName) {
206
        case 'A':
207
            return !!focusTarget.href && focusTarget.rel != 'ignore';
208
        case 'INPUT':
209
            return focusTarget.type != 'hidden' && focusTarget.type != 'file';
210
        case 'BUTTON':
211
        case 'SELECT':
212
        case 'TEXTAREA':
213
            return true;
214
        default:
215
            return false;
216
    }
217
};
218
 
219
/**
220
 * Attempt to focus the supplied focusTarget.
221
 *
222
 * Note: This example is a heavily inspired by the WCAG example.
223
 *
224
 * @method
225
 * @param {HTMLElement} focusTarget
226
 * @returns {Bool} Whether focus was successful o rnot.
227
 */
228
const attemptFocus = focusTarget => {
229
    if (!isFocusable(focusTarget)) {
230
        return false;
231
    }
232
 
233
    // The ignoreFocusChanges variable prevents the focus event handler from interfering and entering a fight with itself.
234
    ignoreFocusChanges = true;
235
 
236
    try {
237
        focusTarget.focus();
238
    } catch (e) {
239
        // Ignore failures. We will just try to focus the next element in the list.
240
    }
241
 
242
    ignoreFocusChanges = false;
243
 
244
    // If focus was successful the activeElement will be the one we focused.
245
    return (document.activeElement === focusTarget);
246
};
247
 
248
/**
249
 * Get the current lock region from the top of the stack.
250
 *
251
 * @method
252
 * @returns {HTMLElement}
253
 */
254
const getCurrentLockRegion = () => {
255
    return lockRegionStack[lockRegionStack.length - 1];
256
};
257
 
258
/**
259
 * Add a new lock region to the stack.
260
 *
261
 * @method
262
 * @param {HTMLElement} newLockRegion
263
 */
264
const addLockRegionToStack = newLockRegion => {
265
    if (newLockRegion === getCurrentLockRegion()) {
266
        return;
267
    }
268
 
269
    lockRegionStack.push(newLockRegion);
270
    const currentLockRegion = getCurrentLockRegion();
271
 
272
    // Append an empty div which can be focused just outside of the item locked.
273
    // This locks tab focus to within the tab region, and does not allow it to extend back into the window by
274
    // guaranteeing the existence of a tabable item after the lock region which can be focused but which will be caught
275
    // by the handler.
276
    const element = document.createElement('div');
277
    element.tabIndex = 0;
278
    element.style.position = 'fixed';
279
    element.style.top = 0;
280
    element.style.left = 0;
281
 
282
    const initialNode = element.cloneNode();
283
    currentLockRegion.parentNode.insertBefore(initialNode, currentLockRegion);
284
    initialFocusElementStack.push(initialNode);
285
 
286
    const finalNode = element.cloneNode();
287
    currentLockRegion.parentNode.insertBefore(finalNode, currentLockRegion.nextSibling);
288
    finalFocusElementStack.push(finalNode);
289
};
290
 
291
/**
292
 * Remove the top lock region from the stack.
293
 *
294
 * @method
295
 */
296
const removeLastLockRegionFromStack = () => {
297
    // Take the top element off the stack, and replce the current lockRegion value.
298
    lockRegionStack.pop();
299
 
300
    const finalNode = finalFocusElementStack.pop();
301
    if (finalNode) {
302
        // The final focus element may have been removed if it was part of a parent item.
303
        finalNode.remove();
304
    }
305
 
306
    const initialNode = initialFocusElementStack.pop();
307
    if (initialNode) {
308
        // The initial focus element may have been removed if it was part of a parent item.
309
        initialNode.remove();
310
    }
311
};
312
 
313
/**
314
 * Whether any region is left in the stack.
315
 *
316
 * @return {Bool}
317
 */
318
const hasTrappedRegionsInStack = () => {
319
    return !!lockRegionStack.length;
320
};
321
 
322
/**
323
 * Start trapping the focus and lock it to the specified newLockRegion.
324
 *
325
 * @method
326
 * @param {HTMLElement} newLockRegion The container to lock focus to
327
 */
328
export const trapFocus = newLockRegion => {
329
    // Update the lock region stack.
330
    // This allows us to support nesting.
331
    addLockRegionToStack(newLockRegion);
332
 
333
    if (!isLocked) {
334
        // Add the focus handler.
335
        document.addEventListener('focus', lockHandler, true);
1441 ariadna 336
        document.addEventListener('keydown', keyDownHandler, true);
1 efrain 337
    }
338
 
339
    // Attempt to focus on the first item in the lock region.
340
    if (!focusFirstDescendant()) {
341
        const currentLockRegion = getCurrentLockRegion();
342
 
343
        // No focusable descendants found in the region yet.
344
        // This can happen when the region is locked before content is generated.
345
        // Focus on the region itself for now.
346
        const originalRegionTabIndex = currentLockRegion.tabIndex;
347
        currentLockRegion.tabIndex = 0;
348
        attemptFocus(currentLockRegion);
349
        currentLockRegion.tabIndex = originalRegionTabIndex;
350
    }
351
 
352
    // Keep track of the last item focused.
353
    lastFocus = document.activeElement;
354
 
355
    isLocked = true;
356
};
357
 
358
/**
359
 * Stop trapping the focus.
360
 *
361
 * @method
362
 */
363
export const untrapFocus = () => {
364
    // Remove the top region from the stack.
365
    removeLastLockRegionFromStack();
366
 
367
    if (hasTrappedRegionsInStack()) {
368
        // The focus manager still has items in the stack.
369
        return;
370
    }
371
 
372
    document.removeEventListener('focus', lockHandler, true);
1441 ariadna 373
    document.removeEventListener('keydown', keyDownHandler, true);
1 efrain 374
 
375
    lastFocus = null;
376
    ignoreFocusChanges = false;
377
    isLocked = false;
378
};