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