Datastar attribute plugin for signal value translation
Datastar is a backend technology agnostic front-end (or “hypermedia”) framework supporting reactive UI development that combines features from HTMX, AlpineJS and React. It’s currently at version 1.0 RC5 and while the public plugin API is still being fleshed out and documented, I wanted to give it a go with a simple idea I had for a custom data-textlabel attribute allowing extra control over values rendered via data-text.
Datastar core’s data-text (guide, reference, source) simply binds an expression value to element’s text content. With this, it is straightforward to bind signal values directly to the UI but if the intended text formatting varies across the values and is hardcoded into the signal value itself it can become cumbersome to refer to those values in other scenarios.
Example UI
As example, an audio web site provides a search function with different “search modes” for searching either sound effects or music. A nested signal search.mode is set by mode switcher button’s on-click event via data-on (guide, reference, source), defining the current search mode. Additionally, data-signals (guide, reference, source) is used in the containing div element to set the initial $search.mode signal value on page load:
<div data-signals-search.mode="'Music'">
<button data-on-click="$search.mode = 'Music'">
Music
</button>
<button data-on-click="$search.mode = 'SFX'">
SFX
</button>
</div>
Note: It might be more readable to initialize the signal using data-on-load="$search.mode = 'music'" (reference, source), but there’s currently and open issue related to using unitialized nested signals in expressions leading the initial value being undefined.
Next to add some interactivity, a text label bound via data-text is added indicating the current mode and buttons styles are bound via data-style (reference, source) so that the button for the active mode has bolder text:
<div data-signals-search.mode="'Music'">
<button data-on-click="$search.mode = 'Music'"
data-style-font-weight="$search.mode === 'Music' ? '800' : '400'">
Music
</button>
<button data-on-click="$search.mode = 'SFX'"
data-style-font-weight="$search.mode === 'SFX' ? '800' : '400'">
SFX
</button>
<span>Searching for
<strong data-text="$search.mode"></strong>
</span>
</div>
This already shows that the expression in data-style-font-weight needs to refer to the formatted values to resolve correctly. This might not be an issue for a small demo like this but it can become cumbersome when the signal values are getting referred in many places where the formatting is not relevant.
Decoupling formatting from signal values
To decouple the formatting from the signal values, following JavaScript object (hash map) is used which maps lowcased signal values that are easy to remember to the intended UI formatting:
const labels = {
"search": {
"mode": {
"music": "Music",
"sfx": "SFX",
"all": "All",
}
}
}
window.labels = labels;
It is also bound to the window object so that it will be globally available and can be accessed from the custom Datastar attribute later in the article.
As a first step to utilize this, the signal values are lowcased and the data-text attribute expression is set to fetch the value to be rendered from the labels object.
<div data-signals-bettersearch.mode="'music'">
<button data-on-click="$bettersearch.mode = 'music'"
data-style-font-weight="$bettersearch.mode === 'music' ? '800' : '400'">
Music
</button>
<button data-on-click="$bettersearch.mode = 'sfx'"
data-style-font-weight="$bettersearch.mode === 'sfx' ? '800' : '400'">
SFX
</button>
<span>Searching for
<strong data-text="labels.search.mode[$bettersearch.mode]"></strong>
</span>
</div>
This works fine but it is quite verbose requiring the object lookup to be defined manually on every occasion.
The way Datastar expressions and JavaScript are evaluated makes it hard to do this succinctly as Lisp-like runtime macros are not supported out of the box.
Luckily, this can improved further by extending on the built-in data-text attribute plugin with custom processing rules for the Datastar expression engine.
Custom datastar attribute plugin
While Datastar and it’s built-in attribute plugins are written with TypeScript, vanilla JavaScript can be used as well, only requirement being that the plugin code should be loaded as an ECMAScript module so that it can refer to Datastar’s internal load and apply via module import.
Following code defines a new data-textlabel attribute and registers it with Datastar:
import { load, apply } from 'https://cdn.jsdelivr.net/gh/starfederation/datastar@main/bundles/datastar.js'
const TextlabelPlugin = {
type: 'attribute',
name: 'textlabel',
keyReq: 'denied',
valReq: 'must',
returnsValue: true,
onLoad: ({ el, effect, rx, runtimeErr, value }) => {
const update = () => {
observer.disconnect()
const signalValue = rx()
// console.log("Plugin update, value: " + value + " evaluating to " + signalValue)
const labels = window.labels
if (!labels) {
throw runtimeErr('LabelsObjectNotFound', {
message: 'Global labels object not found on window.labels'
})
}
// Parse the signal path from the evaluated value
const pathMatch = String(value).match(/^\$([a-zA-Z_][a-zA-Z0-9_.]*)$/)?.[1]
if (!pathMatch) {
throw runtimeErr('InvalidLabelPath', {
message: 'Could not extract label path from expression'
})
}
const pathSegments = pathMatch.split('.')
let labelObj = labels
for (const segment of pathSegments) {
if (labelObj && typeof labelObj === 'object') {
labelObj = labelObj[segment]
} else {
labelObj = undefined
break
}
}
const label = labelObj?.[signalValue]
if (label === undefined) {
console.warn(`Label not found for path: ${pathSegments.join('.')}.${signalValue}`)
el.textContent = signalValue
} else {
el.textContent = `${label}`
}
observer.observe(el, {
childList: true,
characterData: true,
subtree: true,
})
}
const observer = new MutationObserver(update)
const cleanup = effect(update)
return () => {
observer.disconnect()
cleanup()
}
},
}
load(TextlabelPlugin) // Load the plugin into Datastar
apply() // Apply to existing elements
Main difference with the built-in data-text is that the new attribute resolves the formatted value from the window.labels object defined earlier. It can be then utilized as follows:
<div data-signals-bettersearch.mode="'music'">
<button data-on-click="$bettersearch.mode = 'music'"
data-style-font-weight="$bettersearch.mode === 'music' ? '800' : '400'">
Music
</button>
<button data-on-click="$bettersearch.mode = 'sfx'"
data-style-font-weight="$bettersearch.mode === 'sfx' ? '800' : '400'">
SFX
</button>
<span>Searching for
<strong data-textlabel="$bettersearch.mode"></strong>
</span>
</div>
As a future development idea, the attribute could be made more general by removing the hard coded window.labels object reference and parsing the object name dynamically from the attribute name such as data-textlabel-<objectname> but that is for another time.
NOTE: with the current implementation there’s a occasional Uncaught (in promise) DOMException: The operation was aborted. from apply in browser console. While it does not seem to affect the functionality, it should be investigated after some improvements to the public plugin API land in upcoming Datastar 1.0 RC6.
Thank’s to Datastar developer @jmstevers for feedback on the implementation and some debugging that was involved.
Full example code is available in Github / Codeberg and as Codepen under BSD-3 license.