Ir a la última revisión | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |
// 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/>./*** Keyboard initialization for a given html node.** @module core/menu_navigation* @copyright 2021 Moodle* @author Mathew May <mathew.solutions>* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/const SELECTORS = {'menuitem': '[role="menuitem"]','tab': '[role="tab"]','dropdowntoggle': '[data-toggle="dropdown"]','primarymenuitemactive': '.primary-navigation .dropdown-item[aria-current="true"]',};let openDropdownNode = null;/*** Small helper function to check if a given node is null or not.** @param {HTMLElement|null} item The node that we want to compare.* @param {HTMLElement} fallback Either the first node or final node that can be focused on.* @return {HTMLElement}*/const clickErrorHandler = (item, fallback) => {if (item !== null) {return item;} else {return fallback;}};/*** Control classes etc of the selected dropdown item and its' parent <a>** @param {HTMLElement} src The node within the dropdown the user selected.*/const menuItemHelper = src => {let parent;// Do not apply any actions if the selected dropdown item is explicitly instructing to not display an active state.if (src.dataset.disableactive) {return;}// Handling for dropdown escapes.// A bulk of the handling is already done by aria.js just add polish.if (src.classList.contains('dropdown-item')) {parent = src.closest('.dropdown-menu');const dropDownToggle = document.getElementById(parent.getAttribute('aria-labelledby'));dropDownToggle.classList.add('active');dropDownToggle.setAttribute('tabindex', 0);} else if (src.matches(`${SELECTORS.tab},${SELECTORS.menuitem}`) && !src.matches(SELECTORS.dropdowntoggle)) {parent = src.parentElement.parentElement.querySelector('.dropdown-menu');} else {return;}// Remove active class from any other dropdown elements.Array.prototype.forEach.call(parent.children, node => {const menuItem = node.querySelector(SELECTORS.menuitem);if (menuItem !== null) {menuItem.classList.remove('active');// Remove aria selection state.menuItem.removeAttribute('aria-current');}});// Set the applicable element's selection state.if (src.getAttribute('role') === 'menuitem') {src.setAttribute('aria-current', 'true');}};/*** Check if there are sub items in a dropdown menu. There can be one element active only. That is usually controlled* by the server. However, when you click, the newly clicked item gets the active state as well. This is no problem* because the user leaves the page and a new page load happens. When the user hits the back button, the old page dom* is restored from the cache, with both menu items active. If there is such a case, we need to uncheck the item that* was clicked when leaving this page.* Make sure that this function is applied in the main menu only. The gradebook may contain drop down menus as well* were more than one item can be flagged as active.*/const dropDownMenuActiveCheck = function() {const items = document.querySelectorAll(SELECTORS.primarymenuitemactive);// Do the check only, if there is more than one subitem active.if (items !== null && items.length > 1) {items.forEach(function(e) {// Get the link target from the href attribute and compare it with the current url in the browser.const href = e.getAttribute('href');const windowHref = window.location.href || '';const windowPath = window.location.pathname || '';if (href !== windowHref && href !== windowPath&& href !== windowHref + '/index.php' && href !== windowPath + 'index.php') {e.classList.remove('active');e.removeAttribute('aria-current');}});}};/*** Defined keyboard event handling so we can remove listeners on nodes on resize etc.** @param {event} e The triggering element and key presses etc.*/const keyboardListenerEvents = e => {const src = e.srcElement;const firstNode = e.currentTarget.firstElementChild;const lastNode = findUsableLastNode(e.currentTarget);// Handling for dropdown escapes.// A bulk of the handling is already done by aria.js just add polish.if (src.classList.contains('dropdown-item')) {if (e.key == 'ArrowRight' ||e.key == 'ArrowLeft') {e.preventDefault();if (openDropdownNode !== null) {openDropdownNode.parentElement.click();}}if (e.key == ' ' ||e.key == 'Enter') {e.preventDefault();menuItemHelper(src);if (!src.parentElement.classList.contains('dropdown')) {src.click();}}} else {const rtl = window.right_to_left();const arrowNext = rtl ? 'ArrowLeft' : 'ArrowRight';const arrowPrevious = rtl ? 'ArrowRight' : 'ArrowLeft';if (src.getAttribute('role') === 'menuitem') {// When not rendered within a dropdown menu, handle keyboard navigation if the element is rendered as a menu item.if (e.key == arrowNext) {e.preventDefault();setFocusNext(src, firstNode);}if (e.key == arrowPrevious) {e.preventDefault();setFocusPrev(src, lastNode);}// Let aria.js handle the dropdowns.if (e.key == 'ArrowUp' ||e.key == 'ArrowDown') {openDropdownNode = src;e.preventDefault();}if (e.key == 'Home') {e.preventDefault();setFocusHomeEnd(firstNode);}if (e.key == 'End') {e.preventDefault();setFocusHomeEnd(lastNode);}}if (e.key == ' ' ||e.key == 'Enter') {e.preventDefault();// Aria.js handles dropdowns etc.if (!src.parentElement.classList.contains('dropdown')) {src.click();}}}};/*** Defined click event handling so we can remove listeners on nodes on resize etc.** @param {event} e The triggering element and key presses etc.*/const clickListenerEvents = e => {const src = e.srcElement;menuItemHelper(src);};/*** The initial entry point that a given module can pass a HTMLElement.** @param {HTMLElement} elementRoot The menu to add handlers upon.*/export default elementRoot => {// Remove any and all instances of old listeners on the passed element.elementRoot.removeEventListener('keydown', keyboardListenerEvents);elementRoot.removeEventListener('click', clickListenerEvents);// (Re)apply our event listeners to the passed element.elementRoot.addEventListener('keydown', keyboardListenerEvents);elementRoot.addEventListener('click', clickListenerEvents);};// We need this triggered only when the user hits the back button.window.addEventListener('pageshow', dropDownMenuActiveCheck);/*** Handle the focusing to the next element in the dropdown.** @param {HTMLElement|null} currentNode The node that we want to take action on.* @param {HTMLElement} firstNode The backup node to focus as a last resort.*/const setFocusNext = (currentNode, firstNode) => {const listElement = currentNode.parentElement;const nextListItem = ((el) => {do {el = el.nextElementSibling;} while (el && !el.offsetHeight); // We only work with the visible tabs.return el;})(listElement);const nodeToSelect = clickErrorHandler(nextListItem, firstNode);const parent = listElement.parentElement;const isTabList = parent.getAttribute('role') === 'tablist';const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;const menuItem = nodeToSelect.querySelector(itemSelector);menuItem.focus();};/*** Handle the focusing to the previous element in the dropdown.** @param {HTMLElement|null} currentNode The node that we want to take action on.* @param {HTMLElement} lastNode The backup node to focus as a last resort.*/const setFocusPrev = (currentNode, lastNode) => {const listElement = currentNode.parentElement;const nextListItem = ((el) => {do {el = el.previousElementSibling;} while (el && !el.offsetHeight); // We only work with the visible tabs.return el;})(listElement);const nodeToSelect = clickErrorHandler(nextListItem, lastNode);const parent = listElement.parentElement;const isTabList = parent.getAttribute('role') === 'tablist';const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;const menuItem = nodeToSelect.querySelector(itemSelector);menuItem.focus();};/*** Focus on either the start or end of a nav list.** @param {HTMLElement} node The element to focus on.*/const setFocusHomeEnd = node => {node.querySelector(SELECTORS.menuitem).focus();};/*** We need to look within the menu to find a last node we can add focus to.** @param {HTMLElement} elementRoot Menu to find a final child node within.* @return {HTMLElement}*/const findUsableLastNode = elementRoot => {const lastNode = elementRoot.lastElementChild;// An example is the more menu existing but hidden on the page for the time being.if (!lastNode.classList.contains('d-none')) {return elementRoot.lastElementChild;} else {// Cast the HTMLCollection & reverse it.const extractedNodes = Array.prototype.map.call(elementRoot.children, node => {return node;}).reverse();// Get rid of any nodes we can not set focus on.const nodesToUse = extractedNodes.filter((node => {if (!node.classList.contains('d-none')) {return node;}}));// If we find no elements we can set focus on, fall back to the absolute first element.if (nodesToUse.length !== 0) {return nodesToUse[0];} else {return elementRoot.firstElementChild;}}};