feat(a11y): implement accessible menu button
This commit is contained in:
parent
41b6f8f51b
commit
b076ecaf96
4 changed files with 259 additions and 23 deletions
|
@ -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);
|
||||
|
|
257
app/javascript/controllers/menu_button_controller.ts
Normal file
257
app/javascript/controllers/menu_button_controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue