Skip to content

TodoList

Demonstrates r-for with required :key, two-way bound items array via model: true, multiple $emit calls for add/toggle/remove, named slot with fallback content (#header falls back to a default heading), default slot with per-item scoped params (the marquee scoped-slot pattern — consumer can override the row renderer), and r-if / r-else for the empty-state branch.

This is the heaviest scoped-slots example. The React output shows the documented divergence: instead of children-as-JSX, React consumers see a render-prop callback (children?: (ctx) => ReactNode, renderHeader?: (ctx) => ReactNode). The other four targets keep an idiomatic markup form.

Live demo

Two-way bound to the page's items ref. Add, toggle, remove — every mutation flows back through v-model:items to the parent state. Delete every item and the empty-state slot's fallback kicks in.

Items on the page-level ref: 3 (2 remaining)

Source — TodoList.rozie

rozie
<!--
  TodoList.rozie

  Demonstrates:
    - r-for with required :key
    - $data array mutation (reactive; signal-backed)
    - Multiple $emit calls for different custom events
    - Named slot with fallback content (slot body renders if consumer doesn't override)
    - Default slot with per-item slot params (the marquee scoped-slot pattern —
      consumer can provide a custom row renderer)
    - r-if / r-else with empty-state branch
    - Nested component composition (Counter used inline)
-->

<rozie name="TodoList">

<props>
{
  items: { type: Array, default: () => [], model: true },
  title: { type: String, default: 'Todo' },
}
</props>

<data>
{
  draft: '',
}
</data>

<script>
const remaining = $computed(() => $props.items.filter(i => !i.done).length)

const add = () => {
  const text = $data.draft.trim()
  if (!text) return
  $props.items = [...$props.items, { id: crypto.randomUUID(), text, done: false }]
  $data.draft = ''
  $emit('add', text)
}

const toggle = (id) => {
  $props.items = $props.items.map(i =>
    i.id === id ? { ...i, done: !i.done } : i
  )
  $emit('toggle', id)
}

const remove = (id) => {
  $props.items = $props.items.filter(i => i.id !== id)
  $emit('remove', id)
}
</script>

<template>
<div class="todo-list">
  <header>
    <slot name="header" :remaining="remaining" :total="$props.items.length">
      <!-- Fallback content if consumer doesn't provide #header -->
      <h3>{{ $props.title }} ({{ remaining }} remaining)</h3>
    </slot>
  </header>

  <form @submit.prevent="add">
    <input r-model="$data.draft" placeholder="What needs doing?" />
    <button type="submit" :disabled="!$data.draft.trim()">Add</button>
  </form>

  <ul r-if="$props.items.length > 0">
    <li r-for="item in $props.items" :key="item.id" :class="{ done: item.done }">
      <!--
        Default slot with per-item params. Consumer can provide a custom row
        renderer via `#default="{ item, toggle, remove }"`, or omit it entirely
        to get the fallback row below.
      -->
      <slot :item="item" :toggle="() => toggle(item.id)" :remove="() => remove(item.id)">
        <label>
          <input type="checkbox" :checked="item.done" @change="toggle(item.id)" />
          <span>{{ item.text }}</span>
        </label>
        <button @click="remove(item.id)" aria-label="Remove">×</button>
      </slot>
    </li>
  </ul>

  <p r-else class="empty">
    <slot name="empty">Nothing to do. ✨</slot>
  </p>
</div>
</template>

<style>
.todo-list { font-family: system-ui, sans-serif; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done span { text-decoration: line-through; opacity: 0.5; }
.empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
</style>

</rozie>

Vue output

vue
<template>

<div class="todo-list">
  <header>
    <slot name="header" :remaining="remaining" :total="items.length">
      
      <h3>{{ props.title }} ({{ remaining }} remaining)</h3>
    </slot>
  </header>

  <form @submit.prevent="add">
    <input v-model="draft" placeholder="What needs doing?" />
    <button type="submit" :disabled="!draft.trim()">Add</button>
  </form>

  <ul v-if="items.length > 0">
    <li v-for="item in items" :key="item.id" :class="{ done: item.done }">
      
      <slot :item="item" :toggle="() => toggle(item.id)" :remove="() => remove(item.id)">
        <label>
          <input type="checkbox" :checked="item.done" @change="toggle(item.id)" />
          <span>{{ item.text }}</span>
        </label>
        <button aria-label="Remove" @click="remove(item.id)">×</button>
      </slot>
    </li>
  </ul><p v-else class="empty">
    <slot name="empty">Nothing to do. ✨</slot>
  </p></div>

</template>

<script setup lang="ts">
import { computed, ref } from 'vue';

const props = withDefaults(
  defineProps<{ title?: string }>(),
  { title: 'Todo' }
);

const items = defineModel<unknown[]>('items', { default: () => [] });

const emit = defineEmits<{
  add: [...args: any[]];
  toggle: [...args: any[]];
  remove: [...args: any[]];
}>();

defineSlots<{
  header(props: { remaining: any; total: any }): any;
  default(props: { item: any; toggle: any; remove: any }): any;
  empty(props: {  }): any;
}>();

const draft = ref('');

const remaining = computed(() => items.value.filter(i => !i.done).length);

const add = () => {
  const text = draft.value.trim();
  if (!text) return;
  items.value = [...items.value, {
    id: crypto.randomUUID(),
    text,
    done: false
  }];
  draft.value = '';
  emit('add', text);
};
const toggle = id => {
  items.value = items.value.map(i => i.id === id ? {
    ...i,
    done: !i.done
  } : i);
  emit('toggle', id);
};
const remove = id => {
  items.value = items.value.filter(i => i.id !== id);
  emit('remove', id);
};
</script>

<style scoped>
.todo-list { font-family: system-ui, sans-serif; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done span { text-decoration: line-through; opacity: 0.5; }
.empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
</style>

React output

tsx
import { useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, useControllableState } from '@rozie/runtime-react';
import styles from './TodoList.module.css';

interface HeaderCtx { remaining: any; total: any; }

interface ChildrenCtx { item: any; toggle: any; remove: any; }

interface TodoListProps {
  items?: unknown[];
  defaultValue?: unknown[];
  onItemsChange?: (items: unknown[]) => void;
  title?: string;
  onAdd?: (...args: unknown[]) => void;
  onToggle?: (...args: unknown[]) => void;
  onRemove?: (...args: unknown[]) => void;
  renderHeader?: (ctx: HeaderCtx) => ReactNode;
  children?: (ctx: ChildrenCtx) => ReactNode;
  renderEmpty?: ReactNode;
}

export default function TodoList(_props: TodoListProps): JSX.Element {
  const props: TodoListProps = {
    ..._props,
    title: _props.title ?? 'Todo',
  };
  const [items, setItems] = useControllableState({
    value: props.items,
    defaultValue: props.defaultValue ?? (() => [])(),
    onValueChange: props.onItemsChange,
  });
  const [draft, setDraft] = useState('');
  const remaining = useMemo(() => items.filter(i => !i.done).length, [items]);

  const { onAdd: _rozieProp_onAdd } = props;
    const add = useCallback(() => {
    const text = draft.trim();
    if (!text) return;
    setItems([...items, {
      id: crypto.randomUUID(),
      text,
      done: false
    }]);
    setDraft('');
    _rozieProp_onAdd && _rozieProp_onAdd(text);
  }, [_rozieProp_onAdd, draft, items, setItems]);
  const { onToggle: _rozieProp_onToggle } = props;
    const toggle = useCallback(id => {
    setItems(items.map(i => i.id === id ? {
      ...i,
      done: !i.done
    } : i));
    _rozieProp_onToggle && _rozieProp_onToggle(id);
  }, [_rozieProp_onToggle, items, setItems]);
  const { onRemove: _rozieProp_onRemove } = props;
    const remove = useCallback(id => {
    setItems(items.filter(i => i.id !== id));
    _rozieProp_onRemove && _rozieProp_onRemove(id);
  }, [_rozieProp_onRemove, items, setItems]);

  return (
    <>
    <div className={styles["todo-list"]}>
      <header>
        {props.renderHeader ? props.renderHeader({ remaining, total: items.length }) : <h3>{props.title} ({remaining} remaining)</h3>}
      </header>

      <form onSubmit={(e) => { e.preventDefault(); add(e); }}>
        <input placeholder="What needs doing?" value={draft} onChange={e => setDraft(e.target.value)} />
        <button type="submit" disabled={!draft.trim()}>Add</button>
      </form>

      {(items.length > 0) ? <ul>
        {items.map((item) => <li key={item.id} className={clsx({ [styles.done]: item.done })}>
          
          {props.children ? props.children({ item, toggle: () => toggle(item.id), remove: () => remove(item.id) }) : <><label>
              <input type="checkbox" checked={item.done} onChange={(e) => { toggle(item.id); }} />
              <span>{item.text}</span>
            </label><button aria-label="Remove" onClick={(e) => { remove(item.id); }}>×</button></>}
        </li>)}
      </ul> : <p className={styles.empty}>
        {props.renderEmpty ?? "Nothing to do. ✨"}
      </p>}</div>
    </>
  );
}

Svelte output

svelte
<script lang="ts">
import type { Snippet } from 'svelte';

interface Props {
  items?: unknown[];
  title?: string;
  header?: Snippet<[any, any]>;
  children?: Snippet<[any, any, any]>;
  empty?: Snippet;
  onadd?: (...args: unknown[]) => void;
  ontoggle?: (...args: unknown[]) => void;
  onremove?: (...args: unknown[]) => void;
}

let {
  items = $bindable((() => [])()),
  title = 'Todo',
  header,
  children,
  empty,
  onadd,
  ontoggle,
  onremove,
}: Props = $props();

let draft = $state('');

const add = () => {
  const text = draft.trim();
  if (!text) return;
  items = [...items, {
    id: crypto.randomUUID(),
    text,
    done: false
  }];
  draft = '';
  onadd?.(text);
};
const toggle = id => {
  items = items.map(i => i.id === id ? {
    ...i,
    done: !i.done
  } : i);
  ontoggle?.(id);
};
const remove = id => {
  items = items.filter(i => i.id !== id);
  onremove?.(id);
};

const remaining = $derived(items.filter(i => !i.done).length);
</script>


<div class="todo-list">
  <header>
    {#if header}{@render header(remaining, items.length)}{:else}
      
      <h3>{title} ({remaining} remaining)</h3>
    {/if}
  </header>

  <form onsubmit={(e) => { e.preventDefault(); add(e); }}>
    <input bind:value={draft} placeholder="What needs doing?" />
    <button type="submit" disabled={!draft.trim()}>Add</button>
  </form>

  {#if items.length > 0}<ul>
    {#each items as item (item.id)}<li class={{ done: item.done }}>
      
      {#if children}{@render children(item, () => toggle(item.id), () => remove(item.id))}{:else}
        <label>
          <input type="checkbox" checked={item.done} onchange={(e) => { toggle(item.id); }} />
          <span>{item.text}</span>
        </label>
        <button aria-label="Remove" onclick={(e) => { remove(item.id); }}>×</button>
      {/if}
    </li>{/each}
  </ul>{:else}<p class="empty">
    {#if empty}{@render empty()}{:else}Nothing to do. ✨{/if}
  </p>{/if}</div>


<style>
.todo-list { font-family: system-ui, sans-serif; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done span { text-decoration: line-through; opacity: 0.5; }
.empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
</style>

Angular output

ts
import { Component, ContentChild, TemplateRef, ViewEncapsulation, computed, input, model, output, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms';

interface HeaderCtx {
  $implicit: { remaining: any; total: any };
  remaining: any;
  total: any;
}

interface DefaultCtx {
  $implicit: { item: any; toggle: any; remove: any };
  item: any;
  toggle: any;
  remove: any;
}

interface EmptyCtx {}

@Component({
  selector: 'rozie-todo-list',
  standalone: true,
  imports: [NgTemplateOutlet, FormsModule],
  template: `

    <div class="todo-list">
      <header>
        @if (headerTpl) {
    <ng-container *ngTemplateOutlet="headerTpl; context: { $implicit: { remaining: remaining(), total: items().length }, remaining: remaining(), total: items().length }" />
    } @else {

          
          <h3>{{ title() }} ({{ remaining() }} remaining)</h3>
        
    }
      </header>

      <form (submit)="_guarded_add($event)">
        <input [ngModel]="draft()" (ngModelChange)="draft.set($event)" [ngModelOptions]="{standalone: true}" placeholder="What needs doing?" />
        <button type="submit" [disabled]="!draft().trim()">Add</button>
      </form>

      @if (items().length > 0) {
    <ul>
        @for (item of items(); track item.id) {
    <li [class]="{ done: item.done }">
          
          @if (defaultTpl) {
    <ng-container *ngTemplateOutlet="defaultTpl; context: _defaultSlot_ctx_1(item)" />
    } @else {

            <label>
              <input type="checkbox" [checked]="item.done" (change)="_toggle(item.id)" />
              <span>{{ item.text }}</span>
            </label>
            <button aria-label="Remove" (click)="_remove(item.id)">×</button>
          
    }
        </li>
    }
      </ul>
    } @else {
    <p class="empty">
        @if (emptyTpl) {
    <ng-container *ngTemplateOutlet="emptyTpl" />
    } @else {
    Nothing to do. ✨
    }
      </p>
    }</div>

  `,
  styles: [`
    .todo-list { font-family: system-ui, sans-serif; }
    ul { list-style: none; padding: 0; }
    li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
    li.done span { text-decoration: line-through; opacity: 0.5; }
    .empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
    form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
  `],
})
export class TodoList {
  items = model<any[]>((() => [])());
  title = input<string>('Todo');
  draft = signal('');
  add = output<unknown>();
  toggle = output<unknown>();
  remove = output<unknown>();
  @ContentChild('header', { read: TemplateRef }) headerTpl?: TemplateRef<HeaderCtx>;
  @ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
  @ContentChild('empty', { read: TemplateRef }) emptyTpl?: TemplateRef<EmptyCtx>;

  remaining = computed(() => this.items().filter(i => !i.done).length);

  _add = () => {
    const text = this.draft().trim();
    if (!text) return;
    this.items.set([...this.items(), {
      id: crypto.randomUUID(),
      text,
      done: false
    }]);
    this.draft.set('');
    this.add.emit(text);
  };
  _toggle = (id: any) => {
    this.items.set(this.items().map(i => i.id === id ? {
      ...i,
      done: !i.done
    } : i));
    this.toggle.emit(id);
  };
  _remove = (id: any) => {
    this.items.set(this.items().filter(i => i.id !== id));
    this.remove.emit(id);
  };

  static ngTemplateContextGuard(
    _dir: TodoList,
    _ctx: unknown,
  ): _ctx is HeaderCtx | DefaultCtx | EmptyCtx {
    return true;
  }

  private _guarded_add = (e: any) => {
    e.preventDefault();
    this._add();
  };

  private _defaultSlot_ctx_1 = (item: any) => ({ $implicit: { item: item, toggle: () => this._toggle(item.id), remove: () => this._remove(item.id) }, item: item, toggle: () => this._toggle(item.id), remove: () => this._remove(item.id) });
}

export default TodoList;

Solid output

tsx
import type { JSX } from 'solid-js';
import { For, Show, children, createMemo, createSignal, mergeProps, splitProps } from 'solid-js';
import { createControllableSignal } from '@rozie/runtime-solid';

interface HeaderSlotCtx { remaining: any; total: any; }

interface TodoListProps {
  items?: unknown[];
  defaultItems?: unknown[];
  onItemsChange?: (items: unknown[]) => void;
  title?: string;
  onAdd?: (...args: unknown[]) => void;
  onToggle?: (...args: unknown[]) => void;
  onRemove?: (...args: unknown[]) => void;
  headerSlot?: (ctx: HeaderSlotCtx) => JSX.Element;
  // D-131: default slot resolved via children() at body top
  children?: JSX.Element;
  emptySlot?: JSX.Element;
}

export default function TodoList(_props: TodoListProps): JSX.Element {
  const _merged = mergeProps({ title: 'Todo' }, _props);
  const [local, rest] = splitProps(_merged, ['items', 'title', 'children']);
  const resolved = children(() => local.children);

  const [items, setItems] = createControllableSignal(_props as Record<string, unknown>, 'items', (() => [])());
  const [draft, setDraft] = createSignal('');
  const remaining = createMemo(() => items().filter(i => !i.done).length);

  const add = () => {
    const text = draft().trim();
    if (!text) return;
    setItems([...items(), {
      id: crypto.randomUUID(),
      text,
      done: false
    }]);
    setDraft('');
    _props.onAdd?.(text);
  };
  const toggle = id => {
    setItems(items().map(i => i.id === id ? {
      ...i,
      done: !i.done
    } : i));
    _props.onToggle?.(id);
  };
  const remove = id => {
    setItems(items().filter(i => i.id !== id));
    _props.onRemove?.(id);
  };

  return (
    <>
    <style>{`.todo-list { font-family: system-ui, sans-serif; }
    ul { list-style: none; padding: 0; }
    li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
    li.done span { text-decoration: line-through; opacity: 0.5; }
    .empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
    form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }`}</style>
    <>
    <div class={"todo-list"}>
      <header>
        {_props.headerSlot ? _props.headerSlot({ remaining: remaining(), total: items().length }) : <h3>{local.title} ({remaining()} remaining)</h3>}
      </header>

      <form onSubmit={(e) => { e.preventDefault(); add(); }}>
        <input placeholder="What needs doing?" value={draft()} onInput={e => setDraft(e.currentTarget.value)} />
        <button type="submit" disabled={!draft().trim()}>Add</button>
      </form>

      {<Show when={items().length > 0} fallback={<p class={"empty"}>
        {_props.emptySlot ?? "Nothing to do. ✨"}
      </p>}><ul>
        <For each={items()}>{(item) => <li classList={{ done: item.done }}>
          
          {resolved() ?? <><label>
              <input type="checkbox" checked={item.done} onChange={(e) => { toggle(item.id); }} />
              <span>{item.text}</span>
            </label><button aria-label="Remove" onClick={(e) => { remove(item.id); }}>×</button></>}
        </li>}</For>
      </ul></Show>}</div>
    </>
    </>
  );
}

Lit output

ts
import { LitElement, css, html } from 'lit';
import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { createLitControllableProperty } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';

interface RozieHeaderSlotCtx {
  remaining: unknown;
  total: unknown;
}

interface RozieDefaultSlotCtx {
  item: unknown;
  toggle: unknown;
  remove: unknown;
}

@customElement('rozie-todo-list')
export default class TodoList extends SignalWatcher(LitElement) {
  static styles = css`
.todo-list { font-family: system-ui, sans-serif; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done span { text-decoration: line-through; opacity: 0.5; }
.empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
`;

  @property({ type: Array, attribute: 'items' }) _items_attr: unknown[] = [];
  private _itemsControllable = createLitControllableProperty<unknown[]>({ host: this, eventName: 'items-change', defaultValue: [], initialControlledValue: undefined });
  @property({ type: String, reflect: true }) title: string = 'Todo';
  private _draft = signal('');

  @state() private _hasSlotHeader = false;
  @queryAssignedElements({ slot: 'header', flatten: true }) private _slotHeaderElements!: Element[];
  @state() private _hasSlotDefault = false;
  @queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
  @state() private _hasSlotEmpty = false;
  @queryAssignedElements({ slot: 'empty', flatten: true }) private _slotEmptyElements!: Element[];

  private _disconnectCleanups: Array<() => void> = [];

  private _armListeners(): void {
    this.addEventListener('rozie-default-toggle', (e) => { (() => this.toggle(item.id))((e as CustomEvent).detail); });

    this.addEventListener('rozie-default-remove', (e) => { (() => this.remove(item.id))((e as CustomEvent).detail); });

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="header"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotHeader = this._slotHeaderElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="empty"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotEmpty = this._slotEmptyElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    super.connectedCallback();
    if (this.hasUpdated) this._armListeners();
  }

  firstUpdated(): void {
    this._armListeners();
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    for (const fn of this._disconnectCleanups) fn();
    this._disconnectCleanups = [];
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'items') this._itemsControllable.notifyAttributeChange(value as unknown as unknown[]);
  }

  render() {
    return html`
<div class="todo-list">
  <header>
    <slot name="header" data-rozie-params=${(() => { try { return JSON.stringify({remaining: this.remaining, total: this.items.length}); } catch { return '{}'; } })()}>
      
      <h3>${this.title} (${this.remaining} remaining)</h3>
    </slot>
  </header>

  <form @submit=${(e: Event) => { e.preventDefault(); (this.add)(e); }}>
    <input placeholder="What needs doing?" .value=${this._draft.value} @input=${(e) => this._draft.value = (e.target as HTMLInputElement).value} />
    <button type="submit" ?disabled=${!this._draft.value.trim()}>Add</button>
  </form>

  ${this.items.length > 0 ? html`<ul>
    ${repeat(this.items, (item) => item.id, (item, _idx) => html`<li class="${Object.entries({ done: item.done }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${item.id}>
      
      <slot data-rozie-params=${(() => { try { return JSON.stringify({item: item}); } catch { return '{}'; } })()}>
        <label>
          <input type="checkbox" ?checked=${item.done} @change=${(e: Event) => { this.toggle(item.id); }} />
          <span>${item.text}</span>
        </label>
        <button aria-label="Remove" @click=${(e: Event) => { this.remove(item.id); }}>×</button>
      </slot>
    </li>`)}
  </ul>` : html`<p class="empty">
    <slot name="empty">Nothing to do. ✨</slot>
  </p>`}</div>
`;
  }

  get remaining() { return this.items.filter(i => !i.done).length; }

  add = () => {
  const text = this._draft.value.trim();
  if (!text) return;
  this.items = [...this.items, {
    id: crypto.randomUUID(),
    text,
    done: false
  }];
  this._draft.value = '';
  this.dispatchEvent(new CustomEvent("add", {
    detail: text,
    bubbles: true,
    composed: true
  }));
};

  toggle = id => {
  this.items = this.items.map(i => i.id === id ? {
    ...i,
    done: !i.done
  } : i);
  this.dispatchEvent(new CustomEvent("toggle", {
    detail: id,
    bubbles: true,
    composed: true
  }));
};

  remove = id => {
  this.items = this.items.filter(i => i.id !== id);
  this.dispatchEvent(new CustomEvent("remove", {
    detail: id,
    bubbles: true,
    composed: true
  }));
};

  get items(): unknown[] { return this._itemsControllable.read(); }
  set items(v: unknown[]) { this._itemsControllable.write(v); }
}

Pre-v1.0 — internal monorepo.