preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
474 lines (391 loc) • 10.9 kB
text/typescript
import {
IAccessibilityComponent,
IAccessibilityKeyboardHandlers,
} from './interfaces';
import { isFormElement } from '../../utils';
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 getActiveComponentForKey(
el: HTMLElement,
key: string,
): IAccessibilityComponent | null {
if (!el) return null;
const containingComponents = this.components.filter(
(component) =>
component.wrapper.contains(el) ||
(component.context && component.context.contains(el)),
);
if (containingComponents.length === 0) return null;
const hasHandlerForKey = (component: IAccessibilityComponent): boolean => {
const handlers = component.handlers;
switch (key) {
case 'Escape':
return !!handlers.onEsc;
case 'Enter':
return !!handlers.onEnter;
case ' ':
case 'Space':
return !!handlers.onSpace;
case 'ArrowDown':
case 'ArrowUp':
case 'ArrowLeft':
case 'ArrowRight':
return !!handlers.onArrow;
case 'Tab':
return !!handlers.onTab || !!handlers.onShiftTab;
case 'Home':
return !!handlers.onHome;
case 'End':
return !!handlers.onEnd;
default:
return !!handlers.onFirstLetter;
}
};
const candidates = containingComponents.filter(hasHandlerForKey);
if (candidates.length === 0) return this.getActiveComponent(el);
if (candidates.length === 1) return candidates[0];
let closestComponent: IAccessibilityComponent | null = null;
let minDistance = Number.MAX_SAFE_INTEGER;
for (const candidate of candidates) {
let distance = 0;
let current: HTMLElement | null = el;
while (
current &&
current !== candidate.wrapper &&
current !== candidate.context
) {
distance++;
current = current.parentElement;
}
if (distance < minDistance) {
minDistance = distance;
closestComponent = candidate;
}
}
return closestComponent;
}
private getDistanceToComponent(
el: HTMLElement,
component: IAccessibilityComponent,
): number {
let distance = 0;
let current: HTMLElement | null = el;
while (
current &&
current !== component.wrapper &&
current !== component.context
) {
distance++;
current = current.parentElement;
}
return distance;
}
private getComponentsByNesting(el: HTMLElement): IAccessibilityComponent[] {
if (!el) return [];
const containingComponents = this.components.filter(
(component) =>
component.wrapper.contains(el) ||
(component.context && component.context.contains(el)),
);
if (containingComponents.length <= 1) return containingComponents;
return [...containingComponents].sort(
(a, b) =>
this.getDistanceToComponent(el, b) - this.getDistanceToComponent(el, a),
);
}
private getSequentialHandlersForKey(
el: HTMLElement,
key: 'Enter' | 'Space',
): Array<(evt?: KeyboardEvent) => boolean | void> {
const components = this.getComponentsByNesting(el);
if (components.length === 0) return [];
return components
.map((component) => {
if (key === 'Enter') return component.handlers.onEnter;
return component.handlers.onSpace;
})
.filter(
(handler): handler is (evt?: KeyboardEvent) => boolean | void =>
typeof handler === 'function',
);
}
private executeSequentialHandlers(
handlers: Array<(evt?: KeyboardEvent) => boolean | void>,
evt?: KeyboardEvent,
): {
called: boolean;
stopped: boolean;
} {
let called = false;
let stopped = false;
for (const handler of handlers) {
called = true;
const result = handler(evt);
if (result === false) {
stopped = true;
break;
}
}
return { called, stopped };
}
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.getActiveComponentForKey(target, evt.key);
const activeComponent = this.activeComponent;
const isActivationKey =
evt.key === 'Enter' || evt.key === ' ' || evt.key === 'Space';
if (!activeComponent && !isActivationKey) return;
if (!this.isAllowedKeybinding(evt)) {
return;
}
switch (evt.key) {
case 'Escape':
if (!activeComponent) break;
if (!activeComponent.isOpened) {
const closestOpenParent = this.findClosestOpenParent(target);
if (closestOpenParent?.handlers.onEsc) {
closestOpenParent.handlers.onEsc();
evt.preventDefault();
evt.stopPropagation();
}
} else if (activeComponent.handlers.onEsc) {
const escResult = activeComponent.handlers.onEsc();
evt.preventDefault();
evt.stopPropagation();
if (escResult === false) {
const closestOpenParent = this.findClosestOpenParent(target);
if (closestOpenParent?.handlers.onEsc) {
closestOpenParent.handlers.onEsc();
}
}
}
break;
case 'Enter': {
const enterHandlers = this.getSequentialHandlersForKey(target, 'Enter');
if (enterHandlers.length === 0) break;
const { called, stopped } = this.executeSequentialHandlers(
enterHandlers,
evt,
);
if (called && !isFormElement(target)) {
evt.stopPropagation();
evt.preventDefault();
}
if (stopped) {
break;
}
break;
}
case ' ':
case 'Space': {
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
const closestComponent = this.getActiveComponent(target);
const spaceHandlers = this.getSequentialHandlersForKey(target, 'Space');
if (spaceHandlers.length === 0) break;
const { stopped } = this.executeSequentialHandlers(spaceHandlers);
if (stopped || closestComponent?.handlers.onSpace) {
evt.preventDefault();
evt.stopPropagation();
}
break;
}
case 'ArrowDown':
case 'ArrowUp':
case 'ArrowLeft':
case 'ArrowRight':
if (!activeComponent) break;
if (activeComponent.handlers.onArrow) {
if (evt.metaKey || evt.ctrlKey || evt.altKey || evt.shiftKey) {
return;
}
activeComponent.handlers.onArrow(evt);
evt.preventDefault();
evt.stopPropagation();
}
break;
case 'Tab':
if (!activeComponent) break;
if (!activeComponent.handlers.onTab) break;
const handler = evt.shiftKey
? activeComponent.handlers.onShiftTab
: activeComponent.handlers.onTab;
if (handler) handler(evt);
break;
case 'Home':
if (!activeComponent) break;
if (activeComponent.handlers.onHome) {
activeComponent.handlers.onHome();
evt.preventDefault();
evt.stopPropagation();
}
break;
case 'End':
if (!activeComponent) break;
if (activeComponent.handlers.onEnd) {
activeComponent.handlers.onEnd();
evt.preventDefault();
evt.stopPropagation();
}
break;
default:
if (!activeComponent) break;
if (
activeComponent.handlers.onFirstLetter &&
evt.key.length === 1 &&
/^[a-zA-Z]$/.test(evt.key)
) {
activeComponent.handlers.onFirstLetter(evt.key);
if (!activeComponent.stopPropagation?.onFirstLetter) {
return;
} else {
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,
stopPropagation?: {
[key: string]: boolean;
},
): IAccessibilityComponent {
const component: IAccessibilityComponent = {
wrapper,
handlers,
isOpened,
name,
selector,
context,
isRegistered: true,
stopPropagation,
};
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;