1
11
12
13export const FOCUSABLE_SELECTOR =
14 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])';
15
16
17export interface FocusableElement {
18 tagname?: string;
19 getAttribute?(name: string): string | null;
20 isContentEditable?: boolean;
21 closest?(selector: string): unknown;
22}
23
24const FOCUSABLE_TAGS = new Set(["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"]);
25
26
33export function isFocusable(el: FocusableElement): boolean {
34 const tag = (el.tagname ?? "").toUpperCase();
35 const disabled = el.getAttribute?.("disabled") != null;
36 const tabindex = el.getAttribute?.("tabindex");
37 if (tag === "A" || FOCUSABLE_TAGS.has(tag)) {
38 if (disabled) return false;
39 if (tag === "A" && el.getAttribute?.("href") == null) return false;
40 return true;
41 }
42 if (tabindex != null && tabindex !== "-1") return true;
43 if (el.isContentEditable === true) return true;
44 if (el.getAttribute?.("contenteditable") === "true") return true;
45 return false;
46}
47
48
58export function nextFocusable(
59 candidates: string[],
60 current: string,
61 shift: boolean,
62 focusableFlags: boolean[],
63): number {
64 const n = candidates.length;
65 if (n === 0) return -1;
66 const anyFocusable = focusableFlags.some(Boolean);
67 if (!anyFocusable) return -1;
68
69 const currentIdx = candidates.indexOf(current);
70 const start = currentIdx >= 0 ? currentIdx : shift ? 0 : n - 1;
71 const step = shift ? -1 : 1;
72 for (let i = 1; i <= n; i += 1) {
73 const idx = (((start + step * i) % n) + n) % n;
74 const flag = focusableFlags[idx] ?? false;
75 if (flag) return idx;
76 }
77 return -1;
78}
79
80export interface TrapOptions {
81
82 container: unknown;
83
84 returnFocus?: boolean;
85}
86
87export interface FocusTrap {
88 activate(): void;
89 deactivate(): void;
90}
91
92
107export function trapFocus(options: TrapOptions): FocusTrap {
108 const container = options.container as {
109 querySelectorAll?: (sel: string) => ArrayLike<FocusableElement & { focus?: () => void }>;
110 addEventListener?: (
111 type: string,
112 handler: (e: { key?: string; shiftKey?: boolean; preventDefault?: () => void }) => void,
113 ) => void;
114 removeEventListener?: (type: string, handler: (e: unknown) => void) => void;
115 } | null;
116 let previous: { focus?: () => void } | null = null;
117 let handler:
118 | ((e: { key?: string; shiftKey?: boolean; preventDefault?: () => void }) => void)
119 | null = null;
120
121 const onKeyDown = (e: {
122 key?: string;
123 shiftKey?: boolean;
124 preventDefault?: () => void;
125 }): void => {
126 if (e.key !== "Tab") return;
127 if (!container) return;
128 const list = Array.from(container.querySelectorAll?.(FOCUSABLE_SELECTOR) ?? []);
129 if (list.length === 0) {
130 e.preventDefault?.();
131 return;
132 }
133 const current = (container as { ownerDocument?: { activeElement?: unknown } }).ownerDocument
134 ?.activeElement as (FocusableElement & { focus?: () => void }) | undefined;
135 const currentKey = list.findIndex(
136 (el) => el === current || (el as unknown) === (current as unknown),
137 );
138 const flags = list.map((el) => isFocusable(el));
139 const nextIdx = nextFocusable(
140 list.map((_, i) => String(i)),
141 currentKey >= 0 ? String(currentKey) : "-1",
142 Boolean(e.shiftKey),
143 flags,
144 );
145 if (nextIdx >= 0) {
146 e.preventDefault?.();
147 (list[nextIdx] as { focus?: () => void } | undefined)?.focus?.();
148 }
149 };
150
151 return {
152 activate(): void {
153 if (!container) return;
154 previous = (container as unknown as { ownerDocument?: { activeElement?: unknown } })
155 .ownerDocument?.activeElement as { focus?: () => void } | null;
156 const list = Array.from(container.querySelectorAll?.(FOCUSABLE_SELECTOR) ?? []);
157 const firstIdx = list.findIndex((el) => isFocusable(el));
158 (firstIdx >= 0 ? (list[firstIdx] as { focus?: () => void }) : null)?.focus?.();
159 handler = onKeyDown;
160 container.addEventListener?.("keydown", handler);
161 },
162 deactivate(): void {
163 if (handler)
164 container?.removeEventListener?.(
165 "keydown",
166 handler as (e: unknown) => void,
167 );
168 handler = null;
169 if (options.returnFocus === true) previous?.focus?.();
170 previous = null;
171 },
172 };
173}
174