Review an accessible modal focus lifecycle

src/confirmation-modal.tsTypeScript
@@ -0,0 +1,65 @@
1+const FOCUSABLE_SELECTOR = [
2+ "button:not([disabled])",
3+ "a[href]",
4+ "input:not([disabled])",
5+ "select:not([disabled])",
6+ "textarea:not([disabled])",
7+ '[tabindex]:not([tabindex="-1"])',
8+].join(",");
9+
10+function focusableElements(root: HTMLElement): HTMLElement[] {
11+ return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
12+}
13+
14+export class ConfirmationModal {
15+ private previouslyFocused: HTMLElement | null = null;
16+ private pending = false;
17+
18+ constructor(
19+ private readonly backdrop: HTMLElement,
20+ private readonly dialog: HTMLElement,
21+ private readonly cancelButton: HTMLButtonElement,
22+ ) {}
23+
24+ open(): void {
25+ this.backdrop.hidden = false;
26+ const initialTarget = focusableElements(this.dialog)[0] ?? this.dialog;
27+ initialTarget.focus();
28+ this.previouslyFocused =
29+ document.activeElement instanceof HTMLElement
30+ ? document.activeElement
31+ : null;
32+
33+ document.addEventListener("keydown", this.onKeyDown.bind(this));
34+ this.backdrop.addEventListener("click", this.onBackdropClick);
35+ }
36+
37+ close(): void {
38+ this.setPending(false);
39+ this.backdrop.hidden = true;
40+ document.removeEventListener("keydown", this.onKeyDown.bind(this));
41+ this.backdrop.removeEventListener("click", this.onBackdropClick);
42+
43+ if (this.previouslyFocused?.isConnected) {
44+ this.previouslyFocused.focus();
45+ }
46+ this.previouslyFocused = null;
47+ }
48+
49+ setPending(pending: boolean): void {
50+ this.pending = pending;
51+ this.cancelButton.disabled = pending;
52+ this.dialog.setAttribute("aria-busy", String(pending));
53+ }
54+
55+ private onKeyDown(event: KeyboardEvent): void {
56+ if (event.key === "Escape") {
57+ event.preventDefault();
58+ this.close();
59+ }
60+ }
61+
62+ private readonly onBackdropClick = (): void => {
63+ this.close();
64+ };
65+}