Skip to content

Writing a directive

A directive is an attribute the runtime mounts when it sees the element and unmounts when the attribute or element goes away. You write the mount; the runtime handles parsing, evaluation, teardown and error reporting.

import { attribute } from 'sigmx'
export const upper = attribute({
name: 'upper', // data-upper
value: 'required',
mount({ el, evaluate, effect }) {
effect(() => { el.textContent = String(evaluate()).toUpperCase() })
},
})
data-upper
data-upper

this text is replaced once the plugin registers

<div class="ex custom" data-signals="{ text: 'registered with instance.use()' }">
<span class="label">data-upper</span>
<p data-upper="$text">this text is replaced once the plugin registers</p>
<input class="field" data-bind:text placeholder="Type something">
</div>
<script>
import { attribute } from 'sigmx'
const upper = attribute({
name: 'upper',
value: 'required',
mount({ el, evaluate, effect }) {
effect(() => { el.textContent = String(evaluate()).toUpperCase() })
},
})
;(window as any).sigmx.use(upper)
</script>
<style is:global>
.custom { padding: 1rem; display: grid; gap: .5rem; background: var(--ex-surface); }
.custom .label { font-family: var(--sl-font-mono); font-size: .72rem; color: var(--ex-muted); }
.custom p { margin: 0; font-weight: 600; letter-spacing: .04em; }
</style>

An attribute is parsed into a plugin name, an optional key, modifiers and a value:

data-on:click__debounce.300ms__prevent="@get('/search')"
─┬ ──┬── ──────────┬──────────── ─────────┬────────
name key modifiers value

The definition says what the directive accepts:

field meaning
name the attribute name after the prefix; use kebab-case (data-on-interval), since HTML lowercases attribute names
key 'required', 'forbidden' or left out for optional; the part after the colon
value 'required', 'forbidden' or optional; the attribute’s text
literal true when the value is plain text (a selector, a mask, a duration) rather than an expression; __dynamic then opts an attribute back into evaluation
args extra names the expression may reference, in the order you pass them to evaluate

The runtime checks key and value before calling mount and reports data-x: needs a key or data-x: takes no value through onError, so mount can assume the contract holds.

mount receives one object. Everything registered through it is undone automatically when the attribute changes, the element leaves the document, or the instance is destroyed.

member purpose
el the element
key, value, mods, attr, plugin what was written: the key, the raw value, a Map of modifiers to their dotted arguments, the full attribute name, the plugin name
cased(style?) the key recased (camel by default, or kebab, snake, pascal), honouring a __case.<style> modifier on the attribute
evaluate(evt?, ...args) compile the expression once and run it; evt and any args are in scope by name, and el always is
effect(fn) a reactive effect that re-runs when a signal it read changes; disposed with the attribute
listen(target, type, fn, options?) addEventListener that is removed on teardown; exceptions in fn are reported
cleanup(fn) run fn on teardown; returns a function that unregisters it if the work finished early
error(message) build an Error prefixed with the attribute name and carrying the element
report(error) send an error that happened later (a callback, a promise) to onError with the same context
store, runtime the store and the instance’s services (emit, handle, attr, the other plugins)

Return a function from mount for extra teardown; it is registered like cleanup. A mount that returns a promise has its rejection reported too.

data-bind:first-name reaches you as key: 'first-name'. Use cased() rather than the raw key so authors get the same __case override every built-in offers:

const path = cased() // 'firstName'
const type = cased('kebab') // 'first-name', what data-on uses for event names

Modifiers arrive split on __ and their arguments on .: data-x__after.300ms__once gives mods.get('after') → ['300ms'] and mods.has('once') → true. Numbers with a dot (__every.1.5s) are re-joined by toMs.

The timing helpers are exported for reuse:

helper use
toMs(args, fallback) ['300ms'] → 300, ['2s'] → 2000, ['1', '5s'] → 1500
withTiming(fn, mods, cleanup) applies __debounce and __throttle with their .leading / .trailing options, and cancels a pending call on teardown
debounce(fn, ms), throttle(fn, ms), delay(fn, ms) the same primitives on their own
import { attribute, withTiming } from 'sigmx'
export const typed = attribute({
name: 'typed', // data-typed__debounce.250ms="$draft = el.value"
value: 'required',
mount({ el, mods, evaluate, listen, cleanup }) {
listen(el, 'input', withTiming((e) => evaluate(e), mods, cleanup))
},
})

evaluate() inside effect() subscribes to every signal the expression reads, so the effect re-runs when any of them changes. That is the whole implementation of data-text, data-show and data-attr. Two rules keep effects cheap:

  • Read inside the effect, write outside it where you can. A write inside an effect is fine as long as the effect does not also read that signal, otherwise it loops and the runtime reports it.
  • An effect has no cleanup of its own. If it schedules work, keep the handle in a closure, clear it at the top of the next run, and register one cleanup for the final teardown.

The clock below does exactly that: the key names the signal to write, __every sets the interval, and the value is an expression that pauses it. Toggling $running re-runs the effect, which clears the old interval before starting a new one.

data-clock:now__every.1s
--:--:--ticking
data-clock:now__every.1s="$running"
<div class="ex clock" data-signals="{ running: true, now: '' }" data-clock:now__every.1s="$running">
<div class="face">
<span class="time num" data-text="$now || '--:--:--'">--:--:--</span>
<span class="chip" data-class="{ good: $running }" data-text="$running ? 'ticking' : 'paused'">ticking</span>
</div>
<div class="row">
<button class="btn ghost" data-on:click="$running = !$running" data-text="$running ? 'Pause' : 'Resume'">Pause</button>
<span class="muted">data-clock:now__every.1s="$running"</span>
</div>
</div>
<script>
import { attribute, toMs } from 'sigmx'
const clock = attribute({
name: 'clock',
key: 'required',
mount({ cased, mods, value, evaluate, store, effect, cleanup }) {
const path = cased()
const every = toMs(mods.get('every'), 1000)
const tick = () => store.set(path, new Date().toLocaleTimeString('en-GB'))
let id: ReturnType<typeof setInterval> | undefined
const stop = () => clearInterval(id)
cleanup(stop)
effect(() => {
stop()
if (value && !evaluate()) return
tick()
id = setInterval(tick, every)
})
},
})
;(window as any).sigmx.use(clock)
</script>
<style is:global>
.clock { padding: 1rem; display: grid; gap: .7rem; background: var(--ex-surface); }
.clock .face { display: flex; align-items: center; gap: .8rem; }
.clock .time { font-family: var(--sl-font-mono); font-size: 1.6rem; font-weight: 500; }
.clock .row { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
.clock .row .muted { font-family: var(--sl-font-mono); font-size: .75rem; }
</style>

Both store.set and the $signal syntax write the same store; use store in plugins because the compiled expression is not in scope there.

listen covers most cases. For anything else with a handle, cleanup registers the undo and gives you back a function to unregister it early:

mount({ el, cleanup, report }) {
const observer = new ResizeObserver((entries) => { /* … */ })
observer.observe(el)
cleanup(() => observer.disconnect())
const ac = new AbortController()
const forget = cleanup(() => ac.abort())
fetch('/hint', { signal: ac.signal }).then(apply, report).finally(forget)
}

The request functions use the same pattern so an in-flight request is aborted when the element that started it is removed.

Throw error('message') for a misuse the author can fix; it carries the attribute name and element. For anything that fails later, in a callback or a promise, call report(e) so it reaches onError with the same context instead of an unhandled rejection. Exceptions thrown inside effect and listen callbacks are reported for you, and a promise returned from evaluate() (an expression calling @post) has its rejection reported once.

mount({ mods, error, report }) {
const ms = toMs(mods.get('after'))
if (!ms) throw error('needs __after.<duration>')
navigator.wakeLock?.request('screen').catch(report)
}
createSigmx({ plugins: [...essentials, upper, clock] })
app.use(typed) // later; applied to observed roots immediately

The demos on this page register their directives with window.sigmx.use() from a script in the component, which is why the markup has to render before the effect runs. In an application, put the plugin in a file of its own, register it at creation, and let auto mode include it only when a template uses it. Packaging a plugin covers the shape that makes that possible.