@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,406 @@
1
+ /**
2
+ * ask.ts — ONE Promise-shaped question dialog, replacing window.confirm and
3
+ * window.prompt. Two shapes (boolean / input) share one preemption domain and
4
+ * the `.uip-ask` class family, each over its own lazily-created reused dialog.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ import { el } from "../dom/el.js";
10
+ import { untracked } from "../signal/signal.js";
11
+
12
+ import { listenBackdrop } from "./dialog.js";
13
+ import { dialogEngine, type DialogEngine, type DialogFacet } from "./dialog-engine.js";
14
+
15
+ /** Input configuration for an input-collecting ask. */
16
+ export interface AskInput {
17
+ /** Input type. Default `"text"`. */
18
+ type?: "text" | "password";
19
+ /** Pre-filled value, focused and selected on open (like window.prompt). */
20
+ initialValue?: string;
21
+ /** Input placeholder. */
22
+ placeholder?: string;
23
+ /** Maximum input length (the native `maxlength` constraint). */
24
+ maxLength?: number;
25
+ /** Autocomplete hint (e.g. `"new-password"` for a password-manager-aware
26
+ * password ask). Default `"off"`. */
27
+ autocomplete?: string;
28
+ }
29
+
30
+ /** Presentation and shape of one ask: heading, button labels, destructive
31
+ * treatment, and whether a value is collected. The presence of `input` decides
32
+ * what the promise resolves to — `boolean` without it, `string | null` with
33
+ * it — so the two are not independent choices. */
34
+ export interface AskOptions {
35
+ /** Optional concise heading. With a title, the dialog is labelled by it and
36
+ * described by the message; without one, the message is the label. */
37
+ title?: string;
38
+ /** OK button text. Default `"Confirm"` (boolean ask) / `"OK"` (input ask). */
39
+ confirmLabel?: string;
40
+ /** Cancel button text. Default `"Cancel"`. */
41
+ cancelLabel?: string;
42
+ /** `"destructive"` upgrades to `role="alertdialog"`, marks OK
43
+ * `is-destructive`, and (boolean ask only) focuses Cancel. */
44
+ variant?: "normal" | "destructive";
45
+ /** Collect a value: the ask gains an input (the message becomes its
46
+ * `<label>`) and resolves `string | null` instead of `boolean`. Pass a
47
+ * configuration object or `true` for a default text input. */
48
+ input?: AskInput | true;
49
+ }
50
+
51
+ interface AskRefs {
52
+ dialog: HTMLDialogElement;
53
+ title: HTMLElement;
54
+ message: HTMLElement;
55
+ ok: HTMLButtonElement;
56
+ cancel: HTMLButtonElement;
57
+ }
58
+
59
+ interface AskInputRefs extends AskRefs {
60
+ input: HTMLInputElement;
61
+ form: HTMLFormElement;
62
+ }
63
+
64
+ const INPUT_ID = "uip-ask-input";
65
+
66
+ type Shape = "boolean" | "input";
67
+ type Answer = boolean | string | null;
68
+
69
+ /** Everything one ask shows, read once from the caller's options. */
70
+ interface View {
71
+ readonly message: string;
72
+ readonly title: string;
73
+ readonly confirmLabel: string;
74
+ readonly cancelLabel: string;
75
+ readonly destructive: boolean;
76
+ readonly input: {
77
+ readonly type: "text" | "password";
78
+ readonly initialValue: string;
79
+ readonly placeholder: string | undefined;
80
+ readonly maxLength: number | undefined;
81
+ readonly autocomplete: string;
82
+ } | null;
83
+ }
84
+
85
+ interface Question {
86
+ readonly id: number;
87
+ readonly view: View;
88
+ readonly cancelValue: boolean | null;
89
+ readonly resolve: (value: Answer) => void;
90
+ readonly reject: (error: unknown) => void;
91
+ }
92
+
93
+ interface Slot {
94
+ readonly refs: AskRefs | AskInputRefs;
95
+ readonly engine: DialogEngine;
96
+ readonly facet: DialogFacet;
97
+ /** The last question shown on this dialog. */
98
+ question: Question | null;
99
+ }
100
+
101
+ let questionSeq = 0;
102
+ /** The one live question across both shapes; only it is unsettled. */
103
+ let current: Question | null = null;
104
+ const slots: Record<Shape, Slot | null> = { boolean: null, input: null };
105
+
106
+ /** Replace the live question, settling the outgoing one exactly once. */
107
+ function replaceCurrent(
108
+ next: Question | null,
109
+ outcome?: { readonly value: Answer } | { readonly error: unknown },
110
+ ): void {
111
+ const previous = current;
112
+ current = next;
113
+ if (previous === null) {
114
+ return;
115
+ }
116
+ if (outcome !== undefined && "error" in outcome) {
117
+ previous.reject(outcome.error);
118
+ } else {
119
+ previous.resolve(outcome === undefined ? previous.cancelValue : outcome.value);
120
+ }
121
+ }
122
+
123
+ function snapshot(message: string, opts: AskOptions | undefined): View {
124
+ const inputOpt = opts?.input;
125
+ const input = inputOpt === undefined ? null : inputOpt === true ? {} : inputOpt;
126
+ return {
127
+ message,
128
+ title: opts?.title ?? "",
129
+ confirmLabel: opts?.confirmLabel ?? (input !== null ? "OK" : "Confirm"),
130
+ cancelLabel: opts?.cancelLabel ?? "Cancel",
131
+ destructive: (opts?.variant ?? "normal") === "destructive",
132
+ input:
133
+ input === null
134
+ ? null
135
+ : {
136
+ type: input.type ?? "text",
137
+ initialValue: input.initialValue ?? "",
138
+ placeholder: input.placeholder,
139
+ maxLength: input.maxLength,
140
+ autocomplete: input.autocomplete ?? "off",
141
+ },
142
+ };
143
+ }
144
+
145
+ function writeView(refs: AskRefs, input: AskInputRefs | null, view: View): void {
146
+ if (view.title !== "") {
147
+ // With a title, the dialog's accessible NAME is the concise title and its
148
+ // DESCRIPTION is the message body (alertdialog wants a short name + body).
149
+ refs.title.textContent = view.title;
150
+ refs.title.hidden = false;
151
+ refs.dialog.setAttribute("aria-labelledby", refs.title.id);
152
+ refs.dialog.setAttribute("aria-describedby", refs.message.id);
153
+ } else {
154
+ refs.title.textContent = "";
155
+ refs.title.hidden = true;
156
+ refs.dialog.setAttribute("aria-labelledby", refs.message.id);
157
+ refs.dialog.removeAttribute("aria-describedby");
158
+ }
159
+ refs.message.textContent = view.message;
160
+ refs.ok.textContent = view.confirmLabel;
161
+ refs.cancel.textContent = view.cancelLabel;
162
+ if (view.destructive) {
163
+ refs.dialog.setAttribute("role", "alertdialog");
164
+ refs.ok.classList.add("is-destructive");
165
+ } else {
166
+ refs.dialog.removeAttribute("role");
167
+ refs.ok.classList.remove("is-destructive");
168
+ }
169
+ if (input !== null && view.input !== null) {
170
+ input.input.type = view.input.type;
171
+ input.input.value = view.input.initialValue;
172
+ // The attribute, not the IDL property: the property's `AutoFill` union
173
+ // does not cover every legal token list the autocomplete grammar allows.
174
+ input.input.setAttribute("autocomplete", view.input.autocomplete);
175
+ if (view.input.placeholder !== undefined) {
176
+ input.input.placeholder = view.input.placeholder;
177
+ } else {
178
+ input.input.removeAttribute("placeholder");
179
+ }
180
+ if (view.input.maxLength !== undefined) {
181
+ input.input.maxLength = view.input.maxLength;
182
+ } else {
183
+ input.input.removeAttribute("maxlength");
184
+ }
185
+ }
186
+ }
187
+
188
+ /** Settle the live question `id` with `value` and fade its dialog. */
189
+ function answer(slot: Slot, id: number, value: Answer): void {
190
+ if (current?.id !== id) {
191
+ return;
192
+ }
193
+ replaceCurrent(null, { value });
194
+ slot.engine.close();
195
+ slot.engine.flush();
196
+ }
197
+
198
+ function createSlot(shape: Shape): Slot {
199
+ const input = shape === "input" ? buildInput() : null;
200
+ const refs: AskRefs = input ?? buildBoolean();
201
+ let appliedId = 0;
202
+ let armedFor = 0;
203
+ let focusedId = 0;
204
+ let selectedId = 0;
205
+ let listeners: AbortController | null = null;
206
+ const disarm = (): void => {
207
+ listeners?.abort();
208
+ listeners = null;
209
+ armedFor = 0;
210
+ };
211
+ const arm = (q: Question): void => {
212
+ disarm();
213
+ const controller = new AbortController();
214
+ const { signal } = controller;
215
+ listeners = controller;
216
+ armedFor = q.id;
217
+ if (input !== null) {
218
+ input.form.addEventListener(
219
+ "submit",
220
+ (e) => {
221
+ e.preventDefault();
222
+ answer(slot, q.id, input.input.value);
223
+ },
224
+ { signal },
225
+ );
226
+ } else {
227
+ refs.ok.addEventListener(
228
+ "click",
229
+ () => {
230
+ answer(slot, q.id, true);
231
+ },
232
+ { signal },
233
+ );
234
+ }
235
+ refs.cancel.addEventListener(
236
+ "click",
237
+ () => {
238
+ answer(slot, q.id, q.cancelValue);
239
+ },
240
+ { signal },
241
+ );
242
+ refs.dialog.addEventListener(
243
+ "cancel",
244
+ (e) => {
245
+ // A descendant's bubbling `cancel` is not an Escape (see createDialog).
246
+ if (e.target !== refs.dialog) {
247
+ return;
248
+ }
249
+ e.preventDefault();
250
+ answer(slot, q.id, q.cancelValue);
251
+ },
252
+ { signal },
253
+ );
254
+ const removeBackdrop = listenBackdrop(refs.dialog, () => {
255
+ answer(slot, q.id, q.cancelValue);
256
+ });
257
+ signal.addEventListener("abort", removeBackdrop);
258
+ };
259
+
260
+ const facet: DialogFacet = {
261
+ prepare(fx, d) {
262
+ const q = slot.question;
263
+ if (!refs.dialog.isConnected) {
264
+ // Mounted again when the page cleared the body.
265
+ fx.append(document.body, refs.dialog);
266
+ }
267
+ if (q !== null && appliedId !== d.subject && q.id === d.subject) {
268
+ appliedId = q.id;
269
+ writeView(refs, input, q.view);
270
+ }
271
+ if (q !== null && q === current) {
272
+ if (armedFor !== q.id) {
273
+ arm(q);
274
+ }
275
+ } else {
276
+ disarm();
277
+ }
278
+ },
279
+ follow(fx, d) {
280
+ const q = slot.question;
281
+ if (d.phase !== "open" || q === null || q !== current) {
282
+ return;
283
+ }
284
+ // Native showModal() traps Tab and restores focus; only the initial
285
+ // placement is ours: the input, or Cancel for a destructive question.
286
+ if (focusedId !== q.id) {
287
+ focusedId = q.id;
288
+ fx.focus(input?.input ?? (q.view.destructive ? refs.cancel : refs.ok));
289
+ }
290
+ if (input !== null && selectedId !== q.id) {
291
+ selectedId = q.id;
292
+ fx.select(input.input);
293
+ }
294
+ },
295
+ notify(_fx, d) {
296
+ // A close the browser made, or a question never shown, cancels it.
297
+ if (d.phase === "closed" && slot.question !== null && slot.question === current) {
298
+ replaceCurrent(null);
299
+ }
300
+ },
301
+ cleanup() {
302
+ disarm();
303
+ },
304
+ refused(subject, error) {
305
+ if (current?.id === subject) {
306
+ replaceCurrent(null, error === undefined ? undefined : { error });
307
+ }
308
+ },
309
+ };
310
+ const slot: Slot = { refs, engine: dialogEngine(refs.dialog), facet, question: null };
311
+ slot.engine.attach(facet);
312
+ return slot;
313
+ }
314
+
315
+ /** The shared action row (Cancel, OK). */
316
+ function buildActions(): { ok: HTMLButtonElement; cancel: HTMLButtonElement; row: HTMLElement } {
317
+ const cancel = el("button", {
318
+ type: "button",
319
+ className: "uip-ask-cancel",
320
+ }) as HTMLButtonElement;
321
+ // In the input shape OK is the form's submit button (Enter in the input
322
+ // activates it); in the boolean shape it is a plain button.
323
+ const ok = el("button", { type: "button", className: "uip-ask-ok" }) as HTMLButtonElement;
324
+ const row = el("div", { className: "uip-ask-actions" }, cancel, ok);
325
+ return { ok, cancel, row };
326
+ }
327
+
328
+ function buildBoolean(): AskRefs {
329
+ const title = el("h2", { className: "uip-ask-title", id: "uip-ask-title" });
330
+ const message = el("p", { className: "uip-ask-msg", id: "uip-ask-msg" });
331
+ const { ok, cancel, row } = buildActions();
332
+ const dialog = el("dialog", { className: "uip-ask" }, title, message, row) as HTMLDialogElement;
333
+ return { dialog, title, message, ok, cancel };
334
+ }
335
+
336
+ function buildInput(): AskInputRefs {
337
+ const title = el("h2", { className: "uip-ask-title", id: "uip-ask-input-title" });
338
+ // The message is the input's real <label>, so the association is native
339
+ // form semantics rather than ARIA plumbing.
340
+ const message = el("label", {
341
+ className: "uip-ask-msg",
342
+ id: "uip-ask-input-msg",
343
+ for: INPUT_ID,
344
+ }) as HTMLLabelElement;
345
+ const input = el("input", { className: "uip-ask-input", id: INPUT_ID }) as HTMLInputElement;
346
+ const { ok, cancel, row } = buildActions();
347
+ ok.type = "submit";
348
+ const form = el("form", { className: "uip-ask-form" }, message, input, row) as HTMLFormElement;
349
+ const dialog = el(
350
+ "dialog",
351
+ { className: "uip-ask uip-ask--input" },
352
+ title,
353
+ form,
354
+ ) as HTMLDialogElement;
355
+ return { dialog, title, message, input, form, ok, cancel };
356
+ }
357
+
358
+ /** Show a question dialog. Without `input`, resolves `true` on confirm and
359
+ * `false` on cancel, Escape, backdrop click, or preemption by a later
360
+ * `ask()`. */
361
+ export function ask(message: string, opts?: AskOptions & { input?: undefined }): Promise<boolean>;
362
+ /** Show a single-input dialog (`input` present). Resolves the input's value
363
+ * on OK / Enter — as-is, not trimmed; an empty submission resolves `""`,
364
+ * distinct from the `null` of cancellation — or `null` on Cancel, Escape,
365
+ * backdrop click, or preemption by a later `ask()`. */
366
+ export function ask(
367
+ message: string,
368
+ opts: AskOptions & { input: AskInput | true },
369
+ ): Promise<string | null>;
370
+ export function ask(message: string, opts?: AskOptions): Promise<boolean | string | null> {
371
+ // Every option is read here, once, before any shared state: an ask() from a
372
+ // getter runs to its own commit and is then preempted by this one.
373
+ const view = untracked(() => snapshot(message, opts));
374
+ const shape: Shape = view.input === null ? "boolean" : "input";
375
+ return new Promise<Answer>((resolve, reject) => {
376
+ const mine = (slots[shape] ??= createSlot(shape));
377
+ const other = slots[shape === "input" ? "boolean" : "input"];
378
+ const q: Question = {
379
+ id: ++questionSeq,
380
+ view,
381
+ cancelValue: view.input === null ? false : null,
382
+ resolve,
383
+ reject,
384
+ };
385
+ replaceCurrent(q);
386
+ mine.question = q;
387
+ mine.engine.open(q.id);
388
+ other?.engine.close();
389
+ other?.engine.flush();
390
+ mine.engine.flush();
391
+ });
392
+ }
393
+
394
+ /** Test-only: settle the live question, remove both shared dialogs and reset internal state. */
395
+ export function _resetForTest(): void {
396
+ replaceCurrent(null);
397
+ for (const shape of ["boolean", "input"] as const) {
398
+ const slot = slots[shape];
399
+ if (slot !== null) {
400
+ slot.engine.retire(slot.facet);
401
+ slot.engine.flush();
402
+ slot.refs.dialog.remove();
403
+ slots[shape] = null;
404
+ }
405
+ }
406
+ }