@davidsouther/jiffies 2026.4.0 → 2026.24.0

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 (123) hide show
  1. package/README.md +0 -3
  2. package/package.json +13 -6
  3. package/src/404.html +1 -1
  4. package/src/components/accordion.ts +25 -0
  5. package/src/components/alert.ts +47 -0
  6. package/src/components/card.ts +54 -0
  7. package/src/components/children.ts +11 -0
  8. package/src/components/form.ts +25 -0
  9. package/src/components/index.ts +22 -0
  10. package/src/components/link.ts +22 -0
  11. package/src/components/modal.ts +15 -0
  12. package/src/components/nav.ts +42 -0
  13. package/src/components/property.ts +32 -0
  14. package/src/components/tabs.ts +82 -0
  15. package/src/components/virtual_scroll.ts +1 -1
  16. package/src/dom/README.md +8 -3
  17. package/src/dom/SKILL.md +201 -0
  18. package/src/dom/dom.ts +192 -41
  19. package/src/dom/fc.ts +7 -3
  20. package/src/dom/form/form.app.ts +35 -41
  21. package/src/dom/form/form.ts +79 -10
  22. package/src/dom/form/index.html +2 -2
  23. package/src/dom/html.ts +1 -1
  24. package/src/dom/hydrate.ts +206 -0
  25. package/src/dom/navigation/index.ts +349 -0
  26. package/src/dom/render.ts +41 -0
  27. package/src/dom/router/router.ts +1 -1
  28. package/src/dom/svg.ts +6 -2
  29. package/src/fs_node.ts +2 -2
  30. package/src/log.ts +154 -2
  31. package/src/server/http/response.ts +6 -3
  32. package/src/server/http/sitemap.ts +10 -34
  33. package/src/server/http/static.ts +0 -2
  34. package/src/server/live-reload.ts +208 -0
  35. package/src/server/main.ts +14 -7
  36. package/src/server/ws/frame.ts +36 -0
  37. package/src/server/ws/handshake.ts +42 -0
  38. package/src/server/ws/index.ts +100 -0
  39. package/src/ssg/bundle.ts +85 -0
  40. package/src/ssg/copy-public.ts +44 -0
  41. package/src/ssg/discover.ts +143 -0
  42. package/src/ssg/main.ts +168 -0
  43. package/src/ssg/rewrite.ts +18 -0
  44. package/src/ssg/ssg.ts +134 -0
  45. package/src/components/test.ts +0 -5
  46. package/src/components/virtual_scroll.test.ts +0 -30
  47. package/src/context.test.ts +0 -58
  48. package/src/context.ts +0 -67
  49. package/src/diff.test.ts +0 -48
  50. package/src/dom/fc.test.ts +0 -43
  51. package/src/dom/form/form.test.ts +0 -0
  52. package/src/dom/html.test.ts +0 -74
  53. package/src/dom/observable.test.ts +0 -43
  54. package/src/dom/test.ts +0 -11
  55. package/src/equal.test.ts +0 -23
  56. package/src/flags.test.ts +0 -43
  57. package/src/flags.ts +0 -53
  58. package/src/fs.test.ts +0 -106
  59. package/src/fs_win.test.ts +0 -11
  60. package/src/generator.test.ts +0 -27
  61. package/src/index.html +0 -82
  62. package/src/is_browser.js +0 -1
  63. package/src/lock.test.ts +0 -17
  64. package/src/observable/observable.test.ts +0 -73
  65. package/src/pico/_variables.scss +0 -66
  66. package/src/pico/components/_accordion.scss +0 -112
  67. package/src/pico/components/_button-group.scss +0 -51
  68. package/src/pico/components/_card.scss +0 -47
  69. package/src/pico/components/_dropdown.scss +0 -203
  70. package/src/pico/components/_modal.scss +0 -181
  71. package/src/pico/components/_nav.scss +0 -79
  72. package/src/pico/components/_progress.scss +0 -70
  73. package/src/pico/components/_property.scss +0 -34
  74. package/src/pico/content/_button.scss +0 -152
  75. package/src/pico/content/_code.scss +0 -63
  76. package/src/pico/content/_embedded.scss +0 -0
  77. package/src/pico/content/_form-alt.scss +0 -276
  78. package/src/pico/content/_form.scss +0 -259
  79. package/src/pico/content/_misc.scss +0 -0
  80. package/src/pico/content/_table.scss +0 -28
  81. package/src/pico/content/_toggle.scss +0 -132
  82. package/src/pico/content/_typography.scss +0 -232
  83. package/src/pico/layout/_container.scss +0 -40
  84. package/src/pico/layout/_document.scss +0 -0
  85. package/src/pico/layout/_flex.scss +0 -46
  86. package/src/pico/layout/_grid.scss +0 -24
  87. package/src/pico/layout/_scroller.scss +0 -16
  88. package/src/pico/layout/_section.scss +0 -8
  89. package/src/pico/layout/_sectioning.scss +0 -55
  90. package/src/pico/pico.scss +0 -60
  91. package/src/pico/reset/_accessibility.scss +0 -34
  92. package/src/pico/reset/_button.scss +0 -17
  93. package/src/pico/reset/_code.scss +0 -15
  94. package/src/pico/reset/_document.scss +0 -48
  95. package/src/pico/reset/_embedded.scss +0 -39
  96. package/src/pico/reset/_form.scss +0 -97
  97. package/src/pico/reset/_misc.scss +0 -23
  98. package/src/pico/reset/_nav.scss +0 -5
  99. package/src/pico/reset/_progress.scss +0 -4
  100. package/src/pico/reset/_table.scss +0 -8
  101. package/src/pico/reset/_typography.scss +0 -25
  102. package/src/pico/themes/default/_colors.scss +0 -65
  103. package/src/pico/themes/default/_dark.scss +0 -148
  104. package/src/pico/themes/default/_light.scss +0 -149
  105. package/src/pico/themes/default/_styles.scss +0 -272
  106. package/src/pico/themes/default.scss +0 -34
  107. package/src/pico/utilities/_accessibility.scss +0 -3
  108. package/src/pico/utilities/_loading.scss +0 -52
  109. package/src/pico/utilities/_reduce-motion.scss +0 -27
  110. package/src/pico/utilities/_tooltip.scss +0 -101
  111. package/src/result.test.ts +0 -101
  112. package/src/scope/describe.ts +0 -81
  113. package/src/scope/display/console.ts +0 -26
  114. package/src/scope/display/dom.ts +0 -36
  115. package/src/scope/display/junit.ts +0 -64
  116. package/src/scope/execute.ts +0 -110
  117. package/src/scope/expect.ts +0 -169
  118. package/src/scope/fix.ts +0 -30
  119. package/src/scope/index.ts +0 -11
  120. package/src/scope/scope.ts +0 -21
  121. package/src/scope/state.ts +0 -13
  122. package/src/test.mjs +0 -33
  123. package/src/test_all.ts +0 -35
@@ -0,0 +1,201 @@
1
+ ---
2
+ name: using-jiffies-dom
3
+ description: Use when building or updating UI in a project that depends on @davidsouther/jiffies and you are writing DOM code with its html/svg/fc modules — creating elements, updating a node in place via its .update() method, wiring event handlers, setting class/style, or building stateful FC components. Covers the reentrant create-or-update model and the correct .ts import paths.
4
+ ---
5
+
6
+ # Using Jiffies DOM
7
+
8
+ ## Overview
9
+
10
+ Jiffies DOM is a tiny functional library that implements **reentrant DOM**, where every
11
+ node is a function that when called again updates its contents. There are exactly two
12
+ operations:
13
+
14
+ - **Create:** calling a tag function (`div(...)`, `button(...)`, `circle(...)`) makes a
15
+ **new** node every time.
16
+ - **Update in place:** every node Jiffies creates carries an `.update(attrs?, ...children)`
17
+ method. Calling it mutates **that same node** — same identity, no replacement.
18
+
19
+ To update a node later, you must **keep a reference to it** and call `.update()` on it.
20
+ Calling the tag function again does not update the old node; it builds a new one.
21
+
22
+ > Ideally, the object returned from the tag function would itself be directly callable, so instead of `const el = div({'class': 'off'}); el.update({'class': 'on'});`, you could just do `const el = div({'class': 'off'}); el({'class': 'on})';`. This is possible by wrapping the returned `HTMLDivElement` in a `Proxy` that implements a call interceptor, but `Proxy` cannot be passed to DOM APIs like appendChildren or addEventListener.
23
+
24
+ ## Import paths (get this right first)
25
+
26
+ The package is `@davidsouther/jiffies` and its export map is `"./*.ts": "./src/*.ts"`.
27
+ Imports use the real subpath **with the `.ts` extension**:
28
+
29
+ ```ts
30
+ import { div, button, span, ul, li, p } from "@davidsouther/jiffies/dom/html.ts";
31
+ import { FC, State } from "@davidsouther/jiffies/dom/fc.ts";
32
+ import { svg, circle } from "@davidsouther/jiffies/dom/svg.ts";
33
+ ```
34
+
35
+ The package READMEs show `jiffies/dom/html` — that path is **wrong**. Only `html` and `fc`
36
+ are re-exported from `dom/index.ts`; reach `svg`, `observable`, `router`, `provide`, and
37
+ `xml` by their own deep paths.
38
+
39
+ ## Quick reference
40
+
41
+ | You want to… | Do this |
42
+ |---|---|
43
+ | Create an element | `div(attrs?, ...children)` — first arg is attrs only if a plain object |
44
+ | Update a node in place | hold a reference to the node, then call `node.update(attrs?, ...children)` |
45
+ | Set children only | `node.update("new text")` or `node.update(child1, child2)` |
46
+ | Empty children | `import { CLEAR } from ".../dom/dom.ts"; node.update(CLEAR)` |
47
+ | Add an event | `button({ events: { click: (e) => {...} } })` |
48
+ | Replace an event handler | `node.update({ events: { click: newHandler } })` — old listener is removed first |
49
+ | Remove an event | `node.update({ events: { click: null } })` |
50
+ | Set a class | `div({ class: "a b" })` or `{ class: ["a", "b"] }` |
51
+ | Remove a class on update | `node.update({ class: "!hidden" })` (`!` prefix removes) |
52
+ | Inline style | `{ style: { flexDirection: "column" } }` or `{ style: "color:red" }` |
53
+ | Boolean attribute | `{ disabled: true }` (falsy removes the attribute) |
54
+
55
+ ## The argument rule (common error source)
56
+
57
+ The first argument is treated as an **attributes object** only if it is a plain object with
58
+ no `nodeType`. A string, a Node, or `CLEAR` is treated as the **first child**.
59
+
60
+ ```ts
61
+ div({ class: "row" }, span("a"), span("b")); // attrs + 2 children
62
+ div(span("a"), span("b")); // 0 attrs, 2 children
63
+ div("hello"); // 0 attrs, 1 text child
64
+ ```
65
+
66
+ ## Example: in-place Counter
67
+
68
+ The number node is created once and reused on every click. The tag function is the create;
69
+ `display.update(...)` is the reentrant update.
70
+
71
+ ```ts
72
+ import { div, span, button } from "@davidsouther/jiffies/dom/html.ts";
73
+
74
+ export function Counter(start = 0) {
75
+ let count = start;
76
+ const display = span(`${count}`); // created ONCE — keep the reference
77
+
78
+ return div(
79
+ display,
80
+ button(
81
+ { events: { click: () => { count += 1; display.update(`${count}`); } } },
82
+ "+1",
83
+ ),
84
+ );
85
+ }
86
+ ```
87
+
88
+ ## FC: stateful components
89
+
90
+ Use `FC` when a component owns state and should re-render itself from props. `FC(name, render)`
91
+ defines a custom element and returns a constructor. Calling it creates the element; calling
92
+ `.update(props)` **merges** props, re-runs `render`, and reconciles the rendered output into the host.
93
+
94
+ ```ts
95
+ import { FC } from "@davidsouther/jiffies/dom/fc.ts";
96
+ import { section, h2, ul, li } from "@davidsouther/jiffies/dom/html.ts";
97
+
98
+ export const TodoList = FC<{ title: string; items: string[] }>(
99
+ "todo-list",
100
+ (_el, { title, items }) =>
101
+ section(h2(title ?? "Todos"), ul(...items.map((i) => li(i)))),
102
+ );
103
+
104
+ const list = TodoList({ title: "Chores", items: ["wash", "fold"] });
105
+ document.body.append(list);
106
+ list.update({ items: ["wash", "fold", "iron"] }); // title is retained (props merge)
107
+ ```
108
+
109
+ The render function is `(el, props, children) => Element | Element[]`. `el` is the host
110
+ custom element (also typed to carry `.update()` and the `State` symbol). It may return a
111
+ single node or an array. Persist component state on the host via the `State` symbol:
112
+
113
+ ```ts
114
+ import { FC, State } from "@davidsouther/jiffies/dom/fc.ts";
115
+
116
+ export const Toggle = FC<{ label: string }, { on: boolean }>(
117
+ "app-toggle",
118
+ (el, { label }) => {
119
+ el[State] ??= { on: false }; // initialise once; retained across updates
120
+ const s = el[State];
121
+ return button(
122
+ { events: { click: () => { s.on = !s.on; el.update(); } } }, // el.update() re-renders
123
+ `${label}: ${s.on ? "on" : "off"}`,
124
+ );
125
+ },
126
+ );
127
+ ```
128
+
129
+ ## Typing nodes
130
+
131
+ You do not need a Jiffies-specific type to hold a node. Jiffies augments the global DOM
132
+ `Element` interface with `.update()`, so standard lib types already carry it:
133
+
134
+ ```ts
135
+ const display: HTMLSpanElement = span("0");
136
+ display.update("1"); // .update is in scope on every Element
137
+ ```
138
+
139
+ `dom/dom.ts` also exports `DOMElement` (`Element & ElementCSSInlineStyle`) for the general case.
140
+
141
+ ## Updates reconcile children by identity
142
+
143
+ `.update(...children)` reconciles the new child list against the mounted children **by node
144
+ object identity**. A child you pass back by the **same reference** is left in place and never
145
+ detached, so its focus, scroll position, text selection, event listeners, and any descendant
146
+ state survive the update. A freshly built node (or a string) has no matching identity, so it
147
+ is inserted; a mounted child you omit is removed; order follows the argument list.
148
+
149
+ This means you can update a parent and keep a specific subtree alive by passing the same node
150
+ reference back through it:
151
+
152
+ ```ts
153
+ const panel = div(span("Title"), input({ name: "q" })); // keep this reference
154
+ const root = div(panel, p("status"));
155
+ // ...later: replace the status line but keep the panel (and its focused input):
156
+ root.update(panel, p("ready")); // panel is reused in place; only the <p> is rebuilt
157
+ ```
158
+
159
+ Strings always rebuild (they carry no identity). The same reference must not appear twice in
160
+ one update — a DOM node can occupy only one position.
161
+
162
+ For fine-grained leaf updates (e.g. a counter), you can still hold a reference to the specific
163
+ child node and call `.update()` on **that** node directly. **FC is the exception:** its
164
+ `render` builds fresh nodes each update, so identity is not preserved inside an FC's own
165
+ output (see the common mistake below).
166
+
167
+ ## Testing
168
+
169
+ Tests use the `scope` microframework and run under Node (jsdom loads automatically when
170
+ `window` is undefined). Run with `npm test` (`node ./src/test.mjs`).
171
+
172
+ ```ts
173
+ import { describe, it, expect } from "@davidsouther/jiffies/scope/index.ts";
174
+ import { button } from "@davidsouther/jiffies/dom/html.ts";
175
+
176
+ describe("counter", () => {
177
+ it("reuses the node on update", () => {
178
+ const b = button("0");
179
+ b.update("1");
180
+ expect(b.textContent).toBe("1");
181
+ });
182
+ });
183
+ ```
184
+
185
+ ## Common mistakes
186
+
187
+ - **Calling the tag function again to "update."** That creates a new node. Hold the
188
+ reference and call `.update()` on it.
189
+ - **Expecting event handlers to stack.** Calling `node.update({ events: { click: newFn } })`
190
+ removes the old listener before adding the new one. Each event key tracks exactly one
191
+ listener; the last one set is the one that fires.
192
+ - **Importing `jiffies/dom/html`.** Use `@davidsouther/jiffies/dom/html.ts` (full name, `.ts`).
193
+ - **Passing a config object as the first child.** A plain object becomes attrs; wrap text/nodes
194
+ as children explicitly.
195
+ - **Expecting FC `.update()` to preserve child DOM identity.** It re-renders and replaces
196
+ children. For stable child nodes, update them directly.
197
+ - **Using namespace-qualified SVG attributes.** `update()` always calls `setAttribute`, not
198
+ `setAttributeNS`. Attributes on SVG elements are plain unqualified names; read them back with
199
+ `getAttribute`, not `getAttributeNS`.
200
+ - **Copying README examples verbatim.** The package README snippets are illustrative and not
201
+ all valid TypeScript; rely on this skill and the `*.test.ts` files for working code.
package/src/dom/dom.ts CHANGED
@@ -1,14 +1,37 @@
1
- import { assertExists } from "../assert.ts";
1
+ import { assert, assertExists } from "../assert.ts";
2
2
  import type { Properties as SVGProperties } from "./types/css.ts";
3
3
 
4
+ if (typeof window === "undefined") {
5
+ const { JSDOM } = await import("jsdom");
6
+ // biome-ignore lint/suspicious/noGlobalAssign: Load JSDom globally
7
+ window = global.window = new JSDOM().window as unknown as Window &
8
+ typeof globalThis;
9
+ global.HTMLElement ??= window.HTMLElement;
10
+ global.customElements ??= window.customElements;
11
+ // Unconditional: jsdom's dispatchEvent instanceof-checks its own Event class, so Node's native Event must be replaced.
12
+ global.Event = window.Event as unknown as typeof Event;
13
+ global.MouseEvent ??= window.MouseEvent as unknown as typeof MouseEvent;
14
+ global.Element ??= window.Element as unknown as typeof Element;
15
+ }
16
+
4
17
  export const XHTML_NAMESPACE_URI = "http://www.w3.org/1999/xhtml";
5
18
  export const SVG_NAMESPACE_URI = "http://www.w3.org/2000/svg";
6
19
 
7
20
  const Events = Symbol("events");
8
21
  export const CLEAR = Symbol("Clear children");
9
22
 
23
+ // Node.ELEMENT_NODE; the Node global is not installed in the jsdom bootstrap
24
+ // above, so the numeric constant is used directly (cf. nodeType 3 for text).
25
+ const ELEMENT_NODE = 1;
26
+
10
27
  export type EventHandler = EventListenerOrEventListenerObject;
11
- export type DenormChildren = Node | string | typeof CLEAR;
28
+ export type DenormChildren =
29
+ | Node
30
+ | string
31
+ | typeof CLEAR
32
+ | null
33
+ | undefined
34
+ | false;
12
35
 
13
36
  export type DOMElement = Element & ElementCSSInlineStyle;
14
37
 
@@ -43,7 +66,7 @@ export type DOMUpdates<E extends Element = Element> =
43
66
  | DenormChildren[];
44
67
 
45
68
  function isAttrs<E extends Element>(
46
- attrs: DenormAttrs<E> | undefined
69
+ attrs: DenormAttrs<E> | undefined,
47
70
  ): attrs is Attrs<E> {
48
71
  if (!attrs) {
49
72
  return false;
@@ -57,7 +80,7 @@ function isAttrs<E extends Element>(
57
80
  export function normalizeArguments<E extends Element>(
58
81
  attrs?: DenormAttrs<E>,
59
82
  children: DenormChildren[] = [],
60
- defaultAttrs: Attrs<E> = {}
83
+ defaultAttrs: Attrs<E> = {},
61
84
  ): [Attrs<E>, DenormChildren[]] {
62
85
  let attributes: Attrs<E>;
63
86
  if (isAttrs(attrs)) {
@@ -68,7 +91,10 @@ export function normalizeArguments<E extends Element>(
68
91
  }
69
92
  attributes = defaultAttrs;
70
93
  }
71
- return [attributes, children.flat()];
94
+ // Drop conditional/absent children (React's `{cond && <X/>}` idiom): null,
95
+ // undefined, and false. `0` and `""` are kept — they are legitimate text
96
+ // nodes, and dropping them would reintroduce the React `0`-renders-nothing bug.
97
+ return [attributes, children.flat().filter((c) => c != null && c !== false)];
72
98
  }
73
99
 
74
100
  export function up<E extends Element>(
@@ -79,29 +105,53 @@ export function up<E extends Element>(
79
105
  return update(element, ...normalizeArguments(attrs, children)) as E;
80
106
  }
81
107
 
108
+ /**
109
+ * (Re)attach a single listener for `type`, replacing any handler `events`
110
+ * already tracks for it, so each event has exactly one live handler — no
111
+ * stacking, no orphans. `events` is the element's own `[Events]` map; it
112
+ * stays the single source of truth.
113
+ */
114
+ function setListener(
115
+ target: EventTarget,
116
+ events: Map<string, EventHandler>,
117
+ type: string,
118
+ handler: EventHandler,
119
+ ): void {
120
+ if (events.has(type)) {
121
+ target.removeEventListener(type, assertExists(events.get(type)));
122
+ }
123
+ target.addEventListener(type, handler);
124
+ events.set(type, handler);
125
+ }
126
+
127
+ /** Detach the listener `events` tracks for `type`, if any, and forget it. */
128
+ function clearListener(
129
+ target: EventTarget,
130
+ events: Map<string, EventHandler>,
131
+ type: string,
132
+ ): void {
133
+ if (events.has(type)) {
134
+ target.removeEventListener(type, assertExists(events.get(type)));
135
+ events.delete(type);
136
+ }
137
+ }
138
+
82
139
  export function update(
83
140
  element: Omit<Element, "update">,
84
141
  attrs: Attrs<Element>,
85
- children: DenormChildren[]
142
+ children: DenormChildren[],
86
143
  ): Element {
87
- // Track events, to remove later
88
144
  element[Events] ??= new Map<string, EventHandler>();
89
145
  const $events = element[Events];
90
- // const { style = {}, events = {}, ...rest } = attrs;
91
146
 
92
- for (const [k, v] of Object.entries(
93
- (attrs.events as NonNullable<typeof attrs.events>) ?? {}
94
- )) {
147
+ for (const [k, v] of Object.entries(attrs.events ?? {})) {
95
148
  if (v === null) {
96
- if ($events.has(k)) {
97
- const listener = assertExists($events.get(k));
98
- element.removeEventListener(k, listener);
99
- }
149
+ clearListener(element, $events, k);
100
150
  } else if (v !== undefined) {
101
- element.addEventListener(k as keyof ElementEventMap, v);
102
- $events.set(k, v);
151
+ setListener(element, $events, k, v);
103
152
  }
104
153
  }
154
+ element.toggleAttribute("data-hydrate", $events.size > 0);
105
155
 
106
156
  const _style = (element as { style?: Partial<CSSStyleDeclaration> }).style;
107
157
  if (_style) {
@@ -109,7 +159,7 @@ export function update(
109
159
  _style.cssText = attrs.style;
110
160
  } else {
111
161
  for (const [k, v] of Object.entries(
112
- (attrs.style as Partial<CSSStyleDeclaration>) ?? {}
162
+ (attrs.style as Partial<CSSStyleDeclaration>) ?? {},
113
163
  )) {
114
164
  // @ts-expect-error Object.entries is unable to statically look into args
115
165
  _style[k] = v;
@@ -138,34 +188,19 @@ export function update(
138
188
  continue;
139
189
  }
140
190
 
141
- const useNamespace = false;
142
- element.namespaceURI &&
143
- element.namespaceURI !== XHTML_NAMESPACE_URI &&
144
- element.namespaceURI !== SVG_NAMESPACE_URI;
145
- const remove = !v;
146
-
147
- if (useNamespace) {
148
- if (remove) {
149
- element.removeAttributeNS(element.namespaceURI, k);
150
- } else if (v === true) {
151
- element.setAttributeNS(element.namespaceURI, k, k);
152
- } else {
153
- element.setAttributeNS(element.namespaceURI, k, String(v));
154
- }
191
+ if (!v) {
192
+ element.removeAttribute(k);
193
+ } else if (v === true) {
194
+ element.setAttribute(k, k);
155
195
  } else {
156
- if (remove) {
157
- element.removeAttribute(k);
158
- } else if (v === true) {
159
- element.setAttribute(k, k);
160
- } else {
161
- element.setAttribute(k, String(v));
162
- }
196
+ element.setAttribute(k, String(v));
163
197
  }
164
198
  }
165
199
 
166
200
  if (children?.length > 0) {
167
- element.replaceChildren(
168
- ...(children[0] === CLEAR ? [] : (children as (string | Node)[]))
201
+ reconcileChildren(
202
+ element,
203
+ children[0] === CLEAR ? [] : (children as (string | Node)[]),
169
204
  );
170
205
  }
171
206
 
@@ -174,3 +209,119 @@ export function update(
174
209
 
175
210
  return element as Element;
176
211
  }
212
+
213
+ /**
214
+ * Reconcile `element`'s mounted children against expected `children`, mutating the live DOM in place.
215
+ */
216
+ export function reconcileChildren(
217
+ element: Node,
218
+ children: (string | Node)[],
219
+ ): void {
220
+ const desired = findDesiredNodes(element, children);
221
+
222
+ const { mountedSet, unclaimed } = findUnclaimedNodes(desired, element);
223
+
224
+ patchUnclaimedNodes(desired, mountedSet, unclaimed);
225
+
226
+ clearUnwantedNodes(desired, element);
227
+
228
+ insertDesiredNodes(element, desired);
229
+ }
230
+
231
+ function findDesiredNodes(element: Node, children: (string | Node)[]): Node[] {
232
+ const doc = element.ownerDocument ?? window.document;
233
+ const desired: Node[] = children.map((child) =>
234
+ typeof child === "string" ? doc.createTextNode(child) : child,
235
+ );
236
+ return desired;
237
+ }
238
+
239
+ function insertDesiredNodes(element: Node, desired: Node[]) {
240
+ let cursor: ChildNode | null = element.firstChild;
241
+ for (const node of desired) {
242
+ if (node === cursor) {
243
+ cursor = cursor.nextSibling;
244
+ } else {
245
+ element.insertBefore(node, cursor);
246
+ }
247
+ }
248
+ }
249
+
250
+ function clearUnwantedNodes(desired: Node[], element: Node) {
251
+ const keep = new Set(desired);
252
+ for (const mounted of Array.from(element.childNodes)) {
253
+ if (!keep.has(mounted)) {
254
+ element.removeChild(mounted);
255
+ }
256
+ }
257
+ }
258
+
259
+ function patchUnclaimedNodes(
260
+ desired: Node[],
261
+ mountedSet: Set<Node>,
262
+ unclaimed: Node[],
263
+ ) {
264
+ let claim = 0;
265
+ for (let i = 0; i < desired.length; i++) {
266
+ const node = desired[i];
267
+ if (node.nodeType !== ELEMENT_NODE || mountedSet.has(node)) {
268
+ continue;
269
+ }
270
+ if (claim < unclaimed.length) {
271
+ if (unclaimed[claim].nodeName === node.nodeName) {
272
+ patchNode(unclaimed[claim] as Element, node as Element);
273
+ desired[i] = unclaimed[claim];
274
+ }
275
+ claim++;
276
+ }
277
+ }
278
+ }
279
+
280
+ function findUnclaimedNodes(
281
+ desired: Node[],
282
+ element: Node,
283
+ ): { mountedSet: Set<Node>; unclaimed: Node[] } {
284
+ const unclaimed: Node[] = [];
285
+ const desiredSet = new Set<Node>(desired);
286
+ const mountedSet = new Set<Node>(element.childNodes);
287
+ for (const mounted of Array.from(element.childNodes)) {
288
+ if (mounted.nodeType === ELEMENT_NODE && !desiredSet.has(mounted)) {
289
+ unclaimed.push(mounted);
290
+ }
291
+ }
292
+ return { mountedSet, unclaimed };
293
+ }
294
+
295
+ export function patchNode(kept: Element, fresh: Element): void {
296
+ assert(kept.nodeName === fresh.nodeName, "patching nodes of different types");
297
+
298
+ // Remove `kept` attributes that aren't on `fresh`, then add `fresh` attributes not on `kept`.
299
+ for (const { name } of Array.from(kept.attributes)) {
300
+ if (!fresh.hasAttribute(name)) {
301
+ kept.removeAttribute(name);
302
+ }
303
+ }
304
+ for (const { name, value } of Array.from(fresh.attributes)) {
305
+ if (kept.getAttribute(name) !== value) {
306
+ kept.setAttribute(name, value);
307
+ }
308
+ }
309
+
310
+ // Similar to attributes, but operating in a map on the side rather than the node itself.
311
+ kept[Events] ??= new Map<string, EventHandler>();
312
+ const keptEvents = kept[Events];
313
+ const freshEvents = fresh[Events] ?? new Map<string, EventHandler>();
314
+ for (const [type] of keptEvents) {
315
+ if (!freshEvents.has(type)) {
316
+ clearListener(kept, keptEvents, type);
317
+ }
318
+ }
319
+ for (const [type, handler] of freshEvents) {
320
+ setListener(kept, keptEvents, type, handler);
321
+ }
322
+
323
+ // Custom elements rebuild their own subtrees
324
+ if (customElements.get(kept.localName)) return;
325
+
326
+ reconcileChildren(kept, Array.from(fresh.childNodes));
327
+ }
package/src/dom/fc.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type DenormChildren,
4
4
  type DomAttrs,
5
5
  normalizeArguments,
6
+ reconcileChildren,
6
7
  update,
7
8
  } from "./dom.ts";
8
9
 
@@ -56,8 +57,8 @@ export function FC<Props extends object, State extends object = object>(
56
57
  update(this, this.#attrs, []);
57
58
 
58
59
  // Re-run the component function using new element, attrs, and children.
59
- const replace = [component(this, this.#attrs, this.#children)];
60
- this.replaceChildren(...replace.flat());
60
+ const rendered = [component(this, this.#attrs, this.#children)];
61
+ reconcileChildren(this, rendered.flat());
61
62
  return this;
62
63
  }
63
64
  }
@@ -68,7 +69,10 @@ export function FC<Props extends object, State extends object = object>(
68
69
  attrs?: Attrs<Props> | DenormChildren,
69
70
  ...children: DenormChildren[]
70
71
  ): FCComponent<Props, State> => {
71
- const element = document.createElement(name) as FCComponent<Props, State>;
72
+ const element = window.document.createElement(name) as FCComponent<
73
+ Props,
74
+ State
75
+ >;
72
76
  element.update(attrs, ...children);
73
77
  return element;
74
78
  };
@@ -1,50 +1,44 @@
1
- import { article, button, div, main, small } from "../html.ts";
1
+ import { button, div, main, small } from "../html.ts";
2
2
  import { Form, Input } from "./form.ts";
3
3
 
4
4
  export const App = () =>
5
5
  main(
6
- { class: "container" },
7
- article(
8
- Form(
9
- {
10
- events: {
11
- submit(event) {
12
- console.log(
13
- "Should see fields for firstname, lastname, email, etc",
14
- );
15
- console.log(event);
16
- },
6
+ Form(
7
+ {
8
+ events: {
9
+ submit(event) {
10
+ console.log(
11
+ "Should see fields for firstname, lastname, email, etc",
12
+ );
13
+ console.log(event);
17
14
  },
18
15
  },
19
- div(
20
- { class: "grid" },
21
- Input({ id: "firstname", placeholder: "First name" }),
22
- Input({ id: "lastname", placeholder: "Last name" }),
23
- ),
24
- Input(
25
- {
26
- id: "email",
27
- type: "email",
28
- placeholder: "Email address",
29
- required: true,
30
- },
31
- small("We will never share your email with anyone."),
32
- ),
33
- button({ type: "submit" }, "Submit"),
34
- div(
35
- { class: "grid" },
36
- Input({ id: "valid", placeholder: "Valid", "aria-invalid": "false" }),
37
- Input({
38
- id: "invalid",
39
- placeholder: "Invalid",
40
- "aria-invalid": "true",
41
- }),
42
- Input({ id: "disabled", placeholder: "Disabled", disabled: true }),
43
- Input({ id: "readonly", value: "Readonly", readOnly: true }),
44
- ),
45
- // Dropdown({id: 'fruit', label: "Fruit", placeholder: "Select a fruit...", options: ['Banana', 'Watermelon', 'Apple', 'Orange', 'Mango']}),
46
- // Radios({legend: 'Size', options: {small: 'Small', medium: 'Medium', large: 'Large', extralarge: "Extra Large"}, checked: 'small'}),
47
- // Checkboxes({options: {terms: 'I agree to the Terms and Conditions', termsSharing: {label: 'I agree to share my information with partners', disabled: true, checked: true}}),
16
+ },
17
+ div(
18
+ { class: "grid" },
19
+ Input({ id: "firstname", placeholder: "First name" }),
20
+ Input({ id: "lastname", placeholder: "Last name" }),
21
+ ),
22
+ Input(
23
+ {
24
+ id: "email",
25
+ type: "email",
26
+ placeholder: "Email address",
27
+ required: true,
28
+ },
29
+ small("We will never share your email with anyone."),
30
+ ),
31
+ button({ type: "submit" }, "Submit"),
32
+ div(
33
+ { class: "grid" },
34
+ Input({ id: "valid", placeholder: "Valid", "aria-invalid": "false" }),
35
+ Input({
36
+ id: "invalid",
37
+ placeholder: "Invalid",
38
+ "aria-invalid": "true",
39
+ }),
40
+ Input({ id: "disabled", placeholder: "Disabled", disabled: true }),
41
+ Input({ id: "readonly", value: "Readonly", readOnly: true }),
48
42
  ),
49
43
  ),
50
44
  );