@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,437 @@
1
+ import type { FetchConfig } from "./types.js";
2
+ import { API_TIMEOUT_MS, requestSignal, type AbortSource } from "./timeout.js";
3
+ import type { AnyRequestOptions, ApiErr, ApiResult, FailedRequest, HttpMethod } from "./types.js";
4
+
5
+ /** The single implementation behind every `RequestRawFn` overload. */
6
+ export type RequestRawCore = <T>(
7
+ method: HttpMethod,
8
+ path: string,
9
+ opts?: AnyRequestOptions<T>,
10
+ ) => Promise<ApiResult<T>>;
11
+
12
+ /** The single implementation behind every `RequestFn` overload. */
13
+ export type RequestCore = <T>(
14
+ method: HttpMethod,
15
+ path: string,
16
+ opts?: AnyRequestOptions<T>,
17
+ ) => Promise<T | null>;
18
+
19
+ const JSON_CT = "application/json";
20
+
21
+ /** Extract a human-readable message from an unknown thrown value without
22
+ * risking a `[object Object]` stringification. */
23
+ function errMsg(e: unknown): string {
24
+ if (e instanceof Error || e instanceof DOMException) {
25
+ return e.message;
26
+ }
27
+ if (typeof e === "string") {
28
+ return e;
29
+ }
30
+ return "unknown error";
31
+ }
32
+
33
+ /** Narrow an unknown parsed body to an indexable object. */
34
+ function isRecord(v: unknown): v is Record<string, unknown> {
35
+ return typeof v === "object" && v !== null;
36
+ }
37
+
38
+ /** Build an ApiErr, omitting optional fields when absent
39
+ * (exactOptionalPropertyTypes). `headers`/`body` are passed only where a real
40
+ * HTTP response exists, per the ApiErr contract. */
41
+ function makeErr(
42
+ status: number,
43
+ error: string,
44
+ code?: string,
45
+ requestId?: string,
46
+ headers?: Headers,
47
+ body?: unknown,
48
+ ): ApiErr {
49
+ const err: {
50
+ ok: false;
51
+ status: number;
52
+ error: string;
53
+ code?: string;
54
+ requestId?: string;
55
+ headers?: Headers;
56
+ body?: unknown;
57
+ } = {
58
+ ok: false,
59
+ status,
60
+ error,
61
+ };
62
+ if (code !== undefined) {
63
+ err.code = code;
64
+ }
65
+ if (requestId !== undefined) {
66
+ err.requestId = requestId;
67
+ }
68
+ if (headers !== undefined) {
69
+ err.headers = headers;
70
+ }
71
+ if (body !== undefined) {
72
+ err.body = body;
73
+ }
74
+ return err;
75
+ }
76
+
77
+ /** Coerce a possibly-non-numeric status (a custom fetchFn may return a
78
+ * malformed object) to a number, defaulting to 0. */
79
+ function statusOf(res: Response): number {
80
+ return typeof res.status === "number" ? res.status : 0;
81
+ }
82
+
83
+ /** Neutralize a relative path's navigation syntax before it joins a base URL,
84
+ * so URL normalization cannot pop the base path prefix. Backslashes, and in
85
+ * the path part ASCII TAB/LF/CR (which the WHATWG parser strips, fusing two
86
+ * dots into a live `..`), are percent-encoded. Every dot-segment, encoded or
87
+ * not, is double-encoded to `%252E`: the parser still pops `%2E`. */
88
+ function safeSuffix(path: string): string {
89
+ // Query (`?`) / fragment (`#`) are isolated before segment processing: the
90
+ // URL parser does not path-normalize them and they must reach the server
91
+ // verbatim.
92
+ const marks = [path.indexOf("?"), path.indexOf("#")].filter((i) => i !== -1);
93
+ const sep = marks.length > 0 ? Math.min(...marks) : -1;
94
+ const pathPart = sep === -1 ? path : path.slice(0, sep);
95
+ const rest = sep === -1 ? "" : path.slice(sep);
96
+ const raw = pathPart.startsWith("/") ? pathPart : `/${pathPart}`;
97
+ const encoded = raw
98
+ .replace(/\\/g, "%5C")
99
+ .replace(/\t/g, "%09")
100
+ .replace(/\n/g, "%0A")
101
+ .replace(/\r/g, "%0D")
102
+ .split("/")
103
+ .map((segment) => {
104
+ const dotLike = segment.replace(/%2e/gi, ".");
105
+ if (dotLike === "." || dotLike === "..") {
106
+ return segment.replace(/\./g, "%2E").replace(/%/g, "%25");
107
+ }
108
+ return segment;
109
+ })
110
+ .join("/");
111
+ return encoded + rest;
112
+ }
113
+
114
+ /**
115
+ * Join a base URL with a relative path: strip a trailing slash from the base,
116
+ * then append the path via {@link safeSuffix}. With no base, the path is
117
+ * returned verbatim.
118
+ */
119
+ function joinUrl(baseUrl: string | undefined, path: string): string {
120
+ if (baseUrl === undefined) {
121
+ return path;
122
+ }
123
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
124
+ return `${base}${safeSuffix(path)}`;
125
+ }
126
+
127
+ /** Merge caller-supplied headers (object or Headers) into the target. */
128
+ function mergeHeaders(target: Headers, source: Record<string, string> | Headers | undefined): void {
129
+ if (source === undefined) {
130
+ return;
131
+ }
132
+ if (source instanceof Headers) {
133
+ source.forEach((value, key) => {
134
+ target.set(key, value);
135
+ });
136
+ return;
137
+ }
138
+ for (const [key, value] of Object.entries(source)) {
139
+ target.set(key, value);
140
+ }
141
+ }
142
+
143
+ /** The caller-aborted-wins envelope shared by both classifiers. */
144
+ function cancelledErr(): ApiErr {
145
+ return makeErr(0, "request cancelled", "cancelled");
146
+ }
147
+
148
+ /**
149
+ * Classify a throw from the BUILD phase (header construction, JSON body
150
+ * encoding, `prepareHeaders`, timeout composition, url join). A caller that
151
+ * already aborted classifies as "cancelled"; every other build failure is a
152
+ * client-side "invalid" request — it never reached the network.
153
+ */
154
+ function classifyBuildError(e: unknown, callerSignal: AbortSignal | undefined): ApiErr {
155
+ if (callerSignal?.aborted === true) {
156
+ return cancelledErr();
157
+ }
158
+ return makeErr(0, errMsg(e), "invalid");
159
+ }
160
+
161
+ /**
162
+ * Classify a thrown error from the FETCH / response-read phase. Priority:
163
+ * the caller's abort won the request signal → "cancelled"; DOMException
164
+ * TimeoutError / AbortError → "timeout"; everything else → "network".
165
+ */
166
+ function classifyThrown(e: unknown, winner: AbortSource | undefined): ApiErr {
167
+ if (winner === "caller") {
168
+ return cancelledErr();
169
+ }
170
+ if (e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError")) {
171
+ return makeErr(0, e.message, "timeout");
172
+ }
173
+ return makeErr(0, errMsg(e), "network");
174
+ }
175
+
176
+ /** Start releasing a body the decided result no longer reads, without
177
+ * waiting: a cancel that throws, rejects or never settles cannot replace or
178
+ * hold that result. */
179
+ function releaseQuietly(cancel: () => Promise<void> | undefined): void {
180
+ try {
181
+ void cancel()?.catch(() => undefined);
182
+ } catch {
183
+ // A custom fetchFn's stream may throw where a platform one rejects.
184
+ }
185
+ }
186
+
187
+ /** Read a response body's bytes, refusing more than `max`: a `content-length`
188
+ * over the cap is rejected up front and the streamed body is aborted the
189
+ * moment it exceeds the cap, so an untrusted upstream cannot force unbounded
190
+ * buffering. */
191
+ async function readChunks(res: Response, max: number): Promise<Uint8Array<ArrayBuffer>[]> {
192
+ const contentLength = res.headers.get("content-length");
193
+ if (contentLength !== null && Number(contentLength) > max) {
194
+ throw new Error(`response exceeds ${max} bytes`);
195
+ }
196
+ const body = res.body;
197
+ if (body === null) {
198
+ return [];
199
+ }
200
+ const reader = body.getReader();
201
+ const chunks: Uint8Array<ArrayBuffer>[] = [];
202
+ let total = 0;
203
+ for (;;) {
204
+ const result = await reader.read();
205
+ if (result.done) {
206
+ return chunks;
207
+ }
208
+ total += result.value.byteLength;
209
+ if (total > max) {
210
+ releaseQuietly(() => reader.cancel());
211
+ throw new Error(`response exceeds ${max} bytes`);
212
+ }
213
+ chunks.push(result.value);
214
+ }
215
+ }
216
+
217
+ /** Read a response body as text, bounded to `max` bytes when set. */
218
+ async function readBoundedText(res: Response, max: number | undefined): Promise<string> {
219
+ if (max === undefined) {
220
+ return res.text();
221
+ }
222
+ return new Blob(await readChunks(res, max)).text();
223
+ }
224
+
225
+ /** Read a response body as a `Blob` typed by its `Content-Type`, bounded to
226
+ * `max` bytes when set. */
227
+ async function readBoundedBlob(res: Response, max: number | undefined): Promise<Blob> {
228
+ const type = res.headers.get("content-type") ?? "";
229
+ return new Blob(await readChunks(res, max ?? Number.POSITIVE_INFINITY), { type });
230
+ }
231
+
232
+ /** Parse a non-2xx response body, lifting error / code / request_id fields. */
233
+ async function parseErrorResponse(res: Response, max: number | undefined): Promise<ApiErr> {
234
+ const status = statusOf(res);
235
+ let error = `HTTP ${status}`;
236
+ let code: string | undefined;
237
+ let requestId: string | undefined;
238
+ let parsed: unknown;
239
+ try {
240
+ parsed = JSON.parse(await readBoundedText(res, max));
241
+ if (isRecord(parsed)) {
242
+ const errField = parsed["error"];
243
+ if (typeof errField === "string") {
244
+ error = errField;
245
+ }
246
+ const codeField = parsed["code"];
247
+ if (typeof codeField === "string") {
248
+ code = codeField;
249
+ }
250
+ const ridField = parsed["request_id"] ?? parsed["requestId"];
251
+ if (typeof ridField === "string") {
252
+ requestId = ridField;
253
+ }
254
+ }
255
+ } catch {
256
+ // Non-JSON / empty error body: keep the `HTTP <status>` fallback.
257
+ }
258
+ return makeErr(status, error, code, requestId, res.headers, parsed);
259
+ }
260
+
261
+ function logObserverError(e: unknown): void {
262
+ try {
263
+ console.error("@cplieger/fetch: onError threw", e);
264
+ } catch {
265
+ // A throwing console cannot reach the caller either.
266
+ }
267
+ }
268
+
269
+ /** Hand a failure to the instance's observer, which must never change or
270
+ * delay the result its caller receives. */
271
+ function notify(
272
+ onError: NonNullable<FetchConfig["onError"]>,
273
+ err: ApiErr,
274
+ req: FailedRequest,
275
+ ): void {
276
+ try {
277
+ Promise.resolve(onError(err, req)).catch(logObserverError);
278
+ } catch (e) {
279
+ logObserverError(e);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Build the non-throwing request core bound to an immutable config, captured
285
+ * once at instance construction ({@link createFetch}).
286
+ */
287
+ export function makeRequestRaw(cfg: FetchConfig): RequestRawCore {
288
+ const perform = async function perform<T>(
289
+ method: HttpMethod,
290
+ path: string,
291
+ opts?: AnyRequestOptions<T>,
292
+ ): Promise<ApiResult<T>> {
293
+ let callerSignal: AbortSignal | undefined;
294
+ let winner: () => AbortSource | undefined;
295
+
296
+ // --- Build phase ------------------------------------------------------
297
+ // A throw here is a client-side "invalid" request (or "cancelled" if the
298
+ // caller already aborted) — never hit the network.
299
+ const init: RequestInit = { method };
300
+ let url: string;
301
+ try {
302
+ callerSignal = opts?.signal;
303
+ const headers = new Headers();
304
+ if (opts?.rawBody !== undefined && opts.body != null) {
305
+ throw new TypeError("body and rawBody are mutually exclusive");
306
+ }
307
+ // The option types refuse this pairing; a JavaScript caller can still pass it.
308
+ const loose: { as?: unknown; decoder?: unknown; ignoreBody?: unknown } | undefined = opts;
309
+ if (loose?.as === "blob" && (loose.decoder !== undefined || loose.ignoreBody === true)) {
310
+ throw new TypeError('as: "blob" excludes decoder and ignoreBody');
311
+ }
312
+ if (method !== "GET" && opts?.rawBody !== undefined) {
313
+ init.body = opts.rawBody;
314
+ } else if (method !== "GET" && opts?.body != null) {
315
+ const encoded = JSON.stringify(opts.body) as string | undefined;
316
+ if (encoded === undefined) {
317
+ throw new TypeError("request body is not JSON-encodable");
318
+ }
319
+ headers.set("Content-Type", JSON_CT);
320
+ init.body = encoded;
321
+ }
322
+ mergeHeaders(headers, opts?.headers);
323
+
324
+ let effectiveHeaders = headers;
325
+ if (cfg.prepareHeaders !== undefined) {
326
+ const prepared = await cfg.prepareHeaders(headers);
327
+ if (prepared !== undefined) {
328
+ effectiveHeaders = prepared;
329
+ }
330
+ }
331
+ init.headers = effectiveHeaders;
332
+
333
+ if (cfg.credentials !== undefined) {
334
+ init.credentials = cfg.credentials;
335
+ }
336
+
337
+ const composed = requestSignal(callerSignal, opts?.timeoutMs ?? API_TIMEOUT_MS);
338
+ init.signal = composed.signal;
339
+ winner = composed.winner;
340
+ url = joinUrl(cfg.baseUrl, path);
341
+ } catch (e) {
342
+ return classifyBuildError(e, callerSignal);
343
+ }
344
+
345
+ // --- Fetch phase --------------------------------------------------------
346
+ // A throw here is a genuine network / timeout / cancellation failure.
347
+ let res: Response;
348
+ try {
349
+ const fetchImpl = cfg.fetchFn ?? fetch;
350
+ res = await fetchImpl(url, init);
351
+ } catch (e) {
352
+ return classifyThrown(e, winner());
353
+ }
354
+
355
+ // --- Response phase -----------------------------------------------------
356
+ // The outer try preserves the never-throw guarantee for a malformed
357
+ // result from a custom fetchFn and for a mid-body read failure. A
358
+ // JSON.parse / decoder throw is a "decode" error, handled by its own
359
+ // inner try before it can reach here.
360
+ try {
361
+ if (!res.ok) {
362
+ return await parseErrorResponse(res, cfg.maxResponseBytes);
363
+ }
364
+ const status = statusOf(res);
365
+ if (opts?.as === "blob") {
366
+ const blob = await readBoundedBlob(res, cfg.maxResponseBytes);
367
+ return { ok: true, status, data: blob as T, headers: res.headers };
368
+ }
369
+ if (status === 204) {
370
+ return { ok: true, status, data: undefined as T, headers: res.headers };
371
+ }
372
+
373
+ if (opts?.ignoreBody === true) {
374
+ releaseQuietly(() => res.body?.cancel());
375
+ return { ok: true, status, data: undefined as T, headers: res.headers };
376
+ }
377
+
378
+ const text = await readBoundedText(res, cfg.maxResponseBytes);
379
+ if (text === "") {
380
+ return { ok: true, status, data: undefined as T, headers: res.headers };
381
+ }
382
+
383
+ let parsed: unknown;
384
+ try {
385
+ parsed = JSON.parse(text);
386
+ } catch (e) {
387
+ return makeErr(status, `response not JSON: ${errMsg(e)}`, "decode", undefined, res.headers);
388
+ }
389
+
390
+ if (opts?.decoder !== undefined) {
391
+ try {
392
+ return { ok: true, status, data: opts.decoder(parsed), headers: res.headers };
393
+ } catch (e) {
394
+ return makeErr(
395
+ status,
396
+ `response shape mismatch: ${errMsg(e)}`,
397
+ "decode",
398
+ undefined,
399
+ res.headers,
400
+ parsed,
401
+ );
402
+ }
403
+ }
404
+
405
+ return { ok: true, status, data: parsed as T, headers: res.headers };
406
+ } catch (e) {
407
+ return classifyThrown(e, winner());
408
+ }
409
+ };
410
+
411
+ return async function requestRaw<T>(
412
+ method: HttpMethod,
413
+ path: string,
414
+ opts?: AnyRequestOptions<T>,
415
+ ): Promise<ApiResult<T>> {
416
+ const result = await perform(method, path, opts);
417
+ if (!result.ok && cfg.onError !== undefined) {
418
+ notify(cfg.onError, result, { method, path });
419
+ }
420
+ return result;
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Build a null-collapsing `request` over a `requestRaw`: the decoded data on a
426
+ * successful result, or `null` on any error.
427
+ */
428
+ export function makeRequest(raw: RequestRawCore): RequestCore {
429
+ return async function request<T>(
430
+ method: HttpMethod,
431
+ path: string,
432
+ opts?: AnyRequestOptions<T>,
433
+ ): Promise<T | null> {
434
+ const result = await raw<T>(method, path, opts);
435
+ return result.ok && result.data !== undefined ? result.data : null;
436
+ };
437
+ }
@@ -0,0 +1,67 @@
1
+ /** Default request timeout in milliseconds. */
2
+ export const API_TIMEOUT_MS = 30_000;
3
+
4
+ /**
5
+ * Compose an optional caller signal with a fresh timeout signal: the result
6
+ * aborts on whichever fires first, with that signal's `reason`. Throws the
7
+ * `TypeError` of `AbortSignal.timeout` for an out-of-range `ms`.
8
+ */
9
+ export function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
10
+ const timeout = AbortSignal.timeout(ms);
11
+ if (signal === undefined) {
12
+ return timeout;
13
+ }
14
+ // Preferred where present: the native composite lets a finished request be
15
+ // collected at once instead of when its timeout fires.
16
+ if (typeof AbortSignal.any === "function") {
17
+ return AbortSignal.any([signal, timeout]);
18
+ }
19
+ return firstAbort(signal, timeout);
20
+ }
21
+
22
+ /** @internal Which source aborted a request's signal first. */
23
+ export type AbortSource = "caller" | "timeout";
24
+
25
+ /** @internal {@link withTimeout}'s signal, plus the source that won it: set
26
+ * once when the signal aborts, `undefined` while it has not. */
27
+ export function requestSignal(
28
+ caller: AbortSignal | undefined,
29
+ ms: number,
30
+ ): { readonly signal: AbortSignal; readonly winner: () => AbortSource | undefined } {
31
+ const signal = withTimeout(caller, ms);
32
+ let winner: AbortSource | undefined;
33
+ // Hooked before `signal` is handed out, so it runs before any code that sees
34
+ // the abort could abort the caller: here `caller.aborted` means the caller won.
35
+ // Write-once on the real abort: an "abort" event anyone dispatches on the
36
+ // handed-out signal, before it aborted or after the winner is set, is ignored.
37
+ const record = (): void => {
38
+ if (winner === undefined && signal.aborted) {
39
+ winner = caller?.aborted === true ? "caller" : "timeout";
40
+ }
41
+ };
42
+ record();
43
+ signal.addEventListener("abort", record);
44
+ return { signal, winner: () => winner };
45
+ }
46
+
47
+ /** `AbortSignal.any` for the runtimes without it (Chrome before 116, Safari
48
+ * before 17.4, Firefox before 124, Node before 20.3). */
49
+ function firstAbort(a: AbortSignal, b: AbortSignal): AbortSignal {
50
+ const out = new AbortController();
51
+ for (const source of [a, b]) {
52
+ if (source.aborted) {
53
+ out.abort(source.reason);
54
+ return out.signal;
55
+ }
56
+ }
57
+ // The first abort unhooks both listeners, so a long-lived caller signal holds
58
+ // a finished request's controller only until that request's timeout fires.
59
+ const unhook = new AbortController();
60
+ const forward = (source: AbortSignal) => () => {
61
+ out.abort(source.reason);
62
+ unhook.abort();
63
+ };
64
+ a.addEventListener("abort", forward(a), { signal: unhook.signal });
65
+ b.addEventListener("abort", forward(b), { signal: unhook.signal });
66
+ return out.signal;
67
+ }