@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,268 @@
1
+ // actions holds its own fetch instance (the fetch module is
2
+ // instances-only), fully isolated from any instance a consuming app builds,
3
+ // and maps fetch's ApiResult envelope onto ActionError.
4
+
5
+ import { createFetch } from "../fetch/instance.js";
6
+ import type { FetchInstance } from "../fetch/instance.js";
7
+ import { API_TIMEOUT_MS } from "../fetch/timeout.js";
8
+ import type { ApiErr, FetchConfig, RequestOptions } from "../fetch/types.js";
9
+
10
+ import { defineAction, IDEMPOTENCY_HEADER } from "./define.js";
11
+ import { ActionError } from "./error.js";
12
+ import type {
13
+ Action,
14
+ ActionContext,
15
+ ActionDefinition,
16
+ ActionErrorLike,
17
+ RequestSpec,
18
+ } from "./types.js";
19
+
20
+ const JSON_CT = "application/json";
21
+
22
+ /** Configuration for the global API fetch layer (mirrors RTK's
23
+ * fetchBaseQuery). Set via `configureApi()`. */
24
+ export interface ApiConfig {
25
+ /** Base URL prepended to every RequestSpec.path (e.g. "https://api.example.com/v1").
26
+ * `RequestSpec.path` is treated as a RELATIVE path: when this is set, an absolute or
27
+ * protocol-relative path cannot override the origin (it is kept as a path segment).
28
+ * When this is UNSET, `RequestSpec.path` is passed to fetch() verbatim, so the caller
29
+ * owns the full URL and must never pass untrusted input as the whole path. */
30
+ readonly baseUrl?: string;
31
+ /** Inject headers on every request. Receives current headers + the request spec.
32
+ * Mutate and/or return the headers object. May be async (e.g. to read a token store). */
33
+ readonly prepareHeaders?: (
34
+ headers: Headers,
35
+ context: { spec: RequestSpec },
36
+ ) => Headers | undefined | Promise<Headers | undefined>;
37
+ /** RequestInit.credentials mode applied to every request (e.g. "include" for cookies). */
38
+ readonly credentials?: RequestCredentials;
39
+ /** Custom fetch implementation. Useful for SSR (isomorphic-fetch) or testing. */
40
+ readonly fetchFn?: typeof fetch;
41
+ }
42
+
43
+ // A fetch instance's config is frozen at construction, so configureApi's
44
+ // replace-not-merge semantics rebuild the instance rather than mutate it.
45
+ // prepareHeaders stays here rather than on the instance: it is spec-aware
46
+ // (`(headers, { spec })`), a shape fetch's `(headers)`-only hook can't express.
47
+ let apiFetch: FetchInstance = createFetch();
48
+ let apiPrepareHeaders: ApiConfig["prepareHeaders"];
49
+
50
+ /** Configure the global HTTP layer used by all `apiAction` instances. Call
51
+ * once at app boot; a later call replaces the whole previous config. */
52
+ export function configureApi(config: ApiConfig): void {
53
+ const fetchConfig: FetchConfig = {};
54
+ if (config.baseUrl !== undefined) {
55
+ fetchConfig.baseUrl = config.baseUrl;
56
+ }
57
+ if (config.credentials !== undefined) {
58
+ fetchConfig.credentials = config.credentials;
59
+ }
60
+ if (config.fetchFn !== undefined) {
61
+ fetchConfig.fetchFn = config.fetchFn;
62
+ }
63
+ // Fetch instances are immutable; replace-not-merge needs a fresh one.
64
+ apiFetch = createFetch(fetchConfig);
65
+ apiPrepareHeaders = config.prepareHeaders;
66
+ }
67
+
68
+ /** Reset API config. @internal Test-only. */
69
+ export function _resetApiConfigForTest(): void {
70
+ apiFetch = createFetch();
71
+ apiPrepareHeaders = undefined;
72
+ }
73
+
74
+ /** Context passed to the {@link ApiActionDefinition.decode} hook. */
75
+ export interface ApiDecodeContext {
76
+ /** HTTP status of the 2xx response being decoded. */
77
+ readonly status: number;
78
+ /** The request descriptor this response answers. */
79
+ readonly spec: RequestSpec;
80
+ }
81
+
82
+ /** A failed request as seen by {@link ApiActionDefinition.decodeError}: the
83
+ * real HTTP response's status, the message the fetch layer lifted, the
84
+ * server-supplied code (when present), the parsed JSON body (when one
85
+ * parsed), and the response headers. Transport-level failures (network /
86
+ * timeout / cancellation / invalid — status 0) never reach the hook. */
87
+ export interface ApiErrorInfo {
88
+ readonly status: number;
89
+ readonly message: string;
90
+ readonly code?: string;
91
+ /** Parsed JSON body of the failed response. Server-controlled input —
92
+ * validate the shape before reading fields. */
93
+ readonly body?: unknown;
94
+ readonly headers?: Headers;
95
+ }
96
+
97
+ /** Return shape of {@link ApiActionDefinition.decodeError}: resolve the
98
+ * dispatch as a success carrying `value`, or replace the default error. */
99
+ export type ApiErrorDecision<TResult> =
100
+ | { readonly kind: "success"; readonly value: TResult }
101
+ | { readonly kind: "error"; readonly error: ActionErrorLike };
102
+
103
+ /** Caller-facing shape of an apiAction definition. Replaces `run` with
104
+ * a `request` function that returns an HTTP {@link RequestSpec}. */
105
+ export interface ApiActionDefinition<TArgs, TResult, TOp = unknown> extends Omit<
106
+ ActionDefinition<TArgs, TResult, TOp>,
107
+ "run"
108
+ > {
109
+ request: (args: TArgs) => RequestSpec;
110
+
111
+ /** Decode / validate a 2xx response body. Receives the raw decoded body
112
+ * (`undefined` for a 204 / empty-body response) and returns the action
113
+ * result; throw (an {@link ActionError}) to route the dispatch to the
114
+ * error branch instead (rollback + error notification + registry error) —
115
+ * the seam for 200-with-error envelopes. Absent: the body is returned as
116
+ * `TResult` unchanged. */
117
+ decode?: (data: unknown, ctx: ApiDecodeContext) => TResult;
118
+
119
+ /** Reinterpret a failed request that produced a real HTTP response. Return
120
+ * `{ kind: "success", value }` to resolve the dispatch as success (e.g. a
121
+ * 409 whose body is a meaningful payload), `{ kind: "error", error }` to
122
+ * replace the default error, or `undefined` to keep the default
123
+ * {@link ActionError} mapping. Transport failures (status 0: network /
124
+ * timeout / cancelled / invalid) never reach this hook, so retry
125
+ * classification and cancellation semantics are preserved. */
126
+ decodeError?: (
127
+ info: ApiErrorInfo,
128
+ ctx: { readonly spec: RequestSpec },
129
+ ) => ApiErrorDecision<TResult> | undefined;
130
+ }
131
+
132
+ /**
133
+ * Build an Action from an HTTP request descriptor.
134
+ * Wraps `defineAction` with a generated `run()` that dispatches the
135
+ * {@link RequestSpec} through the {@link ApiConfig} layer configured with
136
+ * {@link configureApi} (backed by an isolated `@cplieger/ui/fetch` instance).
137
+ */
138
+ export function apiAction<TArgs, TResult = unknown, TOp = unknown>(
139
+ def: ApiActionDefinition<TArgs, TResult, TOp>,
140
+ ): Action<TArgs, TResult> {
141
+ const { request, decode, decodeError, ...rest } = def;
142
+ return defineAction<TArgs, TResult, TOp>({
143
+ ...rest,
144
+ run: async (args, signal, ctx) => {
145
+ const spec = request(args);
146
+ return executeRequest<TResult>(spec, signal, ctx, decode, decodeError);
147
+ },
148
+ });
149
+ }
150
+
151
+ async function executeRequest<T>(
152
+ spec: RequestSpec,
153
+ signal: AbortSignal,
154
+ ctx?: ActionContext,
155
+ decode?: (data: unknown, dctx: ApiDecodeContext) => T,
156
+ decodeError?: (
157
+ info: ApiErrorInfo,
158
+ dctx: { readonly spec: RequestSpec },
159
+ ) => ApiErrorDecision<T> | undefined,
160
+ ): Promise<T> {
161
+ // Content-Type is set before prepareHeaders so the hook can see/override it.
162
+ const headers = new Headers();
163
+ if (spec.method !== "GET" && spec.body !== undefined) {
164
+ headers.set("Content-Type", JSON_CT);
165
+ }
166
+ if (ctx?.idempotencyKey !== undefined) {
167
+ headers.set(IDEMPOTENCY_HEADER, ctx.idempotencyKey);
168
+ }
169
+ if (spec.headers !== undefined) {
170
+ for (const [k, v] of Object.entries(spec.headers)) {
171
+ headers.set(k, v);
172
+ }
173
+ }
174
+ // A returned Headers wins (RTK convention); a throw here propagates before
175
+ // fetch is ever called.
176
+ let effectiveHeaders = headers;
177
+ if (apiPrepareHeaders !== undefined) {
178
+ const prepared = await apiPrepareHeaders(headers, { spec });
179
+ if (prepared !== undefined) {
180
+ effectiveHeaders = prepared;
181
+ }
182
+ }
183
+
184
+ const opts: RequestOptions<T> = {
185
+ signal,
186
+ timeoutMs: API_TIMEOUT_MS,
187
+ headers: effectiveHeaders,
188
+ };
189
+ if (spec.method !== "GET") {
190
+ if (spec.rawBody !== undefined) {
191
+ // Sent as-is: no JSON encoding, no automatic Content-Type.
192
+ opts.rawBody = spec.rawBody;
193
+ } else if (spec.body !== undefined) {
194
+ opts.body = spec.body;
195
+ }
196
+ }
197
+
198
+ const result = await apiFetch.requestRaw<T>(spec.method, spec.path, opts);
199
+ if (result.ok) {
200
+ if (decode !== undefined) {
201
+ // A decode throw routes the dispatch to the error branch — the seam
202
+ // for 200-with-error envelopes.
203
+ return decode(result.data, { status: result.status, spec });
204
+ }
205
+ // fetch collapses a 204 and an empty-body 2xx to data === undefined.
206
+ if (result.data === undefined && result.status !== 204 && spec.method !== "DELETE") {
207
+ console.warn(
208
+ `[actions] ${spec.method} ${spec.path} returned empty body — callers expecting data will receive undefined`,
209
+ );
210
+ }
211
+ return result.data;
212
+ }
213
+ // Transport failures (status 0) never reach decodeError, so retryNetwork
214
+ // classification and cancellation semantics can't be rewritten by the hook.
215
+ if (decodeError !== undefined && result.status > 0) {
216
+ const decision = decodeError(
217
+ {
218
+ status: result.status,
219
+ message: result.error,
220
+ ...(result.code !== undefined && { code: result.code }),
221
+ ...(result.body !== undefined && { body: result.body }),
222
+ ...(result.headers !== undefined && { headers: result.headers }),
223
+ },
224
+ { spec },
225
+ );
226
+ if (decision !== undefined) {
227
+ if (decision.kind === "success") {
228
+ return decision.value;
229
+ }
230
+ const e = decision.error;
231
+ throw e instanceof ActionError
232
+ ? e
233
+ : new ActionError(e.message, {
234
+ ...(e.status !== undefined && { status: e.status }),
235
+ ...(e.code !== undefined && { code: e.code }),
236
+ ...(e.cause !== undefined && { cause: e.cause }),
237
+ });
238
+ }
239
+ }
240
+ throw actionErrorFromApiErr(result);
241
+ }
242
+
243
+ /**
244
+ * Map a fetch {@link ApiErr} envelope onto an {@link ActionError}. A `status`
245
+ * of 0 marks a transport failure (`code: network | timeout | cancelled |
246
+ * invalid`); any other status is a real HTTP response.
247
+ */
248
+ function actionErrorFromApiErr(err: ApiErr): ActionError {
249
+ const { status, error, code } = err;
250
+ if (status === 0) {
251
+ if (code === "cancelled") {
252
+ return new ActionError("Request cancelled", { code: "cancelled" });
253
+ }
254
+ if (code === "timeout") {
255
+ return new ActionError("Request timed out", { status: 0, code: "timeout" });
256
+ }
257
+ if (code === "invalid") {
258
+ // No HTTP status: retryNetwork treats a status-less error as non-retryable.
259
+ return new ActionError(error, { code: "invalid" });
260
+ }
261
+ return new ActionError(error, { status: 0, code: "network" });
262
+ }
263
+ const opts: { status: number; code?: string } = { status };
264
+ if (code !== undefined) {
265
+ opts.code = code;
266
+ }
267
+ return new ActionError(error, opts);
268
+ }
@@ -0,0 +1,217 @@
1
+ import { el } from "../dom/el.js";
2
+
3
+ const RESET_MS = 1200;
4
+
5
+ const DEFAULT_ANNOUNCE = { success: "Action completed", error: "Action failed" } as const;
6
+
7
+ const CHECK_HTML =
8
+ '<svg class="btn-async-glyph" width="14" height="14" viewBox="0 0 24 24" fill="none" ' +
9
+ 'stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" ' +
10
+ 'aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
11
+
12
+ const X_HTML =
13
+ '<svg class="btn-async-glyph" width="14" height="14" viewBox="0 0 24 24" fill="none" ' +
14
+ 'stroke="currentColor" stroke-width="3" stroke-linecap="round" ' +
15
+ 'aria-hidden="true"><path d="M18 6L6 18M6 6l12 12"/></svg>';
16
+
17
+ /** Parse a static SVG string via <template> and return a fresh clone. The
18
+ * markup is a library-controlled constant, so a single root element is
19
+ * guaranteed. */
20
+ function svgNode(svg: string): Node {
21
+ const template = document.createElement("template");
22
+ template.innerHTML = svg;
23
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- default glyph markup always has a single root element
24
+ return template.content.firstElementChild!.cloneNode(true);
25
+ }
26
+
27
+ const defaultRenderPending = (): Node =>
28
+ el("span", { className: "spinner-sm btn-async-spinner", "aria-hidden": "true" });
29
+ const defaultRenderSuccess = (): Node => svgNode(CHECK_HTML);
30
+ const defaultRenderError = (): Node => svgNode(X_HTML);
31
+
32
+ const resetTimers = new WeakMap<HTMLButtonElement, ReturnType<typeof setTimeout>>();
33
+
34
+ // Keyed on this, not `data-async-status`, so a persisted outcome glyph
35
+ // (resetMs<=0) can't permanently block re-dispatch.
36
+ const inFlight = new WeakSet<HTMLButtonElement>();
37
+
38
+ let liveRegion: HTMLElement | null = null;
39
+
40
+ function announce(message: string): void {
41
+ if (liveRegion === null) {
42
+ liveRegion = el("span", {
43
+ className: "sr-only",
44
+ "aria-live": "polite",
45
+ "aria-atomic": "true",
46
+ });
47
+ document.body.appendChild(liveRegion);
48
+ }
49
+ const region = liveRegion;
50
+ // Clear then set: re-announces an identical message.
51
+ region.textContent = "";
52
+ setTimeout(() => {
53
+ region.textContent = message;
54
+ }, 50);
55
+ }
56
+
57
+ /** Options for {@link withAsyncFeedback}. All fields are optional; the glyph
58
+ * renderers default to built-in inline SVGs. */
59
+ export interface AsyncFeedbackOptions {
60
+ /** Post-completion glyph hold in ms before content reverts. Default 1200;
61
+ * `<= 0` persists the glyph indefinitely (button still re-enables, but
62
+ * `data-async-status` keeps its terminal value). */
63
+ resetMs?: number;
64
+ /** When true, prepend the spinner before the existing content (e.g.
65
+ * "⟳ Cloning…") instead of replacing the content with just the spinner.
66
+ * Default false (icon-only replace). Ignored when {@link target} is set. */
67
+ keepLabel?: boolean;
68
+ /** Drive a single child slot via element replacement
69
+ * (`current.replaceWith(next)`) instead of the button's whole content;
70
+ * other children of `btn` are untouched and the original node is restored
71
+ * on a non-persist reset. Expected to be a descendant of `btn` (not
72
+ * validated). `keepLabel` is ignored when set. */
73
+ target?: HTMLElement;
74
+ /** Pending-state node factory. Returns a fresh node per call. */
75
+ renderPending?: () => Node;
76
+ /** Success-glyph node factory. Returns a fresh node per call. */
77
+ renderSuccess?: () => Node;
78
+ /** Error-glyph node factory. Returns a fresh node per call. */
79
+ renderError?: () => Node;
80
+ /** Live-region announcement text, or `false` to disable announcing. */
81
+ announce?: { readonly success: string; readonly error: string } | false;
82
+ }
83
+
84
+ /** Run an async function with consistent button feedback. The button is
85
+ * disabled during the call. Re-entrant calls (clicking again while a cycle
86
+ * is active) are ignored. */
87
+ export async function withAsyncFeedback(
88
+ btn: HTMLButtonElement,
89
+ fn: () => Promise<unknown>,
90
+ opts: AsyncFeedbackOptions = {},
91
+ ): Promise<void> {
92
+ // `data-async-status` would deadlock the persist path, whose terminal
93
+ // status is never cleared.
94
+ if (inFlight.has(btn)) {
95
+ return;
96
+ }
97
+ inFlight.add(btn);
98
+
99
+ // Avoids a stale restore from a prior cycle.
100
+ const prevTimer = resetTimers.get(btn);
101
+ if (prevTimer !== undefined) {
102
+ clearTimeout(prevTimer);
103
+ resetTimers.delete(btn);
104
+ }
105
+
106
+ const renderPending = opts.renderPending ?? defaultRenderPending;
107
+ const renderSuccess = opts.renderSuccess ?? defaultRenderSuccess;
108
+ const renderError = opts.renderError ?? defaultRenderError;
109
+ const announceCfg = opts.announce ?? DEFAULT_ANNOUNCE;
110
+
111
+ // `originalTarget` keeps the exact node so a non-persist reset restores it;
112
+ // `currentSlot` tracks whichever node currently occupies the slot.
113
+ const target = opts.target;
114
+ const useTarget = target !== undefined;
115
+ const originalTarget: ChildNode | null = useTarget ? target : null;
116
+ let currentSlot: ChildNode | null = useTarget ? target : null;
117
+
118
+ // Render factories return Element/Text, both ChildNode at runtime.
119
+ const swapSlot = (next: Node): void => {
120
+ if (currentSlot === null) {
121
+ return;
122
+ }
123
+ currentSlot.replaceWith(next);
124
+ currentSlot = next as ChildNode;
125
+ };
126
+
127
+ const origNodes = useTarget ? [] : [...btn.childNodes].map((n) => n.cloneNode(true));
128
+ const origDisabled = btn.disabled;
129
+ const origAriaBusy = btn.getAttribute("aria-busy");
130
+ // Captured before disabling: `btn.disabled = true` below blurs the button.
131
+ const hadFocus = document.activeElement === btn;
132
+
133
+ const restoreAriaBusy = (): void => {
134
+ if (origAriaBusy === null) {
135
+ btn.removeAttribute("aria-busy");
136
+ } else {
137
+ btn.setAttribute("aria-busy", origAriaBusy);
138
+ }
139
+ };
140
+
141
+ // Only restores if focus has not since moved to a competing element.
142
+ const restoreFocus = (): void => {
143
+ if (hadFocus && btn.isConnected && !btn.disabled) {
144
+ const active = document.activeElement;
145
+ if (active === null || active === document.body) {
146
+ btn.focus();
147
+ }
148
+ }
149
+ };
150
+
151
+ btn.dataset["asyncStatus"] = "pending";
152
+ btn.disabled = true;
153
+ btn.setAttribute("aria-busy", "true");
154
+ if (useTarget) {
155
+ swapSlot(renderPending());
156
+ } else if (opts.keepLabel === true) {
157
+ btn.prepend(renderPending(), document.createTextNode(" "));
158
+ } else {
159
+ btn.replaceChildren(renderPending());
160
+ }
161
+
162
+ let ok = true;
163
+ try {
164
+ await fn();
165
+ } catch {
166
+ ok = false;
167
+ }
168
+
169
+ // The async op may have removed btn from the DOM (e.g. a re-rendered list);
170
+ // the new DOM already reflects the result.
171
+ if (!btn.isConnected) {
172
+ inFlight.delete(btn);
173
+ restoreAriaBusy();
174
+ delete btn.dataset["asyncStatus"];
175
+ return;
176
+ }
177
+
178
+ btn.dataset["asyncStatus"] = ok ? "success" : "error";
179
+ if (useTarget) {
180
+ swapSlot(ok ? renderSuccess() : renderError());
181
+ } else {
182
+ btn.replaceChildren(ok ? renderSuccess() : renderError());
183
+ }
184
+ restoreAriaBusy();
185
+ inFlight.delete(btn);
186
+ if (announceCfg !== false) {
187
+ announce(ok ? announceCfg.success : announceCfg.error);
188
+ }
189
+
190
+ // `resetMs <= 0` persists: no revert timer, but re-enable now since no
191
+ // later callback will.
192
+ const reset = opts.resetMs ?? RESET_MS;
193
+ if (reset <= 0) {
194
+ btn.disabled = origDisabled;
195
+ restoreFocus();
196
+ return;
197
+ }
198
+
199
+ const timerId = setTimeout(() => {
200
+ resetTimers.delete(btn);
201
+ if (!btn.isConnected) {
202
+ return;
203
+ }
204
+ if (useTarget) {
205
+ if (currentSlot !== null && originalTarget !== null) {
206
+ currentSlot.replaceWith(originalTarget);
207
+ currentSlot = originalTarget;
208
+ }
209
+ } else {
210
+ btn.replaceChildren(...origNodes.map((n) => n.cloneNode(true)));
211
+ }
212
+ btn.disabled = origDisabled;
213
+ restoreFocus();
214
+ delete btn.dataset["asyncStatus"];
215
+ }, reset);
216
+ resetTimers.set(btn, timerId);
217
+ }
@@ -0,0 +1,66 @@
1
+ import type { Action } from "./types.js";
2
+
3
+ // Cleanup hooks must be idempotent: cancellation can fire multiple times
4
+ // (e.g. a cancelled navigation followed by a confirmed one).
5
+
6
+ /** Minimal shape needed for cleanup — avoids variance-unsafe casts. */
7
+ interface Cancellable {
8
+ readonly name: string;
9
+ cancel(): void;
10
+ }
11
+
12
+ const trackedActions = new Set<Cancellable>();
13
+ const cleanupHooks = new Set<() => void>();
14
+ let beforeunloadInstalled = false;
15
+
16
+ /** Internal: register an Action so cancelAllPending() can iterate it. */
17
+ export function _registerAction<TArgs, TResult>(action: Action<TArgs, TResult>): void {
18
+ trackedActions.add(action);
19
+ installBeforeunloadOnce();
20
+ }
21
+
22
+ /**
23
+ * Register a cleanup function to run on page unload (or test invoke).
24
+ */
25
+ export function registerCleanup(fn: () => void): () => void {
26
+ cleanupHooks.add(fn);
27
+ installBeforeunloadOnce();
28
+ return () => cleanupHooks.delete(fn);
29
+ }
30
+
31
+ function cancelAllPending(): void {
32
+ for (const action of trackedActions) {
33
+ try {
34
+ action.cancel();
35
+ } catch (e) {
36
+ console.error(`[actions] cancel for ${action.name} threw`, e);
37
+ }
38
+ }
39
+ for (const fn of [...cleanupHooks]) {
40
+ try {
41
+ fn();
42
+ } catch (e) {
43
+ console.error("[actions] cleanup hook threw", e);
44
+ }
45
+ }
46
+ }
47
+
48
+ function installBeforeunloadOnce(): void {
49
+ if (beforeunloadInstalled) {
50
+ return;
51
+ }
52
+ beforeunloadInstalled = true;
53
+ if (typeof window !== "undefined") {
54
+ window.addEventListener("beforeunload", cancelAllPending);
55
+ }
56
+ }
57
+
58
+ /** Test-only: clear both registries + uninstall the listener. */
59
+ export function _resetForTest(): void {
60
+ trackedActions.clear();
61
+ cleanupHooks.clear();
62
+ if (beforeunloadInstalled && typeof window !== "undefined") {
63
+ window.removeEventListener("beforeunload", cancelAllPending);
64
+ }
65
+ beforeunloadInstalled = false;
66
+ }
@@ -0,0 +1,150 @@
1
+ import { createRoot } from "../signal/signal.js";
2
+
3
+ import type { Action } from "./types.js";
4
+
5
+ // A no-argument action's args ARE `undefined`, so it can't double as the
6
+ // empty-state marker; this sentinel is module-private and unforgeable as TArgs.
7
+ const NO_ARGS = Symbol("debounce.noArgs");
8
+
9
+ /** The callable {@link debouncedDispatch} returns: invoking it schedules a
10
+ * dispatch rather than performing one (except under `leading: true`, where the
11
+ * call that opens a window dispatches immediately), and the extra members
12
+ * drive whatever is currently scheduled. Only `flush` hands back the action's
13
+ * promise — every other path drops it, so results and errors are observed
14
+ * through the action's own notifications and callbacks. */
15
+ export interface DebouncedDispatch<TArgs> {
16
+ /** Replaces any pending dispatch's args. */
17
+ (args: TArgs): void;
18
+
19
+ /** Fires immediately with the most-recent args, or the args given here.
20
+ * No-op if nothing is pending and no args supplied. */
21
+ flush(args?: TArgs): Promise<unknown> | undefined;
22
+
23
+ cancel(): void;
24
+
25
+ isPending(): boolean;
26
+ }
27
+
28
+ interface DebounceOptions {
29
+ readonly wait: number;
30
+ /** Default false (trailing edge). */
31
+ readonly leading?: boolean;
32
+ }
33
+
34
+ /** Wrap an action so a burst of calls collapses into one dispatch per `wait`
35
+ * window. Trailing edge by default (each call restarts the timer, last args
36
+ * win); `leading: true` fires the first call at once and holds the rest for
37
+ * one trailing fire. There is no `maxWait`: call `flush()` for a guaranteed
38
+ * fire. `cancel()` drops scheduled args only, never a dispatch already
39
+ * started. Disposing the owner it was created under cancels it for good. */
40
+ export function debouncedDispatch<TArgs, TResult>(
41
+ action: Action<TArgs, TResult>,
42
+ opts: DebounceOptions,
43
+ ): DebouncedDispatch<TArgs> {
44
+ let timer: ReturnType<typeof setTimeout> | undefined;
45
+ let lastArgs: TArgs | typeof NO_ARGS = NO_ARGS;
46
+ let pending = false;
47
+ let lastFiredAt = 0;
48
+ let ended = false;
49
+
50
+ const fn = ((args: TArgs): void => {
51
+ if (ended) {
52
+ return;
53
+ }
54
+ if (opts.leading === true) {
55
+ const now = Date.now();
56
+ if (now - lastFiredAt < opts.wait) {
57
+ lastArgs = args;
58
+ pending = true;
59
+ if (timer === undefined) {
60
+ const remaining = Math.max(0, opts.wait - (now - lastFiredAt));
61
+ timer = setTimeout(fireTrailing, remaining);
62
+ }
63
+ return;
64
+ }
65
+ void action.dispatch(args);
66
+ // The action's synchronous prefix may have disposed the owner.
67
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- set by owner disposal during dispatch
68
+ if (ended) {
69
+ return;
70
+ }
71
+ lastFiredAt = now;
72
+ lastArgs = NO_ARGS;
73
+ // The timer below is a cooldown re-arm, not a pending dispatch.
74
+ pending = false;
75
+ if (timer !== undefined) {
76
+ clearTimeout(timer);
77
+ }
78
+ timer = setTimeout(fireTrailing, opts.wait);
79
+ return;
80
+ }
81
+ lastArgs = args;
82
+ pending = true;
83
+ if (timer !== undefined) {
84
+ clearTimeout(timer);
85
+ }
86
+ timer = setTimeout(() => {
87
+ timer = undefined;
88
+ pending = false;
89
+ const a = lastArgs;
90
+ lastArgs = NO_ARGS;
91
+ if (a !== NO_ARGS) {
92
+ void action.dispatch(a);
93
+ }
94
+ }, opts.wait);
95
+ }) as DebouncedDispatch<TArgs>;
96
+
97
+ function fireTrailing(): void {
98
+ timer = undefined;
99
+ const a = lastArgs;
100
+ lastArgs = NO_ARGS;
101
+ if (a !== NO_ARGS) {
102
+ lastFiredAt = Date.now();
103
+ pending = false;
104
+ timer = setTimeout(fireTrailing, opts.wait);
105
+ void action.dispatch(a);
106
+ } else {
107
+ pending = false;
108
+ }
109
+ }
110
+
111
+ fn.flush = (args?: TArgs): Promise<unknown> | undefined => {
112
+ if (ended) {
113
+ return undefined;
114
+ }
115
+ if (timer !== undefined) {
116
+ clearTimeout(timer);
117
+ timer = undefined;
118
+ }
119
+ const a = args ?? lastArgs;
120
+ lastArgs = NO_ARGS;
121
+ pending = false;
122
+ if (a !== NO_ARGS) {
123
+ if (opts.leading === true) {
124
+ lastFiredAt = Date.now();
125
+ }
126
+ return action.dispatch(a);
127
+ }
128
+ return undefined;
129
+ };
130
+
131
+ fn.cancel = (): void => {
132
+ if (timer !== undefined) {
133
+ clearTimeout(timer);
134
+ timer = undefined;
135
+ }
136
+ lastArgs = NO_ARGS;
137
+ pending = false;
138
+ };
139
+
140
+ fn.isPending = (): boolean => pending;
141
+
142
+ createRoot((_dispose, owner) => {
143
+ owner.onDispose(() => {
144
+ ended = true;
145
+ fn.cancel();
146
+ });
147
+ });
148
+
149
+ return fn;
150
+ }