3. Two-way props and events#
Components talk to the outside the way built-in elements do: properties in, DOM events out. That's why one component works in every framework without glue code.
Events#
<script>
import { emit } from '@zubra/runtime'
function pick(color) {
emit('colorpick', color)
}
</script>
<color-picker colors={["red", "green", "blue"]}>
<zb-each items={colors} as="color" key={color}>
<button type="button" style:background={color} aria-label={color} onclick={() => pick(color)}></button>
</zb-each>
</color-picker>
emit(name, detail)fires aCustomEventfrom the element. It bubbles, anddetailcarries the value.- Calling
emitdeclares the event: it appears in the typings and the docs. Here it'scolorpick: CustomEvent<unknown>, becausecoloris a variable; in a<script lang="ts">,emit<string>('colorpick', color)makes itCustomEvent<string>. There's nothing to register. - Names are lowercase, like the platform's own (
colorpick, notcolorPick): every framework can listen to those declaratively. A camelCase name is an error that suggests the right one. before…events are cancelable:if (!emit('beforeclose')) returnstops when a listener calledpreventDefault().
Listening, in each place the element is used:
| Where | |
|---|---|
| Plain HTML | el.addEventListener('colorpick', (event) => …) |
| Another Zubra component | <color-picker oncolorpick={(event) => …}> |
| React (through the plugin) | <ColorPicker onColorpick={(event) => …} /> |
| Vue | <ColorPicker @colorpick="…" /> |
| Svelte, Preact | oncolorpick={(event) => …} |
| Solid | on:colorpick={(event) => …} |
| Lit | @colorpick=${(event) => …} |
| Angular | (colorpick)="…" |
Two-way props#
Most interactive components have a value the user changes: a toggle's checked, a stepper's value. Mark that prop with :model:
<toggle-switch checked:model={false} label="Notifications">
<button type="button" role="switch" aria-checked={checked ? "true" : "false"} onclick={() => (checked = !checked)}>
{label}
</button>
</toggle-switch>
Now, whenever the component assigns checked, the element fires checkedchange with the new value. Nothing fires when the value is set from outside, because the caller already knows it. Frameworks bind to exactly that pair:
| Vue | <ToggleSwitch v-model:checked="enabled" /> (and v-model for a value prop) |
| React | <ToggleSwitch checked={enabled} onCheckedChange={(event) => setEnabled(event.detail)} /> |
| Svelte | <toggle-switch checked={enabled} oncheckedchange={(event) => (enabled = event.detail)}> |
| Angular | <toggle-switch zubraModel="checked" [(ngModel)]="enabled"> with ZubraModel |
State that starts from a prop#
let left = seconds reads the prop once: if the app later sets a new seconds, left keeps counting from the old one. For state that should start over when a prop changes, use from():
<script>
import { from } from '@zubra/runtime'
let left = from(seconds) // follows `seconds`, until the timer counts down
let running = from(seconds, false) // `false`, and `false` again on a new length
</script>
<focus-timer seconds={1500}>
<button onclick={() => (running = !running)}>{running ? 'Pause' : 'Start'}</button>
<time>{left}</time>
</focus-timer>
Assigning left works like any let. When seconds changes, the assignment is dropped and left is the new seconds. On the server it renders as the prop's value.
Typing props#
A prop's type comes from its default: count={0} is a number, label="Hi" a string. When the default doesn't say enough (null, []), declare the type in a TypeScript script:
<script lang="ts">
type Size = 'small' | 'medium' | 'large'
declare const size: Size
declare const value: number | null
</script>
<number-field size="medium" value:model={null}>…</number-field>
The declaration only types the prop (the default still comes from the host tag), and the typings, the manifest and other components' checks all use it.
Signals that read like values#
For state shared between components, or anything outside one element, state() and computed() from @zubra/runtime make explicit signals. A const holding one reads like a value, in the script and the template:
<script>
import { inject } from '@zubra/runtime'
import { session } from '../session.js'
const user = inject(session) // a signal that follows the nearest provider
const greeting = `Hi, ${user?.name}` // derived: stays in sync
</script>
<user-badge>
<span>{greeting}</span> <small>{user.email}</small>
</user-badge>
user.email means user.get().email. To change a state(), call .set(…). To pass the signal itself to something that takes one (watch(), formValue(), provide()), write signal(user).
Next: Composition and slots