@pfern/elements 0.1.7 → 0.1.9

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,9 +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
28
 
29
29
  let componentUpdateDepth = 0
30
+ let currentEventRoot = null
30
31
 
31
32
  /**
32
33
  * Determines whether two nodes have changed enough to require replacement.
@@ -99,6 +100,8 @@ const assignProperties = (el, props) =>
99
100
  while (target && !target.__root) target = target.parentNode
100
101
  if (!target) return
101
102
 
103
+ const prevEventRoot = currentEventRoot
104
+ currentEventRoot = target
102
105
  try {
103
106
  const event = args[0]
104
107
  const isFormEvent = /^(oninput|onsubmit|onchange)$/.test(key)
@@ -142,6 +145,8 @@ const assignProperties = (el, props) =>
142
145
  }
143
146
  } catch (error) {
144
147
  console.error(error)
148
+ } finally {
149
+ currentEventRoot = prevEventRoot
145
150
  }
146
151
  }
147
152
  } else if (key === 'style' && typeof value === 'object') {
@@ -174,7 +179,7 @@ const assignProperties = (el, props) =>
174
179
  */
175
180
  const renderTree = (node, isRoot = true) => {
176
181
  if (typeof node === 'string' || typeof node === 'number') {
177
- return isNodeEnv ? node : document.createTextNode(node)
182
+ return isNodeEnv() ? node : document.createTextNode(node)
178
183
  }
179
184
 
180
185
  if (!node || node.length === 0) {
@@ -213,6 +218,11 @@ const renderTree = (node, isRoot = true) => {
213
218
  ? document.createElementNS(svgNS, tag)
214
219
  : document.createElement(tag)
215
220
 
221
+ if (!el && (tag === 'head' || tag === 'body')) {
222
+ el = document.createElement(tag)
223
+ document.documentElement.appendChild(el)
224
+ }
225
+
216
226
  el.__vnode = node
217
227
 
218
228
  if (isRoot && tag !== 'html' && tag !== 'head' && tag !== 'body') {
@@ -284,7 +294,9 @@ export const render = (vtree, container = null) => {
284
294
  if (!prevVNode) {
285
295
  const dom = renderTree(vtree)
286
296
  if (target === document.documentElement) {
287
- document.replaceChild(dom, document.documentElement)
297
+ if (dom !== document.documentElement) {
298
+ document.replaceChild(dom, document.documentElement)
299
+ }
288
300
  } else {
289
301
  target.appendChild(dom)
290
302
  }
@@ -297,6 +309,34 @@ export const render = (vtree, container = null) => {
297
309
  rootMap.set(vtree, target)
298
310
  }
299
311
 
312
+ /**
313
+ * Updates the browser URL via the History API (no full page load).
314
+ * No-ops outside the browser.
315
+ *
316
+ * @param {string} to - The target URL (path/search/hash).
317
+ * @param {Object} [options]
318
+ * @param {boolean} [options.replace=false] - Use replaceState instead of pushState.
319
+ * @param {boolean} [options.force=false] - Update even if URL is already `to`.
320
+ * @param {any} [options.state={}] - History state.
321
+ * @param {string} [options.title=''] - History title (mostly ignored by browsers).
322
+ */
323
+ export const navigate = (to, {
324
+ replace = false,
325
+ force = false,
326
+ state = {},
327
+ title = ''
328
+ } = {}) => {
329
+ if (typeof window === 'undefined') return
330
+ if (typeof to !== 'string' || !to.length) return
331
+
332
+ const { pathname, search, hash } = window.location
333
+ const current = `${pathname}${search}${hash}`
334
+ if (!force && current === to) return
335
+
336
+ const method = replace ? 'replaceState' : 'pushState'
337
+ window.history[method](state, title, to)
338
+ }
339
+
300
340
  /**
301
341
  * Wraps a function component so that it participates in reconciliation.
302
342
  *
@@ -308,11 +348,18 @@ export const component = fn => {
308
348
  return (...args) => {
309
349
  try {
310
350
  const prevEl = rootMap.get(instance)
311
- const canUpdateInPlace = !!prevEl?.parentNode && componentUpdateDepth === 0
351
+ const canUpdateInPlace =
352
+ !!prevEl?.parentNode
353
+ && componentUpdateDepth === 0
354
+ && !currentEventRoot
312
355
 
313
356
  componentUpdateDepth++
314
- const vnode = fn(...args)
315
- componentUpdateDepth--
357
+ let vnode
358
+ try {
359
+ vnode = fn(...args)
360
+ } finally {
361
+ componentUpdateDepth--
362
+ }
316
363
 
317
364
  if (canUpdateInPlace) {
318
365
  const replacement = renderTree(['wrap', { __instance: instance }, vnode], true)
@@ -322,7 +369,6 @@ export const component = fn => {
322
369
 
323
370
  return ['wrap', { __instance: instance }, vnode]
324
371
  } catch (err) {
325
- componentUpdateDepth = Math.max(0, componentUpdateDepth - 1)
326
372
  console.error('Component error:', err)
327
373
  return ['div', {}, `Error: ${err.message}`]
328
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pfern/elements",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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