sprae
Version:
DOM microhydration
267 lines (233 loc) • 10.4 kB
JavaScript
/**
* @fileoverview Sprae - lightweight reactive HTML templating library
* @module sprae
*/
import store from "./store.js";
import { batch, computed, effect, signal, untracked } from './core.js';
import * as signals from './signal.js';
import sprae, { use, decorate, directive, modifier, parse, throttle, debounce, _off, _state, _on, _dispose, _add, start, isCE } from './core.js';
import _if from "./directive/if.js";
import _else from "./directive/else.js";
import _text from "./directive/text.js";
import _class from "./directive/class.js";
import _style from "./directive/style.js";
import _fx from "./directive/fx.js";
import _value from "./directive/value.js";
import _ref from "./directive/ref.js";
import _scope from "./directive/scope.js";
import _each from "./directive/each.js";
import _default from "./directive/_.js";
import _spread from "./directive/spread.js";
import _event from "./directive/event.js";
import _seq from "./directive/sequence.js";
import _html from "./directive/html.js";
import _portal from "./directive/portal.js";
import _hidden from "./directive/hidden.js";
import _mount from "./directive/mount.js";
import _change from "./directive/change.js";
import _intersect from "./directive/intersect.js";
import _resize from "./directive/resize.js";
// mark observers: they handle own modifiers, bypass reactive plumbing
_mount.observer = _change.observer = true
Object.assign(directive, {
_: _default,
'': _spread,
class: _class,
text: _text,
html: _html,
style: _style,
fx: _fx,
value: _value,
ref: _ref,
scope: _scope,
if: _if,
else: _else,
each: _each,
portal: _portal,
hidden: _hidden,
mount: _mount,
change: _change,
intersect: _intersect,
resize: _resize,
})
/**
* Directive initializer with modifiers support.
* @param {Element} target - Target element
* @param {string} name - Directive name with modifiers (e.g., 'onclick.throttle-500')
* @param {string} expr - Expression string
* @param {Object} state - Reactive state object
* @returns {() => (() => void) | void} Initializer function that returns a disposer
*/
const dir = (target, name, expr, state) => {
let [dirName, ...mods] = name.split('.'), create = directive[dirName] || directive._
let hasMods = mods.length > 0
return () => {
let el = target, update, change, trigger, count = 0
if (hasMods) {
// modifiers can retarget the update or schedule it, so they keep the trigger indirection
change = signal(0)
trigger = decorate(Object.assign(() => change.value++, { target }), mods)
el = trigger.target ?? target
}
update = create(el, state, expr, dirName)
if (!update?.call) return update?.[_dispose]
let evaluate = update.eval ?? parse(expr).bind(el),
_out, out = () => (typeof _out === 'function' && _out(), _out=null) // effect trigger and invoke may happen in the same tick, so it will be effect-within-effect call - we need to store output of evaluate to return from trigger effect
// use element's own state for expression evaluation, unless it's a custom element
// (custom elements: directives are parent prop setters, must evaluate against parent state)
if (!isCE(el)) state = el[_state] ?? state
let off = hasMods ? effect(() => {
change.value == count ? trigger() : (count = change.value, _out = evaluate(state, update))
return out
}) : effect(() => (_out = evaluate(state, update), out))
if (!(_state in el)) return off
let _d = 0
return () => { if (_d) return; _d = 1; off(); update[_off] ? update[_off]() : el[_dispose]?.() }
}
}
// Parses time string to ms: 100, 100ms, 1s, 1m
const parseTime = (t) => !t ? 0 : typeof t === 'number' ? t :
(([, n, u] = t.match(/^(\d+)(ms|s|m)?$/) || []) => (n = +n, u === 's' ? n * 1000 : u === 'm' ? n * 60000 : n))()
// Creates scheduler from time/keyword (idle, raf, tick, or ms)
const scheduler = (t) =>
t === 'idle' ? requestIdleCallback :
t === 'raf' ? requestAnimationFrame :
!t || t === 'tick' ? queueMicrotask :
(fn) => setTimeout(fn, parseTime(t))
// Built-in modifiers for timing, targeting, and event handling
Object.assign(modifier, {
/**
* Delays callback by interval since last call (trailing edge).
* Supports: tick (default), raf, idle, N, Nms, Ns, Nm. Add -immediate for leading edge.
* Examples: .debounce, .debounce-100, .debounce-1s, .debounce-raf, .debounce-idle, .debounce-100-immediate
*/
debounce: (fn, a, b) => debounce(fn, scheduler(a === 'immediate' ? b : a), a === 'immediate' || b === 'immediate'),
/**
* Limits callback rate to interval (leading + trailing edges).
* Supports: tick (default), raf, idle, N, Nms, Ns, Nm.
* Examples: .throttle, .throttle-100, .throttle-1s, .throttle-raf, .throttle-idle
*/
throttle: (fn, a) => throttle(fn, scheduler(a)),
/** Runs callback after delay. Supports: tick (default), raf, idle, N, Nms, Ns, Nm. */
delay: (fn, a) => ((sched = scheduler(a)) => (e) => sched(() => fn(e)))(),
/** Shortcut for delay-tick (next microtask). */
tick: (fn) => (e) => queueMicrotask(() => fn(e)),
/** Shortcut for delay-raf (next animation frame). */
raf: (fn) => (e) => requestAnimationFrame(() => fn(e)),
/** Calls handler only once. */
once: (fn, _done, _fn) => (_fn = (e) => !_done && (_done = 1, fn(e)), _fn.once = true, _fn),
/** Attaches event listener to window. */
window: fn => (fn.target = fn.target.ownerDocument.defaultView, fn),
/** Attaches event listener to document. */
document: fn => (fn.target = fn.target.ownerDocument, fn),
/** Attaches event listener to document root element (<html>). */
root: fn => (fn.target = fn.target.ownerDocument.documentElement, fn),
/** Attaches event listener to body. */
body: fn => (fn.target = fn.target.ownerDocument.body, fn),
/** Attaches event listener to parent element. */
parent: fn => (fn.target = fn.target.parentNode, fn),
/** Triggers only when event target is the element itself. */
self: (fn) => (e) => (e.target === fn.target && fn(e)),
/** Triggers when event is outside the element. Ignores drag-out (pointerdown inside, pointerup outside). */
away: (fn, _pd) => {
let doc = fn.target.ownerDocument, pdHandler = e => _pd = e.target, _skip = doc.currentEvent || doc.defaultView?.event
doc.addEventListener('pointerdown', pdHandler, true)
return Object.assign(
(e) => e !== _skip && !fn.target.contains(e.type === 'click' ? _pd ?? e.target : e.target) && e.target.isConnected && fn(e),
{ target: doc, [_dispose]: () => doc.removeEventListener('pointerdown', pdHandler, true) }
)
},
/** Calls preventDefault() before handler. */
prevent: (fn) => (e) => (e?.preventDefault(), fn(e)),
/** Calls stopPropagation() or stopImmediatePropagation() (with -immediate). */
stop: (fn, _how) => (e) => (_how?.[0] === 'i' ? e?.stopImmediatePropagation() : e?.stopPropagation(), fn(e)),
/** Sets passive option for event listener. */
passive: fn => (fn.passive = true, fn),
/** Sets capture option for event listener. */
capture: fn => (fn.capture = true, fn),
})
/** Alias for .away modifier */
modifier.outside = modifier.away
/**
* Key testers for keyboard event modifiers.
* @type {Record<string, (e: KeyboardEvent) => boolean>}
*/
const keys = {
ctrl: e => e.ctrlKey || e.key === "Control" || e.key === "Ctrl",
shift: e => e.shiftKey || e.key === "Shift",
alt: e => e.altKey || e.key === "Alt",
meta: e => e.metaKey || e.key === "Meta",
cmd: e => e.metaKey || e.key === "Command",
arrow: e => e.key?.startsWith("Arrow"),
enter: e => e.key === "Enter",
esc: e => e.key?.startsWith("Esc"),
tab: e => e.key === "Tab",
space: e => e.key === " " || e.key === "Space" || e.key === " ",
delete: e => e.key === "Delete" || e.key === "Backspace",
digit: e => /^\d$/.test(e.key),
letter: e => /^\p{L}$/gu.test(e.key),
char: e => /^\S$/.test(e.key),
};
// match key by name, or by e.key (case-insensitive), or by keyCode (digits)
const keyMatch = (k, e) => keys[k]?.(e) || e.key?.toLowerCase() === k || e.keyCode == k
// Augment modifiers with key testers (e.g., .enter, .ctrl, .ctrl-a, .ctrl-65)
for (let k in keys) modifier[k] = (fn, a, b) => (e) => keys[k](e) && (!a || keyMatch(a, e)) && (!b || keyMatch(b, e)) && fn(e)
// Checks for first-level semicolons (statement vs expression)
const hasSemi = s => {
let d = 0, q = '', esc = 0
for (let ch of s) {
if (q) {
if (esc) esc = 0
else if (ch === '\\') esc = 1
else if (ch === q) q = ''
continue
}
if (ch === ';' && !d) return true
if (ch === '{') d++
else if (ch === '}') d--
else if (ch === '"' || ch === "'" || ch === '`') q = ch
}
return false
}
// Configure sprae with default compiler and signals
use({
// Default compiler wraps expression for new Function
compile: expr => {
// if, const, let - no return
if (/^(if|let|const)\b/.test(expr));
// first-level semicolons - no return
else if (hasSemi(expr));
else expr = `return ${expr}`
// async expression
if (/\bawait\s/.test(expr)) expr = `return (async()=>{${expr}})()`
return sprae.constructor(`with(arguments[0]){${expr}}`)
},
// these 2 exceptions might look inconsistent, but arguably that's the cleanest way to avoid coupling
dir: (el, name, expr, state) => {
// sequences: handle own modifiers, return dispose
if (name.includes('..')) return () => _seq(el, state, expr, name)[_dispose]
return name.split(':').reduce((prev, str) => {
let dirName = str.split('.')[0]
// events and observers handle own modifiers, return dispose
let obs = directive[dirName]
let start = str.startsWith('on') ? () => _event(el, state, expr, str)[_dispose]
: obs?.observer ? () => obs(el, state, expr, str)[_dispose]
: dir(el, str, expr, state)
return !prev ? start : (p, s) => (p = prev(), s = start(), () => { p(); s() })
}, null)
},
...signals
})
// Expose for runtime configuration
sprae.use = use
sprae.store = store
sprae.directive = directive
sprae.modifier = modifier
/**
* Disposes a spraed element, cleaning up all effects and state.
* @param {Element} el - Element to dispose
*/
const dispose = sprae.dispose = (el) => el[_dispose]?.()
sprae.start = start
export default sprae
export { sprae, store, signal, effect, computed, batch, untracked, start, use, throttle, debounce, dispose }