import { IAccessibilityComponent, IAccessibilityKeyboardHandlers } from './interfaces' class HSAccessibilityObserver { private components: IAccessibilityComponent[] = [] private currentlyOpenedComponents: IAccessibilityComponent[] = [] private activeComponent: IAccessibilityComponent | null = null private readonly allowedKeybindings = new Set([ 'Escape', 'Enter', ' ', 'Space', 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Tab', 'Home', 'End' ]) constructor() { this.initGlobalListeners() } private initGlobalListeners(): void { document.addEventListener('keydown', evt => this.handleGlobalKeydown(evt)) document.addEventListener('focusin', evt => this.handleGlobalFocusin(evt)) } private isAllowedKeybinding(evt: KeyboardEvent): boolean { if (this.allowedKeybindings.has(evt.key)) { return true } if ( evt.key.length === 1 && /^[a-zA-Z]$/.test(evt.key) && !evt.metaKey && !evt.ctrlKey && !evt.altKey && !evt.shiftKey ) { return true } return false } private getActiveComponent(el: HTMLElement) { if (!el) return null const containingComponents = this.components.filter( comp => comp.wrapper.contains(el) || (comp.context && comp.context.contains(el)) ) if (containingComponents.length === 0) return null if (containingComponents.length === 1) return containingComponents[0] let closestComponent = null let minDistance = Number.MAX_SAFE_INTEGER for (const comp of containingComponents) { let distance = 0 let current = el while (current && current !== comp.wrapper && current !== comp.context) { distance++ current = current.parentElement } if (distance < minDistance) { minDistance = distance closestComponent = comp } } return closestComponent } private handleGlobalFocusin(evt: FocusEvent): void { const target = evt.target as HTMLElement this.activeComponent = this.getActiveComponent(target) } private handleGlobalKeydown(evt: KeyboardEvent): void { const target = evt.target as HTMLElement this.activeComponent = this.getActiveComponent(target) if (!this.activeComponent) return if (!this.isAllowedKeybinding(evt)) { return } switch (evt.key) { case 'Escape': if (!this.activeComponent.isOpened) { const closestOpenParent = this.findClosestOpenParent(target) if (closestOpenParent?.handlers.onEsc) { closestOpenParent.handlers.onEsc() evt.preventDefault() evt.stopPropagation() } } else if (this.activeComponent.handlers.onEsc) { this.activeComponent.handlers.onEsc() evt.preventDefault() evt.stopPropagation() } break case 'Enter': if (this.activeComponent.handlers.onEnter) { this.activeComponent.handlers.onEnter() evt.preventDefault() evt.stopPropagation() } break case ' ': case 'Space': if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { return } if (this.activeComponent.handlers.onSpace) { this.activeComponent.handlers.onSpace() evt.preventDefault() evt.stopPropagation() } break case 'ArrowDown': case 'ArrowUp': case 'ArrowLeft': case 'ArrowRight': if (this.activeComponent.handlers.onArrow) { if (evt.metaKey || evt.ctrlKey || evt.altKey || evt.shiftKey) { return } this.activeComponent.handlers.onArrow(evt) evt.preventDefault() evt.stopPropagation() } break case 'Tab': { if (!this.activeComponent.handlers.onTab) break const handler = evt.shiftKey ? this.activeComponent.handlers.onShiftTab : this.activeComponent.handlers.onTab if (handler) handler() break } case 'Home': if (this.activeComponent.handlers.onHome) { this.activeComponent.handlers.onHome() evt.preventDefault() evt.stopPropagation() } break case 'End': if (this.activeComponent.handlers.onEnd) { this.activeComponent.handlers.onEnd() evt.preventDefault() evt.stopPropagation() } break default: if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return } if (this.activeComponent.handlers.onFirstLetter && evt.key.length === 1 && /^[a-zA-Z]$/.test(evt.key)) { this.activeComponent.handlers.onFirstLetter(evt.key) evt.preventDefault() evt.stopPropagation() } break } } private findClosestOpenParent(target: HTMLElement): IAccessibilityComponent | null { let current = target.parentElement while (current) { const parentComponent = this.currentlyOpenedComponents.find( comp => comp.wrapper === current && comp !== this.activeComponent ) if (parentComponent) { return parentComponent } current = current.parentElement } return null } public registerComponent( wrapper: HTMLElement, handlers: IAccessibilityKeyboardHandlers, isOpened: boolean = true, name: string = '', selector: string = '', context?: HTMLElement ): IAccessibilityComponent { const component: IAccessibilityComponent = { wrapper, handlers, isOpened, name, selector, context, isRegistered: true } this.components.push(component) return component } public updateComponentState(component: IAccessibilityComponent, isOpened: boolean): void { component.isOpened = isOpened if (isOpened) { if (!this.currentlyOpenedComponents.includes(component)) { this.currentlyOpenedComponents.push(component) } } else { this.currentlyOpenedComponents = this.currentlyOpenedComponents.filter(comp => comp !== component) } } public unregisterComponent(component: IAccessibilityComponent): void { this.components = this.components.filter(comp => comp !== component) this.currentlyOpenedComponents = this.currentlyOpenedComponents.filter(comp => comp !== component) } public addAllowedKeybinding(key: string): void { this.allowedKeybindings.add(key) } public removeAllowedKeybinding(key: string): void { this.allowedKeybindings.delete(key) } public getAllowedKeybindings(): string[] { return Array.from(this.allowedKeybindings) } } export default HSAccessibilityObserver