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 HTMLElement30+
? document.activeElement31+
: 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+
}