UNPKG

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