@sc4rfurryx/proteusjs
Version:
The Modern Web Development Framework for Accessible, Responsive, and High-Performance Applications. Intelligent container queries, fluid typography, WCAG compliance, and performance optimization.
153 lines (124 loc) • 4.41 kB
text/typescript
/**
* @sc4rfurryx/proteusjs/a11y-primitives
* Lightweight accessibility patterns
*
* @version 2.0.0
* @author sc4rfurry
* @license MIT
*/
export interface Controller {
destroy(): void;
}
export interface DialogOptions {
modal?: boolean;
restoreFocus?: boolean;
}
export interface TooltipOptions {
delay?: number;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
export interface FocusTrapController {
activate(): void;
deactivate(): void;
}
export function dialog(root: Element | string, opts: DialogOptions = {}): Controller {
const el = typeof root === 'string' ? document.querySelector(root) : root;
if (!el) throw new Error('Dialog element not found');
const { modal = true, restoreFocus = true } = opts;
let prevFocus: Element | null = null;
const open = () => {
if (restoreFocus) prevFocus = document.activeElement;
el.setAttribute('role', 'dialog');
if (modal) el.setAttribute('aria-modal', 'true');
(el as HTMLElement).focus();
};
const close = () => {
if (restoreFocus && prevFocus) (prevFocus as HTMLElement).focus();
};
return { destroy: () => close() };
}
export function tooltip(trigger: Element, content: HTMLElement, opts: TooltipOptions = {}): Controller {
const { delay = 300 } = opts;
let timeout: number;
const show = () => {
clearTimeout(timeout);
timeout = window.setTimeout(() => {
content.setAttribute('role', 'tooltip');
trigger.setAttribute('aria-describedby', content.id || 'tooltip');
content.style.display = 'block';
}, delay);
};
const hide = () => {
clearTimeout(timeout);
content.style.display = 'none';
trigger.removeAttribute('aria-describedby');
};
trigger.addEventListener('mouseenter', show);
trigger.addEventListener('mouseleave', hide);
trigger.addEventListener('focus', show);
trigger.addEventListener('blur', hide);
return {
destroy: () => {
clearTimeout(timeout);
trigger.removeEventListener('mouseenter', show);
trigger.removeEventListener('mouseleave', hide);
trigger.removeEventListener('focus', show);
trigger.removeEventListener('blur', hide);
}
};
}
export function focusTrap(container: Element): FocusTrapController {
const focusable = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const activate = () => {
const elements = container.querySelectorAll(focusable);
if (elements.length === 0) return;
const first = elements[0] as HTMLElement;
const last = elements[elements.length - 1] as HTMLElement;
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
container.addEventListener('keydown', handleTab as EventListener);
first.focus();
return () => container.removeEventListener('keydown', handleTab as EventListener);
};
let deactivate = () => {};
return {
activate: () => { deactivate = activate() || (() => {}); },
deactivate: () => deactivate()
};
}
export function menu(container: Element): Controller {
const items = container.querySelectorAll('[role="menuitem"]');
let currentIndex = 0;
const navigate = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
currentIndex = (currentIndex + 1) % items.length;
(items[currentIndex] as HTMLElement).focus();
break;
case 'ArrowUp':
e.preventDefault();
currentIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1;
(items[currentIndex] as HTMLElement).focus();
break;
case 'Escape':
e.preventDefault();
container.dispatchEvent(new CustomEvent('menu:close'));
break;
}
};
container.setAttribute('role', 'menu');
container.addEventListener('keydown', navigate as EventListener);
return {
destroy: () => container.removeEventListener('keydown', navigate as EventListener)
};
}
export default { dialog, tooltip, focusTrap, menu };