@ai-matrx/kit 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/README.md +5 -1
- package/dist/confirm-opener.cjs +129 -30
- package/dist/confirm-opener.cjs.map +1 -1
- package/dist/confirm-opener.d.cts +13 -38
- package/dist/confirm-opener.d.ts +13 -38
- package/dist/confirm-opener.js +129 -30
- package/dist/confirm-opener.js.map +1 -1
- package/dist/opener-react.cjs +70 -0
- package/dist/opener-react.cjs.map +1 -0
- package/dist/opener-react.d.cts +148 -0
- package/dist/opener-react.d.ts +148 -0
- package/dist/opener-react.js +40 -0
- package/dist/opener-react.js.map +1 -0
- package/dist/opener.cjs +147 -0
- package/dist/opener.cjs.map +1 -0
- package/dist/opener.d.cts +131 -0
- package/dist/opener.d.ts +131 -0
- package/dist/opener.js +126 -0
- package/dist/opener.js.map +1 -0
- package/package.json +22 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 — 2026-09-11
|
|
4
|
+
|
|
5
|
+
**THE OPENER PATTERN IS NOW A PRIMITIVE.** `@ai-matrx/kit/opener` +
|
|
6
|
+
`@ai-matrx/kit/opener-react` ship the engine behind an imperative global
|
|
7
|
+
dialog; `@ai-matrx/kit/confirm-opener` is now a thin specialisation of it, on
|
|
8
|
+
the SAME registry and the SAME `globalThis` slot.
|
|
9
|
+
|
|
10
|
+
**Why.** The confirm opener was the fourth copy of one contract, not the first.
|
|
11
|
+
matrx-frontend had three more — the sandbox pre-send gate, the chat/scope
|
|
12
|
+
mismatch gate, and the `prompt_user` value prompts — each hand-rolling the same
|
|
13
|
+
`let host`, the same `queue`, the same `_registerHost` drain loop, the same
|
|
14
|
+
never-resolve-silently promise, and each host component re-writing the same
|
|
15
|
+
ref-backed queue + `tick` + drain effect. Three of them kept that state in
|
|
16
|
+
module-level variables, which is the dual-loader-graph hazard C23 names: the
|
|
17
|
+
moment such an opener leaves one app's bundle, host registration silently
|
|
18
|
+
splits from its callers and the promise hangs forever with no error anywhere.
|
|
19
|
+
Package logic is never duplicated outside the package, so the pattern moved in.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `@ai-matrx/kit/opener` — `createOpener<TRequest, TResponse>(slot, options)`.
|
|
24
|
+
Strictly typed request/response, one host at a time, pre-mount requests
|
|
25
|
+
queued in call order, and the never-a-silent-default rule. New beyond what
|
|
26
|
+
the four copies had:
|
|
27
|
+
- **the no-host scream** (platform law 4): a request that has waited
|
|
28
|
+
`warnWithoutHostAfterMs` (default 5s) with no host logs a console error
|
|
29
|
+
naming the exact component to mount (`hostHint`) instead of hanging
|
|
30
|
+
invisibly. Screaming never changes the answer — the promise stays pending
|
|
31
|
+
and still resolves when a host mounts.
|
|
32
|
+
- **`dedupeKey`**: concurrent duplicates collapse onto ONE dialog and ONE
|
|
33
|
+
promise, so a double-clicked send cannot stack two gates.
|
|
34
|
+
- **`AbortSignal` cancellation** per call: the request is dropped from the
|
|
35
|
+
queue and the promise settles — resolved with `onAbortResolveWith` when
|
|
36
|
+
the response type has a neutral answer (`"cancel"`, `null`), rejected with
|
|
37
|
+
an `AbortError` otherwise. A host answering an already-settled request is
|
|
38
|
+
ignored, never a double-resolve.
|
|
39
|
+
- `@ai-matrx/kit/opener-react` — `useOpenerHost(opener, { onActivate })`,
|
|
40
|
+
returning `{ request, open, settle }`. The mount-side machinery every host
|
|
41
|
+
was re-writing: registration, the ref-backed queue, the drain effect, and a
|
|
42
|
+
settle-once callback (a dismiss handler racing a button click is the normal
|
|
43
|
+
case, not a bug). `onActivate` seeds per-request form state inside the same
|
|
44
|
+
React update, so no frame shows the previous request's values.
|
|
45
|
+
- `confirmOpener` and `ConfirmHostController` from
|
|
46
|
+
`@ai-matrx/kit/confirm-opener`, for hosts that prefer `useOpenerHost` over
|
|
47
|
+
hand-wiring `_registerHost`.
|
|
48
|
+
|
|
49
|
+
### Unchanged
|
|
50
|
+
|
|
51
|
+
- `confirm`, `ConfirmOptions`, `_registerHost`, `_unregisterHost`,
|
|
52
|
+
`_resetConfirmOpenerState` — same names, same behaviour, same
|
|
53
|
+
`Symbol.for("ai-matrx.kit.confirm-opener-state")` slot. A
|
|
54
|
+
`<ConfirmDialogHost />` from any released `@ai-matrx/design-system` still
|
|
55
|
+
meets its callers; there is no second registry.
|
|
56
|
+
|
|
57
|
+
**Consumer action:** none required. Build any app-side imperative dialog on
|
|
58
|
+
`createOpener` + `useOpenerHost` instead of hand-rolling a host registry — and
|
|
59
|
+
delete the hand-rolled one you already have.
|
|
60
|
+
|
|
3
61
|
## 0.9.2 — 2026-09-11
|
|
4
62
|
|
|
5
63
|
Removed unused URL-state destructuring so strict workspace consumers with
|
package/README.md
CHANGED
|
@@ -12,7 +12,9 @@ loaded lazily only when the browser has no native `BarcodeDetector`).
|
|
|
12
12
|
[`@ai-matrx/design-system`](../design-system/README.md), which owns the AlertDialog,
|
|
13
13
|
the shipped motion layer and the overlay scrim token. Mount
|
|
14
14
|
`<ConfirmDialogHost />` from there once at your app root, then call `confirm()`
|
|
15
|
-
from `@ai-matrx/kit/confirm-opener` anywhere.
|
|
15
|
+
from `@ai-matrx/kit/confirm-opener` anywhere. Since 0.10.0 that opener is a
|
|
16
|
+
thin specialisation of `@ai-matrx/kit/opener` — the same engine any app can use
|
|
17
|
+
for its own imperative dialogs. One surface, one owner: kit
|
|
16
18
|
re-exports none of the dialog.
|
|
17
19
|
|
|
18
20
|
```bash
|
|
@@ -29,6 +31,8 @@ npm install @ai-matrx/kit
|
|
|
29
31
|
| `@ai-matrx/kit/concurrency` | `runWithConcurrency` | Bounded worker pool over an array with per-item failure isolation and a cooperative `shouldStart()` cancel hook. |
|
|
30
32
|
| `@ai-matrx/kit/text-case` | `formatText`, `createFormatter` | Acronym-aware normalization of messy DB/API strings into title/sentence case with word-replacement overrides. |
|
|
31
33
|
| `@ai-matrx/kit/drafts` | `useDurableDraft` + the local-drafts store | User-authored text that survives anything: write-through localStorage drafts, restore on mount, key-change adoption, cleared only after content durably landed. |
|
|
34
|
+
| `@ai-matrx/kit/opener` | `createOpener`, `Opener`, `OpenerHostController`, `OpenerAbortError` | **THE opener pattern, once.** Build an imperative `await open(request)` for any global dialog: pure TS, zero React, so hundreds of call sites (thunks, utils, sync handlers) import it at near-zero cost while the heavy body stays behind a lazy host. You get the whole contract for free — one host at a time, requests queued in call order before the host hydrates, never a silent default answer, a console scream naming the component to mount when a request waits with no host, opt-in dedupe so a double-clicked action cannot stack two dialogs, and opt-in `AbortSignal` cancellation. State lives on a `globalThis` `Symbol.for(slot)`, so host and caller find each other across packages AND across ESM/CJS module graphs. |
|
|
35
|
+
| `@ai-matrx/kit/opener-react` | `useOpenerHost` | The mount side of an opener: registers the component as THE host, drains queued requests one at a time, and hands back `{ request, open, settle }`. React only — no markup, no CSS — so the dialog body stays entirely yours. |
|
|
32
36
|
| `@ai-matrx/kit/confirm-opener` | `confirm`, `ConfirmOptions` | The `window.confirm` replacement's imperative half — pure TS, zero React, zero radix, no `"use client"`, so a Redux thunk, a util or a sync handler can import it at near-zero bundle cost. `await confirm({ title, description, variant: "destructive" })` resolves `true`/`false`; pre-mount calls queue and concurrent calls present one at a time; with no host mounted it stays pending forever rather than inventing a silent default. Needs `<ConfirmDialogHost />` from `@ai-matrx/design-system` mounted once — the two share one registry through a `globalThis` slot. |
|
|
33
37
|
| `@ai-matrx/kit/toast` | `createMatrxToast` | The captured sonner wrapper as a factory: pass your sonner `toast` in, get a drop-in `toast` back whose `.error`/`.warning` also feed your injected error-capture sink, plus `toastErrorAlreadyCaptured` for notices whose failure was already captured upstream. No sonner import — and sonner is not declared as a dependency or peer at all. |
|
|
34
38
|
| `@ai-matrx/kit/invalidation` | `registerInvalidationCallback`, `fireInvalidation` | Zero-import, name-keyed callback registry that breaks import edges into heavy chunk clusters: the heavy cluster registers at its own module init, the ubiquitous module fires by name. Unregistered names are a deliberate no-op; a throwing callback screams and never breaks the caller. Keep your key constants in ONE host module. |
|
package/dist/confirm-opener.cjs
CHANGED
|
@@ -23,46 +23,145 @@ __export(confirm_opener_exports, {
|
|
|
23
23
|
_registerHost: () => _registerHost,
|
|
24
24
|
_resetConfirmOpenerState: () => _resetConfirmOpenerState,
|
|
25
25
|
_unregisterHost: () => _unregisterHost,
|
|
26
|
-
confirm: () => confirm
|
|
26
|
+
confirm: () => confirm,
|
|
27
|
+
confirmOpener: () => confirmOpener
|
|
27
28
|
});
|
|
28
29
|
module.exports = __toCommonJS(confirm_opener_exports);
|
|
29
30
|
|
|
30
|
-
// src/
|
|
31
|
-
var
|
|
32
|
-
|
|
31
|
+
// src/opener.ts
|
|
32
|
+
var OpenerAbortError = class extends Error {
|
|
33
|
+
name = "AbortError";
|
|
34
|
+
constructor(slot) {
|
|
35
|
+
super(`The ${slot} request was aborted before the user answered it.`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
function getState(slot) {
|
|
33
39
|
const holder = globalThis;
|
|
34
|
-
|
|
40
|
+
const symbol = Symbol.for(slot);
|
|
41
|
+
let state = holder[symbol];
|
|
35
42
|
if (!state) {
|
|
36
|
-
state = { host: null, queue: [] };
|
|
37
|
-
holder[
|
|
43
|
+
state = { host: null, queue: [], inFlight: /* @__PURE__ */ new Map(), warnTimer: null, warned: false };
|
|
44
|
+
holder[symbol] = state;
|
|
38
45
|
}
|
|
39
46
|
return state;
|
|
40
47
|
}
|
|
41
|
-
function
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
function createOpener(slot, options = {}) {
|
|
49
|
+
const warnAfterMs = options.warnWithoutHostAfterMs ?? 5e3;
|
|
50
|
+
const hostHint = options.hostHint;
|
|
51
|
+
const dedupeKeyOf = options.dedupeKey;
|
|
52
|
+
function clearWarnTimer(state) {
|
|
53
|
+
if (state.warnTimer !== null) {
|
|
54
|
+
clearTimeout(state.warnTimer);
|
|
55
|
+
state.warnTimer = null;
|
|
56
|
+
}
|
|
47
57
|
}
|
|
58
|
+
function armWarnTimer(state) {
|
|
59
|
+
if (warnAfterMs <= 0 || state.warned || state.warnTimer !== null) return;
|
|
60
|
+
state.warnTimer = setTimeout(() => {
|
|
61
|
+
state.warnTimer = null;
|
|
62
|
+
if (state.host || state.queue.length === 0) return;
|
|
63
|
+
state.warned = true;
|
|
64
|
+
console.error(
|
|
65
|
+
`[@ai-matrx/kit/opener] ${state.queue.length} request(s) on "${slot}" have been waiting ${warnAfterMs}ms with no host registered, so the awaiting code is stuck and the user sees nothing. REMEDY: mount ${hostHint ?? `the host for "${slot}"`} once, near the root of this provider tree.`
|
|
66
|
+
);
|
|
67
|
+
}, warnAfterMs);
|
|
68
|
+
state.warnTimer.unref?.();
|
|
69
|
+
}
|
|
70
|
+
function drainTo(state, controller) {
|
|
71
|
+
while (state.queue.length > 0) {
|
|
72
|
+
const next = state.queue.shift();
|
|
73
|
+
if (next.settled) continue;
|
|
74
|
+
controller.show(next.request, next.settle);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const opener = {
|
|
78
|
+
slot,
|
|
79
|
+
open(request, requestOptions = {}) {
|
|
80
|
+
const state = getState(slot);
|
|
81
|
+
const signal = requestOptions.signal;
|
|
82
|
+
const dedupeKey = dedupeKeyOf?.(request);
|
|
83
|
+
if (dedupeKey !== void 0) {
|
|
84
|
+
const existing = state.inFlight.get(dedupeKey);
|
|
85
|
+
if (existing && !existing.entry.settled) return existing.promise;
|
|
86
|
+
}
|
|
87
|
+
let entry;
|
|
88
|
+
const promise = new Promise((resolve, reject) => {
|
|
89
|
+
const finish = (run) => {
|
|
90
|
+
if (entry.settled) return;
|
|
91
|
+
entry.settled = true;
|
|
92
|
+
if (entry.dedupeKey !== void 0) state.inFlight.delete(entry.dedupeKey);
|
|
93
|
+
const queuedAt = state.queue.indexOf(entry);
|
|
94
|
+
if (queuedAt >= 0) state.queue.splice(queuedAt, 1);
|
|
95
|
+
if (state.queue.length === 0) clearWarnTimer(state);
|
|
96
|
+
run();
|
|
97
|
+
};
|
|
98
|
+
entry = {
|
|
99
|
+
request,
|
|
100
|
+
dedupeKey,
|
|
101
|
+
settled: false,
|
|
102
|
+
settle: (response) => finish(() => resolve(response))
|
|
103
|
+
};
|
|
104
|
+
const abort = () => finish(() => {
|
|
105
|
+
if ("onAbortResolveWith" in requestOptions && requestOptions.onAbortResolveWith !== void 0) {
|
|
106
|
+
resolve(requestOptions.onAbortResolveWith);
|
|
107
|
+
} else {
|
|
108
|
+
reject(new OpenerAbortError(slot));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
if (signal?.aborted) {
|
|
112
|
+
abort();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
116
|
+
});
|
|
117
|
+
if (entry.settled) return promise;
|
|
118
|
+
if (dedupeKey !== void 0) state.inFlight.set(dedupeKey, { entry, promise });
|
|
119
|
+
if (state.host) {
|
|
120
|
+
state.host.show(entry.request, entry.settle);
|
|
121
|
+
} else {
|
|
122
|
+
state.queue.push(entry);
|
|
123
|
+
armWarnTimer(state);
|
|
124
|
+
}
|
|
125
|
+
return promise;
|
|
126
|
+
},
|
|
127
|
+
_registerHost(controller) {
|
|
128
|
+
const state = getState(slot);
|
|
129
|
+
state.host = controller;
|
|
130
|
+
state.warned = false;
|
|
131
|
+
clearWarnTimer(state);
|
|
132
|
+
drainTo(state, controller);
|
|
133
|
+
},
|
|
134
|
+
_unregisterHost(controller) {
|
|
135
|
+
const state = getState(slot);
|
|
136
|
+
if (state.host === controller) state.host = null;
|
|
137
|
+
},
|
|
138
|
+
_reset() {
|
|
139
|
+
const state = getState(slot);
|
|
140
|
+
clearWarnTimer(state);
|
|
141
|
+
state.host = null;
|
|
142
|
+
state.queue.length = 0;
|
|
143
|
+
state.inFlight.clear();
|
|
144
|
+
state.warned = false;
|
|
145
|
+
},
|
|
146
|
+
_hasHost() {
|
|
147
|
+
return getState(slot).host !== null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
return opener;
|
|
48
151
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
state
|
|
56
|
-
|
|
57
|
-
|
|
152
|
+
|
|
153
|
+
// src/confirm/opener.ts
|
|
154
|
+
var confirmOpener = createOpener(
|
|
155
|
+
// 🚨 The slot name is a PUBLIC name: it is how callers in one module graph
|
|
156
|
+
// (or one package) find the host in another. It has been this string since
|
|
157
|
+
// the opener existed — never change it.
|
|
158
|
+
"ai-matrx.kit.confirm-opener-state",
|
|
159
|
+
{ hostHint: "<ConfirmDialogHost /> from @ai-matrx/design-system" }
|
|
160
|
+
);
|
|
161
|
+
var _registerHost = confirmOpener._registerHost;
|
|
162
|
+
var _unregisterHost = confirmOpener._unregisterHost;
|
|
163
|
+
var _resetConfirmOpenerState = confirmOpener._reset;
|
|
58
164
|
function confirm(opts) {
|
|
59
|
-
return
|
|
60
|
-
const state = getState();
|
|
61
|
-
if (state.host) {
|
|
62
|
-
state.host.show(opts, resolve);
|
|
63
|
-
} else {
|
|
64
|
-
state.queue.push({ opts, resolve });
|
|
65
|
-
}
|
|
66
|
-
});
|
|
165
|
+
return confirmOpener.open(opts);
|
|
67
166
|
}
|
|
68
167
|
//# sourceMappingURL=confirm-opener.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/confirm-opener.ts","../src/confirm/opener.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/confirm-opener — the imperative `confirm()` API alone.\n *\n * THE WHOLE CONFIRM SYSTEM THIS PACKAGE SHIPS (since 0.9.0, census row 19i).\n * The dialog BODY — `ConfirmDialog` and `ConfirmDialogHost` — lives in\n * `@ai-matrx/design-system`, which owns the AlertDialog, the shipped motion\n * layer and the `--matrx-overlay-scrim` token this surface needs and kit could\n * neither ship nor import (design-system depends on kit; importing back is a\n * cycle). kit re-exports none of it: one surface, one owner.\n *\n * What is here is the pure half: zero React runtime, zero radix, no\n * \"use client\" banner — statically importable from anywhere (hooks, utils,\n * Redux thunks, sync code) at near-zero bundle cost, which is what lets a host\n * keep hundreds of imperative call sites out of the dialog's chunk.\n *\n * Wiring, end to end:\n * 1. mount `<ConfirmDialogHost />` from `@ai-matrx/design-system` once, near\n * the root of every provider tree;\n * 2. `await confirm({ ... })` from anywhere, importing from HERE.\n * The two find each other through the\n * `Symbol.for(\"ai-matrx.kit.confirm-opener-state\")` globalThis slot, so they\n * observe one registration across every module graph and both packages.\n */\nexport {\n confirm,\n _registerHost,\n _unregisterHost,\n _resetConfirmOpenerState,\n type ConfirmOptions,\n} from \"./confirm/opener\";\n","/**\n * @ai-matrx/kit/confirm-opener — the imperative opener, and since 0.9.0 the\n * ONLY half of the confirm system this package ships.\n *\n * Pure-TS imperative API for the global confirm dialog. Zero React, zero\n * dialog markup — this module is statically importable from anywhere\n * (hooks, utils, Redux thunks, async handlers, sync code, anything).\n *\n * The host (`ConfirmDialogHost`, in `@ai-matrx/design-system`) registers a\n * controller on mount and\n * unregisters on unmount. Calls made before the host has hydrated queue\n * up and resolve as soon as the host is alive — so a destructive action\n * triggered in the first ~50ms after page load still gets a real\n * confirmation, never a silent default-yes/no. With no host ever mounted,\n * a `confirm()` promise stays pending forever (the original's behavior —\n * it never resolves to a silent default).\n *\n * One dialog at a time: concurrent calls queue and present sequentially.\n *\n * Ported verbatim from matrx-frontend\n * `components/dialogs/confirm/confirmDialogOpener.ts`, with ONE structural\n * inversion: the host/queue state lives on `globalThis` under a\n * `Symbol.for` slot instead of module-level variables. With the package\n * built `splitting: false` in dual ESM/CJS format, and the host now living in\n * a DIFFERENT PACKAGE, CJS/ESM each instantiate their own module graph — a module-level variable would\n * silently split the host registration from the callers (the same hazard\n * `@ai-matrx/tap-target` documents for its link registry). Behavior is\n * unchanged; never \"clean this up\" into a module local.\n */\n\nimport type { ReactNode } from \"react\";\n\nexport interface ConfirmOptions {\n title: ReactNode;\n description?: ReactNode | undefined;\n confirmLabel?: string | undefined;\n /** `null` hides the cancel button (acknowledge-only dialogs). */\n cancelLabel?: string | null | undefined;\n variant?: \"default\" | \"destructive\" | undefined;\n}\n\ntype Resolver = (confirmed: boolean) => void;\n\ninterface PendingRequest {\n opts: ConfirmOptions;\n resolve: Resolver;\n}\n\ninterface HostController {\n show: (opts: ConfirmOptions, resolve: Resolver) => void;\n}\n\ninterface OpenerState {\n host: HostController | null;\n queue: PendingRequest[];\n}\n\nconst STATE_SLOT = Symbol.for(\"ai-matrx.kit.confirm-opener-state\");\n\nfunction getState(): OpenerState {\n const holder = globalThis as Record<symbol, OpenerState | undefined>;\n let state = holder[STATE_SLOT];\n if (!state) {\n state = { host: null, queue: [] };\n holder[STATE_SLOT] = state;\n }\n return state;\n}\n\n/** @internal Called by `ConfirmDialogHost` on mount. */\nexport function _registerHost(controller: HostController): void {\n const state = getState();\n state.host = controller;\n while (state.queue.length > 0) {\n const next = state.queue.shift()!;\n controller.show(next.opts, next.resolve);\n }\n}\n\n/** @internal Called by `ConfirmDialogHost` on unmount. */\nexport function _unregisterHost(controller: HostController): void {\n const state = getState();\n if (state.host === controller) state.host = null;\n}\n\n/** @internal Test-only: drop any registered host and pending queue. */\nexport function _resetConfirmOpenerState(): void {\n const state = getState();\n state.host = null;\n state.queue.length = 0;\n}\n\n/**\n * Imperative confirm. Returns a Promise that resolves `true` if the user\n * confirms, `false` if they cancel/dismiss. Replaces `window.confirm`.\n *\n * @example\n * const ok = await confirm({\n * title: \"Delete sandbox\",\n * description: \"This cannot be undone.\",\n * variant: \"destructive\",\n * confirmLabel: \"Delete\",\n * });\n * if (!ok) return;\n */\nexport function confirm(opts: ConfirmOptions): Promise<boolean> {\n return new Promise<boolean>((resolve) => {\n const state = getState();\n if (state.host) {\n state.host.show(opts, resolve);\n } else {\n state.queue.push({ opts, resolve });\n }\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACyDA,IAAM,aAAa,uBAAO,IAAI,mCAAmC;AAEjE,SAAS,WAAwB;AAC/B,QAAM,SAAS;AACf,MAAI,QAAQ,OAAO,UAAU;AAC7B,MAAI,CAAC,OAAO;AACV,YAAQ,EAAE,MAAM,MAAM,OAAO,CAAC,EAAE;AAChC,WAAO,UAAU,IAAI;AAAA,EACvB;AACA,SAAO;AACT;AAGO,SAAS,cAAc,YAAkC;AAC9D,QAAM,QAAQ,SAAS;AACvB,QAAM,OAAO;AACb,SAAO,MAAM,MAAM,SAAS,GAAG;AAC7B,UAAM,OAAO,MAAM,MAAM,MAAM;AAC/B,eAAW,KAAK,KAAK,MAAM,KAAK,OAAO;AAAA,EACzC;AACF;AAGO,SAAS,gBAAgB,YAAkC;AAChE,QAAM,QAAQ,SAAS;AACvB,MAAI,MAAM,SAAS,WAAY,OAAM,OAAO;AAC9C;AAGO,SAAS,2BAAiC;AAC/C,QAAM,QAAQ,SAAS;AACvB,QAAM,OAAO;AACb,QAAM,MAAM,SAAS;AACvB;AAeO,SAAS,QAAQ,MAAwC;AAC9D,SAAO,IAAI,QAAiB,CAAC,YAAY;AACvC,UAAM,QAAQ,SAAS;AACvB,QAAI,MAAM,MAAM;AACd,YAAM,KAAK,KAAK,MAAM,OAAO;AAAA,IAC/B,OAAO;AACL,YAAM,MAAM,KAAK,EAAE,MAAM,QAAQ,CAAC;AAAA,IACpC;AAAA,EACF,CAAC;AACH;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/confirm-opener.ts","../src/opener.ts","../src/confirm/opener.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/confirm-opener — the imperative `confirm()` API alone.\n *\n * Since 0.10.0 this is a thin specialisation of `@ai-matrx/kit/opener`: the\n * host registry, queue, globalThis slot and no-host scream are the generic\n * engine's, and the confirm subpath is its types plus the names call sites\n * already import. One registry, not two — the slot is byte-identical.\n *\n * THE WHOLE CONFIRM SYSTEM THIS PACKAGE SHIPS (since 0.9.0, census row 19i).\n * The dialog BODY — `ConfirmDialog` and `ConfirmDialogHost` — lives in\n * `@ai-matrx/design-system`, which owns the AlertDialog, the shipped motion\n * layer and the `--matrx-overlay-scrim` token this surface needs and kit could\n * neither ship nor import (design-system depends on kit; importing back is a\n * cycle). kit re-exports none of it: one surface, one owner.\n *\n * What is here is the pure half: zero React runtime, zero radix, no\n * \"use client\" banner — statically importable from anywhere (hooks, utils,\n * Redux thunks, sync code) at near-zero bundle cost, which is what lets a host\n * keep hundreds of imperative call sites out of the dialog's chunk.\n *\n * Wiring, end to end:\n * 1. mount `<ConfirmDialogHost />` from `@ai-matrx/design-system` once, near\n * the root of every provider tree;\n * 2. `await confirm({ ... })` from anywhere, importing from HERE.\n * The two find each other through the\n * `Symbol.for(\"ai-matrx.kit.confirm-opener-state\")` globalThis slot, so they\n * observe one registration across every module graph and both packages.\n */\nexport {\n confirm,\n confirmOpener,\n _registerHost,\n _unregisterHost,\n _resetConfirmOpenerState,\n type ConfirmOptions,\n type ConfirmHostController,\n} from \"./confirm/opener\";\n","/**\n * @ai-matrx/kit/opener — THE opener pattern, once.\n *\n * An \"opener\" is the imperative half of a global dialog: a pure-TS function\n * you can `await` from anywhere (a Redux thunk, a util, a sync handler, an\n * async pipeline) that resolves with what the user chose, plus a registry a\n * React host attaches itself to on mount. The whole point is that the CALL\n * SITE never imports the dialog: the opener is zero-React, zero-markup,\n * zero-CSS, so hundreds of static call sites cost nothing and the heavy body\n * stays behind a lazy host.\n *\n * This module is the generic engine. `./confirm-opener` is a three-line\n * specialisation of it, and so is every app-side opener — the sandbox gate,\n * the scope-mismatch gate, the value prompts. Before this existed each one\n * hand-rolled the same `let host`, the same `queue`, the same\n * `_registerHost` drain loop, the same never-resolve-silently promise; four\n * copies of one contract, three of them with module-level state that would\n * have split across loader graphs the moment they left the app.\n *\n * THE CONTRACT — every opener made here behaves identically:\n *\n * • ONE host at a time. It registers on mount and unregisters on unmount;\n * a late unmount of a SUPERSEDED host never tears down the live one\n * (the React double-mount / provider-swap case).\n * • NEVER a silent default. With no host mounted, `open()` does NOT resolve\n * to some assumed yes/no — the promise stays pending and the request\n * queues, so a destructive action fired in the first ~50ms after page load\n * still gets a real dialog once the host hydrates. Requests are served in\n * call order.\n * • NOTHING FAILS SILENTLY (platform law 4). \"Stays pending\" would be an\n * invisible hang if the host is never mounted at all, so an opener that\n * has waited `warnWithoutHostAfterMs` with a queued request SCREAMS on the\n * console, naming the remedy (`hostHint`) — once per opener, cleared the\n * moment a host registers.\n * • DEDUPE is opt-in. With a `dedupeKey`, a second `open()` for the same key\n * while the first is unsettled returns THE SAME promise instead of\n * stacking a second dialog (double-clicked send buttons).\n * • CANCELLATION is opt-in per call. Pass an `AbortSignal` and the pending\n * request is dropped from the queue and settled — resolved with\n * `onAbortResolveWith` when the caller supplies a neutral answer\n * (\"cancel\"), rejected with an `AbortError` otherwise. A host that answers\n * an already-settled request is ignored, never a double-resolve.\n *\n * 🚨 STATE LIVES ON A `globalThis` SLOT, NEVER A MODULE LOCAL. With the\n * package built `splitting: false` in dual ESM/CJS format — and the host\n * routinely living in a DIFFERENT PACKAGE from the caller — each loader graph\n * instantiates its own copy of this module. A module-level `let host` would\n * silently split host registration from the callers: the dialog mounts, the\n * caller queues into a different registry, and the promise hangs forever with\n * no error anywhere. Same hazard `@ai-matrx/tap-target` documents for its link\n * registry. Never \"clean this up\" into a module local; the tarball canary\n * proves the slot spans both graphs.\n */\n\n/** What a mounted host must provide. */\nexport interface OpenerHostController<TRequest, TResponse> {\n show: (request: TRequest, resolve: (response: TResponse) => void) => void;\n}\n\ninterface PendingEntry<TRequest, TResponse> {\n request: TRequest;\n /** Wrapped resolver: settles once, then every later call is a no-op. */\n settle: (response: TResponse) => void;\n /** Present only while the entry is still queued (no host yet). */\n dedupeKey: string | undefined;\n settled: boolean;\n}\n\ninterface OpenerState<TRequest, TResponse> {\n host: OpenerHostController<TRequest, TResponse> | null;\n queue: PendingEntry<TRequest, TResponse>[];\n /** Unsettled entries by dedupe key — queued AND in-flight at the host. */\n inFlight: Map<string, { entry: PendingEntry<TRequest, TResponse>; promise: Promise<TResponse> }>;\n warnTimer: ReturnType<typeof setTimeout> | null;\n warned: boolean;\n}\n\nexport interface OpenerOptions<TRequest> {\n /**\n * Collapse concurrent duplicates. Return a stable string for requests that\n * must not stack (the same conversation's send gate, say), or `undefined`\n * to opt this request out of deduping.\n */\n dedupeKey?: ((request: TRequest) => string | undefined) | undefined;\n /**\n * How long a request may sit queued with no host before the opener screams\n * on the console. `0` disables the scream (tests, deliberately host-less\n * environments). Default 5000ms.\n */\n warnWithoutHostAfterMs?: number | undefined;\n /**\n * The remedy named in that scream — say EXACTLY what to mount and from\n * where, e.g. `\"<ConfirmDialogHost /> from @ai-matrx/design-system\"`.\n */\n hostHint?: string | undefined;\n}\n\nexport interface OpenRequestOptions<TResponse> {\n /** Abort the request: drops it from the queue and settles the promise. */\n signal?: AbortSignal | undefined;\n /**\n * On abort, resolve with this value instead of rejecting. Use it when the\n * response type already HAS a neutral answer (`\"cancel\"`, `null`) — an\n * abort is then indistinguishable from the user dismissing the dialog, and\n * no call site needs a try/catch.\n */\n onAbortResolveWith?: TResponse | undefined;\n}\n\nexport interface Opener<TRequest, TResponse> {\n /** The `globalThis` slot name this opener's state lives under. */\n readonly slot: string;\n /**\n * Open the dialog. Resolves with the host's answer. With no host mounted\n * the request queues and the promise stays pending — never a silent\n * default.\n */\n open: (request: TRequest, options?: OpenRequestOptions<TResponse>) => Promise<TResponse>;\n /** @internal Called by the host component on mount. */\n _registerHost: (controller: OpenerHostController<TRequest, TResponse>) => void;\n /** @internal Called by the host component on unmount. */\n _unregisterHost: (controller: OpenerHostController<TRequest, TResponse>) => void;\n /** @internal Test-only: drop the registered host, the queue and the timers. */\n _reset: () => void;\n /** @internal Diagnostics: is a host currently registered? */\n _hasHost: () => boolean;\n}\n\n/** Thrown to an aborted `open()` that supplied no `onAbortResolveWith`. */\nexport class OpenerAbortError extends Error {\n override readonly name = \"AbortError\";\n constructor(slot: string) {\n super(`The ${slot} request was aborted before the user answered it.`);\n }\n}\n\nfunction getState<TRequest, TResponse>(slot: string): OpenerState<TRequest, TResponse> {\n const holder = globalThis as Record<symbol, unknown>;\n const symbol = Symbol.for(slot);\n let state = holder[symbol] as OpenerState<TRequest, TResponse> | undefined;\n if (!state) {\n state = { host: null, queue: [], inFlight: new Map(), warnTimer: null, warned: false };\n holder[symbol] = state;\n }\n return state;\n}\n\n/**\n * Build an opener.\n *\n * @param slot The `globalThis` slot name — the opener's identity across every\n * module graph in the process, so it must be globally unique and stable.\n * Convention: `\"<owner>.<name>-opener-state\"`, e.g.\n * `\"ai-matrx.kit.confirm-opener-state\"` or\n * `\"matrx-frontend.sandbox-gate-opener-state\"`. Changing it after release\n * splits live hosts from live callers — treat it as a public name.\n *\n * @example\n * // sandboxGateOpener.ts — the whole module.\n * const opener = createOpener<SandboxGateOptions, SandboxGateChoice>(\n * \"matrx-frontend.sandbox-gate-opener-state\",\n * { hostHint: \"<SandboxGateHost /> (mounted once per provider tree)\" },\n * );\n * export const openSandboxGate = opener.open;\n */\nexport function createOpener<TRequest, TResponse>(\n slot: string,\n options: OpenerOptions<TRequest> = {},\n): Opener<TRequest, TResponse> {\n const warnAfterMs = options.warnWithoutHostAfterMs ?? 5000;\n const hostHint = options.hostHint;\n const dedupeKeyOf = options.dedupeKey;\n\n function clearWarnTimer(state: OpenerState<TRequest, TResponse>): void {\n if (state.warnTimer !== null) {\n clearTimeout(state.warnTimer);\n state.warnTimer = null;\n }\n }\n\n function armWarnTimer(state: OpenerState<TRequest, TResponse>): void {\n if (warnAfterMs <= 0 || state.warned || state.warnTimer !== null) return;\n state.warnTimer = setTimeout(() => {\n state.warnTimer = null;\n if (state.host || state.queue.length === 0) return;\n state.warned = true;\n // THE SCREAM. A queued request with no host is a promise that will hang\n // forever — invisible to the user, invisible in the network tab. Say\n // what is stuck and exactly what to mount.\n console.error(\n `[@ai-matrx/kit/opener] ${state.queue.length} request(s) on \"${slot}\" have been waiting ` +\n `${warnAfterMs}ms with no host registered, so the awaiting code is stuck and the user ` +\n `sees nothing. REMEDY: mount ${hostHint ?? `the host for \"${slot}\"`} once, near the root ` +\n `of this provider tree.`,\n );\n }, warnAfterMs);\n // Never hold a Node process open for a diagnostic timer.\n (state.warnTimer as unknown as { unref?: () => void }).unref?.();\n }\n\n function drainTo(\n state: OpenerState<TRequest, TResponse>,\n controller: OpenerHostController<TRequest, TResponse>,\n ): void {\n while (state.queue.length > 0) {\n const next = state.queue.shift()!;\n if (next.settled) continue;\n controller.show(next.request, next.settle);\n }\n }\n\n const opener: Opener<TRequest, TResponse> = {\n slot,\n\n open(request, requestOptions = {}) {\n const state = getState<TRequest, TResponse>(slot);\n const signal = requestOptions.signal;\n const dedupeKey = dedupeKeyOf?.(request);\n\n if (dedupeKey !== undefined) {\n const existing = state.inFlight.get(dedupeKey);\n // A duplicate NEVER stacks a second dialog: the second caller awaits\n // the answer the first one is already asking for.\n if (existing && !existing.entry.settled) return existing.promise;\n }\n\n let entry!: PendingEntry<TRequest, TResponse>;\n const promise = new Promise<TResponse>((resolve, reject) => {\n const finish = (run: () => void): void => {\n if (entry.settled) return;\n entry.settled = true;\n if (entry.dedupeKey !== undefined) state.inFlight.delete(entry.dedupeKey);\n const queuedAt = state.queue.indexOf(entry);\n if (queuedAt >= 0) state.queue.splice(queuedAt, 1);\n if (state.queue.length === 0) clearWarnTimer(state);\n run();\n };\n\n entry = {\n request,\n dedupeKey,\n settled: false,\n settle: (response) => finish(() => resolve(response)),\n };\n\n const abort = (): void =>\n finish(() => {\n if (\"onAbortResolveWith\" in requestOptions && requestOptions.onAbortResolveWith !== undefined) {\n resolve(requestOptions.onAbortResolveWith);\n } else {\n reject(new OpenerAbortError(slot));\n }\n });\n\n if (signal?.aborted) {\n abort();\n return;\n }\n signal?.addEventListener(\"abort\", abort, { once: true });\n });\n\n if (entry.settled) return promise; // aborted before it ever queued\n\n if (dedupeKey !== undefined) state.inFlight.set(dedupeKey, { entry, promise });\n\n if (state.host) {\n state.host.show(entry.request, entry.settle);\n } else {\n state.queue.push(entry);\n armWarnTimer(state);\n }\n return promise;\n },\n\n _registerHost(controller) {\n const state = getState<TRequest, TResponse>(slot);\n state.host = controller;\n state.warned = false;\n clearWarnTimer(state);\n drainTo(state, controller);\n },\n\n _unregisterHost(controller) {\n const state = getState<TRequest, TResponse>(slot);\n // Only the CURRENT host may unregister: a superseded host's late unmount\n // must not tear down the live one.\n if (state.host === controller) state.host = null;\n },\n\n _reset() {\n const state = getState<TRequest, TResponse>(slot);\n clearWarnTimer(state);\n state.host = null;\n state.queue.length = 0;\n state.inFlight.clear();\n state.warned = false;\n },\n\n _hasHost() {\n return getState<TRequest, TResponse>(slot).host !== null;\n },\n };\n\n return opener;\n}\n","/**\n * @ai-matrx/kit/confirm-opener — the imperative confirm opener, and since\n * 0.9.0 the ONLY half of the confirm system this package ships.\n *\n * Since 0.10.0 this file is a THIN SPECIALISATION of `../opener`: the\n * host registry, the request queue, the never-resolve-silently promise, the\n * globalThis slot and the no-host scream are all the generic engine's, and\n * what is left here is the confirm-shaped types plus the names call sites\n * already import. There is exactly ONE registry — `createOpener` reads and\n * writes the same `Symbol.for(\"ai-matrx.kit.confirm-opener-state\")` slot this\n * module always used, so a host built against any earlier version still meets\n * its callers.\n *\n * Pure TS, zero React, zero dialog markup — statically importable from\n * anywhere (hooks, utils, Redux thunks, async handlers, sync code).\n *\n * The host (`ConfirmDialogHost`, in `@ai-matrx/design-system`) registers a\n * controller on mount and unregisters on unmount. Calls made before the host\n * has hydrated queue up and resolve as soon as the host is alive — so a\n * destructive action triggered in the first ~50ms after page load still gets a\n * real confirmation, never a silent default-yes/no. With no host EVER mounted\n * the promise stays pending (it never resolves to a silent default) and the\n * opener screams on the console naming the component to mount.\n *\n * One dialog at a time: concurrent calls queue and present sequentially.\n */\n\nimport type { ReactNode } from \"react\";\n\nimport { createOpener, type OpenerHostController } from \"../opener\";\n\nexport interface ConfirmOptions {\n title: ReactNode;\n description?: ReactNode | undefined;\n confirmLabel?: string | undefined;\n /** `null` hides the cancel button (acknowledge-only dialogs). */\n cancelLabel?: string | null | undefined;\n variant?: \"default\" | \"destructive\" | undefined;\n}\n\n/** @internal The shape `ConfirmDialogHost` registers. */\nexport type ConfirmHostController = OpenerHostController<ConfirmOptions, boolean>;\n\n/**\n * The confirm opener itself. Exported for hosts that want the generic\n * `useOpenerHost` machinery (`@ai-matrx/kit/opener-react`) instead of\n * hand-wiring `_registerHost`.\n */\nexport const confirmOpener = createOpener<ConfirmOptions, boolean>(\n // 🚨 The slot name is a PUBLIC name: it is how callers in one module graph\n // (or one package) find the host in another. It has been this string since\n // the opener existed — never change it.\n \"ai-matrx.kit.confirm-opener-state\",\n { hostHint: \"<ConfirmDialogHost /> from @ai-matrx/design-system\" },\n);\n\n/** @internal Called by `ConfirmDialogHost` on mount. */\nexport const _registerHost = confirmOpener._registerHost;\n\n/** @internal Called by `ConfirmDialogHost` on unmount. */\nexport const _unregisterHost = confirmOpener._unregisterHost;\n\n/** @internal Test-only: drop any registered host and pending queue. */\nexport const _resetConfirmOpenerState = confirmOpener._reset;\n\n/**\n * Imperative confirm. Returns a Promise that resolves `true` if the user\n * confirms, `false` if they cancel/dismiss. Replaces `window.confirm`.\n *\n * @example\n * const ok = await confirm({\n * title: \"Delete sandbox\",\n * description: \"This cannot be undone.\",\n * variant: \"destructive\",\n * confirmLabel: \"Delete\",\n * });\n * if (!ok) return;\n */\nexport function confirm(opts: ConfirmOptions): Promise<boolean> {\n return confirmOpener.open(opts);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,35 +1,6 @@
|
|
|
1
|
+
import { OpenerHostController, Opener } from './opener.cjs';
|
|
1
2
|
import { ReactNode } from 'react';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
* @ai-matrx/kit/confirm-opener — the imperative opener, and since 0.9.0 the
|
|
5
|
-
* ONLY half of the confirm system this package ships.
|
|
6
|
-
*
|
|
7
|
-
* Pure-TS imperative API for the global confirm dialog. Zero React, zero
|
|
8
|
-
* dialog markup — this module is statically importable from anywhere
|
|
9
|
-
* (hooks, utils, Redux thunks, async handlers, sync code, anything).
|
|
10
|
-
*
|
|
11
|
-
* The host (`ConfirmDialogHost`, in `@ai-matrx/design-system`) registers a
|
|
12
|
-
* controller on mount and
|
|
13
|
-
* unregisters on unmount. Calls made before the host has hydrated queue
|
|
14
|
-
* up and resolve as soon as the host is alive — so a destructive action
|
|
15
|
-
* triggered in the first ~50ms after page load still gets a real
|
|
16
|
-
* confirmation, never a silent default-yes/no. With no host ever mounted,
|
|
17
|
-
* a `confirm()` promise stays pending forever (the original's behavior —
|
|
18
|
-
* it never resolves to a silent default).
|
|
19
|
-
*
|
|
20
|
-
* One dialog at a time: concurrent calls queue and present sequentially.
|
|
21
|
-
*
|
|
22
|
-
* Ported verbatim from matrx-frontend
|
|
23
|
-
* `components/dialogs/confirm/confirmDialogOpener.ts`, with ONE structural
|
|
24
|
-
* inversion: the host/queue state lives on `globalThis` under a
|
|
25
|
-
* `Symbol.for` slot instead of module-level variables. With the package
|
|
26
|
-
* built `splitting: false` in dual ESM/CJS format, and the host now living in
|
|
27
|
-
* a DIFFERENT PACKAGE, CJS/ESM each instantiate their own module graph — a module-level variable would
|
|
28
|
-
* silently split the host registration from the callers (the same hazard
|
|
29
|
-
* `@ai-matrx/tap-target` documents for its link registry). Behavior is
|
|
30
|
-
* unchanged; never "clean this up" into a module local.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
4
|
interface ConfirmOptions {
|
|
34
5
|
title: ReactNode;
|
|
35
6
|
description?: ReactNode | undefined;
|
|
@@ -38,16 +9,20 @@ interface ConfirmOptions {
|
|
|
38
9
|
cancelLabel?: string | null | undefined;
|
|
39
10
|
variant?: "default" | "destructive" | undefined;
|
|
40
11
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
12
|
+
/** @internal The shape `ConfirmDialogHost` registers. */
|
|
13
|
+
type ConfirmHostController = OpenerHostController<ConfirmOptions, boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* The confirm opener itself. Exported for hosts that want the generic
|
|
16
|
+
* `useOpenerHost` machinery (`@ai-matrx/kit/opener-react`) instead of
|
|
17
|
+
* hand-wiring `_registerHost`.
|
|
18
|
+
*/
|
|
19
|
+
declare const confirmOpener: Opener<ConfirmOptions, boolean>;
|
|
45
20
|
/** @internal Called by `ConfirmDialogHost` on mount. */
|
|
46
|
-
declare
|
|
21
|
+
declare const _registerHost: (controller: OpenerHostController<ConfirmOptions, boolean>) => void;
|
|
47
22
|
/** @internal Called by `ConfirmDialogHost` on unmount. */
|
|
48
|
-
declare
|
|
23
|
+
declare const _unregisterHost: (controller: OpenerHostController<ConfirmOptions, boolean>) => void;
|
|
49
24
|
/** @internal Test-only: drop any registered host and pending queue. */
|
|
50
|
-
declare
|
|
25
|
+
declare const _resetConfirmOpenerState: () => void;
|
|
51
26
|
/**
|
|
52
27
|
* Imperative confirm. Returns a Promise that resolves `true` if the user
|
|
53
28
|
* confirms, `false` if they cancel/dismiss. Replaces `window.confirm`.
|
|
@@ -63,4 +38,4 @@ declare function _resetConfirmOpenerState(): void;
|
|
|
63
38
|
*/
|
|
64
39
|
declare function confirm(opts: ConfirmOptions): Promise<boolean>;
|
|
65
40
|
|
|
66
|
-
export { type ConfirmOptions, _registerHost, _resetConfirmOpenerState, _unregisterHost, confirm };
|
|
41
|
+
export { type ConfirmHostController, type ConfirmOptions, _registerHost, _resetConfirmOpenerState, _unregisterHost, confirm, confirmOpener };
|
package/dist/confirm-opener.d.ts
CHANGED
|
@@ -1,35 +1,6 @@
|
|
|
1
|
+
import { OpenerHostController, Opener } from './opener.js';
|
|
1
2
|
import { ReactNode } from 'react';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
* @ai-matrx/kit/confirm-opener — the imperative opener, and since 0.9.0 the
|
|
5
|
-
* ONLY half of the confirm system this package ships.
|
|
6
|
-
*
|
|
7
|
-
* Pure-TS imperative API for the global confirm dialog. Zero React, zero
|
|
8
|
-
* dialog markup — this module is statically importable from anywhere
|
|
9
|
-
* (hooks, utils, Redux thunks, async handlers, sync code, anything).
|
|
10
|
-
*
|
|
11
|
-
* The host (`ConfirmDialogHost`, in `@ai-matrx/design-system`) registers a
|
|
12
|
-
* controller on mount and
|
|
13
|
-
* unregisters on unmount. Calls made before the host has hydrated queue
|
|
14
|
-
* up and resolve as soon as the host is alive — so a destructive action
|
|
15
|
-
* triggered in the first ~50ms after page load still gets a real
|
|
16
|
-
* confirmation, never a silent default-yes/no. With no host ever mounted,
|
|
17
|
-
* a `confirm()` promise stays pending forever (the original's behavior —
|
|
18
|
-
* it never resolves to a silent default).
|
|
19
|
-
*
|
|
20
|
-
* One dialog at a time: concurrent calls queue and present sequentially.
|
|
21
|
-
*
|
|
22
|
-
* Ported verbatim from matrx-frontend
|
|
23
|
-
* `components/dialogs/confirm/confirmDialogOpener.ts`, with ONE structural
|
|
24
|
-
* inversion: the host/queue state lives on `globalThis` under a
|
|
25
|
-
* `Symbol.for` slot instead of module-level variables. With the package
|
|
26
|
-
* built `splitting: false` in dual ESM/CJS format, and the host now living in
|
|
27
|
-
* a DIFFERENT PACKAGE, CJS/ESM each instantiate their own module graph — a module-level variable would
|
|
28
|
-
* silently split the host registration from the callers (the same hazard
|
|
29
|
-
* `@ai-matrx/tap-target` documents for its link registry). Behavior is
|
|
30
|
-
* unchanged; never "clean this up" into a module local.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
4
|
interface ConfirmOptions {
|
|
34
5
|
title: ReactNode;
|
|
35
6
|
description?: ReactNode | undefined;
|
|
@@ -38,16 +9,20 @@ interface ConfirmOptions {
|
|
|
38
9
|
cancelLabel?: string | null | undefined;
|
|
39
10
|
variant?: "default" | "destructive" | undefined;
|
|
40
11
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
12
|
+
/** @internal The shape `ConfirmDialogHost` registers. */
|
|
13
|
+
type ConfirmHostController = OpenerHostController<ConfirmOptions, boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* The confirm opener itself. Exported for hosts that want the generic
|
|
16
|
+
* `useOpenerHost` machinery (`@ai-matrx/kit/opener-react`) instead of
|
|
17
|
+
* hand-wiring `_registerHost`.
|
|
18
|
+
*/
|
|
19
|
+
declare const confirmOpener: Opener<ConfirmOptions, boolean>;
|
|
45
20
|
/** @internal Called by `ConfirmDialogHost` on mount. */
|
|
46
|
-
declare
|
|
21
|
+
declare const _registerHost: (controller: OpenerHostController<ConfirmOptions, boolean>) => void;
|
|
47
22
|
/** @internal Called by `ConfirmDialogHost` on unmount. */
|
|
48
|
-
declare
|
|
23
|
+
declare const _unregisterHost: (controller: OpenerHostController<ConfirmOptions, boolean>) => void;
|
|
49
24
|
/** @internal Test-only: drop any registered host and pending queue. */
|
|
50
|
-
declare
|
|
25
|
+
declare const _resetConfirmOpenerState: () => void;
|
|
51
26
|
/**
|
|
52
27
|
* Imperative confirm. Returns a Promise that resolves `true` if the user
|
|
53
28
|
* confirms, `false` if they cancel/dismiss. Replaces `window.confirm`.
|
|
@@ -63,4 +38,4 @@ declare function _resetConfirmOpenerState(): void;
|
|
|
63
38
|
*/
|
|
64
39
|
declare function confirm(opts: ConfirmOptions): Promise<boolean>;
|
|
65
40
|
|
|
66
|
-
export { type ConfirmOptions, _registerHost, _resetConfirmOpenerState, _unregisterHost, confirm };
|
|
41
|
+
export { type ConfirmHostController, type ConfirmOptions, _registerHost, _resetConfirmOpenerState, _unregisterHost, confirm, confirmOpener };
|
package/dist/confirm-opener.js
CHANGED
|
@@ -1,45 +1,144 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
var
|
|
3
|
-
|
|
1
|
+
// src/opener.ts
|
|
2
|
+
var OpenerAbortError = class extends Error {
|
|
3
|
+
name = "AbortError";
|
|
4
|
+
constructor(slot) {
|
|
5
|
+
super(`The ${slot} request was aborted before the user answered it.`);
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
function getState(slot) {
|
|
4
9
|
const holder = globalThis;
|
|
5
|
-
|
|
10
|
+
const symbol = Symbol.for(slot);
|
|
11
|
+
let state = holder[symbol];
|
|
6
12
|
if (!state) {
|
|
7
|
-
state = { host: null, queue: [] };
|
|
8
|
-
holder[
|
|
13
|
+
state = { host: null, queue: [], inFlight: /* @__PURE__ */ new Map(), warnTimer: null, warned: false };
|
|
14
|
+
holder[symbol] = state;
|
|
9
15
|
}
|
|
10
16
|
return state;
|
|
11
17
|
}
|
|
12
|
-
function
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
function createOpener(slot, options = {}) {
|
|
19
|
+
const warnAfterMs = options.warnWithoutHostAfterMs ?? 5e3;
|
|
20
|
+
const hostHint = options.hostHint;
|
|
21
|
+
const dedupeKeyOf = options.dedupeKey;
|
|
22
|
+
function clearWarnTimer(state) {
|
|
23
|
+
if (state.warnTimer !== null) {
|
|
24
|
+
clearTimeout(state.warnTimer);
|
|
25
|
+
state.warnTimer = null;
|
|
26
|
+
}
|
|
18
27
|
}
|
|
28
|
+
function armWarnTimer(state) {
|
|
29
|
+
if (warnAfterMs <= 0 || state.warned || state.warnTimer !== null) return;
|
|
30
|
+
state.warnTimer = setTimeout(() => {
|
|
31
|
+
state.warnTimer = null;
|
|
32
|
+
if (state.host || state.queue.length === 0) return;
|
|
33
|
+
state.warned = true;
|
|
34
|
+
console.error(
|
|
35
|
+
`[@ai-matrx/kit/opener] ${state.queue.length} request(s) on "${slot}" have been waiting ${warnAfterMs}ms with no host registered, so the awaiting code is stuck and the user sees nothing. REMEDY: mount ${hostHint ?? `the host for "${slot}"`} once, near the root of this provider tree.`
|
|
36
|
+
);
|
|
37
|
+
}, warnAfterMs);
|
|
38
|
+
state.warnTimer.unref?.();
|
|
39
|
+
}
|
|
40
|
+
function drainTo(state, controller) {
|
|
41
|
+
while (state.queue.length > 0) {
|
|
42
|
+
const next = state.queue.shift();
|
|
43
|
+
if (next.settled) continue;
|
|
44
|
+
controller.show(next.request, next.settle);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const opener = {
|
|
48
|
+
slot,
|
|
49
|
+
open(request, requestOptions = {}) {
|
|
50
|
+
const state = getState(slot);
|
|
51
|
+
const signal = requestOptions.signal;
|
|
52
|
+
const dedupeKey = dedupeKeyOf?.(request);
|
|
53
|
+
if (dedupeKey !== void 0) {
|
|
54
|
+
const existing = state.inFlight.get(dedupeKey);
|
|
55
|
+
if (existing && !existing.entry.settled) return existing.promise;
|
|
56
|
+
}
|
|
57
|
+
let entry;
|
|
58
|
+
const promise = new Promise((resolve, reject) => {
|
|
59
|
+
const finish = (run) => {
|
|
60
|
+
if (entry.settled) return;
|
|
61
|
+
entry.settled = true;
|
|
62
|
+
if (entry.dedupeKey !== void 0) state.inFlight.delete(entry.dedupeKey);
|
|
63
|
+
const queuedAt = state.queue.indexOf(entry);
|
|
64
|
+
if (queuedAt >= 0) state.queue.splice(queuedAt, 1);
|
|
65
|
+
if (state.queue.length === 0) clearWarnTimer(state);
|
|
66
|
+
run();
|
|
67
|
+
};
|
|
68
|
+
entry = {
|
|
69
|
+
request,
|
|
70
|
+
dedupeKey,
|
|
71
|
+
settled: false,
|
|
72
|
+
settle: (response) => finish(() => resolve(response))
|
|
73
|
+
};
|
|
74
|
+
const abort = () => finish(() => {
|
|
75
|
+
if ("onAbortResolveWith" in requestOptions && requestOptions.onAbortResolveWith !== void 0) {
|
|
76
|
+
resolve(requestOptions.onAbortResolveWith);
|
|
77
|
+
} else {
|
|
78
|
+
reject(new OpenerAbortError(slot));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
if (signal?.aborted) {
|
|
82
|
+
abort();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
if (entry.settled) return promise;
|
|
88
|
+
if (dedupeKey !== void 0) state.inFlight.set(dedupeKey, { entry, promise });
|
|
89
|
+
if (state.host) {
|
|
90
|
+
state.host.show(entry.request, entry.settle);
|
|
91
|
+
} else {
|
|
92
|
+
state.queue.push(entry);
|
|
93
|
+
armWarnTimer(state);
|
|
94
|
+
}
|
|
95
|
+
return promise;
|
|
96
|
+
},
|
|
97
|
+
_registerHost(controller) {
|
|
98
|
+
const state = getState(slot);
|
|
99
|
+
state.host = controller;
|
|
100
|
+
state.warned = false;
|
|
101
|
+
clearWarnTimer(state);
|
|
102
|
+
drainTo(state, controller);
|
|
103
|
+
},
|
|
104
|
+
_unregisterHost(controller) {
|
|
105
|
+
const state = getState(slot);
|
|
106
|
+
if (state.host === controller) state.host = null;
|
|
107
|
+
},
|
|
108
|
+
_reset() {
|
|
109
|
+
const state = getState(slot);
|
|
110
|
+
clearWarnTimer(state);
|
|
111
|
+
state.host = null;
|
|
112
|
+
state.queue.length = 0;
|
|
113
|
+
state.inFlight.clear();
|
|
114
|
+
state.warned = false;
|
|
115
|
+
},
|
|
116
|
+
_hasHost() {
|
|
117
|
+
return getState(slot).host !== null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
return opener;
|
|
19
121
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
state
|
|
27
|
-
|
|
28
|
-
|
|
122
|
+
|
|
123
|
+
// src/confirm/opener.ts
|
|
124
|
+
var confirmOpener = createOpener(
|
|
125
|
+
// 🚨 The slot name is a PUBLIC name: it is how callers in one module graph
|
|
126
|
+
// (or one package) find the host in another. It has been this string since
|
|
127
|
+
// the opener existed — never change it.
|
|
128
|
+
"ai-matrx.kit.confirm-opener-state",
|
|
129
|
+
{ hostHint: "<ConfirmDialogHost /> from @ai-matrx/design-system" }
|
|
130
|
+
);
|
|
131
|
+
var _registerHost = confirmOpener._registerHost;
|
|
132
|
+
var _unregisterHost = confirmOpener._unregisterHost;
|
|
133
|
+
var _resetConfirmOpenerState = confirmOpener._reset;
|
|
29
134
|
function confirm(opts) {
|
|
30
|
-
return
|
|
31
|
-
const state = getState();
|
|
32
|
-
if (state.host) {
|
|
33
|
-
state.host.show(opts, resolve);
|
|
34
|
-
} else {
|
|
35
|
-
state.queue.push({ opts, resolve });
|
|
36
|
-
}
|
|
37
|
-
});
|
|
135
|
+
return confirmOpener.open(opts);
|
|
38
136
|
}
|
|
39
137
|
export {
|
|
40
138
|
_registerHost,
|
|
41
139
|
_resetConfirmOpenerState,
|
|
42
140
|
_unregisterHost,
|
|
43
|
-
confirm
|
|
141
|
+
confirm,
|
|
142
|
+
confirmOpener
|
|
44
143
|
};
|
|
45
144
|
//# sourceMappingURL=confirm-opener.js.map
|