@pfern/elements 0.1.6 → 0.1.8

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/elements.js CHANGED
@@ -24,7 +24,10 @@ const svgNS = 'http://www.w3.org/2000/svg'
24
24
  */
25
25
  const rootMap = new WeakMap()
26
26
 
27
- const isNodeEnv = typeof document === 'undefined'
27
+ const isNodeEnv = () => typeof document === 'undefined'
28
+
29
+ let componentUpdateDepth = 0
30
+ let currentEventRoot = null
28
31
 
29
32
  /**
30
33
  * Determines whether two nodes have changed enough to require replacement.
@@ -97,6 +100,8 @@ const assignProperties = (el, props) =>
97
100
  while (target && !target.__root) target = target.parentNode
98
101
  if (!target) return
99
102
 
103
+ const prevEventRoot = currentEventRoot
104
+ currentEventRoot = target
100
105
  try {
101
106
  const event = args[0]
102
107
  const isFormEvent = /^(oninput|onsubmit|onchange)$/.test(key)
@@ -140,6 +145,8 @@ const assignProperties = (el, props) =>
140
145
  }
141
146
  } catch (error) {
142
147
  console.error(error)
148
+ } finally {
149
+ currentEventRoot = prevEventRoot
143
150
  }
144
151
  }
145
152
  } else if (key === 'style' && typeof value === 'object') {
@@ -172,7 +179,7 @@ const assignProperties = (el, props) =>
172
179
  */
173
180
  const renderTree = (node, isRoot = true) => {
174
181
  if (typeof node === 'string' || typeof node === 'number') {
175
- return isNodeEnv ? node : document.createTextNode(node)
182
+ return isNodeEnv() ? node : document.createTextNode(node)
176
183
  }
177
184
 
178
185
  if (!node || node.length === 0) {
@@ -185,8 +192,12 @@ const renderTree = (node, isRoot = true) => {
185
192
  }
186
193
 
187
194
  if (Array.isArray(node) && node[0] === 'wrap') {
188
- const [_tag, _props, child] = node
189
- return renderTree(child, true)
195
+ const [_tag, props = {}, child] = node
196
+ const el = renderTree(child, true)
197
+ if (props && typeof props === 'object' && props.__instance) {
198
+ rootMap.set(props.__instance, el)
199
+ }
200
+ return el
190
201
  }
191
202
 
192
203
  const [tag, props = {}, ...children] = node
@@ -291,6 +302,34 @@ export const render = (vtree, container = null) => {
291
302
  rootMap.set(vtree, target)
292
303
  }
293
304
 
305
+ /**
306
+ * Updates the browser URL via the History API (no full page load).
307
+ * No-ops outside the browser.
308
+ *
309
+ * @param {string} to - The target URL (path/search/hash).
310
+ * @param {Object} [options]
311
+ * @param {boolean} [options.replace=false] - Use replaceState instead of pushState.
312
+ * @param {boolean} [options.force=false] - Update even if URL is already `to`.
313
+ * @param {any} [options.state={}] - History state.
314
+ * @param {string} [options.title=''] - History title (mostly ignored by browsers).
315
+ */
316
+ export const navigate = (to, {
317
+ replace = false,
318
+ force = false,
319
+ state = {},
320
+ title = ''
321
+ } = {}) => {
322
+ if (typeof window === 'undefined') return
323
+ if (typeof to !== 'string' || !to.length) return
324
+
325
+ const { pathname, search, hash } = window.location
326
+ const current = `${pathname}${search}${hash}`
327
+ if (!force && current === to) return
328
+
329
+ const method = replace ? 'replaceState' : 'pushState'
330
+ window.history[method](state, title, to)
331
+ }
332
+
294
333
  /**
295
334
  * Wraps a function component so that it participates in reconciliation.
296
335
  *
@@ -298,16 +337,30 @@ export const render = (vtree, container = null) => {
298
337
  * @returns {(...args: any[]) => any} - A callable component that can manage its own subtree.
299
338
  */
300
339
  export const component = fn => {
340
+ const instance = {}
301
341
  return (...args) => {
302
342
  try {
303
- const vnode = fn(...args)
304
- const prevEl = rootMap.get(vnode)
305
- if (prevEl?.parentNode) {
306
- const replacement = renderTree(['wrap', {}, vnode], true)
343
+ const prevEl = rootMap.get(instance)
344
+ const canUpdateInPlace =
345
+ !!prevEl?.parentNode
346
+ && componentUpdateDepth === 0
347
+ && !currentEventRoot
348
+
349
+ componentUpdateDepth++
350
+ let vnode
351
+ try {
352
+ vnode = fn(...args)
353
+ } finally {
354
+ componentUpdateDepth--
355
+ }
356
+
357
+ if (canUpdateInPlace) {
358
+ const replacement = renderTree(['wrap', { __instance: instance }, vnode], true)
307
359
  prevEl.parentNode.replaceChild(replacement, prevEl)
308
360
  return replacement.__vnode
309
361
  }
310
- return ['wrap', {}, vnode]
362
+
363
+ return ['wrap', { __instance: instance }, vnode]
311
364
  } catch (err) {
312
365
  console.error('Component error:', err)
313
366
  return ['div', {}, `Error: ${err.message}`]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pfern/elements",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A minimalist, pure functional declarative UI toolkit.",
5
5
  "type": "module",
6
6
  "main": "elements.js",
@@ -12,6 +12,15 @@
12
12
  export const DEBUG: boolean;
13
13
  export function render(vtree: any, container?: any): void;
14
14
  export function component(fn: (...args: any[]) => any): (...args: any[]) => any;
15
+ export function navigate(
16
+ to: string,
17
+ options?: {
18
+ replace?: boolean;
19
+ force?: boolean;
20
+ state?: any;
21
+ title?: string;
22
+ }
23
+ ): void;
15
24
  /**
16
25
  * @typedef {Record<string, any>} Props
17
26
  * @typedef {any[] | string | number | boolean | null | undefined | Node} Child