sprae 12.4.17 → 13.0.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.
Files changed (46) hide show
  1. package/core.js +17 -13
  2. package/directive/_.js +2 -2
  3. package/directive/change.js +40 -0
  4. package/directive/class.js +1 -1
  5. package/directive/intersect.js +44 -0
  6. package/directive/mount.js +37 -0
  7. package/directive/ref.js +19 -12
  8. package/directive/resize.js +39 -0
  9. package/directive/sequence.js +2 -2
  10. package/directive/spread.js +2 -2
  11. package/directive/style.js +1 -1
  12. package/directive/text.js +22 -1
  13. package/directive/value.js +11 -37
  14. package/dist/sprae-csp.js +11 -11
  15. package/dist/sprae-csp.js.map +4 -4
  16. package/dist/sprae-csp.umd.js +11 -11
  17. package/dist/sprae-csp.umd.js.map +4 -4
  18. package/dist/sprae-preact.js +4 -4
  19. package/dist/sprae-preact.js.map +4 -4
  20. package/dist/sprae-preact.umd.js +3 -3
  21. package/dist/sprae-preact.umd.js.map +4 -4
  22. package/dist/sprae.js +3 -3
  23. package/dist/sprae.js.map +4 -4
  24. package/dist/sprae.umd.js +3 -3
  25. package/dist/sprae.umd.js.map +4 -4
  26. package/package.json +1 -1
  27. package/readme.md +7 -2
  28. package/sprae.js +22 -5
  29. package/types/core.d.ts +4 -3
  30. package/types/core.d.ts.map +1 -1
  31. package/types/directive/_.d.ts.map +1 -1
  32. package/types/directive/change.d.ts +5 -0
  33. package/types/directive/change.d.ts.map +1 -0
  34. package/types/directive/intersect.d.ts +22 -0
  35. package/types/directive/intersect.d.ts.map +1 -0
  36. package/types/directive/mount.d.ts +5 -0
  37. package/types/directive/mount.d.ts.map +1 -0
  38. package/types/directive/ref.d.ts +1 -3
  39. package/types/directive/ref.d.ts.map +1 -1
  40. package/types/directive/resize.d.ts +21 -0
  41. package/types/directive/resize.d.ts.map +1 -1
  42. package/types/directive/spread.d.ts.map +1 -1
  43. package/types/directive/text.d.ts.map +1 -1
  44. package/types/directive/value.d.ts +1 -2
  45. package/types/directive/value.d.ts.map +1 -1
  46. package/types/sprae.d.ts.map +1 -1
package/core.js CHANGED
@@ -20,6 +20,9 @@ export const _add = Symbol('init')
20
20
  /** Directive prefix (default: ':') */
21
21
  export let prefix = ':';
22
22
 
23
+ /** Check if element is a custom element (has hyphen in tag name) */
24
+ export const isCE = (el) => el.localName?.includes('-')
25
+
23
26
  /**
24
27
  * A reactive signal containing a value.
25
28
  * @template T
@@ -144,16 +147,16 @@ const err = (e, expr, el = currentEl) => {
144
147
  * @param {Object} [state] - Initial state values to populate the element's reactive state.
145
148
  * @returns {SpraeState & Object} The reactive state object associated with the element.
146
149
  */
147
- const sprae = (el = document.body, state) => {
150
+ const sprae = (root = document.body, state) => {
148
151
  // repeated call can be caused by eg. :each with new objects with old keys
149
- if (el[_state]) return Object.assign(el[_state], state)
152
+ if (root[_state]) return Object.assign(root[_state], state)
150
153
 
151
- // console.group('sprae', el)
154
+ // console.group('sprae', root)
152
155
 
153
156
  // take over existing state instead of creating a clone
154
157
  state = store(state || {})
155
158
 
156
- let fx = [], offs = []
159
+ let el = root, fx = [], offs = []
157
160
 
158
161
  // on/off all effects
159
162
  // we don't call prevOn as convention: everything defined before :else :if won't be disabled by :if
@@ -181,10 +184,14 @@ const sprae = (el = document.body, state) => {
181
184
  fx.push(start = dir(el, name.slice(prefix.length), value, state)), offs.push(start())
182
185
 
183
186
  // stop after subsprae like :each, :if, :scope etc.
184
- if (_state in el) return
187
+ // custom elements: continue processing all directives (prop setters), descent blocked separately (line 189)
188
+ if (_state in el && !isCE(el)) return
185
189
  } else i++
186
190
  }
187
191
 
192
+ // custom elements own their children — don't descend
193
+ if (el !== root && isCE(el)) return
194
+
188
195
  // :if and :each replace element with text node, which tweaks .children length, but .childNodes length persists
189
196
  // real DOM: firstChild/nextSibling avoids array copy; frag.childNodes is already snapshot array
190
197
  if (el.firstChild !== undefined) {
@@ -398,8 +405,10 @@ export const dashcase = (str) => str.replace(/[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g,
398
405
  * @param {string | boolean | null | undefined} v - Attribute value (null/false removes, true sets empty)
399
406
  * @returns {void}
400
407
  */
408
+ const camelcase = (str) => str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
409
+
401
410
  export const attr = (el, name, v) => (v == null || v === false) ? el.removeAttribute(name) :
402
- (typeof v === 'object' && el.tagName?.includes('-')) ? el[name] = v :
411
+ isCE(el) ? (el[camelcase(name)] = v) :
403
412
  el.setAttribute(name, v === true ? "" : v);
404
413
 
405
414
  /**
@@ -407,9 +416,9 @@ export const attr = (el, name, v) => (v == null || v === false) ? el.removeAttri
407
416
  * @param {string | string[] | Record<string, boolean> | null | undefined} c - Class input
408
417
  * @returns {string} Space-separated class string
409
418
  */
410
- export const clsx = (c, _out = []) => !c ? '' : typeof c === 'string' ? c : (
419
+ export const clsx = (c) => !c ? '' : typeof c === 'string' ? c : (
411
420
  Array.isArray(c) ? c.map(clsx) :
412
- Object.entries(c).reduce((s, [k, v]) => !v ? s : [...s, k], [])
421
+ Object.entries(c).reduce((s, [k, v]) => (v && s.push(k), s), [])
413
422
  ).join(' ')
414
423
 
415
424
  /**
@@ -448,11 +457,6 @@ export const debounce = (fn, ms, immediate) => {
448
457
  : ((_count = 0) => (arg, _c = ++_count) => schedule(() => _c == _count && fn(arg)))()
449
458
  }
450
459
 
451
- /**
452
- * Parses time string to milliseconds. Supports: 100, 100ms, 1s, 1m
453
- * @param {string|number} t - Time value
454
- * @returns {number} Milliseconds
455
- */
456
460
  export * from './store.js';
457
461
 
458
462
  export default sprae
package/directive/_.js CHANGED
@@ -1,4 +1,4 @@
1
- import { attr } from "../core.js";
1
+ import { attr, isCE } from "../core.js";
2
2
 
3
3
  /**
4
4
  * Default attribute directive - sets any attribute value.
@@ -8,4 +8,4 @@ import { attr } from "../core.js";
8
8
  * @param {string} name - Attribute name
9
9
  * @returns {(v: any) => void} Update function
10
10
  */
11
- export default (el, st, ex, name) => v => attr(el, name, typeof v === 'function' ? v(el.getAttribute(name)) : v)
11
+ export default (el, st, ex, name) => v => attr(el, name, typeof v === 'function' && !isCE(el) ? v(el.getAttribute(name)) : v)
@@ -0,0 +1,40 @@
1
+ import { parse, decorate, _dispose } from "../core.js"
2
+
3
+ /**
4
+ * Change directive - normalized input write-back observer.
5
+ * Handles type detection, coercion, caret preservation.
6
+ *
7
+ * :change="v => x = v"
8
+ * :change.debounce-300="v => query = v"
9
+ *
10
+ * @param {Element} el - Form element
11
+ * @param {Object} state - State object
12
+ * @param {string} expr - Handler expression (receives coerced value)
13
+ * @param {string} name - Directive name with modifiers
14
+ * @returns {{ [Symbol.dispose]: () => void }} Disposal object
15
+ */
16
+ export default (el, state, expr, name) => {
17
+ const [, ...mods] = name.split('.'),
18
+ evaluate = parse(expr).bind(el)
19
+
20
+ // coerce value from element based on input type
21
+ const coerce =
22
+ el.type === 'checkbox' ? () => el.checked :
23
+ el.type === 'select-multiple' ? () => [...el.selectedOptions].map(o => o.value) :
24
+ /^(date|time|month|week)/.test(el.type) ? () => el.value :
25
+ () => el.selectedIndex < 0 ? null : isNaN(el.valueAsNumber) ? el.value : el.valueAsNumber
26
+
27
+ const handler = decorate(Object.assign(() => {
28
+ evaluate(state, fn => typeof fn === 'function' ? fn(coerce()) : fn)
29
+ }, { target: el }), mods)
30
+
31
+ el.addEventListener('input', handler)
32
+ el.addEventListener('change', handler)
33
+
34
+ return {
35
+ [_dispose]() {
36
+ el.removeEventListener('input', handler)
37
+ el.removeEventListener('change', handler)
38
+ }
39
+ }
40
+ }
@@ -14,7 +14,7 @@ export default (el, st, ex, name) => {
14
14
 
15
15
  return (v) => {
16
16
  _new = new Set
17
- if (v) clsx(typeof v === 'function' ? v(el.className) : v).split(' ').map(c => c && _new.add(c))
17
+ if (v) for (let c of clsx(typeof v === 'function' ? v(el.className) : v).split(' ')) c && _new.add(c)
18
18
  for (let c of _cur) if (!_new.has(c)) el.classList.remove(c);
19
19
  for (let c of _new) if (!_cur.has(c)) el.classList.add(c);
20
20
  if (!el.classList.length) el.removeAttribute('class')
@@ -0,0 +1,44 @@
1
+ import { parse, decorate, _dispose } from "../core.js"
2
+
3
+ /**
4
+ * Intersect directive - IntersectionObserver wrapper.
5
+ * Statement form fires on enter. Function form receives entry for full control.
6
+ *
7
+ * :intersect="visible = true"
8
+ * :intersect.once="loadImage()"
9
+ * :intersect="entry => visible = entry.isIntersecting"
10
+ *
11
+ * @param {Element} el - Target element
12
+ * @param {Object} state - State object
13
+ * @param {string} expr - Handler expression
14
+ * @param {string} name - Directive name with modifiers
15
+ * @returns {{ [Symbol.dispose]: () => void }} Disposal object
16
+ */
17
+ const intersect = (el, state, expr, name) => {
18
+ const [, ...mods] = name.split('.')
19
+ const evaluate = parse(expr).bind(el)
20
+
21
+ let once = mods.includes('once')
22
+
23
+ const trigger = decorate(Object.assign((entry) => {
24
+ evaluate(state, fn => typeof fn === 'function' ? fn(entry) : fn)
25
+ }, { target: el }), mods)
26
+
27
+ const io = new IntersectionObserver(entries => {
28
+ for (const entry of entries) {
29
+ if (entry.isIntersecting) {
30
+ trigger(entry)
31
+ if (once) io.disconnect()
32
+ }
33
+ }
34
+ })
35
+
36
+ io.observe(el)
37
+
38
+ return {
39
+ [_dispose]() { io.disconnect() }
40
+ }
41
+ }
42
+
43
+ intersect.observer = true
44
+ export default intersect
@@ -0,0 +1,37 @@
1
+ import { parse, decorate, _dispose } from "../core.js"
2
+
3
+ /**
4
+ * Mount directive - lifecycle observer.
5
+ * Runs once on connect. Function form receives element, can return cleanup.
6
+ * Statement form runs directly.
7
+ *
8
+ * :mount="console.log('connected')"
9
+ * :mount="el => (setup(el), () => cleanup(el))"
10
+ * :mount="el => ref = el"
11
+ *
12
+ * @param {Element} el - Target element
13
+ * @param {Object} state - State object
14
+ * @param {string} expr - Handler expression
15
+ * @param {string} name - Directive name with modifiers
16
+ * @returns {{ [Symbol.dispose]: () => void }} Disposal object
17
+ */
18
+ export default (el, state, expr, name) => {
19
+ const [, ...mods] = name.split('.'),
20
+ evaluate = parse(expr).bind(el)
21
+
22
+ let cleanup
23
+
24
+ const trigger = decorate(Object.assign(() => {
25
+ const result = evaluate(state, fn => typeof fn === 'function' ? fn(el) : fn)
26
+ if (typeof result === 'function') cleanup = result
27
+ }, { target: el }), mods)
28
+
29
+ trigger()
30
+
31
+ return {
32
+ [_dispose]() {
33
+ cleanup?.()
34
+ cleanup = null
35
+ }
36
+ }
37
+ }
package/directive/ref.js CHANGED
@@ -1,20 +1,27 @@
1
1
  import { parse } from "../core.js"
2
- import { setter } from "./value.js"
3
2
 
4
3
  /**
5
- * Ref directive - stores element reference in state.
6
- * If expression is a function, calls it with element (returns dispose).
4
+ * Creates a setter function for assigning a value to a state path.
5
+ * @param {string} expr - Expression to assign to (e.g., "x" or "refs.el")
6
+ * @returns {(target: Object, value: any) => void} Setter function
7
+ */
8
+ const setter = (expr, _set = parse(`${expr}=__`)) => (target, value) => {
9
+ target.__ = value; _set(target), delete target.__
10
+ }
11
+
12
+ /**
13
+ * Ref directive - stores element reference in state or calls callback with element.
14
+ *
15
+ * :ref="x" → state.x = el
16
+ * :ref="refs.el" → state.refs.el = el
17
+ * :ref="el => setup(el)" → calls callback with element
18
+ *
7
19
  * @param {Element} el - Target element
8
20
  * @param {Object} state - State object
9
- * @param {string} expr - Variable name or function expression
10
- * @returns {{ [Symbol.dispose]: () => void } | void} Disposal object or void
21
+ * @param {string} expr - Variable name, path, or callback expression
11
22
  */
12
23
  export default (el, state, expr) => {
13
- let fn = parse(expr)(state)
14
-
15
- if (typeof fn == 'function') return {[Symbol.dispose]:fn(el)}
16
-
17
- // NOTE: we have to set element statically (outside of effect) to avoid parasitic sub - multiple els with same :ref can cause recursion (eg. :each :ref="x")
18
- // Object.defineProperty(state, expr, { value: el, configurable: true })
19
- setter(expr)(state, el)
24
+ let result = parse(expr).call(el, state)
25
+ if (typeof result === 'function') result(el)
26
+ else setter(expr)(state, el)
20
27
  }
@@ -0,0 +1,39 @@
1
+ import { parse, decorate, _dispose } from "../core.js"
2
+
3
+ /**
4
+ * Resize directive - ResizeObserver wrapper.
5
+ * Function form receives {width, height, entry} object.
6
+ *
7
+ * :resize="({width, height}) => cols = Math.floor(width / 200)"
8
+ * :resize.throttle-100="({width}) => narrow = width < 600"
9
+ *
10
+ * @param {Element} el - Target element
11
+ * @param {Object} state - State object
12
+ * @param {string} expr - Handler expression
13
+ * @param {string} name - Directive name with modifiers
14
+ * @returns {{ [Symbol.dispose]: () => void }} Disposal object
15
+ */
16
+ const resize = (el, state, expr, name) => {
17
+ const [, ...mods] = name.split('.')
18
+ const evaluate = parse(expr).bind(el)
19
+
20
+ const trigger = decorate(Object.assign((size) => {
21
+ evaluate(state, fn => typeof fn === 'function' ? fn(size) : fn)
22
+ }, { target: el }), mods)
23
+
24
+ const ro = new ResizeObserver(entries => {
25
+ for (const entry of entries) {
26
+ const rect = entry.contentRect
27
+ trigger({ width: rect.width, height: rect.height, entry })
28
+ }
29
+ })
30
+
31
+ ro.observe(el)
32
+
33
+ return {
34
+ [_dispose]() { ro.disconnect() }
35
+ }
36
+ }
37
+
38
+ resize.observer = true
39
+ export default resize
@@ -13,12 +13,12 @@ export default (el, state, expr, names) => {
13
13
  let cur, // current step callback
14
14
  off // current step disposal
15
15
 
16
+ const evaluate = parse(expr).bind(el)
17
+
16
18
  let steps = names.split('..').map((step, i, { length }) => step.split(':').reduce(
17
19
  (prev, str) => {
18
20
  const [name, ...mods] = str.slice(2).split('.')
19
21
 
20
- const evaluate = parse(expr).bind(el)
21
-
22
22
  const next = (fn, e) => cur = typeof fn === 'function' ? fn(e) : fn
23
23
  const trigger = decorate(Object.assign(
24
24
  e => (!i ? evaluate(state, (fn) => next(fn, e)) : next(cur, e), off(), off = steps[(i + 1) % length]()),
@@ -1,4 +1,4 @@
1
- import { attr, dashcase } from "../core.js";
1
+ import { attr, dashcase, isCE } from "../core.js";
2
2
 
3
3
  /**
4
4
  * Spread directive - sets multiple attributes from object.
@@ -6,4 +6,4 @@ import { attr, dashcase } from "../core.js";
6
6
  * @param {Element} target - Target element
7
7
  * @returns {(value: Record<string, any>) => void} Update function
8
8
  */
9
- export default (target) => value => { for (let key in value) attr(target, dashcase(key), value[key]) }
9
+ export default (target) => value => { let ce = isCE(target); for (let key in value) attr(target, ce ? key : dashcase(key), value[key]) }
@@ -19,7 +19,7 @@ export default (el, st, ex, name) => {
19
19
  else {
20
20
  if (_static) attr(el, "style", _static);
21
21
  // NOTE: we skip names not starting with a letter - eg. el.style stores properties as { 0: --x } or JSDOM has _pfx
22
- for (let k in v) k[0] == '-' ? el.style.setProperty(k, v[k]) : k[0] > 'A' && (el.style[k] = v[k])
22
+ for (let k in v) k[0] == '-' ? el.style.setProperty(k, v[k]) : k[0] >= 'A' && (el.style[k] = v[k])
23
23
  }
24
24
  }
25
25
  }
package/directive/text.js CHANGED
@@ -2,11 +2,32 @@ import { frag } from "../core.js"
2
2
 
3
3
  /**
4
4
  * Text directive - sets textContent reactively.
5
+ * Preserves caret/selection position across updates.
5
6
  * @param {Element | HTMLTemplateElement} el - Target element
6
7
  * @returns {(v: any) => void} Update function
7
8
  */
8
9
  export default el => (
9
10
  // <template :text="a"/> or previously initialized template
10
11
  el.content && el.replaceWith(el = frag(el).childNodes[0]),
11
- v => (v = typeof v === 'function' ? v(el.textContent) : v, el.textContent = v == null ? "" : v)
12
+ v => {
13
+ v = typeof v === 'function' ? v(el.textContent) : v
14
+ v = v == null ? "" : v
15
+ if (el.textContent === v) return
16
+
17
+ // save caret position
18
+ let s = el.getRootNode().getSelection?.()
19
+ let off = s?.rangeCount && el.contains(s.anchorNode) ? s.getRangeAt(0).startOffset : -1
20
+
21
+ el.textContent = v
22
+
23
+ // restore caret
24
+ if (off >= 0 && el.firstChild) {
25
+ let pos = Math.min(off, el.firstChild.textContent.length)
26
+ let r = new Range()
27
+ r.setStart(el.firstChild, pos)
28
+ r.collapse(true)
29
+ s.removeAllRanges()
30
+ s.addRange(r)
31
+ }
32
+ }
12
33
  )
@@ -1,42 +1,17 @@
1
- import sprae, { attr, parse, _state, _dispose } from "../core.js";
1
+ import sprae, { attr, _dispose } from "../core.js";
2
2
 
3
3
  /**
4
- * Creates a setter function for two-way binding.
5
- * @param {string} expr - Expression to assign to
6
- * @returns {(target: Object, value: any) => void} Setter function
7
- */
8
- export const setter = (expr, _set = parse(`${expr}=__`)) => (target, value) => {
9
- target.__ = value; _set(target), delete target.__
10
- }
11
-
12
- /**
13
- * Value directive - two-way binding for form elements.
14
- * Supports text, checkbox, radio, select, and textarea.
4
+ * Value directive - one-way binding (state → DOM).
5
+ * Sets element value/checked/selected from state.
6
+ * For write-back (DOM state), use :change directive.
7
+ *
15
8
  * @param {HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement} el - Form element
16
9
  * @param {Object} state - State object
17
- * @param {string} expr - Bound expression
18
10
  * @returns {(value: any) => void} Update function
19
11
  */
20
- export default (el, state, expr) => {
21
- try {
22
- const set = setter(expr)
23
- const handleChange = el.type === 'checkbox' ? () => set(state, el.checked) :
24
- el.type === 'select-multiple' ? () => set(state, [...el.selectedOptions].map(o => o.value)) :
25
- /^(date|time|month|week)/.test(el.type) ? () => set(state, el.value) :
26
- () => set(state, el.selectedIndex < 0 ? null : isNaN(el.valueAsNumber) ? el.value : el.valueAsNumber);
27
-
28
- el.oninput = el.onchange = handleChange;
29
-
30
- if (el.type?.startsWith('select')) {
31
- let mo = new MutationObserver(() => handleChange())
32
- mo.observe(el, { childList: true, subtree: true, attributes: true });
33
- sprae(el, state)
34
- let _prevDispose = el[_dispose]
35
- if (_prevDispose) el[_dispose] = () => { mo?.disconnect(); mo = null; _prevDispose() }
36
- }
37
-
38
- parse(expr)(state) ?? handleChange()
39
- } catch { }
12
+ export default (el, state) => {
13
+ // select elements need children spraed first (for :each options)
14
+ if (el.type?.startsWith('select')) sprae(el, state)
40
15
 
41
16
  return (el.type === "text" || el.type === "" || el.tagName === "TEXTAREA") ?
42
17
  (value, _from, _to) => (
@@ -48,7 +23,7 @@ export default (el, state, expr) => {
48
23
  (el.type === "checkbox") ?
49
24
  (value) => (el.checked = value, attr(el, "checked", value)) :
50
25
  (el.type === 'radio') ? (value) => (
51
- el.value === value && ((el.checked = value), attr(el, 'checked', value))
26
+ el.checked = el.value === value, attr(el, 'checked', el.checked || null)
52
27
  ) :
53
28
  (el.type === "select-one") ?
54
29
  (value) => {
@@ -57,8 +32,7 @@ export default (el, state, expr) => {
57
32
  el.value = value;
58
33
  } :
59
34
  (el.type === 'select-multiple') ? (value) => {
60
- for (let o of el.options) o.removeAttribute('selected')
61
- for (let v of value) el.querySelector(`[value="${v}"]`).setAttribute('selected', '')
35
+ for (let o of el.options) value.some(v => v == o.value) ? o.setAttribute('selected', '') : o.removeAttribute('selected')
62
36
  } :
63
- (value) => (el.value = value);
37
+ (value) => (el.value = value)
64
38
  }