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