Components and slots

A piece of markup with its own state and its own teardown.

One host, two components, one slot

registerComponent(name, {template, create}) gives markup private state: ten cards on a page have ten of everything, and none can see the others. Point data-component at an observable and the rendered component follows it - the old instance is disposed and the new one built, while the page's slot content moves across as the same DOM.

This line is written in the page, projected into the component's footer slot - and it survives the swap, because it is the same DOM moved rather than markup re-rendered.

apps/basics.js - two componentsview whole file
/**
 * Two components over one host element, and a slot the page fills.
 *
 * `data-component` takes an expression: point it at an observable and the
 * rendered component follows the value. The old instance is disposed - view
 * model first, then its effects, then its nodes - and the new one is built
 * with the params re-evaluated. The projected slot content is not rebuilt; it
 * is the same DOM, moved.
 */
registerComponent('team-card', {
    template: `
        <article class="demo-card">
            <h4 data-bind-text="$data.heading"></h4>
            <p class="demo-card-name">{{person.name.value}}</p>
            <p class="demo-card-role">{{person.title.value}}, since {{person.since.value}}</p>
            <button class="btn btn-sm" data-on-click="celebrate" data-bind-text="cheer.value"></button>
            <footer>{{#slot footer}}<small>No footer was supplied.</small>{{/slot}}</footer>
        </article>`,

    create(params) {
        // `cheer` belongs to this instance. Swap the component away and back and
        // it starts again from scratch, because the instance is a new one.
        const cheer = observable('Say something nice');
        return {
            heading: params.heading,
            person: params.person,
            cheer,
            celebrate() { cheer.value = `Well done, ${params.person.name.value}`; }
        };
    }
});

registerComponent('stat-card', {
    template: `
        <article class="demo-card demo-card--stat">
            <h4 data-bind-text="$data.heading"></h4>
            <dl class="demo-facts">
                <div><dt>Role</dt><dd>{{person.title.value}}</dd></div>
                <div><dt>Since</dt><dd>{{person.since.value}}</dd></div>
                <div><dt>Years</dt><dd>{{years.value}}</dd></div>
            </dl>
            <footer>{{#slot footer}}<small>No footer was supplied.</small>{{/slot}}</footer>
        </article>`,

    create(params) {
        return {
            heading: params.heading,
            person: params.person,
            years: computed(() => 2026 - Number(params.person.since.value))
        };
    }
});

function componentDemo() {
    // Params pass by reference when the expression names the observable, so
    // both components see this promotion - the page and the component are
    // reading the same cell, not two copies of a value.
    const person = {
        name: observable('Ada Lovelace'),
        title: observable('Analyst'),
        since: observable(2019)
    };

    const view = observable('team-card');
    const ranks = ['Analyst', 'Senior Analyst', 'Principal', 'Head of Numbers'];

    return {
        person,
        view,
        show(name) { view.value = name; },
        promote() {
            const next = ranks.indexOf(person.title.value) + 1;
            person.title.value = ranks[Math.min(next, ranks.length - 1)];
        }
    };
}
markup/basics-component.html - the host and its slot contentview whole file
<div class="demo-stage">
    <div class="demo-row demo-row--wrap">
        <button class="btn btn-sm" data-on-click="show('team-card')" data-bind-class="view.value === 'team-card' && 'btn-primary'">team-card</button>
        <button class="btn btn-sm" data-on-click="show('stat-card')" data-bind-class="view.value === 'stat-card' && 'btn-primary'">stat-card</button>
        <button class="btn btn-sm btn-ghost" data-on-click="promote">Promote Ada</button>
    </div>

    <div class="demo-swap" data-component="view.value" data-param-person="person" data-param-heading="'One host element, two components'">
        <p data-slot="footer" class="demo-note">This line is written in the page, projected into the component's footer slot - and it survives the swap, because it is the same DOM moved rather than markup re-rendered.</p>
    </div>
</div>

Params, and which way they pass

Markup The component receives Can it write back?
data-param-contact="user.name" the observable itself yes - the page sees the write
data-param-contact="user.name.value" a snapshot of the value no

The difference is the same .value you read through everywhere else, so there is no convention to remember. data-params="{a: x, b: y}" passes several at once, now that object literals parse.

Slots

{{#slot header}}fallback{{/slot}} in the template marks a hole; data-slot="header" at the usage site fills it. The fallback shows only when nothing is projected. Projected content keeps the page's scope - a data-model inside a slot writes straight back to the page's observable.

When something goes wrong

An unknown name, a name that is not a string, a create() that throws, a param that will not parse: each warns once and leaves that host empty. The rest of the page carries on.

Next: extenders All features