lib/amd/src/pagehelpers.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/>.

/**
 * Page utility helpers.
 *
 * @module core/pagehelpers
 * @copyright  2023 Ferran Recio <ferran@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

/**
 * Maximum sizes for breakpoints. This needs to correspond with Bootstrap
 * Breakpoints
 *
 * @private
 */
const Sizes = {
    small: 576,
    medium: 991,
    large: 1400
};

const Selectors = {
    focusable: 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
};

const Classes = {
    behatSite: 'behat-site',
};

/**
 * Check fi the current page is a Behat site.
 * @returns {boolean} true if the current page is a Behat site.
 */
export const isBehatSite = () => {
    return document.body.classList.contains(Classes.behatSite);
};

/**
 * Get the current body width.
 * @returns {number} the current body width.
 */
export const getCurrentWidth = () => {
    const DomRect = document.body.getBoundingClientRect();
    return DomRect.x + DomRect.width;
};

/**
 * Check if the user uses an extra small size browser.
 *
 * @returns {boolean} true if the body is smaller than sizes.small max size.
 */
export const isExtraSmall = () => {
    const browserWidth = getCurrentWidth();
    return browserWidth < Sizes.small;
};

/**
 * Check if the user uses a small size browser.
 *
 * @returns {boolean} true if the body is smaller than sizes.medium max size.
 */
export const isSmall = () => {
    const browserWidth = getCurrentWidth();
    return browserWidth < Sizes.medium;
};

/**
 * Check if the user uses a large size browser.
 *
 * @returns {boolean} true if the body is smaller than sizes.large max size.
 */
export const isLarge = () => {
    const browserWidth = getCurrentWidth();
    return browserWidth >= Sizes.large;
};

/**
 * Get the first focusable element inside a container.
 * @param {HTMLElement} [container] Container to search in. Defaults to document.
 * @returns {HTMLElement|null}
 */
export const firstFocusableElement = (container) => {
    const containerElement = container || document;
    return containerElement.querySelector(Selectors.focusable);
};

/**
 * Get the last focusable element inside a container.
 * @param {HTMLElement} [container] Container to search in. Defaults to document.
 * @returns {HTMLElement|null}
 */
export const lastFocusableElement = (container) => {
    const containerElement = container || document;
    const focusableElements = containerElement.querySelectorAll(Selectors.focusable);
    return focusableElements[focusableElements.length - 1] ?? null;
};

/**
 * Get all focusable elements inside a container.
 * @param {HTMLElement} [container] Container to search in. Defaults to document.
 * @returns {HTMLElement[]}
 */
export const focusableElements = (container) => {
    const containerElement = container || document;
    return containerElement.querySelectorAll(Selectors.focusable);
};

/**
 * Get the previous focusable element in a container.
 * It uses the current focused element to know where to start the search.
 * @param {HTMLElement} [container] Container to search in. Defaults to document.
 * @param {Boolean} [loopSelection] Whether to loop selection or not. Default to false.
 * @returns {HTMLElement|null}
 */
export const previousFocusableElement = (container, loopSelection) => {
    return getRelativeFocusableElement(container, loopSelection, -1);
};

/**
 * Get the next focusable element in a container.
 * It uses the current focused element to know where to start the search.
 * @param {HTMLElement} [container] Container to search in. Defaults to document.
 * @param {Boolean} [loopSelection] Whether to loop selection or not. Default to false.
 * @returns {HTMLElement|null}
 */
export const nextFocusableElement = (container, loopSelection) => {
    return getRelativeFocusableElement(container, loopSelection, 1);
};

/**
 * Internal function to get the next or previous focusable element.
 * @param {HTMLElement} [container] Container to search in. Defaults to document.
 * @param {Boolean} [loopSelection] Whether to loop selection or not.
 * @param {Number} [direction] Direction to search in. 1 for next, -1 for previous.
 * @returns {HTMLElement|null}
 * @private
 */
const getRelativeFocusableElement = (container, loopSelection, direction) => {
    const focusedElement = document.activeElement;
    const focusables = [...focusableElements(container)];
    const focusedIndex = focusables.indexOf(focusedElement);

    if (focusedIndex === -1) {
        return null;
    }

    const newIndex = focusedIndex + direction;

    if (focusables[newIndex] !== undefined) {
        return focusables[newIndex];
    }
    if (loopSelection != true) {
        return null;
    }
    if (direction > 0) {
        return focusables[0] ?? null;
    }
    return focusables[focusables.length - 1] ?? null;
};