@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.
- package/README.md +0 -3
- package/package.json +13 -6
- package/src/404.html +1 -1
- package/src/components/accordion.ts +25 -0
- package/src/components/alert.ts +47 -0
- package/src/components/card.ts +54 -0
- package/src/components/children.ts +11 -0
- package/src/components/form.ts +25 -0
- package/src/components/index.ts +22 -0
- package/src/components/link.ts +22 -0
- package/src/components/modal.ts +15 -0
- package/src/components/nav.ts +42 -0
- package/src/components/property.ts +32 -0
- package/src/components/tabs.ts +82 -0
- package/src/components/virtual_scroll.ts +1 -1
- package/src/dom/README.md +8 -3
- package/src/dom/SKILL.md +201 -0
- package/src/dom/dom.ts +192 -41
- package/src/dom/fc.ts +7 -3
- package/src/dom/form/form.app.ts +35 -41
- package/src/dom/form/form.ts +79 -10
- package/src/dom/form/index.html +2 -2
- package/src/dom/html.ts +1 -1
- package/src/dom/hydrate.ts +206 -0
- package/src/dom/navigation/index.ts +349 -0
- package/src/dom/render.ts +41 -0
- package/src/dom/router/router.ts +1 -1
- package/src/dom/svg.ts +6 -2
- package/src/fs_node.ts +2 -2
- package/src/log.ts +154 -2
- package/src/server/http/response.ts +6 -3
- package/src/server/http/sitemap.ts +10 -34
- package/src/server/http/static.ts +0 -2
- package/src/server/live-reload.ts +208 -0
- package/src/server/main.ts +14 -7
- package/src/server/ws/frame.ts +36 -0
- package/src/server/ws/handshake.ts +42 -0
- package/src/server/ws/index.ts +100 -0
- package/src/ssg/bundle.ts +85 -0
- package/src/ssg/copy-public.ts +44 -0
- package/src/ssg/discover.ts +143 -0
- package/src/ssg/main.ts +168 -0
- package/src/ssg/rewrite.ts +18 -0
- package/src/ssg/ssg.ts +134 -0
- package/src/components/test.ts +0 -5
- package/src/components/virtual_scroll.test.ts +0 -30
- package/src/context.test.ts +0 -58
- package/src/context.ts +0 -67
- package/src/diff.test.ts +0 -48
- package/src/dom/fc.test.ts +0 -43
- package/src/dom/form/form.test.ts +0 -0
- package/src/dom/html.test.ts +0 -74
- package/src/dom/observable.test.ts +0 -43
- package/src/dom/test.ts +0 -11
- package/src/equal.test.ts +0 -23
- package/src/flags.test.ts +0 -43
- package/src/flags.ts +0 -53
- package/src/fs.test.ts +0 -106
- package/src/fs_win.test.ts +0 -11
- package/src/generator.test.ts +0 -27
- package/src/index.html +0 -82
- package/src/is_browser.js +0 -1
- package/src/lock.test.ts +0 -17
- package/src/observable/observable.test.ts +0 -73
- package/src/pico/_variables.scss +0 -66
- package/src/pico/components/_accordion.scss +0 -112
- package/src/pico/components/_button-group.scss +0 -51
- package/src/pico/components/_card.scss +0 -47
- package/src/pico/components/_dropdown.scss +0 -203
- package/src/pico/components/_modal.scss +0 -181
- package/src/pico/components/_nav.scss +0 -79
- package/src/pico/components/_progress.scss +0 -70
- package/src/pico/components/_property.scss +0 -34
- package/src/pico/content/_button.scss +0 -152
- package/src/pico/content/_code.scss +0 -63
- package/src/pico/content/_embedded.scss +0 -0
- package/src/pico/content/_form-alt.scss +0 -276
- package/src/pico/content/_form.scss +0 -259
- package/src/pico/content/_misc.scss +0 -0
- package/src/pico/content/_table.scss +0 -28
- package/src/pico/content/_toggle.scss +0 -132
- package/src/pico/content/_typography.scss +0 -232
- package/src/pico/layout/_container.scss +0 -40
- package/src/pico/layout/_document.scss +0 -0
- package/src/pico/layout/_flex.scss +0 -46
- package/src/pico/layout/_grid.scss +0 -24
- package/src/pico/layout/_scroller.scss +0 -16
- package/src/pico/layout/_section.scss +0 -8
- package/src/pico/layout/_sectioning.scss +0 -55
- package/src/pico/pico.scss +0 -60
- package/src/pico/reset/_accessibility.scss +0 -34
- package/src/pico/reset/_button.scss +0 -17
- package/src/pico/reset/_code.scss +0 -15
- package/src/pico/reset/_document.scss +0 -48
- package/src/pico/reset/_embedded.scss +0 -39
- package/src/pico/reset/_form.scss +0 -97
- package/src/pico/reset/_misc.scss +0 -23
- package/src/pico/reset/_nav.scss +0 -5
- package/src/pico/reset/_progress.scss +0 -4
- package/src/pico/reset/_table.scss +0 -8
- package/src/pico/reset/_typography.scss +0 -25
- package/src/pico/themes/default/_colors.scss +0 -65
- package/src/pico/themes/default/_dark.scss +0 -148
- package/src/pico/themes/default/_light.scss +0 -149
- package/src/pico/themes/default/_styles.scss +0 -272
- package/src/pico/themes/default.scss +0 -34
- package/src/pico/utilities/_accessibility.scss +0 -3
- package/src/pico/utilities/_loading.scss +0 -52
- package/src/pico/utilities/_reduce-motion.scss +0 -27
- package/src/pico/utilities/_tooltip.scss +0 -101
- package/src/result.test.ts +0 -101
- package/src/scope/describe.ts +0 -81
- package/src/scope/display/console.ts +0 -26
- package/src/scope/display/dom.ts +0 -36
- package/src/scope/display/junit.ts +0 -64
- package/src/scope/execute.ts +0 -110
- package/src/scope/expect.ts +0 -169
- package/src/scope/fix.ts +0 -30
- package/src/scope/index.ts +0 -11
- package/src/scope/scope.ts +0 -21
- package/src/scope/state.ts +0 -13
- package/src/test.mjs +0 -33
- package/src/test_all.ts +0 -35
package/src/dom/SKILL.md
ADDED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
element.
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
60
|
-
this.
|
|
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<
|
|
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
|
};
|
package/src/dom/form/form.app.ts
CHANGED
|
@@ -1,50 +1,44 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
),
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
),
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
);
|