@cplieger/ui 0.0.0-stage → 1.0.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +3 -0
  3. package/README.md +157 -2
  4. package/THIRD_PARTY_NOTICES.md +8 -0
  5. package/css/ui-primitives.css +344 -0
  6. package/jsr.json +44 -0
  7. package/package.json +105 -4
  8. package/src/actions/api.ts +268 -0
  9. package/src/actions/async-feedback.ts +217 -0
  10. package/src/actions/cleanup.ts +66 -0
  11. package/src/actions/debounce.ts +150 -0
  12. package/src/actions/define-helpers.ts +87 -0
  13. package/src/actions/define.ts +851 -0
  14. package/src/actions/error.ts +161 -0
  15. package/src/actions/index.ts +60 -0
  16. package/src/actions/loading.ts +131 -0
  17. package/src/actions/notifier.ts +61 -0
  18. package/src/actions/poll-until.ts +133 -0
  19. package/src/actions/poll.ts +156 -0
  20. package/src/actions/registry.ts +259 -0
  21. package/src/actions/retry.ts +79 -0
  22. package/src/actions/testing.ts +29 -0
  23. package/src/actions/transport.ts +104 -0
  24. package/src/actions/types.ts +249 -0
  25. package/src/dom/bind-list.ts +103 -0
  26. package/src/dom/el.ts +67 -0
  27. package/src/dom/index.ts +9 -0
  28. package/src/dom/reconcile-tree.ts +268 -0
  29. package/src/dom/reconcile.ts +76 -0
  30. package/src/fetch/index.ts +18 -0
  31. package/src/fetch/instance.ts +37 -0
  32. package/src/fetch/request.ts +437 -0
  33. package/src/fetch/timeout.ts +67 -0
  34. package/src/fetch/types.ts +206 -0
  35. package/src/fetch/verbs.ts +103 -0
  36. package/src/index.ts +4 -0
  37. package/src/primitives/announce.ts +134 -0
  38. package/src/primitives/ask.ts +406 -0
  39. package/src/primitives/dialog-engine.ts +500 -0
  40. package/src/primitives/dialog.ts +263 -0
  41. package/src/primitives/disclosure.ts +287 -0
  42. package/src/primitives/focus-trap.ts +157 -0
  43. package/src/primitives/fx.ts +204 -0
  44. package/src/primitives/live-regions.ts +83 -0
  45. package/src/primitives/modal-host.ts +214 -0
  46. package/src/primitives/modal.ts +247 -0
  47. package/src/primitives/owner.ts +322 -0
  48. package/src/primitives/popover.ts +500 -0
  49. package/src/primitives/popup-core.ts +603 -0
  50. package/src/primitives/popup.ts +23 -0
  51. package/src/primitives/roving-focus.ts +198 -0
  52. package/src/primitives/scroll-lock.ts +119 -0
  53. package/src/primitives/skeleton.ts +142 -0
  54. package/src/primitives/theme.ts +202 -0
  55. package/src/primitives/toast/engine.ts +327 -0
  56. package/src/primitives/toast/index.ts +139 -0
  57. package/src/primitives/toast/view.ts +244 -0
  58. package/src/primitives/tooltip.ts +377 -0
  59. package/src/primitives/transition.ts +119 -0
  60. package/src/primitives/view-transition.ts +57 -0
  61. package/src/signal/bus.ts +126 -0
  62. package/src/signal/collection.ts +182 -0
  63. package/src/signal/index.ts +34 -0
  64. package/src/signal/resource.ts +102 -0
  65. package/src/signal/signal-map.ts +46 -0
  66. package/src/signal/signal.ts +1002 -0
  67. package/src/signal/store.ts +84 -0
@@ -0,0 +1,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
+ }