smdm-slide-menu
Version:
A library agnostic multilevel page menu with a smooth slide effect based on CSS transitions, focused on accessibility.
101 lines (86 loc) • 2.96 kB
text/typescript
import { CLASSES } from './SlideMenuOptions.js';
import { trapFocus } from './utils/dom.js';
import type { Slide } from './Slide.js';
export interface KeyboardControllerOptions {
keyClose: string;
keyOpen: string;
}
export interface KeyboardControllerCallbacks {
close(): void;
show(): void;
getActiveSubmenu(): Slide | undefined;
getMenuElem(): HTMLElement;
}
/**
* Manages all keyboard interaction concerns:
* - Document-level open/close shortcut keys
* - WCAG: space-key activation for anchor[role="button"] elements
* - Focus trap within the open menu
*/
export class KeyboardController {
private keydownHandler: ((event: KeyboardEvent) => void) | null = null;
private menuKeydownHandler: ((event: KeyboardEvent) => void) | null = null;
constructor(
private readonly options: KeyboardControllerOptions,
private readonly callbacks: KeyboardControllerCallbacks,
) {}
/**
* Attach both keydown listeners. Call once after the menu DOM is ready.
*/
init(): void {
this.initDocumentKeybindings();
this.initMenuKeybindings();
}
/**
* Remove all attached keydown listeners. Call from SlideMenu.destroy().
*/
destroy(): void {
if (this.keydownHandler) {
document.removeEventListener('keydown', this.keydownHandler);
this.keydownHandler = null;
}
if (this.menuKeydownHandler) {
this.callbacks.getMenuElem().removeEventListener('keydown', this.menuKeydownHandler);
this.menuKeydownHandler = null;
}
}
private initDocumentKeybindings(): void {
this.keydownHandler = (event: KeyboardEvent) => {
const elem = document.activeElement;
switch (event.key) {
case this.options.keyClose:
event.preventDefault();
this.callbacks.close();
break;
case this.options.keyOpen:
event.preventDefault();
this.callbacks.show();
break;
case 'Enter':
if (elem?.classList.contains(CLASSES.navigator)) (elem as HTMLElement).click();
break;
}
};
document.addEventListener('keydown', this.keydownHandler);
}
private initMenuKeybindings(): void {
const menuElem = this.callbacks.getMenuElem();
this.menuKeydownHandler = (event: KeyboardEvent) => {
// WCAG — anchor[role="button"] elements must respond to Space
if (
event.key === ' ' &&
event.target instanceof HTMLAnchorElement &&
event.target.role === 'button'
) {
event.preventDefault();
event.target.click();
}
// WCAG — trap focus inside the open menu
const firstControl = menuElem.querySelector(
`.${CLASSES.controls} .${CLASSES.control}:not([disabled]):not([tabindex="-1"])`,
) as HTMLElement | undefined;
trapFocus(event, this.callbacks.getActiveSubmenu()?.menuElem ?? menuElem, firstControl);
};
menuElem.addEventListener('keydown', this.menuKeydownHandler);
}
}