@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,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
|
+
}
|