@pfern/elements 0.1.10 → 0.2.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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Declarative DOM event wrappers.
3
+ *
4
+ * Elements.js treats DOM events as a place to *declare* the next UI tree:
5
+ * if an event handler returns a vnode, the closest component boundary is
6
+ * replaced with the rendered result.
7
+ */
8
+
9
+ export const isEventProp = (key, value) =>
10
+ key.startsWith('on') && typeof value === 'function'
11
+
12
+ export const isFormEventProp = key => /^(oninput|onsubmit|onchange)$/.test(key)
13
+ export const isSubmitEventProp = key => key === 'onsubmit'
14
+ export const isValueEventProp = key => /^(oninput|onchange)$/.test(key)
15
+
16
+ export const getNearestRoot = (el, isRoot) =>
17
+ !el || isRoot(el) ? el : getNearestRoot(el.parentNode, isRoot)
18
+
19
+ const isThenable = x =>
20
+ !!x
21
+ && (typeof x === 'object' || typeof x === 'function')
22
+ && typeof x.then === 'function'
23
+
24
+
25
+ const getHref = el =>
26
+ typeof el?.getAttribute === 'function'
27
+ ? el.getAttribute('href')
28
+ : el?.attributes?.href ?? el?.href
29
+
30
+ const isPlainLeftClick = event =>
31
+ event?.button == null
32
+ ? false
33
+ : event.button === 0
34
+ && !event.defaultPrevented
35
+ && !event.metaKey
36
+ && !event.ctrlKey
37
+ && !event.shiftKey
38
+ && !event.altKey
39
+
40
+ const shouldPreventDefaultOnUpdate = (env, event) =>
41
+ env.key === 'onclick'
42
+ && env.el?.tagName?.toLowerCase?.() === 'a'
43
+ && !!getHref(env.el)
44
+ && event?.cancelable !== false
45
+ && typeof event?.preventDefault === 'function'
46
+ && isPlainLeftClick(event)
47
+
48
+ const describeListener = ({ el, key }) =>
49
+ `Listener '${key}' on <${el.tagName.toLowerCase()}>`
50
+
51
+ const withEventRoot = (env, eventRoot, fn) => {
52
+ const prevEventRoot = env.getCurrentEventRoot()
53
+ const restoreEventRoot = () => env.setCurrentEventRoot(prevEventRoot)
54
+ env.setCurrentEventRoot(eventRoot)
55
+
56
+ let result
57
+ try { result = fn() }
58
+ catch (err) { restoreEventRoot(); throw err }
59
+
60
+ return !isThenable(result)
61
+ ? (restoreEventRoot(), result)
62
+ : result.then(
63
+ value => (restoreEventRoot(), value),
64
+ err => { restoreEventRoot(); throw err }
65
+ )
66
+ }
67
+
68
+ const warnPassiveReturn = (env, resolved) =>
69
+ resolved === undefined
70
+ ? console.warn(
71
+ `${describeListener(env)} returned nothing.\n`
72
+ + 'If you intended a UI update, return a vnode array like: '
73
+ + 'div({}, ...)'
74
+ )
75
+ : !Array.isArray(resolved)
76
+ ? console.warn(
77
+ `${describeListener(env)} returned "${resolved}".\n`
78
+ + 'If you intended a UI update, return a vnode array like: '
79
+ + 'div({}, ...).\n'
80
+ + 'Otherwise, return undefined (or nothing) for native event '
81
+ + 'listener behavior.'
82
+ )
83
+ : undefined
84
+
85
+ /**
86
+ * Wrap an event handler so it can return a vnode to trigger an update.
87
+ *
88
+ * @param {{
89
+ * el: any,
90
+ * key: string,
91
+ * handler: Function,
92
+ * isRoot: (el: any) => boolean,
93
+ * renderTree:
94
+ * (node: any, isRoot?: boolean, namespaceURI?: string | null) => any,
95
+ * getCurrentEventRoot: () => any,
96
+ * setCurrentEventRoot: (el: any) => void,
97
+ * debug: boolean
98
+ * }} env
99
+ */
100
+ export const createDeclarativeEventHandler = env =>
101
+ (...args) => {
102
+ const eventRoot = getNearestRoot(env.el, env.isRoot)
103
+ if (!eventRoot) return
104
+
105
+ return withEventRoot(env, eventRoot, () => {
106
+ const event = args[0]
107
+ const isFormEvent = isFormEventProp(env.key)
108
+ const isSubmitEvent = isSubmitEventProp(env.key)
109
+ const isValueEvent = isValueEventProp(env.key)
110
+
111
+ const arg =
112
+ isSubmitEvent
113
+ ? event?.target?.elements || null
114
+ : isValueEvent
115
+ ? event?.target?.value
116
+ : null
117
+
118
+ const result = isFormEvent
119
+ ? env.handler.call(env.el, arg, event)
120
+ : env.handler.call(env.el, event)
121
+
122
+ const handleResult = resolved => {
123
+ isSubmitEvent && resolved !== undefined && event.preventDefault()
124
+
125
+ env.debug && warnPassiveReturn(env, resolved)
126
+
127
+ Array.isArray(resolved)
128
+ && shouldPreventDefaultOnUpdate(env, event)
129
+ && event.preventDefault()
130
+
131
+ if (!Array.isArray(resolved)) return resolved
132
+
133
+ const parent = eventRoot.parentNode
134
+ if (!parent) return resolved
135
+
136
+ const replacement = env.renderTree(resolved, true)
137
+ parent.replaceChild(replacement, eventRoot)
138
+ return resolved
139
+ }
140
+
141
+ return isThenable(result) ? result.then(handleResult) : handleResult(result)
142
+ })
143
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Element property assignment.
3
+ *
4
+ * The props object is treated as:
5
+ * - DOM events: `on*` functions (wrapped to support vnode returns)
6
+ * - Hooks: non-DOM behavior such as `ontick`
7
+ * - Styling: `style` objects
8
+ * - Everything else: attributes (including SVG namespace handling)
9
+ */
10
+
11
+ import { createDeclarativeEventHandler, isEventProp } from './events.js'
12
+ import { startTickLoop, stopTickLoop } from './tick.js'
13
+
14
+ const isObject = x =>
15
+ typeof x === 'object'
16
+ && x !== null
17
+
18
+ const deleteKey = (obj, key) =>
19
+ (delete obj[String(key)], undefined)
20
+
21
+ const isX3DOMReadyFor = el =>
22
+ (x3d => !x3d || !!x3d.runtime)(el?.closest?.('x3d'))
23
+
24
+ const propertyExceptions = {
25
+ value: 'value',
26
+ checked: 'checked',
27
+ selected: 'selected',
28
+ disabled: 'disabled',
29
+ multiple: 'multiple',
30
+ muted: 'muted',
31
+ volume: 'volume',
32
+ currentTime: 'currentTime',
33
+ playbackRate: 'playbackRate',
34
+ open: 'open',
35
+ indeterminate: 'indeterminate'
36
+ }
37
+
38
+ const propertyExceptionDefaults = {
39
+ value: '',
40
+ checked: false,
41
+ selected: false,
42
+ disabled: false,
43
+ multiple: false,
44
+ muted: false,
45
+ volume: 1,
46
+ currentTime: 0,
47
+ playbackRate: 1,
48
+ open: false,
49
+ indeterminate: false
50
+ }
51
+
52
+ const removeAttribute = (el, key) =>
53
+ typeof el.removeAttribute === 'function'
54
+ ? el.removeAttribute(key)
55
+ : el.attributes ? deleteKey(el.attributes, key) : undefined
56
+
57
+ const clearStyle = el =>
58
+ (style =>
59
+ !style
60
+ ? undefined
61
+ : 'cssText' in style
62
+ ? (style.cssText = '', undefined)
63
+ : (Object.keys(style).forEach(k => delete style[k]), undefined)
64
+ )(el?.style)
65
+
66
+ const clearInnerHTML = el =>
67
+ el.innerHTML = ''
68
+
69
+ const clearEventProp = (el, key) =>
70
+ el[key] = null
71
+
72
+ const clearPropertyException = (el, key) =>
73
+ key in propertyExceptions && key in el
74
+ ? (el[propertyExceptions[key]] = propertyExceptionDefaults[key], undefined)
75
+ : undefined
76
+
77
+ const clearTick = el =>
78
+ (el.ontick = null, stopTickLoop(el))
79
+
80
+ const clearProp = (el, key) =>
81
+ key === 'ontick' ? clearTick(el)
82
+ : key === 'style' ? clearStyle(el)
83
+ : key === 'innerHTML' ? clearInnerHTML(el)
84
+ : key in propertyExceptions ? clearPropertyException(el, key)
85
+ : key.startsWith('on') ? clearEventProp(el, key)
86
+ : removeAttribute(el, key)
87
+
88
+ /**
89
+ * Remove props that existed previously but are absent in the next vnode.
90
+ *
91
+ * This keeps updates symmetric: setting a prop then omitting it later clears it
92
+ * from the DOM element.
93
+ *
94
+ * @param {any} el
95
+ * @param {Record<string, any>} prevProps
96
+ * @param {Record<string, any>} nextProps
97
+ */
98
+ export const removeMissingProps = (el, prevProps, nextProps) => {
99
+ const keys = Object.keys(prevProps)
100
+ for (let i = 0; i < keys.length; i++) {
101
+ const key = keys[i]
102
+ !(key in nextProps) && clearProp(el, key)
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Assign props to a DOM element.
108
+ *
109
+ * @param {any} el
110
+ * @param {Record<string, any>} props
111
+ * @param {{
112
+ * svgNS: string,
113
+ * debug: boolean,
114
+ * isRoot: (el: any) => boolean,
115
+ * renderTree: (node: any, isRoot?: boolean, namespaceURI?: string | null) =>
116
+ * any,
117
+ * getCurrentEventRoot: () => any,
118
+ * setCurrentEventRoot: (el: any) => void
119
+ * }} env
120
+ */
121
+ export const assignProperties = (el, props, env) => {
122
+ const isSvg = el.namespaceURI === env.svgNS
123
+ const keys = Object.keys(props)
124
+ for (let i = 0; i < keys.length; i++) {
125
+ const key = keys[i]
126
+ const value = props[key]
127
+
128
+ if (key === 'ontick' && typeof value === 'function') {
129
+ el.ontick = value
130
+ startTickLoop(el, value, { ready: isX3DOMReadyFor })
131
+ continue
132
+ }
133
+
134
+ if (key in propertyExceptions && key in el) {
135
+ el[propertyExceptions[key]] = value
136
+ continue
137
+ }
138
+
139
+ if (isEventProp(key, value)) {
140
+ el[key] = createDeclarativeEventHandler({
141
+ el,
142
+ key,
143
+ handler: value,
144
+ isRoot: env.isRoot,
145
+ renderTree: env.renderTree,
146
+ getCurrentEventRoot: env.getCurrentEventRoot,
147
+ setCurrentEventRoot: env.setCurrentEventRoot,
148
+ debug: env.debug
149
+ })
150
+ continue
151
+ }
152
+
153
+ if (key === 'style' && isObject(value)) {
154
+ Object.assign(el.style, value)
155
+ continue
156
+ }
157
+
158
+ if (key === 'innerHTML') {
159
+ el.innerHTML = value
160
+ continue
161
+ }
162
+
163
+ if (typeof value === 'boolean') {
164
+ if (key.startsWith('aria-') || key.startsWith('data-')) {
165
+ const str = value ? 'true' : 'false'
166
+ isSvg ? el.setAttributeNS(null, key, str) : el.setAttribute(key, str)
167
+ } else if (value) {
168
+ isSvg ? el.setAttributeNS(null, key, '') : el.setAttribute(key, '')
169
+ } else {
170
+ removeAttribute(el, key)
171
+ }
172
+ continue
173
+ }
174
+
175
+ isSvg ? el.setAttributeNS(null, key, value) : el.setAttribute(key, value)
176
+ }
177
+ }
@@ -0,0 +1,225 @@
1
+ /** @internal */
2
+ export const htmlTagNames = /** @type {const} */ ([
3
+ // Document metadata
4
+ 'html',
5
+ 'head',
6
+ 'base',
7
+ 'link',
8
+ 'meta',
9
+ 'title',
10
+
11
+ // Sections
12
+ 'body',
13
+ 'header',
14
+ 'hgroup',
15
+ 'nav',
16
+ 'main',
17
+ 'section',
18
+ 'article',
19
+ 'aside',
20
+ 'footer',
21
+ 'address',
22
+
23
+ // Text content
24
+ 'h1',
25
+ 'h2',
26
+ 'h3',
27
+ 'h4',
28
+ 'h5',
29
+ 'h6',
30
+ 'p',
31
+ 'hr',
32
+ 'menu',
33
+ 'pre',
34
+ 'blockquote',
35
+ 'ol',
36
+ 'ul',
37
+ 'li',
38
+ 'dl',
39
+ 'dt',
40
+ 'dd',
41
+ 'figure',
42
+ 'figcaption',
43
+ 'div',
44
+
45
+ // Inline text semantics
46
+ 'a',
47
+ 'abbr',
48
+ 'b',
49
+ 'bdi',
50
+ 'bdo',
51
+ 'br',
52
+ 'cite',
53
+ 'code',
54
+ 'data',
55
+ 'dfn',
56
+ 'em',
57
+ 'i',
58
+ 'kbd',
59
+ 'mark',
60
+ 'q',
61
+ 'rb',
62
+ 'rp',
63
+ 'rt',
64
+ 'rtc',
65
+ 'ruby',
66
+ 's',
67
+ 'samp',
68
+ 'small',
69
+ 'span',
70
+ 'strong',
71
+ 'sub',
72
+ 'sup',
73
+ 'time',
74
+ 'u',
75
+ 'var',
76
+ 'wbr',
77
+
78
+ // Edits
79
+ 'ins',
80
+ 'del',
81
+
82
+ // Embedded content
83
+ 'img',
84
+ 'iframe',
85
+ 'embed',
86
+ 'object',
87
+ 'param',
88
+ 'video',
89
+ 'audio',
90
+ 'source',
91
+ 'track',
92
+ 'picture',
93
+
94
+ // Table content
95
+ 'table',
96
+ 'caption',
97
+ 'thead',
98
+ 'tbody',
99
+ 'tfoot',
100
+ 'tr',
101
+ 'th',
102
+ 'td',
103
+ 'colgroup',
104
+ 'col',
105
+
106
+ // Forms
107
+ 'form',
108
+ 'fieldset',
109
+ 'legend',
110
+ 'label',
111
+ 'input',
112
+ 'button',
113
+ 'select',
114
+ 'datalist',
115
+ 'optgroup',
116
+ 'option',
117
+ 'textarea',
118
+ 'output',
119
+ 'progress',
120
+ 'meter',
121
+
122
+ // Interactive elements
123
+ 'details',
124
+ 'search',
125
+ 'summary',
126
+ 'dialog',
127
+ 'slot',
128
+ 'template',
129
+
130
+ // Scripting and style
131
+ 'script',
132
+ 'noscript',
133
+ 'style',
134
+
135
+ // Web components and others
136
+ 'canvas',
137
+ 'picture',
138
+ 'map',
139
+ 'area',
140
+ 'slot'
141
+ ])
142
+
143
+ /** @internal */
144
+ export const svgTagNames = /** @type {const} */ ([
145
+ // Animation elements
146
+ 'animate',
147
+ 'animateMotion',
148
+ 'animateTransform',
149
+ 'mpath',
150
+ 'set',
151
+
152
+ // Basic shapes
153
+ 'circle',
154
+ 'ellipse',
155
+ 'line',
156
+ 'path',
157
+ 'polygon',
158
+ 'polyline',
159
+ 'rect',
160
+
161
+ // Container / structural
162
+ 'defs',
163
+ 'g',
164
+ 'marker',
165
+ 'mask',
166
+ 'pattern',
167
+ 'svg',
168
+ 'switch',
169
+ 'symbol',
170
+ 'use',
171
+
172
+ // Descriptive
173
+ 'desc',
174
+ 'metadata',
175
+ 'title',
176
+
177
+ // Filter primitives
178
+ 'filter',
179
+ 'feBlend',
180
+ 'feColorMatrix',
181
+ 'feComponentTransfer',
182
+ 'feComposite',
183
+ 'feConvolveMatrix',
184
+ 'feDiffuseLighting',
185
+ 'feDisplacementMap',
186
+ 'feDistantLight',
187
+ 'feDropShadow',
188
+ 'feFlood',
189
+ 'feFuncA',
190
+ 'feFuncB',
191
+ 'feFuncG',
192
+ 'feFuncR',
193
+ 'feGaussianBlur',
194
+ 'feImage',
195
+ 'feMerge',
196
+ 'feMergeNode',
197
+ 'feMorphology',
198
+ 'feOffset',
199
+ 'fePointLight',
200
+ 'feSpecularLighting',
201
+ 'feSpotLight',
202
+ 'feTile',
203
+ 'feTurbulence',
204
+
205
+ // Gradient / paint servers
206
+ 'linearGradient',
207
+ 'radialGradient',
208
+ 'stop',
209
+
210
+ // Graphics elements
211
+ 'image',
212
+ 'foreignObject', // included in graphics section as non‑standard children
213
+
214
+ // Text and text-path
215
+ 'text',
216
+ 'textPath',
217
+ 'tspan',
218
+
219
+ // Scripting/style
220
+ 'script',
221
+ 'style',
222
+
223
+ // View
224
+ 'view'
225
+ ])
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tick engine for `ontick`.
3
+ *
4
+ * `ontick` is a hook (not a DOM event) that runs a handler once per frame.
5
+ * It is intentionally minimal: it schedules via `requestAnimationFrame`,
6
+ * threads optional context, and provides a `dt` in milliseconds.
7
+ *
8
+ * The engine is generic and can be used with any element; callers may provide
9
+ * a `ready(el)` predicate to gate ticking on external readiness (e.g. X3DOM
10
+ * runtime availability).
11
+ */
12
+
13
+ const tickStateMap = new WeakMap()
14
+
15
+ export const isConnected = el =>
16
+ typeof el?.isConnected === 'boolean' ? el.isConnected : !!el?.parentNode
17
+
18
+ const isThenable = x =>
19
+ !!x
20
+ && (typeof x === 'object' || typeof x === 'function')
21
+ && typeof x.then === 'function'
22
+
23
+ export const stopTickLoop = el =>
24
+ (state =>
25
+ !state ? undefined
26
+ : (state.running = false,
27
+ state.rafId != null
28
+ && typeof window?.cancelAnimationFrame === 'function'
29
+ && window.cancelAnimationFrame(state.rafId),
30
+ tickStateMap.delete(el),
31
+ undefined)
32
+ )(tickStateMap.get(el))
33
+
34
+ /**
35
+ * Start (or restart) a tick loop for an element.
36
+ *
37
+ * The handler signature is:
38
+ *
39
+ * ```js
40
+ * (el, ctx, dtMs) => nextCtx | undefined
41
+ * ```
42
+ *
43
+ * Returning `undefined` preserves the previous `ctx`. Returning any other value
44
+ * replaces `ctx` for the next tick.
45
+ *
46
+ * If the handler throws (or returns a Promise), ticking stops.
47
+ *
48
+ * @template Ctx
49
+ * @param {Element} el
50
+ * @param {(el: Element, ctx: any, dtMs: number) => (any | void)} handler
51
+ * @param {{ ready?: (el: Element) => boolean }} [options]
52
+ */
53
+ export const startTickLoop = (el, handler, { ready = () => true } = {}) => {
54
+ const canTick =
55
+ typeof window !== 'undefined'
56
+ && typeof window.requestAnimationFrame === 'function'
57
+
58
+ if (!canTick) return
59
+
60
+ const existing = tickStateMap.get(el)
61
+
62
+ if (existing?.handler === handler
63
+ && existing?.ready === ready
64
+ && existing?.running) return
65
+
66
+ existing && stopTickLoop(el)
67
+
68
+ const state = {
69
+ handler,
70
+ ready,
71
+ ctx: undefined,
72
+ lastTime: null,
73
+ rafId: null,
74
+ wasConnected: false,
75
+ running: true
76
+ }
77
+
78
+ tickStateMap.set(el, state)
79
+
80
+ const step = t => {
81
+ const connected = isConnected(el)
82
+ if (!state.running || (!connected && state.wasConnected))
83
+ return stopTickLoop(el)
84
+
85
+ if (!connected) {
86
+ state.lastTime = null
87
+ state.rafId = window.requestAnimationFrame(step)
88
+ return
89
+ }
90
+
91
+ state.wasConnected = true
92
+
93
+ if (!ready(el)) {
94
+ state.lastTime = null
95
+ state.rafId = window.requestAnimationFrame(step)
96
+ return
97
+ }
98
+
99
+ const dt = state.lastTime == null ? 0 : t - state.lastTime
100
+ state.lastTime = t
101
+
102
+ let next
103
+ try { next = handler.call(el, el, state.ctx, dt) }
104
+ catch (err) { stopTickLoop(el); throw err }
105
+
106
+ if (isThenable(next)) {
107
+ stopTickLoop(el)
108
+ throw new TypeError('ontick must be synchronous (no Promises).')
109
+ }
110
+
111
+ next !== undefined && (state.ctx = next)
112
+ state.running && (state.rafId = window.requestAnimationFrame(step))
113
+ }
114
+
115
+ state.rafId = window.requestAnimationFrame(step)
116
+ }