Writing a widget
A widget is a custom element of your own. It lives in a folder under
widgets/: an .html
template, and beside it, if it needs them, a
.mjs
controller and a .css
file. Write its tag on a page and the template expands in place. This page builds two small
ones, t-badge
and t-card.
The template
The file name is the tag name. Its <host>
element stands for the tag itself: its classes land on
<t-badge>, and the rest is ordinary template markup.
<!-- widgets/t/badge/t-badge.html -->
<host
:class="muted ? 'bg-neutral-200 text-neutral-500' : 'bg-primary-100 text-primary-800'"
class="">
<slot><span x-text="label"></span></slot>
</host>
<t-badge label="Beta"></t-badge>
Props
Each key of the controller's default export is a prop, and its default value declares the type.
An attribute sets it, a :-binding sets it from the page's
scope, and a $-binding shares one value with the page, both
ways. muted="false"
arrives as false, not as a string.
// widgets/t/badge/t-badge.mjs
export default {
label: '',
muted: false
}
<div class="" x-data="{ topic: 'Widgets', quiet: true }">
<t-badge label="an attribute"></t-badge>
<t-badge :label="topic"></t-badge>
<t-badge label="muted" muted></t-badge>
<t-badge label="muted=false" muted="false"></t-badge>
<t-badge label="$muted" $muted="quiet"></t-badge>
<button class="" @click="quiet = !quiet">toggle quiet</button>
</div>
Slots
A <slot>
is where the content written between the tags goes;
<slot name="title">
takes the child marked slot="title". What a slot holds is shown
when nothing fills it. t-card
also uses t-badge: widgets compose like any other element.
<!-- widgets/t/card/t-card.html -->
<host>
<header class="bar">
<span class="flex items-center gap-2">
<slot name="title">Untitled</slot>
<t-badge :label="badge" x-if="badge"></t-badge>
</span>
<button @click="pick()">pick</button>
</header>
<div class="body"><slot>No content.</slot></div>
</host>
<div class="grid gap-4">
<t-card badge="new">
<span slot="title">Quarterly report</span>
The body fills the default slot.
</t-card>
<t-card></t-card>
</div>
The controller
The controller is the widget's state and methods. Methods run where the template calls them;
$dispatch
sends an event the page listens to with @pick. A
mount()
runs in the browser only, for work that needs the real element.
// widgets/t/card/t-card.mjs
export default {
name: '',
badge: '',
pick() {
this.$dispatch('pick', { name: this.name })
},
mount() {
this.$el.setAttribute('data-mounted', '1')
return () => this.$el.removeAttribute('data-mounted')
}
}
<div class="grid gap-3" x-data="{ picked: 'nothing yet' }">
<t-card name="alpha" @pick="picked = $event.detail.name">
<span slot="title">Alpha</span>
Press pick.
</t-card>
<t-card name="beta" @pick="picked = $event.detail.name">
<span slot="title">Beta</span>
Or this one.
</t-card>
<p class="text-sm">Picked: <b x-text="picked"></b></p>
</div>
Picked: nothing yet
Scoped styles
The .css
beside the template applies to this widget only. :host
is the element itself, and @apply
pulls in the same utilities the templates use. A page with its own
.bar
class is untouched.
/* widgets/t/card/t-card.css */
:host {
@apply block rounded-lg border border-primary-200 bg-white;
}
.bar {
@apply flex items-center justify-between gap-2 border-b border-primary-100 px-4 py-2 font-semibold;
}
.body {
@apply px-4 py-3 text-primary-700;
}