@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,377 @@
1
+ /**
2
+ * tooltip.ts — One delegated tooltip controller on `document`. Replaces bare
3
+ * `title` attributes with positioned, delay-aware, accessible tooltips.
4
+ *
5
+ * BY DEFAULT `delayWarm` IS `delayCold`, so every hover waits the same time —
6
+ * matching native `title` behavior. A warm group (peers appearing faster once
7
+ * one has shown) is opt-in through an explicit smaller `delayWarm`.
8
+ *
9
+ * Scroll HIDES the tooltip rather than repositioning it, unlike popover:
10
+ * once the user scrolls, the pointer is no longer meaningfully over the
11
+ * anchor, matching native `title`; a popover is an opened surface the user is
12
+ * interacting with and must follow its anchor instead.
13
+ *
14
+ * A TRIGGER'S HIT BOX IS NOT ALWAYS THE INK THE TOOLTIP IS ABOUT, so the trigger
15
+ * may name that ink with `<attribute>-anchor` on a descendant and the tip is
16
+ * placed against the MARK while hover, focus and `aria-describedby` stay on the
17
+ * trigger. Without it a row-wide trigger anchors the tip at the centre of its own
18
+ * box, which is empty space: measured on a consumer, a 570px disclosure row whose
19
+ * only ink is a 16px leading glyph put the tip 269px to the right of that glyph,
20
+ * and a 546px file row whose name sits at its leading edge 243px away.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { el } from "../dom/el.js";
26
+
27
+ import { placeAnchored, type PopoverAnchor } from "./popover.js";
28
+ import { cancelTransition, runTransition } from "./transition.js";
29
+
30
+ /** Tuning for the one delegated controller: the trigger attribute it delegates
31
+ * on, and the three numbers that decide when a tip appears. The defaults
32
+ * reproduce native `title` timing, with the warm group inert. */
33
+ export interface TooltipOptions {
34
+ /** Trigger attribute holding the tooltip text. Default `data-uip-tooltip`.
35
+ * The MARK attribute below is derived from it, so an app that renames one
36
+ * renames both. */
37
+ attribute?: string;
38
+ /** Delay (ms) before the first tooltip of a cold group. Default 500, the
39
+ * hover time a native `title` waits out (Firefox's `ui.tooltipDelay`
40
+ * default; the Windows mouse-hover time is 400). */
41
+ delayCold?: number;
42
+ /** Delay (ms) before tooltips while the group is warm. Defaults to
43
+ * `delayCold`, so every hover costs the same wait. Set it lower to opt into
44
+ * a warm group, where a peer shows faster once one tooltip has. */
45
+ delayWarm?: number;
46
+ /** Warm window (ms) after a tooltip hides. Default 500. Inert while
47
+ * `delayWarm` equals `delayCold`. */
48
+ cooldown?: number;
49
+ }
50
+
51
+ type TooltipState =
52
+ | { readonly kind: "idle" }
53
+ | {
54
+ readonly kind: "pending";
55
+ readonly anchor: HTMLElement;
56
+ readonly timer: ReturnType<typeof setTimeout>;
57
+ }
58
+ | { readonly kind: "visible"; readonly anchor: HTMLElement; readonly tip: HTMLElement }
59
+ | { readonly kind: "fading"; readonly tip: HTMLElement };
60
+
61
+ let tipIdSeq = 0;
62
+ class TooltipController {
63
+ private state: TooltipState = { kind: "idle" };
64
+ private warmUntil = 0;
65
+ private readonly attribute: string;
66
+ private readonly selector: string;
67
+ private readonly markSelector: string;
68
+ private readonly delayCold: number;
69
+ private readonly delayWarm: number;
70
+ private readonly cooldown: number;
71
+
72
+ private readonly onPointerOver = (e: Event): void => {
73
+ this.onEnter(e);
74
+ };
75
+ private readonly onPointerOut = (e: Event): void => {
76
+ this.onLeave(e);
77
+ };
78
+ private readonly onFocusIn = (e: Event): void => {
79
+ // :focus-visible gate: a bare focusin also fires on programmatic focus
80
+ // (modal open, focus-trap restore, autofocus), popping tooltips with no
81
+ // hover or keypress.
82
+ if (!isKeyboardFocus(e.target)) {
83
+ return;
84
+ }
85
+ this.onEnter(e);
86
+ };
87
+ private readonly onFocusOut = (e: Event): void => {
88
+ this.onLeave(e);
89
+ };
90
+ private readonly onKeyDown = (e: KeyboardEvent): void => {
91
+ if (e.key === "Escape") {
92
+ this.hide();
93
+ }
94
+ };
95
+ private readonly onScroll = (): void => {
96
+ this.hide();
97
+ };
98
+ private readonly onWindowBlur = (): void => {
99
+ this.hide();
100
+ };
101
+
102
+ constructor(opts: TooltipOptions) {
103
+ this.attribute = opts.attribute ?? "data-uip-tooltip";
104
+ this.selector = `[${this.attribute}]`;
105
+ // Derived rather than a second option: one trigger vocabulary, so an app
106
+ // renaming the text attribute cannot end up with a mark attribute in the
107
+ // library's namespace and a trigger attribute in its own.
108
+ this.markSelector = `[${this.attribute}-anchor]`;
109
+ this.delayCold = opts.delayCold ?? 500;
110
+ // Defaults to delayCold, not a constant, so a partial override can't
111
+ // produce a warm path faster than the delay the caller asked for.
112
+ this.delayWarm = opts.delayWarm ?? this.delayCold;
113
+ this.cooldown = opts.cooldown ?? 500;
114
+ }
115
+
116
+ init(): void {
117
+ document.addEventListener("pointerover", this.onPointerOver);
118
+ document.addEventListener("pointerout", this.onPointerOut);
119
+ document.addEventListener("focusin", this.onFocusIn);
120
+ document.addEventListener("focusout", this.onFocusOut);
121
+ document.addEventListener("keydown", this.onKeyDown);
122
+ document.addEventListener("scroll", this.onScroll, true);
123
+ window.addEventListener("blur", this.onWindowBlur);
124
+ }
125
+
126
+ dispose(): void {
127
+ document.removeEventListener("pointerover", this.onPointerOver);
128
+ document.removeEventListener("pointerout", this.onPointerOut);
129
+ document.removeEventListener("focusin", this.onFocusIn);
130
+ document.removeEventListener("focusout", this.onFocusOut);
131
+ document.removeEventListener("keydown", this.onKeyDown);
132
+ document.removeEventListener("scroll", this.onScroll, true);
133
+ window.removeEventListener("blur", this.onWindowBlur);
134
+ this.teardown();
135
+ }
136
+
137
+ private closestAnchor(target: EventTarget | null): HTMLElement | null {
138
+ if (!(target instanceof Element)) {
139
+ return null;
140
+ }
141
+ const anchor = target.closest(this.selector);
142
+ return anchor instanceof HTMLElement ? anchor : null;
143
+ }
144
+
145
+ private onEnter(e: Event): void {
146
+ const anchor = this.closestAnchor(e.target);
147
+ if (anchor === null) {
148
+ return;
149
+ }
150
+ const text = anchor.getAttribute(this.attribute) ?? "";
151
+ if (text === "") {
152
+ return;
153
+ }
154
+ if (
155
+ (this.state.kind === "pending" || this.state.kind === "visible") &&
156
+ this.state.anchor === anchor
157
+ ) {
158
+ return;
159
+ }
160
+ this.teardown();
161
+ const delay = Date.now() < this.warmUntil ? this.delayWarm : this.delayCold;
162
+ const timer = setTimeout(() => {
163
+ this.show(anchor, text);
164
+ }, delay);
165
+ this.state = { kind: "pending", anchor, timer };
166
+ }
167
+
168
+ private onLeave(e: Event): void {
169
+ const anchor = this.closestAnchor(e.target);
170
+ if (anchor === null) {
171
+ return;
172
+ }
173
+ if (this.state.kind === "idle" || this.state.kind === "fading") {
174
+ return;
175
+ }
176
+ if (this.state.anchor !== anchor) {
177
+ return;
178
+ }
179
+ const related = (e as { relatedTarget?: EventTarget | null }).relatedTarget ?? null;
180
+ if (related instanceof Node && anchor.contains(related)) {
181
+ return;
182
+ }
183
+ this.hide();
184
+ }
185
+
186
+ private show(anchor: HTMLElement, text: string): void {
187
+ if (!anchor.isConnected) {
188
+ this.state = { kind: "idle" };
189
+ return;
190
+ }
191
+ this.teardown();
192
+
193
+ const tipId = `uip-tip-${(++tipIdSeq).toString()}`;
194
+ const tip = el(
195
+ "div",
196
+ { className: "uip-tooltip", role: "tooltip", id: tipId },
197
+ ...renderLines(text),
198
+ );
199
+ // Shares the dialog's top-layer context; body-appended would paint behind a modal.
200
+ const host: Element = anchor.closest("dialog[open]") ?? document.body;
201
+ host.appendChild(tip);
202
+ addDescribedBy(anchor, tipId);
203
+
204
+ this.position(anchor, tip);
205
+
206
+ this.state = { kind: "visible", anchor, tip };
207
+ // Keep the group warm long enough to cover a cold-delay hover of a peer.
208
+ this.warmUntil = Date.now() + this.cooldown + this.delayCold;
209
+ }
210
+
211
+ private position(anchor: HTMLElement, tip: HTMLElement): void {
212
+ placeAnchored(tip, this.positionAnchor(anchor), {
213
+ placement: "top",
214
+ align: "center",
215
+ offset: 6,
216
+ margin: 4,
217
+ });
218
+ }
219
+
220
+ /** What the tip is placed against: the trigger's marked ink intersected with
221
+ * the trigger's rect, so clipped ink (an ellipsised name, a mark scrolled
222
+ * out of an overflow-hidden trigger) cannot drag the tip off the control.
223
+ * No mark, or an empty intersection, falls back to the trigger. */
224
+ private positionAnchor(anchor: HTMLElement): PopoverAnchor {
225
+ const mark = this.markInside(anchor);
226
+ if (mark === null) {
227
+ return anchor;
228
+ }
229
+ const a = anchor.getBoundingClientRect();
230
+ const m = mark.getBoundingClientRect();
231
+ const left = Math.max(a.left, m.left);
232
+ const top = Math.max(a.top, m.top);
233
+ const width = Math.min(a.right, m.right) - left;
234
+ const height = Math.min(a.bottom, m.bottom) - top;
235
+ if (width <= 0 || height <= 0) {
236
+ return anchor;
237
+ }
238
+ const rect = new DOMRect(left, top, width, height);
239
+ return { getBoundingClientRect: (): DOMRect => rect };
240
+ }
241
+
242
+ /** The trigger's own marked ink: the first mark in document order that belongs
243
+ * to THIS trigger and has a box. A mark inside a nested trigger is that
244
+ * trigger's, and one that renders nothing (a glyph slot with no content yet, a
245
+ * `display: none` mark) is not ink to point at. */
246
+ private markInside(anchor: HTMLElement): HTMLElement | null {
247
+ for (const mark of anchor.querySelectorAll<HTMLElement>(this.markSelector)) {
248
+ if (mark.closest(this.selector) !== anchor) {
249
+ continue;
250
+ }
251
+ const r = mark.getBoundingClientRect();
252
+ if (r.width > 0 && r.height > 0) {
253
+ return mark;
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+
259
+ private hide(): void {
260
+ if (this.state.kind === "pending") {
261
+ clearTimeout(this.state.timer);
262
+ this.state = { kind: "idle" };
263
+ this.warmUntil = Date.now() + this.cooldown;
264
+ return;
265
+ }
266
+ if (this.state.kind !== "visible") {
267
+ return;
268
+ }
269
+ const { anchor, tip } = this.state;
270
+ removeDescribedBy(anchor, tip.id);
271
+ this.state = { kind: "fading", tip };
272
+ this.warmUntil = Date.now() + this.cooldown;
273
+
274
+ runTransition(tip, {
275
+ change: () => {
276
+ tip.classList.add("is-leaving");
277
+ },
278
+ settled: () => {
279
+ this.state = { kind: "idle" };
280
+ tip.remove();
281
+ },
282
+ });
283
+ }
284
+
285
+ private teardown(): void {
286
+ switch (this.state.kind) {
287
+ case "pending":
288
+ clearTimeout(this.state.timer);
289
+ break;
290
+ case "visible":
291
+ removeDescribedBy(this.state.anchor, this.state.tip.id);
292
+ this.state.tip.remove();
293
+ break;
294
+ case "fading":
295
+ cancelTransition(this.state.tip);
296
+ this.state.tip.remove();
297
+ break;
298
+ case "idle":
299
+ break;
300
+ }
301
+ this.state = { kind: "idle" };
302
+ }
303
+ }
304
+
305
+ /** Append `id` to the anchor's `aria-describedby` token list, preserving any
306
+ * tokens the app already set. */
307
+ function addDescribedBy(anchor: HTMLElement, id: string): void {
308
+ const current = anchor.getAttribute("aria-describedby");
309
+ const tokens = current === null ? [] : current.split(/\s+/).filter((t) => t !== "");
310
+ if (!tokens.includes(id)) {
311
+ tokens.push(id);
312
+ }
313
+ anchor.setAttribute("aria-describedby", tokens.join(" "));
314
+ }
315
+
316
+ /** Remove only `id` from the anchor's `aria-describedby`, restoring the rest.
317
+ * Removes the attribute entirely only when nothing else remains. */
318
+ function removeDescribedBy(anchor: HTMLElement, id: string): void {
319
+ const current = anchor.getAttribute("aria-describedby");
320
+ if (current === null) {
321
+ return;
322
+ }
323
+ const tokens = current.split(/\s+/).filter((t) => t !== "" && t !== id);
324
+ if (tokens.length > 0) {
325
+ anchor.setAttribute("aria-describedby", tokens.join(" "));
326
+ } else {
327
+ anchor.removeAttribute("aria-describedby");
328
+ }
329
+ }
330
+
331
+ /** True when the focus landing on `target` should count as keyboard-driven:
332
+ * the element matches `:focus-visible`. Engines without the selector (or a
333
+ * non-Element target) fall back to `true`, preserving the old show-on-focus
334
+ * behavior rather than silently disabling focus tooltips. */
335
+ function isKeyboardFocus(target: EventTarget | null): boolean {
336
+ if (!(target instanceof Element)) {
337
+ return false;
338
+ }
339
+ try {
340
+ return target.matches(":focus-visible");
341
+ } catch {
342
+ return true;
343
+ }
344
+ }
345
+
346
+ /** Split tooltip text on newlines into text nodes separated by <br>. */
347
+ function renderLines(text: string): (string | Node)[] {
348
+ const lines = text.split("\n");
349
+ const children: (string | Node)[] = [];
350
+ for (let i = 0; i < lines.length; i++) {
351
+ if (i > 0) {
352
+ children.push(el("br"));
353
+ }
354
+ children.push(lines[i] ?? "");
355
+ }
356
+ return children;
357
+ }
358
+
359
+ let controller: TooltipController | null = null;
360
+
361
+ /** Install the delegated tooltip controller once. Idempotent — later calls
362
+ * (including with different options) are no-ops until reset. */
363
+ export function initTooltips(opts?: TooltipOptions): void {
364
+ if (controller !== null) {
365
+ return;
366
+ }
367
+ controller = new TooltipController(opts ?? {});
368
+ controller.init();
369
+ }
370
+
371
+ /** Test-only: remove the controller's listeners and any live tooltip. */
372
+ export function _resetForTest(): void {
373
+ if (controller !== null) {
374
+ controller.dispose();
375
+ controller = null;
376
+ }
377
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * transition.ts — Run one CSS transition on an element and finalize it once it
3
+ * ends. A transition needs its start state flushed before the change, or the
4
+ * browser coalesces both writes into one frame and nothing animates;
5
+ * `runTransition` does that flush. The primitives' fades follow the same
6
+ * settle rule through their own private watcher.
7
+ * @module
8
+ */
9
+
10
+ /**
11
+ * Ceiling (ms) on how long a settle waits for `transitionend` before assuming
12
+ * no transition ran at all. Not an estimate of any duration: every duration in
13
+ * this library is the app's, set through a `--uip-*` custom property, so the
14
+ * library cannot know the real one. A transition skinned longer than this is
15
+ * truncated.
16
+ */
17
+ const SETTLE_FALLBACK_MS = 400;
18
+
19
+ /** One state change plus what finalizes it. Handed to {@link runTransition},
20
+ * which flushes the pre-change style before invoking `change` so the engine
21
+ * has a start state to animate from. */
22
+ export interface TransitionSpec {
23
+ /** Writes the state to animate TO. Whatever the caller wrote BEFORE calling
24
+ * `runTransition` is the state it animates FROM. */
25
+ change: () => void;
26
+ /** Runs exactly once when the transition on the element ends, or after the
27
+ * settle fallback ceiling (`SETTLE_FALLBACK_MS`) if none ever starts. Omit
28
+ * it when there is nothing to finalize; then nothing is armed and nothing
29
+ * needs cancelling. */
30
+ settled?: () => void;
31
+ }
32
+
33
+ /** Pending settles, keyed by element: the cancel that detaches one. A WeakMap
34
+ * so a settle armed against a node that is then dropped retains nothing. */
35
+ const inFlight = new WeakMap<HTMLElement, () => void>();
36
+
37
+ /** @internal Arm one settle on `el`: the first `transitionend` whose target is
38
+ * `el`, or the fallback ceiling, whichever comes first. Fires once; returns
39
+ * its cancel. */
40
+ export function watchSettle(el: HTMLElement, onSettled: () => void): () => void {
41
+ let timer: ReturnType<typeof setTimeout> | null = null;
42
+ const detach = (): void => {
43
+ el.removeEventListener("transitionend", onEnd);
44
+ if (timer !== null) {
45
+ clearTimeout(timer);
46
+ timer = null;
47
+ }
48
+ };
49
+ // Detaching (not a latch) is what makes this fire once: one change can end
50
+ // several transitions, and by the second event the listener is gone.
51
+ const fire = (): void => {
52
+ detach();
53
+ onSettled();
54
+ };
55
+ const onEnd = (e: TransitionEvent): void => {
56
+ if (e.target === el) {
57
+ fire();
58
+ }
59
+ };
60
+ el.addEventListener("transitionend", onEnd);
61
+ timer = setTimeout(fire, SETTLE_FALLBACK_MS);
62
+ return detach;
63
+ }
64
+
65
+ /**
66
+ * Drop the settle pending on `el` WITHOUT running it. For a caller that is
67
+ * taking the element somewhere the pending callback must not follow: a reopen
68
+ * during a fade, a dispose mid-animation, a node about to be removed. A no-op
69
+ * when nothing is pending.
70
+ */
71
+ export function cancelTransition(el: HTMLElement): void {
72
+ const cancel = inFlight.get(el);
73
+ if (cancel !== undefined) {
74
+ inFlight.delete(el);
75
+ cancel();
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Run one CSS transition on `el`: `settled` runs once, on the first
81
+ * `transitionend` targeting `el` or after the fallback ceiling. `change` and
82
+ * `settled` are read once, first, and any settle pending on `el` is dropped.
83
+ * A `cancelTransition` or `runTransition` on `el` from inside `change` takes
84
+ * the element and this call arms nothing; a throw from `change` arms nothing.
85
+ */
86
+ export function runTransition(el: HTMLElement, spec: TransitionSpec): void {
87
+ const change = spec.change;
88
+ const settled = spec.settled;
89
+ cancelTransition(el);
90
+
91
+ // Reading a layout property forces the engine to resolve the style written
92
+ // so far; without it the change below lands in the same frame.
93
+ el.getBoundingClientRect();
94
+
95
+ const reservation = (): void => undefined;
96
+ inFlight.set(el, reservation);
97
+ try {
98
+ change();
99
+ } catch (error) {
100
+ if (inFlight.get(el) === reservation) {
101
+ inFlight.delete(el);
102
+ }
103
+ throw error;
104
+ }
105
+ if (inFlight.get(el) !== reservation) {
106
+ return;
107
+ }
108
+ if (settled === undefined) {
109
+ inFlight.delete(el);
110
+ return;
111
+ }
112
+ const cancel = watchSettle(el, () => {
113
+ if (inFlight.get(el) === cancel) {
114
+ inFlight.delete(el);
115
+ }
116
+ settled();
117
+ });
118
+ inFlight.set(el, cancel);
119
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * view-transition.ts — Queued, feature-detected, rejection-safe wrapper over
3
+ * document.startViewTransition. Overlapping calls serialize through a
4
+ * module-level pending promise; the returned promise never rejects, even when
5
+ * the transition rejects or `fn` throws.
6
+ *
7
+ * Suspended-renderer safety: a hidden tab (and some remote/virtualized
8
+ * sessions that stay "visible" while the compositor is suspended) never
9
+ * grants startViewTransition a rendering opportunity, so `finished` never
10
+ * settles and every later call chains behind it forever. Two guards: a
11
+ * `document.hidden` fast-path that skips the transition entirely, and a
12
+ * watchdog that calls `skipTransition()` when `finished` hasn't settled in
13
+ * time (skipping still runs the update callback and settles via task queues).
14
+ *
15
+ * @module
16
+ */
17
+
18
+ let pending: Promise<void> = Promise.resolve();
19
+
20
+ /** How long a transition may stay unsettled before the watchdog skips it.
21
+ * Generous next to real transition durations (a few hundred ms): only a
22
+ * starved renderer ever reaches it. */
23
+ const SKIP_WATCHDOG_MS = 1_000;
24
+
25
+ /** Run `fn` inside a serialized document view transition. Resolves when the
26
+ * transition (or, when the API is absent or the document is hidden, the
27
+ * direct call) finishes. */
28
+ export function viewTransition(fn: () => void | Promise<void>): Promise<void> {
29
+ const run = async (): Promise<void> => {
30
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime feature detection
31
+ if (!document.startViewTransition || document.hidden) {
32
+ try {
33
+ await fn();
34
+ } catch {
35
+ // swallow — matches the API-present path, which never rejects the caller.
36
+ }
37
+ return;
38
+ }
39
+ const transition = document.startViewTransition(fn);
40
+ transition.ready.catch(() => undefined);
41
+ // Otherwise a throwing `fn` raises an unhandled rejection in the page —
42
+ // nothing else here observes updateCallbackDone.
43
+ transition.updateCallbackDone.catch(() => undefined);
44
+ const watchdog = setTimeout(() => {
45
+ transition.skipTransition();
46
+ }, SKIP_WATCHDOG_MS);
47
+ try {
48
+ await transition.finished.catch(() => undefined);
49
+ } finally {
50
+ clearTimeout(watchdog);
51
+ }
52
+ };
53
+ // Reset the shared tail to a swallowed promise so one failure can't wedge the queue.
54
+ const result = pending.then(run, run);
55
+ pending = result.catch(() => undefined);
56
+ return result;
57
+ }
@@ -0,0 +1,126 @@
1
+ // Typed event bus for discrete events with no retained value; continuous state
2
+ // belongs in a signal.
3
+ //
4
+ // Handler sets are snapshot-cached: the iteration array is rebuilt only when
5
+ // the set is mutated, so a handler unsubscribed during an emit still fires for
6
+ // that emit.
7
+
8
+ /** Handler for a bus event. Events whose payload type is `undefined` take no
9
+ * argument. */
10
+ export type BusHandler<T> = T extends undefined ? () => void : (payload: T) => void;
11
+
12
+ /** A typed event bus over an event→payload map `M`. Methods are arrow-function
13
+ * properties (not method signatures) so consumers can safely destructure
14
+ * `const { on, emit } = bus` without unbound-`this` warnings. */
15
+ export interface Bus<M> {
16
+ /** Subscribe to an event. Returns an unsubscribe function. */
17
+ on: <K extends keyof M>(event: K, handler: BusHandler<M[K]>) => () => void;
18
+ /** Subscribe for exactly one emit, then auto-unsubscribe. Returns an
19
+ * unsubscribe function (to cancel before it fires). */
20
+ once: <K extends keyof M>(event: K, handler: BusHandler<M[K]>) => () => void;
21
+ /** Remove a previously-registered handler (no-op if absent). */
22
+ off: <K extends keyof M>(event: K, handler: BusHandler<M[K]>) => void;
23
+ /** Emit an event. Events whose payload is `undefined` are emitted with no
24
+ * payload argument. */
25
+ emit: <K extends keyof M>(
26
+ ...args: M[K] extends undefined ? [event: K] : [event: K, payload: M[K]]
27
+ ) => void;
28
+ /** Remove all handlers for one event, or all handlers entirely. */
29
+ clear: (event?: keyof M) => void;
30
+ }
31
+
32
+ type AnyHandler = (payload?: unknown) => void;
33
+
34
+ interface Slot {
35
+ set: Set<AnyHandler>;
36
+ snapshot: AnyHandler[];
37
+ dirty: boolean;
38
+ }
39
+
40
+ /** Create a typed event bus. `onError` (default `console.error`) isolates a
41
+ * throwing handler so it can't break sibling handlers or the emitter. */
42
+ export function createBus<M>(options?: {
43
+ onError?: (event: keyof M, err: unknown) => void;
44
+ }): Bus<M> {
45
+ const slots = new Map<keyof M, Slot>();
46
+ const onError =
47
+ options?.onError ??
48
+ ((event: keyof M, err: unknown): void => {
49
+ console.error(`[bus] handler error for "${String(event)}":`, err);
50
+ });
51
+
52
+ function slotFor(event: keyof M): Slot {
53
+ let slot = slots.get(event);
54
+ if (slot === undefined) {
55
+ slot = { set: new Set(), snapshot: [], dirty: false };
56
+ slots.set(event, slot);
57
+ }
58
+ return slot;
59
+ }
60
+
61
+ function on<K extends keyof M>(event: K, handler: BusHandler<M[K]>): () => void {
62
+ const slot = slotFor(event);
63
+ slot.set.add(handler as AnyHandler);
64
+ slot.dirty = true;
65
+ return (): void => {
66
+ off(event, handler);
67
+ };
68
+ }
69
+
70
+ function off<K extends keyof M>(event: K, handler: BusHandler<M[K]>): void {
71
+ const slot = slots.get(event);
72
+ if (slot === undefined) {
73
+ return;
74
+ }
75
+ if (slot.set.delete(handler as AnyHandler)) {
76
+ slot.dirty = true;
77
+ }
78
+ }
79
+
80
+ function once<K extends keyof M>(event: K, handler: BusHandler<M[K]>): () => void {
81
+ const wrapped = ((...callArgs: unknown[]): void => {
82
+ off(event, wrapped);
83
+ (handler as AnyHandler)(...callArgs);
84
+ }) as BusHandler<M[K]>;
85
+ return on(event, wrapped);
86
+ }
87
+
88
+ function emit<K extends keyof M>(
89
+ ...args: M[K] extends undefined ? [event: K] : [event: K, payload: M[K]]
90
+ ): void {
91
+ const [event, payload] = args as [K, unknown];
92
+ const slot = slots.get(event);
93
+ if (slot === undefined) {
94
+ return;
95
+ }
96
+ if (slot.dirty) {
97
+ slot.snapshot = Array.from(slot.set);
98
+ slot.dirty = false;
99
+ }
100
+ // No-payload events (`emit(event)` with no second arg) invoke handlers with
101
+ // zero arguments, honoring the `() => void` handler type — a `() => void`
102
+ // handler should never receive a stray `undefined`.
103
+ const hasPayload = args.length > 1;
104
+ for (const handler of slot.snapshot) {
105
+ try {
106
+ if (hasPayload) {
107
+ handler(payload);
108
+ } else {
109
+ handler();
110
+ }
111
+ } catch (err) {
112
+ onError(event, err);
113
+ }
114
+ }
115
+ }
116
+
117
+ function clear(event?: keyof M): void {
118
+ if (event === undefined) {
119
+ slots.clear();
120
+ } else {
121
+ slots.delete(event);
122
+ }
123
+ }
124
+
125
+ return { on, once, off, emit, clear };
126
+ }