@pfern/elements 0.2.0 → 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/README.md CHANGED
@@ -1,31 +1,183 @@
1
+ <!-- Generated by scripts/sync-readmes.js. Source: README.md -->
2
+
1
3
  # @pfern/elements
2
4
 
3
- A minimalist, pure functional declarative UI toolkit.
5
+ A functional, stateless UI toolkit for composing reactive web pages.
4
6
 
5
- Elements.js is JS-first: TypeScript is not required at runtime. This package
6
- ships `.d.ts` files so editors like VSCode can provide rich inline docs and
7
- autocomplete.
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.
16
+
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.
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
33
+ ```js
34
+ import { button, component, div, output } from '@pfern/elements'
35
+
36
+ export const counter = component((count = 0) =>
37
+ div(
38
+ output(count),
39
+ button({ onclick: () => counter(count + 1) },
40
+ 'Increment')))
41
+ ```
8
42
 
9
- ## Install
43
+ ## Quick Start
10
44
 
45
+ ### Install as a dependency
11
46
  ```sh
12
- npm i @pfern/elements
47
+ npm install @pfern/elements
48
+ ```
49
+
50
+ ### Optional 3D / X3DOM helpers
51
+
52
+ ```sh
53
+ npm install @pfern/elements @pfern/elements-x3dom
54
+ ```
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
69
+ ```js
70
+ import { button, component, div, form, input, li, span, ul }
71
+ from '@pfern/elements'
72
+
73
+ const demoItems = [{ value: 'Add my first todo', done: true },
74
+ { value: 'Install elements.js', done: false }]
75
+
76
+ export const todos = component(
77
+ (items = demoItems) => {
78
+ const add = ({ todo: { value } }) =>
79
+ value && todos([...items, { value, done: false }])
80
+
81
+ const remove = item =>
82
+ todos(items.filter(i => i !== item))
83
+
84
+ const toggle = item =>
85
+ todos(items.map(i => i === item ? { ...i, done: !item.done } : i))
86
+
87
+ return (
88
+ div({ class: 'todos' },
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
+ })
99
+ ```
100
+
101
+ ## Root Rendering Shortcut
102
+
103
+ If you use `html`, `head`, or `body` as the top-level tag, `render()` will
104
+ automatically mount into the corresponding document element—no need to pass a
105
+ container.
106
+
107
+ ```js
108
+ import { body, h1, h2, head, header, html,
109
+ link, main, meta, render, section, title } from '@pfern/elements'
110
+ import { todos } from './components/todos.js'
111
+
112
+ render(
113
+ html(
114
+ head(
115
+ title('Elements.js'),
116
+ meta({ name: 'viewport',
117
+ content: 'width=device-width, initial-scale=1.0' }),
118
+ link({ rel: 'stylesheet', href: 'css/style.css' })),
119
+ body(
120
+ header(h1('Elements.js Demo')),
121
+ main(
122
+ section(
123
+ h2('Todos'),
124
+ todos())))))
13
125
  ```
14
126
 
15
127
  ## How Updates Work
16
128
 
17
- Most apps call `render()` once on page load. You can force a full remount via `render(vtree, container, { replace: true })`. After that, updates happen when a
18
- DOM event handler (e.g. `onclick`, `onsubmit`) returns the next vnode: Elements.js
19
- replaces the closest component boundary.
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
147
+
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.
155
+
156
+ Errors are not swallowed: thrown errors and rejected Promises propagate.
157
+
158
+ ### Form Events
159
+
160
+ For `onsubmit`, `oninput`, and `onchange`, Elements.js provides a special
161
+ signature:
20
162
 
163
+ ```js
164
+ (event.target.elements, event)
165
+ ```
21
166
 
22
- Note: for `<a href>` links, if an `onclick` handler returns a vnode, Elements.js
23
- calls `event.preventDefault()` for unmodified left-clicks so SPAs can use real
24
- links without manual boilerplate.
167
+ That is, your handler receives:
25
168
 
169
+ 1. `elements`: the HTML form’s named inputs
170
+ 2. `event`: the original DOM event object
26
171
 
172
+ Elements.js will automatically call `event.preventDefault()` *only if* your
173
+ handler returns a vnode.
174
+
175
+ ```js
176
+ form({ onsubmit: ({ todo: { value } }, e) =>
177
+ value && todos([...items, { value, done: false }]) })
178
+ ```
27
179
 
28
- ### Routing
180
+ ### Routing (optional)
29
181
 
30
182
  For SPAs, register a URL-change handler once:
31
183
 
@@ -36,8 +188,10 @@ onNavigate(() => App())
36
188
  ```
37
189
 
38
190
  With a handler registered, `a({ href: '/path' }, ...)` intercepts unmodified
39
- left-clicks for same-origin links and uses the History API instead of reloading
40
- the page.
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.
41
195
 
42
196
  ### SSG / SSR
43
197
 
@@ -49,10 +203,9 @@ import { div, html, head, body, title, toHtmlString } from '@pfern/elements'
49
203
 
50
204
  toHtmlString(div('Hello')) // => <div>Hello</div>
51
205
 
52
- const doc =
53
- html(
54
- head(title('My page')),
55
- body(div('Hello')))
206
+ const doc = html(
207
+ head(title('My page')),
208
+ body(div('Hello')))
56
209
 
57
210
  const htmlText = toHtmlString(doc, { doctype: true })
58
211
  ```
@@ -62,35 +215,184 @@ Notes:
62
215
  serialization.
63
216
  - `innerHTML` is treated as an explicit escape hatch and is inserted verbatim.
64
217
 
65
- ## Usage
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:
66
237
 
67
238
  ```js
68
- import { button, component, div, output, render } from '@pfern/elements'
239
+ div({ id: 'x', class: 'box' }, 'hello')
240
+ ```
69
241
 
70
- export const counter = component((count = 0) =>
71
- div(
72
- output(count),
73
- button({ onclick: () => counter(count + 1) }, 'Increment')))
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
+ }
262
+ })
263
+ ```
264
+
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)
74
315
 
75
- render(counter(), document.body)
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.
322
+
323
+ ## API
324
+
325
+ ### `component(fn)`
326
+
327
+ Wrap a recursive pure function that returns a vnode.
328
+
329
+ ### `render(vnode[, container])`
330
+
331
+ Render a vnode into the DOM. If `vnode[0]` is `html`, `head`, or `body`, no
332
+ `container` is required.
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
76
344
  ```
77
345
 
78
- ## MathML (curated)
346
+ ### DOM Elements
347
+
348
+ Every HTML and SVG tag is available as a function:
349
+
350
+ ```js
351
+ div({ id: 'box' }, 'hello')
352
+ svg({ width: 100 }, circle({ r: 10 }))
353
+ ```
79
354
 
80
- The runtime supports rendering MathML tags natively (via the MathML namespace).
81
- A small curated helper set is available as a separate entrypoint:
355
+ Curated MathML helpers are available as a separate entrypoint:
82
356
 
83
357
  ```js
84
358
  import { apply, ci, csymbol, math } from '@pfern/elements/mathml'
85
359
 
86
360
  math(
87
- apply(csymbol({ cd: 'ski' }, 'app'), ci('f'), ci('x')))
361
+ apply(csymbol({ cd: 'ski' }, 'app'), ci('f'), ci('x'))
362
+ )
88
363
  ```
89
364
 
90
- ## Optional 3D
365
+ For X3D / X3DOM nodes, use `@pfern/elements-x3dom`:
91
366
 
92
- X3DOM / X3D helpers live in `@pfern/elements-x3dom` to keep this package small:
367
+ ```js
368
+ import { box } from '@pfern/elements-x3dom'
93
369
 
94
- ```sh
95
- npm i @pfern/elements @pfern/elements-x3dom
370
+ box({ size: '2 2 2', solid: true })
96
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.
387
+
388
+ ### Testing Philosophy
389
+
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
394
+
395
+ MIT License
396
+ Copyright (c) 2026 Paul Fernandez
397
+
398
+ [dom]: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pfern/elements",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A minimalist, pure functional declarative UI toolkit.",
5
5
  "author": "Paul Fernandez",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "clean:types": "node scripts/clean-types.js",
30
30
  "build:types": "node scripts/build-types.js",
31
31
  "typecheck": "node scripts/typecheck.js",
32
+ "prepack": "node ../../scripts/sync-readmes.js",
32
33
  "test": "node --test test/*.test.* --test-reporter spec",
33
34
  "test:coverage": "node scripts/test-coverage.js"
34
35
  },
package/src/core/props.js CHANGED
@@ -99,7 +99,9 @@ export const removeMissingProps = (el, prevProps, nextProps) => {
99
99
  const keys = Object.keys(prevProps)
100
100
  for (let i = 0; i < keys.length; i++) {
101
101
  const key = keys[i]
102
- !(key in nextProps) && clearProp(el, key)
102
+ if (key in nextProps) continue
103
+
104
+ clearProp(el, key)
103
105
  }
104
106
  }
105
107
 
@@ -125,6 +127,10 @@ export const assignProperties = (el, props, env) => {
125
127
  const key = keys[i]
126
128
  const value = props[key]
127
129
 
130
+ if (key === 'className') {
131
+ throw new TypeError('Invalid prop: className. Use `class`.')
132
+ }
133
+
128
134
  if (key === 'ontick' && typeof value === 'function') {
129
135
  el.ontick = value
130
136
  startTickLoop(el, value, { ready: isX3DOMReadyFor })
package/src/ssr.js CHANGED
@@ -88,17 +88,16 @@ const shouldDropProp = (key, value) => {
88
88
  const attrsToString = props => {
89
89
  if (!isObject(props)) return ''
90
90
 
91
- const normalized = props.class == null && props.className != null
92
- ? { ...props, class: props.className }
93
- : props
91
+ if (props.className != null && props.className !== false)
92
+ throw new TypeError('Invalid prop: className. Use `class`.')
94
93
 
95
- const keys = Object.keys(normalized)
96
- .filter(k => !shouldDropProp(k, normalized[k]))
94
+ const keys = Object.keys(props)
95
+ .filter(k => !shouldDropProp(k, props[k]))
97
96
  .sort()
98
97
 
99
98
  let out = ''
100
99
  for (const key of keys) {
101
- const value = normalized[key]
100
+ const value = props[key]
102
101
  if (value == null || value === false) {
103
102
  // Preserve `aria-*` / `data-*` boolean semantics for false.
104
103
  if (typeof value === 'boolean'
@@ -172,4 +171,3 @@ const toHtmlStringInner = vnode => {
172
171
  */
173
172
  export const toHtmlString = (vnode, { doctype = false } = {}) =>
174
173
  `${doctype ? '<!doctype html>' : ''}${toHtmlStringInner(vnode)}`
175
-