@pfern/elements 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -5,18 +5,18 @@ Copyright (c) 2025 Paul Fernandez
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
7
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:
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
11
 
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
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
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
21
21
  THE SOFTWARE.
22
22
 
package/README.md CHANGED
@@ -1,38 +1,80 @@
1
- # Elements.js
1
+ <!-- Generated by scripts/sync-readmes.js. Source: README.md -->
2
2
 
3
- Elements.js is a tiny, functional UI toolkit for building DOM trees with plain
4
- functions. Components are just functions; updates are just calling the function
5
- again with new arguments.
3
+ # @pfern/elements
6
4
 
7
- ## Install
5
+ A functional, stateless UI toolkit for composing reactive web pages.
8
6
 
9
- ```bash
10
- npm install @pfern/elements
11
- ```
7
+ Elements.js borrows the simple elegance of functional UI composition from
8
+ [React](https://react.dev/), distilled to its purest form:
9
+
10
+ - No JSX.
11
+ - No hooks or keys.
12
+ - No virtual DOM heuristics.
13
+
14
+ Components are pure functions; updates are just calling the function again with
15
+ new arguments.
12
16
 
13
- ## Quick Example: Counter
17
+ While you may choose to manage application logic with tools like
18
+ [Redux](https://redux.js.org/) or [Zustand](https://github.com/pmndrs/zustand),
19
+ Elements.js keeps _UI state_ exactly where it belongs: in the [DOM][dom] itself.
14
20
 
21
+ ## Principles
22
+
23
+ - **Pure data model:** UI elements are represented as data-in, data-out
24
+ functions. They accept W3C standard `props` and child elements as arguments, and
25
+ return nested arrays.
26
+ - **Dynamic updates:** When an event handler returns the output of a component
27
+ element defined within its scope, the element is updated with its new
28
+ arguments.
29
+ - **Imperative boundary, functional surface:** DOM mutation is abstracted away,
30
+ keeping the authoring experience functional and composable.
31
+
32
+ ### Example: Recursive counter
15
33
  ```js
16
34
  import { button, component, div, output } from '@pfern/elements'
17
35
 
18
36
  export const counter = component((count = 0) =>
19
37
  div(
20
38
  output(count),
21
- button(
22
- { onclick: () => counter(count + 1) },
23
- 'Increment')))
39
+ button({ onclick: () => counter(count + 1) },
40
+ 'Increment')))
24
41
  ```
25
42
 
26
- ## Example: Todos App
43
+ ## Quick Start
44
+
45
+ ### Install as a dependency
46
+ ```sh
47
+ npm install @pfern/elements
48
+ ```
49
+
50
+ ### Optional 3D / X3DOM helpers
51
+
52
+ ```sh
53
+ npm install @pfern/elements @pfern/elements-x3dom
54
+ ```
27
55
 
56
+ ### Install as a minimal starter app
57
+ ```sh
58
+ npx @pfern/create-elements my-app
59
+ cd my-app
60
+ npm install
61
+ ```
62
+
63
+ Source code for the examples on this page can be found in the
64
+ [examples/](https://github.com/pfernandez/elements/tree/main/examples) directory of this repository, which are hosted as a live
65
+ demo [here](https://pfernandez.github.io/elements). The starter app also
66
+ includes examples as well as simple URL router for page navigation.
67
+
68
+ ## Example: Todos App
28
69
  ```js
70
+ import { button, component, div, form, input, li, span, ul }
71
+ from '@pfern/elements'
29
72
 
30
- import { button, component, div,
31
- form, input, li, span, ul } from '@pfern/elements'
73
+ const demoItems = [{ value: 'Add my first todo', done: true },
74
+ { value: 'Install elements.js', done: false }]
32
75
 
33
76
  export const todos = component(
34
- (items = [{ value: 'Add my first todo', done: true }]) => {
35
-
77
+ (items = demoItems) => {
36
78
  const add = ({ todo: { value } }) =>
37
79
  value && todos([...items, { value, done: false }])
38
80
 
@@ -44,18 +86,16 @@ export const todos = component(
44
86
 
45
87
  return (
46
88
  div({ class: 'todos' },
47
-
48
- form({ onsubmit: add },
49
- input({ name: 'todo', placeholder: 'What needs doing?' }),
50
- button({ type: 'submit' }, 'Add')),
51
-
52
- ul(...items.map(item =>
53
- li(
54
- { style:
55
- { 'text-decoration': item.done ? 'line-through' : 'none' } },
56
- span({ onclick: () => toggle(item) }, item.value),
57
- button({ onclick: () => remove(item) }, '✕'))))))})
58
-
89
+ form({ onsubmit: add },
90
+ input({ name: 'todo', placeholder: 'What needs doing?' }),
91
+ button({ type: 'submit' }, 'Add')),
92
+
93
+ ul(...items.map(item =>
94
+ li({ style:
95
+ { 'text-decoration': item.done ? 'line-through' : 'none' } },
96
+ span({ onclick: () => toggle(item) }, item.value),
97
+ button({ onclick: () => remove(item) }, ''))))))
98
+ })
59
99
  ```
60
100
 
61
101
  ## Root Rendering Shortcut
@@ -65,10 +105,8 @@ automatically mount into the corresponding document element—no need to pass a
65
105
  container.
66
106
 
67
107
  ```js
68
- import {
69
- body, h1, h2, head, header, html,
70
- link, main, meta, render, section, title
71
- } from './elements.js'
108
+ import { body, h1, h2, head, header, html,
109
+ link, main, meta, render, section, title } from '@pfern/elements'
72
110
  import { todos } from './components/todos.js'
73
111
 
74
112
  render(
@@ -77,8 +115,7 @@ render(
77
115
  title('Elements.js'),
78
116
  meta({ name: 'viewport',
79
117
  content: 'width=device-width, initial-scale=1.0' }),
80
- link({ rel: 'stylesheet', href: 'css/style.css' })
81
- ),
118
+ link({ rel: 'stylesheet', href: 'css/style.css' })),
82
119
  body(
83
120
  header(h1('Elements.js Demo')),
84
121
  main(
@@ -87,15 +124,36 @@ render(
87
124
  todos())))))
88
125
  ```
89
126
 
90
- ## Declarative Events
127
+ ## How Updates Work
128
+
129
+ Elements.js is designed so you typically call `render()` once at startup (see
130
+ `examples/index.js`). After that, updates happen by returning a vnode from an
131
+ event handler.
132
+
133
+ ### What is a vnode?
134
+
135
+ Elements.js represents UI as plain arrays called **vnodes** (virtual nodes):
136
+
137
+ ```js
138
+ ['div', { class: 'box' }, 'hello', ['span', {}, 'world']]
139
+ ```
140
+
141
+ - `tag`: a string tag name (or `'fragment'` for a wrapper-less group)
142
+ - `props`: an object (attributes, events, and Elements.js hooks like `ontick`)
143
+ - `children`: strings/numbers/vnodes (and optionally `null`/`undefined` slots)
144
+
145
+
146
+ ### Declarative Events
91
147
 
92
- ### General Behavior
148
+ - Any event handler (e.g. `onclick`, `onsubmit`, `oninput`) may return a vnode
149
+ array to trigger a replacement.
150
+ - If the handler returns `undefined` (or any non-vnode value), the event is
151
+ passive and the DOM is left alone.
152
+ - Returned vnodes are applied at the closest component boundary.
153
+ - If you return a vnode from an `<a href>` `onclick` handler, Elements.js
154
+ prevents default navigation for unmodified left-clicks.
93
155
 
94
- * Any event handler (e.g. `onclick`, `onsubmit`, `oninput`) may return a new
95
- vnode to trigger a subtree replacement.
96
- * If the handler returns `undefined`, the event is treated as passive (no update
97
- occurs).
98
- * Returned vnodes are applied at the closest component boundary.
156
+ Errors are not swallowed: thrown errors and rejected Promises propagate.
99
157
 
100
158
  ### Form Events
101
159
 
@@ -115,14 +173,152 @@ Elements.js will automatically call `event.preventDefault()` *only if* your
115
173
  handler returns a vnode.
116
174
 
117
175
  ```js
118
- form({
119
- onsubmit: ({ todo: { value } }, e) =>
120
- value && todos([...items, { value, done: false }])
176
+ form({ onsubmit: ({ todo: { value } }, e) =>
177
+ value && todos([...items, { value, done: false }]) })
178
+ ```
179
+
180
+ ### Routing (optional)
181
+
182
+ For SPAs, register a URL-change handler once:
183
+
184
+ ```js
185
+ import { onNavigate } from '@pfern/elements'
186
+
187
+ onNavigate(() => App())
188
+ ```
189
+
190
+ With a handler registered, `a({ href: '/path' }, ...)` intercepts unmodified
191
+ left-clicks for same-origin links and uses the History API instead of
192
+ reloading the page.
193
+
194
+ You can also call `navigate('/path')` directly.
195
+
196
+ ### SSG / SSR
197
+
198
+ For build-time prerendering (static site generation) or server-side rendering,
199
+ Elements.js can serialize vnodes to HTML:
200
+
201
+ ```js
202
+ import { div, html, head, body, title, toHtmlString } from '@pfern/elements'
203
+
204
+ toHtmlString(div('Hello')) // => <div>Hello</div>
205
+
206
+ const doc = html(
207
+ head(title('My page')),
208
+ body(div('Hello')))
209
+
210
+ const htmlText = toHtmlString(doc, { doctype: true })
211
+ ```
212
+
213
+ Notes:
214
+ - Event handlers (function props like `onclick`) are dropped during
215
+ serialization.
216
+ - `innerHTML` is treated as an explicit escape hatch and is inserted verbatim.
217
+
218
+ ### Explicit Rerenders
219
+
220
+ Calling `render(vtree, container)` again is supported (diff + patch). This is
221
+ useful for explicit rerenders (e.g. dev reload, external state updates).
222
+
223
+ To force a full remount (discarding existing DOM state), pass
224
+ `{ replace: true }`.
225
+
226
+ ### Why Replacement (No Keys)
227
+
228
+ Replacement updates keep the model simple:
229
+
230
+ - You never have to maintain key stability.
231
+ - Identity is the closest component boundary.
232
+ - The DOM remains the single source of truth for UI state.
233
+
234
+ ## Props
235
+
236
+ Element functions accept a single props object as first argument:
237
+
238
+ ```js
239
+ div({ id: 'x', class: 'box' }, 'hello')
240
+ ```
241
+
242
+ In the DOM runtime:
243
+
244
+ - Most props are assigned via `setAttribute`.
245
+ - A small set of keys are treated as property exceptions when the property
246
+ exists on the element.
247
+ - Omitting a prop in a subsequent update clears it from the element.
248
+
249
+ This keeps updates symmetric and predictable.
250
+
251
+ ## `ontick` (animation hook)
252
+
253
+ `ontick` is a hook (not a DOM event) that runs once per animation frame. It can
254
+ thread context across frames:
255
+
256
+ ```js
257
+ transform({
258
+ ontick: (el, ctx = { rotation: 0 }, dt) => {
259
+ el.setAttribute('rotation', `0 1 1 ${ctx.rotation}`)
260
+ return { ...ctx, rotation: ctx.rotation + 0.001 * dt }
261
+ }
121
262
  })
122
263
  ```
123
264
 
124
- If the handler returns nothing, `preventDefault()` is skipped and the form
125
- submits natively.
265
+ `ontick` must be synchronous. If it throws (or returns a Promise), ticking
266
+ stops, and the error is not swallowed.
267
+
268
+ If the element is inside an `<x3d>` scene, Elements.js waits for the X3DOM
269
+ runtime to be ready before ticking.
270
+
271
+ ## X3D / X3DOM (experimental)
272
+
273
+ The optional `@pfern/elements-x3dom` package includes elements for X3DOM’s
274
+ supported X3D node set. You can import them and create 3D scenes
275
+ declaratively:
276
+
277
+ ```sh
278
+ npm i @pfern/elements @pfern/elements-x3dom
279
+ ```
280
+
281
+ ### Demo: Interactive 3D Cube
282
+ ```js
283
+ import { appearance, box, material, scene,
284
+ shape, transform, viewpoint, x3d } from '@pfern/elements-x3dom'
285
+
286
+ export const cubeScene = () =>
287
+ x3d(
288
+ scene(
289
+ viewpoint({ position: '0 0 6', description: 'Default View' }),
290
+ transform({ rotation: '0 1 0 0.5' },
291
+ shape(
292
+ appearance(
293
+ material({ diffuseColor: '0.2 0.6 1.0' })),
294
+ box()))))
295
+ ```
296
+
297
+ ### Lazy Loading
298
+
299
+ X3DOM is lazy-loaded the first time you call any helper from
300
+ `@pfern/elements-x3dom`. For correctness and stability, it always loads the
301
+ vendored `x3dom-full` bundle (plus `x3dom.css`).
302
+
303
+ ## Types (the docs)
304
+
305
+ Elements.js is JS-first: TypeScript is not required at runtime. This package
306
+ ships `.d.ts` files so editors like VSCode can provide rich inline docs and
307
+ autocomplete.
308
+
309
+ The goal is for type definitions to be the canonical reference for:
310
+
311
+ * HTML/SVG/X3D element helpers
312
+ * DOM events (including the special form-event signature)
313
+ * Elements.js-specific prop conventions like `ontick`, plus supported
314
+ prop shorthands like `style` (object) and `innerHTML` (escape hatch)
315
+
316
+ Most props are assigned as attributes. A small set of keys are treated as
317
+ property exceptions (when the property exists on the element): `value`,
318
+ `checked`, `selected`, `disabled`, `multiple`, `muted`, `volume`,
319
+ `currentTime`, `playbackRate`, `open`, `indeterminate`.
320
+
321
+ Omitting a prop in a subsequent update clears it from the element.
126
322
 
127
323
  ## API
128
324
 
@@ -135,6 +331,18 @@ Wrap a recursive pure function that returns a vnode.
135
331
  Render a vnode into the DOM. If `vnode[0]` is `html`, `head`, or `body`, no
136
332
  `container` is required.
137
333
 
334
+ Pass `{ replace: true }` to force a full remount.
335
+
336
+ ### `elements`
337
+
338
+ All tag helpers are also exported in a map for dynamic use:
339
+
340
+ ```js
341
+ import { elements } from '@pfern/elements'
342
+
343
+ const { div, button } = elements
344
+ ```
345
+
138
346
  ### DOM Elements
139
347
 
140
348
  Every HTML and SVG tag is available as a function:
@@ -144,25 +352,47 @@ div({ id: 'box' }, 'hello')
144
352
  svg({ width: 100 }, circle({ r: 10 }))
145
353
  ```
146
354
 
147
- ### TypeScript & JSDoc
355
+ Curated MathML helpers are available as a separate entrypoint:
148
356
 
149
- Each tag function (e.g. `div`, `button`, `svg`) includes a `@typedef` and
150
- MDN-sourced description to:
357
+ ```js
358
+ import { apply, ci, csymbol, math } from '@pfern/elements/mathml'
151
359
 
152
- * Provide editor hints
153
- * Encourage accessibility and semantic markup
154
- * Enable intelligent autocomplete
360
+ math(
361
+ apply(csymbol({ cd: 'ski' }, 'app'), ci('f'), ci('x'))
362
+ )
363
+ ```
364
+
365
+ For X3D / X3DOM nodes, use `@pfern/elements-x3dom`:
366
+
367
+ ```js
368
+ import { box } from '@pfern/elements-x3dom'
369
+
370
+ box({ size: '2 2 2', solid: true })
371
+ ```
372
+
373
+ ### `onNavigate(fn[, options])`
374
+
375
+ Register a handler to run after `popstate` (including calls to `navigate()`).
376
+ Use this to re-render your app on URL changes.
377
+
378
+ ### `toHtmlString(vnode[, options])`
379
+
380
+ Serialize a vnode tree to HTML (SSG / SSR). Pass `{ doctype: true }` to emit
381
+ `<!doctype html>`.
382
+
383
+ ### `navigate(path[, options])`
384
+
385
+ `navigate` updates `window.history` and dispatches a `popstate` event. It is a
386
+ tiny convenience for router-style apps.
155
387
 
156
388
  ### Testing Philosophy
157
389
 
158
- Elements are data-in, data-out only, so mocking and headless browsers like
159
- `jsdom` are unnecessary out of the box. See the tests [in this
160
- repository](test/README.md) for some examples.
390
+ Tests run in Node and use a small in-repo fake DOM for behavioral DOM checks.
391
+ See [`packages/elements/test/README.md`](https://github.com/pfernandez/elements/blob/main/packages/elements/test/README.md).
392
+
393
+ ## License
161
394
 
162
- ## Notes
395
+ MIT License
396
+ Copyright (c) 2026 Paul Fernandez
163
397
 
164
- - Elements.js is intended to be small and easy to reason about.
165
- - For a starter app template, use `@pfern/create-elements`:
166
- - https://github.com/pfernandez/create-elements
167
- - `npx @pfern/create-elements my-app`
168
- - More examples live in `examples/`.
398
+ [dom]: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model