11. Recipes
These examples show common patterns without adding abstractions on top of Nemesia.
Accessible disclosure
<section data-nemesia="disclosure">
<button data-ref="trigger" aria-expanded="false">Details</button>
<div data-ref="content" hidden>Server-rendered content</div>
</section>class Disclosure extends Component('disclosure') {
trigger = this.ref.button('trigger')
content = this.ref.element('content')
onMount() {
this.on(this.trigger, 'click', () => {
const expanded = this.trigger.getAttribute('aria-expanded') === 'true'
this.trigger.setAttribute('aria-expanded', String(!expanded))
this.content.hidden = expanded
})
}
}Configurable tabs
<div data-nemesia="tabs" data-option-active-index="0">
<button data-ref="tab">First</button>
<button data-ref="tab">Second</button>
<div data-ref="panel">Content</div>
</div>class Tabs extends Component('tabs') {
tabs = this.ref.many.button('tab')
panel = this.ref.element('panel')
initial = this.option.optional.number('activeIndex', {
default: 0,
min: 0
})
onMount() {
this.activate(this.initial)
this.on(this.tabs, 'click', (_event, _tab, index) => this.activate(index))
}
private activate(index: number) {
this.tabs.forEach((tab, current) => {
tab.setAttribute('aria-selected', String(current === index))
})
this.panel.dataset.activeIndex = String(index)
}
}Delegated analytics
Use a distributed component when triggers are scattered throughout a mounted scope:
<a href="/pricing" data-track="pricing-link">Pricing</a> <button data-track="newsletter-submit">Subscribe</button>class Analytics extends DistributedComponent('analytics') {
onMount() {
this.on(this.scope, 'click', event => {
const target = event.target
if (!(target instanceof Element)) return
const tracked = target.closest<HTMLElement>('[data-track]')
if (tracked === null) return
this.send(tracked.dataset.track ?? 'unknown')
})
}
private send(name: string) {
console.log('analytics event', name)
}
}Third-party widgets
Create the library instance in onMount and release it in onDestroy:
class MapWidget extends Component('map-widget') {
canvas = this.ref.element('canvas')
private map: { destroy(): void } | undefined
onMount() {
this.map = createMap(this.canvas)
}
onDestroy() {
this.map?.destroy()
this.map = undefined
}
}Nemesia owns listeners registered through this.on; the component owns external objects such as map.
Async onMount
Abort pending work in onDestroy and stop after an await when the component was destroyed:
class ProductPrice extends Component('product-price') {
value = this.ref.element('value')
url = this.option.string('url')
private controller = new AbortController()
async onMount() {
const response = await fetch(this.url, { signal: this.controller.signal })
const { price } = await response.json()
if (this.controller.signal.aborted) return
this.value.textContent = price
}
onDestroy() {
this.controller.abort()
}
}Coordinating components
A parent component reads nested components with findAll. Nested components report changes with bubbling events:
<div data-nemesia="cart">
<div data-nemesia="cart-item" data-option-price="10">
<input data-ref="quantity" type="number" value="1" />
</div>
<div data-nemesia="cart-item" data-option-price="25">
<input data-ref="quantity" type="number" value="2" />
</div>
<span data-ref="total"></span>
</div>class CartItem extends Component('cart-item') {
quantity = this.ref.input('quantity')
price = this.option.number('price')
get subtotal() {
return this.price * Number(this.quantity.value)
}
onMount() {
this.on(this.quantity, 'change', () => {
this.root.dispatchEvent(new CustomEvent('cart:change', { bubbles: true }))
})
}
}
class Cart extends Component('cart') {
total = this.ref.element('total')
onMount() {
this.render()
this.on(this.root, 'cart:change', () => this.render())
}
private render() {
const total = this.findAll(CartItem).reduce((sum, item) => sum + item.subtotal, 0)
this.total.textContent = String(total)
}
}When an event carries data, export its name and type from the component that sends it, so both sides share one definition:
export const CART_ADD = 'cart:add'
export type CartAddEvent = CustomEvent<{ name: string; count: number }>
class Product extends Component('product') {
add = this.ref.button('add')
name = this.option.string('name')
onMount() {
this.on(this.add, 'click', () => {
const event: CartAddEvent = new CustomEvent(CART_ADD, {
bubbles: true,
detail: { name: this.name, count: 1 }
})
this.root.dispatchEvent(event)
})
}
}
class Catalog extends Component('catalog') {
onMount() {
this.on(this.root, CART_ADD, (event: CartAddEvent) => {
const { name, count } = event.detail
const product = this.find(Product, event.target as Element) // Product | null
})
}
}event.target is the root of the sending component, so find returns its instance when the listener needs more than the event data.
Replacing a server fragment
With observation enabled, replacing markup is enough:
const app = createApp({ observe: true })
app.register([Disclosure, Tabs])
app.mount(document.body)
fragment.replaceChildren(newServerRenderedNode)Removed component roots are destroyed before newly added roots mount.
Registration module
For larger sites, keep component registration in one application entry point:
const components = [Analytics, Disclosure, MapWidget, Tabs]
const app = createApp({ observe: true })
app.register(components)
app.mount(document.body)Component modules remain independent and can be loaded by the site's existing bundling strategy.