@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.
- package/LICENSE +202 -0
- package/NOTICE +3 -0
- package/README.md +157 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +44 -0
- package/package.json +105 -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 -0
- 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,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
|
+
}
|