@continuum-js/dom 0.3.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 +60 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +540 -0
- package/dist/jsx-dev-runtime.d.ts +2 -0
- package/dist/jsx-dev-runtime.js +8 -0
- package/dist/jsx-runtime.d.ts +20 -0
- package/dist/jsx-runtime.js +24 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @continuum-js/dom
|
|
2
|
+
|
|
3
|
+
Тонкий fine-grained рендерер поверх [`@continuum-js/frp`](../frp). Отображает
|
|
4
|
+
`Behavior`/`Event` в реальные DOM-узлы с точечными обновлениями; работает с
|
|
5
|
+
обычным JSX через **автоматический рантайм** (`import { h }` не нужен).
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { newBehavior } from "@continuum-js/frp";
|
|
9
|
+
import { mount } from "@continuum-js/dom";
|
|
10
|
+
|
|
11
|
+
const [name, setName] = newBehavior("world");
|
|
12
|
+
mount(document.body, () => <h1>hello {name}</h1>);
|
|
13
|
+
setName("continuum"); // патчится один текст-узел
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **`h` / `Fragment`** — JSX-фабрика; компонент вызывается один раз. `Behavior`
|
|
17
|
+
в детях → живой текст-узел; `Behavior` в пропсах → живой атрибут/свойство;
|
|
18
|
+
`on*` → DOM-слушатель.
|
|
19
|
+
- **`dyn(b, render)`** — условное/переключаемое поддерево.
|
|
20
|
+
- **`each(items, key, render)`** — keyed-список с LIS-диффингом и сохранением
|
|
21
|
+
фокуса.
|
|
22
|
+
- **Дерево владения** — `root` / `scope` / `onCleanup`: каскадная очистка
|
|
23
|
+
подписок при сносе динамических регионов.
|
|
24
|
+
- **Контекст** — `createContext` / `provide` / `use` поверх дерева владения.
|
|
25
|
+
- **Хелперы** — `when`, `bindInput`, `portal`, `mount`, `animationFrames`.
|
|
26
|
+
- **SVG** — теги `<svg>`, `<rect>`, `<path>`, `<circle>`, … создаются в SVG-неймспейсе
|
|
27
|
+
(`createElementNS`); `class` и атрибуты (`viewBox`, `fill`, …) ставятся корректно.
|
|
28
|
+
HTML внутри `<foreignObject>` остаётся HTML.
|
|
29
|
+
- **Компоненты-обёртки** — JSX над хелперами (в духе Solid):
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<Show when={user} fallback={() => <Guest />}>
|
|
33
|
+
{(u) => <span>{u.name}</span>}
|
|
34
|
+
</Show>
|
|
35
|
+
|
|
36
|
+
<Each each={items} key={(i) => i.id}>
|
|
37
|
+
{(item) => <li>{item.name}</li>}
|
|
38
|
+
</Each>
|
|
39
|
+
|
|
40
|
+
<Dynamic value={route}>{(r) => (r === "home" ? <Home /> : <About />)}</Dynamic>
|
|
41
|
+
|
|
42
|
+
<Portal mount={document.body}><Modal /></Portal>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Настройка JSX (автоматический рантайм)
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
// tsconfig.json
|
|
49
|
+
{
|
|
50
|
+
"compilerOptions": {
|
|
51
|
+
"jsx": "react-jsx",
|
|
52
|
+
"jsxImportSource": "@continuum-js/dom",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Для Vite/esbuild — `jsx: "automatic"`, `jsxImportSource: "@continuum-js/dom"`.
|
|
58
|
+
Классическая фабрика тоже поддерживается (`h`/`Fragment` экспортируются) — тогда
|
|
59
|
+
`--jsx react --jsxFactory h --jsxFragmentFactory Fragment` и `import { h }` в
|
|
60
|
+
каждом файле с JSX.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Behavior, Event } from "@continuum-js/frp";
|
|
2
|
+
/** Root owner (a mount point). `fn` receives its dispose handle. */
|
|
3
|
+
export declare function root<T>(fn: (dispose: () => void) => T): T;
|
|
4
|
+
/** Child owner under the current one. Returns its value and dispose handle. */
|
|
5
|
+
export declare function scope<T>(fn: () => T): {
|
|
6
|
+
value: T;
|
|
7
|
+
dispose: () => void;
|
|
8
|
+
};
|
|
9
|
+
/** Register a cleanup in the current owner (no-op outside any owner). */
|
|
10
|
+
export declare function onCleanup(fn: () => void): void;
|
|
11
|
+
export type Child = Node | Behavior<unknown> | string | number | boolean | null | undefined | Child[];
|
|
12
|
+
type Props = Record<string, unknown> | null;
|
|
13
|
+
/** Fragment marker for `--jsxFragmentFactory Fragment`. */
|
|
14
|
+
export declare function Fragment(props: {
|
|
15
|
+
children?: Child;
|
|
16
|
+
}): Node;
|
|
17
|
+
type Component = (props: Record<string, unknown>) => Node;
|
|
18
|
+
/** JSX factory (`--jsxFactory h`). A component function runs exactly once. */
|
|
19
|
+
export declare function h(tag: string | Component | typeof Fragment, props: Props, ...children: Child[]): Node;
|
|
20
|
+
/** Conditional / switching subtree: rebuilds on each change of `b`. */
|
|
21
|
+
export declare function dyn<T>(b: Behavior<T>, render: (v: T) => Child): Node;
|
|
22
|
+
/** Keyed list: reuses rows by key, reorders with minimal moves (LIS). */
|
|
23
|
+
export declare function each<T, K>(items: Behavior<T[]>, key: (item: T) => K, render: (item: T) => Child): Node;
|
|
24
|
+
export interface Context<T> {
|
|
25
|
+
readonly id: symbol;
|
|
26
|
+
readonly defaultValue: T;
|
|
27
|
+
}
|
|
28
|
+
export declare function createContext<T>(defaultValue: T): Context<T>;
|
|
29
|
+
/** Write a context value into the current owner. */
|
|
30
|
+
export declare function provide<T>(ctx: Context<T>, value: T): void;
|
|
31
|
+
/** Read the nearest provided value up the owner tree, else the default. */
|
|
32
|
+
export declare function use<T>(ctx: Context<T>): T;
|
|
33
|
+
/** Conditional region driven by a boolean behavior (no rebuild on same value). */
|
|
34
|
+
export declare function when(cond: Behavior<boolean>, thenRender: () => Child, elseRender?: () => Child): Node;
|
|
35
|
+
/** Two-way binding props for a text input. Spread onto an `<input>`. */
|
|
36
|
+
export declare function bindInput(value: Behavior<string>, set: (v: string) => void): {
|
|
37
|
+
value: Behavior<string>;
|
|
38
|
+
onInput: (e: globalThis.Event) => void;
|
|
39
|
+
};
|
|
40
|
+
/** Render `child` into another node, cleaning up on dispose. */
|
|
41
|
+
export declare function portal(target: Node, child: Child): Node;
|
|
42
|
+
/** Mount a view under a root owner. Returns an unmount function. */
|
|
43
|
+
export declare function mount(container: Node, view: () => Node): () => void;
|
|
44
|
+
/**
|
|
45
|
+
* An `Event<number>` of `requestAnimationFrame` timestamps (ms). Drives the
|
|
46
|
+
* continuous-time combinators (`integral`/`derivative`/`warp` from the core).
|
|
47
|
+
* Registered against the current owner: it stops automatically on unmount.
|
|
48
|
+
*/
|
|
49
|
+
export declare function animationFrames(): Event<number>;
|
|
50
|
+
/**
|
|
51
|
+
* Conditional region. Rebuilds only when the truthiness of `when` toggles; the
|
|
52
|
+
* (narrowed) value is passed to the children render function at build time.
|
|
53
|
+
*
|
|
54
|
+
* ```tsx
|
|
55
|
+
* <Show when={user} fallback={() => <Guest />}>
|
|
56
|
+
* {(u) => <span>{u.name}</span>}
|
|
57
|
+
* </Show>
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function Show<T>(props: {
|
|
61
|
+
when: Behavior<T>;
|
|
62
|
+
children: (value: NonNullable<T>) => Child;
|
|
63
|
+
fallback?: () => Child;
|
|
64
|
+
}): Node;
|
|
65
|
+
/**
|
|
66
|
+
* Keyed list. The `by` key selector defaults to identity. (Note: `key` is a
|
|
67
|
+
* reserved JSX attribute stripped by the compiler, so the prop is named `by`.)
|
|
68
|
+
*
|
|
69
|
+
* ```tsx
|
|
70
|
+
* <Each each={items} by={(i) => i.id}>{(item) => <li>{item.name}</li>}</Each>
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export declare function Each<T, K = T>(props: {
|
|
74
|
+
each: Behavior<T[]>;
|
|
75
|
+
by?: (item: T) => K;
|
|
76
|
+
children: (item: T) => Child;
|
|
77
|
+
}): Node;
|
|
78
|
+
/**
|
|
79
|
+
* Switch the subtree on a behavior's value (rebuilds on every change).
|
|
80
|
+
*
|
|
81
|
+
* ```tsx
|
|
82
|
+
* <Dynamic value={route}>{(r) => r === "home" ? <Home /> : <About />}</Dynamic>
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export declare function Dynamic<T>(props: {
|
|
86
|
+
value: Behavior<T>;
|
|
87
|
+
children: (value: T) => Child;
|
|
88
|
+
}): Node;
|
|
89
|
+
/**
|
|
90
|
+
* Render children into another node (e.g. `document.body`), cleaning up on
|
|
91
|
+
* unmount. Children are eager here — a portal renders immediately.
|
|
92
|
+
*
|
|
93
|
+
* ```tsx
|
|
94
|
+
* <Portal mount={document.body}><Modal /></Portal>
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export declare function Portal(props: {
|
|
98
|
+
mount: Node;
|
|
99
|
+
children?: Child;
|
|
100
|
+
}): Node;
|
|
101
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// Continuum — DOM renderer (dom).
|
|
2
|
+
// Fine-grained rendering over the frp core: bindings, dynamic regions,
|
|
3
|
+
// keyed lists, an ownership tree for lifecycle, and context.
|
|
4
|
+
import { Behavior, Event, newEvent } from "@continuum-js/frp";
|
|
5
|
+
let currentOwner = null;
|
|
6
|
+
function createOwner(parent) {
|
|
7
|
+
const owner = {
|
|
8
|
+
cleanups: [],
|
|
9
|
+
children: [],
|
|
10
|
+
parent,
|
|
11
|
+
contexts: null,
|
|
12
|
+
disposed: false,
|
|
13
|
+
};
|
|
14
|
+
if (parent)
|
|
15
|
+
parent.children.push(owner);
|
|
16
|
+
return owner;
|
|
17
|
+
}
|
|
18
|
+
function disposeOwner(owner) {
|
|
19
|
+
if (owner.disposed)
|
|
20
|
+
return;
|
|
21
|
+
owner.disposed = true;
|
|
22
|
+
// children first (reverse creation order), then this owner's cleanups.
|
|
23
|
+
for (let i = owner.children.length - 1; i >= 0; i--) {
|
|
24
|
+
disposeOwner(owner.children[i]);
|
|
25
|
+
}
|
|
26
|
+
owner.children.length = 0;
|
|
27
|
+
for (let i = owner.cleanups.length - 1; i >= 0; i--) {
|
|
28
|
+
owner.cleanups[i]();
|
|
29
|
+
}
|
|
30
|
+
owner.cleanups.length = 0;
|
|
31
|
+
// detach from parent
|
|
32
|
+
if (owner.parent) {
|
|
33
|
+
const siblings = owner.parent.children;
|
|
34
|
+
const idx = siblings.indexOf(owner);
|
|
35
|
+
if (idx >= 0)
|
|
36
|
+
siblings.splice(idx, 1);
|
|
37
|
+
owner.parent = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function runUnder(owner, fn) {
|
|
41
|
+
const prev = currentOwner;
|
|
42
|
+
currentOwner = owner;
|
|
43
|
+
try {
|
|
44
|
+
return fn();
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
currentOwner = prev;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Root owner (a mount point). `fn` receives its dispose handle. */
|
|
51
|
+
export function root(fn) {
|
|
52
|
+
const owner = createOwner(null);
|
|
53
|
+
return runUnder(owner, () => fn(() => disposeOwner(owner)));
|
|
54
|
+
}
|
|
55
|
+
/** Child owner under the current one. Returns its value and dispose handle. */
|
|
56
|
+
export function scope(fn) {
|
|
57
|
+
const owner = createOwner(currentOwner);
|
|
58
|
+
const value = runUnder(owner, fn);
|
|
59
|
+
return { value, dispose: () => disposeOwner(owner) };
|
|
60
|
+
}
|
|
61
|
+
/** Register a cleanup in the current owner (no-op outside any owner). */
|
|
62
|
+
export function onCleanup(fn) {
|
|
63
|
+
if (currentOwner)
|
|
64
|
+
currentOwner.cleanups.push(fn);
|
|
65
|
+
}
|
|
66
|
+
/** Attach an frp subscription to the current owner's lifecycle. */
|
|
67
|
+
function bind(un) {
|
|
68
|
+
onCleanup(un);
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// JSX factory (§6.4)
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
74
|
+
// SVG-specific tag names. Ambiguous names shared with HTML (a, title, script,
|
|
75
|
+
// style) are treated as HTML; use them inside `foreignObject` for HTML content.
|
|
76
|
+
const SVG_TAGS = new Set([
|
|
77
|
+
"svg",
|
|
78
|
+
"g",
|
|
79
|
+
"defs",
|
|
80
|
+
"symbol",
|
|
81
|
+
"use",
|
|
82
|
+
"image",
|
|
83
|
+
"switch",
|
|
84
|
+
"foreignObject",
|
|
85
|
+
"path",
|
|
86
|
+
"rect",
|
|
87
|
+
"circle",
|
|
88
|
+
"ellipse",
|
|
89
|
+
"line",
|
|
90
|
+
"polyline",
|
|
91
|
+
"polygon",
|
|
92
|
+
"text",
|
|
93
|
+
"tspan",
|
|
94
|
+
"textPath",
|
|
95
|
+
"marker",
|
|
96
|
+
"desc",
|
|
97
|
+
"metadata",
|
|
98
|
+
"view",
|
|
99
|
+
"linearGradient",
|
|
100
|
+
"radialGradient",
|
|
101
|
+
"stop",
|
|
102
|
+
"clipPath",
|
|
103
|
+
"mask",
|
|
104
|
+
"pattern",
|
|
105
|
+
"filter",
|
|
106
|
+
"feGaussianBlur",
|
|
107
|
+
"feOffset",
|
|
108
|
+
"feBlend",
|
|
109
|
+
"feColorMatrix",
|
|
110
|
+
"feComposite",
|
|
111
|
+
"feFlood",
|
|
112
|
+
"feMerge",
|
|
113
|
+
"feMergeNode",
|
|
114
|
+
"feImage",
|
|
115
|
+
"feTile",
|
|
116
|
+
"feMorphology",
|
|
117
|
+
"feDisplacementMap",
|
|
118
|
+
"feTurbulence",
|
|
119
|
+
"animate",
|
|
120
|
+
"animateTransform",
|
|
121
|
+
"animateMotion",
|
|
122
|
+
"mpath",
|
|
123
|
+
"set",
|
|
124
|
+
]);
|
|
125
|
+
function createEl(tag) {
|
|
126
|
+
return SVG_TAGS.has(tag)
|
|
127
|
+
? document.createElementNS(SVG_NS, tag)
|
|
128
|
+
: document.createElement(tag);
|
|
129
|
+
}
|
|
130
|
+
function toText(v) {
|
|
131
|
+
return v == null ? "" : String(v);
|
|
132
|
+
}
|
|
133
|
+
function appendChild(parent, child) {
|
|
134
|
+
if (child == null || child === false || child === true)
|
|
135
|
+
return;
|
|
136
|
+
if (Array.isArray(child)) {
|
|
137
|
+
for (const c of child)
|
|
138
|
+
appendChild(parent, c);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (child instanceof Behavior) {
|
|
142
|
+
// fine-grained: one text node bound to one behavior
|
|
143
|
+
const text = document.createTextNode("");
|
|
144
|
+
bind(child.listen((v) => (text.data = toText(v))));
|
|
145
|
+
parent.appendChild(text);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (child instanceof Node) {
|
|
149
|
+
parent.appendChild(child);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
parent.appendChild(document.createTextNode(String(child)));
|
|
153
|
+
}
|
|
154
|
+
function applyRef(ref, el) {
|
|
155
|
+
if (typeof ref === "function")
|
|
156
|
+
ref(el);
|
|
157
|
+
else if (ref && typeof ref === "object")
|
|
158
|
+
ref.current = el;
|
|
159
|
+
}
|
|
160
|
+
function setProp(el, key, value) {
|
|
161
|
+
if (key === "class" || key === "className") {
|
|
162
|
+
// SVG elements have a read-only `className` (SVGAnimatedString).
|
|
163
|
+
if (el.namespaceURI === SVG_NS) {
|
|
164
|
+
if (value == null)
|
|
165
|
+
el.removeAttribute("class");
|
|
166
|
+
else
|
|
167
|
+
el.setAttribute("class", String(value));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
el.className = value == null ? "" : String(value);
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (key === "style") {
|
|
175
|
+
if (value && typeof value === "object") {
|
|
176
|
+
Object.assign(el.style, value);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
el.setAttribute("style", value == null ? "" : String(value));
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (key === "value" || key === "checked") {
|
|
184
|
+
el[key] = value;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (typeof value === "boolean") {
|
|
188
|
+
if (value)
|
|
189
|
+
el.setAttribute(key, "");
|
|
190
|
+
else
|
|
191
|
+
el.removeAttribute(key);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (value == null) {
|
|
195
|
+
el.removeAttribute(key);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
el.setAttribute(key, String(value));
|
|
199
|
+
}
|
|
200
|
+
function applyProps(el, props) {
|
|
201
|
+
for (const key in props) {
|
|
202
|
+
if (key === "children")
|
|
203
|
+
continue;
|
|
204
|
+
const value = props[key];
|
|
205
|
+
if (key === "ref") {
|
|
206
|
+
applyRef(value, el);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (key.length > 2 && key.startsWith("on")) {
|
|
210
|
+
const evt = key.slice(2).toLowerCase();
|
|
211
|
+
const handler = value;
|
|
212
|
+
el.addEventListener(evt, handler);
|
|
213
|
+
bind(() => el.removeEventListener(evt, handler));
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (value instanceof Behavior) {
|
|
217
|
+
bind(value.listen((v) => setProp(el, key, v)));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
setProp(el, key, value);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Fragment marker for `--jsxFragmentFactory Fragment`. */
|
|
224
|
+
export function Fragment(props) {
|
|
225
|
+
const frag = document.createDocumentFragment();
|
|
226
|
+
appendChild(frag, props?.children ?? null);
|
|
227
|
+
return frag;
|
|
228
|
+
}
|
|
229
|
+
/** JSX factory (`--jsxFactory h`). A component function runs exactly once. */
|
|
230
|
+
export function h(tag, props, ...children) {
|
|
231
|
+
if (tag === Fragment) {
|
|
232
|
+
const frag = document.createDocumentFragment();
|
|
233
|
+
for (const c of children)
|
|
234
|
+
appendChild(frag, c);
|
|
235
|
+
return frag;
|
|
236
|
+
}
|
|
237
|
+
if (typeof tag === "function") {
|
|
238
|
+
return tag({ ...(props || {}), children });
|
|
239
|
+
}
|
|
240
|
+
const el = createEl(tag);
|
|
241
|
+
if (props)
|
|
242
|
+
applyProps(el, props);
|
|
243
|
+
for (const c of children)
|
|
244
|
+
appendChild(el, c);
|
|
245
|
+
return el;
|
|
246
|
+
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Mounting
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Dynamic regions (§7)
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Build `child` into a fragment under a fresh scope of `owner`. Returns the
|
|
254
|
+
// fragment's top-level nodes and the scope's dispose handle.
|
|
255
|
+
function buildScoped(owner, build) {
|
|
256
|
+
const s = runUnder(owner, () => scope(() => {
|
|
257
|
+
const built = build();
|
|
258
|
+
// Fast path: a single element/text node (the common row/component case)
|
|
259
|
+
// needs no fragment or NodeList copy.
|
|
260
|
+
if (built instanceof Node &&
|
|
261
|
+
built.nodeType !== 11 /* DocumentFragment */) {
|
|
262
|
+
return [built];
|
|
263
|
+
}
|
|
264
|
+
const frag = document.createDocumentFragment();
|
|
265
|
+
appendChild(frag, built);
|
|
266
|
+
return Array.from(frag.childNodes);
|
|
267
|
+
}));
|
|
268
|
+
return { nodes: s.value, dispose: s.dispose };
|
|
269
|
+
}
|
|
270
|
+
/** Conditional / switching subtree: rebuilds on each change of `b`. */
|
|
271
|
+
export function dyn(b, render) {
|
|
272
|
+
const owner = currentOwner;
|
|
273
|
+
const start = document.createComment("dyn");
|
|
274
|
+
const end = document.createComment("/dyn");
|
|
275
|
+
const frag = document.createDocumentFragment();
|
|
276
|
+
frag.appendChild(start);
|
|
277
|
+
frag.appendChild(end);
|
|
278
|
+
let current = null;
|
|
279
|
+
const update = (v) => {
|
|
280
|
+
if (current) {
|
|
281
|
+
current.dispose();
|
|
282
|
+
for (const n of current.nodes)
|
|
283
|
+
if (n.parentNode)
|
|
284
|
+
n.parentNode.removeChild(n);
|
|
285
|
+
}
|
|
286
|
+
current = buildScoped(owner, () => render(v));
|
|
287
|
+
const parent = end.parentNode;
|
|
288
|
+
for (const n of current.nodes)
|
|
289
|
+
parent.insertBefore(n, end);
|
|
290
|
+
};
|
|
291
|
+
bind(b.listen(update));
|
|
292
|
+
onCleanup(() => current?.dispose());
|
|
293
|
+
return frag;
|
|
294
|
+
}
|
|
295
|
+
/** Longest strictly-increasing subsequence; returns the set of kept indices. */
|
|
296
|
+
function lisIndices(seq) {
|
|
297
|
+
const n = seq.length;
|
|
298
|
+
const piles = [];
|
|
299
|
+
const prev = new Array(n).fill(-1);
|
|
300
|
+
for (let i = 0; i < n; i++) {
|
|
301
|
+
const x = seq[i];
|
|
302
|
+
if (x < 0)
|
|
303
|
+
continue; // freshly-created rows are never "stable"
|
|
304
|
+
let lo = 0;
|
|
305
|
+
let hi = piles.length;
|
|
306
|
+
while (lo < hi) {
|
|
307
|
+
const mid = (lo + hi) >> 1;
|
|
308
|
+
if (seq[piles[mid]] < x)
|
|
309
|
+
lo = mid + 1;
|
|
310
|
+
else
|
|
311
|
+
hi = mid;
|
|
312
|
+
}
|
|
313
|
+
if (lo > 0)
|
|
314
|
+
prev[i] = piles[lo - 1];
|
|
315
|
+
piles[lo] = i;
|
|
316
|
+
}
|
|
317
|
+
const keep = new Set();
|
|
318
|
+
let k = piles.length ? piles[piles.length - 1] : -1;
|
|
319
|
+
while (k >= 0) {
|
|
320
|
+
keep.add(k);
|
|
321
|
+
k = prev[k];
|
|
322
|
+
}
|
|
323
|
+
return keep;
|
|
324
|
+
}
|
|
325
|
+
/** Keyed list: reuses rows by key, reorders with minimal moves (LIS). */
|
|
326
|
+
export function each(items, key, render) {
|
|
327
|
+
const owner = currentOwner;
|
|
328
|
+
const start = document.createComment("each");
|
|
329
|
+
const end = document.createComment("/each");
|
|
330
|
+
const frag = document.createDocumentFragment();
|
|
331
|
+
frag.appendChild(start);
|
|
332
|
+
frag.appendChild(end);
|
|
333
|
+
let rows = [];
|
|
334
|
+
const update = (list) => {
|
|
335
|
+
const parent = end.parentNode;
|
|
336
|
+
const oldIndex = new Map();
|
|
337
|
+
rows.forEach((r, i) => oldIndex.set(r.key, i));
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
const next = [];
|
|
340
|
+
const seq = [];
|
|
341
|
+
for (const item of list) {
|
|
342
|
+
const k = key(item);
|
|
343
|
+
if (seen.has(k))
|
|
344
|
+
continue; // duplicate keys: keep first
|
|
345
|
+
seen.add(k);
|
|
346
|
+
const prevIdx = oldIndex.get(k);
|
|
347
|
+
if (prevIdx !== undefined) {
|
|
348
|
+
next.push(rows[prevIdx]); // reuse: no re-render
|
|
349
|
+
seq.push(prevIdx);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const built = buildScoped(owner, () => render(item));
|
|
353
|
+
next.push({ key: k, nodes: built.nodes, dispose: built.dispose });
|
|
354
|
+
seq.push(-1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// dispose rows whose key disappeared
|
|
358
|
+
for (const r of rows) {
|
|
359
|
+
if (!seen.has(r.key)) {
|
|
360
|
+
r.dispose();
|
|
361
|
+
for (const n of r.nodes)
|
|
362
|
+
if (n.parentNode)
|
|
363
|
+
n.parentNode.removeChild(n);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// reorder with minimal moves; preserve focus across moves
|
|
367
|
+
const active = (parent.ownerDocument || document).activeElement;
|
|
368
|
+
const keep = lisIndices(seq);
|
|
369
|
+
let anchor = end;
|
|
370
|
+
for (let i = next.length - 1; i >= 0; i--) {
|
|
371
|
+
const row = next[i];
|
|
372
|
+
if (!keep.has(i)) {
|
|
373
|
+
for (const n of row.nodes)
|
|
374
|
+
parent.insertBefore(n, anchor);
|
|
375
|
+
}
|
|
376
|
+
anchor = row.nodes[0] ?? anchor;
|
|
377
|
+
}
|
|
378
|
+
if (active instanceof HTMLElement && active.isConnected)
|
|
379
|
+
active.focus();
|
|
380
|
+
rows = next;
|
|
381
|
+
};
|
|
382
|
+
bind(items.listen(update));
|
|
383
|
+
onCleanup(() => {
|
|
384
|
+
for (const r of rows)
|
|
385
|
+
r.dispose();
|
|
386
|
+
});
|
|
387
|
+
return frag;
|
|
388
|
+
}
|
|
389
|
+
export function createContext(defaultValue) {
|
|
390
|
+
return { id: Symbol("context"), defaultValue };
|
|
391
|
+
}
|
|
392
|
+
/** Write a context value into the current owner. */
|
|
393
|
+
export function provide(ctx, value) {
|
|
394
|
+
if (currentOwner) {
|
|
395
|
+
(currentOwner.contexts ?? (currentOwner.contexts = new Map())).set(ctx.id, value);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** Read the nearest provided value up the owner tree, else the default. */
|
|
399
|
+
export function use(ctx) {
|
|
400
|
+
let o = currentOwner;
|
|
401
|
+
while (o) {
|
|
402
|
+
if (o.contexts && o.contexts.has(ctx.id)) {
|
|
403
|
+
return o.contexts.get(ctx.id);
|
|
404
|
+
}
|
|
405
|
+
o = o.parent;
|
|
406
|
+
}
|
|
407
|
+
return ctx.defaultValue;
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Rendering helpers (§14 roadmap #9)
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// A behavior that only emits updates when its value actually changes.
|
|
413
|
+
// The dedup memory is seeded with the current value, so re-emitting the
|
|
414
|
+
// initial value does not trigger a rebuild.
|
|
415
|
+
function distinctB(b) {
|
|
416
|
+
const out = new Event(b.updates.rank + 1);
|
|
417
|
+
let prev = b.sampleNoTrans();
|
|
418
|
+
b.updates.listen_(out, (t, a) => {
|
|
419
|
+
if (!Object.is(prev, a)) {
|
|
420
|
+
prev = a;
|
|
421
|
+
out.send_(t, a);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
return new Behavior(() => b.sampleNoTrans(), out);
|
|
425
|
+
}
|
|
426
|
+
/** Conditional region driven by a boolean behavior (no rebuild on same value). */
|
|
427
|
+
export function when(cond, thenRender, elseRender) {
|
|
428
|
+
return dyn(distinctB(cond), (c) => c ? thenRender() : elseRender ? elseRender() : null);
|
|
429
|
+
}
|
|
430
|
+
/** Two-way binding props for a text input. Spread onto an `<input>`. */
|
|
431
|
+
export function bindInput(value, set) {
|
|
432
|
+
return {
|
|
433
|
+
value,
|
|
434
|
+
onInput: (e) => set(e.target.value),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** Render `child` into another node, cleaning up on dispose. */
|
|
438
|
+
export function portal(target, child) {
|
|
439
|
+
const built = buildScoped(currentOwner, () => child);
|
|
440
|
+
for (const n of built.nodes)
|
|
441
|
+
target.appendChild(n);
|
|
442
|
+
onCleanup(() => {
|
|
443
|
+
built.dispose();
|
|
444
|
+
for (const n of built.nodes)
|
|
445
|
+
if (n.parentNode)
|
|
446
|
+
n.parentNode.removeChild(n);
|
|
447
|
+
});
|
|
448
|
+
return document.createComment("portal");
|
|
449
|
+
}
|
|
450
|
+
/** Mount a view under a root owner. Returns an unmount function. */
|
|
451
|
+
export function mount(container, view) {
|
|
452
|
+
return root((dispose) => {
|
|
453
|
+
const node = view();
|
|
454
|
+
const nodes = node.nodeType === 11 ? Array.from(node.childNodes) : [node];
|
|
455
|
+
container.appendChild(node);
|
|
456
|
+
onCleanup(() => {
|
|
457
|
+
for (const n of nodes)
|
|
458
|
+
if (n.parentNode)
|
|
459
|
+
n.parentNode.removeChild(n);
|
|
460
|
+
});
|
|
461
|
+
return () => dispose();
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// Animation clock (browser) — a discrete tick source for continuous time.
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
/**
|
|
468
|
+
* An `Event<number>` of `requestAnimationFrame` timestamps (ms). Drives the
|
|
469
|
+
* continuous-time combinators (`integral`/`derivative`/`warp` from the core).
|
|
470
|
+
* Registered against the current owner: it stops automatically on unmount.
|
|
471
|
+
*/
|
|
472
|
+
export function animationFrames() {
|
|
473
|
+
const [ticks, fire] = newEvent();
|
|
474
|
+
let raf = requestAnimationFrame(function loop(t) {
|
|
475
|
+
fire(t);
|
|
476
|
+
raf = requestAnimationFrame(loop);
|
|
477
|
+
});
|
|
478
|
+
onCleanup(() => cancelAnimationFrame(raf));
|
|
479
|
+
return ticks;
|
|
480
|
+
}
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// Control-flow components — JSX wrappers over the rendering helpers.
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// JSX always delivers children as the rest array; a single render function
|
|
485
|
+
// arrives as `[fn]`. Normalize to a render callback.
|
|
486
|
+
function asRender(children) {
|
|
487
|
+
const c = Array.isArray(children) ? children[0] : children;
|
|
488
|
+
return typeof c === "function"
|
|
489
|
+
? c
|
|
490
|
+
: () => c;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Conditional region. Rebuilds only when the truthiness of `when` toggles; the
|
|
494
|
+
* (narrowed) value is passed to the children render function at build time.
|
|
495
|
+
*
|
|
496
|
+
* ```tsx
|
|
497
|
+
* <Show when={user} fallback={() => <Guest />}>
|
|
498
|
+
* {(u) => <span>{u.name}</span>}
|
|
499
|
+
* </Show>
|
|
500
|
+
* ```
|
|
501
|
+
*/
|
|
502
|
+
export function Show(props) {
|
|
503
|
+
const render = asRender(props.children);
|
|
504
|
+
const present = props.when.map((v) => !!v);
|
|
505
|
+
return when(present, () => render(props.when.sample()), props.fallback);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Keyed list. The `by` key selector defaults to identity. (Note: `key` is a
|
|
509
|
+
* reserved JSX attribute stripped by the compiler, so the prop is named `by`.)
|
|
510
|
+
*
|
|
511
|
+
* ```tsx
|
|
512
|
+
* <Each each={items} by={(i) => i.id}>{(item) => <li>{item.name}</li>}</Each>
|
|
513
|
+
* ```
|
|
514
|
+
*/
|
|
515
|
+
export function Each(props) {
|
|
516
|
+
const render = asRender(props.children);
|
|
517
|
+
const key = props.by ?? ((item) => item);
|
|
518
|
+
return each(props.each, key, render);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Switch the subtree on a behavior's value (rebuilds on every change).
|
|
522
|
+
*
|
|
523
|
+
* ```tsx
|
|
524
|
+
* <Dynamic value={route}>{(r) => r === "home" ? <Home /> : <About />}</Dynamic>
|
|
525
|
+
* ```
|
|
526
|
+
*/
|
|
527
|
+
export function Dynamic(props) {
|
|
528
|
+
return dyn(props.value, asRender(props.children));
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Render children into another node (e.g. `document.body`), cleaning up on
|
|
532
|
+
* unmount. Children are eager here — a portal renders immediately.
|
|
533
|
+
*
|
|
534
|
+
* ```tsx
|
|
535
|
+
* <Portal mount={document.body}><Modal /></Portal>
|
|
536
|
+
* ```
|
|
537
|
+
*/
|
|
538
|
+
export function Portal(props) {
|
|
539
|
+
return portal(props.mount, props.children ?? null);
|
|
540
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Continuum — automatic JSX runtime (development variant).
|
|
2
|
+
// `jsxDEV` receives extra debug args (key, isStaticChildren, source, self);
|
|
3
|
+
// we ignore them and delegate to the production adapter.
|
|
4
|
+
import { jsx } from "./jsx-runtime.js";
|
|
5
|
+
export { Fragment } from "./jsx-runtime.js";
|
|
6
|
+
export function jsxDEV(type, props) {
|
|
7
|
+
return jsx(type, props);
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Fragment as Frag } from "./index.js";
|
|
2
|
+
import type { Child } from "./index.js";
|
|
3
|
+
export declare const Fragment: typeof Frag;
|
|
4
|
+
type RuntimeProps = {
|
|
5
|
+
children?: Child;
|
|
6
|
+
} & Record<string, unknown>;
|
|
7
|
+
/** Single/no static child. */
|
|
8
|
+
export declare function jsx(type: unknown, props: RuntimeProps | null): Node;
|
|
9
|
+
/** Multiple static children (children is an array). */
|
|
10
|
+
export declare function jsxs(type: unknown, props: RuntimeProps | null): Node;
|
|
11
|
+
export declare namespace JSX {
|
|
12
|
+
type Element = Node;
|
|
13
|
+
interface ElementChildrenAttribute {
|
|
14
|
+
children: Record<string, never>;
|
|
15
|
+
}
|
|
16
|
+
interface IntrinsicElements {
|
|
17
|
+
[elem: string]: any;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Continuum — automatic JSX runtime.
|
|
2
|
+
// With `"jsx": "react-jsx"` + `"jsxImportSource": "@continuum-js/dom"`, the
|
|
3
|
+
// compiler auto-imports these, so components no longer need `import { h }`.
|
|
4
|
+
// They are thin adapters over `h`: the automatic runtime delivers children in
|
|
5
|
+
// `props.children`, whereas `h` takes them as rest arguments.
|
|
6
|
+
import { h, Fragment as Frag } from "./index.js";
|
|
7
|
+
export const Fragment = Frag;
|
|
8
|
+
function build(type, props) {
|
|
9
|
+
const { children, ...rest } = props ?? {};
|
|
10
|
+
const kids = children === undefined
|
|
11
|
+
? []
|
|
12
|
+
: Array.isArray(children)
|
|
13
|
+
? children
|
|
14
|
+
: [children];
|
|
15
|
+
return h(type, rest, ...kids);
|
|
16
|
+
}
|
|
17
|
+
/** Single/no static child. */
|
|
18
|
+
export function jsx(type, props) {
|
|
19
|
+
return build(type, props);
|
|
20
|
+
}
|
|
21
|
+
/** Multiple static children (children is an array). */
|
|
22
|
+
export function jsxs(type, props) {
|
|
23
|
+
return build(type, props);
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@continuum-js/dom",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Continuum DOM renderer — fine-grained bindings, dynamic regions, ownership, context",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/denislibs/continuum.git",
|
|
9
|
+
"directory": "packages/dom"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./jsx-runtime": {
|
|
21
|
+
"types": "./dist/jsx-runtime.d.ts",
|
|
22
|
+
"import": "./dist/jsx-runtime.js"
|
|
23
|
+
},
|
|
24
|
+
"./jsx-dev-runtime": {
|
|
25
|
+
"types": "./dist/jsx-dev-runtime.d.ts",
|
|
26
|
+
"import": "./dist/jsx-dev-runtime.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@continuum-js/frp": "0.3.0"
|
|
42
|
+
}
|
|
43
|
+
}
|