feat(a11y): implement accessible menu button

This commit is contained in:
Paul Chavard 2022-05-11 09:50:57 +02:00
parent 41b6f8f51b
commit b076ecaf96
4 changed files with 259 additions and 23 deletions

View file

@ -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);

View file

@ -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<HTMLElement>(
'[role="menuitem"]:focus'
);
}
private get menuItems() {
return [
...this.menuTarget.querySelectorAll<HTMLElement>('[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();
}
}
}

View file

@ -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);
}
});

View file

@ -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';