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+
}