theme/boost/amd/src/aria.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. * Enhancements to Bootstrap components for accessibility.
  17. *
  18. * @module theme_boost/aria
  19. * @copyright 2018 Damyon Wiese <damyon@moodle.com>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import Tab from 'theme_boost/bootstrap/tab';
  23. import Pending from 'core/pending';
  24. import * as FocusLockManager from 'core/local/aria/focuslock';
  25. /**
  26. * Drop downs from bootstrap don't support keyboard accessibility by default.
  27. */
  28. const dropdownFix = () => {
  29. let focusEnd = false;
  30. const setFocusEnd = (end = true) => {
  31. focusEnd = end;
  32. };
  33. const getFocusEnd = () => {
  34. const result = focusEnd;
  35. focusEnd = false;
  36. return result;
  37. };
  38. // Special handling for navigation keys when menu is open.
  39. const shiftFocus = (element, focusCheck = null) => {
  40. const pendingPromise = new Pending('core/aria:delayed-focus');
  41. setTimeout(() => {
  42. if (!focusCheck || focusCheck()) {
  43. element.focus();
  44. }
  45. pendingPromise.resolve();
  46. }, 50);
  47. };
  48. // Event handling for the dropdown menu button.
  49. const handleMenuButton = e => {
  50. const trigger = e.key;
  51. let fixFocus = false;
  52. // Space key or Enter key opens the menu.
  53. if (trigger === ' ' || trigger === 'Enter') {
  54. fixFocus = true;
  55. // Cancel random scroll.
  56. e.preventDefault();
  57. // Open the menu instead.
  58. e.target.click();
  59. }
  60. // Up and Down keys also open the menu.
  61. if (trigger === 'ArrowUp' || trigger === 'ArrowDown') {
  62. fixFocus = true;
  63. }
  64. if (!fixFocus) {
  65. // No need to fix the focus. Return early.
  66. return;
  67. }
  68. // Fix the focus on the menu items when the menu is opened.
  69. const menu = e.target.parentElement.querySelector('[role="menu"]');
  70. let menuItems = false;
  71. let foundMenuItem = false;
  72. if (menu) {
  73. menuItems = menu.querySelectorAll('[role="menuitem"]');
  74. }
  75. if (menuItems && menuItems.length > 0) {
  76. // Up key opens the menu at the end.
  77. if (trigger === 'ArrowUp') {
  78. setFocusEnd();
  79. } else {
  80. setFocusEnd(false);
  81. }
  82. if (getFocusEnd()) {
  83. foundMenuItem = menuItems[menuItems.length - 1];
  84. } else {
  85. // The first menu entry, pretty reasonable.
  86. foundMenuItem = menuItems[0];
  87. }
  88. }
  89. if (foundMenuItem) {
  90. shiftFocus(foundMenuItem);
  91. }
  92. };
  93. // Search for menu items by finding the first item that has
  94. // text starting with the typed character (case insensitive).
  95. document.addEventListener('keypress', e => {
  96. if (e.target.matches('[role="menu"] [role="menuitem"]')) {
  97. const menu = e.target.closest('[role="menu"]');
  98. if (!menu) {
  99. return;
  100. }
  101. const menuItems = menu.querySelectorAll('[role="menuitem"]');
  102. if (!menuItems) {
  103. return;
  104. }
  105. const trigger = e.key.toLowerCase();
  106. for (let i = 0; i < menuItems.length; i++) {
  107. const item = menuItems[i];
  108. const itemText = item.text.trim().toLowerCase();
  109. if (itemText.indexOf(trigger) == 0) {
  110. shiftFocus(item);
  111. break;
  112. }
  113. }
  114. }
  115. });
  116. // Keyboard navigation for arrow keys, home and end keys.
  117. document.addEventListener('keydown', e => {
  118. // We only want to set focus when users access the dropdown via keyboard as per
  119. // guidelines defined in w3 aria practices 1.1 menu-button.
  120. if (e.target.matches('[data-bs-toggle="dropdown"]')) {
  121. handleMenuButton(e);
  122. }
  123. if (e.target.matches('[role="menu"] [role="menuitem"]')) {
  124. const trigger = e.key;
  125. let next = false;
  126. const menu = e.target.closest('[role="menu"]');
  127. if (!menu) {
  128. return;
  129. }
  130. const menuItems = menu.querySelectorAll('[role="menuitem"]');
  131. if (!menuItems) {
  132. return;
  133. }
  134. // Down key.
  135. if (trigger == 'ArrowDown') {
  136. for (let i = 0; i < menuItems.length - 1; i++) {
  137. if (menuItems[i] == e.target) {
  138. next = menuItems[i + 1];
  139. break;
  140. }
  141. }
  142. if (!next) {
  143. // Wrap to first item.
  144. next = menuItems[0];
  145. }
  146. } else if (trigger == 'ArrowUp') {
  147. // Up key.
  148. for (let i = 1; i < menuItems.length; i++) {
  149. if (menuItems[i] == e.target) {
  150. next = menuItems[i - 1];
  151. break;
  152. }
  153. }
  154. if (!next) {
  155. // Wrap to last item.
  156. next = menuItems[menuItems.length - 1];
  157. }
  158. } else if (trigger == 'Home') {
  159. // Home key.
  160. next = menuItems[0];
  161. } else if (trigger == 'End') {
  162. // End key.
  163. next = menuItems[menuItems.length - 1];
  164. }
  165. // Variable next is set if we do want to act on the keypress.
  166. if (next) {
  167. e.preventDefault();
  168. shiftFocus(next);
  169. }
  170. return;
  171. }
  172. });
  173. // Trap focus if the dropdown is a dialog.
  174. document.addEventListener('shown.bs.dropdown', e => {
  175. const dialog = e.target.querySelector('.dropdown-menu[role="dialog"]');
  176. if (dialog) {
  177. // Use setTimeout to make sure the dialog is positioned correctly to prevent random scrolling.
  178. setTimeout(() => {
  179. FocusLockManager.trapFocus(dialog);
  180. });
  181. }
  182. });
  183. // Untrap focus when the dialog dropdown is closed.
  184. document.addEventListener('hidden.bs.dropdown', e => {
  185. const dialog = e.target.querySelector('.dropdown-menu[role="dialog"]');
  186. if (dialog) {
  187. FocusLockManager.untrapFocus();
  188. }
  189. // We need to focus on the menu trigger.
  190. const trigger = e.target.querySelector('[data-bs-toggle="dropdown"]');
  191. // If it's a click event, then no element is focused because the clicked element is inside a closed dropdown.
  192. const focused = e.clickEvent?.target || (document.activeElement !== document.body ? document.activeElement : null);
  193. if (trigger && focused && e.target.contains(focused)) {
  194. shiftFocus(trigger, () => {
  195. if (document.activeElement === document.body) {
  196. // If the focus is currently on the body, then we can safely assume that the focus needs to be updated.
  197. return true;
  198. }
  199. // If the focus is on a child of the clicked element still, then update the focus.
  200. return e.target.contains(document.activeElement);
  201. });
  202. }
  203. });
  204. };
  205. /**
  206. * A lot of Bootstrap's out of the box features don't work if dropdown items are not focusable.
  207. */
  208. const comboboxFix = () => {
  209. document.addEventListener('show.bs.dropdown', e => {
  210. if (e.relatedTarget.matches('[role="combobox"]')) {
  211. const combobox = e.relatedTarget;
  212. const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
  213. if (listbox) {
  214. const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
  215. // To make sure ArrowDown doesn't move the active option afterwards.
  216. setTimeout(() => {
  217. if (selectedOption) {
  218. selectedOption.classList.add('active');
  219. combobox.setAttribute('aria-activedescendant', selectedOption.id);
  220. } else {
  221. const firstOption = listbox.querySelector('[role="option"]');
  222. firstOption.setAttribute('aria-selected', 'true');
  223. firstOption.classList.add('active');
  224. combobox.setAttribute('aria-activedescendant', firstOption.id);
  225. }
  226. }, 0);
  227. }
  228. }
  229. });
  230. document.addEventListener('hidden.bs.dropdown', e => {
  231. if (e.relatedTarget.matches('[role="combobox"]')) {
  232. const combobox = e.relatedTarget;
  233. const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
  234. combobox.removeAttribute('aria-activedescendant');
  235. if (listbox) {
  236. setTimeout(() => {
  237. // Undo all previously highlighted options.
  238. listbox.querySelectorAll('.active[role="option"]').forEach(option => {
  239. option.classList.remove('active');
  240. });
  241. }, 0);
  242. }
  243. }
  244. });
  245. // Handling keyboard events for both navigating through and selecting options.
  246. document.addEventListener('keydown', e => {
  247. if (e.target.matches('[role="combobox"][aria-controls]:not([aria-haspopup=dialog])')) {
  248. const combobox = e.target;
  249. const trigger = e.key;
  250. let next = null;
  251. const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
  252. const options = listbox.querySelectorAll('[role="option"]');
  253. const activeOption = listbox.querySelector('.active[role="option"]');
  254. const editable = combobox.hasAttribute('aria-autocomplete');
  255. // Under the special case that the dropdown menu is being shown as a result of the key press (like when the user
  256. // presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.
  257. // It's because of a race condition with show.bs.dropdown event handler.
  258. if (options && (activeOption || editable)) {
  259. if (trigger == 'ArrowDown') {
  260. for (let i = 0; i < options.length - 1; i++) {
  261. if (options[i] == activeOption) {
  262. next = options[i + 1];
  263. break;
  264. }
  265. }
  266. if (editable && !next) {
  267. next = options[0];
  268. }
  269. } if (trigger == 'ArrowUp') {
  270. for (let i = 1; i < options.length; i++) {
  271. if (options[i] == activeOption) {
  272. next = options[i - 1];
  273. break;
  274. }
  275. }
  276. if (editable && !next) {
  277. next = options[options.length - 1];
  278. }
  279. } else if (trigger == 'Home' && !editable) {
  280. next = options[0];
  281. } else if (trigger == 'End' && !editable) {
  282. next = options[options.length - 1];
  283. } else if ((trigger == ' ' && !editable) || trigger == 'Enter') {
  284. e.preventDefault();
  285. selectOption(combobox, activeOption);
  286. } else if (!editable) {
  287. // Search for options by finding the first option that has
  288. // text starting with the typed character (case insensitive).
  289. for (let i = 0; i < options.length; i++) {
  290. const option = options[i];
  291. const optionText = option.textContent.trim().toLowerCase();
  292. const keyPressed = e.key.toLowerCase();
  293. if (optionText.indexOf(keyPressed) == 0) {
  294. next = option;
  295. break;
  296. }
  297. }
  298. }
  299. // Variable next is set if we do want to act on the keypress.
  300. if (next) {
  301. e.preventDefault();
  302. if (activeOption) {
  303. activeOption.classList.remove('active');
  304. }
  305. next.classList.add('active');
  306. combobox.setAttribute('aria-activedescendant', next.id);
  307. next.scrollIntoView({block: 'nearest'});
  308. }
  309. }
  310. }
  311. }, true);
  312. document.addEventListener('click', e => {
  313. const option = e.target.closest('[role="listbox"] [role="option"]');
  314. if (option) {
  315. const listbox = option.closest('[role="listbox"]');
  316. const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`);
  317. if (combobox) {
  318. selectOption(combobox, option);
  319. }
  320. }
  321. });
  322. // In case some code somewhere else changes the value of the combobox.
  323. document.addEventListener('change', e => {
  324. if (e.target.matches('input[type="hidden"][id]')) {
  325. const combobox = document.querySelector(`[role="combobox"][data-input-element="${e.target.id}"]`);
  326. const option = e.target.parentElement.querySelector(`[role="option"][data-value="${e.target.value}"]`);
  327. if (combobox && option) {
  328. selectOption(combobox, option);
  329. }
  330. }
  331. });
  332. const selectOption = (combobox, option) => {
  333. const listbox = option.closest('[role="listbox"]');
  334. const oldSelectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
  335. if (oldSelectedOption != option) {
  336. if (oldSelectedOption) {
  337. oldSelectedOption.removeAttribute('aria-selected');
  338. }
  339. option.setAttribute('aria-selected', 'true');
  340. }
  341. if (combobox.hasAttribute('value')) {
  342. combobox.value = option.dataset.shortText || option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim();
  343. } else {
  344. const selectedOptionContainer = combobox.querySelector('[data-selected-option]');
  345. if (selectedOptionContainer) {
  346. selectedOptionContainer.textContent = option.dataset.shortText || option.textContent;
  347. } else {
  348. combobox.textContent = option.dataset.shortText || option.textContent;
  349. }
  350. }
  351. if (combobox.dataset.inputElement) {
  352. const inputElement = document.getElementById(combobox.dataset.inputElement);
  353. if (inputElement && (inputElement.value != option.dataset.value)) {
  354. inputElement.value = option.dataset.value;
  355. inputElement.dispatchEvent(new Event('change', {bubbles: true}));
  356. }
  357. }
  358. };
  359. };
  360. /**
  361. * After page load, focus on any element with special autofocus attribute.
  362. */
  363. const autoFocus = () => {
  364. window.addEventListener("load", () => {
  365. const alerts = document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');
  366. Array.prototype.forEach.call(alerts, autofocusElement => {
  367. // According to the specification an role="alert" region is only read out on change to the content
  368. // of that region.
  369. autofocusElement.innerHTML += ' ';
  370. autofocusElement.removeAttribute('data-aria-autofocus');
  371. });
  372. });
  373. };
  374. /**
  375. * Changes the focus to the correct element based on the key that is pressed.
  376. * @param {NodeList} elements A NodeList of focusable elements to navigate between.
  377. * @param {KeyboardEvent} e The keyboard event that triggers the roving focus.
  378. * @param {boolean} vertical Whether the navigation is vertical.
  379. * @param {boolean} updateTabIndex Whether to update the tabIndex of the elements.
  380. */
  381. const rovingFocus = (elements, e, vertical, updateTabIndex) => {
  382. const rtl = window.right_to_left();
  383. const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');
  384. const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');
  385. const keys = [arrowNext, arrowPrevious, 'Home', 'End'];
  386. if (!keys.includes(e.key)) {
  387. return;
  388. }
  389. const focusElement = index => {
  390. elements[index].focus();
  391. if (updateTabIndex) {
  392. elements.forEach((element, i) => element.setAttribute('tabindex', i === index ? '0' : '-1'));
  393. }
  394. };
  395. const currentIndex = Array.prototype.indexOf.call(elements, e.target);
  396. let nextIndex;
  397. switch (e.key) {
  398. case arrowNext:
  399. e.preventDefault();
  400. nextIndex = (currentIndex + 1 < elements.length) ? currentIndex + 1 : 0;
  401. focusElement(nextIndex);
  402. break;
  403. case arrowPrevious:
  404. e.preventDefault();
  405. nextIndex = (currentIndex - 1 >= 0) ? currentIndex - 1 : elements.length - 1;
  406. focusElement(nextIndex);
  407. break;
  408. case 'Home':
  409. e.preventDefault();
  410. focusElement(0);
  411. break;
  412. case 'End':
  413. e.preventDefault();
  414. focusElement(elements.length - 1);
  415. }
  416. };
  417. /**
  418. * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.
  419. */
  420. const tabElementFix = () => {
  421. document.addEventListener('keydown', e => {
  422. if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
  423. if (e.target.matches('[role="tablist"] [role="tab"]')) {
  424. const tabList = e.target.closest('[role="tablist"]');
  425. const tabs = Array.prototype.filter.call(
  426. tabList.querySelectorAll('[role="tab"]'),
  427. tab => !!tab.offsetHeight
  428. ); // We only work with the visible tabs.
  429. const vertical = tabList.getAttribute('aria-orientation') == 'vertical';
  430. rovingFocus(tabs, e, vertical, false);
  431. }
  432. }
  433. });
  434. document.addEventListener('click', e => {
  435. if (e.target.matches('[role="tablist"] [data-bs-toggle="tab"], [role="tablist"] [data-bs-toggle="pill"]')) {
  436. const tabs = e.target.closest('[role="tablist"]').querySelectorAll('[data-bs-toggle="tab"], [data-bs-toggle="pill"]');
  437. e.preventDefault();
  438. Tab.getOrCreateInstance(e.target).show();
  439. tabs.forEach(tab => {
  440. tab.tabIndex = -1;
  441. });
  442. e.target.tabIndex = 0;
  443. }
  444. });
  445. };
  446. /**
  447. * Fix keyboard interaction with Bootstrap Collapse elements.
  448. *
  449. * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure|WAI-ARIA Authoring Practices 1.1 - Disclosure (Show/Hide)}
  450. */
  451. const collapseFix = () => {
  452. document.addEventListener('keydown', e => {
  453. if (e.target.matches('[data-bs-toggle="collapse"]')) {
  454. // Pressing space should toggle expand/collapse.
  455. if (e.key === ' ') {
  456. e.preventDefault();
  457. e.target.click();
  458. }
  459. }
  460. });
  461. };
  462. /**
  463. * Fix accessibility issues
  464. */
  465. const toolbarFix = () => {
  466. document.addEventListener('keydown', e => {
  467. if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
  468. if (e.target.matches('[role="toolbar"] button')) {
  469. const buttons = e.target.closest('[role="toolbar"]').querySelectorAll('button');
  470. rovingFocus(buttons, e, false, true);
  471. }
  472. }
  473. });
  474. };
  475. export const init = () => {
  476. dropdownFix();
  477. comboboxFix();
  478. autoFocus();
  479. tabElementFix();
  480. collapseFix();
  481. toolbarFix();
  482. };