1import type { ConfirmDialogOptions, ConfirmDialogVariant } from "@t3tools/contracts";
2
3export type ConfirmDialogState =
4 | { readonly status: "idle" }
5 | {
6 readonly status: "confirming";
7 readonly message: string;
8 readonly variant: ConfirmDialogVariant;
9 readonly initialFocus: ConfirmDialogOptions["initialFocus"];
10 }
11 | {
12 readonly status: "closing";
13 readonly message: string;
14 readonly variant: ConfirmDialogVariant;
15 readonly initialFocus: ConfirmDialogOptions["initialFocus"];
16 };
17
18type PendingConfirmation = {
19 readonly message: string;
20 readonly variant: ConfirmDialogVariant;
21 readonly initialFocus: ConfirmDialogOptions["initialFocus"];
22 readonly resolve: (confirmed: boolean) => void;
23};
24
25const idleState: ConfirmDialogState = { status: "idle" };
26let state: ConfirmDialogState = idleState;
27let activeConfirmation: PendingConfirmation | null = null;
28let queuedConfirmations: PendingConfirmation[] = [];
29const listeners = new Set<() => void>();
30
31function publish(next: ConfirmDialogState): void {
32 state = next;
33 for (const listener of listeners) {
34 listener();
35 }
36}
37
38function resolvePendingConfirmations(confirmed: boolean): void {
39 activeConfirmation?.resolve(confirmed);
40 for (const confirmation of queuedConfirmations) {
41 confirmation.resolve(confirmed);
42 }
43 activeConfirmation = null;
44 queuedConfirmations = [];
45}
46
47export function readConfirmDialogState(): ConfirmDialogState {
48 return state;
49}
50
51/**
52 * Only themed hosts subscribe, so a subscriber is a mounted host. When the last
53 * one leaves, pending confirmations resolve as cancelled.
54 */
55export function subscribeConfirmDialogHost(listener: () => void): () => void {
56 listeners.add(listener);
57 return () => {
58 if (!listeners.delete(listener) || listeners.size > 0) return;
59 resolvePendingConfirmations(false);
60 publish(idleState);
61 };
62}
63
64/** Undefined means no themed host is available. */
65export function requestConfirmDialog(
66 message: string,
67 options?: ConfirmDialogOptions,
68): Promise<boolean> | undefined {
69 if (listeners.size === 0) return undefined;
70
71 const confirmation = new Promise<boolean>((resolve) => {
72 const pending = {
73 message,
74 variant: options?.variant ?? "default",
75 initialFocus: options?.initialFocus,
76 resolve,
77 } satisfies PendingConfirmation;
78 if (activeConfirmation || state.status === "closing") {
79 queuedConfirmations.push(pending);
80 return;
81 }
82
83 activeConfirmation = pending;
84 publish({
85 status: "confirming",
86 message,
87 variant: pending.variant,
88 initialFocus: pending.initialFocus,
89 });
90 });
91
92 return confirmation;
93}
94
95export function isConfirmDialogActive(): boolean {
96 return state.status !== "idle" || activeConfirmation !== null || queuedConfirmations.length > 0;
97}
98
99export function respondToConfirmDialog(confirmed: boolean): void {
100 if (state.status !== "confirming" || !activeConfirmation) return;
101
102 const confirmation = activeConfirmation;
103 activeConfirmation = null;
104 confirmation.resolve(confirmed);
105 publish({
106 status: "closing",
107 message: state.message,
108 variant: state.variant,
109 initialFocus: state.initialFocus,
110 });
111}
112
113export function completeConfirmDialogClose(): void {
114 if (state.status !== "closing") return;
115
116 const next = queuedConfirmations.shift();
117 if (!next) {
118 publish(idleState);
119 return;
120 }
121
122 activeConfirmation = next;
123 publish({
124 status: "confirming",
125 message: next.message,
126 variant: next.variant,
127 initialFocus: next.initialFocus,
128 });
129}
130
131export function resetConfirmDialogForTests(): void {
132 listeners.clear();
133 resolvePendingConfirmations(false);
134 publish(idleState);
135}