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
 * Action menu subpanel JS controls.
18
 *
19
 * @module      core/local/action_menu/subpanel
20
 * @copyright   2023 Mikel Martín <mikel@moodle.com>
21
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
 
24
import {debounce} from 'core/utils';
25
import {
26
    isBehatSite,
27
    isExtraSmall,
28
    firstFocusableElement,
29
    lastFocusableElement,
30
    previousFocusableElement,
31
    nextFocusableElement,
32
} from 'core/pagehelpers';
33
import Pending from 'core/pending';
34
import {
35
    hide,
36
    unhide,
37
} from 'core/aria';
1441 ariadna 38
import EventHandler from 'theme_boost/bootstrap/dom/event-handler';
1 efrain 39
 
40
const Selectors = {
41
    mainMenu: '[role="menu"]',
1441 ariadna 42
    dropdownRight: '.dropdown-menu-end',
1 efrain 43
    subPanel: '.dropdown-subpanel',
44
    subPanelMenuItem: '.dropdown-subpanel > .dropdown-item',
45
    subPanelContent: '.dropdown-subpanel > .dropdown-menu',
46
    // Drawer selector.
47
    drawer: '[data-region="fixed-drawer"]',
48
    // Lateral blocks columns selectors.
49
    blockColumn: '.blockcolumn',
50
    columnLeft: '.columnleft',
51
};
52
 
53
const Classes = {
1441 ariadna 54
    dropRight: 'dropend',
55
    dropLeft: 'dropstart',
1 efrain 56
    dropDown: 'dropdown',
57
    forceLeft: 'downleft',
58
    contentDisplayed: 'content-displayed',
59
};
60
 
61
const BootstrapEvents = {
62
    hideDropdown: 'hidden.bs.dropdown',
63
};
64
 
65
let initialized = false;
66
 
67
/**
68
 * Initialize all delegated events into the page.
69
 */
70
const initPageEvents = () => {
71
    if (initialized) {
72
        return;
73
    }
1441 ariadna 74
    // Hide all subpanels when hiding a dropdown.
75
    document.addEventListener(BootstrapEvents.hideDropdown, () => {
1 efrain 76
        document.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
77
            const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
78
            const subPanel = new SubPanel(dropdownSubPanel);
79
            subPanel.setVisibility(false);
80
        });
81
    });
82
 
83
    window.addEventListener('resize', debounce(updateAllPanelsPosition, 400));
84
 
85
    initialized = true;
86
};
87
 
88
/**
89
 * Update all the panels position.
90
 */
91
const updateAllPanelsPosition = () => {
92
    document.querySelectorAll(Selectors.subPanel).forEach(dropdown => {
93
        const subpanel = new SubPanel(dropdown);
94
        subpanel.updatePosition();
95
    });
96
};
97
 
98
/**
99
 * Subpanel class.
100
 * @private
101
 */
102
class SubPanel {
103
    /**
104
     * Constructor.
105
     * @param {HTMLElement} element The element to initialize.
106
     */
107
    constructor(element) {
108
        this.element = element;
109
        this.menuItem = element.querySelector(Selectors.subPanelMenuItem);
110
        this.panelContent = element.querySelector(Selectors.subPanelContent);
111
        /**
112
         * Enable preview when the menu item has focus.
113
         *
114
         * This is disabled when the user press ESC or shift+TAB to force closing
115
         *
116
         * @type {Boolean}
117
         * @private
118
         */
119
        this.showPreviewOnFocus = true;
120
    }
121
 
122
    /**
123
     * Initialize the subpanel element.
124
     *
125
     * This method adds the event listeners to the subpanel and the position classes.
126
     */
127
    init() {
128
        if (this.element.dataset.subPanelInitialized) {
129
            return;
130
        }
131
 
132
        this.updatePosition();
133
 
134
        // Full element events.
135
        this.element.addEventListener('focusin', this._mainElementFocusInHandler.bind(this));
136
        // Menu Item events.
137
        this.menuItem.addEventListener('click', this._menuItemClickHandler.bind(this));
1441 ariadna 138
        // Use the Bootstrap key handler for the menu item key handler.
139
        // This will avoid Boostrap Dropdown handler to prevent the propagation to the subpanel.
140
        const subpanelMenuItemSelector = `#${this.element.id}${Selectors.subPanelMenuItem}`;
141
        EventHandler.on(document, 'keydown', subpanelMenuItemSelector, this._menuItemKeyHandler.bind(this));
1 efrain 142
        if (!isBehatSite()) {
143
            // Behat in Chrome usually move the mouse over the page when trying clicking a subpanel element.
144
            // If the menu has more than one subpanel this could cause closing the subpanel by mistake.
145
            this.menuItem.addEventListener('mouseover', this._menuItemHoverHandler.bind(this));
146
            this.menuItem.addEventListener('mouseout', this._menuItemHoverOutHandler.bind(this));
147
        }
148
        // Subpanel content events.
149
        this.panelContent.addEventListener('keydown', this._panelContentKeyHandler.bind(this));
150
 
151
        this.element.dataset.subPanelInitialized = true;
152
    }
153
 
154
    /**
155
     * Checks if the subpanel has enough space.
156
     *
157
     * In general there are two scenarios were the subpanel must be interacted differently:
158
     * - Extra small screens: The subpanel is displayed below the menu item.
159
     * - Drawer: The subpanel is displayed one of the drawers.
160
     * - Block columns: for classic based themes.
161
     *
162
     * @returns {Boolean} true if the subpanel should be displayed in small screens.
163
     */
164
    _needSmallSpaceBehaviour() {
165
        return isExtraSmall() ||
166
            this.element.closest(Selectors.drawer) !== null ||
167
            this.element.closest(Selectors.blockColumn) !== null;
168
    }
169
 
170
    /**
171
     * Check if the subpanel should be displayed on the right.
172
     *
173
     * This is defined by the drop right boostrap class. However, if the menu is
174
     * displayed in a block column on the right, the subpanel should be forced
175
     * to the right.
176
     *
177
     * @returns {Boolean} true if the subpanel should be displayed on the right.
178
     */
179
    _needDropdownRight() {
180
        if (this.element.closest(Selectors.columnLeft) !== null) {
181
            return false;
182
        }
183
        return this.element.closest(Selectors.dropdownRight) !== null;
184
    }
185
 
186
    /**
187
     * Main element focus in handler.
188
     */
189
    _mainElementFocusInHandler() {
190
        if (this._needSmallSpaceBehaviour() || !this.showPreviewOnFocus) {
191
            // Preview is disabled when the user press ESC or shift+TAB to force closing
192
            // but if the continue navigating with keyboard the preview is enabled again.
193
            this.showPreviewOnFocus = true;
194
            return;
195
        }
196
        this.setVisibility(true);
197
    }
198
 
199
    /**
200
     * Menu item click handler.
201
     * @param {Event} event
202
     */
203
    _menuItemClickHandler(event) {
204
        // Avoid dropdowns being closed after clicking a subemnu.
205
        // This won't be needed with BS5 (data-bs-auto-close handles it).
206
        event.stopPropagation();
207
        event.preventDefault();
208
        if (this._needSmallSpaceBehaviour()) {
209
            this.setVisibility(!this.getVisibility());
210
        }
211
    }
212
 
213
    /**
214
     * Menu item hover handler.
215
     * @private
216
     */
217
    _menuItemHoverHandler() {
218
        if (this._needSmallSpaceBehaviour()) {
219
            return;
220
        }
221
        this.setVisibility(true);
222
    }
223
 
224
    /**
225
     * Menu item hover out handler.
226
     * @private
227
     */
228
    _menuItemHoverOutHandler() {
229
        if (this._needSmallSpaceBehaviour()) {
230
            return;
231
        }
232
        this._hideOtherSubPanels();
233
    }
234
 
235
    /**
236
     * Menu item key handler.
237
     * @param {Event} event
238
     * @private
239
     */
240
    _menuItemKeyHandler(event) {
241
        // In small sizes te down key will focus on the panel.
242
        if (event.key === 'ArrowUp' || (event.key === 'ArrowDown' && !this._needSmallSpaceBehaviour())) {
243
            this.setVisibility(false);
244
            return;
245
        }
246
 
247
        // Keys to move focus to the panel.
248
        let focusPanel = false;
249
 
250
        if (event.key === 'ArrowRight' || event.key === 'ArrowLeft' || (event.key === 'Tab' && !event.shiftKey)) {
251
            focusPanel = true;
252
        }
253
        if ((event.key === 'Enter' || event.key === ' ')) {
254
            focusPanel = true;
255
        }
256
        // In extra small screen the panel is shown below the item.
257
        if (event.key === 'ArrowDown' && this._needSmallSpaceBehaviour() && this.getVisibility()) {
258
            focusPanel = true;
259
        }
260
        if (focusPanel) {
261
            event.stopPropagation();
262
            event.preventDefault();
263
            this.setVisibility(true);
264
            this._focusPanelContent();
265
        }
266
 
267
    }
268
 
269
    /**
270
     * Sub panel content key handler.
271
     * @param {Event} event
272
     * @private
273
     */
274
    _panelContentKeyHandler(event) {
275
        // In extra small devices the panel is displayed under the menu item
276
        // so the arrow up/down switch between subpanel and the menu item.
277
        const canLoop = !this._needSmallSpaceBehaviour();
278
        let isBrowsingSubPanel = false;
279
        let newFocus = null;
280
        if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
281
            newFocus = this.menuItem;
282
        }
283
        // Acording to WCAG Esc and Tab are similar to arrow navigation but they
284
        // force the subpanel to be closed.
285
        if (event.key === 'Escape' || (event.key === 'Tab' && event.shiftKey)) {
286
            newFocus = this.menuItem;
287
            this.setVisibility(false);
288
            this.showPreviewOnFocus = false;
289
        }
290
        if (event.key === 'ArrowUp') {
291
            newFocus = previousFocusableElement(this.panelContent, canLoop);
292
            isBrowsingSubPanel = true;
293
        }
294
        if (event.key === 'ArrowDown') {
295
            newFocus = nextFocusableElement(this.panelContent, canLoop);
296
            isBrowsingSubPanel = true;
297
        }
298
        if (event.key === 'Home') {
299
            newFocus = firstFocusableElement(this.panelContent);
300
            isBrowsingSubPanel = true;
301
        }
302
        if (event.key === 'End') {
303
            newFocus = lastFocusableElement(this.panelContent);
304
            isBrowsingSubPanel = true;
305
        }
306
        // If the user cannot loop and arrive to the start/end of the subpanel
307
        // we focus on the menu item.
308
        if (newFocus === null && isBrowsingSubPanel && !canLoop) {
309
            newFocus = this.menuItem;
310
        }
311
        if (newFocus !== null) {
312
            event.stopPropagation();
313
            event.preventDefault();
314
            newFocus.focus();
315
        }
316
    }
317
 
318
    /**
319
     * Focus on the first focusable element of the subpanel.
320
     * @private
321
     */
322
    _focusPanelContent() {
323
        const pendingPromise = new Pending('core/action_menu/subpanel:focuscontent');
324
        // Some Bootstrap events are triggered after the click event.
325
        // To prevent this from affecting the focus we wait a bit.
326
        setTimeout(() => {
327
            const firstFocusable = firstFocusableElement(this.panelContent);
328
            if (firstFocusable) {
329
                firstFocusable.focus();
330
            }
331
            pendingPromise.resolve();
332
        }, 100);
333
    }
334
 
335
    /**
336
     * Set the visibility of a subpanel.
337
     * @param {Boolean} visible true if the subpanel should be visible.
338
     */
339
    setVisibility(visible) {
340
        if (visible) {
341
            this._hideOtherSubPanels();
342
        }
343
        // Aria hidden/unhidden can alter the focus, we only want to do it when needed.
344
        if (!visible && this.getVisibility) {
345
            hide(this.panelContent);
346
        }
347
        if (visible && !this.getVisibility) {
348
            unhide(this.panelContent);
349
        }
350
        this.menuItem.setAttribute('aria-expanded', visible ? 'true' : 'false');
351
        this.panelContent.classList.toggle('show', visible);
352
        this.element.classList.toggle(Classes.contentDisplayed, visible);
353
    }
354
 
355
    /**
356
     * Hide all other subpanels in the parent menu.
357
     * @private
358
     */
359
    _hideOtherSubPanels() {
360
        const dropdown = this.element.closest(Selectors.mainMenu);
361
        dropdown.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
362
            const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
363
            if (dropdownSubPanel === this.element) {
364
                return;
365
            }
366
            const subPanel = new SubPanel(dropdownSubPanel);
367
            subPanel.setVisibility(false);
368
        });
369
    }
370
 
371
    /**
372
     * Get the visibility of a subpanel.
373
     * @returns {Boolean} true if the subpanel is visible.
374
     */
375
    getVisibility() {
376
        return this.menuItem.getAttribute('aria-expanded') === 'true';
377
    }
378
 
379
    /**
380
     * Update the panels position depending on the screen size and panel position.
381
     */
382
    updatePosition() {
383
        const dropdownRight = this._needDropdownRight();
384
        if (this._needSmallSpaceBehaviour()) {
385
            this.element.classList.remove(Classes.dropRight);
386
            this.element.classList.remove(Classes.dropLeft);
387
            this.element.classList.add(Classes.dropDown);
388
            this.element.classList.toggle(Classes.forceLeft, dropdownRight);
389
            return;
390
        }
391
        this.element.classList.remove(Classes.dropDown);
392
        this.element.classList.remove(Classes.forceLeft);
393
        this.element.classList.toggle(Classes.dropRight, !dropdownRight);
394
        this.element.classList.toggle(Classes.dropLeft, dropdownRight);
395
    }
396
}
397
 
398
/**
399
 * Initialise module for given report
400
 *
401
 * @method
402
 * @param {string} selector The query selector to init.
403
 */
404
export const init = (selector) => {
405
    initPageEvents();
406
    const subMenu = document.querySelector(selector);
407
    if (!subMenu) {
408
        throw new Error(`Sub panel element not found: ${selector}`);
409
    }
410
    const subPanel = new SubPanel(subMenu);
411
    subPanel.init();
412
};