feat(web): add Modal + EmptyState primitives and a11y hooks
- Modal: shared backdrop/panel with ESC-close, backdrop-click-close, focus trap, body scroll lock; supports center + drawer-right variants, matrix/violet accents, default/wide widths. - EmptyState: icon + title + hint + optional CTA; compact variant for tight rails. - useEscapeKey, useFocusTrap: reusable hooks powering Modal; will also be adopted by CommandPalette and ContextMenu in follow-up commits. No retrofits yet — primitives only. tsc clean.
This commit is contained in:
15
decnet_web/src/hooks/useEscapeKey.ts
Normal file
15
decnet_web/src/hooks/useEscapeKey.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useEscapeKey(onEscape: () => void, active: boolean = true): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onEscape();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onEscape, active]);
|
||||
}
|
||||
49
decnet_web/src/hooks/useFocusTrap.ts
Normal file
49
decnet_web/src/hooks/useFocusTrap.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE =
|
||||
'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export function useFocusTrap(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
active: boolean,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!active || !ref.current) return;
|
||||
const root = ref.current;
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
const focusables = () =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null,
|
||||
);
|
||||
|
||||
const autoFocus =
|
||||
root.querySelector<HTMLElement>('[data-autofocus]') ?? focusables()[0];
|
||||
autoFocus?.focus();
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const items = focusables();
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey && current === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && current === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
root.addEventListener('keydown', handler);
|
||||
return () => {
|
||||
root.removeEventListener('keydown', handler);
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [ref, active]);
|
||||
}
|
||||
Reference in New Issue
Block a user