@cplieger/ui 0.0.0-stage → 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 (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +3 -0
  3. package/README.md +157 -2
  4. package/THIRD_PARTY_NOTICES.md +8 -0
  5. package/css/ui-primitives.css +344 -0
  6. package/jsr.json +44 -0
  7. package/package.json +105 -4
  8. package/src/actions/api.ts +268 -0
  9. package/src/actions/async-feedback.ts +217 -0
  10. package/src/actions/cleanup.ts +66 -0
  11. package/src/actions/debounce.ts +150 -0
  12. package/src/actions/define-helpers.ts +87 -0
  13. package/src/actions/define.ts +851 -0
  14. package/src/actions/error.ts +161 -0
  15. package/src/actions/index.ts +60 -0
  16. package/src/actions/loading.ts +131 -0
  17. package/src/actions/notifier.ts +61 -0
  18. package/src/actions/poll-until.ts +133 -0
  19. package/src/actions/poll.ts +156 -0
  20. package/src/actions/registry.ts +259 -0
  21. package/src/actions/retry.ts +79 -0
  22. package/src/actions/testing.ts +29 -0
  23. package/src/actions/transport.ts +104 -0
  24. package/src/actions/types.ts +249 -0
  25. package/src/dom/bind-list.ts +103 -0
  26. package/src/dom/el.ts +67 -0
  27. package/src/dom/index.ts +9 -0
  28. package/src/dom/reconcile-tree.ts +268 -0
  29. package/src/dom/reconcile.ts +76 -0
  30. package/src/fetch/index.ts +18 -0
  31. package/src/fetch/instance.ts +37 -0
  32. package/src/fetch/request.ts +437 -0
  33. package/src/fetch/timeout.ts +67 -0
  34. package/src/fetch/types.ts +206 -0
  35. package/src/fetch/verbs.ts +103 -0
  36. package/src/index.ts +4 -0
  37. package/src/primitives/announce.ts +134 -0
  38. package/src/primitives/ask.ts +406 -0
  39. package/src/primitives/dialog-engine.ts +500 -0
  40. package/src/primitives/dialog.ts +263 -0
  41. package/src/primitives/disclosure.ts +287 -0
  42. package/src/primitives/focus-trap.ts +157 -0
  43. package/src/primitives/fx.ts +204 -0
  44. package/src/primitives/live-regions.ts +83 -0
  45. package/src/primitives/modal-host.ts +214 -0
  46. package/src/primitives/modal.ts +247 -0
  47. package/src/primitives/owner.ts +322 -0
  48. package/src/primitives/popover.ts +500 -0
  49. package/src/primitives/popup-core.ts +603 -0
  50. package/src/primitives/popup.ts +23 -0
  51. package/src/primitives/roving-focus.ts +198 -0
  52. package/src/primitives/scroll-lock.ts +119 -0
  53. package/src/primitives/skeleton.ts +142 -0
  54. package/src/primitives/theme.ts +202 -0
  55. package/src/primitives/toast/engine.ts +327 -0
  56. package/src/primitives/toast/index.ts +139 -0
  57. package/src/primitives/toast/view.ts +244 -0
  58. package/src/primitives/tooltip.ts +377 -0
  59. package/src/primitives/transition.ts +119 -0
  60. package/src/primitives/view-transition.ts +57 -0
  61. package/src/signal/bus.ts +126 -0
  62. package/src/signal/collection.ts +182 -0
  63. package/src/signal/index.ts +34 -0
  64. package/src/signal/resource.ts +102 -0
  65. package/src/signal/signal-map.ts +46 -0
  66. package/src/signal/signal.ts +1002 -0
  67. package/src/signal/store.ts +84 -0
@@ -0,0 +1,500 @@
1
+ // dialog-engine.ts — INTERNAL: the one owner of every <dialog> the library
2
+ // drives. openDialog, closeDialog, createDialog, createModal and ask all reach
3
+ // a dialog through its engine, which holds its phase, its live regions, its
4
+ // fade, its closeDialog callbacks and its view of the opens and closes the
5
+ // browser makes on its own. An API that adds behaviour to a dialog
6
+ // does so as the engine's one controller facet, realized inside its passes.
7
+
8
+ import type { Fx } from "./fx.js";
9
+ import { keepRegions } from "./live-regions.js";
10
+ import { trackOpens } from "./modal-host.js";
11
+ import { type SUPERSEDED, createDriver } from "./owner.js";
12
+ import { watchSettle } from "./transition.js";
13
+
14
+ /** A dialog's committed state. */
15
+ interface DialogState {
16
+ readonly phase: "closed" | "open" | "leaving";
17
+ /** Counts leaves; the settle token. */
18
+ readonly leave: number;
19
+ /** The dialog was open when the leave was asked: a leave of one never shown has
20
+ * nothing to fade, and one the engine's own earlier close closed is shown again. */
21
+ readonly shown: boolean;
22
+ /** Counts closed-to-open changes; the initial-focus token. */
23
+ readonly opens: number;
24
+ /** The close a closeDialog callback waits for; moves at every reclaim. */
25
+ readonly cycle: number;
26
+ readonly facet: DialogFacet | null;
27
+ /** The facet's content token (ask's question id; 0 otherwise). */
28
+ readonly subject: number;
29
+ /** A library-built dialog its facet has spent: closed for good. */
30
+ readonly retired: boolean;
31
+ }
32
+
33
+ /** The part of a dialog's realization one API adds. Every hook runs inside the engine's pass. */
34
+ export interface DialogFacet {
35
+ /** Before the dialog steps, every pass while attached. */
36
+ prepare?(fx: Fx, d: DialogState): void;
37
+ /** After the dialog steps, every pass while attached. */
38
+ follow?(fx: Fx, d: DialogState): void;
39
+ /** After convergence, while attached. */
40
+ notify?(fx: Fx, d: DialogState): void;
41
+ /** After detach, each pass until it returns. Idempotent. */
42
+ cleanup?(fx: Fx): void;
43
+ /** Set the drivers derived from this dialog's state (`null` once detached). Only `set` or `sync`. */
44
+ derive?(d: DialogState | null): void;
45
+ /** Take a refused open instead of the engine reporting it. */
46
+ refused?(subject: number, error: unknown): void;
47
+ }
48
+
49
+ export interface DialogEngine {
50
+ readonly state: DialogState;
51
+ /** The phase with every browser change so far folded in. It commits nothing; a
52
+ * change it reads is realized at the next microtask. */
53
+ readonly phase: DialogState["phase"];
54
+ /** The methods below commit at once and run no app code; the caller flushes. */
55
+ open(subject?: number): void;
56
+ close(onClosed?: () => void): void;
57
+ attach(facet: DialogFacet): void;
58
+ detach(facet: DialogFacet): void;
59
+ /** `detach`, and closed for good: for a library-built dialog its facet spends. */
60
+ retire(facet: DialogFacet): void;
61
+ flush(): void;
62
+ consult<R>(fn: () => R): R | typeof SUPERSEDED;
63
+ /** The closeDialog callbacks still held, for tests. */
64
+ // deadset:ignore DS1301 -- the dialog and modal tests read it to prove every closeDialog callback is delivered and none is kept
65
+ readonly outboxSize: number;
66
+ }
67
+
68
+ function invalidState(message: string): DOMException {
69
+ return new DOMException(message, "InvalidStateError");
70
+ }
71
+
72
+ const engines = new WeakMap<HTMLDialogElement, DialogEngine>();
73
+
74
+ /** Each engine's own call under way, told of every `beforetoggle` of its dialog as the
75
+ * dispatch starts. */
76
+ const starts = new WeakMap<HTMLDialogElement, (e: ToggleEvent) => void>();
77
+ const startRoots = new WeakSet<ShadowRoot>();
78
+
79
+ function onStart(e: Event): void {
80
+ if (e.target instanceof HTMLDialogElement) {
81
+ starts.get(e.target)?.(e as ToggleEvent);
82
+ }
83
+ }
84
+
85
+ // A capture listener on the window from load runs before those the page adds later, so
86
+ // an event is seen before code its listeners reach starts a nested one.
87
+ addEventListener("beforetoggle", onStart, true);
88
+
89
+ /** A dialog's `beforetoggle` stays inside its shadow root, so the root is listened on too. */
90
+ function listenForStarts(dialog: HTMLDialogElement): void {
91
+ const root = dialog.getRootNode();
92
+ if (root instanceof ShadowRoot && !startRoots.has(root)) {
93
+ startRoots.add(root);
94
+ root.addEventListener("beforetoggle", onStart, true);
95
+ }
96
+ }
97
+
98
+ /** The element's engine when it has one. Creates nothing. */
99
+ export function existingEngine(dialog: HTMLDialogElement): DialogEngine | undefined {
100
+ return engines.get(dialog);
101
+ }
102
+
103
+ /** The element's engine, created with its `open` observer on first use. */
104
+ export function dialogEngine(dialog: HTMLDialogElement): DialogEngine {
105
+ let engine = engines.get(dialog);
106
+ if (engine === undefined) {
107
+ engine = createEngine(dialog);
108
+ engines.set(dialog, engine);
109
+ }
110
+ return engine;
111
+ }
112
+
113
+ interface OwnCall {
114
+ readonly state: "open" | "closed";
115
+ event: Event | null;
116
+ }
117
+
118
+ interface Waiter {
119
+ readonly fn: () => void;
120
+ readonly cycle: number;
121
+ due: boolean;
122
+ }
123
+
124
+ function createEngine(dialog: HTMLDialogElement): DialogEngine {
125
+ let watch: { readonly leave: number; readonly cancel: () => void } | null = null;
126
+ // Since the record (the last observation), the page added `open`, removed it,
127
+ // or added it after the element had been open: the level alone cannot show these.
128
+ // The first record is D's initial `closed`, so an element open at creation was opened by the page.
129
+ let pageOpened = dialog.open;
130
+ let pageClosed = false;
131
+ let reopened = false;
132
+ // The element has been open at some moment since the record.
133
+ let seenOpen = dialog.open;
134
+ // The edge the engine's own showModal (true) or close (false) writes next, from the
135
+ // end of its `beforetoggle` dispatch until a record makes it (`own`). The write follows
136
+ // at once and fx's check after the call takes its record, so it is null again when
137
+ // `own` returns; `own` never nests, since one engine's passes do not.
138
+ let expected: boolean | null = null;
139
+
140
+ /** Reduce `open` records, oldest first. A record holds the presence before its
141
+ * write; the next record's, or the live level for the last, is the presence
142
+ * after it, so a write of the value the attribute has is no edge. True when a
143
+ * record is not the engine's own edge: the page wrote `open`. */
144
+ const take = (records: readonly MutationRecord[]): boolean => {
145
+ let page = false;
146
+ for (const [i, r] of records.entries()) {
147
+ const before = r.oldValue !== null;
148
+ const next = records[i + 1];
149
+ const after = next === undefined ? dialog.open : next.oldValue !== null;
150
+ if (before !== after && expected === after) {
151
+ expected = null;
152
+ } else {
153
+ page = true;
154
+ if (!before && after) {
155
+ pageOpened = true;
156
+ reopened ||= seenOpen;
157
+ } else if (before && !after) {
158
+ pageClosed = true;
159
+ }
160
+ }
161
+ seenOpen = true;
162
+ }
163
+ return page;
164
+ };
165
+ // A microtask checkpoint delivers the records no check took: a page change
166
+ // outside every engine call. It is realized at once, since a dialog fires no
167
+ // `toggle` for some changes (an `open` attribute write, a show() in its own `toggle`).
168
+ const opens = new MutationObserver((records) => {
169
+ take(records);
170
+ realizePageChange();
171
+ });
172
+ opens.observe(dialog, { attributes: true, attributeFilter: ["open"], attributeOldValue: true });
173
+ // From creation, so the page's own opening has a pair before the adoption pass.
174
+ keepRegions(dialog);
175
+ trackOpens(dialog);
176
+ /** The engine's own showModal() or close() under way, and its event once it starts. */
177
+ let call: OwnCall | null = null;
178
+ starts.set(dialog, (e) => {
179
+ if (call !== null && call.event === null && e.newState === call.state) {
180
+ call.event = e;
181
+ }
182
+ });
183
+ const onEnd = (e: Event): void => {
184
+ const c = call;
185
+ if (c?.event !== e) {
186
+ return;
187
+ }
188
+ if (take(opens.takeRecords())) {
189
+ queueMicrotask(realizePageChange);
190
+ }
191
+ const armed =
192
+ c.state === "open"
193
+ ? !e.defaultPrevented &&
194
+ !dialog.open &&
195
+ dialog.isConnected &&
196
+ !dialog.matches(":popover-open")
197
+ : dialog.open;
198
+ expected = armed ? c.state === "open" : null;
199
+ };
200
+ /** Run `fn`, the engine's own showModal() (`edge` true) or close(), so that only
201
+ * the edge it writes is taken as the engine's. Its `beforetoggle` listeners run first
202
+ * and can open or close the dialog themselves: those edges are the page's. When the
203
+ * dialog's last listener sees the call's own event, no code runs before the write
204
+ * (HTML, the showModal() and close-the-dialog steps). Returns the call's result and
205
+ * whether its event fired (C3 reads it). */
206
+ const own = <R>(edge: boolean, fn: () => R): [R, boolean] => {
207
+ const mine: OwnCall = { state: edge ? "open" : "closed", event: null };
208
+ call = mine;
209
+ listenForStarts(dialog);
210
+ // Added again so it runs after every listener the page added since.
211
+ dialog.removeEventListener("beforetoggle", onEnd);
212
+ dialog.addEventListener("beforetoggle", onEnd);
213
+ try {
214
+ const result = fn();
215
+ return [result, mine.event !== null];
216
+ } finally {
217
+ call = null;
218
+ }
219
+ };
220
+ // The last pass ended inside a close of the dialog that C3 could not make: it
221
+ // is not realized until that close finishes, so it notifies nothing.
222
+ let closing = false;
223
+ const prepared = new Set<DialogFacet>();
224
+ let derivedFor: DialogFacet | null = null;
225
+ const outbox: Waiter[] = [];
226
+
227
+ const fold = (d: DialogState): DialogState => {
228
+ // Records taken here never reach the callback: a page change among them is
229
+ // realized at the microtask it would have been, whatever folds it meanwhile.
230
+ if (take(opens.takeRecords())) {
231
+ queueMicrotask(realizePageChange);
232
+ }
233
+ if (d.retired) {
234
+ return d;
235
+ }
236
+ if (!dialog.open) {
237
+ // The page's close is the newest edge, whatever D intends. The cycle stays:
238
+ // the callbacks waiting for that close are due.
239
+ return pageClosed && d.phase !== "closed" ? { ...d, phase: "closed" } : d;
240
+ }
241
+ return reopened || (pageOpened && d.phase === "closed")
242
+ ? { ...d, phase: "open", opens: d.opens + 1, cycle: d.cycle + 1 }
243
+ : d;
244
+ };
245
+
246
+ const cancelWatch = (): void => {
247
+ watch?.cancel();
248
+ watch = null;
249
+ };
250
+
251
+ const settle = (leave: number): void => {
252
+ driver.update((d) =>
253
+ d.phase === "leaving" && d.leave === leave ? { ...d, phase: "closed" } : d,
254
+ );
255
+ driver.flush();
256
+ };
257
+
258
+ /** Cleanup for every facet that has been replaced or detached. */
259
+ const cleanupReplaced = (fx: Fx, current: DialogFacet | null): void => {
260
+ for (const facet of [...prepared]) {
261
+ if (facet !== current) {
262
+ facet.cleanup?.(fx);
263
+ prepared.delete(facet);
264
+ }
265
+ }
266
+ };
267
+
268
+ /** Move the element to `d.phase`. False when the outcome committed a new
269
+ * state the next pass realizes. */
270
+ const steps = (fx: Fx, d: DialogState): boolean => {
271
+ closing = false;
272
+ switch (d.phase) {
273
+ case "open": {
274
+ cancelWatch();
275
+ fx.removeClass(dialog, "is-leaving");
276
+ // Since creation the dialog may have moved to another tree, or lost its pair to
277
+ // the app's render.
278
+ trackOpens(dialog);
279
+ keepRegions(dialog);
280
+ const [shown] = own(true, () => fx.showModal(dialog));
281
+ if (shown.open) {
282
+ return true;
283
+ }
284
+ if (d.facet?.refused !== undefined) {
285
+ d.facet.refused(d.subject, shown.error);
286
+ } else if (shown.error !== undefined) {
287
+ fx.report(shown.error);
288
+ }
289
+ driver.set({ ...d, phase: "closed" });
290
+ return false;
291
+ }
292
+ case "leaving": {
293
+ if (!dialog.open && d.shown) {
294
+ // The engine's own close of an earlier state closed it; a failed show leaves
295
+ // nothing to fade.
296
+ own(true, () => fx.showModal(dialog));
297
+ }
298
+ if (!dialog.open) {
299
+ driver.set({ ...d, phase: "closed" });
300
+ return false;
301
+ }
302
+ if (!dialog.classList.contains("is-leaving")) {
303
+ // The start state, flushed so the class change animates from it.
304
+ dialog.getBoundingClientRect();
305
+ fx.addClass(dialog, "is-leaving");
306
+ }
307
+ if (watch?.leave !== d.leave) {
308
+ cancelWatch();
309
+ const leave = d.leave;
310
+ watch = {
311
+ leave,
312
+ cancel: watchSettle(dialog, () => {
313
+ settle(leave);
314
+ }),
315
+ };
316
+ }
317
+ return true;
318
+ }
319
+ case "closed": {
320
+ cancelWatch();
321
+ fx.removeClass(dialog, "is-leaving");
322
+ const [, fired] = own(false, () => {
323
+ fx.close(dialog);
324
+ });
325
+ if (!dialog.open) {
326
+ return true;
327
+ }
328
+ if (!fired) {
329
+ // Chromium ignores a close while the dialog's own close is in progress
330
+ // (`is_closing_` in blink's html_dialog_element.cc). That close closes it
331
+ // when this call returns, and the pass its records start completes this one.
332
+ closing = true;
333
+ return true;
334
+ }
335
+ // Code the close reached opened it again. fx's check folded that open and
336
+ // restarted the pass, unless the engine is retired and follows no edge:
337
+ // then close it again.
338
+ driver.invalidate();
339
+ return false;
340
+ }
341
+ }
342
+ };
343
+
344
+ const driver = createDriver<DialogState>({
345
+ initial: {
346
+ phase: "closed",
347
+ leave: 0,
348
+ shown: false,
349
+ opens: 0,
350
+ cycle: 0,
351
+ facet: null,
352
+ subject: 0,
353
+ retired: false,
354
+ },
355
+ observe(d) {
356
+ const next = fold(d);
357
+ pageOpened = false;
358
+ pageClosed = false;
359
+ reopened = false;
360
+ seenOpen = dialog.open;
361
+ return next;
362
+ },
363
+ derive(d) {
364
+ if (d.facet !== derivedFor) {
365
+ const previous = derivedFor;
366
+ derivedFor = d.facet;
367
+ previous?.derive?.(null);
368
+ }
369
+ d.facet?.derive?.(d);
370
+ },
371
+ realize(fx, d) {
372
+ const facet = d.facet;
373
+ if (facet !== null) {
374
+ // A replaced facet's cleanup runs before its successor's prepare: they
375
+ // write the same element.
376
+ cleanupReplaced(fx, facet);
377
+ prepared.add(facet);
378
+ facet.prepare?.(fx, d);
379
+ }
380
+ if (!steps(fx, d)) {
381
+ return;
382
+ }
383
+ if (facet === null) {
384
+ // After the steps: a modal's cleanup removes the dialog it has just closed.
385
+ cleanupReplaced(fx, null);
386
+ } else {
387
+ facet.follow?.(fx, d);
388
+ }
389
+ },
390
+ notify(fx, d) {
391
+ if (closing) {
392
+ return;
393
+ }
394
+ // First, so a hook that reopens or disposes cannot cancel a close that completed.
395
+ if (d.phase === "closed") {
396
+ for (const w of outbox) {
397
+ if (w.cycle === d.cycle) {
398
+ w.due = true;
399
+ }
400
+ }
401
+ }
402
+ d.facet?.notify?.(fx, d);
403
+ for (let i = outbox.length - 1; i >= 0; i--) {
404
+ const w = outbox[i];
405
+ if (w !== undefined && !w.due && w.cycle !== d.cycle) {
406
+ outbox.splice(i, 1);
407
+ }
408
+ }
409
+ // Removed before its call, so a restart never delivers a callback twice.
410
+ for (;;) {
411
+ const i = outbox.findIndex((w) => w.due);
412
+ const w = outbox[i];
413
+ if (w === undefined) {
414
+ break;
415
+ }
416
+ outbox.splice(i, 1);
417
+ fx.call(w.fn);
418
+ }
419
+ },
420
+ });
421
+
422
+ // The element may have changed and back again while a dependent read it live
423
+ // (the scroll lock): realize again, whatever the records fold to.
424
+ function realizePageChange(): void {
425
+ driver.invalidate();
426
+ driver.sync();
427
+ driver.flush();
428
+ }
429
+
430
+ const detachTo = (facet: DialogFacet, retired: boolean): void => {
431
+ driver.update((d) =>
432
+ d.facet !== facet
433
+ ? d
434
+ : {
435
+ ...d,
436
+ facet: null,
437
+ phase: "closed",
438
+ // Teardown closes at once with no fade: callbacks waiting for one are dropped.
439
+ cycle: d.phase === "closed" ? d.cycle : d.cycle + 1,
440
+ retired: d.retired || retired,
441
+ },
442
+ );
443
+ };
444
+
445
+ return {
446
+ get state(): DialogState {
447
+ return driver.state;
448
+ },
449
+ get phase(): DialogState["phase"] {
450
+ return fold(driver.state).phase;
451
+ },
452
+ open(subject) {
453
+ if (driver.state.retired) {
454
+ throw invalidState("The dialog's controller is disposed");
455
+ }
456
+ driver.update((d) => {
457
+ const s = subject ?? d.subject;
458
+ if (d.phase === "closed") {
459
+ return { ...d, phase: "open", opens: d.opens + 1, cycle: d.cycle + 1, subject: s };
460
+ }
461
+ if (d.phase === "leaving") {
462
+ // A reopen drops the callbacks waiting for this fade.
463
+ return { ...d, phase: "open", cycle: d.cycle + 1, subject: s };
464
+ }
465
+ return d.subject === s ? d : { ...d, subject: s };
466
+ });
467
+ },
468
+ close(onClosed) {
469
+ driver.update((d) =>
470
+ d.phase === "open" ? { ...d, phase: "leaving", leave: d.leave + 1, shown: dialog.open } : d,
471
+ );
472
+ if (onClosed !== undefined) {
473
+ const d = driver.state;
474
+ outbox.push({ fn: onClosed, cycle: d.cycle, due: d.phase === "closed" && !dialog.open });
475
+ // The outbox is work the state does not describe: the flush must drive.
476
+ driver.invalidate();
477
+ }
478
+ },
479
+ attach(facet) {
480
+ const d = driver.state;
481
+ if (d.retired || d.facet !== null) {
482
+ throw invalidState("The dialog already has a controller");
483
+ }
484
+ driver.update((s) => ({ ...s, facet }));
485
+ },
486
+ detach(facet) {
487
+ detachTo(facet, false);
488
+ },
489
+ retire(facet) {
490
+ detachTo(facet, true);
491
+ },
492
+ flush: () => {
493
+ driver.flush();
494
+ },
495
+ consult: (fn) => driver.consult(fn),
496
+ get outboxSize(): number {
497
+ return outbox.length;
498
+ },
499
+ };
500
+ }