// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Action menu subpanel JS controls.
*
* @module core/local/action_menu/subpanel
* @copyright 2023 Mikel MartÃn <mikel@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import jQuery from 'jquery';
import {debounce} from 'core/utils';
import {
isBehatSite,
isExtraSmall,
firstFocusableElement,
lastFocusableElement,
previousFocusableElement,
nextFocusableElement,
} from 'core/pagehelpers';
import Pending from 'core/pending';
import {
hide,
unhide,
} from 'core/aria';
const Selectors = {
mainMenu: '[role="menu"]',
dropdownRight: '.dropdown-menu-right',
subPanel: '.dropdown-subpanel',
subPanelMenuItem: '.dropdown-subpanel > .dropdown-item',
subPanelContent: '.dropdown-subpanel > .dropdown-menu',
// Drawer selector.
drawer: '[data-region="fixed-drawer"]',
// Lateral blocks columns selectors.
blockColumn: '.blockcolumn',
columnLeft: '.columnleft',
};
const Classes = {
dropRight: 'dropright',
dropLeft: 'dropleft',
dropDown: 'dropdown',
forceLeft: 'downleft',
contentDisplayed: 'content-displayed',
};
const BootstrapEvents = {
hideDropdown: 'hidden.bs.dropdown',
};
let initialized = false;
/**
* Initialize all delegated events into the page.
*/
const initPageEvents = () => {
if (initialized) {
return;
}
// Hide all subpanels when hidind a dropdown.
// This is using JQuery because of BS4 events. JQuery won't be needed with BS5.
jQuery(document).on(BootstrapEvents.hideDropdown, () => {
document.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
const subPanel = new SubPanel(dropdownSubPanel);
subPanel.setVisibility(false);
});
});
window.addEventListener('resize', debounce(updateAllPanelsPosition, 400));
initialized = true;
};
/**
* Update all the panels position.
*/
const updateAllPanelsPosition = () => {
document.querySelectorAll(Selectors.subPanel).forEach(dropdown => {
const subpanel = new SubPanel(dropdown);
subpanel.updatePosition();
});
};
/**
* Subpanel class.
* @private
*/
class SubPanel {
/**
* Constructor.
* @param {HTMLElement} element The element to initialize.
*/
constructor(element) {
this.element = element;
this.menuItem = element.querySelector(Selectors.subPanelMenuItem);
this.panelContent = element.querySelector(Selectors.subPanelContent);
/**
* Enable preview when the menu item has focus.
*
* This is disabled when the user press ESC or shift+TAB to force closing
*
* @type {Boolean}
* @private
*/
this.showPreviewOnFocus = true;
}
/**
* Initialize the subpanel element.
*
* This method adds the event listeners to the subpanel and the position classes.
*/
init() {
if (this.element.dataset.subPanelInitialized) {
return;
}
this.updatePosition();
// Full element events.
this.element.addEventListener('focusin', this._mainElementFocusInHandler.bind(this));
// Menu Item events.
this.menuItem.addEventListener('click', this._menuItemClickHandler.bind(this));
this.menuItem.addEventListener('keydown', this._menuItemKeyHandler.bind(this));
if (!isBehatSite()) {
// Behat in Chrome usually move the mouse over the page when trying clicking a subpanel element.
// If the menu has more than one subpanel this could cause closing the subpanel by mistake.
this.menuItem.addEventListener('mouseover', this._menuItemHoverHandler.bind(this));
this.menuItem.addEventListener('mouseout', this._menuItemHoverOutHandler.bind(this));
}
// Subpanel content events.
this.panelContent.addEventListener('keydown', this._panelContentKeyHandler.bind(this));
this.element.dataset.subPanelInitialized = true;
}
/**
* Checks if the subpanel has enough space.
*
* In general there are two scenarios were the subpanel must be interacted differently:
* - Extra small screens: The subpanel is displayed below the menu item.
* - Drawer: The subpanel is displayed one of the drawers.
* - Block columns: for classic based themes.
*
* @returns {Boolean} true if the subpanel should be displayed in small screens.
*/
_needSmallSpaceBehaviour() {
return isExtraSmall() ||
this.element.closest(Selectors.drawer) !== null ||
this.element.closest(Selectors.blockColumn) !== null;
}
/**
* Check if the subpanel should be displayed on the right.
*
* This is defined by the drop right boostrap class. However, if the menu is
* displayed in a block column on the right, the subpanel should be forced
* to the right.
*
* @returns {Boolean} true if the subpanel should be displayed on the right.
*/
_needDropdownRight() {
if (this.element.closest(Selectors.columnLeft) !== null) {
return false;
}
return this.element.closest(Selectors.dropdownRight) !== null;
}
/**
* Main element focus in handler.
*/
_mainElementFocusInHandler() {
if (this._needSmallSpaceBehaviour() || !this.showPreviewOnFocus) {
// Preview is disabled when the user press ESC or shift+TAB to force closing
// but if the continue navigating with keyboard the preview is enabled again.
this.showPreviewOnFocus = true;
return;
}
this.setVisibility(true);
}
/**
* Menu item click handler.
* @param {Event} event
*/
_menuItemClickHandler(event) {
// Avoid dropdowns being closed after clicking a subemnu.
// This won't be needed with BS5 (data-bs-auto-close handles it).
event.stopPropagation();
event.preventDefault();
if (this._needSmallSpaceBehaviour()) {
this.setVisibility(!this.getVisibility());
}
}
/**
* Menu item hover handler.
* @private
*/
_menuItemHoverHandler() {
if (this._needSmallSpaceBehaviour()) {
return;
}
this.setVisibility(true);
}
/**
* Menu item hover out handler.
* @private
*/
_menuItemHoverOutHandler() {
if (this._needSmallSpaceBehaviour()) {
return;
}
this._hideOtherSubPanels();
}
/**
* Menu item key handler.
* @param {Event} event
* @private
*/
_menuItemKeyHandler(event) {
// In small sizes te down key will focus on the panel.
if (event.key === 'ArrowUp' || (event.key === 'ArrowDown' && !this._needSmallSpaceBehaviour())) {
this.setVisibility(false);
return;
}
// Keys to move focus to the panel.
let focusPanel = false;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft' || (event.key === 'Tab' && !event.shiftKey)) {
focusPanel = true;
}
if ((event.key === 'Enter' || event.key === ' ')) {
focusPanel = true;
}
// In extra small screen the panel is shown below the item.
if (event.key === 'ArrowDown' && this._needSmallSpaceBehaviour() && this.getVisibility()) {
focusPanel = true;
}
if (focusPanel) {
event.stopPropagation();
event.preventDefault();
this.setVisibility(true);
this._focusPanelContent();
}
}
/**
* Sub panel content key handler.
* @param {Event} event
* @private
*/
_panelContentKeyHandler(event) {
// In extra small devices the panel is displayed under the menu item
// so the arrow up/down switch between subpanel and the menu item.
const canLoop = !this._needSmallSpaceBehaviour();
let isBrowsingSubPanel = false;
let newFocus = null;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
newFocus = this.menuItem;
}
// Acording to WCAG Esc and Tab are similar to arrow navigation but they
// force the subpanel to be closed.
if (event.key === 'Escape' || (event.key === 'Tab' && event.shiftKey)) {
newFocus = this.menuItem;
this.setVisibility(false);
this.showPreviewOnFocus = false;
}
if (event.key === 'ArrowUp') {
newFocus = previousFocusableElement(this.panelContent, canLoop);
isBrowsingSubPanel = true;
}
if (event.key === 'ArrowDown') {
newFocus = nextFocusableElement(this.panelContent, canLoop);
isBrowsingSubPanel = true;
}
if (event.key === 'Home') {
newFocus = firstFocusableElement(this.panelContent);
isBrowsingSubPanel = true;
}
if (event.key === 'End') {
newFocus = lastFocusableElement(this.panelContent);
isBrowsingSubPanel = true;
}
// If the user cannot loop and arrive to the start/end of the subpanel
// we focus on the menu item.
if (newFocus === null && isBrowsingSubPanel && !canLoop) {
newFocus = this.menuItem;
}
if (newFocus !== null) {
event.stopPropagation();
event.preventDefault();
newFocus.focus();
}
}
/**
* Focus on the first focusable element of the subpanel.
* @private
*/
_focusPanelContent() {
const pendingPromise = new Pending('core/action_menu/subpanel:focuscontent');
// Some Bootstrap events are triggered after the click event.
// To prevent this from affecting the focus we wait a bit.
setTimeout(() => {
const firstFocusable = firstFocusableElement(this.panelContent);
if (firstFocusable) {
firstFocusable.focus();
}
pendingPromise.resolve();
}, 100);
}
/**
* Set the visibility of a subpanel.
* @param {Boolean} visible true if the subpanel should be visible.
*/
setVisibility(visible) {
if (visible) {
this._hideOtherSubPanels();
}
// Aria hidden/unhidden can alter the focus, we only want to do it when needed.
if (!visible && this.getVisibility) {
hide(this.panelContent);
}
if (visible && !this.getVisibility) {
unhide(this.panelContent);
}
this.menuItem.setAttribute('aria-expanded', visible ? 'true' : 'false');
this.panelContent.classList.toggle('show', visible);
this.element.classList.toggle(Classes.contentDisplayed, visible);
}
/**
* Hide all other subpanels in the parent menu.
* @private
*/
_hideOtherSubPanels() {
const dropdown = this.element.closest(Selectors.mainMenu);
dropdown.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
if (dropdownSubPanel === this.element) {
return;
}
const subPanel = new SubPanel(dropdownSubPanel);
subPanel.setVisibility(false);
});
}
/**
* Get the visibility of a subpanel.
* @returns {Boolean} true if the subpanel is visible.
*/
getVisibility() {
return this.menuItem.getAttribute('aria-expanded') === 'true';
}
/**
* Update the panels position depending on the screen size and panel position.
*/
updatePosition() {
const dropdownRight = this._needDropdownRight();
if (this._needSmallSpaceBehaviour()) {
this.element.classList.remove(Classes.dropRight);
this.element.classList.remove(Classes.dropLeft);
this.element.classList.add(Classes.dropDown);
this.element.classList.toggle(Classes.forceLeft, dropdownRight);
return;
}
this.element.classList.remove(Classes.dropDown);
this.element.classList.remove(Classes.forceLeft);
this.element.classList.toggle(Classes.dropRight, !dropdownRight);
this.element.classList.toggle(Classes.dropLeft, dropdownRight);
}
}
/**
* Initialise module for given report
*
* @method
* @param {string} selector The query selector to init.
*/
export const init = (selector) => {
initPageEvents();
const subMenu = document.querySelector(selector);
if (!subMenu) {
throw new Error(`Sub panel element not found: ${selector}`);
}
const subPanel = new SubPanel(subMenu);
subPanel.init();
};