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