@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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roving-focus.ts — WAI-ARIA roving-tabindex keyboard navigation for
|
|
3
|
+
* composite widgets (menus, listboxes, pickers, toolbars): one Tab stop,
|
|
4
|
+
* arrow keys move focus. Items are queried live each keystroke; call
|
|
5
|
+
* `refresh()` after a bulk re-render to restore the single-Tab-stop invariant.
|
|
6
|
+
*
|
|
7
|
+
* Pairs with popover for the WAI-ARIA menu pattern:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const pop = createPopover(button, panel, { haspopup: "menu" });
|
|
11
|
+
* const nav = rovingFocus(panel, "[role=menuitem]");
|
|
12
|
+
* // on open: nav.focusFirst();
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { untracked } from "../signal/signal.js";
|
|
19
|
+
|
|
20
|
+
import { createDriver, ownedBinding } from "./owner.js";
|
|
21
|
+
|
|
22
|
+
/** Keyboard behavior of a roving-tabindex container: which arrow pair moves
|
|
23
|
+
* focus, whether the ends wrap, whether Home/End jump, and whether
|
|
24
|
+
* Enter/Space activate the focused item. The defaults are the WAI-ARIA
|
|
25
|
+
* vertical-menu shape. */
|
|
26
|
+
export interface RovingFocusOptions {
|
|
27
|
+
/** Which arrow pair moves focus: `"vertical"` (Up/Down, default) or
|
|
28
|
+
* `"horizontal"` (Left/Right). */
|
|
29
|
+
orientation?: "vertical" | "horizontal";
|
|
30
|
+
/** Wrap from the last item to the first and vice versa. Default `true`. */
|
|
31
|
+
wrap?: boolean;
|
|
32
|
+
/** Home/End jump to the first/last item. Default `true`. */
|
|
33
|
+
homeEnd?: boolean;
|
|
34
|
+
/** Enter and Space activate (click) the focused item. Default `true`.
|
|
35
|
+
* Disable when items are inputs or handle their own keys. */
|
|
36
|
+
activate?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Handle on a wired container. Items are re-queried on every keystroke, so
|
|
40
|
+
* adding or removing one needs no call here; `refresh()` exists for a bulk
|
|
41
|
+
* re-render, which discards the element carrying the single `tabindex=0` Tab
|
|
42
|
+
* stop. */
|
|
43
|
+
export interface RovingFocusController {
|
|
44
|
+
/** Focus the first item (e.g. when a menu opens). */
|
|
45
|
+
focusFirst(): void;
|
|
46
|
+
/** Re-apply the roving tabindex after a bulk re-render: the current item
|
|
47
|
+
* (focused, or the previous Tab stop if still present) keeps `tabindex=0`,
|
|
48
|
+
* everything else gets `-1`. */
|
|
49
|
+
refresh(): void;
|
|
50
|
+
/** Remove the listeners. Tabindex attributes are left as-is, and
|
|
51
|
+
* `focusFirst()` and `refresh()` do nothing from then on. */
|
|
52
|
+
dispose(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wire roving-tabindex arrow-key navigation over `container`'s descendants
|
|
56
|
+
* matching `selector`. Returns a controller with `focusFirst` / `refresh` /
|
|
57
|
+
* `dispose`. */
|
|
58
|
+
export function rovingFocus(
|
|
59
|
+
container: HTMLElement,
|
|
60
|
+
selector: string,
|
|
61
|
+
opts?: RovingFocusOptions,
|
|
62
|
+
): RovingFocusController {
|
|
63
|
+
const o = untracked(() => ({
|
|
64
|
+
vertical: (opts?.orientation ?? "vertical") !== "horizontal",
|
|
65
|
+
wrap: opts?.wrap ?? true,
|
|
66
|
+
homeEnd: opts?.homeEnd ?? true,
|
|
67
|
+
activate: opts?.activate ?? true,
|
|
68
|
+
}));
|
|
69
|
+
const prevKey = o.vertical ? "ArrowUp" : "ArrowLeft";
|
|
70
|
+
const nextKey = o.vertical ? "ArrowDown" : "ArrowRight";
|
|
71
|
+
|
|
72
|
+
interface State {
|
|
73
|
+
/** The committed tab stop. */
|
|
74
|
+
readonly stop: HTMLElement | null;
|
|
75
|
+
/** Moves at every refresh(), whose job is to re-apply even an unchanged stop. */
|
|
76
|
+
readonly sync: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return ownedBinding((binding) => {
|
|
80
|
+
const items = (): HTMLElement[] => [...container.querySelectorAll<HTMLElement>(selector)];
|
|
81
|
+
let listening = false;
|
|
82
|
+
|
|
83
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
84
|
+
driver.act((fx) => {
|
|
85
|
+
const list = items();
|
|
86
|
+
if (list.length === 0) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const active = document.activeElement;
|
|
90
|
+
const current = active instanceof HTMLElement ? list.indexOf(active) : -1;
|
|
91
|
+
let next: number;
|
|
92
|
+
switch (e.key) {
|
|
93
|
+
case nextKey:
|
|
94
|
+
next = current < list.length - 1 ? current + 1 : o.wrap ? 0 : list.length - 1;
|
|
95
|
+
break;
|
|
96
|
+
case prevKey:
|
|
97
|
+
next = current > 0 ? current - 1 : o.wrap ? list.length - 1 : 0;
|
|
98
|
+
break;
|
|
99
|
+
case "Home":
|
|
100
|
+
if (!o.homeEnd) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
next = 0;
|
|
104
|
+
break;
|
|
105
|
+
case "End":
|
|
106
|
+
if (!o.homeEnd) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
next = list.length - 1;
|
|
110
|
+
break;
|
|
111
|
+
case "Enter":
|
|
112
|
+
case " ":
|
|
113
|
+
case "Spacebar": {
|
|
114
|
+
const item = list[current];
|
|
115
|
+
if (o.activate && item !== undefined) {
|
|
116
|
+
// Suppress the native Enter/Space activation and fire exactly one click.
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
fx.click(item);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
const target = list[next];
|
|
127
|
+
if (target !== undefined) {
|
|
128
|
+
fx.focus(target);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
const onFocusIn = (e: FocusEvent): void => {
|
|
133
|
+
const target = e.target;
|
|
134
|
+
if (binding.ended || !(target instanceof HTMLElement) || !target.matches(selector)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
driver.commit(
|
|
138
|
+
driver.state.stop === target ? driver.state : { ...driver.state, stop: target },
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
const listen = (on: boolean): void => {
|
|
142
|
+
if (listening !== on) {
|
|
143
|
+
listening = on;
|
|
144
|
+
const method = on ? "addEventListener" : "removeEventListener";
|
|
145
|
+
container[method]("keydown", onKeyDown as EventListener);
|
|
146
|
+
container[method]("focusin", onFocusIn as EventListener);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const driver = createDriver<State>({
|
|
151
|
+
initial: { stop: null, sync: 0 },
|
|
152
|
+
realize(fx, d) {
|
|
153
|
+
listen(true);
|
|
154
|
+
const list = items();
|
|
155
|
+
const target = d.stop !== null && list.includes(d.stop) ? d.stop : (list[0] ?? null);
|
|
156
|
+
for (const item of list) {
|
|
157
|
+
fx.setAttr(item, "tabindex", item === target ? "0" : "-1");
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
terminal() {
|
|
161
|
+
listen(false);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
controller: {
|
|
167
|
+
focusFirst(): void {
|
|
168
|
+
driver.act((fx) => {
|
|
169
|
+
const first = items()[0];
|
|
170
|
+
if (first !== undefined) {
|
|
171
|
+
fx.focus(first);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
refresh(): void {
|
|
176
|
+
if (binding.ended) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Re-deriving the stop from the DOM is this method's contract.
|
|
180
|
+
const list = items();
|
|
181
|
+
const active = document.activeElement;
|
|
182
|
+
const stop =
|
|
183
|
+
active instanceof HTMLElement && list.includes(active)
|
|
184
|
+
? active
|
|
185
|
+
: (list.find((i) => i.getAttribute("tabindex") === "0") ?? null);
|
|
186
|
+
driver.commit({ stop, sync: driver.state.sync + 1 });
|
|
187
|
+
},
|
|
188
|
+
dispose: binding.dispose,
|
|
189
|
+
},
|
|
190
|
+
start: () => {
|
|
191
|
+
driver.flush();
|
|
192
|
+
},
|
|
193
|
+
release: () => {
|
|
194
|
+
driver.release();
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// scroll-lock.ts — INTERNAL: the body scroll lock nested modals share. iOS
|
|
2
|
+
// Safari ignores `overflow: hidden` on the root for touch-scroll, so the body
|
|
3
|
+
// is pinned with `position: fixed` at the negated scroll offset instead.
|
|
4
|
+
//
|
|
5
|
+
// The lock is held while any registered holder is live, and a holder's
|
|
6
|
+
// liveness is read from its dialog at every check, so a close or open the
|
|
7
|
+
// browser makes during a lock write stops the writes for the old target.
|
|
8
|
+
|
|
9
|
+
import type { StyleProp } from "./fx.js";
|
|
10
|
+
import { createDriver, type Driver } from "./owner.js";
|
|
11
|
+
|
|
12
|
+
type LockProp = Extract<StyleProp, "position" | "top" | "left" | "right" | "width" | "overflow">;
|
|
13
|
+
const PROPS: readonly LockProp[] = ["position", "top", "left", "right", "width", "overflow"];
|
|
14
|
+
|
|
15
|
+
interface LockState {
|
|
16
|
+
readonly held: ReadonlySet<symbol>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Each holder's live state, read at every check. */
|
|
20
|
+
const holders = new Map<symbol, () => boolean>();
|
|
21
|
+
/** The page's own body styles and scroll offset, kept across a restart. */
|
|
22
|
+
let saved: { readonly y: number; readonly styles: Readonly<Record<LockProp, string>> } | null =
|
|
23
|
+
null;
|
|
24
|
+
|
|
25
|
+
function liveHolders(): ReadonlySet<symbol> {
|
|
26
|
+
const held = new Set<symbol>();
|
|
27
|
+
for (const [holder, live] of holders) {
|
|
28
|
+
if (live()) {
|
|
29
|
+
held.add(holder);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return held;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sameSet(a: ReadonlySet<symbol>, b: ReadonlySet<symbol>): boolean {
|
|
36
|
+
return a.size === b.size && [...a].every((h) => b.has(h));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lockValue(prop: LockProp, y: number): string {
|
|
40
|
+
switch (prop) {
|
|
41
|
+
case "position":
|
|
42
|
+
return "fixed";
|
|
43
|
+
case "top":
|
|
44
|
+
return `-${y.toString()}px`;
|
|
45
|
+
case "left":
|
|
46
|
+
case "right":
|
|
47
|
+
return "0";
|
|
48
|
+
case "width":
|
|
49
|
+
return "100%";
|
|
50
|
+
case "overflow":
|
|
51
|
+
return "hidden";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeLock(): Driver<LockState> {
|
|
56
|
+
return createDriver<LockState>({
|
|
57
|
+
initial: { held: new Set() },
|
|
58
|
+
observe(s) {
|
|
59
|
+
const held = liveHolders();
|
|
60
|
+
return sameSet(held, s.held) ? s : { held };
|
|
61
|
+
},
|
|
62
|
+
realize(fx, s) {
|
|
63
|
+
const body = document.body;
|
|
64
|
+
if (s.held.size > 0) {
|
|
65
|
+
saved ??= {
|
|
66
|
+
y: window.scrollY,
|
|
67
|
+
styles: Object.fromEntries(PROPS.map((p) => [p, body.style[p]])) as Record<
|
|
68
|
+
LockProp,
|
|
69
|
+
string
|
|
70
|
+
>,
|
|
71
|
+
};
|
|
72
|
+
for (const prop of PROPS) {
|
|
73
|
+
fx.setStyle(body, prop, lockValue(prop, saved.y));
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (saved === null) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const prop of PROPS) {
|
|
81
|
+
fx.setStyle(body, prop, saved.styles[prop]);
|
|
82
|
+
}
|
|
83
|
+
const y = saved.y;
|
|
84
|
+
saved = null;
|
|
85
|
+
window.scrollTo(0, y);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let lock = makeLock();
|
|
91
|
+
|
|
92
|
+
/** Register `holder` with its live state, or withdraw it with `null`. Sets
|
|
93
|
+
* the lock's state without driving it. */
|
|
94
|
+
export function holdScrollLock(holder: symbol, live: (() => boolean) | null): void {
|
|
95
|
+
if (live === null) {
|
|
96
|
+
holders.delete(holder);
|
|
97
|
+
} else {
|
|
98
|
+
holders.set(holder, live);
|
|
99
|
+
}
|
|
100
|
+
lock.sync();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Realize the lock for its current holders. */
|
|
104
|
+
export function flushScrollLock(): void {
|
|
105
|
+
lock.sync();
|
|
106
|
+
lock.flush();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Test-only: restore the page's body styles and forget every holder. */
|
|
110
|
+
export function _resetScrollLockForTest(): void {
|
|
111
|
+
if (saved !== null) {
|
|
112
|
+
for (const prop of PROPS) {
|
|
113
|
+
document.body.style[prop] = saved.styles[prop];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
saved = null;
|
|
117
|
+
holders.clear();
|
|
118
|
+
lock = makeLock();
|
|
119
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skeleton.ts — anti-flicker timing for a "show a skeleton, then replace it
|
|
3
|
+
* with content" load. Pure timing, no DOM: the caller paints the skeleton and
|
|
4
|
+
* the content; this owns WHEN.
|
|
5
|
+
*
|
|
6
|
+
* Two consumption styles, unified:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* // commit-style: the content render replaces the skeleton in place
|
|
10
|
+
* const t = skeletonTiming(() => paint(out, skeleton), { minVisibleMs: 300, signal });
|
|
11
|
+
* const data = await load(signal);
|
|
12
|
+
* t.commit(() => paint(out, content(data)));
|
|
13
|
+
*
|
|
14
|
+
* // teardown-style: the skeleton is its own element, removed on settle
|
|
15
|
+
* const t = skeletonTiming(() => {
|
|
16
|
+
* const node = makeSkeleton();
|
|
17
|
+
* list.append(node);
|
|
18
|
+
* return () => node.remove();
|
|
19
|
+
* });
|
|
20
|
+
* await load();
|
|
21
|
+
* t.cancel(); // removes the skeleton if it was painted
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The three windows that decide whether a skeleton is painted at all and how
|
|
28
|
+
* long it stays: the delay before it appears, the floor on how long it stays
|
|
29
|
+
* once it has, and the load's abort signal. */
|
|
30
|
+
export interface SkeletonTimingOptions {
|
|
31
|
+
/** Delay before the skeleton is painted (default 150ms). A load that settles
|
|
32
|
+
* within this window never paints the skeleton at all. */
|
|
33
|
+
showDelayMs?: number;
|
|
34
|
+
/** Minimum time the skeleton stays up once painted (default 0 = none), so it
|
|
35
|
+
* never appears then instantly vanishes. Leave at 0 when the skeleton
|
|
36
|
+
* shares its container with the real content and must clear the instant the
|
|
37
|
+
* load completes. */
|
|
38
|
+
minVisibleMs?: number;
|
|
39
|
+
/** Abort signal for the underlying load. If it fires before the skeleton is
|
|
40
|
+
* painted, the skeleton is suppressed — no orphaned skeleton for a
|
|
41
|
+
* cancelled or superseded load. It does NOT retract an already-painted
|
|
42
|
+
* skeleton (settle with `commit` or `cancel` for that). */
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The settle handle for one load. Call exactly one of `commit` (the content
|
|
47
|
+
* arrived) or `cancel` (the load was abandoned); whichever runs first wins,
|
|
48
|
+
* and a later call to either is a no-op. Leaving both uncalled leaves a
|
|
49
|
+
* painted skeleton up forever. */
|
|
50
|
+
export interface SkeletonTimingController {
|
|
51
|
+
/** Paint the content. Call once, after the awaited work settles. Honors
|
|
52
|
+
* show-delay (renders immediately when the skeleton was never painted) and
|
|
53
|
+
* min-visible (defers until the skeleton has been up long enough); any
|
|
54
|
+
* teardown returned by `show` runs immediately before the render.
|
|
55
|
+
*
|
|
56
|
+
* The render always runs (unless `cancel()` won first) — a caller whose
|
|
57
|
+
* content is a captured, stale-sensitive result should guard its own render
|
|
58
|
+
* closure (e.g. `if (signal.aborted) return;`). */
|
|
59
|
+
commit(render: () => void): void;
|
|
60
|
+
/** Abandon the load: clears a pending skeleton (so it never paints), runs
|
|
61
|
+
* the `show` teardown if it was painted, and drops any pending deferred
|
|
62
|
+
* `commit` render. Idempotent; the teardown runs at most once. */
|
|
63
|
+
cancel(): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build an anti-flicker controller. `show` paints the skeleton (deferred by
|
|
68
|
+
* show-delay) and may return a teardown that removes it again; the returned
|
|
69
|
+
* controller settles the load with `commit(render)` (paint content) or
|
|
70
|
+
* `cancel()` (abandon).
|
|
71
|
+
*/
|
|
72
|
+
export function skeletonTiming(
|
|
73
|
+
show: (() => () => void) | (() => void),
|
|
74
|
+
opts?: SkeletonTimingOptions,
|
|
75
|
+
): SkeletonTimingController {
|
|
76
|
+
const showDelayMs = opts?.showDelayMs ?? 150;
|
|
77
|
+
const minVisibleMs = opts?.minVisibleMs ?? 0;
|
|
78
|
+
|
|
79
|
+
let shownAt: number | null = null;
|
|
80
|
+
let teardown: (() => void) | undefined;
|
|
81
|
+
let committed = false;
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
let commitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
84
|
+
|
|
85
|
+
const showTimer = setTimeout(() => {
|
|
86
|
+
if (opts?.signal?.aborted === true) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
shownAt = Date.now();
|
|
90
|
+
const result = show();
|
|
91
|
+
teardown = typeof result === "function" ? result : undefined;
|
|
92
|
+
}, showDelayMs);
|
|
93
|
+
|
|
94
|
+
const runTeardown = (): void => {
|
|
95
|
+
if (teardown !== undefined) {
|
|
96
|
+
const t = teardown;
|
|
97
|
+
teardown = undefined;
|
|
98
|
+
t();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
commit(render: () => void): void {
|
|
104
|
+
if (committed || cancelled) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
committed = true;
|
|
108
|
+
clearTimeout(showTimer);
|
|
109
|
+
if (shownAt === null) {
|
|
110
|
+
// Skeleton never painted (fast load, or suppressed by abort) — paint
|
|
111
|
+
// the content straight away.
|
|
112
|
+
render();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const paint = (): void => {
|
|
116
|
+
commitTimer = null;
|
|
117
|
+
runTeardown();
|
|
118
|
+
render();
|
|
119
|
+
};
|
|
120
|
+
const remaining = minVisibleMs - (Date.now() - shownAt);
|
|
121
|
+
if (remaining <= 0) {
|
|
122
|
+
paint();
|
|
123
|
+
} else {
|
|
124
|
+
commitTimer = setTimeout(paint, remaining);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
cancel(): void {
|
|
128
|
+
if (cancelled) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
cancelled = true;
|
|
132
|
+
clearTimeout(showTimer);
|
|
133
|
+
if (commitTimer !== null) {
|
|
134
|
+
// A commit deferred by min-visible loses to a later cancel: the render
|
|
135
|
+
// must not fire into a view the caller is abandoning.
|
|
136
|
+
clearTimeout(commitTimer);
|
|
137
|
+
commitTimer = null;
|
|
138
|
+
}
|
|
139
|
+
runTeardown();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* theme.ts — Persisted tri-state theme controller (light / dark / system).
|
|
3
|
+
* While the choice is "system" it follows the OS preference live via
|
|
4
|
+
* matchMedia; concrete choices are pinned. The resolved concrete value is
|
|
5
|
+
* written to a document-element attribute (default `data-theme`) so CSS can
|
|
6
|
+
* key off it. `themeInitSnippet` produces an inline blocking-script string for
|
|
7
|
+
* paint-time application (a real import can't run before first paint).
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The persisted preference: a pinned concrete theme, or `"system"` to follow
|
|
13
|
+
* the OS preference live. Distinct from the resolved value written to the
|
|
14
|
+
* document element, which is always concrete. */
|
|
15
|
+
export type ThemeChoice = "light" | "dark" | "system";
|
|
16
|
+
type Resolved = "light" | "dark";
|
|
17
|
+
|
|
18
|
+
/** Persistence adapter for the theme preference. Encapsulates WHERE and HOW the
|
|
19
|
+
* value is stored, so an app can keep the theme inside its own structure (for
|
|
20
|
+
* example a field of a larger JSON blob) rather than a bare localStorage key. */
|
|
21
|
+
export interface ThemeStorage {
|
|
22
|
+
/** Read the persisted preference, or `null` when nothing is stored yet. */
|
|
23
|
+
get(): string | null;
|
|
24
|
+
/** Persist the preference value. */
|
|
25
|
+
set(value: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Where the preference is persisted, which attribute carries the resolved
|
|
29
|
+
* theme, and a hook for each application. `storageKey` is the only required
|
|
30
|
+
* field; supply `storage` when the value must live somewhere other than a
|
|
31
|
+
* bare localStorage key. */
|
|
32
|
+
export interface ThemeOptions {
|
|
33
|
+
/** localStorage key the default adapter persists under. Unused when a custom
|
|
34
|
+
* `storage` adapter is supplied (the adapter owns where the value lives). */
|
|
35
|
+
storageKey: string;
|
|
36
|
+
/** Persistence adapter. Defaults to bare `localStorage[storageKey]`. Supply a
|
|
37
|
+
* custom adapter to read/write the theme inside your own structure — e.g. a
|
|
38
|
+
* `theme` field of a JSON blob: `get` parses the blob and returns the field,
|
|
39
|
+
* `set` does a read-modify-write of that field. Everything else (tri-state
|
|
40
|
+
* resolution, following the OS while `system`, the applied attribute) is
|
|
41
|
+
* unchanged; a 2-state app simply never calls `set("system")`. */
|
|
42
|
+
storage?: ThemeStorage;
|
|
43
|
+
/** Attribute set on `<html>` with the resolved value. Default `data-theme`. */
|
|
44
|
+
attribute?: string;
|
|
45
|
+
/** Called with the resolved concrete theme whenever it changes/applies. */
|
|
46
|
+
onChange?: (resolved: Resolved) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A live theme controller. The preference (`get`) and the theme actually
|
|
50
|
+
* applied (`resolved`) are separate values and differ whenever the preference
|
|
51
|
+
* is `"system"`. A controller keeps a matchMedia listener until `dispose`. */
|
|
52
|
+
export interface ThemeController {
|
|
53
|
+
/** The stored preference (may be `"system"`). */
|
|
54
|
+
get(): ThemeChoice;
|
|
55
|
+
/** Persist + apply a preference. */
|
|
56
|
+
set(choice: ThemeChoice): void;
|
|
57
|
+
/** The concrete resolved theme (`"system"` → OS value via matchMedia). */
|
|
58
|
+
resolved(): Resolved;
|
|
59
|
+
/** Cycle light → dark → system → light. */
|
|
60
|
+
cycle(): void;
|
|
61
|
+
/** The current OS preference. */
|
|
62
|
+
getSystem(): Resolved;
|
|
63
|
+
/** Remove the matchMedia listener. */
|
|
64
|
+
dispose(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
68
|
+
const DEFAULT_ATTRIBUTE = "data-theme";
|
|
69
|
+
|
|
70
|
+
function isChoice(value: string | null): value is ThemeChoice {
|
|
71
|
+
return value === "light" || value === "dark" || value === "system";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Create a theme controller bound to the given storage key. Applies the
|
|
75
|
+
* resolved theme immediately and starts following the OS while in "system". */
|
|
76
|
+
export function createTheme(opts: ThemeOptions): ThemeController {
|
|
77
|
+
const attribute = opts.attribute ?? DEFAULT_ATTRIBUTE;
|
|
78
|
+
// Lazy + try/catch-wrapped below, so blocked/absent storage degrades to
|
|
79
|
+
// in-memory rather than throwing at construction.
|
|
80
|
+
const storage: ThemeStorage = opts.storage ?? {
|
|
81
|
+
get: () => window.localStorage.getItem(opts.storageKey),
|
|
82
|
+
set: (value) => {
|
|
83
|
+
window.localStorage.setItem(opts.storageKey, value);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const mql = typeof window.matchMedia === "function" ? window.matchMedia(DARK_QUERY) : null;
|
|
87
|
+
|
|
88
|
+
const readChoice = (): ThemeChoice => {
|
|
89
|
+
let stored: string | null;
|
|
90
|
+
try {
|
|
91
|
+
stored = storage.get();
|
|
92
|
+
} catch {
|
|
93
|
+
stored = null;
|
|
94
|
+
}
|
|
95
|
+
return isChoice(stored) ? stored : "system";
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let choice: ThemeChoice = readChoice();
|
|
99
|
+
|
|
100
|
+
const getSystem = (): Resolved => (mql?.matches === true ? "dark" : "light");
|
|
101
|
+
const resolved = (): Resolved => (choice === "system" ? getSystem() : choice);
|
|
102
|
+
|
|
103
|
+
const apply = (): void => {
|
|
104
|
+
const value = resolved();
|
|
105
|
+
document.documentElement.setAttribute(attribute, value);
|
|
106
|
+
opts.onChange?.(value);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const set = (next: ThemeChoice): void => {
|
|
110
|
+
choice = next;
|
|
111
|
+
try {
|
|
112
|
+
storage.set(next);
|
|
113
|
+
} catch {
|
|
114
|
+
// Storage denied, or a custom adapter threw — apply in memory only.
|
|
115
|
+
}
|
|
116
|
+
apply();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const cycle = (): void => {
|
|
120
|
+
set(choice === "light" ? "dark" : choice === "dark" ? "system" : "light");
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const onSystemChange = (): void => {
|
|
124
|
+
if (choice === "system") {
|
|
125
|
+
apply();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
mql?.addEventListener("change", onSystemChange);
|
|
129
|
+
|
|
130
|
+
apply();
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
get: () => choice,
|
|
134
|
+
set,
|
|
135
|
+
resolved,
|
|
136
|
+
cycle,
|
|
137
|
+
getSystem,
|
|
138
|
+
dispose: () => {
|
|
139
|
+
mql?.removeEventListener("change", onSystemChange);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Build a self-contained IIFE string that reads the stored preference and sets
|
|
145
|
+
* the resolved theme attribute synchronously. Inline it in a blocking `<head>`
|
|
146
|
+
* script so the correct theme is painted before stylesheets parse:
|
|
147
|
+
*
|
|
148
|
+
* <script>{themeInitSnippet("app-theme")}</script>
|
|
149
|
+
*/
|
|
150
|
+
export function themeInitSnippet(storageKey: string, attribute = DEFAULT_ATTRIBUTE): string {
|
|
151
|
+
const key = jsonForScript(storageKey);
|
|
152
|
+
const attr = jsonForScript(attribute);
|
|
153
|
+
const system = `window.matchMedia("${DARK_QUERY}").matches?"dark":"light"`;
|
|
154
|
+
return (
|
|
155
|
+
`(function(){try{` +
|
|
156
|
+
`var c=localStorage.getItem(${key});` +
|
|
157
|
+
`if(c!=="light"&&c!=="dark"&&c!=="system"){c="system";}` +
|
|
158
|
+
`var r=c==="system"?(${system}):c;` +
|
|
159
|
+
`document.documentElement.setAttribute(${attr},r);` +
|
|
160
|
+
// Falls back to the system preference, matching createTheme's runtime
|
|
161
|
+
// default, rather than a hardcoded "light".
|
|
162
|
+
`}catch(e){document.documentElement.setAttribute(${attr},${system});}})();`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Like {@link themeInitSnippet}, but the preference is the `field` of a JSON
|
|
167
|
+
* object stored under `storageKey`, for a custom `storage` adapter that keeps
|
|
168
|
+
* the theme in a JSON blob. A missing, blank or non-object blob, or a
|
|
169
|
+
* `"system"` or unrecognized field, resolves to the OS preference. All three
|
|
170
|
+
* arguments are escaped for the inline-`<script>` context. */
|
|
171
|
+
export function themeInitSnippetFromJSON(
|
|
172
|
+
storageKey: string,
|
|
173
|
+
field: string,
|
|
174
|
+
attribute = DEFAULT_ATTRIBUTE,
|
|
175
|
+
): string {
|
|
176
|
+
const key = jsonForScript(storageKey);
|
|
177
|
+
const fld = jsonForScript(field);
|
|
178
|
+
const attr = jsonForScript(attribute);
|
|
179
|
+
const system = `window.matchMedia("${DARK_QUERY}").matches?"dark":"light"`;
|
|
180
|
+
return (
|
|
181
|
+
`(function(){try{` +
|
|
182
|
+
`var raw=localStorage.getItem(${key});` +
|
|
183
|
+
`var o=raw?JSON.parse(raw):null;` +
|
|
184
|
+
`var c=o&&typeof o==="object"?o[${fld}]:null;` +
|
|
185
|
+
`if(c!=="light"&&c!=="dark"&&c!=="system"){c="system";}` +
|
|
186
|
+
`var r=c==="system"?(${system}):c;` +
|
|
187
|
+
`document.documentElement.setAttribute(${attr},r);` +
|
|
188
|
+
// Same system-preference fallback as themeInitSnippet.
|
|
189
|
+
`}catch(e){document.documentElement.setAttribute(${attr},${system});}})();`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `JSON.stringify` a value, then neutralize the characters that could break
|
|
194
|
+
* out of an inline `<script>` context: `<` (so `</script>` can't close the
|
|
195
|
+
* tag) and the raw line separators U+2028 / U+2029 (invalid in JS strings in
|
|
196
|
+
* older engines, and stray line breaks in HTML). */
|
|
197
|
+
function jsonForScript(value: string): string {
|
|
198
|
+
return JSON.stringify(value)
|
|
199
|
+
.replace(/</g, "\\x3C")
|
|
200
|
+
.replace(/\u2028/g, "\\u2028")
|
|
201
|
+
.replace(/\u2029/g, "\\u2029");
|
|
202
|
+
}
|