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

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * Action menu subpanel JS controls.
  17. *
  18. * @module core/local/action_menu/subpanel
  19. * @copyright 2023 Mikel Martín <mikel@moodle.com>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import {debounce} from 'core/utils';
  23. import {
  24. isBehatSite,
  25. isExtraSmall,
  26. firstFocusableElement,
  27. lastFocusableElement,
  28. previousFocusableElement,
  29. nextFocusableElement,
  30. } from 'core/pagehelpers';
  31. import Pending from 'core/pending';
  32. import {
  33. hide,
  34. unhide,
  35. } from 'core/aria';
  36. import EventHandler from 'theme_boost/bootstrap/dom/event-handler';
  37. const Selectors = {
  38. mainMenu: '[role="menu"]',
  39. dropdownRight: '.dropdown-menu-end',
  40. subPanel: '.dropdown-subpanel',
  41. subPanelMenuItem: '.dropdown-subpanel > .dropdown-item',
  42. subPanelContent: '.dropdown-subpanel > .dropdown-menu',
  43. // Drawer selector.
  44. drawer: '[data-region="fixed-drawer"]',
  45. // Lateral blocks columns selectors.
  46. blockColumn: '.blockcolumn',
  47. columnLeft: '.columnleft',
  48. };
  49. const Classes = {
  50. dropRight: 'dropend',
  51. dropLeft: 'dropstart',
  52. dropDown: 'dropdown',
  53. forceLeft: 'downleft',
  54. contentDisplayed: 'content-displayed',
  55. };
  56. const BootstrapEvents = {
  57. hideDropdown: 'hidden.bs.dropdown',
  58. };
  59. let initialized = false;
  60. /**
  61. * Initialize all delegated events into the page.
  62. */
  63. const initPageEvents = () => {
  64. if (initialized) {
  65. return;
  66. }
  67. // Hide all subpanels when hiding a dropdown.
  68. document.addEventListener(BootstrapEvents.hideDropdown, () => {
  69. document.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
  70. const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
  71. const subPanel = new SubPanel(dropdownSubPanel);
  72. subPanel.setVisibility(false);
  73. });
  74. });
  75. window.addEventListener('resize', debounce(updateAllPanelsPosition, 400));
  76. initialized = true;
  77. };
  78. /**
  79. * Update all the panels position.
  80. */
  81. const updateAllPanelsPosition = () => {
  82. document.querySelectorAll(Selectors.subPanel).forEach(dropdown => {
  83. const subpanel = new SubPanel(dropdown);
  84. subpanel.updatePosition();
  85. });
  86. };
  87. /**
  88. * Subpanel class.
  89. * @private
  90. */
  91. class SubPanel {
  92. /**
  93. * Constructor.
  94. * @param {HTMLElement} element The element to initialize.
  95. */
  96. constructor(element) {
  97. this.element = element;
  98. this.menuItem = element.querySelector(Selectors.subPanelMenuItem);
  99. this.panelContent = element.querySelector(Selectors.subPanelContent);
  100. /**
  101. * Enable preview when the menu item has focus.
  102. *
  103. * This is disabled when the user press ESC or shift+TAB to force closing
  104. *
  105. * @type {Boolean}
  106. * @private
  107. */
  108. this.showPreviewOnFocus = true;
  109. }
  110. /**
  111. * Initialize the subpanel element.
  112. *
  113. * This method adds the event listeners to the subpanel and the position classes.
  114. */
  115. init() {
  116. if (this.element.dataset.subPanelInitialized) {
  117. return;
  118. }
  119. this.updatePosition();
  120. // Full element events.
  121. this.element.addEventListener('focusin', this._mainElementFocusInHandler.bind(this));
  122. // Menu Item events.
  123. this.menuItem.addEventListener('click', this._menuItemClickHandler.bind(this));
  124. // Use the Bootstrap key handler for the menu item key handler.
  125. // This will avoid Boostrap Dropdown handler to prevent the propagation to the subpanel.
  126. const subpanelMenuItemSelector = `#${this.element.id}${Selectors.subPanelMenuItem}`;
  127. EventHandler.on(document, 'keydown', subpanelMenuItemSelector, this._menuItemKeyHandler.bind(this));
  128. if (!isBehatSite()) {
  129. // Behat in Chrome usually move the mouse over the page when trying clicking a subpanel element.
  130. // If the menu has more than one subpanel this could cause closing the subpanel by mistake.
  131. this.menuItem.addEventListener('mouseover', this._menuItemHoverHandler.bind(this));
  132. this.menuItem.addEventListener('mouseout', this._menuItemHoverOutHandler.bind(this));
  133. }
  134. // Subpanel content events.
  135. this.panelContent.addEventListener('keydown', this._panelContentKeyHandler.bind(this));
  136. this.element.dataset.subPanelInitialized = true;
  137. }
  138. /**
  139. * Checks if the subpanel has enough space.
  140. *
  141. * In general there are two scenarios were the subpanel must be interacted differently:
  142. * - Extra small screens: The subpanel is displayed below the menu item.
  143. * - Drawer: The subpanel is displayed one of the drawers.
  144. * - Block columns: for classic based themes.
  145. *
  146. * @returns {Boolean} true if the subpanel should be displayed in small screens.
  147. */
  148. _needSmallSpaceBehaviour() {
  149. return isExtraSmall() ||
  150. this.element.closest(Selectors.drawer) !== null ||
  151. this.element.closest(Selectors.blockColumn) !== null;
  152. }
  153. /**
  154. * Check if the subpanel should be displayed on the right.
  155. *
  156. * This is defined by the drop right boostrap class. However, if the menu is
  157. * displayed in a block column on the right, the subpanel should be forced
  158. * to the right.
  159. *
  160. * @returns {Boolean} true if the subpanel should be displayed on the right.
  161. */
  162. _needDropdownRight() {
  163. if (this.element.closest(Selectors.columnLeft) !== null) {
  164. return false;
  165. }
  166. return this.element.closest(Selectors.dropdownRight) !== null;
  167. }
  168. /**
  169. * Main element focus in handler.
  170. */
  171. _mainElementFocusInHandler() {
  172. if (this._needSmallSpaceBehaviour() || !this.showPreviewOnFocus) {
  173. // Preview is disabled when the user press ESC or shift+TAB to force closing
  174. // but if the continue navigating with keyboard the preview is enabled again.
  175. this.showPreviewOnFocus = true;
  176. return;
  177. }
  178. this.setVisibility(true);
  179. }
  180. /**
  181. * Menu item click handler.
  182. * @param {Event} event
  183. */
  184. _menuItemClickHandler(event) {
  185. // Avoid dropdowns being closed after clicking a subemnu.
  186. // This won't be needed with BS5 (data-bs-auto-close handles it).
  187. event.stopPropagation();
  188. event.preventDefault();
  189. if (this._needSmallSpaceBehaviour()) {
  190. this.setVisibility(!this.getVisibility());
  191. }
  192. }
  193. /**
  194. * Menu item hover handler.
  195. * @private
  196. */
  197. _menuItemHoverHandler() {
  198. if (this._needSmallSpaceBehaviour()) {
  199. return;
  200. }
  201. this.setVisibility(true);
  202. }
  203. /**
  204. * Menu item hover out handler.
  205. * @private
  206. */
  207. _menuItemHoverOutHandler() {
  208. if (this._needSmallSpaceBehaviour()) {
  209. return;
  210. }
  211. this._hideOtherSubPanels();
  212. }
  213. /**
  214. * Menu item key handler.
  215. * @param {Event} event
  216. * @private
  217. */
  218. _menuItemKeyHandler(event) {
  219. // In small sizes te down key will focus on the panel.
  220. if (event.key === 'ArrowUp' || (event.key === 'ArrowDown' && !this._needSmallSpaceBehaviour())) {
  221. this.setVisibility(false);
  222. return;
  223. }
  224. // Keys to move focus to the panel.
  225. let focusPanel = false;
  226. if (event.key === 'ArrowRight' || event.key === 'ArrowLeft' || (event.key === 'Tab' && !event.shiftKey)) {
  227. focusPanel = true;
  228. }
  229. if ((event.key === 'Enter' || event.key === ' ')) {
  230. focusPanel = true;
  231. }
  232. // In extra small screen the panel is shown below the item.
  233. if (event.key === 'ArrowDown' && this._needSmallSpaceBehaviour() && this.getVisibility()) {
  234. focusPanel = true;
  235. }
  236. if (focusPanel) {
  237. event.stopPropagation();
  238. event.preventDefault();
  239. this.setVisibility(true);
  240. this._focusPanelContent();
  241. }
  242. }
  243. /**
  244. * Sub panel content key handler.
  245. * @param {Event} event
  246. * @private
  247. */
  248. _panelContentKeyHandler(event) {
  249. // In extra small devices the panel is displayed under the menu item
  250. // so the arrow up/down switch between subpanel and the menu item.
  251. const canLoop = !this._needSmallSpaceBehaviour();
  252. let isBrowsingSubPanel = false;
  253. let newFocus = null;
  254. if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
  255. newFocus = this.menuItem;
  256. }
  257. // Acording to WCAG Esc and Tab are similar to arrow navigation but they
  258. // force the subpanel to be closed.
  259. if (event.key === 'Escape' || (event.key === 'Tab' && event.shiftKey)) {
  260. newFocus = this.menuItem;
  261. this.setVisibility(false);
  262. this.showPreviewOnFocus = false;
  263. }
  264. if (event.key === 'ArrowUp') {
  265. newFocus = previousFocusableElement(this.panelContent, canLoop);
  266. isBrowsingSubPanel = true;
  267. }
  268. if (event.key === 'ArrowDown') {
  269. newFocus = nextFocusableElement(this.panelContent, canLoop);
  270. isBrowsingSubPanel = true;
  271. }
  272. if (event.key === 'Home') {
  273. newFocus = firstFocusableElement(this.panelContent);
  274. isBrowsingSubPanel = true;
  275. }
  276. if (event.key === 'End') {
  277. newFocus = lastFocusableElement(this.panelContent);
  278. isBrowsingSubPanel = true;
  279. }
  280. // If the user cannot loop and arrive to the start/end of the subpanel
  281. // we focus on the menu item.
  282. if (newFocus === null && isBrowsingSubPanel && !canLoop) {
  283. newFocus = this.menuItem;
  284. }
  285. if (newFocus !== null) {
  286. event.stopPropagation();
  287. event.preventDefault();
  288. newFocus.focus();
  289. }
  290. }
  291. /**
  292. * Focus on the first focusable element of the subpanel.
  293. * @private
  294. */
  295. _focusPanelContent() {
  296. const pendingPromise = new Pending('core/action_menu/subpanel:focuscontent');
  297. // Some Bootstrap events are triggered after the click event.
  298. // To prevent this from affecting the focus we wait a bit.
  299. setTimeout(() => {
  300. const firstFocusable = firstFocusableElement(this.panelContent);
  301. if (firstFocusable) {
  302. firstFocusable.focus();
  303. }
  304. pendingPromise.resolve();
  305. }, 100);
  306. }
  307. /**
  308. * Set the visibility of a subpanel.
  309. * @param {Boolean} visible true if the subpanel should be visible.
  310. */
  311. setVisibility(visible) {
  312. if (visible) {
  313. this._hideOtherSubPanels();
  314. }
  315. // Aria hidden/unhidden can alter the focus, we only want to do it when needed.
  316. if (!visible && this.getVisibility) {
  317. hide(this.panelContent);
  318. }
  319. if (visible && !this.getVisibility) {
  320. unhide(this.panelContent);
  321. }
  322. this.menuItem.setAttribute('aria-expanded', visible ? 'true' : 'false');
  323. this.panelContent.classList.toggle('show', visible);
  324. this.element.classList.toggle(Classes.contentDisplayed, visible);
  325. }
  326. /**
  327. * Hide all other subpanels in the parent menu.
  328. * @private
  329. */
  330. _hideOtherSubPanels() {
  331. const dropdown = this.element.closest(Selectors.mainMenu);
  332. dropdown.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
  333. const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
  334. if (dropdownSubPanel === this.element) {
  335. return;
  336. }
  337. const subPanel = new SubPanel(dropdownSubPanel);
  338. subPanel.setVisibility(false);
  339. });
  340. }
  341. /**
  342. * Get the visibility of a subpanel.
  343. * @returns {Boolean} true if the subpanel is visible.
  344. */
  345. getVisibility() {
  346. return this.menuItem.getAttribute('aria-expanded') === 'true';
  347. }
  348. /**
  349. * Update the panels position depending on the screen size and panel position.
  350. */
  351. updatePosition() {
  352. const dropdownRight = this._needDropdownRight();
  353. if (this._needSmallSpaceBehaviour()) {
  354. this.element.classList.remove(Classes.dropRight);
  355. this.element.classList.remove(Classes.dropLeft);
  356. this.element.classList.add(Classes.dropDown);
  357. this.element.classList.toggle(Classes.forceLeft, dropdownRight);
  358. return;
  359. }
  360. this.element.classList.remove(Classes.dropDown);
  361. this.element.classList.remove(Classes.forceLeft);
  362. this.element.classList.toggle(Classes.dropRight, !dropdownRight);
  363. this.element.classList.toggle(Classes.dropLeft, dropdownRight);
  364. }
  365. }
  366. /**
  367. * Initialise module for given report
  368. *
  369. * @method
  370. * @param {string} selector The query selector to init.
  371. */
  372. export const init = (selector) => {
  373. initPageEvents();
  374. const subMenu = document.querySelector(selector);
  375. if (!subMenu) {
  376. throw new Error(`Sub panel element not found: ${selector}`);
  377. }
  378. const subPanel = new SubPanel(subMenu);
  379. subPanel.init();
  380. };