From b076ecaf96f3151a2c84a4461bae8adfbb5c184a Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 11 May 2022 09:50:57 +0200 Subject: [PATCH] feat(a11y): implement accessible menu button --- app/javascript/controllers/index.ts | 2 + .../controllers/menu_button_controller.ts | 257 ++++++++++++++++++ app/javascript/new_design/dropdown.js | 22 -- app/javascript/packs/application.js | 1 - 4 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 app/javascript/controllers/menu_button_controller.ts delete mode 100644 app/javascript/new_design/dropdown.js diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index 0e9a6a560..133d83ea7 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -6,6 +6,7 @@ import { GeoAreaController } from './geo_area_controller'; import { TurboInputController } from './turbo_input_controller'; import { AutosaveController } from './autosave_controller'; import { AutosaveStatusController } from './autosave_status_controller'; +import { MenuButtonController } from './menu_button_controller'; const Stimulus = Application.start(); Stimulus.register('react', ReactController); @@ -14,3 +15,4 @@ Stimulus.register('geo-area', GeoAreaController); Stimulus.register('turbo-input', TurboInputController); Stimulus.register('autosave', AutosaveController); Stimulus.register('autosave-status', AutosaveStatusController); +Stimulus.register('menu-button', MenuButtonController); diff --git a/app/javascript/controllers/menu_button_controller.ts b/app/javascript/controllers/menu_button_controller.ts new file mode 100644 index 000000000..19e23f2e3 --- /dev/null +++ b/app/javascript/controllers/menu_button_controller.ts @@ -0,0 +1,257 @@ +import { ApplicationController } from './application_controller'; + +export class MenuButtonController extends ApplicationController { + static targets = ['button', 'menu']; + + declare readonly buttonTarget: HTMLButtonElement; + declare readonly menuTarget: HTMLElement; + + #isOpen = false; + #teardown?: () => void; + + connect() { + this.setup(); + } + + disconnect(): void { + this.#teardown?.(); + } + + private get isMenu() { + return !(this.element as HTMLElement).dataset.popover; + } + + private setup() { + this.buttonTarget.setAttribute( + 'aria-haspopup', + this.isMenu ? 'menu' : 'true' + ); + this.buttonTarget.setAttribute('aria-controls', this.menuTarget.id); + if (!this.buttonTarget.id) { + this.buttonTarget.id = `${this.menuTarget.id}_button`; + } + + this.menuTarget.setAttribute('aria-labelledby', this.buttonTarget.id); + this.menuTarget.setAttribute('role', this.isMenu ? 'menu' : 'region'); + this.menuTarget.classList.add('fade-in-down'); + this.menuTarget.setAttribute('tab-index', '-1'); + + if (this.isMenu) { + for (const menuItem of this.menuTarget.querySelectorAll('a')) { + menuItem.setAttribute('role', 'menuitem'); + } + } + + this.on('click', (event) => { + const target = event.target as HTMLElement; + if (this.buttonTarget == target || this.buttonTarget.contains(target)) { + event.preventDefault(); + + if (this.#isOpen) { + this.close(); + } else { + this.open(); + } + } + }); + this.on('keydown', (event: KeyboardEvent) => { + const target = event.target as HTMLElement; + if (this.buttonTarget == target) { + this.onButtonKeydown(event); + } else if ( + this.isMenu && + (this.menuTarget == target || this.menuTarget.contains(target)) + ) { + this.onMenuKeydown(event); + } + }); + } + + private open(focusMenuItem: 'first' | 'last' = 'first') { + this.buttonTarget.setAttribute('aria-expanded', ''); + this.menuTarget.parentElement?.classList.add('open'); + this.menuTarget.focus(); + + const onClickBody = (event: Event) => { + const target = event.target as HTMLElement; + if (this.isClickOutside(target)) { + this.menuTarget.classList.remove('fade-in-down'); + this.close(); + } + }; + requestAnimationFrame(() => { + if (focusMenuItem == 'first') { + this.setFocusToFirstMenuitem(); + } else { + this.setFocusToLastMenuitem(); + } + document.body.addEventListener('click', onClickBody); + }); + + this.#isOpen = true; + this.#teardown = () => + document.body.removeEventListener('click', onClickBody); + } + + private close() { + this.buttonTarget.removeAttribute('aria-expanded'); + this.menuTarget.parentElement?.classList.remove('open'); + this.#teardown?.(); + this.setFocusToMenuitem(null); + this.#isOpen = false; + } + + private isClickOutside(target: HTMLElement) { + return ( + target.isConnected && + !this.element.contains(target) && + !target.closest('reach-portal') && + this.#isOpen + ); + } + + private get currentMenuItem() { + return this.menuTarget.querySelector( + '[role="menuitem"]:focus' + ); + } + + private get menuItems() { + return [ + ...this.menuTarget.querySelectorAll('[role="menuitem"]') + ]; + } + + private setFocusToMenuitem(menuItem: HTMLElement | null) { + if (menuItem) { + menuItem.focus(); + } else { + this.buttonTarget.focus(); + } + } + + private setFocusToFirstMenuitem() { + this.setFocusToMenuitem(this.menuItems[0]); + } + + private setFocusToLastMenuitem() { + const length = this.menuItems.length; + this.setFocusToMenuitem(this.menuItems[length - 1]); + } + + setFocusToPreviousMenuitem() { + const { currentMenuItem, menuItems } = this; + + if (currentMenuItem) { + const index = menuItems.indexOf(currentMenuItem); + if (index == 0) { + this.setFocusToLastMenuitem(); + } else { + this.setFocusToMenuitem(menuItems[index - 1]); + } + } + } + + setFocusToNextMenuitem() { + const { currentMenuItem, menuItems } = this; + + if (currentMenuItem) { + const index = menuItems.indexOf(currentMenuItem); + if (index == menuItems.length - 1) { + this.setFocusToFirstMenuitem(); + } else { + this.setFocusToMenuitem(menuItems[index + 1]); + } + } + } + + performMenuAction(target: EventTarget | null) { + target?.dispatchEvent(new Event('click')); + } + + private onButtonKeydown(event: KeyboardEvent) { + let stopPropagation = false; + switch (event.key) { + case ' ': + case 'Enter': + case 'ArrowDown': + case 'Down': + this.open(); + stopPropagation = true; + break; + case 'Esc': + case 'Escape': + this.close(); + stopPropagation = true; + break; + case 'Up': + case 'ArrowUp': + this.open('last'); + stopPropagation = true; + break; + default: + break; + } + + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } + } + + onMenuKeydown(event: KeyboardEvent) { + let stopPropagation = false; + if (event.ctrlKey || event.altKey || event.metaKey) { + return; + } + + if (event.shiftKey) { + if (event.key == 'Tab') { + this.close(); + stopPropagation = true; + } + } else { + switch (event.key) { + case ' ': + this.performMenuAction(event.target); + stopPropagation = true; + break; + case 'Esc': + case 'Escape': + this.close(); + stopPropagation = true; + break; + case 'Up': + case 'ArrowUp': + this.setFocusToPreviousMenuitem(); + stopPropagation = true; + break; + case 'ArrowDown': + case 'Down': + this.setFocusToNextMenuitem(); + stopPropagation = true; + break; + case 'Home': + case 'PageUp': + this.setFocusToFirstMenuitem(); + stopPropagation = true; + break; + case 'End': + case 'PageDown': + this.setFocusToLastMenuitem(); + stopPropagation = true; + break; + case 'Tab': + this.close(); + break; + default: + break; + } + } + + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } + } +} diff --git a/app/javascript/new_design/dropdown.js b/app/javascript/new_design/dropdown.js deleted file mode 100644 index dc948c983..000000000 --- a/app/javascript/new_design/dropdown.js +++ /dev/null @@ -1,22 +0,0 @@ -import { delegate } from '@utils'; - -delegate('click', 'body', (event) => { - if (!event.target.closest('.dropdown, [data-reach-combobox-popover]')) { - [...document.querySelectorAll('.dropdown')].forEach((element) => { - const button = element.querySelector('.dropdown-button'); - button.setAttribute('aria-expanded', false); - element.classList.remove('open', 'fade-in-down'); - }); - } -}); - -delegate('click', '.dropdown-button', (event) => { - event.stopPropagation(); - const button = event.target.closest('.dropdown-button'); - const parent = button.parentElement; - if (parent.classList.contains('dropdown')) { - parent.classList.toggle('open'); - var buttonExpanded = button.getAttribute('aria-expanded') === 'true'; - button.setAttribute('aria-expanded', !buttonExpanded); - } -}); diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index c121d1972..549033697 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -13,7 +13,6 @@ import '../shared/ujs-error-handling'; import { registerComponents } from '../controllers/react_controller'; import '../controllers'; -import '../new_design/dropdown'; import '../new_design/form-validation'; import '../new_design/procedure-context'; import '../new_design/procedure-form';