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
 * Enhancements to Bootstrap components for accessibility.
18
 *
19
 * @module     theme_universe/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
 
1441 ariadna 24
import Tab from 'theme_universe/bootstrap/tab';
1 efrain 25
import Pending from 'core/pending';
1441 ariadna 26
import * as FocusLockManager from 'core/local/aria/focuslock';
1 efrain 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
 
1441 ariadna 102
        if (foundMenuItem) {
1 efrain 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 => {
1441 ariadna 110
        if (e.target.matches('[role="menu"] [role="menuitem"]')) {
1 efrain 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.
1441 ariadna 138
        if (e.target.matches('[data-bs-toggle="dropdown"]')) {
1 efrain 139
            handleMenuButton(e);
140
        }
141
 
1441 ariadna 142
        if (e.target.matches('[role="menu"] [role="menuitem"]')) {
1 efrain 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
 
1441 ariadna 196
    // Trap focus if the dropdown is a dialog.
197
    document.addEventListener('shown.bs.dropdown', e => {
198
        const dialog = e.target.querySelector('.dropdown-menu[role="dialog"]');
199
        if (dialog) {
200
            // Use setTimeout to make sure the dialog is positioned correctly to prevent random scrolling.
201
            setTimeout(() => {
202
                FocusLockManager.trapFocus(dialog);
203
            });
204
        }
205
    });
206
 
207
    // Untrap focus when the dialog dropdown is closed.
208
    document.addEventListener('hidden.bs.dropdown', e => {
209
        const dialog = e.target.querySelector('.dropdown-menu[role="dialog"]');
210
        if (dialog) {
211
            FocusLockManager.untrapFocus();
212
        }
213
 
1 efrain 214
        // We need to focus on the menu trigger.
1441 ariadna 215
        const trigger = e.target.querySelector('[data-bs-toggle="dropdown"]');
216
        // If it's a click event, then no element is focused because the clicked element is inside a closed dropdown.
217
        const focused = e.clickEvent?.target || (document.activeElement !== document.body ? document.activeElement : null);
1 efrain 218
        if (trigger && focused && e.target.contains(focused)) {
219
            shiftFocus(trigger, () => {
220
                if (document.activeElement === document.body) {
221
                    // If the focus is currently on the body, then we can safely assume that the focus needs to be updated.
222
                    return true;
223
                }
224
 
225
                // If the focus is on a child of the clicked element still, then update the focus.
226
                return e.target.contains(document.activeElement);
227
            });
228
        }
229
    });
230
};
231
 
232
/**
233
 * A lot of Bootstrap's out of the box features don't work if dropdown items are not focusable.
234
 */
235
const comboboxFix = () => {
1441 ariadna 236
    document.addEventListener('show.bs.dropdown', e => {
1 efrain 237
        if (e.relatedTarget.matches('[role="combobox"]')) {
238
            const combobox = e.relatedTarget;
239
            const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
240
 
241
            if (listbox) {
242
                const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
243
 
244
                // To make sure ArrowDown doesn't move the active option afterwards.
245
                setTimeout(() => {
246
                    if (selectedOption) {
247
                        selectedOption.classList.add('active');
248
                        combobox.setAttribute('aria-activedescendant', selectedOption.id);
249
                    } else {
250
                        const firstOption = listbox.querySelector('[role="option"]');
251
                        firstOption.setAttribute('aria-selected', 'true');
252
                        firstOption.classList.add('active');
253
                        combobox.setAttribute('aria-activedescendant', firstOption.id);
254
                    }
255
                }, 0);
256
            }
257
        }
258
    });
259
 
1441 ariadna 260
    document.addEventListener('hidden.bs.dropdown', e => {
1 efrain 261
        if (e.relatedTarget.matches('[role="combobox"]')) {
262
            const combobox = e.relatedTarget;
263
            const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
264
 
265
            combobox.removeAttribute('aria-activedescendant');
266
 
267
            if (listbox) {
268
                setTimeout(() => {
269
                    // Undo all previously highlighted options.
270
                    listbox.querySelectorAll('.active[role="option"]').forEach(option => {
271
                        option.classList.remove('active');
272
                    });
273
                }, 0);
274
            }
275
        }
276
    });
277
 
278
    // Handling keyboard events for both navigating through and selecting options.
279
    document.addEventListener('keydown', e => {
280
        if (e.target.matches('[role="combobox"][aria-controls]:not([aria-haspopup=dialog])')) {
281
            const combobox = e.target;
282
            const trigger = e.key;
283
            let next = null;
284
            const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
285
            const options = listbox.querySelectorAll('[role="option"]');
286
            const activeOption = listbox.querySelector('.active[role="option"]');
287
            const editable = combobox.hasAttribute('aria-autocomplete');
288
 
289
            // Under the special case that the dropdown menu is being shown as a result of the key press (like when the user
290
            // presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.
291
            // It's because of a race condition with show.bs.dropdown event handler.
292
            if (options && (activeOption || editable)) {
293
                if (trigger == 'ArrowDown') {
294
                    for (let i = 0; i < options.length - 1; i++) {
295
                        if (options[i] == activeOption) {
296
                            next = options[i + 1];
297
                            break;
298
                        }
299
                    }
300
                    if (editable && !next) {
301
                        next = options[0];
302
                    }
303
                } if (trigger == 'ArrowUp') {
304
                    for (let i = 1; i < options.length; i++) {
305
                        if (options[i] == activeOption) {
306
                            next = options[i - 1];
307
                            break;
308
                        }
309
                    }
310
                    if (editable && !next) {
311
                        next = options[options.length - 1];
312
                    }
1441 ariadna 313
                } else if (trigger == 'Home' && !editable) {
1 efrain 314
                    next = options[0];
1441 ariadna 315
                } else if (trigger == 'End' && !editable) {
1 efrain 316
                    next = options[options.length - 1];
317
                } else if ((trigger == ' ' && !editable) || trigger == 'Enter') {
318
                    e.preventDefault();
319
                    selectOption(combobox, activeOption);
320
                } else if (!editable) {
321
                    // Search for options by finding the first option that has
322
                    // text starting with the typed character (case insensitive).
323
                    for (let i = 0; i < options.length; i++) {
324
                        const option = options[i];
325
                        const optionText = option.textContent.trim().toLowerCase();
326
                        const keyPressed = e.key.toLowerCase();
327
                        if (optionText.indexOf(keyPressed) == 0) {
328
                            next = option;
329
                            break;
330
                        }
331
                    }
332
                }
333
 
334
                // Variable next is set if we do want to act on the keypress.
335
                if (next) {
336
                    e.preventDefault();
337
                    if (activeOption) {
338
                        activeOption.classList.remove('active');
339
                    }
340
                    next.classList.add('active');
341
                    combobox.setAttribute('aria-activedescendant', next.id);
342
                    next.scrollIntoView({block: 'nearest'});
343
                }
344
            }
345
        }
346
    });
347
 
348
    document.addEventListener('click', e => {
349
        const option = e.target.closest('[role="listbox"] [role="option"]');
350
        if (option) {
351
            const listbox = option.closest('[role="listbox"]');
352
            const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`);
353
            if (combobox) {
354
                selectOption(combobox, option);
355
            }
356
        }
357
    });
358
 
359
    // In case some code somewhere else changes the value of the combobox.
360
    document.addEventListener('change', e => {
361
        if (e.target.matches('input[type="hidden"][id]')) {
362
            const combobox = document.querySelector(`[role="combobox"][data-input-element="${e.target.id}"]`);
363
            const option = e.target.parentElement.querySelector(`[role="option"][data-value="${e.target.value}"]`);
364
 
365
            if (combobox && option) {
366
                selectOption(combobox, option);
367
            }
368
        }
369
    });
370
 
371
    const selectOption = (combobox, option) => {
372
        const listbox = option.closest('[role="listbox"]');
373
        const oldSelectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
374
 
375
        if (oldSelectedOption != option) {
376
            if (oldSelectedOption) {
377
                oldSelectedOption.removeAttribute('aria-selected');
378
            }
379
            option.setAttribute('aria-selected', 'true');
380
        }
381
 
382
        if (combobox.hasAttribute('value')) {
1441 ariadna 383
            combobox.value = option.dataset.shortText || option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim();
1 efrain 384
        } else {
1441 ariadna 385
            const selectedOptionContainer = combobox.querySelector('[data-selected-option]');
386
            if (selectedOptionContainer) {
387
                selectedOptionContainer.textContent = option.dataset.shortText || option.textContent;
388
            } else {
389
                combobox.textContent = option.dataset.shortText || option.textContent;
390
            }
1 efrain 391
        }
392
 
393
        if (combobox.dataset.inputElement) {
394
            const inputElement = document.getElementById(combobox.dataset.inputElement);
395
            if (inputElement && (inputElement.value != option.dataset.value)) {
396
                inputElement.value = option.dataset.value;
397
                inputElement.dispatchEvent(new Event('change', {bubbles: true}));
398
            }
399
        }
400
    };
401
};
402
 
403
/**
404
 * After page load, focus on any element with special autofocus attribute.
405
 */
406
const autoFocus = () => {
407
    window.addEventListener("load", () => {
408
        const alerts = document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');
409
        Array.prototype.forEach.call(alerts, autofocusElement => {
410
            // According to the specification an role="alert" region is only read out on change to the content
411
            // of that region.
412
            autofocusElement.innerHTML += ' ';
413
            autofocusElement.removeAttribute('data-aria-autofocus');
414
        });
415
    });
416
};
417
 
418
/**
1441 ariadna 419
 * Changes the focus to the correct element based on the key that is pressed.
420
 * @param {NodeList} elements A NodeList of focusable elements to navigate between.
421
 * @param {KeyboardEvent} e The keyboard event that triggers the roving focus.
422
 * @param {boolean} vertical Whether the navigation is vertical.
423
 * @param {boolean} updateTabIndex Whether to update the tabIndex of the elements.
1 efrain 424
 */
1441 ariadna 425
const rovingFocus = (elements, e, vertical, updateTabIndex) => {
1 efrain 426
    const rtl = window.right_to_left();
427
    const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');
428
    const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');
1441 ariadna 429
    const keys = [arrowNext, arrowPrevious, 'Home', 'End'];
1 efrain 430
 
1441 ariadna 431
    if (!keys.includes(e.key)) {
432
        return;
1 efrain 433
    }
434
 
1441 ariadna 435
    const focusElement = index => {
436
        elements[index].focus();
437
        if (updateTabIndex) {
438
            elements.forEach((element, i) => element.setAttribute('tabindex', i === index ? '0' : '-1'));
439
        }
440
    };
441
 
442
    const currentIndex = Array.prototype.indexOf.call(elements, e.target);
443
    let nextIndex;
444
 
1 efrain 445
    switch (e.key) {
446
        case arrowNext:
447
            e.preventDefault();
1441 ariadna 448
            nextIndex = (currentIndex + 1 < elements.length) ? currentIndex + 1 : 0;
449
            focusElement(nextIndex);
1 efrain 450
            break;
451
        case arrowPrevious:
452
            e.preventDefault();
1441 ariadna 453
            nextIndex = (currentIndex - 1 >= 0) ? currentIndex - 1 : elements.length - 1;
454
            focusElement(nextIndex);
1 efrain 455
            break;
456
        case 'Home':
457
            e.preventDefault();
1441 ariadna 458
            focusElement(0);
1 efrain 459
            break;
460
        case 'End':
461
            e.preventDefault();
1441 ariadna 462
            focusElement(elements.length - 1);
1 efrain 463
    }
464
};
465
 
466
/**
467
 * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.
468
 */
469
const tabElementFix = () => {
470
    document.addEventListener('keydown', e => {
471
        if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
472
            if (e.target.matches('[role="tablist"] [role="tab"]')) {
1441 ariadna 473
                const tabList = e.target.closest('[role="tablist"]');
474
                const tabs = Array.prototype.filter.call(
475
                    tabList.querySelectorAll('[role="tab"]'),
476
                    tab => !!tab.offsetHeight
477
                ); // We only work with the visible tabs.
478
                const vertical = tabList.getAttribute('aria-orientation') == 'vertical';
479
 
480
                rovingFocus(tabs, e, vertical, false);
1 efrain 481
            }
482
        }
483
    });
484
 
485
    document.addEventListener('click', e => {
1441 ariadna 486
        if (e.target.matches('[role="tablist"] [data-bs-toggle="tab"], [role="tablist"] [data-bs-toggle="pill"]')) {
487
            const tabs = e.target.closest('[role="tablist"]').querySelectorAll('[data-bs-toggle="tab"], [data-bs-toggle="pill"]');
1 efrain 488
            e.preventDefault();
1441 ariadna 489
            Tab.getOrCreateInstance(e.target).show();
1 efrain 490
            tabs.forEach(tab => {
491
                tab.tabIndex = -1;
492
            });
493
            e.target.tabIndex = 0;
494
        }
495
    });
496
};
497
 
498
/**
499
 * Fix keyboard interaction with Bootstrap Collapse elements.
500
 *
501
 * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure|WAI-ARIA Authoring Practices 1.1 - Disclosure (Show/Hide)}
502
 */
503
const collapseFix = () => {
504
    document.addEventListener('keydown', e => {
1441 ariadna 505
        if (e.target.matches('[data-bs-toggle="collapse"]')) {
1 efrain 506
            // Pressing space should toggle expand/collapse.
507
            if (e.key === ' ') {
508
                e.preventDefault();
509
                e.target.click();
510
            }
511
        }
512
    });
513
};
514
 
1441 ariadna 515
/**
516
 * Fix accessibility issues
517
 */
518
const toolbarFix = () => {
519
    document.addEventListener('keydown', e => {
520
        if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
521
            if (e.target.matches('[role="toolbar"] button')) {
522
                const buttons = e.target.closest('[role="toolbar"]').querySelectorAll('button');
523
                rovingFocus(buttons, e, false, true);
524
            }
525
        }
526
    });
527
};
528
 
1 efrain 529
export const init = () => {
530
    dropdownFix();
531
    comboboxFix();
532
    autoFocus();
533
    tabElementFix();
534
    collapseFix();
1441 ariadna 535
    toolbarFix();
1 efrain 536
};