@alixex/ranview 0.1.0-alpha.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 chaxus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,446 @@
1
+ # ranview — fluent DOM + fine-grained reactivity
2
+
3
+ A framework-free way to build DOM declaratively, with SwiftUI/Solid-style fine-grained
4
+ reactivity. No virtual DOM, no re-render of a whole tree — a signal change updates only
5
+ the exact node bound to it.
6
+
7
+ It runs in a browser against real elements, and in Node against a mock that serialises to
8
+ HTML, so the same view code renders client-side or to a string.
9
+
10
+ This is the view layer under [ranui](https://www.npmjs.com/package/ranui)'s 40 components.
11
+ It lived inside that package until it outgrew it: a general-purpose library should not
12
+ require you to install a video player, a diagram renderer and a maths typesetter to use
13
+ it. `ranui/builder` still re-exports everything here, unchanged.
14
+
15
+ ```sh
16
+ npm i @alixex/ranview
17
+ ```
18
+
19
+ > **Principle: build once, update in place.** A view function runs once; state
20
+ > changes flow through fine-grained bindings, never by re-running the view. Pick
21
+ > the primitive that matches the shape — value → getter binding; conditional →
22
+ > [`Show`](#conditionals--show) / [`Switch`](#multi-branch--switch--match); list →
23
+ > [`For`](#keyed-lists--for) / [`Index`](#index-lists--index). A raw getter child
24
+ > is the coarse fallback (rebuilds its whole region on any read).
25
+
26
+ ```ts
27
+ import {
28
+ View,
29
+ Div,
30
+ Span,
31
+ ButtonBuilder, // element factories
32
+ signal,
33
+ computed,
34
+ createEffect,
35
+ batch,
36
+ untrack, // reactivity
37
+ createRoot,
38
+ onCleanup,
39
+ getOwner,
40
+ runWithOwner, // ownership
41
+ EventManager, // lifecycle-scoped events
42
+ } from '@alixex/ranview';
43
+ ```
44
+
45
+ The builder does **not** register custom elements. To use `<r-button>` etc.,
46
+ also import the component subpath once: `import 'ranui/button'`.
47
+
48
+ ---
49
+
50
+ ## 1. Elements
51
+
52
+ Factories return an `ElementBuilder` (chainable). `build()` returns the DOM node.
53
+
54
+ ```ts
55
+ const box = Div().class('card').text('hi').build();
56
+
57
+ const submit = View('r-button') // any tag, incl. custom elements
58
+ .attr('type', 'primary')
59
+ .text('Save')
60
+ .on('click', onSave)
61
+ .build();
62
+ ```
63
+
64
+ ### Markup that is already HTML
65
+
66
+ `text()` escapes; `unsafeHtml()` does not. It is the only way to put content
67
+ that _is_ HTML into a builder tree — rendered markdown, a fragment assembled
68
+ elsewhere, a translated string carrying its own emphasis.
69
+
70
+ ```ts
71
+ View('p').unsafeHtml('Local-first · <b>open source</b>').build();
72
+ ```
73
+
74
+ Everything handed to it is parsed as markup, so sanitize anything that came from
75
+ outside the program first. Like `textContent`, it replaces whatever content the
76
+ element had, and the last of `text()` / `unsafeHtml()` to run wins.
77
+
78
+ ### SVG
79
+
80
+ Tags that only exist in SVG (`svg`, `path`, `circle`, `g`, …) are created in the
81
+ SVG namespace automatically — `document.createElement('svg')` would return an
82
+ `HTMLUnknownElement` that the browser does not render as SVG, and whose
83
+ `viewBox` would be flattened to `viewbox` and then ignored.
84
+
85
+ ```ts
86
+ const icon = View('svg')
87
+ .attrs({ viewBox: '0 0 16 16', 'aria-hidden': 'true' })
88
+ .children(View('circle').attrs({ cx: '8', cy: '8', r: '6.25' }).build())
89
+ .build();
90
+ ```
91
+
92
+ The tags SVG shares with HTML — `a`, `script`, `style`, `title` — stay HTML,
93
+ since guessing would break the far commoner case. Use `Svg()` when one of those
94
+ is meant as SVG:
95
+
96
+ ```ts
97
+ Svg('a').attr('href', '#').children(View('path').attr('d', 'M1 1').build()).build();
98
+ ```
99
+
100
+ Factories: `View(tag)`, `Div`, `Span`, `Slot`, `ButtonBuilder`, `InputBuilder`,
101
+ `Label`, `Ul`, `Li`, `Section`, `Article`, `Nav`, `Header`, `Footer`, `Main`,
102
+ `Style`, `DeclarativeShadow`. For anything else use `View('tag')`.
103
+
104
+ ### Chainable API
105
+
106
+ | Group | Methods |
107
+ | -------------- | ----------------------------------------------------------------------------------------------------------- |
108
+ | Identity/class | `id(v)`, `class(v)`, `addClass(...v)`, `removeClass(...v)` |
109
+ | Attributes | `attr(name, v)`, `attrs({...})`, `boolAttr(name, on, enabledValue?)`, `part(v)`, `data(key, v)` |
110
+ | Style | `style(prop, v)` / `style({...})`, `cssVar(name, v)` |
111
+ | A11y | `aria(key, v)`, `role(v)`, `tabIndex(n)`, `label(v)`, `labelledBy(id)`, `describedBy(id)`, `ariaHidden(b?)` |
112
+ | Content | `text(v)`, `children(...nodes)`, `replaceChildren(...nodes)` |
113
+ | Refs/shadow | `ref(holder)`, `shadow(opts?)` → `ShadowBuilder` |
114
+ | Terminal | `build()`, `serialize()` (SSR HTML string) |
115
+
116
+ `children()` accepts elements, strings, other `ElementBuilder`s, arrays,
117
+ `null`/`undefined` (skipped), **and getters `() => …`** (reactive regions, see
118
+ [Reactive children](#reactive-children)) — nest freely:
119
+
120
+ ```ts
121
+ Ul()
122
+ .class('list')
123
+ .children(
124
+ items.map((it) => Li().text(it.name)),
125
+ extra && Li().text('extra'),
126
+ )
127
+ .build();
128
+ ```
129
+
130
+ ### Events
131
+
132
+ ```ts
133
+ .on(type, handler, options?) // permanent listener (element lifetime)
134
+ .listen(manager, type, handler) // lifecycle-managed (removed via manager.abort())
135
+ .delegate(manager, selector, type, handler) // event delegation on a container
136
+ ```
137
+
138
+ ### Refs (incl. imperative custom-element methods)
139
+
140
+ `createRef<T>()` + `.ref(holder)` capture the built element. For a custom
141
+ element with imperative methods, **type the ref with the component's element
142
+ class** (each component exports it) — then its methods are typed, no cast:
143
+
144
+ ```ts
145
+ import { Popover } from 'ranui'; // the element class
146
+ import { View, createRef } from '@alixex/ranview';
147
+
148
+ const ref = createRef<Popover>();
149
+ View<Popover>('r-popover').attr('trigger', 'click').ref(ref).children(/* … */).build();
150
+ ref.current?.closePopover(); // typed method, no `as` cast
151
+ ```
152
+
153
+ ---
154
+
155
+ ## 2. Reactivity
156
+
157
+ ```ts
158
+ const [count, setCount] = signal(0);
159
+ count(); // read (tracked inside effects/memos)
160
+ setCount(1); // write; setCount(n => n + 1) also works
161
+ setCount(1); // no-op if unchanged (Object.is; override via signal(v, { equals }))
162
+
163
+ const double = computed(() => count() * 2); // LAZY + memoized: recomputes only
164
+ double(); // when read after a dep changed
165
+ // A memo re-notifies its readers only when its VALUE changes (override the
166
+ // comparison via computed(fn, { equals })), so effects behind a stable memo sleep.
167
+
168
+ const dispose = createEffect(() => {
169
+ // runs now + on every dependency change
170
+ console.log(count());
171
+ return () => {
172
+ /* optional cleanup, runs before re-run and on dispose */
173
+ };
174
+ });
175
+
176
+ batch(() => {
177
+ setCount(1);
178
+ setName('x');
179
+ }); // one flush, effects deduped
180
+ untrack(() => count()); // read without subscribing
181
+ ```
182
+
183
+ - **`computed` is lazy** — an unread memo never recomputes. Diamond dependencies
184
+ run a dependent effect **once** per change, not once per path.
185
+ - **Effects auto-track**: only signals actually read on the latest run are
186
+ subscribed (conditionals never leave stale subscriptions).
187
+ - **Cyclic guard**: an effect that writes a signal it reads throws instead of
188
+ looping forever.
189
+
190
+ ### Reactive bindings
191
+
192
+ Any of `text`, `attr`, `class`, `boolAttr`, `style`, `part`, `data`, `aria`,
193
+ `role`, `label` accepts a **getter** — pass a signal (or `computed`) and the
194
+ attribute/text updates itself, no manual effect:
195
+
196
+ ```ts
197
+ const [label, setLabel] = signal('Connect');
198
+ const btn = View('r-button').text(label).build(); // updates when setLabel(...) runs
199
+
200
+ const [active, setActive] = signal(true);
201
+ Div()
202
+ .class(() => (active() ? 'row active' : 'row')) // recomputes on toggle
203
+ .boolAttr('disabled', () => !active())
204
+ .build();
205
+ ```
206
+
207
+ ### Reactive children
208
+
209
+ `children()` / `replaceChildren()` also take a **getter** as any argument. The
210
+ getter marks a live region: it re-runs when a signal it reads changes, replacing
211
+ the region — no `createRef` + `createEffect` + `replaceChildren` boilerplate.
212
+ Return a node, a `null` (conditional), or an array (list). Static siblings around
213
+ the getter keep their position.
214
+
215
+ > **Semantics — this is a full rebuild, not a keyed diff.** On each change the
216
+ > whole region is torn down and re-created; there is no per-item keying, and it
217
+ > re-runs on **every** change the getter reads — even one that doesn't alter the
218
+ > result. It is the coarse escape hatch. Prefer the fine-grained primitives:
219
+ > [`Show`](#conditionals--show) for conditionals (rebuilds only when the branch
220
+ > flips) and [`For`](#keyed-lists--for) for lists (reuses nodes by key). Reach for
221
+ > a raw getter only for content whose shape genuinely changes on every update.
222
+
223
+ ### Conditionals — `Show`
224
+
225
+ `Show` is a **fine-grained** conditional: it rebuilds a branch only when the
226
+ _truthiness_ of `when` flips, not on every change `when` reads. Content inside a
227
+ branch updates through its own bindings — the branch is built once. (A raw getter
228
+ child, by contrast, tears down and rebuilds on every dependency tick.)
229
+
230
+ ```ts
231
+ import { Show } from '@alixex/ranview';
232
+
233
+ Div().children(
234
+ Show({
235
+ when: () => user(), // reads a signal
236
+ children: (u) => Span().text(() => u().name), // built once; text updates in place
237
+ fallback: () => Span().text('Signed out'), // optional
238
+ }),
239
+ );
240
+ ```
241
+
242
+ `children` gets an accessor to the narrowed truthy value — read it inside a
243
+ binding so it updates without rebuilding. `Show` returns a getter, so it slots
244
+ into `children()` anywhere.
245
+
246
+ **Control flow composes.** A `Show`/`Switch` branch (and any reactive getter) may
247
+ return **any `Child`** — including a `For`/`Index` list or a nested `Show` — no
248
+ wrapper element needed:
249
+
250
+ ```ts
251
+ Show({
252
+ when: () => loaded(),
253
+ children: () => For({ each: () => rows(), key: (r) => r.id, render: (r) => Li().text(r.title) }),
254
+ fallback: () => Spinner(),
255
+ });
256
+ ```
257
+
258
+ When the branch flips away, the nested list's scope (its effects) is disposed and
259
+ its nodes removed; re-entering rebuilds fresh from the current source.
260
+
261
+ ### Multi-branch — `Switch` / `Match`
262
+
263
+ `Switch` is the n-way `Show`: it renders the **first** `Match` whose `when` is
264
+ truthy (else `fallback`), and is equally fine-grained — only the _index_ of the
265
+ winning branch is memoized, so it rebuilds only when the active branch changes.
266
+ Evaluation short-circuits at the first match.
267
+
268
+ ```ts
269
+ import { Switch, Match } from '@alixex/ranview';
270
+
271
+ Div().children(
272
+ Switch({
273
+ fallback: () => Span().text('idle'),
274
+ children: [
275
+ Match({ when: () => status() === 'loading', children: () => Spinner() }),
276
+ Match({ when: () => error(), children: (e) => ErrorView(e) }), // e: accessor to the truthy value
277
+ ],
278
+ }),
279
+ );
280
+ ```
281
+
282
+ ```ts
283
+ // Conditional — toggles a node in/out between static siblings
284
+ Div().children(Header().text('H'), () => (open() ? Div().class('panel').text('body') : null), Footer().text('F'));
285
+
286
+ // List — reconciles as the array changes
287
+ Ul().children(() => rows().map((r) => Li().text(r.title)));
288
+ ```
289
+
290
+ On **SSR** a getter is evaluated once (static snapshot); reactivity is a
291
+ client-only concern. Like all bindings, reactive children must be built inside a
292
+ `createRoot` so their effects are owned and disposed with the page (see §3 Ownership).
293
+
294
+ ### Keyed lists — `For`
295
+
296
+ For lists that add/remove/reorder, `For` matches items by `key` and **reuses
297
+ their DOM nodes** — only changed items touch the DOM, so focus, scroll, input
298
+ values and transitions inside surviving rows are preserved (a plain getter child
299
+ would rebuild all of them). Pass the handle straight to `children()`.
300
+
301
+ ```ts
302
+ import { For } from '@alixex/ranview';
303
+
304
+ const [rows, setRows] = signal([{ id: 1, title: 'a' }]);
305
+
306
+ Ul().children(
307
+ For({
308
+ each: () => rows(), // reactive source array
309
+ key: (r) => r.id, // stable, UNIQUE identity per item
310
+ render: (r, index) => Li().text(() => `${index()}. ${r.title}`),
311
+ }),
312
+ );
313
+ ```
314
+
315
+ - `key` **must be unique** — it is how a node is matched to its item across
316
+ updates. A duplicate key is ignored (only the first item with it renders) and
317
+ warned in dev. (Don't use the array index as the key — that defeats reuse on
318
+ reorder.)
319
+ - `each` reads a signal, so update it with a **new array** (`setRows([...])`).
320
+ Mutating the same array in place and re-setting it is skipped (`Object.is`
321
+ equality), and the list won't update.
322
+ - `render` runs **once per item**, not on every list change. Drive per-row
323
+ updates with signals (e.g. `.text(() => …)`); `index` is a **getter** so it
324
+ stays correct after reorders.
325
+ - Removing an item disposes that row's scope (its effects/cleanups). The whole
326
+ list is disposed with its owning `createRoot`.
327
+ - **SSR**: rendered once as a static snapshot (no reconciliation).
328
+
329
+ ### Index lists — `Index`
330
+
331
+ `Index` is the position-keyed counterpart to `For`: the node at index `i` is
332
+ reused across updates and its **item is a signal** — when the value at that
333
+ position changes, it updates in place (the node is not rebuilt); nodes never
334
+ move. Use it when position is the identity (primitive arrays, fixed rows); use
335
+ `For` when items have a stable id and can reorder.
336
+
337
+ ```ts
338
+ import { Index } from '@alixex/ranview';
339
+
340
+ Ul().children(
341
+ Index({
342
+ each: () => nums(),
343
+ render: (n, i) => Li().text(() => `${i}: ${n()}`), // n is an accessor; i is a fixed number
344
+ }),
345
+ );
346
+ ```
347
+
348
+ Rule of thumb: **`Show`/`Switch` for conditionals; `For` (keyed by id) or `Index`
349
+ (keyed by position) for lists; a raw getter child only for content that changes
350
+ shape on every update.**
351
+
352
+ A getter binding creates an effect **owned by the current scope** (see below), so
353
+ it is cleaned up automatically when that scope is disposed. Reactivity applies to
354
+ the single-value forms — `style(prop, getter)`, not the `style({...})` /
355
+ `attrs({...})` map forms, which apply once. A getter dropped into a map is called
356
+ once and never re-runs.
357
+
358
+ ---
359
+
360
+ ## 3. Ownership — the key to leak-free apps (and MPAs)
361
+
362
+ Every effect/memo (including reactive bindings) is owned by the scope that
363
+ created it. Disposing a scope disposes everything under it — nested effects,
364
+ bindings, and `onCleanup` callbacks.
365
+
366
+ ```ts
367
+ import { createRoot, onCleanup } from '@alixex/ranview';
368
+
369
+ const dispose = createRoot((dispose) => {
370
+ const el = Div().text(msg).build(); // this binding is owned by the root
371
+ onCleanup(() => console.log('torn down'));
372
+ mount(el);
373
+ return dispose;
374
+ });
375
+
376
+ // later:
377
+ dispose(); // removes the binding's effect + runs cleanups
378
+ ```
379
+
380
+ Build reactive UI **inside a `createRoot`**. Bindings created with no owner still
381
+ work but won't auto-dispose (dispose them manually).
382
+
383
+ > **Inside a Web Component?** A component's `constructor`/`connectedCallback` are
384
+ > _not_ reactive scopes, so a getter binding or `createEffect` created there is
385
+ > orphaned (never disposed). Don't use getter bindings in component code — build
386
+ > with plain values and drive updates with explicit `createEffect`s whose dispose
387
+ > functions you collect and call in `disconnectedCallback`, re-arming on reconnect.
388
+ > See the ranui component guide (`CLAUDE.md` → "Using reactivity inside a component").
389
+
390
+ ### Multi-page / SPA pattern
391
+
392
+ Give each page/route its own root and dispose it on navigation — every effect,
393
+ binding, timer, and listener that page created is torn down in one call:
394
+
395
+ ```ts
396
+ let disposePage: (() => void) | null = null;
397
+
398
+ function showPage(render: (host: HTMLElement) => void, host: HTMLElement) {
399
+ disposePage?.(); // tear down the previous page
400
+ disposePage = createRoot((dispose) => {
401
+ render(host); // effects/bindings register to this root
402
+ return dispose;
403
+ });
404
+ }
405
+ ```
406
+
407
+ ranui's `<r-route>` has this built in. Two modes:
408
+
409
+ - **Static (default)** — slotted content, shown/hidden by URL; all routes stay in
410
+ the DOM (great for SSG — every page is pre-rendered): `<r-route path="/a">…</r-route>`.
411
+ - **Lazy (`src`)** — code-split, mount on match / unmount on leave:
412
+ `<r-route path="/a" src="/pages/a.js">`. On match the module is dynamically
413
+ imported and its `default: (host) => void | (() => void)` render runs **inside a
414
+ `createRoot`**; on leave that root is disposed — every effect, binding, and
415
+ `onCleanup` the page registered is torn down automatically. The per-page-lifecycle
416
+ mode for larger multi-page apps (client-rendered).
417
+
418
+ `getOwner()` / `runWithOwner()` let a router capture and restore a scope across
419
+ async boundaries.
420
+
421
+ ---
422
+
423
+ ## 4. Web-component listeners — `EventManager`
424
+
425
+ For listeners inside a custom element, use `EventManager` (backed by
426
+ `AbortController`) so `disconnectedCallback` removes them all at once:
427
+
428
+ ```ts
429
+ private events = new EventManager();
430
+ connectedCallback() {
431
+ this.events
432
+ .on(this.input, 'input', this.onInput)
433
+ .delegate(this, '[data-action]', 'click', (e, el) => this.run(el.dataset.action));
434
+ }
435
+ disconnectedCallback() { this.events.abort(); } // resets for the next connect
436
+ ```
437
+
438
+ ---
439
+
440
+ ## Notes
441
+
442
+ - **SSR**: builders work under `defineSSR` / `renderToString`; `build()` returns a
443
+ mock node, `serialize()` returns HTML. Reactive bindings run once during SSR.
444
+ - **Components vs builder**: use registered `<r-*>` elements (see
445
+ [ranui's component list](../ranui/docs/COMPONENTS.md)) for rich widgets; use the builder for
446
+ layout/glue and reactive views. Theme tokens: [ranui's DESIGN.md](../ranui/docs/DESIGN.md).