Proyectos de Subversion Moodle

Rev

| 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
 *
49
 * @method
50
 * @param {Event} event The event from the focus change
51
 */
52
const lockHandler = event => {
53
    if (ignoreFocusChanges) {
54
        // The focus change was made by an internal call to set focus.
55
        return;
56
    }
57
 
58
    // Find the current lock region.
59
    let lockRegion = getCurrentLockRegion();
60
    while (lockRegion) {
61
        if (document.contains(lockRegion)) {
62
            break;
63
        }
64
 
65
        // The lock region does not exist.
66
        // Perhaps it was removed without being untrapped.
67
        untrapFocus();
68
        lockRegion = getCurrentLockRegion();
69
    }
70
    if (!lockRegion) {
71
        return;
72
    }
73
 
74
    if (lockRegion.contains(event.target)) {
75
        lastFocus = event.target;
76
    } else {
77
        focusFirstDescendant();
78
        if (lastFocus == document.activeElement) {
79
            focusLastDescendant();
80
        }
81
        lastFocus = document.activeElement;
82
    }
83
};
84
 
85
/**
86
 * Focus the first descendant of the current lock region.
87
 *
88
 * @method
89
 * @returns {Bool} Whether a node was focused
90
 */
91
const focusFirstDescendant = () => {
92
    const lockRegion = getCurrentLockRegion();
93
 
94
    // Grab all elements in the lock region and attempt to focus each element until one is focused.
95
    // We can capture most of this in the query selector, but some cases may still reject focus.
96
    // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
97
    // to capture this.
98
    // The use of Array.some just ensures that we stop as soon as we have a successful focus.
99
    const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable));
100
 
101
    // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.
102
    // We must include it in the calculation of descendants to ensure that looping works correctly.
103
    focusableElements.unshift(lockRegion);
104
    return focusableElements.some(focusableElement => attemptFocus(focusableElement));
105
};
106
 
107
/**
108
 * Focus the last descendant of the current lock region.
109
 *
110
 * @method
111
 * @returns {Bool} Whether a node was focused
112
 */
113
const focusLastDescendant = () => {
114
    const lockRegion = getCurrentLockRegion();
115
 
116
    // Grab all elements in the lock region, reverse them, and attempt to focus each element until one is focused.
117
    // We can capture most of this in the query selector, but some cases may still reject focus.
118
    // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
119
    // to capture this.
120
    // The use of Array.some just ensures that we stop as soon as we have a successful focus.
121
    const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable)).reverse();
122
 
123
    // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.
124
    // We must include it in the calculation of descendants to ensure that looping works correctly.
125
    focusableElements.push(lockRegion);
126
    return focusableElements.some(focusableElement => attemptFocus(focusableElement));
127
};
128
 
129
/**
130
 * Check whether the supplied focusTarget is actually focusable.
131
 * There are cases where a normally focusable element can reject focus.
132
 *
133
 * Note: This example is a wholesale copy of the WCAG example.
134
 *
135
 * @method
136
 * @param {HTMLElement} focusTarget
137
 * @returns {Bool}
138
 */
139
const isFocusable = focusTarget => {
140
    if (focusTarget.tabIndex > 0 || (focusTarget.tabIndex === 0 && focusTarget.getAttribute('tabIndex') !== null)) {
141
        return true;
142
    }
143
 
144
    if (focusTarget.disabled) {
145
        return false;
146
    }
147
 
148
    switch (focusTarget.nodeName) {
149
        case 'A':
150
            return !!focusTarget.href && focusTarget.rel != 'ignore';
151
        case 'INPUT':
152
            return focusTarget.type != 'hidden' && focusTarget.type != 'file';
153
        case 'BUTTON':
154
        case 'SELECT':
155
        case 'TEXTAREA':
156
            return true;
157
        default:
158
            return false;
159
    }
160
};
161
 
162
/**
163
 * Attempt to focus the supplied focusTarget.
164
 *
165
 * Note: This example is a heavily inspired by the WCAG example.
166
 *
167
 * @method
168
 * @param {HTMLElement} focusTarget
169
 * @returns {Bool} Whether focus was successful o rnot.
170
 */
171
const attemptFocus = focusTarget => {
172
    if (!isFocusable(focusTarget)) {
173
        return false;
174
    }
175
 
176
    // The ignoreFocusChanges variable prevents the focus event handler from interfering and entering a fight with itself.
177
    ignoreFocusChanges = true;
178
 
179
    try {
180
        focusTarget.focus();
181
    } catch (e) {
182
        // Ignore failures. We will just try to focus the next element in the list.
183
    }
184
 
185
    ignoreFocusChanges = false;
186
 
187
    // If focus was successful the activeElement will be the one we focused.
188
    return (document.activeElement === focusTarget);
189
};
190
 
191
/**
192
 * Get the current lock region from the top of the stack.
193
 *
194
 * @method
195
 * @returns {HTMLElement}
196
 */
197
const getCurrentLockRegion = () => {
198
    return lockRegionStack[lockRegionStack.length - 1];
199
};
200
 
201
/**
202
 * Add a new lock region to the stack.
203
 *
204
 * @method
205
 * @param {HTMLElement} newLockRegion
206
 */
207
const addLockRegionToStack = newLockRegion => {
208
    if (newLockRegion === getCurrentLockRegion()) {
209
        return;
210
    }
211
 
212
    lockRegionStack.push(newLockRegion);
213
    const currentLockRegion = getCurrentLockRegion();
214
 
215
    // Append an empty div which can be focused just outside of the item locked.
216
    // This locks tab focus to within the tab region, and does not allow it to extend back into the window by
217
    // guaranteeing the existence of a tabable item after the lock region which can be focused but which will be caught
218
    // by the handler.
219
    const element = document.createElement('div');
220
    element.tabIndex = 0;
221
    element.style.position = 'fixed';
222
    element.style.top = 0;
223
    element.style.left = 0;
224
 
225
    const initialNode = element.cloneNode();
226
    currentLockRegion.parentNode.insertBefore(initialNode, currentLockRegion);
227
    initialFocusElementStack.push(initialNode);
228
 
229
    const finalNode = element.cloneNode();
230
    currentLockRegion.parentNode.insertBefore(finalNode, currentLockRegion.nextSibling);
231
    finalFocusElementStack.push(finalNode);
232
};
233
 
234
/**
235
 * Remove the top lock region from the stack.
236
 *
237
 * @method
238
 */
239
const removeLastLockRegionFromStack = () => {
240
    // Take the top element off the stack, and replce the current lockRegion value.
241
    lockRegionStack.pop();
242
 
243
    const finalNode = finalFocusElementStack.pop();
244
    if (finalNode) {
245
        // The final focus element may have been removed if it was part of a parent item.
246
        finalNode.remove();
247
    }
248
 
249
    const initialNode = initialFocusElementStack.pop();
250
    if (initialNode) {
251
        // The initial focus element may have been removed if it was part of a parent item.
252
        initialNode.remove();
253
    }
254
};
255
 
256
/**
257
 * Whether any region is left in the stack.
258
 *
259
 * @return {Bool}
260
 */
261
const hasTrappedRegionsInStack = () => {
262
    return !!lockRegionStack.length;
263
};
264
 
265
/**
266
 * Start trapping the focus and lock it to the specified newLockRegion.
267
 *
268
 * @method
269
 * @param {HTMLElement} newLockRegion The container to lock focus to
270
 */
271
export const trapFocus = newLockRegion => {
272
    // Update the lock region stack.
273
    // This allows us to support nesting.
274
    addLockRegionToStack(newLockRegion);
275
 
276
    if (!isLocked) {
277
        // Add the focus handler.
278
        document.addEventListener('focus', lockHandler, true);
279
    }
280
 
281
    // Attempt to focus on the first item in the lock region.
282
    if (!focusFirstDescendant()) {
283
        const currentLockRegion = getCurrentLockRegion();
284
 
285
        // No focusable descendants found in the region yet.
286
        // This can happen when the region is locked before content is generated.
287
        // Focus on the region itself for now.
288
        const originalRegionTabIndex = currentLockRegion.tabIndex;
289
        currentLockRegion.tabIndex = 0;
290
        attemptFocus(currentLockRegion);
291
        currentLockRegion.tabIndex = originalRegionTabIndex;
292
    }
293
 
294
    // Keep track of the last item focused.
295
    lastFocus = document.activeElement;
296
 
297
    isLocked = true;
298
};
299
 
300
/**
301
 * Stop trapping the focus.
302
 *
303
 * @method
304
 */
305
export const untrapFocus = () => {
306
    // Remove the top region from the stack.
307
    removeLastLockRegionFromStack();
308
 
309
    if (hasTrappedRegionsInStack()) {
310
        // The focus manager still has items in the stack.
311
        return;
312
    }
313
 
314
    document.removeEventListener('focus', lockHandler, true);
315
 
316
    lastFocus = null;
317
    ignoreFocusChanges = false;
318
    isLocked = false;
319
};