@cplieger/ui 0.0.0 → 1.0.0-dev.1

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.
Files changed (66) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +145 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/css/ui-primitives.css +344 -0
  5. package/jsr.json +36 -4
  6. package/package.json +86 -4
  7. package/src/actions/api.ts +268 -0
  8. package/src/actions/async-feedback.ts +217 -0
  9. package/src/actions/cleanup.ts +66 -0
  10. package/src/actions/debounce.ts +150 -0
  11. package/src/actions/define-helpers.ts +87 -0
  12. package/src/actions/define.ts +851 -0
  13. package/src/actions/error.ts +161 -0
  14. package/src/actions/index.ts +60 -0
  15. package/src/actions/loading.ts +131 -0
  16. package/src/actions/notifier.ts +61 -0
  17. package/src/actions/poll-until.ts +133 -0
  18. package/src/actions/poll.ts +156 -0
  19. package/src/actions/registry.ts +259 -0
  20. package/src/actions/retry.ts +79 -0
  21. package/src/actions/testing.ts +29 -0
  22. package/src/actions/transport.ts +104 -0
  23. package/src/actions/types.ts +249 -0
  24. package/src/dom/bind-list.ts +103 -0
  25. package/src/dom/el.ts +67 -0
  26. package/src/dom/index.ts +9 -0
  27. package/src/dom/reconcile-tree.ts +268 -0
  28. package/src/dom/reconcile.ts +76 -0
  29. package/src/fetch/index.ts +18 -0
  30. package/src/fetch/instance.ts +37 -0
  31. package/src/fetch/request.ts +437 -0
  32. package/src/fetch/timeout.ts +67 -0
  33. package/src/fetch/types.ts +206 -0
  34. package/src/fetch/verbs.ts +103 -0
  35. package/src/index.ts +4 -8
  36. package/src/primitives/announce.ts +134 -0
  37. package/src/primitives/ask.ts +406 -0
  38. package/src/primitives/dialog-engine.ts +500 -0
  39. package/src/primitives/dialog.ts +263 -0
  40. package/src/primitives/disclosure.ts +287 -0
  41. package/src/primitives/focus-trap.ts +157 -0
  42. package/src/primitives/fx.ts +204 -0
  43. package/src/primitives/live-regions.ts +83 -0
  44. package/src/primitives/modal-host.ts +214 -0
  45. package/src/primitives/modal.ts +247 -0
  46. package/src/primitives/owner.ts +322 -0
  47. package/src/primitives/popover.ts +500 -0
  48. package/src/primitives/popup-core.ts +603 -0
  49. package/src/primitives/popup.ts +23 -0
  50. package/src/primitives/roving-focus.ts +198 -0
  51. package/src/primitives/scroll-lock.ts +119 -0
  52. package/src/primitives/skeleton.ts +142 -0
  53. package/src/primitives/theme.ts +202 -0
  54. package/src/primitives/toast/engine.ts +327 -0
  55. package/src/primitives/toast/index.ts +139 -0
  56. package/src/primitives/toast/view.ts +244 -0
  57. package/src/primitives/tooltip.ts +377 -0
  58. package/src/primitives/transition.ts +119 -0
  59. package/src/primitives/view-transition.ts +57 -0
  60. package/src/signal/bus.ts +126 -0
  61. package/src/signal/collection.ts +182 -0
  62. package/src/signal/index.ts +34 -0
  63. package/src/signal/resource.ts +102 -0
  64. package/src/signal/signal-map.ts +46 -0
  65. package/src/signal/signal.ts +1002 -0
  66. package/src/signal/store.ts +84 -0
@@ -0,0 +1,259 @@
1
+ import { signal, batch } from "../signal/signal.js";
2
+ import { SignalMap } from "../signal/signal-map.js";
3
+
4
+ import type { ActionInstance, RegistryListener } from "./types.js";
5
+
6
+ const MAX_LOG_SIZE = 200;
7
+
8
+ /** Only a dispatch that never settles can grow `inflight` past this; the
9
+ * oldest is then reclaimed with a console.warn naming it. */
10
+ const MAX_INFLIGHT = 1000;
11
+
12
+ interface LogEntry {
13
+ instance: ActionInstance;
14
+ /** Monotonic stamp assigned at the dispatch id's FIRST record; preserved
15
+ * across every transition so the recomposed log keeps first-record order. */
16
+ readonly seq: number;
17
+ }
18
+
19
+ // Disjoint lifetimes keep pending accounting from desyncing with log
20
+ // eviction: `inflight` holds pending dispatches (reclaimed only past
21
+ // MAX_INFLIGHT), `settled` terminal snapshots, latest per id, bounded to
22
+ // MAX_LOG_SIZE by evicting the lowest `seq`. `seq`, stamped at an id's first
23
+ // record, orders getActionLog(); pendingByName indexes `inflight`.
24
+ const inflight = new Map<string, LogEntry>();
25
+ const settled = new Map<string, LogEntry>();
26
+ let seqCounter = 0;
27
+
28
+ const listeners = new Set<RegistryListener>();
29
+ const namedListeners = new Map<string, Set<RegistryListener>>();
30
+ const pendingByName = new Map<string, Set<string>>();
31
+
32
+ // Reactive mirrors of the pending state. The inflight table + its name index
33
+ // remain the source of truth; these signals expose the derived counts so
34
+ // isPending/pendingCount can be read reactively (e.g. by bindLoadingState's
35
+ // effect).
36
+ const pendingSigs = new SignalMap<number>();
37
+ const pendingTotalSig = signal(0);
38
+
39
+ /** Add `id` to the name index + refresh the signals. Call AFTER the inflight
40
+ * mutation so `inflight.size` is current. */
41
+ function indexPending(name: string, id: string): void {
42
+ let s = pendingByName.get(name);
43
+ if (s === undefined) {
44
+ s = new Set();
45
+ pendingByName.set(name, s);
46
+ }
47
+ s.add(id);
48
+ const size = s.size;
49
+ batch(() => {
50
+ pendingSigs.ensure(name, 0).value = size;
51
+ pendingTotalSig.value = inflight.size;
52
+ });
53
+ }
54
+
55
+ /** Remove `id` from the name index + refresh the signals. Call AFTER the
56
+ * inflight mutation. No-op when the id was never indexed. */
57
+ function unindexPending(name: string, id: string): void {
58
+ const s = pendingByName.get(name);
59
+ if (!s?.delete(id)) {
60
+ return;
61
+ }
62
+ const size = s.size;
63
+ if (size === 0) {
64
+ pendingByName.delete(name);
65
+ }
66
+ batch(() => {
67
+ pendingSigs.ensure(name, 0).value = size;
68
+ pendingTotalSig.value = inflight.size;
69
+ });
70
+ }
71
+
72
+ /** Evicts the lowest-seq (first-record order) entry. Map insertion order
73
+ * alone is settle order, not first-record order (a long-pending dispatch
74
+ * settles late), hence the linear scan. `protectId` shields the entry being
75
+ * recorded right now: a long-lived pending that finally settles can carry
76
+ * the lowest seq of the whole log and would otherwise evict itself. */
77
+ function evictOldestSettled(protectId: string): void {
78
+ while (settled.size > MAX_LOG_SIZE) {
79
+ let oldestKey: string | undefined;
80
+ let oldestSeq = Infinity;
81
+ for (const [key, entry] of settled) {
82
+ if (key !== protectId && entry.seq < oldestSeq) {
83
+ oldestSeq = entry.seq;
84
+ oldestKey = key;
85
+ }
86
+ }
87
+ if (oldestKey === undefined) {
88
+ return;
89
+ }
90
+ settled.delete(oldestKey);
91
+ }
92
+ }
93
+
94
+ /** Reclaims the oldest in-flight entry once the table exceeds MAX_INFLIGHT,
95
+ * skipping `protectId` (a revived entry keeps its original, possibly-lowest
96
+ * seq, and must not reclaim itself). Dropped outright, never moved to
97
+ * `settled`: its status is still "pending" and the terminal log must not lie. */
98
+ function reclaimLeakedPending(protectId: string): void {
99
+ if (inflight.size <= MAX_INFLIGHT) {
100
+ return;
101
+ }
102
+ let oldestKey: string | undefined;
103
+ let oldestSeq = Infinity;
104
+ for (const [key, entry] of inflight) {
105
+ if (key !== protectId && entry.seq < oldestSeq) {
106
+ oldestSeq = entry.seq;
107
+ oldestKey = key;
108
+ }
109
+ }
110
+ if (oldestKey === undefined) {
111
+ return;
112
+ }
113
+ const entry = inflight.get(oldestKey);
114
+ inflight.delete(oldestKey);
115
+ if (entry !== undefined) {
116
+ unindexPending(entry.instance.name, oldestKey);
117
+ console.warn(
118
+ `[actions] in-flight capacity exceeded (${String(MAX_INFLIGHT)} entries) — reclaiming oldest pending "${entry.instance.name}" (${oldestKey}). Check for dispatches that never settle.`,
119
+ );
120
+ }
121
+ }
122
+
123
+ /** Record a state transition. Each cell completes its map moves before
124
+ * publishing signals, so a reentrant record from an effect sees coherent state:
125
+ *
126
+ * status | in inflight | in settled | unknown
127
+ * pending | overwrite (keep seq) | move back, re-index | insert, index, watchdog
128
+ * terminal | move to settled, | overwrite (keep seq | insert into settled,
129
+ * | unindex | and position) | evict */
130
+ export function record(instance: ActionInstance): void {
131
+ const { id } = instance;
132
+ if (instance.status === "pending") {
133
+ const inf = inflight.get(id);
134
+ if (inf !== undefined) {
135
+ inf.instance = instance;
136
+ } else {
137
+ const done = settled.get(id);
138
+ if (done !== undefined) {
139
+ settled.delete(id);
140
+ done.instance = instance;
141
+ inflight.set(id, done);
142
+ indexPending(instance.name, id);
143
+ reclaimLeakedPending(id);
144
+ } else {
145
+ seqCounter += 1;
146
+ inflight.set(id, { instance, seq: seqCounter });
147
+ indexPending(instance.name, id);
148
+ reclaimLeakedPending(id);
149
+ }
150
+ }
151
+ } else {
152
+ const inf = inflight.get(id);
153
+ if (inf !== undefined) {
154
+ // Entry must be fully moved before unindexPending publishes signals:
155
+ // reactive effects flush synchronously at that batch's end, and a
156
+ // reentrant same-id record from one must find it in `settled` (the
157
+ // revive cell) rather than minting a duplicate wrapper.
158
+ const indexedName = inf.instance.name;
159
+ inflight.delete(id);
160
+ inf.instance = instance;
161
+ settled.set(id, inf);
162
+ evictOldestSettled(id);
163
+ unindexPending(indexedName, id);
164
+ } else {
165
+ const done = settled.get(id);
166
+ if (done !== undefined) {
167
+ done.instance = instance;
168
+ } else {
169
+ seqCounter += 1;
170
+ settled.set(id, { instance, seq: seqCounter });
171
+ evictOldestSettled(id);
172
+ }
173
+ }
174
+ }
175
+ for (const fn of listeners) {
176
+ try {
177
+ fn(instance);
178
+ } catch (e) {
179
+ console.error("[actions] registry listener threw", e);
180
+ }
181
+ }
182
+ const named = namedListeners.get(instance.name);
183
+ if (named !== undefined) {
184
+ for (const fn of named) {
185
+ try {
186
+ fn(instance);
187
+ } catch (e) {
188
+ console.error("[actions] registry listener threw", e);
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ /** Subscribe to all action lifecycle events. */
195
+ export function subscribe(fn: RegistryListener): () => void {
196
+ listeners.add(fn);
197
+ return () => listeners.delete(fn);
198
+ }
199
+
200
+ /** Subscribe to lifecycle events for a single action name. */
201
+ export function subscribeByName(name: string, fn: RegistryListener): () => void {
202
+ let set = namedListeners.get(name);
203
+ if (set === undefined) {
204
+ set = new Set();
205
+ namedListeners.set(name, set);
206
+ }
207
+ set.add(fn);
208
+ const captured = set;
209
+ return () => {
210
+ captured.delete(fn);
211
+ if (captured.size === 0 && namedListeners.get(name) === captured) {
212
+ namedListeners.delete(name);
213
+ }
214
+ };
215
+ }
216
+
217
+ /** Read the recent action log. Useful for devtools integration and
218
+ * debugging panels. Returns a snapshot of all live entries.
219
+ *
220
+ * SECURITY/PRIVACY: each entry retains the dispatch's `args`, `result` and
221
+ * `error` (up to MAX_LOG_SIZE settled entries plus every in-flight dispatch,
222
+ * watchdog-bounded at MAX_INFLIGHT), and every listener receives them. A
223
+ * definition whose args or result carry a secret, token or PII sets
224
+ * `sensitive`, whose entries hold none of the three. */
225
+ export function getActionLog(): readonly ActionInstance[] {
226
+ const entries = [...inflight.values(), ...settled.values()];
227
+ entries.sort((a, b) => a.seq - b.seq);
228
+ return entries.map((e) => e.instance);
229
+ }
230
+
231
+ /** O(1) check: true if at least one instance of the named action is pending.
232
+ * Reactive — reading inside an effect tracks the name's pending signal. */
233
+ export function isPending(name: string): boolean {
234
+ return pendingSigs.ensure(name, 0).value > 0;
235
+ }
236
+
237
+ /** Pending count for action(s). Reactive — reads track the relevant signals. */
238
+ export function pendingCount(names?: readonly string[]): number {
239
+ if (names === undefined) {
240
+ return pendingTotalSig.value;
241
+ }
242
+ let total = 0;
243
+ for (const name of names) {
244
+ total += pendingSigs.ensure(name, 0).value;
245
+ }
246
+ return total;
247
+ }
248
+
249
+ /** Test-only: clear log + listeners. */
250
+ export function _resetForTest(): void {
251
+ inflight.clear();
252
+ settled.clear();
253
+ seqCounter = 0;
254
+ pendingByName.clear();
255
+ listeners.clear();
256
+ namedListeners.clear();
257
+ pendingSigs.clearAll();
258
+ pendingTotalSig.value = 0;
259
+ }
@@ -0,0 +1,79 @@
1
+ /** Abort-aware sleep. Rejects with AbortError if the signal fires
2
+ * before the timeout elapses. Resolves immediately for ms <= 0. */
3
+ export function sleep(ms: number, signal: AbortSignal): Promise<void> {
4
+ if (signal.aborted) {
5
+ return Promise.reject(new DOMException("aborted", "AbortError"));
6
+ }
7
+ if (ms <= 0) {
8
+ return Promise.resolve();
9
+ }
10
+ return new Promise<void>((resolve, reject) => {
11
+ const t = setTimeout(() => {
12
+ signal.removeEventListener("abort", onAbort);
13
+ resolve();
14
+ }, ms);
15
+ const onAbort = (): void => {
16
+ clearTimeout(t);
17
+ reject(new DOMException("aborted", "AbortError"));
18
+ };
19
+ signal.addEventListener("abort", onAbort, { once: true });
20
+ });
21
+ }
22
+
23
+ /** Wait for the browser to come back online, or for the signal to abort.
24
+ * Resolves immediately unless the platform positively reports being offline. */
25
+ export function waitForOnline(signal: AbortSignal): Promise<void> {
26
+ // Node ships a `navigator` with no `onLine`; testing for `false` (not for the
27
+ // object's presence) avoids reading that absence as "offline".
28
+ const onLine = (globalThis as { readonly navigator?: Navigator }).navigator?.onLine;
29
+ if (onLine !== false) {
30
+ return Promise.resolve();
31
+ }
32
+ if (signal.aborted) {
33
+ return Promise.reject(new DOMException("aborted", "AbortError"));
34
+ }
35
+ return new Promise<void>((resolve, reject) => {
36
+ const onOnline = (): void => {
37
+ cleanup();
38
+ resolve();
39
+ };
40
+ const onAbort = (): void => {
41
+ cleanup();
42
+ reject(new DOMException("aborted", "AbortError"));
43
+ };
44
+ function cleanup(): void {
45
+ if (typeof window !== "undefined") {
46
+ window.removeEventListener("online", onOnline);
47
+ }
48
+ signal.removeEventListener("abort", onAbort);
49
+ }
50
+ if (typeof window !== "undefined") {
51
+ window.addEventListener("online", onOnline, { once: true });
52
+ }
53
+ signal.addEventListener("abort", onAbort, { once: true });
54
+ });
55
+ }
56
+
57
+ /** Attach attempt count to a thrown error (non-enumerable property). */
58
+ export function attachAttempts(e: unknown, attempts: number): void {
59
+ if (typeof e === "object" && e !== null) {
60
+ try {
61
+ Object.defineProperty(e, "_attempts", { value: attempts, configurable: true });
62
+ } catch {
63
+ /* frozen/sealed object — skip */
64
+ }
65
+ }
66
+ }
67
+
68
+ /** Read the attempt count attached by runWithRetry, or undefined. */
69
+ export function readAttempts(e: unknown): number | undefined {
70
+ try {
71
+ if (typeof e === "object" && e !== null && "_attempts" in e) {
72
+ const val = (e as { readonly _attempts: unknown })._attempts;
73
+ return typeof val === "number" ? val : undefined;
74
+ }
75
+ } catch {
76
+ /* Proxy or getter threw — skip */
77
+ }
78
+ return undefined;
79
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Test-only resets for the framework's module-global state.
3
+ *
4
+ * Published as the `@cplieger/ui/actions/testing` subpath so a suite can return
5
+ * the notifier, api, transport, define, registry and cleanup slots to their
6
+ * unconfigured state between cases; without that, one test's `configure*()`
7
+ * call and its registered actions leak into the next. Import it from test code
8
+ * only: in an application the slots are set once at boot, and clearing them at
9
+ * runtime drops the adapters every dispatch depends on.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import { _resetForTest as resetDefine } from "./define.js";
15
+ import { _resetForTest as resetRegistry } from "./registry.js";
16
+ import { _resetForTest as resetCleanup } from "./cleanup.js";
17
+ import { _resetApiConfigForTest as resetApi } from "./api.js";
18
+ import { _resetTransportForTest as resetTransport } from "./transport.js";
19
+ import { _resetNotifierForTest as resetNotifier } from "./notifier.js";
20
+
21
+ /** Reset every framework state slot. Call from `beforeEach()`. */
22
+ export function resetActionFramework(): void {
23
+ resetDefine();
24
+ resetRegistry();
25
+ resetCleanup();
26
+ resetApi();
27
+ resetTransport();
28
+ resetNotifier();
29
+ }
@@ -0,0 +1,104 @@
1
+ import { defineAction } from "./define.js";
2
+ import { ActionError } from "./error.js";
3
+ import type { Action, ActionContext, ActionDefinition } from "./types.js";
4
+
5
+ /** Result returned by the transport send function.
6
+ * `ok: true` means the command was accepted; `ok: false` triggers the
7
+ * action's error branch (rollback + notification). */
8
+ export interface TransportSendResult {
9
+ readonly ok: boolean;
10
+ readonly status: number;
11
+ readonly error?: string;
12
+ readonly code?: string;
13
+ }
14
+
15
+ /** A command object sent via the transport layer. Must include a `type`
16
+ * discriminator; additional fields carry the command payload. */
17
+ export interface TransportCommand {
18
+ readonly type: string;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ /** Signature of the consumer-provided send function.
23
+ * Receives the command and an options bag with an AbortSignal for cancellation. */
24
+ export type TransportSendFn = (
25
+ cmd: TransportCommand,
26
+ opts: { signal: AbortSignal },
27
+ ) => Promise<TransportSendResult>;
28
+
29
+ /** Command field name carrying the per-dispatch idempotency key injected by
30
+ * {@link transportAction}. Exported so consumer-authored custom runners can
31
+ * share the wire convention instead of hand-copying the literal. */
32
+ export const IDEMPOTENCY_COMMAND_FIELD = "idempotency_key";
33
+
34
+ let _send: TransportSendFn | undefined;
35
+
36
+ /** Configure the global transport adapter. Call once at app boot;
37
+ * only needed if using `transportAction`. */
38
+ export function configureTransport(fn: TransportSendFn): void {
39
+ _send = fn;
40
+ }
41
+
42
+ /** @internal Test-only: reset the transport to unconfigured state. */
43
+ export function _resetTransportForTest(): void {
44
+ _send = undefined;
45
+ }
46
+
47
+ /** Caller-facing shape of a transportAction definition. `command`
48
+ * replaces `run`. Result is `void` because transport.send does not
49
+ * return a payload (the response arrives later via SSE events). */
50
+ interface TransportActionDefinition<TArgs, TOp = unknown> extends Omit<
51
+ ActionDefinition<TArgs, void, TOp>,
52
+ "run"
53
+ > {
54
+ /** Build the command for this dispatch. Re-evaluated per-dispatch. */
55
+ command: (args: TArgs) => TransportCommand;
56
+ }
57
+
58
+ /** Build an Action from a transport command descriptor. The generated
59
+ * `run()` calls the configured send function and throws {@link ActionError}
60
+ * on `!ok`, so the dispatcher's error branch fires consistently. */
61
+ export function transportAction<TArgs, TOp = unknown>(
62
+ def: TransportActionDefinition<TArgs, TOp>,
63
+ ): Action<TArgs, void> {
64
+ const { command, ...rest } = def;
65
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- void used as generic type argument
66
+ return defineAction<TArgs, void, TOp>({
67
+ ...(rest as Omit<ActionDefinition<TArgs, void, TOp>, "run">),
68
+ run: async (args: TArgs, signal: AbortSignal, ctx?: ActionContext) => {
69
+ if (_send === undefined) {
70
+ throw new ActionError("Transport not configured. Call configureTransport() at boot.", {
71
+ code: "transport_not_configured",
72
+ });
73
+ }
74
+ const raw = command(args);
75
+ let cmd: TransportCommand;
76
+ if (ctx?.idempotencyKey !== undefined) {
77
+ cmd = { ...raw, [IDEMPOTENCY_COMMAND_FIELD]: ctx.idempotencyKey };
78
+ } else {
79
+ cmd = raw;
80
+ }
81
+ const r = await _send(cmd, { signal });
82
+ if (!r.ok) {
83
+ if (signal.aborted || r.code === "cancelled") {
84
+ throw new ActionError("cancelled", { code: "cancelled" });
85
+ }
86
+ if (r.code === "timeout") {
87
+ throw new ActionError(r.error ?? "Request timed out", {
88
+ status: r.status,
89
+ code: "timeout",
90
+ });
91
+ }
92
+ if (r.code === "network") {
93
+ throw new ActionError(r.error ?? "network error", { status: r.status, code: "network" });
94
+ }
95
+ const errOpts: { status: number; code?: string } = { status: r.status };
96
+ if (r.code !== undefined) {
97
+ errOpts.code = r.code;
98
+ }
99
+ throw new ActionError(r.error ?? `send failed with status ${String(r.status)}`, errOpts);
100
+ }
101
+ return undefined;
102
+ },
103
+ });
104
+ }