@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,249 @@
|
|
|
1
|
+
/** Lifecycle status of a single dispatched action instance.
|
|
2
|
+
*
|
|
3
|
+
* - `"pending"` — optimistic ran (if any), run() in flight
|
|
4
|
+
* - `"success"` — run() resolved
|
|
5
|
+
* - `"error"` — run() threw; rollback ran
|
|
6
|
+
* - `"cancelled"` — action.cancel() called or signal aborted externally */
|
|
7
|
+
export type ActionLifecycleStatus = "pending" | "success" | "error" | "cancelled";
|
|
8
|
+
|
|
9
|
+
/** Errors thrown by an action's run() function. ActionError subclass
|
|
10
|
+
* in error.ts attaches HTTP status + server error code metadata. */
|
|
11
|
+
export interface ActionErrorLike {
|
|
12
|
+
readonly message: string;
|
|
13
|
+
/** HTTP status if applicable. */
|
|
14
|
+
readonly status?: number;
|
|
15
|
+
/** Server-side error code. */
|
|
16
|
+
readonly code?: string;
|
|
17
|
+
readonly cause?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @internal The lifecycle fields every action-log record carries, sensitive
|
|
21
|
+
* or not. Each is a number or a string the framework sets, so none can hold
|
|
22
|
+
* a value from args, result or run(); a payload-bearing field belongs on the
|
|
23
|
+
* non-sensitive arm of {@link ActionInstance}. */
|
|
24
|
+
export interface ActionInstanceFields {
|
|
25
|
+
/** Unique per dispatch. */
|
|
26
|
+
readonly id: string;
|
|
27
|
+
/** Matches ActionDefinition.name. */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly status: ActionLifecycleStatus;
|
|
30
|
+
/** Date.now() when dispatch() was called. */
|
|
31
|
+
readonly dispatchedAt: number;
|
|
32
|
+
/** Date.now() when run() begins (after scope queue). */
|
|
33
|
+
readonly startedAt: number;
|
|
34
|
+
/** Date.now() at terminal state. */
|
|
35
|
+
readonly completedAt?: number;
|
|
36
|
+
/** Total run() invocations (1 = no retry; >1 = retries fired). */
|
|
37
|
+
readonly attempts?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Snapshot of a single in-flight or historical action invocation, stored in
|
|
41
|
+
* the registry log. A `sensitive` definition's records carry no `args`,
|
|
42
|
+
* `result` or `error`; narrow on `sensitive` before reading `args`. */
|
|
43
|
+
export type ActionInstance<TArgs = unknown, TResult = unknown> =
|
|
44
|
+
| (ActionInstanceFields & {
|
|
45
|
+
readonly sensitive?: never;
|
|
46
|
+
readonly args: TArgs;
|
|
47
|
+
/** Present iff status === "success". */
|
|
48
|
+
readonly result?: TResult;
|
|
49
|
+
/** Present iff status === "error". */
|
|
50
|
+
readonly error?: ActionErrorLike;
|
|
51
|
+
})
|
|
52
|
+
| (ActionInstanceFields & {
|
|
53
|
+
readonly sensitive: true;
|
|
54
|
+
readonly args?: never;
|
|
55
|
+
readonly result?: never;
|
|
56
|
+
readonly error?: never;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Notification wiring: either a literal string (used as-is), or a function
|
|
60
|
+
* computed from action args + result/error at call time. Pass false
|
|
61
|
+
* to opt out of the default notification for that branch. */
|
|
62
|
+
export type NotificationSpec<TArgs, TPayload> =
|
|
63
|
+
string | ((args: TArgs, payload: TPayload) => string) | false;
|
|
64
|
+
|
|
65
|
+
/** Per-dispatch context passed to run() as the 3rd argument. Mostly
|
|
66
|
+
* populated by the framework so adapters (apiAction, transportAction)
|
|
67
|
+
* can read out values like the idempotency key without the caller
|
|
68
|
+
* having to plumb them. */
|
|
69
|
+
export interface ActionContext {
|
|
70
|
+
/** Stable identifier for this dispatch (matches the registry's
|
|
71
|
+
* ActionInstance.id). */
|
|
72
|
+
readonly instanceID: string;
|
|
73
|
+
/** Set when ActionDefinition.idempotencyKey is configured. The
|
|
74
|
+
* framework generates this once per dispatch (not per retry). */
|
|
75
|
+
readonly idempotencyKey?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Configuration for automatic retry of transient failures. */
|
|
79
|
+
export interface RetryConfig {
|
|
80
|
+
/** Additional attempts beyond the first (e.g. 2 = up to 3 total). */
|
|
81
|
+
readonly count: number;
|
|
82
|
+
/** Milliseconds before each retry. Number form: exponential backoff via
|
|
83
|
+
* `delay × factor^(attempt-1)`, capped at 5s. Function form: full
|
|
84
|
+
* control — receives the attempt number and the triggering error. */
|
|
85
|
+
readonly delay: number | ((attempt: number, err: ActionErrorLike) => number);
|
|
86
|
+
/** Backoff multiplier per retry. Default 2. Ignored when `delay` is a function. */
|
|
87
|
+
readonly factor?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Standard retry config: 2 retries, 300ms initial delay. */
|
|
91
|
+
export const RETRY_STANDARD: RetryConfig = { count: 2, delay: 300 } as const;
|
|
92
|
+
|
|
93
|
+
/** Declarative description of an action, consulted on every dispatch. Only
|
|
94
|
+
* `name` and `run` are required; function-valued members are re-evaluated
|
|
95
|
+
* per dispatch. A throw from a callback, `rollback` or a notification spec
|
|
96
|
+
* is logged and leaves the outcome unchanged; a throw from `optimistic`
|
|
97
|
+
* fails the dispatch before run(), with code `"optimistic_failed"` unless
|
|
98
|
+
* the error carries its own. */
|
|
99
|
+
export interface ActionDefinition<TArgs, TResult, TOp = unknown> {
|
|
100
|
+
/** Stable identifier, e.g. "chat.delete", "files.create".
|
|
101
|
+
* Used in the registry log + as a default notification prefix. */
|
|
102
|
+
readonly name: string;
|
|
103
|
+
|
|
104
|
+
/** The work the action performs. Must throw ActionError on failure
|
|
105
|
+
* (or any Error — wrappers will normalise). */
|
|
106
|
+
run: (args: TArgs, signal: AbortSignal, ctx?: ActionContext) => Promise<TResult>;
|
|
107
|
+
|
|
108
|
+
/** Optional optimistic mutation. Runs synchronously before run(). */
|
|
109
|
+
optimistic?: (args: TArgs) => TOp | undefined;
|
|
110
|
+
|
|
111
|
+
/** Undo the optimistic mutation. Called on error OR cancellation (including a
|
|
112
|
+
* cancellation that lands after run() resolves), never on success. The `err`
|
|
113
|
+
* carries {code:'cancelled'} on the cancellation path. */
|
|
114
|
+
rollback?: (args: TArgs, op: TOp | undefined, err: ActionErrorLike) => void;
|
|
115
|
+
|
|
116
|
+
/** Notification on success. Default: no notification. */
|
|
117
|
+
success?: NotificationSpec<TArgs, TResult>;
|
|
118
|
+
|
|
119
|
+
/** Notification on error. Default: action name humanised + error message. */
|
|
120
|
+
error?: NotificationSpec<TArgs, ActionErrorLike>;
|
|
121
|
+
|
|
122
|
+
/** Definition-level success callback. Fires on every successful dispatch.
|
|
123
|
+
* Mirrors TanStack Query's mutation-level onSuccess. */
|
|
124
|
+
onSuccess?: (result: TResult, args: TArgs) => void;
|
|
125
|
+
|
|
126
|
+
/** Definition-level error callback. Fires on every failed dispatch.
|
|
127
|
+
* Mirrors TanStack Query's mutation-level onError. */
|
|
128
|
+
onError?: (err: ActionErrorLike, args: TArgs) => void;
|
|
129
|
+
|
|
130
|
+
/** Definition-level settled callback. Fires after every dispatch
|
|
131
|
+
* (success, error, or cancellation). Mirrors TanStack Query's onSettled. */
|
|
132
|
+
onSettled?: (args: TArgs) => void;
|
|
133
|
+
|
|
134
|
+
/** Classify whether an error qualifies for retry. */
|
|
135
|
+
retryable?: (err: ActionErrorLike) => boolean;
|
|
136
|
+
|
|
137
|
+
/** Auto-retry transient failures before surfacing the error notification. */
|
|
138
|
+
retry?: RetryConfig;
|
|
139
|
+
|
|
140
|
+
/** Auto-retry behavior when offline. Default `"online"`. */
|
|
141
|
+
networkMode?: "online" | "always";
|
|
142
|
+
|
|
143
|
+
/** Timeout in milliseconds for the run() function. Uses AbortSignal.timeout()
|
|
144
|
+
* composed with the cancellation signal. If run() exceeds this duration,
|
|
145
|
+
* the signal aborts with a TimeoutError. */
|
|
146
|
+
timeout?: number;
|
|
147
|
+
|
|
148
|
+
/** Serialize concurrent dispatches sharing the same scope key. */
|
|
149
|
+
scope?: string | ((args: TArgs) => string);
|
|
150
|
+
|
|
151
|
+
/** Generate an idempotency key per dispatch. */
|
|
152
|
+
idempotencyKey?: boolean | ((args: TArgs) => string);
|
|
153
|
+
|
|
154
|
+
/** Collapse concurrent dispatches with matching key into one in-flight promise. */
|
|
155
|
+
dedupe?: boolean | ((args: TArgs) => string);
|
|
156
|
+
|
|
157
|
+
/** For a call carrying a plaintext secret: the action log and every
|
|
158
|
+
* registry listener get each instance without `args`, `result` and
|
|
159
|
+
* `error`, so the result and the error reach only the dispatch's handle,
|
|
160
|
+
* the callbacks and the notifications (the default and string error
|
|
161
|
+
* notifications quote the error's message). Combined with `retry` or
|
|
162
|
+
* `retryable`, defineAction() throws: a retry button would hold the args. */
|
|
163
|
+
sensitive?: true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A registered action, returned by defineAction(). Can be dispatched
|
|
167
|
+
* many times; each dispatch is a separate instance with its own
|
|
168
|
+
* cancellation token. */
|
|
169
|
+
export interface Action<TArgs, TResult> {
|
|
170
|
+
readonly name: string;
|
|
171
|
+
|
|
172
|
+
/** Run the action. Returns a DispatchHandle with the result promise
|
|
173
|
+
* and a per-dispatch abort() method (RTK pattern). */
|
|
174
|
+
dispatch(args: TArgs, opts?: DispatchOptions<TArgs, TResult>): DispatchHandle<TResult>;
|
|
175
|
+
|
|
176
|
+
/** Cancel all in-flight instances. Each instance moves to status
|
|
177
|
+
* "cancelled" and run()'s signal aborts. */
|
|
178
|
+
cancel(): void;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Typed terminal outcome of a single dispatch, resolved by
|
|
182
|
+
* {@link DispatchHandle.outcome}. Distinguishes the three states the
|
|
183
|
+
* handle's own `TResult | null` resolution collapses: success (including a
|
|
184
|
+
* legitimate `null` / `undefined` result), failure (carrying the normalized
|
|
185
|
+
* error), and cancellation. */
|
|
186
|
+
export type ActionOutcome<TResult> =
|
|
187
|
+
| { readonly status: "success"; readonly value: TResult; readonly attempts?: number }
|
|
188
|
+
| { readonly status: "error"; readonly error: ActionErrorLike; readonly attempts?: number }
|
|
189
|
+
| { readonly status: "cancelled" };
|
|
190
|
+
|
|
191
|
+
/** Handle returned by dispatch(). An augmented Promise with an abort()
|
|
192
|
+
* method for per-dispatch cancellation (mirrors RTK createAsyncThunk).
|
|
193
|
+
* Can be awaited directly as a Promise. */
|
|
194
|
+
export interface DispatchHandle<TResult> extends Promise<TResult | null> {
|
|
195
|
+
/** Abort this specific dispatch; other in-flight dispatches of the same
|
|
196
|
+
* action are unaffected. One still queued behind its `scope` settles
|
|
197
|
+
* cancelled at once. On a handle that joined through `dedupe`, only this
|
|
198
|
+
* call settles cancelled: the dispatch it joined keeps running. */
|
|
199
|
+
abort(): void;
|
|
200
|
+
/** Opt-in typed terminal outcome. Resolves alongside the handle (it never
|
|
201
|
+
* rejects) with a discriminated {@link ActionOutcome}, letting a caller
|
|
202
|
+
* distinguish a legitimate `null` result from failure or cancellation
|
|
203
|
+
* without wiring callbacks. The never-rejecting `TResult | null`
|
|
204
|
+
* resolution and the callback tiers remain the canonical surface. */
|
|
205
|
+
readonly outcome: Promise<ActionOutcome<TResult>>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Per-dispatch overrides. */
|
|
209
|
+
export interface DispatchOptions<TArgs = unknown, TResult = unknown> {
|
|
210
|
+
/** Suppress the success notification for this call. Errors still notify. */
|
|
211
|
+
readonly silent?: boolean;
|
|
212
|
+
/** Per-call success callback. Fires after the action-level notification. */
|
|
213
|
+
readonly onSuccess?: (result: TResult, args: TArgs) => void;
|
|
214
|
+
/** Per-call error callback. Fires after the action-level error notification. */
|
|
215
|
+
readonly onError?: (err: ActionErrorLike, args: TArgs) => void;
|
|
216
|
+
/** Per-call settled callback. Fires for success, error, AND cancellation. */
|
|
217
|
+
readonly onSettled?: (args: TArgs) => void;
|
|
218
|
+
/** Aborting it cancels this dispatch as `abort()` does: before it starts
|
|
219
|
+
* (a dispatch queued behind its `scope` settles at once and never runs),
|
|
220
|
+
* or mid-run, aborting run()'s signal with this signal's reason. An
|
|
221
|
+
* already-aborted signal cancels the dispatch without running it. */
|
|
222
|
+
readonly signal?: AbortSignal;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** HTTP request descriptor used by apiAction(). GET is included for
|
|
226
|
+
* read actions that want notification/cancellation semantics. */
|
|
227
|
+
export type RequestSpec =
|
|
228
|
+
| {
|
|
229
|
+
readonly method: "GET";
|
|
230
|
+
readonly path: string;
|
|
231
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
232
|
+
}
|
|
233
|
+
| {
|
|
234
|
+
readonly method: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
235
|
+
readonly path: string;
|
|
236
|
+
/** JSON-encoded request body. Mutually exclusive with `rawBody`. */
|
|
237
|
+
readonly body?: unknown;
|
|
238
|
+
/** Pre-encoded request body, sent as-is with NO JSON encoding and NO
|
|
239
|
+
* automatic Content-Type — set the type via `headers` (e.g. a YAML
|
|
240
|
+
* document with `Content-Type: text/yaml`). The request() function is
|
|
241
|
+
* the encoder seam: compute the encoded payload there per dispatch.
|
|
242
|
+
* Mutually exclusive with `body`. */
|
|
243
|
+
readonly rawBody?: BodyInit;
|
|
244
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** Subscriber callback for the registry. Fires once per state
|
|
248
|
+
* transition (pending -> success/error/cancelled). */
|
|
249
|
+
export type RegistryListener = (instance: ActionInstance) => void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Two-tier render binding for a reactive list source. One structural effect
|
|
2
|
+
// tracks `source.ids` and reconciles the id list; each row owns an effect that
|
|
3
|
+
// tracks only its entity signal, so an entity update repaints just that row.
|
|
4
|
+
// The engine saves and restores evalContext around nested effects, so a row
|
|
5
|
+
// effect's reads never leak into the structural effect. A `Collection<T>` is a
|
|
6
|
+
// `ListSource<T>`; a filtered, sorted or paginated view is a `computed` id
|
|
7
|
+
// list paired with the collection's `signalFor`.
|
|
8
|
+
|
|
9
|
+
import { reconcile } from "./reconcile.js";
|
|
10
|
+
import {
|
|
11
|
+
createRoot,
|
|
12
|
+
createRootUnder,
|
|
13
|
+
effect,
|
|
14
|
+
type Owner,
|
|
15
|
+
type ReadonlySignal,
|
|
16
|
+
} from "../signal/signal.js";
|
|
17
|
+
|
|
18
|
+
/** The minimal reactive surface `bindList` renders: a structure signal (the
|
|
19
|
+
* ordered ids) and a per-id content-signal lookup. `Collection<T>` satisfies
|
|
20
|
+
* this; so does `{ ids: computed(...), signalFor: collection.signalFor }`.
|
|
21
|
+
* An id with no matching signal (an inconsistent source) is skipped — no row
|
|
22
|
+
* is rendered for it until the source becomes consistent. */
|
|
23
|
+
export interface ListSource<T> {
|
|
24
|
+
readonly ids: ReadonlySignal<readonly string[]>;
|
|
25
|
+
signalFor(id: string): ReadonlySignal<T> | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Row lifecycle for `bindList`. Each row has its own owner, so effects and
|
|
29
|
+
* roots created in `mount` and `update` are disposed when the row leaves. */
|
|
30
|
+
export interface ListSpec<T> {
|
|
31
|
+
/** Create the row element for a new entity. Receives the entity's current
|
|
32
|
+
* value, its id and the row's owner, whose `signal` aborts when the row
|
|
33
|
+
* leaves or the list is disposed. Keep this to structural shell creation;
|
|
34
|
+
* let `update` fill reactive content (it runs once at mount and on every
|
|
35
|
+
* later change). */
|
|
36
|
+
mount: (item: T, id: string, owner: Owner) => HTMLElement;
|
|
37
|
+
/** Update a row when its entity changes. Runs immediately at mount and again
|
|
38
|
+
* on every per-entity change (the content tier). Omit for immutable rows
|
|
39
|
+
* whose `mount` renders everything. */
|
|
40
|
+
update?: (el: HTMLElement, item: T, id: string) => void;
|
|
41
|
+
/** Optional cleanup when a row leaves the list, after its owner is disposed
|
|
42
|
+
* and before the element is removed from the DOM. */
|
|
43
|
+
onRemove?: (el: HTMLElement, id: string) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Bind a list source to a parent node with two-tier reactivity, in a root
|
|
47
|
+
* owned by the current owner when there is one. Returns a dispose function
|
|
48
|
+
* that tears down the structural effect and every row. */
|
|
49
|
+
export function bindList<T>(
|
|
50
|
+
parent: ParentNode,
|
|
51
|
+
source: ListSource<T>,
|
|
52
|
+
spec: ListSpec<T>,
|
|
53
|
+
): () => void {
|
|
54
|
+
return createRoot((dispose, listOwner) => {
|
|
55
|
+
const rowDisposers = new WeakMap<HTMLElement, () => void>();
|
|
56
|
+
|
|
57
|
+
effect(() => {
|
|
58
|
+
// Structure tier: re-runs on add/remove/reorder only. An id whose entity
|
|
59
|
+
// signal is missing (an inconsistent ListSource) is skipped instead of
|
|
60
|
+
// mounting a row with a manufactured `undefined as T` value; it renders
|
|
61
|
+
// once the source becomes consistent (the next `ids` change).
|
|
62
|
+
const ids = source.ids.value.filter((id) => source.signalFor(id) !== undefined);
|
|
63
|
+
reconcile<string>(parent, ids, {
|
|
64
|
+
key: (id) => id,
|
|
65
|
+
mount: (id) => {
|
|
66
|
+
const sig = source.signalFor(id);
|
|
67
|
+
if (sig === undefined) {
|
|
68
|
+
// Unreachable via the structural filter above (same synchronous
|
|
69
|
+
// pass); reachable only by a source whose signalFor is not stable
|
|
70
|
+
// within one read — surface that loudly instead of manufacturing
|
|
71
|
+
// an entity value. Routed to the effect error handler.
|
|
72
|
+
throw new Error(`bindList: no entity signal for id "${id}"`);
|
|
73
|
+
}
|
|
74
|
+
// A row belongs to the list, not to this structural run, whose
|
|
75
|
+
// scope is disposed on every add, remove or reorder.
|
|
76
|
+
return createRootUnder(listOwner, (disposeRow, rowOwner) => {
|
|
77
|
+
const el = spec.mount(sig.peek(), id, rowOwner);
|
|
78
|
+
rowDisposers.set(el, disposeRow);
|
|
79
|
+
if (spec.update !== undefined) {
|
|
80
|
+
const update = spec.update;
|
|
81
|
+
// content tier: isolated per-row effect; runs now (fills content)
|
|
82
|
+
// and on every later change to THIS entity.
|
|
83
|
+
effect(() => {
|
|
84
|
+
const cur = source.signalFor(id);
|
|
85
|
+
if (cur !== undefined) {
|
|
86
|
+
update(el, cur.value, id);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return el;
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
// No structural `update`: content is owned entirely by the per-row effect.
|
|
94
|
+
onRemove: (el, id) => {
|
|
95
|
+
rowDisposers.get(el)?.();
|
|
96
|
+
spec.onRemove?.(el, id);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return dispose;
|
|
102
|
+
});
|
|
103
|
+
}
|
package/src/dom/el.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// el.ts — CSP-safe DOM element factory. The build half of the DOM layer; pairs
|
|
2
|
+
// with reconcile/patch (the commit half). `on*` handlers are assigned as
|
|
3
|
+
// properties AND registered via trackHandler, so patch() reconciles them across
|
|
4
|
+
// re-renders. No innerHTML, no string templating — safe under a strict CSP.
|
|
5
|
+
|
|
6
|
+
import { trackHandler } from "./reconcile-tree.js";
|
|
7
|
+
|
|
8
|
+
/** A value for an element attribute/property/handler. Functions are treated as
|
|
9
|
+
* event handlers when the key starts with `on`. */
|
|
10
|
+
export type AttrValue =
|
|
11
|
+
string | number | boolean | null | undefined | ((...args: never[]) => unknown);
|
|
12
|
+
|
|
13
|
+
// Keys set as DOM *properties* (not attributes) because the attribute form
|
|
14
|
+
// either doesn't reflect (value/checked) or is awkward (boolean presence).
|
|
15
|
+
const BOOL_PROPS = new Set([
|
|
16
|
+
"hidden",
|
|
17
|
+
"disabled",
|
|
18
|
+
"checked",
|
|
19
|
+
"selected",
|
|
20
|
+
"multiple",
|
|
21
|
+
"readOnly",
|
|
22
|
+
"required",
|
|
23
|
+
"open",
|
|
24
|
+
"default",
|
|
25
|
+
]);
|
|
26
|
+
const VALUE_PROPS = new Set(["value", "colSpan", "rowSpan", "tabIndex", "htmlFor"]);
|
|
27
|
+
|
|
28
|
+
/** Create an element: `el("button", { className: "x", onclick: fn }, "Label")`.
|
|
29
|
+
* - `className` → the class property
|
|
30
|
+
* - `on*` → handler property + `trackHandler` (reconciled by patch)
|
|
31
|
+
* - boolean DOM props (hidden/disabled/checked/selected/…) → property
|
|
32
|
+
* - value/colSpan/rowSpan/tabIndex/htmlFor → property
|
|
33
|
+
* - everything else → `setAttribute` (incl. `data-*`, `aria-*`, `style`, `id`)
|
|
34
|
+
* Null/undefined attrs and children are skipped. String children become text
|
|
35
|
+
* nodes (never parsed as HTML). */
|
|
36
|
+
export function el(
|
|
37
|
+
tag: string,
|
|
38
|
+
attrs?: Record<string, AttrValue> | null,
|
|
39
|
+
...children: (string | Node | null | undefined)[]
|
|
40
|
+
): HTMLElement {
|
|
41
|
+
const e = document.createElement(tag);
|
|
42
|
+
if (attrs) {
|
|
43
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
44
|
+
if (v === null || v === undefined) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (k === "className") {
|
|
48
|
+
e.className = v as string;
|
|
49
|
+
} else if (/^on/i.test(k)) {
|
|
50
|
+
(e as unknown as Record<string, unknown>)[k] = v;
|
|
51
|
+
trackHandler(e, k);
|
|
52
|
+
} else if (BOOL_PROPS.has(k) || VALUE_PROPS.has(k)) {
|
|
53
|
+
// Both sets are assigned as DOM properties, not attributes.
|
|
54
|
+
(e as unknown as Record<string, unknown>)[k] = v;
|
|
55
|
+
} else {
|
|
56
|
+
e.setAttribute(k, String(v));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const child of children) {
|
|
61
|
+
if (child === null || child === undefined) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
e.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
|
65
|
+
}
|
|
66
|
+
return e;
|
|
67
|
+
}
|
package/src/dom/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Build and reconcile DOM: `el`, `patch`, `reconcile` and `bindList`. @module */
|
|
2
|
+
|
|
3
|
+
export { bindList } from "./bind-list.js";
|
|
4
|
+
export type { ListSpec, ListSource } from "./bind-list.js";
|
|
5
|
+
export { el } from "./el.js";
|
|
6
|
+
export type { AttrValue } from "./el.js";
|
|
7
|
+
export { reconcile, KEY_ATTR } from "./reconcile.js";
|
|
8
|
+
export type { ReconcileSpec } from "./reconcile.js";
|
|
9
|
+
export { patch, trackHandler } from "./reconcile-tree.js";
|