@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.
- package/NOTICE +3 -0
- package/README.md +145 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +36 -4
- package/package.json +86 -4
- package/src/actions/api.ts +268 -0
- package/src/actions/async-feedback.ts +217 -0
- package/src/actions/cleanup.ts +66 -0
- package/src/actions/debounce.ts +150 -0
- package/src/actions/define-helpers.ts +87 -0
- package/src/actions/define.ts +851 -0
- package/src/actions/error.ts +161 -0
- package/src/actions/index.ts +60 -0
- package/src/actions/loading.ts +131 -0
- package/src/actions/notifier.ts +61 -0
- package/src/actions/poll-until.ts +133 -0
- package/src/actions/poll.ts +156 -0
- package/src/actions/registry.ts +259 -0
- package/src/actions/retry.ts +79 -0
- package/src/actions/testing.ts +29 -0
- package/src/actions/transport.ts +104 -0
- package/src/actions/types.ts +249 -0
- package/src/dom/bind-list.ts +103 -0
- package/src/dom/el.ts +67 -0
- package/src/dom/index.ts +9 -0
- package/src/dom/reconcile-tree.ts +268 -0
- package/src/dom/reconcile.ts +76 -0
- package/src/fetch/index.ts +18 -0
- package/src/fetch/instance.ts +37 -0
- package/src/fetch/request.ts +437 -0
- package/src/fetch/timeout.ts +67 -0
- package/src/fetch/types.ts +206 -0
- package/src/fetch/verbs.ts +103 -0
- package/src/index.ts +4 -8
- package/src/primitives/announce.ts +134 -0
- package/src/primitives/ask.ts +406 -0
- package/src/primitives/dialog-engine.ts +500 -0
- package/src/primitives/dialog.ts +263 -0
- package/src/primitives/disclosure.ts +287 -0
- package/src/primitives/focus-trap.ts +157 -0
- package/src/primitives/fx.ts +204 -0
- package/src/primitives/live-regions.ts +83 -0
- package/src/primitives/modal-host.ts +214 -0
- package/src/primitives/modal.ts +247 -0
- package/src/primitives/owner.ts +322 -0
- package/src/primitives/popover.ts +500 -0
- package/src/primitives/popup-core.ts +603 -0
- package/src/primitives/popup.ts +23 -0
- package/src/primitives/roving-focus.ts +198 -0
- package/src/primitives/scroll-lock.ts +119 -0
- package/src/primitives/skeleton.ts +142 -0
- package/src/primitives/theme.ts +202 -0
- package/src/primitives/toast/engine.ts +327 -0
- package/src/primitives/toast/index.ts +139 -0
- package/src/primitives/toast/view.ts +244 -0
- package/src/primitives/tooltip.ts +377 -0
- package/src/primitives/transition.ts +119 -0
- package/src/primitives/view-transition.ts +57 -0
- package/src/signal/bus.ts +126 -0
- package/src/signal/collection.ts +182 -0
- package/src/signal/index.ts +34 -0
- package/src/signal/resource.ts +102 -0
- package/src/signal/signal-map.ts +46 -0
- package/src/signal/signal.ts +1002 -0
- 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
|
+
}
|