@cplieger/ui 0.0.0 → 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 (66) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +145 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/css/ui-primitives.css +344 -0
  5. package/jsr.json +36 -4
  6. package/package.json +86 -4
  7. package/src/actions/api.ts +268 -0
  8. package/src/actions/async-feedback.ts +217 -0
  9. package/src/actions/cleanup.ts +66 -0
  10. package/src/actions/debounce.ts +150 -0
  11. package/src/actions/define-helpers.ts +87 -0
  12. package/src/actions/define.ts +851 -0
  13. package/src/actions/error.ts +161 -0
  14. package/src/actions/index.ts +60 -0
  15. package/src/actions/loading.ts +131 -0
  16. package/src/actions/notifier.ts +61 -0
  17. package/src/actions/poll-until.ts +133 -0
  18. package/src/actions/poll.ts +156 -0
  19. package/src/actions/registry.ts +259 -0
  20. package/src/actions/retry.ts +79 -0
  21. package/src/actions/testing.ts +29 -0
  22. package/src/actions/transport.ts +104 -0
  23. package/src/actions/types.ts +249 -0
  24. package/src/dom/bind-list.ts +103 -0
  25. package/src/dom/el.ts +67 -0
  26. package/src/dom/index.ts +9 -0
  27. package/src/dom/reconcile-tree.ts +268 -0
  28. package/src/dom/reconcile.ts +76 -0
  29. package/src/fetch/index.ts +18 -0
  30. package/src/fetch/instance.ts +37 -0
  31. package/src/fetch/request.ts +437 -0
  32. package/src/fetch/timeout.ts +67 -0
  33. package/src/fetch/types.ts +206 -0
  34. package/src/fetch/verbs.ts +103 -0
  35. package/src/index.ts +4 -8
  36. package/src/primitives/announce.ts +134 -0
  37. package/src/primitives/ask.ts +406 -0
  38. package/src/primitives/dialog-engine.ts +500 -0
  39. package/src/primitives/dialog.ts +263 -0
  40. package/src/primitives/disclosure.ts +287 -0
  41. package/src/primitives/focus-trap.ts +157 -0
  42. package/src/primitives/fx.ts +204 -0
  43. package/src/primitives/live-regions.ts +83 -0
  44. package/src/primitives/modal-host.ts +214 -0
  45. package/src/primitives/modal.ts +247 -0
  46. package/src/primitives/owner.ts +322 -0
  47. package/src/primitives/popover.ts +500 -0
  48. package/src/primitives/popup-core.ts +603 -0
  49. package/src/primitives/popup.ts +23 -0
  50. package/src/primitives/roving-focus.ts +198 -0
  51. package/src/primitives/scroll-lock.ts +119 -0
  52. package/src/primitives/skeleton.ts +142 -0
  53. package/src/primitives/theme.ts +202 -0
  54. package/src/primitives/toast/engine.ts +327 -0
  55. package/src/primitives/toast/index.ts +139 -0
  56. package/src/primitives/toast/view.ts +244 -0
  57. package/src/primitives/tooltip.ts +377 -0
  58. package/src/primitives/transition.ts +119 -0
  59. package/src/primitives/view-transition.ts +57 -0
  60. package/src/signal/bus.ts +126 -0
  61. package/src/signal/collection.ts +182 -0
  62. package/src/signal/index.ts +34 -0
  63. package/src/signal/resource.ts +102 -0
  64. package/src/signal/signal-map.ts +46 -0
  65. package/src/signal/signal.ts +1002 -0
  66. package/src/signal/store.ts +84 -0
@@ -0,0 +1,268 @@
1
+ // Structural tree-diff: reconcile a parent's children against new nodes.
2
+ //
3
+ // A child the parent HAS corresponds to a child the caller ASKED for by, in
4
+ // order: the same node; the same key (a `*-id` attribute over `data-col`,
5
+ // same-key siblings pairing in document order); the next unclaimed unkeyed
6
+ // child. Each child is claimed at most once, so the tiers cannot disagree.
7
+
8
+ const handlerKeysMap = new WeakMap<HTMLElement, Set<string>>();
9
+
10
+ /** Register an on* handler property key for reconciliation tracking. */
11
+ export function trackHandler(el: HTMLElement, key: string): void {
12
+ let keys = handlerKeysMap.get(el);
13
+ if (!keys) {
14
+ keys = new Set();
15
+ handlerKeysMap.set(el, keys);
16
+ }
17
+ keys.add(key);
18
+ }
19
+
20
+ /** Replace a parent's children with the given nodes or strings, reusing each
21
+ * existing child that corresponds to a requested one, so focus, selection,
22
+ * scroll and live input state survive. Strings become text nodes, a
23
+ * `DocumentFragment` contributes its children, `null`/`undefined` are skipped.
24
+ * A node already in `parent` is moved there unmodified; any other node is a
25
+ * template whose content may be copied into a reused child, and is never
26
+ * inserted itself. */
27
+ export function patch(
28
+ parent: Node,
29
+ ...children: (string | Node | DocumentFragment | null | undefined)[]
30
+ ): void {
31
+ const newChildren: Node[] = [];
32
+ for (const child of children) {
33
+ if (child == null) {
34
+ continue;
35
+ }
36
+ if (typeof child === "string") {
37
+ newChildren.push(document.createTextNode(child));
38
+ } else if (child.nodeType === 11) {
39
+ newChildren.push(...Array.from(child.childNodes));
40
+ } else {
41
+ newChildren.push(child);
42
+ }
43
+ }
44
+ reconcileChildren(parent, newChildren);
45
+ }
46
+
47
+ /** Reconcile a parent node's children against a new set of child nodes, patching in place.
48
+ *
49
+ * @internal Not on the package's `exports` map. `patch(parent, ...nodes)` is
50
+ * the same call for a `Node[]`, with string, fragment and nullish handling on
51
+ * top.
52
+ */
53
+ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
54
+ // A node the caller NAMED is reserved for itself. This is the one fact that
55
+ // keeps the three tiers disjoint: a node already in this parent is seated as
56
+ // itself (tier 1), so tiers 2 and 3 must never hand it to a different child.
57
+ // Without the reservation a node can be claimed twice, and the second claim
58
+ // either removes it as a tag mismatch or patches another child's content into
59
+ // it — which also means reading out of a live sibling this loop has already
60
+ // mutated.
61
+ const named = new Set<Node>(newChildren);
62
+
63
+ // Partition the current children ONCE, before any mutation, by the tier
64
+ // allowed to claim them: a keyed child only ever through its key's FIFO queue,
65
+ // an unkeyed one only ever through the positional cursor. Nothing is in both,
66
+ // so tiers 2 and 3 cannot collide, and neither can yield a node twice — a
67
+ // queue consumes by shift, the cursor only moves forward.
68
+ const byKey = new Map<string, Node[]>();
69
+ const positional: Node[] = [];
70
+ for (let node = parent.firstChild; node !== null; node = node.nextSibling) {
71
+ const key = nodeKey(node);
72
+ if (key) {
73
+ const queue = byKey.get(key);
74
+ if (queue === undefined) {
75
+ byKey.set(key, [node]);
76
+ } else {
77
+ queue.push(node);
78
+ }
79
+ } else {
80
+ positional.push(node);
81
+ }
82
+ }
83
+
84
+ // The node currently at the index being resolved. Every branch below seats the
85
+ // resolved child at exactly that index, so the children before it are final
86
+ // and the next reference node is the seated child's next sibling — O(1), where
87
+ // a childNodes.item(i) lookup per step is not. That invariant is also what
88
+ // makes the sweep at the end of the loop exact.
89
+ let ref: Node | null = parent.firstChild;
90
+ let cursor = 0;
91
+
92
+ for (const newChild of newChildren) {
93
+ // Tier 1 — the same NODE. A child the caller handed back needs no lookup and
94
+ // no reconciliation: it already IS the requested child, so it falls straight
95
+ // through to being seated as itself below. Short-circuiting the other two
96
+ // tiers is what stops a sibling being claimed as its host — with duplicate
97
+ // keys, the key queue would otherwise skip the reserved node, reach the next
98
+ // one, find it patchable on the shared tag, and let the sweep delete the node
99
+ // the caller actually named.
100
+ let matched: Node | null = null;
101
+ if (newChild.parentNode !== parent) {
102
+ const key = nodeKey(newChild);
103
+ if (key) {
104
+ // Tier 2 — the same KEY.
105
+ const queue = byKey.get(key);
106
+ while (queue !== undefined && queue.length > 0) {
107
+ const candidate = queue.shift();
108
+ if (candidate !== undefined && !named.has(candidate)) {
109
+ matched = candidate;
110
+ break;
111
+ }
112
+ }
113
+ } else {
114
+ // Tier 3 — the same POSITION.
115
+ while (cursor < positional.length) {
116
+ const candidate = positional[cursor++];
117
+ if (candidate !== undefined && !named.has(candidate)) {
118
+ matched = candidate;
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ if (matched !== null && canPatch(matched, newChild)) {
126
+ // An existing child hosts this one: seat it, then patch it to match. The
127
+ // requested node is a template and is not inserted.
128
+ //
129
+ // Re-inserting a node that is already in place still detaches and
130
+ // reattaches it, which blurs a focused input, so move only when the
131
+ // position actually differs.
132
+ if (ref !== matched) {
133
+ parent.insertBefore(matched, ref);
134
+ }
135
+ if (matched.nodeType === 3) {
136
+ if (matched.textContent !== newChild.textContent) {
137
+ matched.textContent = newChild.textContent;
138
+ }
139
+ } else if (matched.nodeType === 1) {
140
+ patchAttrs(matched as HTMLElement, newChild as HTMLElement);
141
+ reconcileChildren(matched, Array.from(newChild.childNodes));
142
+ }
143
+ ref = matched.nextSibling;
144
+ } else {
145
+ // Nothing to reconcile: either the caller handed back a node this parent
146
+ // already has, or nothing corresponds to it, or the tags disagree. Either
147
+ // way the requested node itself is seated here. A child that was matched
148
+ // but not patchable is NOT removed at this point — it now sits at or after
149
+ // `ref`, was consumed so no later index can claim it, and the sweep below
150
+ // takes it. One removal path, not two.
151
+ if (ref !== newChild) {
152
+ parent.insertBefore(newChild, ref);
153
+ }
154
+ ref = newChild.nextSibling;
155
+ }
156
+ }
157
+
158
+ // Everything from `ref` on is a child the caller did not ask for. The
159
+ // invariant above makes that exact where comparing counts cannot be: pass one
160
+ // node twice and the requested count exceeds what a tree can hold, leaving a
161
+ // stale sibling behind.
162
+ while (ref !== null) {
163
+ const next = ref.nextSibling;
164
+ parent.removeChild(ref);
165
+ ref = next;
166
+ }
167
+ }
168
+
169
+ function canPatch(oldNode: Node, newNode: Node): boolean {
170
+ if (oldNode.nodeType !== newNode.nodeType) {
171
+ return false;
172
+ }
173
+ if (oldNode.nodeType === 3) {
174
+ return true;
175
+ }
176
+ if (oldNode.nodeType !== 1) {
177
+ return false;
178
+ }
179
+ return oldNode.nodeName === newNode.nodeName;
180
+ }
181
+
182
+ function nodeKey(node: Node): string {
183
+ if (node.nodeType !== 1) {
184
+ return "";
185
+ }
186
+ // A specific entity id (`*-id`) wins over the generic `data-col` key. The
187
+ // `data-col` scan is last-wins: `setAttributeNS` with a foreign namespace can
188
+ // add a second attribute named "data-col", and first-wins would key the node
189
+ // off it, recreating the node instead of reusing it.
190
+ let colKey = "";
191
+ for (const attr of (node as Element).attributes) {
192
+ if (attr.name.endsWith("-id")) {
193
+ return `${attr.name}=${attr.value}`;
194
+ }
195
+ if (attr.name === "data-col") {
196
+ colKey = `${attr.name}=${attr.value}`;
197
+ }
198
+ }
199
+ return colKey;
200
+ }
201
+
202
+ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
203
+ for (const attr of newEl.attributes) {
204
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
205
+ oldEl.setAttribute(attr.name, attr.value);
206
+ }
207
+ }
208
+ for (const attr of Array.from(oldEl.attributes)) {
209
+ if (!newEl.hasAttribute(attr.name)) {
210
+ oldEl.removeAttribute(attr.name);
211
+ }
212
+ }
213
+
214
+ // `checked`, `selected` and an input's or textarea's `value` are dirty-flag
215
+ // IDL properties whose content attributes are only defaults, so the loops
216
+ // above never reach the live state; without this a re-patch could not turn a
217
+ // checkbox off or replace a field's text. Writing `value` moves the caret, so a
218
+ // consumer that re-renders mid-keystroke must drive the field from an effect;
219
+ // docs/dom.md records the obligation. canPatch guarantees both nodes share a
220
+ // nodeName, so one side picks the branch.
221
+ if (oldEl.nodeName === "INPUT") {
222
+ const oldInput = oldEl as HTMLInputElement;
223
+ const newInput = newEl as HTMLInputElement;
224
+ // Each `!==` guard is required: a `.value` write sets the dirty-value flag
225
+ // for good, severing the property from its attribute, and on a raw-content
226
+ // field (`type=date`, `number`, `time`, ...) it destroys a half-typed entry.
227
+ // The tests pin all four guards through those flags, not through the caret.
228
+ if (oldInput.checked !== newInput.checked) {
229
+ oldInput.checked = newInput.checked;
230
+ }
231
+ if (oldInput.value !== newInput.value) {
232
+ oldInput.value = newInput.value;
233
+ }
234
+ } else if (oldEl.nodeName === "TEXTAREA") {
235
+ const oldArea = oldEl as HTMLTextAreaElement;
236
+ const newArea = newEl as HTMLTextAreaElement;
237
+ if (oldArea.value !== newArea.value) {
238
+ oldArea.value = newArea.value;
239
+ }
240
+ } else if (oldEl.nodeName === "OPTION") {
241
+ const oldOption = oldEl as HTMLOptionElement;
242
+ const newOption = newEl as HTMLOptionElement;
243
+ if (oldOption.selected !== newOption.selected) {
244
+ oldOption.selected = newOption.selected;
245
+ }
246
+ }
247
+
248
+ // Reconcile on* event handler properties (not reflected as attributes).
249
+ const newKeys = handlerKeysMap.get(newEl);
250
+ const oldKeys = handlerKeysMap.get(oldEl);
251
+ if (oldKeys) {
252
+ for (const key of oldKeys) {
253
+ if (!newKeys?.has(key)) {
254
+ (oldEl as unknown as Record<string, unknown>)[key] = null;
255
+ }
256
+ }
257
+ }
258
+ if (newKeys) {
259
+ const oldRec = oldEl as unknown as Record<string, unknown>;
260
+ const newRec = newEl as unknown as Record<string, unknown>;
261
+ for (const key of newKeys) {
262
+ oldRec[key] = newRec[key];
263
+ }
264
+ handlerKeysMap.set(oldEl, new Set(newKeys));
265
+ } else if (oldKeys) {
266
+ handlerKeysMap.delete(oldEl);
267
+ }
268
+ }
@@ -0,0 +1,76 @@
1
+ // Keyed-list DOM reconciliation with mount/update/onRemove lifecycle.
2
+ // Identity-preserving: existing elements survive across renders.
3
+
4
+ /** Attribute name used to store reconciliation keys on elements. */
5
+ export const KEY_ATTR = "data-reconcile-key";
6
+
7
+ /** Specification for keyed-list reconciliation lifecycle callbacks. */
8
+ export interface ReconcileSpec<T> {
9
+ key: (item: T) => string;
10
+ mount: (item: T) => HTMLElement;
11
+ update?: (el: HTMLElement, item: T) => void;
12
+ onRemove?: (el: HTMLElement, key: string) => void;
13
+ }
14
+
15
+ /** Reconcile a keyed list of items into a parent node, preserving existing elements by key. */
16
+ export function reconcile<T>(
17
+ parent: ParentNode,
18
+ items: readonly T[],
19
+ spec: ReconcileSpec<T>,
20
+ ): void {
21
+ const existing = new Map<string, HTMLElement>();
22
+ for (let n = parent.firstChild; n !== null; n = n.nextSibling) {
23
+ if (n.nodeType !== 1) {
24
+ continue;
25
+ }
26
+ const el = n as HTMLElement;
27
+ const k = el.getAttribute(KEY_ATTR);
28
+ if (k !== null) {
29
+ existing.set(k, el);
30
+ }
31
+ }
32
+
33
+ // DEPARTING ELEMENTS GO FIRST, before anything is placed. The walk below runs
34
+ // backwards and skips an element already sitting before the last one it placed; a
35
+ // departing element still in the tree makes every PREDECESSOR's `nextSibling` point at
36
+ // a node about to vanish, so the guard sees a mismatch and re-seats a row nothing
37
+ // changed — which restarts its animations and drops `:hover` and focus. So `onRemove`
38
+ // fires before the survivors are placed, holding its own intact element; only the
39
+ // siblings' positions are not yet final. `key` is still called exactly once per item,
40
+ // and the pairs carry it into the walk so neither loop indexes an array.
41
+ const pairs: [T, string][] = [];
42
+ const wanted = new Set<string>();
43
+ for (const item of items) {
44
+ const k = spec.key(item);
45
+ pairs.push([item, k]);
46
+ wanted.add(k);
47
+ }
48
+ for (const [k, el] of existing) {
49
+ if (!wanted.has(k)) {
50
+ spec.onRemove?.(el, k);
51
+ el.remove();
52
+ existing.delete(k);
53
+ }
54
+ }
55
+
56
+ let target: Node | null = null;
57
+ for (const [item, k] of pairs.reverse()) {
58
+ let el = existing.get(k);
59
+ if (el === undefined) {
60
+ el = spec.mount(item);
61
+ el.setAttribute(KEY_ATTR, k);
62
+ } else {
63
+ existing.delete(k);
64
+ if (spec.update) {
65
+ spec.update(el, item);
66
+ }
67
+ }
68
+ // Skip the move when el is already at its target position: re-inserting a node
69
+ // that is already correctly placed still detaches+reattaches it in the DOM,
70
+ // which blurs a focused input / drops a text selection inside that row.
71
+ if (el.parentNode !== parent || el.nextSibling !== target) {
72
+ parent.insertBefore(el, target);
73
+ }
74
+ target = el;
75
+ }
76
+ }
@@ -0,0 +1,18 @@
1
+ /** Typed HTTP requests that never throw: each resolves to a result envelope, or to `null` on failure. @module */
2
+
3
+ export { createFetch } from "./instance.js";
4
+ export type { FetchInstance } from "./instance.js";
5
+
6
+ export { API_TIMEOUT_MS, withTimeout } from "./timeout.js";
7
+
8
+ export type {
9
+ ApiErr,
10
+ ApiOk,
11
+ ApiResult,
12
+ BlobRequestOptions,
13
+ Decoder,
14
+ FailedRequest,
15
+ FetchConfig,
16
+ HttpMethod,
17
+ RequestOptions,
18
+ } from "./types.js";
@@ -0,0 +1,37 @@
1
+ import { makeRequest, makeRequestRaw } from "./request.js";
2
+ import { makeVerbs } from "./verbs.js";
3
+ import type { FetchVerbs } from "./verbs.js";
4
+ import type { FetchConfig, RequestFn, RequestRawFn } from "./types.js";
5
+
6
+ /** An isolated fetch instance: its own immutable config, request core, and 12
7
+ * verb helpers. Returned by {@link createFetch}. */
8
+ export interface FetchInstance extends FetchVerbs {
9
+ /** The non-throwing request core bound to this instance. */
10
+ requestRaw: RequestRawFn;
11
+ /** The null-collapsing request bound to this instance. */
12
+ request: RequestFn;
13
+ }
14
+
15
+ /**
16
+ * Create an isolated fetch instance. `config` is shallow-copied and frozen, so
17
+ * two instances never share settings and a changed backend is a new instance;
18
+ * read late-bound state, such as a token, inside `prepareHeaders`.
19
+ * Throws a `TypeError` on a `maxResponseBytes` that is `NaN` or negative.
20
+ */
21
+ export function createFetch(config: FetchConfig = {}): FetchInstance {
22
+ const max = config.maxResponseBytes;
23
+ if (max !== undefined && !(max >= 0)) {
24
+ throw new TypeError(
25
+ `createFetch: maxResponseBytes must be a byte count of 0 or more (or Infinity for no cap), not ${String(max)}`,
26
+ );
27
+ }
28
+ const cfg: FetchConfig = Object.freeze({ ...config });
29
+ const requestRaw = makeRequestRaw(cfg);
30
+ const request = makeRequest(requestRaw);
31
+ const verbs = makeVerbs(request, requestRaw);
32
+ return {
33
+ ...verbs,
34
+ requestRaw,
35
+ request,
36
+ };
37
+ }