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
 * Enhancements to Bootstrap components for accessibility.
18
 *
19
 * @module     theme_boost/aria
20
 * @copyright  2018 Damyon Wiese <damyon@moodle.com>
21
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
 
24
import $ from 'jquery';
25
import Pending from 'core/pending';
26
import * as FocusLockManager from 'core/local/aria/focuslock';
27
 
28
/**
29
 * Drop downs from bootstrap don't support keyboard accessibility by default.
30
 */
31
const dropdownFix = () => {
32
    let focusEnd = false;
33
    const setFocusEnd = (end = true) => {
34
        focusEnd = end;
35
    };
36
    const getFocusEnd = () => {
37
        const result = focusEnd;
38
        focusEnd = false;
39
        return result;
40
    };
41
 
42
    // Special handling for navigation keys when menu is open.
43
    const shiftFocus = (element, focusCheck = null) => {
44
        const pendingPromise = new Pending('core/aria:delayed-focus');
45
        setTimeout(() => {
46
            if (!focusCheck || focusCheck()) {
47
                element.focus();
48
            }
49
 
50
            pendingPromise.resolve();
51
        }, 50);
52
    };
53
 
54
    // Event handling for the dropdown menu button.
55
    const handleMenuButton = e => {
56
        const trigger = e.key;
57
        let fixFocus = false;
58
 
59
        // Space key or Enter key opens the menu.
60
        if (trigger === ' ' || trigger === 'Enter') {
61
            fixFocus = true;
62
            // Cancel random scroll.
63
            e.preventDefault();
64
            // Open the menu instead.
65
            e.target.click();
66
        }
67
 
68
        // Up and Down keys also open the menu.
69
        if (trigger === 'ArrowUp' || trigger === 'ArrowDown') {
70
            fixFocus = true;
71
        }
72
 
73
        if (!fixFocus) {
74
            // No need to fix the focus. Return early.
75
            return;
76
        }
77
 
78
        // Fix the focus on the menu items when the menu is opened.
79
        const menu = e.target.parentElement.querySelector('[role="menu"]');
80
        let menuItems = false;
81
        let foundMenuItem = false;
82
 
83
        if (menu) {
84
            menuItems = menu.querySelectorAll('[role="menuitem"]');
85
        }
86
        if (menuItems && menuItems.length > 0) {
87
            // Up key opens the menu at the end.
88
            if (trigger === 'ArrowUp') {
89
                setFocusEnd();
90
            } else {
91
                setFocusEnd(false);
92
            }
93
 
94
            if (getFocusEnd()) {
95
                foundMenuItem = menuItems[menuItems.length - 1];
96
            } else {
97
                // The first menu entry, pretty reasonable.
98
                foundMenuItem = menuItems[0];
99
            }
100
        }
101
 
102
        if (foundMenuItem) {
103
            shiftFocus(foundMenuItem);
104
        }
105
    };
106
 
107
    // Search for menu items by finding the first item that has
108
    // text starting with the typed character (case insensitive).
109
    document.addEventListener('keypress', e => {
110
        if (e.target.matches('.dropdown [role="menu"] [role="menuitem"]')) {
111
            const menu = e.target.closest('[role="menu"]');
112
            if (!menu) {
113
                return;
114
            }
115
            const menuItems = menu.querySelectorAll('[role="menuitem"]');
116
            if (!menuItems) {
117
                return;
118
            }
119
 
120
            const trigger = e.key.toLowerCase();
121
 
122
            for (let i = 0; i < menuItems.length; i++) {
123
                const item = menuItems[i];
124
                const itemText = item.text.trim().toLowerCase();
125
                if (itemText.indexOf(trigger) == 0) {
126
                    shiftFocus(item);
127
                    break;
128
                }
129
            }
130
        }
131
    });
132
 
133
    // Keyboard navigation for arrow keys, home and end keys.
134
    document.addEventListener('keydown', e => {
135
 
136
        // We only want to set focus when users access the dropdown via keyboard as per
137
        // guidelines defined in w3 aria practices 1.1 menu-button.
138
        if (e.target.matches('[data-toggle="dropdown"]')) {
139
            handleMenuButton(e);
140
        }
141
 
142
        if (e.target.matches('.dropdown [role="menu"] [role="menuitem"]')) {
143
            const trigger = e.key;
144
            let next = false;
145
            const menu = e.target.closest('[role="menu"]');
146
 
147
            if (!menu) {
148
                return;
149
            }
150
            const menuItems = menu.querySelectorAll('[role="menuitem"]');
151
            if (!menuItems) {
152
                return;
153
            }
154
            // Down key.
155
            if (trigger == 'ArrowDown') {
156
                for (let i = 0; i < menuItems.length - 1; i++) {
157
                    if (menuItems[i] == e.target) {
158
                        next = menuItems[i + 1];
159
                        break;
160
                    }
161
                }
162
                if (!next) {
163
                    // Wrap to first item.
164
                    next = menuItems[0];
165
                }
166
            } else if (trigger == 'ArrowUp') {
167
                // Up key.
168
                for (let i = 1; i < menuItems.length; i++) {
169
                    if (menuItems[i] == e.target) {
170
                        next = menuItems[i - 1];
171
                        break;
172
                    }
173
                }
174
                if (!next) {
175
                    // Wrap to last item.
176
                    next = menuItems[menuItems.length - 1];
177
                }
178
            } else if (trigger == 'Home') {
179
                // Home key.
180
                next = menuItems[0];
181
 
182
            } else if (trigger == 'End') {
183
                // End key.
184
                next = menuItems[menuItems.length - 1];
185
            }
186
 
187
            // Variable next is set if we do want to act on the keypress.
188
            if (next) {
189
                e.preventDefault();
190
                shiftFocus(next);
191
            }
192
            return;
193
        }
194
    });
195
 
196
    $('.dropdown').on('shown.bs.dropdown', e => {
197
        const dialog = e.target.querySelector(`#${e.relatedTarget.getAttribute('aria-controls')}[role="dialog"]`);
198
        if (dialog) {
199
            // Use setTimeout to make sure the dialog is positioned correctly to prevent random scrolling.
200
            setTimeout(() => {
201
                FocusLockManager.trapFocus(dialog);
202
            });
203
        }
204
    });
205
 
206
    $('.dropdown').on('hidden.bs.dropdown', e => {
207
        const dialog = e.target.querySelector(`#${e.relatedTarget.getAttribute('aria-controls')}[role="dialog"]`);
208
        if (dialog) {
209
            FocusLockManager.untrapFocus();
210
        }
211
 
212
        // We need to focus on the menu trigger.
213
        const trigger = e.target.querySelector('[data-toggle="dropdown"]');
214
        // If it's a click event, then no element is focused because the clicked element is inside a closed dropdown.
215
        const focused = e.clickEvent?.target || (document.activeElement !== document.body ? document.activeElement : null);
216
        if (trigger && focused && e.target.contains(focused)) {
217
            shiftFocus(trigger, () => {
218
                if (document.activeElement === document.body) {
219
                    // If the focus is currently on the body, then we can safely assume that the focus needs to be updated.
220
                    return true;
221
                }
222
 
223
                // If the focus is on a child of the clicked element still, then update the focus.
224
                return e.target.contains(document.activeElement);
225
            });
226
        }
227
    });
228
};
229
 
230
/**
231
 * A lot of Bootstrap's out of the box features don't work if dropdown items are not focusable.
232
 */
233
const comboboxFix = () => {
234
    $(document).on('show.bs.dropdown', e => {
235
        if (e.relatedTarget.matches('[role="combobox"]')) {
236
            const combobox = e.relatedTarget;
237
            const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
238
 
239
            if (listbox) {
240
                const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
241
 
242
                // To make sure ArrowDown doesn't move the active option afterwards.
243
                setTimeout(() => {
244
                    if (selectedOption) {
245
                        selectedOption.classList.add('active');
246
                        combobox.setAttribute('aria-activedescendant', selectedOption.id);
247
                    } else {
248
                        const firstOption = listbox.querySelector('[role="option"]');
249
                        firstOption.setAttribute('aria-selected', 'true');
250
                        firstOption.classList.add('active');
251
                        combobox.setAttribute('aria-activedescendant', firstOption.id);
252
                    }
253
                }, 0);
254
            }
255
        }
256
    });
257
 
258
    $(document).on('hidden.bs.dropdown', e => {
259
        if (e.relatedTarget.matches('[role="combobox"]')) {
260
            const combobox = e.relatedTarget;
261
            const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
262
 
263
            combobox.removeAttribute('aria-activedescendant');
264
 
265
            if (listbox) {
266
                setTimeout(() => {
267
                    // Undo all previously highlighted options.
268
                    listbox.querySelectorAll('.active[role="option"]').forEach(option => {
269
                        option.classList.remove('active');
270
                    });
271
                }, 0);
272
            }
273
        }
274
    });
275
 
276
    // Handling keyboard events for both navigating through and selecting options.
277
    document.addEventListener('keydown', e => {
278
        if (e.target.matches('[role="combobox"][aria-controls]:not([aria-haspopup=dialog])')) {
279
            const combobox = e.target;
280
            const trigger = e.key;
281
            let next = null;
282
            const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
283
            const options = listbox.querySelectorAll('[role="option"]');
284
            const activeOption = listbox.querySelector('.active[role="option"]');
285
            const editable = combobox.hasAttribute('aria-autocomplete');
286
 
287
            // Under the special case that the dropdown menu is being shown as a result of the key press (like when the user
288
            // presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.
289
            // It's because of a race condition with show.bs.dropdown event handler.
290
            if (options && (activeOption || editable)) {
291
                if (trigger == 'ArrowDown') {
292
                    for (let i = 0; i < options.length - 1; i++) {
293
                        if (options[i] == activeOption) {
294
                            next = options[i + 1];
295
                            break;
296
                        }
297
                    }
298
                    if (editable && !next) {
299
                        next = options[0];
300
                    }
301
                } if (trigger == 'ArrowUp') {
302
                    for (let i = 1; i < options.length; i++) {
303
                        if (options[i] == activeOption) {
304
                            next = options[i - 1];
305
                            break;
306
                        }
307
                    }
308
                    if (editable && !next) {
309
                        next = options[options.length - 1];
310
                    }
311
                } else if (trigger == 'Home' && !editable) {
312
                    next = options[0];
313
                } else if (trigger == 'End' && !editable) {
314
                    next = options[options.length - 1];
315
                } else if ((trigger == ' ' && !editable) || trigger == 'Enter') {
316
                    e.preventDefault();
317
                    selectOption(combobox, activeOption);
318
                } else if (!editable) {
319
                    // Search for options by finding the first option that has
320
                    // text starting with the typed character (case insensitive).
321
                    for (let i = 0; i < options.length; i++) {
322
                        const option = options[i];
323
                        const optionText = option.textContent.trim().toLowerCase();
324
                        const keyPressed = e.key.toLowerCase();
325
                        if (optionText.indexOf(keyPressed) == 0) {
326
                            next = option;
327
                            break;
328
                        }
329
                    }
330
                }
331
 
332
                // Variable next is set if we do want to act on the keypress.
333
                if (next) {
334
                    e.preventDefault();
335
                    if (activeOption) {
336
                        activeOption.classList.remove('active');
337
                    }
338
                    next.classList.add('active');
339
                    combobox.setAttribute('aria-activedescendant', next.id);
340
                    next.scrollIntoView({block: 'nearest'});
341
                }
342
            }
343
        }
344
    });
345
 
346
    document.addEventListener('click', e => {
347
        const option = e.target.closest('[role="listbox"] [role="option"]');
348
        if (option) {
349
            const listbox = option.closest('[role="listbox"]');
350
            const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`);
351
            if (combobox) {
352
                selectOption(combobox, option);
353
            }
354
        }
355
    });
356
 
357
    // In case some code somewhere else changes the value of the combobox.
358
    document.addEventListener('change', e => {
359
        if (e.target.matches('input[type="hidden"][id]')) {
360
            const combobox = document.querySelector(`[role="combobox"][data-input-element="${e.target.id}"]`);
361
            const option = e.target.parentElement.querySelector(`[role="option"][data-value="${e.target.value}"]`);
362
 
363
            if (combobox && option) {
364
                selectOption(combobox, option);
365
            }
366
        }
367
    });
368
 
369
    const selectOption = (combobox, option) => {
370
        const listbox = option.closest('[role="listbox"]');
371
        const oldSelectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
372
 
373
        if (oldSelectedOption != option) {
374
            if (oldSelectedOption) {
375
                oldSelectedOption.removeAttribute('aria-selected');
376
            }
377
            option.setAttribute('aria-selected', 'true');
378
        }
379
 
380
        if (combobox.hasAttribute('value')) {
381
            combobox.value = option.dataset.shortText || option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim();
382
        } else {
383
            combobox.textContent = option.dataset.shortText || option.textContent;
384
        }
385
 
386
        if (combobox.dataset.inputElement) {
387
            const inputElement = document.getElementById(combobox.dataset.inputElement);
388
            if (inputElement && (inputElement.value != option.dataset.value)) {
389
                inputElement.value = option.dataset.value;
390
                inputElement.dispatchEvent(new Event('change', {bubbles: true}));
391
            }
392
        }
393
    };
394
};
395
 
396
/**
397
 * After page load, focus on any element with special autofocus attribute.
398
 */
399
const autoFocus = () => {
400
    window.addEventListener("load", () => {
401
        const alerts = document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');
402
        Array.prototype.forEach.call(alerts, autofocusElement => {
403
            // According to the specification an role="alert" region is only read out on change to the content
404
            // of that region.
405
            autofocusElement.innerHTML += ' ';
406
            autofocusElement.removeAttribute('data-aria-autofocus');
407
        });
408
    });
409
};
410
 
411
/**
412
 * Changes the focus to the correct tab based on the key that is pressed.
413
 * @param {KeyboardEvent} e
414
 */
415
const updateTabFocus = e => {
416
    const tabList = e.target.closest('[role="tablist"]');
417
    const vertical = tabList.getAttribute('aria-orientation') == 'vertical';
418
    const rtl = window.right_to_left();
419
    const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');
420
    const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');
421
    const tabs = Array.prototype.filter.call(
422
        tabList.querySelectorAll('[role="tab"]'),
423
        tab => !!tab.offsetHeight); // We only work with the visible tabs.
424
 
425
    for (let i = 0; i < tabs.length; i++) {
426
        tabs[i].index = i;
427
    }
428
 
429
    switch (e.key) {
430
        case arrowNext:
431
            e.preventDefault();
432
            if (e.target.index !== undefined && tabs[e.target.index + 1]) {
433
                tabs[e.target.index + 1].focus();
434
            } else {
435
                tabs[0].focus();
436
            }
437
            break;
438
        case arrowPrevious:
439
            e.preventDefault();
440
            if (e.target.index !== undefined && tabs[e.target.index - 1]) {
441
                tabs[e.target.index - 1].focus();
442
            } else {
443
                tabs[tabs.length - 1].focus();
444
            }
445
            break;
446
        case 'Home':
447
            e.preventDefault();
448
            tabs[0].focus();
449
            break;
450
        case 'End':
451
            e.preventDefault();
452
            tabs[tabs.length - 1].focus();
453
    }
454
};
455
 
456
/**
457
 * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.
458
 */
459
const tabElementFix = () => {
460
    document.addEventListener('keydown', e => {
461
        if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
462
            if (e.target.matches('[role="tablist"] [role="tab"]')) {
463
                updateTabFocus(e);
464
            }
465
        }
466
    });
467
 
468
    document.addEventListener('click', e => {
469
        if (e.target.matches('[role="tablist"] [data-toggle="tab"], [role="tablist"] [data-toggle="pill"]')) {
470
            const tabs = e.target.closest('[role="tablist"]').querySelectorAll('[data-toggle="tab"], [data-toggle="pill"]');
471
            e.preventDefault();
472
            $(e.target).tab('show');
473
            tabs.forEach(tab => {
474
                tab.tabIndex = -1;
475
            });
476
            e.target.tabIndex = 0;
477
        }
478
    });
479
};
480
 
481
/**
482
 * Fix keyboard interaction with Bootstrap Collapse elements.
483
 *
484
 * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure|WAI-ARIA Authoring Practices 1.1 - Disclosure (Show/Hide)}
485
 */
486
const collapseFix = () => {
487
    document.addEventListener('keydown', e => {
488
        if (e.target.matches('[data-toggle="collapse"]')) {
489
            // Pressing space should toggle expand/collapse.
490
            if (e.key === ' ') {
491
                e.preventDefault();
492
                e.target.click();
493
            }
494
        }
495
    });
496
};
497
 
498
export const init = () => {
499
    dropdownFix();
500
    comboboxFix();
501
    autoFocus();
502
    tabElementFix();
503
    collapseFix();
504
};