From 0c6a581f1ecf69377ab284735bc87fc0d5a265d4 Mon Sep 17 00:00:00 2001 From: Ferran Recio Date: Thu, 28 Sep 2023 18:22:08 +0200 Subject: [PATCH] MDL-79157 output: fix menu subpanels keyboard navigation --- .../build/local/action_menu/subpanel.min.js | 4 +- .../local/action_menu/subpanel.min.js.map | 2 +- lib/amd/src/local/action_menu/subpanel.js | 45 +++++++++++++++++-- lib/tests/behat/action_menu_subpanel.feature | 38 ++++++++++++---- 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/lib/amd/build/local/action_menu/subpanel.min.js b/lib/amd/build/local/action_menu/subpanel.min.js index e75fef7e853..1730b467ce5 100644 --- a/lib/amd/build/local/action_menu/subpanel.min.js +++ b/lib/amd/build/local/action_menu/subpanel.min.js @@ -1,10 +1,10 @@ -define("core/local/action_menu/subpanel",["exports","jquery","core/utils","core/pagehelpers","core/pending"],(function(_exports,_jquery,_utils,_pagehelpers,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +define("core/local/action_menu/subpanel",["exports","jquery","core/utils","core/pagehelpers","core/pending","core/aria"],(function(_exports,_jquery,_utils,_pagehelpers,_pending,_aria){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Action menu subpanel JS controls. * * @module core/local/action_menu/subpanel * @copyright 2023 Mikel Martín * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);const Selectors_mainMenu='[role="menu"]',Selectors_dropdownRight=".dropdown-menu-right",Selectors_subPanel=".dropdown-subpanel",Selectors_subPanelMenuItem=".dropdown-subpanel > .dropdown-item",Selectors_subPanelContent=".dropdown-subpanel > .dropdown-menu",Selectors_drawer='[data-region="fixed-drawer"]',Selectors_blockColumn=".blockcolumn",Selectors_columnLeft=".columnleft",Classes_dropRight="dropright",Classes_dropLeft="dropleft",Classes_dropDown="dropdown",Classes_forceLeft="downleft",Classes_contentDisplayed="content-displayed",BootstrapEvents_hideDropdown="hidden.bs.dropdown";let initialized=!1;const updateAllPanelsPosition=()=>{document.querySelectorAll(Selectors_subPanel).forEach((dropdown=>{new SubPanel(dropdown).updatePosition()}))};class SubPanel{constructor(element){this.element=element,this.menuItem=element.querySelector(Selectors_subPanelMenuItem),this.panelContent=element.querySelector(Selectors_subPanelContent)}init(){this.element.dataset.subPanelInitialized||(this.updatePosition(),this.element.addEventListener("focusin",this._mainElementFocusInHandler.bind(this)),this.menuItem.addEventListener("click",this._menuItemClickHandler.bind(this)),this.menuItem.addEventListener("keydown",this._menuItemKeyHandler.bind(this)),(0,_pagehelpers.isBehatSite)()||(this.menuItem.addEventListener("mouseover",this._menuItemHoverHandler.bind(this)),this.menuItem.addEventListener("mouseout",this._menuItemHoverOutHandler.bind(this))),this.panelContent.addEventListener("keydown",this._panelContentKeyHandler.bind(this)),this.element.dataset.subPanelInitialized=!0)}_needSmallSpaceBehaviour(){return(0,_pagehelpers.isExtraSmall)()||null!==this.element.closest(Selectors_drawer)||null!==this.element.closest(Selectors_blockColumn)}_needDropdownRight(){return null===this.element.closest(Selectors_columnLeft)&&null!==this.element.closest(Selectors_dropdownRight)}_mainElementFocusInHandler(){this._needSmallSpaceBehaviour()||this.setVisibility(!0)}_menuItemClickHandler(event){event.stopPropagation(),event.preventDefault(),this._needSmallSpaceBehaviour()&&this.setVisibility(!this.getVisibility())}_menuItemHoverHandler(){this._needSmallSpaceBehaviour()||this.setVisibility(!0)}_menuItemHoverOutHandler(){this._needSmallSpaceBehaviour()||this._hideOtherSubPanels()}_menuItemKeyHandler(event){if("ArrowUp"===event.key||"ArrowDown"===event.key&&!this._needSmallSpaceBehaviour())return void this.setVisibility(!1);let focusPanel=!1;"ArrowRight"!==event.key&&"ArrowLeft"!==event.key||(focusPanel=!0),"Enter"!==event.key&&" "!==event.key||this._needSmallSpaceBehaviour()||(focusPanel=!0),"ArrowDown"===event.key&&this._needSmallSpaceBehaviour()&&this.getVisibility()&&(focusPanel=!0),focusPanel&&(event.stopPropagation(),event.preventDefault(),this.setVisibility(!0),this._focusPanelContent())}_panelContentKeyHandler(event){const canLoop=!this._needSmallSpaceBehaviour();let isBrowsingSubPanel=!1,newFocus=null;"ArrowRight"!==event.key&&"ArrowLeft"!==event.key||(newFocus=this.menuItem),"ArrowUp"===event.key&&(newFocus=(0,_pagehelpers.previousFocusableElement)(this.panelContent,canLoop),isBrowsingSubPanel=!0),"ArrowDown"===event.key&&(newFocus=(0,_pagehelpers.nextFocusableElement)(this.panelContent,canLoop),isBrowsingSubPanel=!0),null===newFocus&&isBrowsingSubPanel&&!canLoop&&(newFocus=this.menuItem),null!==newFocus&&(event.stopPropagation(),event.preventDefault(),newFocus.focus())}_focusPanelContent(){const pendingPromise=new _pending.default("core/action_menu/subpanel:focuscontent");setTimeout((()=>{const firstFocusable=(0,_pagehelpers.firstFocusableElement)(this.panelContent);firstFocusable&&firstFocusable.focus(),pendingPromise.resolve()}),100)}setVisibility(visible){visible&&this._hideOtherSubPanels(),this.menuItem.setAttribute("aria-expanded",visible?"true":"false"),this.panelContent.classList.toggle("show",visible),this.element.classList.toggle(Classes_contentDisplayed,visible)}_hideOtherSubPanels(){this.element.closest(Selectors_mainMenu).querySelectorAll("".concat(Selectors_subPanelContent,".show")).forEach((visibleSubPanel=>{const dropdownSubPanel=visibleSubPanel.closest(Selectors_subPanel);if(dropdownSubPanel===this.element)return;new SubPanel(dropdownSubPanel).setVisibility(!1)}))}getVisibility(){return"true"===this.menuItem.getAttribute("aria-expanded")}updatePosition(){const dropdownRight=this._needDropdownRight();if(this._needSmallSpaceBehaviour())return this.element.classList.remove(Classes_dropRight),this.element.classList.remove(Classes_dropLeft),this.element.classList.add(Classes_dropDown),void this.element.classList.toggle(Classes_forceLeft,dropdownRight);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)}}_exports.init=selector=>{initialized||((0,_jquery.default)(document).on(BootstrapEvents_hideDropdown,(()=>{document.querySelectorAll("".concat(Selectors_subPanelContent,".show")).forEach((visibleSubPanel=>{const dropdownSubPanel=visibleSubPanel.closest(Selectors_subPanel);new SubPanel(dropdownSubPanel).setVisibility(!1)}))})),window.addEventListener("resize",(0,_utils.debounce)(updateAllPanelsPosition,400)),initialized=!0);const subMenu=document.querySelector(selector);if(!subMenu)throw new Error("Sub panel element not found: ".concat(selector));new SubPanel(subMenu).init()}})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);const Selectors_mainMenu='[role="menu"]',Selectors_dropdownRight=".dropdown-menu-right",Selectors_subPanel=".dropdown-subpanel",Selectors_subPanelMenuItem=".dropdown-subpanel > .dropdown-item",Selectors_subPanelContent=".dropdown-subpanel > .dropdown-menu",Selectors_drawer='[data-region="fixed-drawer"]',Selectors_blockColumn=".blockcolumn",Selectors_columnLeft=".columnleft",Classes_dropRight="dropright",Classes_dropLeft="dropleft",Classes_dropDown="dropdown",Classes_forceLeft="downleft",Classes_contentDisplayed="content-displayed",BootstrapEvents_hideDropdown="hidden.bs.dropdown";let initialized=!1;const updateAllPanelsPosition=()=>{document.querySelectorAll(Selectors_subPanel).forEach((dropdown=>{new SubPanel(dropdown).updatePosition()}))};class SubPanel{constructor(element){this.element=element,this.menuItem=element.querySelector(Selectors_subPanelMenuItem),this.panelContent=element.querySelector(Selectors_subPanelContent),this.showPreviewOnFocus=!0}init(){this.element.dataset.subPanelInitialized||(this.updatePosition(),this.element.addEventListener("focusin",this._mainElementFocusInHandler.bind(this)),this.menuItem.addEventListener("click",this._menuItemClickHandler.bind(this)),this.menuItem.addEventListener("keydown",this._menuItemKeyHandler.bind(this)),(0,_pagehelpers.isBehatSite)()||(this.menuItem.addEventListener("mouseover",this._menuItemHoverHandler.bind(this)),this.menuItem.addEventListener("mouseout",this._menuItemHoverOutHandler.bind(this))),this.panelContent.addEventListener("keydown",this._panelContentKeyHandler.bind(this)),this.element.dataset.subPanelInitialized=!0)}_needSmallSpaceBehaviour(){return(0,_pagehelpers.isExtraSmall)()||null!==this.element.closest(Selectors_drawer)||null!==this.element.closest(Selectors_blockColumn)}_needDropdownRight(){return null===this.element.closest(Selectors_columnLeft)&&null!==this.element.closest(Selectors_dropdownRight)}_mainElementFocusInHandler(){!this._needSmallSpaceBehaviour()&&this.showPreviewOnFocus?this.setVisibility(!0):this.showPreviewOnFocus=!0}_menuItemClickHandler(event){event.stopPropagation(),event.preventDefault(),this._needSmallSpaceBehaviour()&&this.setVisibility(!this.getVisibility())}_menuItemHoverHandler(){this._needSmallSpaceBehaviour()||this.setVisibility(!0)}_menuItemHoverOutHandler(){this._needSmallSpaceBehaviour()||this._hideOtherSubPanels()}_menuItemKeyHandler(event){if("ArrowUp"===event.key||"ArrowDown"===event.key&&!this._needSmallSpaceBehaviour())return void this.setVisibility(!1);let focusPanel=!1;("ArrowRight"===event.key||"ArrowLeft"===event.key||"Tab"===event.key&&!event.shiftKey)&&(focusPanel=!0),"Enter"!==event.key&&" "!==event.key||(focusPanel=!0),"ArrowDown"===event.key&&this._needSmallSpaceBehaviour()&&this.getVisibility()&&(focusPanel=!0),focusPanel&&(event.stopPropagation(),event.preventDefault(),this.setVisibility(!0),this._focusPanelContent())}_panelContentKeyHandler(event){const canLoop=!this._needSmallSpaceBehaviour();let isBrowsingSubPanel=!1,newFocus=null;"ArrowRight"!==event.key&&"ArrowLeft"!==event.key||(newFocus=this.menuItem),("Escape"===event.key||"Tab"===event.key&&event.shiftKey)&&(newFocus=this.menuItem,this.setVisibility(!1),this.showPreviewOnFocus=!1),"ArrowUp"===event.key&&(newFocus=(0,_pagehelpers.previousFocusableElement)(this.panelContent,canLoop),isBrowsingSubPanel=!0),"ArrowDown"===event.key&&(newFocus=(0,_pagehelpers.nextFocusableElement)(this.panelContent,canLoop),isBrowsingSubPanel=!0),"Home"===event.key&&(newFocus=(0,_pagehelpers.firstFocusableElement)(this.panelContent),isBrowsingSubPanel=!0),"End"===event.key&&(newFocus=(0,_pagehelpers.lastFocusableElement)(this.panelContent),isBrowsingSubPanel=!0),null===newFocus&&isBrowsingSubPanel&&!canLoop&&(newFocus=this.menuItem),null!==newFocus&&(event.stopPropagation(),event.preventDefault(),newFocus.focus())}_focusPanelContent(){const pendingPromise=new _pending.default("core/action_menu/subpanel:focuscontent");setTimeout((()=>{const firstFocusable=(0,_pagehelpers.firstFocusableElement)(this.panelContent);firstFocusable&&firstFocusable.focus(),pendingPromise.resolve()}),100)}setVisibility(visible){visible&&this._hideOtherSubPanels(),!visible&&this.getVisibility&&(0,_aria.hide)(this.panelContent),visible&&!this.getVisibility&&(0,_aria.unhide)(this.panelContent),this.menuItem.setAttribute("aria-expanded",visible?"true":"false"),this.panelContent.classList.toggle("show",visible),this.element.classList.toggle(Classes_contentDisplayed,visible)}_hideOtherSubPanels(){this.element.closest(Selectors_mainMenu).querySelectorAll("".concat(Selectors_subPanelContent,".show")).forEach((visibleSubPanel=>{const dropdownSubPanel=visibleSubPanel.closest(Selectors_subPanel);if(dropdownSubPanel===this.element)return;new SubPanel(dropdownSubPanel).setVisibility(!1)}))}getVisibility(){return"true"===this.menuItem.getAttribute("aria-expanded")}updatePosition(){const dropdownRight=this._needDropdownRight();if(this._needSmallSpaceBehaviour())return this.element.classList.remove(Classes_dropRight),this.element.classList.remove(Classes_dropLeft),this.element.classList.add(Classes_dropDown),void this.element.classList.toggle(Classes_forceLeft,dropdownRight);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)}}_exports.init=selector=>{initialized||((0,_jquery.default)(document).on(BootstrapEvents_hideDropdown,(()=>{document.querySelectorAll("".concat(Selectors_subPanelContent,".show")).forEach((visibleSubPanel=>{const dropdownSubPanel=visibleSubPanel.closest(Selectors_subPanel);new SubPanel(dropdownSubPanel).setVisibility(!1)}))})),window.addEventListener("resize",(0,_utils.debounce)(updateAllPanelsPosition,400)),initialized=!0);const subMenu=document.querySelector(selector);if(!subMenu)throw new Error("Sub panel element not found: ".concat(selector));new SubPanel(subMenu).init()}})); //# sourceMappingURL=subpanel.min.js.map \ No newline at end of file diff --git a/lib/amd/build/local/action_menu/subpanel.min.js.map b/lib/amd/build/local/action_menu/subpanel.min.js.map index e6f5fc2eddf..5ad0592299f 100644 --- a/lib/amd/build/local/action_menu/subpanel.min.js.map +++ b/lib/amd/build/local/action_menu/subpanel.min.js.map @@ -1 +1 @@ -{"version":3,"file":"subpanel.min.js","sources":["../../../src/local/action_menu/subpanel.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Action menu subpanel JS controls.\n *\n * @module core/local/action_menu/subpanel\n * @copyright 2023 Mikel Martín \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport jQuery from 'jquery';\nimport {debounce} from 'core/utils';\nimport {\n isBehatSite,\n isExtraSmall,\n firstFocusableElement,\n previousFocusableElement,\n nextFocusableElement,\n} from 'core/pagehelpers';\nimport Pending from 'core/pending';\n\nconst Selectors = {\n mainMenu: '[role=\"menu\"]',\n dropdownRight: '.dropdown-menu-right',\n subPanel: '.dropdown-subpanel',\n subPanelMenuItem: '.dropdown-subpanel > .dropdown-item',\n subPanelContent: '.dropdown-subpanel > .dropdown-menu',\n // Drawer selector.\n drawer: '[data-region=\"fixed-drawer\"]',\n // Lateral blocks columns selectors.\n blockColumn: '.blockcolumn',\n columnLeft: '.columnleft',\n};\n\nconst Classes = {\n dropRight: 'dropright',\n dropLeft: 'dropleft',\n dropDown: 'dropdown',\n forceLeft: 'downleft',\n contentDisplayed: 'content-displayed',\n};\n\nconst BootstrapEvents = {\n hideDropdown: 'hidden.bs.dropdown',\n};\n\nlet initialized = false;\n\n/**\n * Initialize all delegated events into the page.\n */\nconst initPageEvents = () => {\n if (initialized) {\n return;\n }\n // Hide all subpanels when hidind a dropdown.\n // This is using JQuery because of BS4 events. JQuery won't be needed with BS5.\n jQuery(document).on(BootstrapEvents.hideDropdown, () => {\n document.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {\n const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);\n const subPanel = new SubPanel(dropdownSubPanel);\n subPanel.setVisibility(false);\n });\n });\n\n window.addEventListener('resize', debounce(updateAllPanelsPosition, 400));\n\n initialized = true;\n};\n\n/**\n * Update all the panels position.\n */\nconst updateAllPanelsPosition = () => {\n document.querySelectorAll(Selectors.subPanel).forEach(dropdown => {\n const subpanel = new SubPanel(dropdown);\n subpanel.updatePosition();\n });\n};\n\n/**\n * Subpanel class.\n * @private\n */\nclass SubPanel {\n /**\n * Constructor.\n * @param {HTMLElement} element The element to initialize.\n */\n constructor(element) {\n this.element = element;\n this.menuItem = element.querySelector(Selectors.subPanelMenuItem);\n this.panelContent = element.querySelector(Selectors.subPanelContent);\n }\n\n /**\n * Initialize the subpanel element.\n *\n * This method adds the event listeners to the subpanel and the position classes.\n */\n init() {\n if (this.element.dataset.subPanelInitialized) {\n return;\n }\n\n this.updatePosition();\n\n // Full element events.\n this.element.addEventListener('focusin', this._mainElementFocusInHandler.bind(this));\n // Menu Item events.\n this.menuItem.addEventListener('click', this._menuItemClickHandler.bind(this));\n this.menuItem.addEventListener('keydown', this._menuItemKeyHandler.bind(this));\n if (!isBehatSite()) {\n // Behat in Chrome usually move the mouse over the page when trying clicking a subpanel element.\n // If the menu has more than one subpanel this could cause closing the subpanel by mistake.\n this.menuItem.addEventListener('mouseover', this._menuItemHoverHandler.bind(this));\n this.menuItem.addEventListener('mouseout', this._menuItemHoverOutHandler.bind(this));\n }\n // Subpanel content events.\n this.panelContent.addEventListener('keydown', this._panelContentKeyHandler.bind(this));\n\n this.element.dataset.subPanelInitialized = true;\n }\n\n /**\n * Checks if the subpanel has enough space.\n *\n * In general there are two scenarios were the subpanel must be interacted differently:\n * - Extra small screens: The subpanel is displayed below the menu item.\n * - Drawer: The subpanel is displayed one of the drawers.\n * - Block columns: for classic based themes.\n *\n * @returns {Boolean} true if the subpanel should be displayed in small screens.\n */\n _needSmallSpaceBehaviour() {\n return isExtraSmall() ||\n this.element.closest(Selectors.drawer) !== null ||\n this.element.closest(Selectors.blockColumn) !== null;\n }\n\n /**\n * Check if the subpanel should be displayed on the right.\n *\n * This is defined by the drop right boostrap class. However, if the menu is\n * displayed in a block column on the right, the subpanel should be forced\n * to the right.\n *\n * @returns {Boolean} true if the subpanel should be displayed on the right.\n */\n _needDropdownRight() {\n if (this.element.closest(Selectors.columnLeft) !== null) {\n return false;\n }\n return this.element.closest(Selectors.dropdownRight) !== null;\n }\n\n /**\n * Main element focus in handler.\n */\n _mainElementFocusInHandler() {\n if (this._needSmallSpaceBehaviour()) {\n return;\n }\n this.setVisibility(true);\n }\n\n /**\n * Menu item click handler.\n * @param {Event} event\n */\n _menuItemClickHandler(event) {\n // Avoid dropdowns being closed after clicking a subemnu.\n // This won't be needed with BS5 (data-bs-auto-close handles it).\n event.stopPropagation();\n event.preventDefault();\n if (this._needSmallSpaceBehaviour()) {\n this.setVisibility(!this.getVisibility());\n }\n }\n\n /**\n * Menu item hover handler.\n * @private\n */\n _menuItemHoverHandler() {\n if (this._needSmallSpaceBehaviour()) {\n return;\n }\n this.setVisibility(true);\n }\n\n /**\n * Menu item hover out handler.\n * @private\n */\n _menuItemHoverOutHandler() {\n if (this._needSmallSpaceBehaviour()) {\n return;\n }\n this._hideOtherSubPanels();\n }\n\n /**\n * Menu item key handler.\n * @param {Event} event\n * @private\n */\n _menuItemKeyHandler(event) {\n // In small sizes te down key will focus on the panel.\n if (event.key === 'ArrowUp' || (event.key === 'ArrowDown' && !this._needSmallSpaceBehaviour())) {\n this.setVisibility(false);\n return;\n }\n\n // Keys to move focus to the panel.\n let focusPanel = false;\n\n if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {\n focusPanel = true;\n }\n if ((event.key === 'Enter' || event.key === ' ') && !this._needSmallSpaceBehaviour()) {\n focusPanel = true;\n }\n // In extra small screen the panel is shown below the item.\n if (event.key === 'ArrowDown' && this._needSmallSpaceBehaviour() && this.getVisibility()) {\n focusPanel = true;\n }\n if (focusPanel) {\n event.stopPropagation();\n event.preventDefault();\n this.setVisibility(true);\n this._focusPanelContent();\n }\n\n }\n\n /**\n * Sub panel content key handler.\n * @param {Event} event\n * @private\n */\n _panelContentKeyHandler(event) {\n // In extra small devices the panel is displayed under the menu item\n // so the arrow up/down switch between subpanel and the menu item.\n const canLoop = !this._needSmallSpaceBehaviour();\n let isBrowsingSubPanel = false;\n let newFocus = null;\n if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {\n newFocus = this.menuItem;\n }\n if (event.key === 'ArrowUp') {\n newFocus = previousFocusableElement(this.panelContent, canLoop);\n isBrowsingSubPanel = true;\n }\n if (event.key === 'ArrowDown') {\n newFocus = nextFocusableElement(this.panelContent, canLoop);\n isBrowsingSubPanel = true;\n }\n // If the user cannot loop and arrive to the start/end of the subpanel\n // we focus on the menu item.\n if (newFocus === null && isBrowsingSubPanel && !canLoop) {\n newFocus = this.menuItem;\n }\n if (newFocus !== null) {\n event.stopPropagation();\n event.preventDefault();\n newFocus.focus();\n }\n }\n\n /**\n * Focus on the first focusable element of the subpanel.\n * @private\n */\n _focusPanelContent() {\n const pendingPromise = new Pending('core/action_menu/subpanel:focuscontent');\n // Some Bootstrap events are triggered after the click event.\n // To prevent this from affecting the focus we wait a bit.\n setTimeout(() => {\n const firstFocusable = firstFocusableElement(this.panelContent);\n if (firstFocusable) {\n firstFocusable.focus();\n }\n pendingPromise.resolve();\n }, 100);\n }\n\n /**\n * Set the visibility of a subpanel.\n * @param {Boolean} visible true if the subpanel should be visible.\n */\n setVisibility(visible) {\n if (visible) {\n this._hideOtherSubPanels();\n }\n this.menuItem.setAttribute('aria-expanded', visible ? 'true' : 'false');\n this.panelContent.classList.toggle('show', visible);\n this.element.classList.toggle(Classes.contentDisplayed, visible);\n }\n\n /**\n * Hide all other subpanels in the parent menu.\n * @private\n */\n _hideOtherSubPanels() {\n const dropdown = this.element.closest(Selectors.mainMenu);\n dropdown.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {\n const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);\n if (dropdownSubPanel === this.element) {\n return;\n }\n const subPanel = new SubPanel(dropdownSubPanel);\n subPanel.setVisibility(false);\n });\n }\n\n /**\n * Get the visibility of a subpanel.\n * @returns {Boolean} true if the subpanel is visible.\n */\n getVisibility() {\n return this.menuItem.getAttribute('aria-expanded') === 'true';\n }\n\n /**\n * Update the panels position depending on the screen size and panel position.\n */\n updatePosition() {\n const dropdownRight = this._needDropdownRight();\n if (this._needSmallSpaceBehaviour()) {\n this.element.classList.remove(Classes.dropRight);\n this.element.classList.remove(Classes.dropLeft);\n this.element.classList.add(Classes.dropDown);\n this.element.classList.toggle(Classes.forceLeft, dropdownRight);\n return;\n }\n this.element.classList.remove(Classes.dropDown);\n this.element.classList.remove(Classes.forceLeft);\n this.element.classList.toggle(Classes.dropRight, !dropdownRight);\n this.element.classList.toggle(Classes.dropLeft, dropdownRight);\n }\n}\n\n/**\n * Initialise module for given report\n *\n * @method\n * @param {string} selector The query selector to init.\n */\nexport const init = (selector) => {\n initPageEvents();\n const subMenu = document.querySelector(selector);\n if (!subMenu) {\n throw new Error(`Sub panel element not found: ${selector}`);\n }\n const subPanel = new SubPanel(subMenu);\n subPanel.init();\n};\n"],"names":["Selectors","Classes","BootstrapEvents","initialized","updateAllPanelsPosition","document","querySelectorAll","forEach","dropdown","SubPanel","updatePosition","constructor","element","menuItem","querySelector","panelContent","init","this","dataset","subPanelInitialized","addEventListener","_mainElementFocusInHandler","bind","_menuItemClickHandler","_menuItemKeyHandler","_menuItemHoverHandler","_menuItemHoverOutHandler","_panelContentKeyHandler","_needSmallSpaceBehaviour","closest","_needDropdownRight","setVisibility","event","stopPropagation","preventDefault","getVisibility","_hideOtherSubPanels","key","focusPanel","_focusPanelContent","canLoop","isBrowsingSubPanel","newFocus","focus","pendingPromise","Pending","setTimeout","firstFocusable","resolve","visible","setAttribute","classList","toggle","visibleSubPanel","dropdownSubPanel","getAttribute","dropdownRight","remove","add","selector","on","window","subMenu","Error"],"mappings":";;;;;;;0KAkCMA,mBACQ,gBADRA,wBAEa,uBAFbA,mBAGQ,qBAHRA,2BAIgB,sCAJhBA,0BAKe,sCALfA,iBAOM,+BAPNA,sBASW,eATXA,qBAUU,cAGVC,kBACS,YADTA,iBAEQ,WAFRA,iBAGQ,WAHRA,kBAIS,WAJTA,yBAKgB,oBAGhBC,6BACY,yBAGdC,aAAc,QA2BZC,wBAA0B,KAC5BC,SAASC,iBAAiBN,oBAAoBO,SAAQC,WACjC,IAAIC,SAASD,UACrBE,2BAQXD,SAKFE,YAAYC,cACHA,QAAUA,aACVC,SAAWD,QAAQE,cAAcd,iCACjCe,aAAeH,QAAQE,cAAcd,2BAQ9CgB,OACQC,KAAKL,QAAQM,QAAQC,2BAIpBT,sBAGAE,QAAQQ,iBAAiB,UAAWH,KAAKI,2BAA2BC,KAAKL,YAEzEJ,SAASO,iBAAiB,QAASH,KAAKM,sBAAsBD,KAAKL,YACnEJ,SAASO,iBAAiB,UAAWH,KAAKO,oBAAoBF,KAAKL,QACnE,qCAGIJ,SAASO,iBAAiB,YAAaH,KAAKQ,sBAAsBH,KAAKL,YACvEJ,SAASO,iBAAiB,WAAYH,KAAKS,yBAAyBJ,KAAKL,aAG7EF,aAAaK,iBAAiB,UAAWH,KAAKU,wBAAwBL,KAAKL,YAE3EL,QAAQM,QAAQC,qBAAsB,GAa/CS,kCACW,gCACwC,OAA3CX,KAAKL,QAAQiB,QAAQ7B,mBAC2B,OAAhDiB,KAAKL,QAAQiB,QAAQ7B,uBAY7B8B,4BACuD,OAA/Cb,KAAKL,QAAQiB,QAAQ7B,uBAGgC,OAAlDiB,KAAKL,QAAQiB,QAAQ7B,yBAMhCqB,6BACQJ,KAAKW,iCAGJG,eAAc,GAOvBR,sBAAsBS,OAGlBA,MAAMC,kBACND,MAAME,iBACFjB,KAAKW,iCACAG,eAAed,KAAKkB,iBAQjCV,wBACQR,KAAKW,iCAGJG,eAAc,GAOvBL,2BACQT,KAAKW,iCAGJQ,sBAQTZ,oBAAoBQ,UAEE,YAAdA,MAAMK,KAAoC,cAAdL,MAAMK,MAAwBpB,KAAKW,4CAC1DG,eAAc,OAKnBO,YAAa,EAEC,eAAdN,MAAMK,KAAsC,cAAdL,MAAMK,MACpCC,YAAa,GAEE,UAAdN,MAAMK,KAAiC,MAAdL,MAAMK,KAAiBpB,KAAKW,6BACtDU,YAAa,GAGC,cAAdN,MAAMK,KAAuBpB,KAAKW,4BAA8BX,KAAKkB,kBACrEG,YAAa,GAEbA,aACAN,MAAMC,kBACND,MAAME,sBACDH,eAAc,QACdQ,sBAUbZ,wBAAwBK,aAGdQ,SAAWvB,KAAKW,+BAClBa,oBAAqB,EACrBC,SAAW,KACG,eAAdV,MAAMK,KAAsC,cAAdL,MAAMK,MACpCK,SAAWzB,KAAKJ,UAEF,YAAdmB,MAAMK,MACNK,UAAW,yCAAyBzB,KAAKF,aAAcyB,SACvDC,oBAAqB,GAEP,cAAdT,MAAMK,MACNK,UAAW,qCAAqBzB,KAAKF,aAAcyB,SACnDC,oBAAqB,GAIR,OAAbC,UAAqBD,qBAAuBD,UAC5CE,SAAWzB,KAAKJ,UAEH,OAAb6B,WACAV,MAAMC,kBACND,MAAME,iBACNQ,SAASC,SAQjBJ,2BACUK,eAAiB,IAAIC,iBAAQ,0CAGnCC,YAAW,WACDC,gBAAiB,sCAAsB9B,KAAKF,cAC9CgC,gBACAA,eAAeJ,QAEnBC,eAAeI,YAChB,KAOPjB,cAAckB,SACNA,cACKb,2BAEJvB,SAASqC,aAAa,gBAAiBD,QAAU,OAAS,cAC1DlC,aAAaoC,UAAUC,OAAO,OAAQH,cACtCrC,QAAQuC,UAAUC,OAAOnD,yBAA0BgD,SAO5Db,sBACqBnB,KAAKL,QAAQiB,QAAQ7B,oBAC7BM,2BAAoBN,oCAAkCO,SAAQ8C,wBAC7DC,iBAAmBD,gBAAgBxB,QAAQ7B,uBAC7CsD,mBAAqBrC,KAAKL,eAGb,IAAIH,SAAS6C,kBACrBvB,eAAc,MAQ/BI,sBAC2D,SAAhDlB,KAAKJ,SAAS0C,aAAa,iBAMtC7C,uBACU8C,cAAgBvC,KAAKa,wBACvBb,KAAKW,uCACAhB,QAAQuC,UAAUM,OAAOxD,wBACzBW,QAAQuC,UAAUM,OAAOxD,uBACzBW,QAAQuC,UAAUO,IAAIzD,4BACtBW,QAAQuC,UAAUC,OAAOnD,kBAAmBuD,oBAGhD5C,QAAQuC,UAAUM,OAAOxD,uBACzBW,QAAQuC,UAAUM,OAAOxD,wBACzBW,QAAQuC,UAAUC,OAAOnD,mBAAoBuD,oBAC7C5C,QAAQuC,UAAUC,OAAOnD,iBAAkBuD,8BAUnCG,WAzSbxD,kCAKGE,UAAUuD,GAAG1D,8BAA8B,KAC9CG,SAASC,2BAAoBN,oCAAkCO,SAAQ8C,wBAC7DC,iBAAmBD,gBAAgBxB,QAAQ7B,oBAChC,IAAIS,SAAS6C,kBACrBvB,eAAc,SAI/B8B,OAAOzC,iBAAiB,UAAU,mBAAShB,wBAAyB,MAEpED,aAAc,SA4RR2D,QAAUzD,SAASS,cAAc6C,cAClCG,cACK,IAAIC,6CAAsCJ,WAEnC,IAAIlD,SAASqD,SACrB9C"} \ No newline at end of file +{"version":3,"file":"subpanel.min.js","sources":["../../../src/local/action_menu/subpanel.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Action menu subpanel JS controls.\n *\n * @module core/local/action_menu/subpanel\n * @copyright 2023 Mikel Martín \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport jQuery from 'jquery';\nimport {debounce} from 'core/utils';\nimport {\n isBehatSite,\n isExtraSmall,\n firstFocusableElement,\n lastFocusableElement,\n previousFocusableElement,\n nextFocusableElement,\n} from 'core/pagehelpers';\nimport Pending from 'core/pending';\nimport {\n hide,\n unhide,\n} from 'core/aria';\n\nconst Selectors = {\n mainMenu: '[role=\"menu\"]',\n dropdownRight: '.dropdown-menu-right',\n subPanel: '.dropdown-subpanel',\n subPanelMenuItem: '.dropdown-subpanel > .dropdown-item',\n subPanelContent: '.dropdown-subpanel > .dropdown-menu',\n // Drawer selector.\n drawer: '[data-region=\"fixed-drawer\"]',\n // Lateral blocks columns selectors.\n blockColumn: '.blockcolumn',\n columnLeft: '.columnleft',\n};\n\nconst Classes = {\n dropRight: 'dropright',\n dropLeft: 'dropleft',\n dropDown: 'dropdown',\n forceLeft: 'downleft',\n contentDisplayed: 'content-displayed',\n};\n\nconst BootstrapEvents = {\n hideDropdown: 'hidden.bs.dropdown',\n};\n\nlet initialized = false;\n\n/**\n * Initialize all delegated events into the page.\n */\nconst initPageEvents = () => {\n if (initialized) {\n return;\n }\n // Hide all subpanels when hidind a dropdown.\n // This is using JQuery because of BS4 events. JQuery won't be needed with BS5.\n jQuery(document).on(BootstrapEvents.hideDropdown, () => {\n document.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {\n const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);\n const subPanel = new SubPanel(dropdownSubPanel);\n subPanel.setVisibility(false);\n });\n });\n\n window.addEventListener('resize', debounce(updateAllPanelsPosition, 400));\n\n initialized = true;\n};\n\n/**\n * Update all the panels position.\n */\nconst updateAllPanelsPosition = () => {\n document.querySelectorAll(Selectors.subPanel).forEach(dropdown => {\n const subpanel = new SubPanel(dropdown);\n subpanel.updatePosition();\n });\n};\n\n/**\n * Subpanel class.\n * @private\n */\nclass SubPanel {\n /**\n * Constructor.\n * @param {HTMLElement} element The element to initialize.\n */\n constructor(element) {\n this.element = element;\n this.menuItem = element.querySelector(Selectors.subPanelMenuItem);\n this.panelContent = element.querySelector(Selectors.subPanelContent);\n /**\n * Enable preview when the menu item has focus.\n *\n * This is disabled when the user press ESC or shift+TAB to force closing\n *\n * @type {Boolean}\n * @private\n */\n this.showPreviewOnFocus = true;\n }\n\n /**\n * Initialize the subpanel element.\n *\n * This method adds the event listeners to the subpanel and the position classes.\n */\n init() {\n if (this.element.dataset.subPanelInitialized) {\n return;\n }\n\n this.updatePosition();\n\n // Full element events.\n this.element.addEventListener('focusin', this._mainElementFocusInHandler.bind(this));\n // Menu Item events.\n this.menuItem.addEventListener('click', this._menuItemClickHandler.bind(this));\n this.menuItem.addEventListener('keydown', this._menuItemKeyHandler.bind(this));\n if (!isBehatSite()) {\n // Behat in Chrome usually move the mouse over the page when trying clicking a subpanel element.\n // If the menu has more than one subpanel this could cause closing the subpanel by mistake.\n this.menuItem.addEventListener('mouseover', this._menuItemHoverHandler.bind(this));\n this.menuItem.addEventListener('mouseout', this._menuItemHoverOutHandler.bind(this));\n }\n // Subpanel content events.\n this.panelContent.addEventListener('keydown', this._panelContentKeyHandler.bind(this));\n\n this.element.dataset.subPanelInitialized = true;\n }\n\n /**\n * Checks if the subpanel has enough space.\n *\n * In general there are two scenarios were the subpanel must be interacted differently:\n * - Extra small screens: The subpanel is displayed below the menu item.\n * - Drawer: The subpanel is displayed one of the drawers.\n * - Block columns: for classic based themes.\n *\n * @returns {Boolean} true if the subpanel should be displayed in small screens.\n */\n _needSmallSpaceBehaviour() {\n return isExtraSmall() ||\n this.element.closest(Selectors.drawer) !== null ||\n this.element.closest(Selectors.blockColumn) !== null;\n }\n\n /**\n * Check if the subpanel should be displayed on the right.\n *\n * This is defined by the drop right boostrap class. However, if the menu is\n * displayed in a block column on the right, the subpanel should be forced\n * to the right.\n *\n * @returns {Boolean} true if the subpanel should be displayed on the right.\n */\n _needDropdownRight() {\n if (this.element.closest(Selectors.columnLeft) !== null) {\n return false;\n }\n return this.element.closest(Selectors.dropdownRight) !== null;\n }\n\n /**\n * Main element focus in handler.\n */\n _mainElementFocusInHandler() {\n if (this._needSmallSpaceBehaviour() || !this.showPreviewOnFocus) {\n // Preview is disabled when the user press ESC or shift+TAB to force closing\n // but if the continue navigating with keyboard the preview is enabled again.\n this.showPreviewOnFocus = true;\n return;\n }\n this.setVisibility(true);\n }\n\n /**\n * Menu item click handler.\n * @param {Event} event\n */\n _menuItemClickHandler(event) {\n // Avoid dropdowns being closed after clicking a subemnu.\n // This won't be needed with BS5 (data-bs-auto-close handles it).\n event.stopPropagation();\n event.preventDefault();\n if (this._needSmallSpaceBehaviour()) {\n this.setVisibility(!this.getVisibility());\n }\n }\n\n /**\n * Menu item hover handler.\n * @private\n */\n _menuItemHoverHandler() {\n if (this._needSmallSpaceBehaviour()) {\n return;\n }\n this.setVisibility(true);\n }\n\n /**\n * Menu item hover out handler.\n * @private\n */\n _menuItemHoverOutHandler() {\n if (this._needSmallSpaceBehaviour()) {\n return;\n }\n this._hideOtherSubPanels();\n }\n\n /**\n * Menu item key handler.\n * @param {Event} event\n * @private\n */\n _menuItemKeyHandler(event) {\n // In small sizes te down key will focus on the panel.\n if (event.key === 'ArrowUp' || (event.key === 'ArrowDown' && !this._needSmallSpaceBehaviour())) {\n this.setVisibility(false);\n return;\n }\n\n // Keys to move focus to the panel.\n let focusPanel = false;\n\n if (event.key === 'ArrowRight' || event.key === 'ArrowLeft' || (event.key === 'Tab' && !event.shiftKey)) {\n focusPanel = true;\n }\n if ((event.key === 'Enter' || event.key === ' ')) {\n focusPanel = true;\n }\n // In extra small screen the panel is shown below the item.\n if (event.key === 'ArrowDown' && this._needSmallSpaceBehaviour() && this.getVisibility()) {\n focusPanel = true;\n }\n if (focusPanel) {\n event.stopPropagation();\n event.preventDefault();\n this.setVisibility(true);\n this._focusPanelContent();\n }\n\n }\n\n /**\n * Sub panel content key handler.\n * @param {Event} event\n * @private\n */\n _panelContentKeyHandler(event) {\n // In extra small devices the panel is displayed under the menu item\n // so the arrow up/down switch between subpanel and the menu item.\n const canLoop = !this._needSmallSpaceBehaviour();\n let isBrowsingSubPanel = false;\n let newFocus = null;\n if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {\n newFocus = this.menuItem;\n }\n // Acording to WCAG Esc and Tab are similar to arrow navigation but they\n // force the subpanel to be closed.\n if (event.key === 'Escape' || (event.key === 'Tab' && event.shiftKey)) {\n newFocus = this.menuItem;\n this.setVisibility(false);\n this.showPreviewOnFocus = false;\n }\n if (event.key === 'ArrowUp') {\n newFocus = previousFocusableElement(this.panelContent, canLoop);\n isBrowsingSubPanel = true;\n }\n if (event.key === 'ArrowDown') {\n newFocus = nextFocusableElement(this.panelContent, canLoop);\n isBrowsingSubPanel = true;\n }\n if (event.key === 'Home') {\n newFocus = firstFocusableElement(this.panelContent);\n isBrowsingSubPanel = true;\n }\n if (event.key === 'End') {\n newFocus = lastFocusableElement(this.panelContent);\n isBrowsingSubPanel = true;\n }\n // If the user cannot loop and arrive to the start/end of the subpanel\n // we focus on the menu item.\n if (newFocus === null && isBrowsingSubPanel && !canLoop) {\n newFocus = this.menuItem;\n }\n if (newFocus !== null) {\n event.stopPropagation();\n event.preventDefault();\n newFocus.focus();\n }\n }\n\n /**\n * Focus on the first focusable element of the subpanel.\n * @private\n */\n _focusPanelContent() {\n const pendingPromise = new Pending('core/action_menu/subpanel:focuscontent');\n // Some Bootstrap events are triggered after the click event.\n // To prevent this from affecting the focus we wait a bit.\n setTimeout(() => {\n const firstFocusable = firstFocusableElement(this.panelContent);\n if (firstFocusable) {\n firstFocusable.focus();\n }\n pendingPromise.resolve();\n }, 100);\n }\n\n /**\n * Set the visibility of a subpanel.\n * @param {Boolean} visible true if the subpanel should be visible.\n */\n setVisibility(visible) {\n if (visible) {\n this._hideOtherSubPanels();\n }\n // Aria hidden/unhidden can alter the focus, we only want to do it when needed.\n if (!visible && this.getVisibility) {\n hide(this.panelContent);\n }\n if (visible && !this.getVisibility) {\n unhide(this.panelContent);\n }\n this.menuItem.setAttribute('aria-expanded', visible ? 'true' : 'false');\n this.panelContent.classList.toggle('show', visible);\n this.element.classList.toggle(Classes.contentDisplayed, visible);\n }\n\n /**\n * Hide all other subpanels in the parent menu.\n * @private\n */\n _hideOtherSubPanels() {\n const dropdown = this.element.closest(Selectors.mainMenu);\n dropdown.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {\n const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);\n if (dropdownSubPanel === this.element) {\n return;\n }\n const subPanel = new SubPanel(dropdownSubPanel);\n subPanel.setVisibility(false);\n });\n }\n\n /**\n * Get the visibility of a subpanel.\n * @returns {Boolean} true if the subpanel is visible.\n */\n getVisibility() {\n return this.menuItem.getAttribute('aria-expanded') === 'true';\n }\n\n /**\n * Update the panels position depending on the screen size and panel position.\n */\n updatePosition() {\n const dropdownRight = this._needDropdownRight();\n if (this._needSmallSpaceBehaviour()) {\n this.element.classList.remove(Classes.dropRight);\n this.element.classList.remove(Classes.dropLeft);\n this.element.classList.add(Classes.dropDown);\n this.element.classList.toggle(Classes.forceLeft, dropdownRight);\n return;\n }\n this.element.classList.remove(Classes.dropDown);\n this.element.classList.remove(Classes.forceLeft);\n this.element.classList.toggle(Classes.dropRight, !dropdownRight);\n this.element.classList.toggle(Classes.dropLeft, dropdownRight);\n }\n}\n\n/**\n * Initialise module for given report\n *\n * @method\n * @param {string} selector The query selector to init.\n */\nexport const init = (selector) => {\n initPageEvents();\n const subMenu = document.querySelector(selector);\n if (!subMenu) {\n throw new Error(`Sub panel element not found: ${selector}`);\n }\n const subPanel = new SubPanel(subMenu);\n subPanel.init();\n};\n"],"names":["Selectors","Classes","BootstrapEvents","initialized","updateAllPanelsPosition","document","querySelectorAll","forEach","dropdown","SubPanel","updatePosition","constructor","element","menuItem","querySelector","panelContent","showPreviewOnFocus","init","this","dataset","subPanelInitialized","addEventListener","_mainElementFocusInHandler","bind","_menuItemClickHandler","_menuItemKeyHandler","_menuItemHoverHandler","_menuItemHoverOutHandler","_panelContentKeyHandler","_needSmallSpaceBehaviour","closest","_needDropdownRight","setVisibility","event","stopPropagation","preventDefault","getVisibility","_hideOtherSubPanels","key","focusPanel","shiftKey","_focusPanelContent","canLoop","isBrowsingSubPanel","newFocus","focus","pendingPromise","Pending","setTimeout","firstFocusable","resolve","visible","setAttribute","classList","toggle","visibleSubPanel","dropdownSubPanel","getAttribute","dropdownRight","remove","add","selector","on","window","subMenu","Error"],"mappings":"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"} \ No newline at end of file diff --git a/lib/amd/src/local/action_menu/subpanel.js b/lib/amd/src/local/action_menu/subpanel.js index e380af094ca..1c1e5821cec 100644 --- a/lib/amd/src/local/action_menu/subpanel.js +++ b/lib/amd/src/local/action_menu/subpanel.js @@ -27,10 +27,15 @@ 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"]', @@ -104,6 +109,15 @@ class SubPanel { 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; } /** @@ -171,7 +185,10 @@ class SubPanel { * Main element focus in handler. */ _mainElementFocusInHandler() { - if (this._needSmallSpaceBehaviour()) { + 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); @@ -228,10 +245,10 @@ class SubPanel { // Keys to move focus to the panel. let focusPanel = false; - if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft' || (event.key === 'Tab' && !event.shiftKey)) { focusPanel = true; } - if ((event.key === 'Enter' || event.key === ' ') && !this._needSmallSpaceBehaviour()) { + if ((event.key === 'Enter' || event.key === ' ')) { focusPanel = true; } // In extra small screen the panel is shown below the item. @@ -261,6 +278,13 @@ class SubPanel { 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; @@ -269,6 +293,14 @@ class SubPanel { 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) { @@ -306,6 +338,13 @@ class SubPanel { 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); diff --git a/lib/tests/behat/action_menu_subpanel.feature b/lib/tests/behat/action_menu_subpanel.feature index 4709611ada6..f720c5f8cba 100644 --- a/lib/tests/behat/action_menu_subpanel.feature +++ b/lib/tests/behat/action_menu_subpanel.feature @@ -139,10 +139,9 @@ Feature: Navigate action menu subpanels And I should not see "Status B" in the "regularscenario" "region" And I should see "Status C" in the "regularscenario" "region" And I should see "Status D" in the "regularscenario" "region" + And the focused element is "Status C" "link" in the "regularscenario" "region" # Loop the subpanel links wand the menu item with up and down. Then I press the down key - And the focused element is "Status C" "link" in the "regularscenario" "region" - And I press the down key And the focused element is "Status D" "link" in the "regularscenario" "region" And I press the down key And the focused element is "Another subpanel" "menuitem" in the "regularscenario" "region" @@ -164,9 +163,9 @@ Feature: Navigate action menu subpanels # Enter the panel and select the second link. And I press the enter key And I press the down key - And the focused element is "Status A" "link" in the "regularscenario" "region" + And the focused element is "Status B" "link" in the "regularscenario" "region" And I press the enter key - And I should see "Foo param value: Aardvark" in the "paramcheck" "region" + And I should see "Foo param value: Beetle" in the "paramcheck" "region" Scenario: action menu subpanels can display optional icons in the menu item Given I click on "Actions menu" "button" in the "regularscenario" "region" @@ -196,10 +195,9 @@ Feature: Navigate action menu subpanels And I should not see "Status B" in the "drawersimulation" "region" And I should see "Status C" in the "drawersimulation" "region" And I should see "Status D" in the "drawersimulation" "region" + And the focused element is "Status C" "link" in the "drawersimulation" "region" # Loop the subpanel links wand the menu item with up and down. Then I press the down key - And the focused element is "Status C" "link" in the "drawersimulation" "region" - And I press the down key And the focused element is "Status D" "link" in the "drawersimulation" "region" And I press the down key And the focused element is "Another subpanel" "menuitem" in the "drawersimulation" "region" @@ -222,6 +220,30 @@ Feature: Navigate action menu subpanels # Enter the panel and select the second link. And I press the enter key And I press the down key - And the focused element is "Status A" "link" in the "drawersimulation" "region" + And the focused element is "Status B" "link" in the "drawersimulation" "region" And I press the enter key - And I should see "Foo param value: Aardvark" in the "paramcheck" "region" + And I should see "Foo param value: Beetle" in the "paramcheck" "region" + + Scenario: User can browse the menu using the WCAG recommended compount keyboard navigation + Given I click on "Actions menu" "button" in the "regularscenario" "region" + And I press the down key + And I press the down key + And I press the down key + And the focused element is "Subpanel example" "menuitem" in the "regularscenario" "region" + When I press the tab key + And the focused element is "Status A" "link" in the "regularscenario" "region" + And I should see "Status A" in the "regularscenario" "region" + And I should see "Status B" in the "regularscenario" "region" + And I press the down key + Then I press the shift tab key + And the focused element is "Subpanel example" "menuitem" in the "regularscenario" "region" + And I should not see "Status A" in the "regularscenario" "region" + And I should not see "Status B" in the "regularscenario" "region" + And I press the enter key + And the focused element is "Status A" "link" in the "regularscenario" "region" + And I should see "Status A" in the "regularscenario" "region" + And I should see "Status B" in the "regularscenario" "region" + And I press the escape key + And the focused element is "Subpanel example" "menuitem" in the "regularscenario" "region" + And I should not see "Status A" in the "regularscenario" "region" + And I should not see "Status B" in the "regularscenario" "region"