Review a destructive action confirmation dialog

src/confirmation-dialog.tsTypeScript
@@ -0,0 +1,79 @@
1+interface OpenDialogOptions {
2+ onConfirm: () => Promise<void>;
3+ onDismiss: () => void;
4+}
5+
6+export class ConfirmationDialog {
7+ private readonly panel: HTMLElement;
8+ private readonly cancelButton: HTMLButtonElement;
9+ private readonly confirmButton: HTMLButtonElement;
10+ private current: OpenDialogOptions | undefined;
11+ private returnFocus: HTMLElement | null = null;
12+ private pending = false;
13+
14+ constructor(private readonly backdrop: HTMLElement) {
15+ this.panel = this.required<HTMLElement>("[data-dialog-panel]");
16+ this.cancelButton = this.required<HTMLButtonElement>("[data-dialog-cancel]");
17+ this.confirmButton = this.required<HTMLButtonElement>("[data-dialog-confirm]");
18+
19+ this.cancelButton.addEventListener("click", () => this.requestDismiss());
20+ this.confirmButton.addEventListener("click", () => void this.confirm());
21+ this.backdrop.addEventListener("click", (event) => {
22+ if (event.target === this.backdrop) {
23+ this.hide();
24+ this.current?.onDismiss();
25+ }
26+ });
27+ }
28+
29+ open(options: OpenDialogOptions): void {
30+ this.current = options;
31+ this.backdrop.hidden = false;
32+ this.panel.setAttribute("aria-modal", "true");
33+ this.cancelButton.focus();
34+ this.returnFocus =
35+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
36+
37+ document.addEventListener("keydown", (event) => {
38+ if (event.key === "Escape" && !this.pending) {
39+ this.hide();
40+ options.onDismiss();
41+ }
42+ });
43+ }
44+
45+ private requestDismiss(): void {
46+ if (this.pending) return;
47+ this.hide();
48+ this.current?.onDismiss();
49+ }
50+
51+ private async confirm(): Promise<void> {
52+ if (!this.current || this.pending) return;
53+ this.setPending(true);
54+
55+ try {
56+ await this.current.onConfirm();
57+ } finally {
58+ this.setPending(false);
59+ }
60+ }
61+
62+ private setPending(pending: boolean): void {
63+ this.pending = pending;
64+ this.panel.toggleAttribute("aria-busy", pending);
65+ this.cancelButton.disabled = pending;
66+ this.confirmButton.disabled = pending;
67+ }
68+
69+ private hide(): void {
70+ this.backdrop.hidden = true;
71+ this.returnFocus?.focus();
72+ }
73+
74+ private required<T extends Element>(selector: string): T {
75+ const element = this.backdrop.querySelector<T>(selector);
76+ if (!element) throw new Error(`Missing dialog element: ${selector}`);
77+ return element;
78+ }
79+}