Thanks to visit codestin.com
Credit goes to github.com

Skip to content

dy/sprae

Repository files navigation

∴ spræ tests npm bundle size npm

Simple progressive enhancement for DOM or JSX.

Usage

<div id="counter" :scope="{ count: 0 }">
  <p :text="`Clicked ${count} times`"></p>
  <button :onclick="count++">Click me</button>
</div>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/sprae.umd.js" start></script>

Sprae enables reactivity via :-directives.

Directives

:text

Set text content.

Welcome, <span :text="user.name">Guest</span>.

<!-- fragment -->
Welcome, <template :text="user.name"><template>.

<!-- function -->
<span :text="val => val + text"></span>

:class

Set className.

<div :class="foo"></div>

<!-- appends to static class -->
<div class="bar" :class="baz"></div>

<!-- array/object, a-la clsx -->
<div :class="['foo', bar && 'bar', { baz }]"></div>

<!-- function -->
<div :class="str => [str, 'active']"></div>

:style

Set style.

<span :style="'display: inline-block'"></span>

<!-- extends static style -->
<div style="foo: bar" :style="'bar-baz: qux'">

<!-- object -->
<div :style="{bar: 'baz', '--qux': 'quv'}"></div>

<!-- function -->
<div :style="obj => ({'--bar': baz})"></div>

:value

Bind input, textarea or select value.

<input :value="value" />
<textarea :value="value" />

<!-- handles option & selected attr -->
<select :value="selected">
  <option :each="i in 5" :value="i" :text="i"></option>
</select>

<!-- checked attr -->
<input type="checkbox" :value="item.done" />

<!-- function -->
<input :value="value => value + str" />

:<attr>, :

Set any attribute(s).

<label :for="name" :text="name" />

<!-- multiple -->
<input :id:name="name" />

<!-- function -->
<div :hidden="hidden => !hidden"></div>

<!-- spread -->
<input :="{ id: name, name, type: 'text', value, ...props  }" />

:if, :else

Control flow.

<span :if="foo">foo</span>
<span :else :if="bar">bar</span>
<span :else>baz</span>

<!-- fragment -->
<template :if="foo">foo <span>bar</span> baz</template>

<!-- function -->
<span :if="active => test()"></span>

:each

Multiply content.

<ul><li :each="item in items" :text="item" /></ul>

<!-- cases -->
<li :each="item, idx? in array" />
<li :each="value, key? in object" />
<li :each="count, idx? in number" />
<li :each="item, idx? in function" />

<!-- fragment -->
<template :each="item in items">
  <dt :text="item.term"/>
  <dd :text="item.definition"/>
</template>

:scope

Define state container for a subtree.

<!-- transparent -->
<x :scope="{foo: 'foo'}">
  <y :scope="{bar: 'bar'}" :text="foo + bar"></y>
</x>

<!-- define variables -->
<x :scope="x=1, y=2" :text="x+y"></x>

<!-- blank -->
<x :scope :ref="id"></x>

<!-- access to local scope instance -->
<x :scope="scope => { scope.x = 'foo'; return scope }" :text="x"></x>

:fx

Run effect.

<!-- inline -->
<div :fx="a.value ? foo() : bar()" />

<!-- function / cleanup -->
<div :fx="() => (id = setInterval(tick, 1000), () => clearInterval(id))" />

:ref

Expose an element in scope or get ref to the element.

<div :ref="card" :fx="handle(card)"></div>

<!-- reference -->
<div :ref="el => el.innerHTML = '...'"></div>

<!-- local reference -->
<li :each="item in items" :scope :ref="li">
  <input :onfocus="e => li.classList.add('editing')"/>
</li>

<!-- mount / unmount -->
<textarea :ref="el => {/* onmount */ return () => {/* onunmount */}}" :if="show"></textarea>

:on<event>

Add event listener.

<!-- inline -->
<button :onclick="count++">Up</button>

<!-- function -->
<input type="checkbox" :onchange="event => isChecked = event.target.value">

<!-- multiple -->
<input :onvalue="text" :oninput:onchange="event => text = event.target.value">

<!-- sequence -->
<button :onfocus..onblur="evt => { handleFocus(); return evt => handleBlur()}">

<!-- modifiers -->
<button :onclick.throttle-500="handle()">Not too often</button>

Modifiers

.debounce-<ms|tick|frame|idle>?

Defer callback by ms, next tick/animation frame, or until idle. Defaults to 250ms.

<!-- debounce keyboard input by 200ms -->
<input :oninput.debounce-200="event => update(event)" />

<!-- set class in the next tick -->
<div :class.debounce-tick="{ active }">...</div>

<!-- debounce resize to animation framerate -->
<div :onresize.window.debounce-frame="updateSize()">...</div>

<!-- batch logging when idle -->
<div :fx.debounce-idle="sendAnalytics(batch)"></div>

.throttle-<ms|tick|frame>?

Limit callback rate to interval in ms, tick or animation framerate. By default 250ms.

<!-- throttle text update -->
<div :text.throttle-100="text.length"></div>

<!-- lock style update to animation framerate -->
<div :onscroll.throttle-frame="progress = (scrollTop / scrollHeight) * 100"/>

<!-- ensure separate stack for events -->
<div :onmessage.window.throttle-tick="event => log(event)">...</div>

.once

Call only once.

<!-- run event callback only once -->
<button :onclick.once="loadMoreData()">Start</button>

<!-- run once on sprae init -->
<div :fx.once="console.log('sprae init')">

.window, .document, .parent, .outside, .self  events only

Specify event target.

<!-- close dropdown when click outside -->
<div :onclick.outside="closeMenu()" :class="{ open: isOpen }">Dropdown</div>

<!-- interframe communication -->
<div :onmessage.window="e => e.data.type === 'success' && complete()">...</div>

.passive, .capture  events only

Event listener options.

<div :onscroll.passive="e => pos = e.scrollTop">Scroll me</div>

<body :ontouchstart.capture="logTouch(e)"></body>

.prevent, .stop, .stop-immediate  events only

Prevent default or stop (immediate) propagation.

<!-- prevent default -->
<a :onclick.prevent="navigate('/page')" href="/default">Go</a>

<!-- stop immediate propagation -->
<button :onclick.stop-immediate="criticalHandle()">Click</button>

.<key>-<*> events only

Filter event by event.key or combination:

  • .ctrl, .shift, .alt, .meta, .enter, .esc, .tab, .space – direct key
  • .delete – delete or backspace
  • .arrow – up, right, down or left arrow
  • .digit – 0-9
  • .letter – A-Z, a-z or any unicode letter
  • .char – any non-space character
<!-- any arrow event -->
<div :onkeydown.arrow="event => navigate(event.key)"></div>

<!-- key combination -->
<input :onkeydown.prevent.ctrl-c="copy(clean(value))">

.<any>

Any other modifier has no effect, but allows binding multiple handlers.

<span :fx.once="init(x)" :fx.update="() => (update(), () => destroy())">

Store

Sprae uses signals store for reactivity.

import sprae, { store, signal, effect, computed } from 'sprae'

const name = signal('foo');
const capname = computed(() => name.value.toUpperCase());

const state = store(
  {
    count: 0,                             // prop
    inc(){ this.count++ },                // method
    name, capname,                        // signal
    get twice(){ return this.count * 2 }, // computed
    _i: 0,                                // untracked
  },

  // globals / sandbox
  { Math }
)

sprae(element, state).      // init

state.inc(), state.count++  // update
name.value = 'bar'          // signal update
state._i++                  // no update

state.Math                  // == globalThis.Math
state.navigator             // == undefined

Signals

Default signals can be replaced with preact-signals alternative:

import sprae from 'sprae';
import { signal, computed, effect, batch, untracked } from 'sprae/signal';
import * as signals from '@preact/signals-core';

sprae.use(signals);
Provider Size Feature
ulive 350b Minimal implementation, basic performance, good for small states.
signal 633b Class-based, better performance, good for small-medium states.
usignal 955b Class-based with optimizations and optional async effects.
@preact/signals-core 1.47kb Best performance, good for any states, industry standard.
signal-polyfill 2.5kb Proposal signals. Use via adapter.
alien-signals 2.67kb Preact-flavored alien signals.

Evaluator

Default evaluator is fast and compact, but violates "unsafe-eval" CSP.
To make eval stricter & safer, any alternative can be used, eg. justin:

import sprae from 'sprae'
import justin from 'subscript/justin'

sprae.use({compile: justin})

Autoinit

The start / data-start attribute automatically starts sprae on document. It can use a selector to adjust target container.

<div id="counter" :scope="{count: 1}">
  <p :text="`Clicked ${count} times`"></p>
  <button :onclick="count++">Click me</button>
</div>

<script src="./sprae.js" data-start="#counter"></script>

For manual start, remove start attribute:

<script src="./sprae.js"></script>
<script>
  // watch & autoinit els
  sprae.start(document.body, { count: 1 });

  // OR init individual el (no watch)
  const state = sprae(document.getElementById('counter'), { count: 0 })
</script>

For more control use ESM:

<script type="module">
  import sprae from './sprae.js'

  // init
  const state = sprae(document.getElementById('counter'), { count: 0 })

  // update state
  state.count++
</script>

JSX

Sprae works with JSX via custom prefix (eg. s-). Useful to offload UI logic from server components in react / nextjs, instead of converting them to client components.

// app/page.jsx - server component
export default function Page() {
  return <>
    <nav id="nav">
      <a href="/" s-class="location.pathname === '/' && 'active'">Home</a>
      <a href="/about" s-class="location.pathname === '/about' && 'active'">About</a>
    </nav>
    ...
  </>
}
// layout.jsx
import Script from 'next/script'

export default function Layout({ children }) {
  return <>
    {children}
    <Script src="https://unpkg.com/sprae" data-prefix="s-" data-start />
  </>
}

Custom build

Sprae build can be tweaked for project needs / size:

// sprae.custom.js
import sprae, { directive, use } from 'sprae/core'
import * as signals from '@preact/signals'
import compile from 'subscript/justin'

import _default from 'sprae/directive/default.js'
import _if from 'sprae/directive/if.js'
import _text from 'sprae/directive/text.js'

use({
  // custom prefix, defaults to ':'
  prefix: 'data-sprae-',

  // use preact signals
  ...signals,

  // use safer compiler
  compile
})

// standard directives
directive.if = _if;
directive.text = _text;
directive.default = _default;

// custom directive :id="expression"
directive.id = (el, state, expr) => {
  // ...init
  return newValue => {
    // ...update
    let nextValue = el.id = newValue
    return nextValue
  }
}

export default sprae;

Hints

  • To prevent FOUC add <style>[\:each],[\:if],[\:else] {visibility: hidden}</style>.
  • Attributes order matters, eg. <li :each="el in els" :text="el.name"></li> is not the same as <li :text="el.name" :each="el in els"></li>.
  • Invalid self-closing tags like <a :text="item" /> cause error. Valid self-closing tags are: li, p, dt, dd, option, tr, td, th, input, img, br.
  • To destroy state and detach sprae handlers, call element[Symbol.dispose]().
  • this is not used, to get element reference use :ref="element => {...}".
  • key is not used, :each uses direct list mapping instead of DOM diffing.
  • Expressions can be async: <div :text="await load()"></div>

Justification

Modern frontend is like processed food. Frameworks come with endless tooling, tedious setups and configs, proprietary conventions, artificial abstractions and ecosystem lock-in. Progressive enhancement / graceful degradation is anachronism.

Native template-parts and DCE give distant hope, but stuck with HTML quirks 1, 2, 3.

Alpine and petite-vue offer PE / GD, but have API inconsistencies (x-, @, $, etc.), tend to self-encapsulate, limit extensibility and disregard performance / size.

Sprae holds open, safe, minimalistic philosophy:

  • One : prefix. Zero magic.
  • Valid HTML. Non-obtrusive.
  • Signals for reactivity. (preact-signals compatible)
  • Configurable signals, evaluator, directives, modifiers.
  • Build-free, ecosystem-agnostic.
  • Small, safe & fast.
  • 🫰 developers

Ideal for small websites, static pages, prototypes, landings, SPA, PWA, JSX / SSR, micro-frontends or anywhere where you need lightweight UI.

Examples

Refs

alpine, lucia, petite-vue, nuejs, hmpl, unpoly, dagger