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
 * Keyboard initialization for a given html node.
18
 *
19
 * @module     core/menu_navigation
20
 * @copyright  2021 Moodle
21
 * @author     Mathew May <mathew.solutions>
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
const SELECTORS = {
26
    'menuitem': '[role="menuitem"]',
27
    'tab': '[role="tab"]',
1441 ariadna 28
    'dropdowntoggle': '[data-bs-toggle="dropdown"]',
1 efrain 29
};
30
 
31
let openDropdownNode = null;
32
 
33
/**
34
 * Small helper function to check if a given node is null or not.
35
 *
36
 * @param {HTMLElement|null} item The node that we want to compare.
37
 * @param {HTMLElement} fallback Either the first node or final node that can be focused on.
38
 * @return {HTMLElement}
39
 */
40
const clickErrorHandler = (item, fallback) => {
41
    if (item !== null) {
42
        return item;
43
    } else {
44
        return fallback;
45
    }
46
};
47
 
48
/**
49
 * Control classes etc of the selected dropdown item and its' parent <a>
50
 *
51
 * @param {HTMLElement} src The node within the dropdown the user selected.
52
 */
53
const menuItemHelper = src => {
54
    let parent;
55
 
56
    // Do not apply any actions if the selected dropdown item is explicitly instructing to not display an active state.
57
    if (src.dataset.disableactive) {
58
        return;
59
    }
60
    // Handling for dropdown escapes.
61
    // A bulk of the handling is already done by aria.js just add polish.
62
    if (src.classList.contains('dropdown-item')) {
63
        parent = src.closest('.dropdown-menu');
64
        const dropDownToggle = document.getElementById(parent.getAttribute('aria-labelledby'));
65
        dropDownToggle.classList.add('active');
66
        dropDownToggle.setAttribute('tabindex', 0);
67
    } else if (src.matches(`${SELECTORS.tab},${SELECTORS.menuitem}`) && !src.matches(SELECTORS.dropdowntoggle)) {
68
        parent = src.parentElement.parentElement.querySelector('.dropdown-menu');
69
    } else {
70
        return;
71
    }
72
    // Remove active class from any other dropdown elements.
73
    Array.prototype.forEach.call(parent.children, node => {
74
        const menuItem = node.querySelector(SELECTORS.menuitem);
75
        if (menuItem !== null) {
76
            menuItem.classList.remove('active');
77
            // Remove aria selection state.
78
            menuItem.removeAttribute('aria-current');
79
        }
80
    });
81
    // Set the applicable element's selection state.
82
    if (src.getAttribute('role') === 'menuitem') {
83
        src.setAttribute('aria-current', 'true');
84
    }
85
};
86
 
87
/**
88
 * Defined keyboard event handling so we can remove listeners on nodes on resize etc.
89
 *
90
 * @param {event} e The triggering element and key presses etc.
91
 */
92
const keyboardListenerEvents = e => {
93
    const src = e.srcElement;
94
    const firstNode = e.currentTarget.firstElementChild;
95
    const lastNode = findUsableLastNode(e.currentTarget);
96
 
97
    // Handling for dropdown escapes.
98
    // A bulk of the handling is already done by aria.js just add polish.
99
    if (src.classList.contains('dropdown-item')) {
100
        if (e.key == 'ArrowRight' ||
101
            e.key == 'ArrowLeft') {
102
            e.preventDefault();
103
            if (openDropdownNode !== null) {
104
                openDropdownNode.parentElement.click();
105
            }
106
        }
107
        if (e.key == ' ' ||
108
            e.key == 'Enter') {
109
            e.preventDefault();
110
 
111
            menuItemHelper(src);
112
 
113
            if (!src.parentElement.classList.contains('dropdown')) {
114
                src.click();
115
            }
116
        }
117
    } else {
118
        const rtl = window.right_to_left();
119
        const arrowNext = rtl ? 'ArrowLeft' : 'ArrowRight';
120
        const arrowPrevious = rtl ? 'ArrowRight' : 'ArrowLeft';
121
 
122
        if (src.getAttribute('role') === 'menuitem') {
123
            // When not rendered within a dropdown menu, handle keyboard navigation if the element is rendered as a menu item.
124
            if (e.key == arrowNext) {
125
                e.preventDefault();
126
                setFocusNext(src, firstNode);
127
            }
128
            if (e.key == arrowPrevious) {
129
                e.preventDefault();
130
                setFocusPrev(src, lastNode);
131
            }
132
            // Let aria.js handle the dropdowns.
133
            if (e.key == 'ArrowUp' ||
134
                e.key == 'ArrowDown') {
135
                openDropdownNode = src;
136
                e.preventDefault();
137
            }
138
            if (e.key == 'Home') {
139
                e.preventDefault();
140
                setFocusHomeEnd(firstNode);
141
            }
142
            if (e.key == 'End') {
143
                e.preventDefault();
144
                setFocusHomeEnd(lastNode);
145
            }
146
        }
147
 
148
        if (e.key == ' ' ||
149
            e.key == 'Enter') {
150
            e.preventDefault();
151
            // Aria.js handles dropdowns etc.
152
            if (!src.parentElement.classList.contains('dropdown')) {
153
                src.click();
154
            }
155
        }
156
    }
157
};
158
 
159
/**
160
 * Defined click event handling so we can remove listeners on nodes on resize etc.
161
 *
162
 * @param {event} e The triggering element and key presses etc.
163
 */
164
const clickListenerEvents = e => {
165
    const src = e.srcElement;
166
    menuItemHelper(src);
167
};
168
 
169
/**
170
 * The initial entry point that a given module can pass a HTMLElement.
171
 *
172
 * @param {HTMLElement} elementRoot The menu to add handlers upon.
173
 */
174
export default elementRoot => {
175
    // Remove any and all instances of old listeners on the passed element.
176
    elementRoot.removeEventListener('keydown', keyboardListenerEvents);
177
    elementRoot.removeEventListener('click', clickListenerEvents);
178
    // (Re)apply our event listeners to the passed element.
179
    elementRoot.addEventListener('keydown', keyboardListenerEvents);
180
    elementRoot.addEventListener('click', clickListenerEvents);
181
};
182
 
183
/**
184
 * Handle the focusing to the next element in the dropdown.
185
 *
186
 * @param {HTMLElement|null} currentNode The node that we want to take action on.
187
 * @param {HTMLElement} firstNode The backup node to focus as a last resort.
188
 */
189
const setFocusNext = (currentNode, firstNode) => {
190
    const listElement = currentNode.parentElement;
191
    const nextListItem = ((el) => {
192
        do {
193
            el = el.nextElementSibling;
194
        } while (el && !el.offsetHeight); // We only work with the visible tabs.
195
        return el;
196
    })(listElement);
197
    const nodeToSelect = clickErrorHandler(nextListItem, firstNode);
198
    const parent = listElement.parentElement;
199
    const isTabList = parent.getAttribute('role') === 'tablist';
200
    const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;
201
    const menuItem = nodeToSelect.querySelector(itemSelector);
202
    menuItem.focus();
203
};
204
 
205
/**
206
 * Handle the focusing to the previous element in the dropdown.
207
 *
208
 * @param {HTMLElement|null} currentNode The node that we want to take action on.
209
 * @param {HTMLElement} lastNode The backup node to focus as a last resort.
210
 */
211
const setFocusPrev = (currentNode, lastNode) => {
212
    const listElement = currentNode.parentElement;
213
    const nextListItem = ((el) => {
214
        do {
215
            el = el.previousElementSibling;
216
        } while (el && !el.offsetHeight); // We only work with the visible tabs.
217
        return el;
218
    })(listElement);
219
    const nodeToSelect = clickErrorHandler(nextListItem, lastNode);
220
    const parent = listElement.parentElement;
221
    const isTabList = parent.getAttribute('role') === 'tablist';
222
    const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;
223
    const menuItem = nodeToSelect.querySelector(itemSelector);
224
    menuItem.focus();
225
};
226
 
227
/**
228
 * Focus on either the start or end of a nav list.
229
 *
230
 * @param {HTMLElement} node The element to focus on.
231
 */
232
const setFocusHomeEnd = node => {
233
    node.querySelector(SELECTORS.menuitem).focus();
234
};
235
 
236
/**
237
 * We need to look within the menu to find a last node we can add focus to.
238
 *
239
 * @param {HTMLElement} elementRoot Menu to find a final child node within.
240
 * @return {HTMLElement}
241
 */
242
const findUsableLastNode = elementRoot => {
243
    const lastNode = elementRoot.lastElementChild;
244
 
245
    // An example is the more menu existing but hidden on the page for the time being.
246
    if (!lastNode.classList.contains('d-none')) {
247
        return elementRoot.lastElementChild;
248
    } else {
249
        // Cast the HTMLCollection & reverse it.
250
        const extractedNodes = Array.prototype.map.call(elementRoot.children, node => {
251
            return node;
252
        }).reverse();
253
 
254
        // Get rid of any nodes we can not set focus on.
255
        const nodesToUse = extractedNodes.filter((node => {
256
            if (!node.classList.contains('d-none')) {
257
                return node;
258
            }
259
        }));
260
 
261
        // If we find no elements we can set focus on, fall back to the absolute first element.
262
        if (nodesToUse.length !== 0) {
263
            return nodesToUse[0];
264
        } else {
265
            return elementRoot.firstElementChild;
266
        }
267
    }
268
};