@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,182 @@
1
+ // collection.ts — reactive ordered collection of keyed entities.
2
+ //
3
+ // Generalizes the "list of entities, each independently reactive, plus a
4
+ // separate structure signal" model: a per-entity content update touches only
5
+ // that entity's subscribers, while add/remove/reorder bumps the `ids` signal.
6
+ // This is the data half of the two-tier list pattern (see `bindList` for the
7
+ // render half). Built on `signal` + `SignalMap` — no separate engine.
8
+
9
+ import { signal, batch, type ReadonlySignal } from "./signal.js";
10
+ import { SignalMap } from "./signal-map.js";
11
+
12
+ /** A reactive, ordered, keyed collection of entities of type T. */
13
+ export interface Collection<T> {
14
+ /** Replace the whole collection (e.g. initial load). Entities no longer
15
+ * present are dropped; the order becomes `items`, deduplicated by key:
16
+ * the first occurrence keeps the position, and a later value replaces the
17
+ * held one only when `equals` reports a change. */
18
+ setAll(items: readonly T[]): void;
19
+ /** Add a new entity or replace an existing one (keyed by `keyOf`). New
20
+ * entities are appended to the order. */
21
+ upsert(item: T): void;
22
+ /** Add new entities to the FRONT of the order (e.g. loading older items for
23
+ * scroll-up pagination). Existing ids are updated in place without moving. */
24
+ prepend(items: readonly T[]): void;
25
+ /** Update one entity in place (no-op if absent). Pass a value or an
26
+ * updater. Does not change order. */
27
+ update(id: string, next: T | ((cur: T) => T)): void;
28
+ /** Remove one entity (no-op if absent). */
29
+ remove(id: string): void;
30
+ /** Remove every entity. */
31
+ clear(): void;
32
+ /** Current value of one entity (untracked peek), or undefined. */
33
+ get(id: string): T | undefined;
34
+ /** Whether an entity is present (untracked). */
35
+ has(id: string): boolean;
36
+ /** Reactive per-entity signal, or undefined if absent. Read inside an effect
37
+ * to react only when THIS entity changes (the content tier). */
38
+ signalFor(id: string): ReadonlySignal<T> | undefined;
39
+ /** Reactive ordered id list — changes on add/remove/reorder, NOT on a
40
+ * per-entity content update (the structure tier). */
41
+ readonly ids: ReadonlySignal<readonly string[]>;
42
+ /** Ordered snapshot of entity values. Reactive: reading this inside an
43
+ * effect tracks order AND every entity (coarse — use `ids` + `signalFor`
44
+ * for the two-tier split). */
45
+ items(): T[];
46
+ /** Entity count (untracked). */
47
+ readonly size: number;
48
+ }
49
+
50
+ /** Create a reactive ordered keyed collection. `keyOf` derives a stable
51
+ * string id from an entity; `equals` decides when a write of an entity is a
52
+ * change to notify, `Object.is` when omitted. */
53
+ export function createCollection<T>(
54
+ keyOf: (item: T) => string,
55
+ options?: { readonly equals?: (a: T, b: T) => boolean },
56
+ ): Collection<T> {
57
+ // Shallow-equal so the structure tier fires only when the id sequence
58
+ // actually changes (a same-order setAll updates entities without a
59
+ // spurious structural re-render).
60
+ const order = signal<readonly string[]>([], {
61
+ equals: (a, b) => a.length === b.length && a.every((x, i) => x === b[i]),
62
+ });
63
+ const sigs = new SignalMap<T>(options);
64
+
65
+ function setAll(items: readonly T[]): void {
66
+ // Atomic: batch the per-entity writes + the order swap so effects (and any
67
+ // row update that cross-reads siblings) only run once the whole collection
68
+ // is consistent, not mid-mutation.
69
+ batch(() => {
70
+ const ids: string[] = [];
71
+ const seen = new Set<string>();
72
+ for (const item of items) {
73
+ const id = keyOf(item);
74
+ // One order slot per id, matching the one signal per id.
75
+ if (!seen.has(id)) {
76
+ seen.add(id);
77
+ ids.push(id);
78
+ }
79
+ sigs.ensure(id, item).value = item;
80
+ }
81
+ for (const id of order.peek()) {
82
+ if (!seen.has(id)) {
83
+ sigs.clear(id);
84
+ }
85
+ }
86
+ order.value = ids;
87
+ });
88
+ }
89
+
90
+ function upsert(item: T): void {
91
+ const id = keyOf(item);
92
+ const existing = sigs.get(id);
93
+ if (existing === undefined) {
94
+ sigs.ensure(id, item);
95
+ order.value = [...order.peek(), id];
96
+ } else {
97
+ existing.value = item;
98
+ }
99
+ }
100
+
101
+ function prepend(items: readonly T[]): void {
102
+ batch(() => {
103
+ const newIds: string[] = [];
104
+ for (const item of items) {
105
+ const id = keyOf(item);
106
+ const existing = sigs.get(id);
107
+ if (existing === undefined) {
108
+ sigs.ensure(id, item);
109
+ newIds.push(id);
110
+ } else {
111
+ existing.value = item;
112
+ }
113
+ }
114
+ if (newIds.length > 0) {
115
+ order.value = [...newIds, ...order.peek()];
116
+ }
117
+ });
118
+ }
119
+
120
+ function update(id: string, next: T | ((cur: T) => T)): void {
121
+ const s = sigs.get(id);
122
+ if (s === undefined) {
123
+ return;
124
+ }
125
+ s.value = typeof next === "function" ? (next as (cur: T) => T)(s.peek()) : next;
126
+ }
127
+
128
+ function remove(id: string): void {
129
+ if (sigs.get(id) === undefined) {
130
+ return;
131
+ }
132
+ sigs.clear(id);
133
+ order.value = order.peek().filter((x) => x !== id);
134
+ }
135
+
136
+ function clear(): void {
137
+ sigs.clearAll();
138
+ order.value = [];
139
+ }
140
+
141
+ function get(id: string): T | undefined {
142
+ return sigs.get(id)?.peek();
143
+ }
144
+
145
+ function has(id: string): boolean {
146
+ return sigs.get(id) !== undefined;
147
+ }
148
+
149
+ function signalFor(id: string): ReadonlySignal<T> | undefined {
150
+ return sigs.get(id);
151
+ }
152
+
153
+ function items(): T[] {
154
+ const result: T[] = [];
155
+ for (const id of order.value) {
156
+ const s = sigs.get(id);
157
+ if (s !== undefined) {
158
+ result.push(s.value);
159
+ }
160
+ }
161
+ return result;
162
+ }
163
+
164
+ return {
165
+ setAll,
166
+ upsert,
167
+ prepend,
168
+ update,
169
+ remove,
170
+ clear,
171
+ get,
172
+ has,
173
+ signalFor,
174
+ items,
175
+ get ids(): ReadonlySignal<readonly string[]> {
176
+ return order;
177
+ },
178
+ get size(): number {
179
+ return order.peek().length;
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,34 @@
1
+ /** Signals, computed values, effects, owners, resources, stores, collections and the event bus. @module */
2
+
3
+ export {
4
+ signal,
5
+ effect,
6
+ batch,
7
+ setEffectErrorHandler,
8
+ computed,
9
+ untracked,
10
+ touch,
11
+ subscribe,
12
+ isSignal,
13
+ isComputed,
14
+ on,
15
+ createRoot,
16
+ runWithOwner,
17
+ } from "./signal.js";
18
+ export type {
19
+ Signal,
20
+ Cleanup,
21
+ EffectErrorHandler,
22
+ Owner,
23
+ ReadonlySignal,
24
+ SignalOptions,
25
+ } from "./signal.js";
26
+ export { resource } from "./resource.js";
27
+ export type { Loadable, Resource, ResourceOptions } from "./resource.js";
28
+ export { createStore } from "./store.js";
29
+ export type { Store } from "./store.js";
30
+ export { SignalMap } from "./signal-map.js";
31
+ export { createCollection } from "./collection.js";
32
+ export type { Collection } from "./collection.js";
33
+ export { createBus } from "./bus.js";
34
+ export type { Bus, BusHandler } from "./bus.js";
@@ -0,0 +1,102 @@
1
+ import {
2
+ createRoot,
3
+ effect,
4
+ requireOwner,
5
+ runWithOwner,
6
+ signal,
7
+ type ReadonlySignal,
8
+ } from "./signal.js";
9
+
10
+ /** The state of an async value: loading, ready or failed. `stale` is the last
11
+ * ready value, kept while a later run loads or after it fails. */
12
+ export type Loadable<T, E = unknown> =
13
+ | { readonly state: "loading"; readonly stale?: T }
14
+ | { readonly state: "ready"; readonly data: T }
15
+ | { readonly state: "error"; readonly error: E; readonly stale?: T };
16
+
17
+ /** A signal of the latest run's `Loadable`, with `refetch` to start a new run. */
18
+ export interface Resource<T, E = unknown> extends ReadonlySignal<Loadable<T, E>> {
19
+ /** Start a new run for the current source value, superseding the running
20
+ * one. Does nothing once the resource's owner is being disposed. */
21
+ refetch(): void;
22
+ }
23
+
24
+ /** Options for `resource`. */
25
+ export interface ResourceOptions<S> {
26
+ /** Source equality; an equal new source value starts no run. Defaults to
27
+ * `Object.is`. */
28
+ readonly equals?: (a: S, b: S) => boolean;
29
+ }
30
+
31
+ /** Load `load(source(), signal)` now and again whenever the tracked `source`
32
+ * changes, owned by the current owner. Each run has its own owner, whose
33
+ * `signal` is the one passed to `load` and which is current while `load`
34
+ * runs: a new source value, `refetch()` or the resource's disposal disposes
35
+ * the previous run, and a disposed run's settlement writes nothing. `E` is
36
+ * whatever `load` throws or rejects with. A throw from `source` goes to the
37
+ * effect error handler and starts no run. Throws outside an owner, or under
38
+ * one being disposed. */
39
+ export function resource<S, T, E = unknown>(
40
+ source: () => S,
41
+ load: (source: S, signal: AbortSignal) => Promise<T>,
42
+ options?: ResourceOptions<S>,
43
+ ): Resource<T, E> {
44
+ const owner = requireOwner("a resource");
45
+ const equals = options?.equals ?? Object.is;
46
+ const state = signal<Loadable<T, E>>({ state: "loading" });
47
+ let good: { readonly data: T } | undefined;
48
+ let current: { readonly value: S } | undefined;
49
+ let disposeRun: (() => void) | undefined;
50
+
51
+ const start = (value: S): void => {
52
+ runWithOwner(owner, () => {
53
+ disposeRun?.();
54
+ createRoot((dispose, run) => {
55
+ disposeRun = dispose;
56
+ state.value =
57
+ good === undefined ? { state: "loading" } : { state: "loading", stale: good.data };
58
+ // The async wrapper turns a synchronous throw from `load` into a rejection.
59
+ const pending = (async (): Promise<T> => load(value, run.signal))();
60
+ pending.then(
61
+ (data) => {
62
+ if (!run.signal.aborted) {
63
+ good = { data };
64
+ state.value = { state: "ready", data };
65
+ }
66
+ },
67
+ (error: unknown) => {
68
+ if (!run.signal.aborted) {
69
+ state.value =
70
+ good === undefined
71
+ ? { state: "error", error: error as E }
72
+ : { state: "error", error: error as E, stale: good.data };
73
+ }
74
+ },
75
+ );
76
+ });
77
+ });
78
+ };
79
+
80
+ effect(() => {
81
+ const value = source();
82
+ const prev = current;
83
+ current = { value };
84
+ if (prev === undefined || !equals(prev.value, value)) {
85
+ start(value);
86
+ }
87
+ });
88
+
89
+ return {
90
+ get value(): Loadable<T, E> {
91
+ return state.value;
92
+ },
93
+ peek(): Loadable<T, E> {
94
+ return state.peek();
95
+ },
96
+ refetch(): void {
97
+ if (current !== undefined) {
98
+ start(current.value);
99
+ }
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,46 @@
1
+ // SignalMap — a registry of lazily-created signals keyed by string ID.
2
+ //
3
+ // For dynamic, per-entity reactive state where the key set isn't known ahead
4
+ // of time (per-message streaming text, per-tool-call, per-row state, …). This
5
+ // complements `createStore`, whose typed key set is fixed at the type level.
6
+ // Like everything else here it's a facade over `signal` — no separate engine.
7
+
8
+ import { signal, type Signal } from "./signal.js";
9
+
10
+ /** A typed map of lazily-created signals keyed by string ID. Provides
11
+ * get / ensure / clear / clearAll over any value type. */
12
+ export class SignalMap<V> {
13
+ private readonly map = new Map<string, Signal<V>>();
14
+ private readonly equals: ((a: V, b: V) => boolean) | undefined;
15
+
16
+ /** `equals` decides when a write to one of the map's signals is a change;
17
+ * `Object.is` when omitted. */
18
+ constructor(options?: { readonly equals?: (a: V, b: V) => boolean }) {
19
+ this.equals = options?.equals;
20
+ }
21
+
22
+ /** The signal for `id`, or undefined if none has been created yet. */
23
+ get(id: string): Signal<V> | undefined {
24
+ return this.map.get(id);
25
+ }
26
+
27
+ /** The signal for `id`, creating it with `initial` on first access. */
28
+ ensure(id: string, initial: V): Signal<V> {
29
+ let sig = this.map.get(id);
30
+ if (sig === undefined) {
31
+ sig = this.equals === undefined ? signal(initial) : signal(initial, { equals: this.equals });
32
+ this.map.set(id, sig);
33
+ }
34
+ return sig;
35
+ }
36
+
37
+ /** Drop the signal for `id` (e.g. when the entity is removed). */
38
+ clear(id: string): void {
39
+ this.map.delete(id);
40
+ }
41
+
42
+ /** Drop every signal — e.g. on a wholesale context switch. */
43
+ clearAll(): void {
44
+ this.map.clear();
45
+ }
46
+ }