lib/amd/src/local/action_menu/subpanel.js

// 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();
};