sprae 11.6.0 → 12.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.
package/core.js CHANGED
@@ -1,138 +1,247 @@
1
- import { use, effect, untracked } from "./signal.js";
2
- import { store } from './store.js';
1
+ import store, { _change, _signals } from "./store.js";
3
2
 
4
- // polyfill
5
- export const _dispose = (Symbol.dispose ||= Symbol("dispose"));
3
+ export const _dispose = (Symbol.dispose ||= Symbol("dispose")),
4
+ _state = Symbol("state"),
5
+ _on = Symbol('on'),
6
+ _off = Symbol('off'),
7
+ _add = Symbol('add');
6
8
 
7
- export const _state = Symbol("state"), _on = Symbol('on'), _off = Symbol('off')
8
9
 
9
- // registered directives
10
- export const directive = {}
10
+ export let prefix = ':', signal, effect, computed, batch = (fn) => fn(), untracked = batch;
11
11
 
12
- /**
13
- * Register a directive with a parsed expression and evaluator.
14
- * @param {string} name - The name of the directive.
15
- * @param {(el: Element, state: Object, expr: string, name: string) => (value: any) => void} create - A function to create the directive.
16
- * @param {(expr: string) => (state: Object) => any} [p=parse] - Create evaluator from expression string.
17
- */
18
- export const dir = (name, create, p = parse) => directive[name] = (el, expr, state, name, update, evaluate) => (
19
- update = create(el, state, expr, name),
20
- evaluate = p(expr, ':'+name),
21
- () => update(evaluate(state))
22
- )
12
+ export let directive = {}, modifier = {}
13
+
14
+ let currentDir = null;
23
15
 
24
16
  /**
25
17
  * Applies directives to an HTML element and manages its reactive state.
26
18
  *
27
19
  * @param {Element} [el=document.body] - The target HTML element to apply directives to.
28
- * @param {Object} [values] - Initial values to populate the element's reactive state.
20
+ * @param {Object|store} [state] - Initial state values to populate the element's reactive state.
29
21
  * @returns {Object} The reactive state object associated with the element.
30
22
  */
31
- export const sprae = (el=document.body, values) => {
23
+ const sprae = (el = document.body, state) => {
32
24
  // repeated call can be caused by eg. :each with new objects with old keys
33
- if (el[_state]) return Object.assign(el[_state], values)
25
+ if (el[_state]) return Object.assign(el[_state], state)
26
+
27
+ // console.group('sprae', el.outerHTML)
34
28
 
35
29
  // take over existing state instead of creating a clone
36
- let state = store(values || {}), offs = [], fx = []
30
+ state = store(state || {})
37
31
 
38
- let init = (el, attrs = el.attributes) => {
39
- // we iterate live collection (subsprae can init args)
40
- if (attrs) for (let i = 0; i < attrs.length;) {
41
- let { name, value } = attrs[i], update, dir
32
+ let fx = [], offs = [], fn,
33
+ on = () => (!offs && (offs = fx.map(fn => fn()))),
34
+ off = () => (offs?.map(off => off()), offs = null)
42
35
 
43
- // if we have parts meaning there's attr needs to be spraed
44
- if (name.startsWith(prefix)) {
45
- el.removeAttribute(name);
36
+ // on/off all effects
37
+ // we don't call prevOn as convention: everything defined before :else :if won't be disabled by :if
38
+ // imagine <x :onx="..." :if="..."/> - when :if is false, it disables directives after :if (calls _off) but ignores :onx
39
+ el[_on] = on
40
+ el[_off] = off
46
41
 
47
- // multiple attributes like :id:for=""
48
- for (dir of name.slice(prefix.length).split(':')) {
49
- update = (directive[dir] || directive.default)(el, value, state, dir)
42
+ // destroy
43
+ el[_dispose] ||= () => (el[_off](), el[_off] = el[_on] = el[_dispose] = el[_state] = el[_add] = null)
50
44
 
51
- // save & start effect
52
- fx.push(update)
53
- // FIXME: since effect can have async start, we can just use el[_on]
54
- offs.push(effect(update))
45
+ const add = (el, _attrs = el.attributes) => {
46
+ // we iterate live collection (subsprae can init args)
47
+ if (_attrs) for (let i = 0; i < _attrs.length;) {
48
+ let { name, value } = _attrs[i]
55
49
 
56
- // stop after :each, :if, :with etc.
57
- if (el[_state] === null) return
58
- }
59
- } else i++
60
- }
50
+ if (name.startsWith(prefix)) {
51
+ el.removeAttribute(name)
61
52
 
62
- // :if and :each replace element with text node, which tweaks .children length, but .childNodes length persists
63
- for (let child of el.childNodes) child.nodeType == 1 && init(child)
64
- };
53
+ // directive initializer can be redefined
54
+ fx.push(fn = initDirective(el, name, value, state))
55
+ offs.push(fn())
65
56
 
66
- init(el);
57
+ // stop after subsprae like :each, :if, :scope etc.
58
+ if (_state in el) return
59
+ } else i++
60
+ }
67
61
 
68
- // if element was spraed by inline :with instruction (meaning it has extended state) - skip, otherwise save _state
69
- if (!(_state in el)) {
70
- el[_state] = state
62
+ // :if and :each replace element with text node, which tweaks .children length, but .childNodes length persists
63
+ // for (let i = 0, child; i < (el.childNodes.length); i++) child = el.childNodes[i], child.nodeType == 1 && add(child)
64
+ for (let child of [...el.childNodes]) child.nodeType == 1 && add(child)
65
+ };
71
66
 
72
- // on/off all effects
73
- el[_off] = () => (offs.map(off => off()), offs = [])
74
- el[_on] = () => offs = fx.map(f => effect(f))
67
+ el[_add] = add;
75
68
 
76
- // destroy
77
- el[_dispose] = () => (el[_off](), el[_off] = el[_on] = el[_dispose] = el[_state] = null)
78
- }
69
+ add(el);
70
+
71
+ // if element was spraed by inline :with/:if/:each/etc instruction (meaning it has state placeholder) - skip, otherwise save _state
72
+ if (el[_state] === undefined) el[_state] = state
73
+
74
+ // console.groupEnd()
79
75
 
80
76
  return state;
81
77
  }
82
78
 
83
- // configure signals/compile
84
- // it's more compact than using sprae.signal = signal etc.
85
- sprae.use = s => (
86
- s.signal && use(s),
87
- s.compile && (compile = s.compile),
88
- s.prefix && (prefix = s.prefix)
89
- )
90
79
 
91
80
  /**
92
- * Parses an expression into an evaluator function, caching the result for reuse.
93
- *
94
- * @param {string} expr - The expression to parse and compile into a function.
95
- * @param {string} dir - The directive associated with the expression (used for error reporting).
96
- * @returns {Function} The compiled evaluator function for the expression.
97
- */
98
- export const parse = (expr, dir, fn) => {
99
- if (fn = memo[expr = expr.trim()]) return fn
81
+ * Initializes directive (defined by sprae build), returns "on" function that enables it
82
+ * Multiprop sequences initializer, eg. :a:b..c:d
83
+ * @type {(el: HTMLElement, name:string, value:string, state:Object) => Function}
84
+ * */
85
+ const initDirective = (el, attrName, expr, state) => {
86
+ let cur, // current step callback
87
+ off // current step disposal
100
88
 
101
- // static time errors
102
- try { fn = compile(expr) }
103
- catch (e) { err(e, dir, expr) }
89
+ let steps = attrName.slice(prefix.length).split('..').map((step, i, { length }) => (
90
+ // multiple attributes like :id:for=""
91
+ step.split(prefix).reduce((prev, str) => {
92
+ let [name, ...mods] = str.split('.');
93
+ let evaluate = parse(expr, directive[currentDir = name]?.parse)
104
94
 
105
- // run time errors
106
- return memo[expr] = s => {
107
- try { return fn(s) }
108
- catch(e) { err(e, dir, expr) }
109
- }
95
+ // events have no effects and can be sequenced
96
+ if (name.startsWith('on')) {
97
+ let type = name.slice(2),
98
+ first = e => (call(evaluate(state), e)),
99
+ fn = applyMods(
100
+ Object.assign(
101
+ // single event vs chain
102
+ length == 1 ? first :
103
+ e => (cur = (!i ? first : cur)(e), off(), off = steps[(i + 1) % length]()),
104
+ { target: el, type }
105
+ ),
106
+ mods);
107
+
108
+ return (_poff) => (_poff = prev?.(), fn.target.addEventListener(type, fn, fn), () => (_poff?.(), fn.target.removeEventListener(type, fn)))
109
+ }
110
+
111
+ // props have no sequences and can be sync
112
+ let update = (directive[name] || directive['*'])(el, state, expr, name)
113
+
114
+ // no-modifiers shortcut
115
+ if (!mods.length && !prev) return () => update && effect(() => evaluate(state, update))
116
+
117
+ let dispose,
118
+ change = signal(-1), // signal authorized to trigger effect: 0 = init; >0 = trigger
119
+ count = -1, // called effect count
120
+
121
+ // effect applier - first time it applies the effect, next times effect is triggered by change signal
122
+ fn = throttle(applyMods(() => {
123
+ if (++change.value) return // all calls except for the first one are handled by effect
124
+ dispose = effect(() => update && (
125
+ change.value == count ? fn() : // separate tick makes sure planner effect call is finished before real eval call
126
+ (count = change.value, evaluate(state, update)) // if changed more than effect called - call it
127
+ ));
128
+ }, mods))
129
+
130
+ return (_poff) => (
131
+ _poff = prev?.(),
132
+ // console.log('ON', name),
133
+ fn(),
134
+ ({
135
+ [name]: () => (
136
+ // console.log('OFF', name, el),
137
+ _poff?.(), dispose(), change.value = -1, count = dispose = null
138
+ )
139
+ })[name]
140
+ )
141
+ }, null)
142
+ ));
143
+
144
+ // off can be changed on the go
145
+ return () => (off = steps[0]())
110
146
  }
111
- const memo = {};
147
+
112
148
 
113
149
  /**
114
- * Branded sprae error with context about the directive and expression
115
- *
116
- * @param {Error} e - The original error object to enhance.
117
- * @param {string} dir - The directive where the error occurred.
118
- * @param {string} [expr=''] - The expression associated with the error, if any.
119
- * @throws {Error} The enhanced error object with a formatted message.
150
+ * Configure sprae
120
151
  */
121
- export const err = (e, dir = '', expr = '') => {
122
- throw Object.assign(e, { message: `∴ ${e.message}\n\n${dir}${expr ? `="${expr}"\n\n` : ""}`, expr })
152
+ export const use = (s) => (
153
+ s.compile && (compile = s.compile),
154
+ s.prefix && (prefix = s.prefix),
155
+ s.signal && (signal = s.signal),
156
+ s.effect && (effect = s.effect),
157
+ s.computed && (computed = s.computed),
158
+ s.batch && (batch = s.batch),
159
+ s.untracked && (untracked = s.untracked)
160
+ )
161
+
162
+
163
+ /**
164
+ * Lifecycle hanger: makes DOM slightly slower but spraes automatically
165
+ */
166
+ export const start = (root = document.body, values) => {
167
+ const state = store(values);
168
+ sprae(root, state);
169
+ const mo = new MutationObserver(mutations => {
170
+ for (const m of mutations) {
171
+ for (const el of m.addedNodes) {
172
+ if (el.nodeType === 1 && el[_state] === undefined) {
173
+ for (const attr of el.attributes) {
174
+ if (attr.name.startsWith(prefix)) {
175
+ root[_add](el); break;
176
+ }
177
+ }
178
+ }
179
+ }
180
+ // for (const el of m.removedNodes) el[Symbol.dispose]?.()
181
+ }
182
+ });
183
+ mo.observe(root, { childList: true, subtree: true });
184
+ return state
123
185
  }
124
186
 
187
+
125
188
  /**
126
189
  * Compiles an expression into an evaluator function.
127
- *
128
- * @type {(expr: string) => Function}
190
+ * @type {(dir:string, expr: string, clean?: string => string) => Function}
129
191
  */
130
192
  export let compile
131
193
 
132
194
  /**
133
- * Attributes prefix, by default ':'
195
+ * Parses an expression into an evaluator function, caching the result for reuse.
196
+ *
197
+ * @param {string} expr The expression to parse and compile into a function.
198
+ * @returns {Function} The compiled evaluator function for the expression.
134
199
  */
135
- export let prefix = ':'
200
+ export const parse = (expr, prepare, _fn) => {
201
+ if (_fn = parse.cache[expr]) return _fn
202
+
203
+ let _expr = expr.trim() || 'undefined'
204
+ if (prepare) _expr = prepare(_expr)
205
+
206
+ // if, const, let - no return
207
+ if (/^(if|let|const)\b/.test(_expr) || /;/.test(_expr)) ;
208
+ else _expr = `return ${_expr}`
209
+
210
+ // async expression
211
+ if (/\bawait\s/.test(_expr)) _expr = `return (async()=>{ ${_expr} })()`
212
+
213
+ // static time errors
214
+ try {
215
+ _fn = compile(_expr)
216
+ Object.defineProperty(_fn, "name", {value: `∴ ${expr}`})
217
+ } catch (e) { console.error(`∴ ${e}\n\n${prefix + currentDir}="${expr}"`) }
218
+
219
+ // run time errors
220
+ return parse.cache[expr] = (state, cb, _out) => {
221
+ try {
222
+ let result = _fn?.(state)
223
+ // if cb is given - call it with result and return function that returns last cb result - needed for effect cleanup
224
+ if (cb) return result?.then ? result.then(v => _out = cb(v)) : _out = cb(result), () => call(_out)
225
+ else return result
226
+ } catch (e) {
227
+ console.error(`∴ ${e}\n\n${prefix + currentDir}="${expr}"`)
228
+ }
229
+ }
230
+ }
231
+ parse.cache = {};
232
+
233
+
234
+ // apply modifiers to context (from the end due to nature of wrapping ctx.call)
235
+ const applyMods = (fn, mods) => {
236
+ while (mods.length) {
237
+ let [name, ...params] = mods.pop().split('-')
238
+ fn = sx(modifier[name]?.(fn, ...params) ?? fn, fn)
239
+ }
240
+ return fn
241
+ }
242
+
243
+ // soft-extend missing props and ignoring signals
244
+ const sx = (a, b) => { if (a != b) for (let k in b) (a[k] ??= b[k]); return a }
136
245
 
137
246
  // instantiated <template> fragment holder, like persisting fragment but with minimal API surface
138
247
  export const frag = (tpl) => {
@@ -160,4 +269,34 @@ export const frag = (tpl) => {
160
269
  }
161
270
  }
162
271
 
272
+ // if value is function - return result of its call
273
+ export const call = (v, arg) => typeof v === 'function' ? v(arg) : v
274
+
275
+ // camel to kebab
276
+ export const dashcase = (str) => str.replace(/[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g, (match, i) => (i ? '-' : '') + match.toLowerCase());
277
+
278
+ // set attr
279
+ export const attr = (el, name, v) => (v == null || v === false) ? el.removeAttribute(name) : el.setAttribute(name, v === true ? "" : v);
280
+
281
+ // convert any-arg to className string
282
+ export const clsx = (c, _out = []) => !c ? '' : typeof c === 'string' ? c : (
283
+ Array.isArray(c) ? c.map(clsx) :
284
+ Object.entries(c).reduce((s, [k, v]) => !v ? s : [...s, k], [])
285
+ ).join(' ')
286
+
287
+ // throttle function to (once per tick or other custom scheduler)
288
+ export const throttle = (fn, schedule = queueMicrotask) => {
289
+ let _planned = 0;
290
+ const throttled = (e) => {
291
+ if (!_planned++) fn(e), schedule((_dirty = _planned > 1) => (
292
+ _planned = 0, _dirty && throttled(e)
293
+ ));
294
+ }
295
+ return throttled;
296
+ }
297
+
298
+ export const debounce = (fn, schedule = queueMicrotask, _count = 0) => (arg, _planned=++_count) => schedule(() => (_planned == _count && fn(arg)))
299
+
300
+ export * from './store.js';
301
+
163
302
  export default sprae
@@ -1,15 +1,11 @@
1
- import { dir } from "../core.js";
1
+ import { clsx, call } from "../core.js";
2
2
 
3
- dir('class', (el, cur) => (
4
- cur = new Set,
5
- v => {
6
- let clsx = new Set;
7
- if (v) {
8
- if (typeof v === "string") v.split(' ').map(cls => clsx.add(cls));
9
- else if (Array.isArray(v)) v.map(v => v && clsx.add(v));
10
- else Object.entries(v).map(([k, v]) => v && clsx.add(k));
11
- }
12
- for (let cls of cur) if (clsx.has(cls)) clsx.delete(cls); else el.classList.remove(cls);
13
- for (let cls of cur = clsx) el.classList.add(cls)
14
- })
3
+ export default (el, _cur, _new) => (
4
+ _cur = new Set,
5
+ (v) => {
6
+ _new = new Set
7
+ if (v) clsx(call(v, el.className)).split(' ').map(c => c && _new.add(c))
8
+ for (let c of _cur) if (_new.has(c)) _new.delete(c); else el.classList.remove(c);
9
+ for (let c of _cur = _new) el.classList.add(c)
10
+ }
15
11
  )
@@ -1,155 +1,3 @@
1
- // generic property directive
2
- import { dir, err } from "../core.js";
1
+ import { attr, call } from "../core.js";
3
2
 
4
- dir('default', (target, state, expr, name) => {
5
- // simple prop
6
- if (!name.startsWith('on'))
7
- return name ?
8
- value => attr(target, name, value) :
9
- value => { for (let key in value) attr(target, dashcase(key), value[key]) };
10
-
11
- // bind event to a target
12
- // NOTE: if you decide to remove chain of events, thing again - that's unique feature of sprae, don't diminish your own value.
13
- // ona..onb
14
- let ctxs = name.split('..').map(e => {
15
- let ctx = { evt: '', target, test: () => true };
16
- ctx.evt = (e.startsWith('on') ? e.slice(2) : e).replace(/\.(\w+)?-?([-\w]+)?/g,
17
- (_, mod, param = '') => (ctx.test = mods[mod]?.(ctx, ...param.split('-')) || ctx.test, '')
18
- );
19
- return ctx;
20
- });
21
-
22
- // add listener with the context
23
- let addListener = (fn, { evt, target, test, defer, stop, prevent, immediate, ...opts }, cb) => {
24
- if (defer) fn = defer(fn)
25
-
26
- cb = (e) => {
27
- try {
28
- test(e) && (stop && (immediate ? e.stopImmediatePropagation() : e.stopPropagation()), prevent && e.preventDefault(), fn?.call(state, e))
29
- } catch (error) { err(error, `:on${evt}`, fn) }
30
- };
31
-
32
- target.addEventListener(evt, cb, opts)
33
- return () => target.removeEventListener(evt, cb, opts)
34
- };
35
-
36
- // single event
37
- if (ctxs.length == 1) return v => addListener(v, ctxs[0])
38
-
39
- // events cycler
40
- let startFn, nextFn, off, idx = 0
41
- let nextListener = (fn) => {
42
- off = addListener((e) => (
43
- off(), nextFn = fn?.(e), (idx = ++idx % ctxs.length) ? nextListener(nextFn) : (startFn && nextListener(startFn))
44
- ), ctxs[idx]);
45
- }
46
-
47
- return value => (
48
- startFn = value,
49
- !off && nextListener(startFn),
50
- () => startFn = null // nil startFn to autodispose chain
51
- )
52
- })
53
-
54
- // event modifiers
55
- const mods = {
56
- // actions
57
- prevent(ctx) { ctx.prevent = true; },
58
- stop(ctx) { ctx.stop = true; },
59
- immediate(ctx) { ctx.immediate = true; },
60
-
61
- // options
62
- once(ctx) { ctx.once = true; },
63
- passive(ctx) { ctx.passive = true; },
64
- capture(ctx) { ctx.capture = true; },
65
-
66
- // target
67
- window(ctx) { ctx.target = window; },
68
- document(ctx) { ctx.target = document; },
69
- parent(ctx) { ctx.target = ctx.target.parentNode; },
70
-
71
- throttle(ctx, limit=108) { ctx.defer = (fn) => throttle(fn, limit)},
72
- debounce(ctx, wait=108) { ctx.defer = (fn) => debounce(fn, wait) },
73
-
74
- // test
75
- outside: (ctx) => (e) => {
76
- let target = ctx.target;
77
- if (target.contains(e.target)) return false;
78
- if (e.target.isConnected === false) return false;
79
- if (target.offsetWidth < 1 && target.offsetHeight < 1) return false;
80
- return true;
81
- },
82
- self: (ctx) => (e) => e.target === ctx.target,
83
-
84
- // keyboard
85
- ctrl: (_, ...param) => (e) => keys.ctrl(e) && param.every((p) => (keys[p] ? keys[p](e) : e.key === p)),
86
- shift: (_, ...param) => (e) => keys.shift(e) && param.every((p) => (keys[p] ? keys[p](e) : e.key === p)),
87
- alt: (_, ...param) => (e) => keys.alt(e) && param.every((p) => (keys[p] ? keys[p](e) : e.key === p)),
88
- meta: (_, ...param) => (e) => keys.meta(e) && param.every((p) => (keys[p] ? keys[p](e) : e.key === p)),
89
- // NOTE: we don't expose up/left/right/down as too verbose: can and better be handled/differentiated at once
90
- arrow: () => keys.arrow,
91
- enter: () => keys.enter,
92
- esc: () => keys.esc,
93
- tab: () => keys.tab,
94
- space: () => keys.space,
95
- delete: () => keys.delete,
96
- digit: () => keys.digit,
97
- letter: () => keys.letter,
98
- char: () => keys.char,
99
- };
100
-
101
- // key testers
102
- const keys = {
103
- ctrl: (e) => e.ctrlKey || e.key === "Control" || e.key === "Ctrl",
104
- shift: (e) => e.shiftKey || e.key === "Shift",
105
- alt: (e) => e.altKey || e.key === "Alt",
106
- meta: (e) => e.metaKey || e.key === "Meta" || e.key === "Command",
107
- arrow: (e) => e.key.startsWith("Arrow"),
108
- enter: (e) => e.key === "Enter",
109
- esc: (e) => e.key.startsWith("Esc"),
110
- tab: (e) => e.key === "Tab",
111
- space: (e) => e.key === " " || e.key === "Space" || e.key === " ",
112
- delete: (e) => e.key === "Delete" || e.key === "Backspace",
113
- digit: (e) => /^\d$/.test(e.key),
114
- letter: (e) => /^\p{L}$/gu.test(e.key),
115
- char: (e) => /^\S$/.test(e.key),
116
- };
117
-
118
- // create delayed fns
119
- const throttle = (fn, limit) => {
120
- let pause, planned,
121
- block = (e) => {
122
- pause = true;
123
- setTimeout(() => {
124
- pause = false;
125
- // if event happened during blocked time, it schedules call by the end
126
- if (planned) return (planned = false), block(e), fn(e);
127
- }, limit);
128
- };
129
- return (e) => {
130
- if (pause) return (planned = true);
131
- block(e);
132
- return fn(e);
133
- };
134
- };
135
-
136
- const debounce = (fn, wait) => {
137
- let timeout;
138
- return (e) => {
139
- clearTimeout(timeout);
140
- timeout = setTimeout(() => {
141
- timeout = null;
142
- fn(e);
143
- }, wait);
144
- };
145
- };
146
-
147
- // set attr
148
- export const attr = (el, name, v) => {
149
- if (v == null || v === false) el.removeAttribute(name);
150
- else el.setAttribute(name, v === true ? "" : typeof v === "number" || typeof v === "string" ? v : "");
151
- }
152
-
153
- export const dashcase = (str) => {
154
- return str.replace(/[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g, (match, i) => (i ? '-' : '') + match.toLowerCase());
155
- }
3
+ export default (el, st, ex, name) => v => attr(el, name, call(v, el.getAttribute(name)))