Appearance
TodoList
Demonstrates r-for with required :key, two-way bound items array via model: true, an r-model two-way input binding on the draft field, 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 five 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
$model.items = [...$props.items, { id: crypto.randomUUID(), text, done: false }]
$data.draft = ''
$emit('add', text)
}
const toggle = (id) => {
$model.items = $props.items.map(i =>
i.id === id ? { ...i, done: !i.done } : i
)
$emit('toggle', id)
}
// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
const removeItem = (id) => {
$model.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="() => removeItem(item.id)">
<label><input type="checkbox" :checked="item.done" @change="toggle(item.id)" /><span>{{ item.text }}</span></label>
<button @click="removeItem(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>Compiled output
vue
<template>
<div class="todo-list" v-bind="$attrs">
<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="() => removeItem(item.id)">
<label><input type="checkbox" :checked="item.done" @change="toggle(item.id)" /><span>{{ item.text }}</span></label>
<button aria-label="Remove" @click="removeItem(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<any[]>('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: any) => !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: any) => {
items.value = items.value.map((i: any) => i.id === id ? {
...i,
done: !i.done
} : i);
emit('toggle', id);
};
// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
const removeItem = (id: any) => {
items.value = items.value.filter((i: any) => 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>tsx
import { useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './TodoList.css';
interface HeaderCtx { remaining: any; total: any; }
interface ChildrenCtx { item: any; toggle: any; remove: any; }
interface TodoListProps {
items?: any[];
defaultItems?: any[];
onItemsChange?: (items: any[]) => void;
title?: string;
onAdd?: (...args: any[]) => void;
onToggle?: (...args: any[]) => void;
onRemove?: (...args: any[]) => void;
renderHeader?: (ctx: HeaderCtx) => ReactNode;
children?: ReactNode | ((ctx: ChildrenCtx) => ReactNode);
renderEmpty?: () => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export default function TodoList(_props: TodoListProps): JSX.Element {
const props: Omit<TodoListProps, 'title'> & { title: string } = {
..._props,
title: _props.title ?? 'Todo',
};
const attrs: Record<string, unknown> = (() => {
const { items, title, defaultValue, onItemsChange, defaultItems, ...rest } = _props as TodoListProps & Record<string, unknown>;
void items; void title; void defaultValue; void onItemsChange; void defaultItems;
return rest;
})();
const [items, setItems] = useControllableState({
value: props.items,
defaultValue: props.defaultItems ?? (() => [])(),
onValueChange: props.onItemsChange,
});
const [draft, setDraft] = useState('');
const remaining = useMemo(() => items.filter((i: any) => !i.done).length, [items]);
const { onAdd: _rozieProp_onAdd } = props;
const add = useCallback(() => {
const text = draft.trim();
if (!text) return;
setItems(prev => [...prev, {
id: crypto.randomUUID(),
text,
done: false
}]);
setDraft('');
_rozieProp_onAdd && _rozieProp_onAdd(text);
}, [_rozieProp_onAdd, draft, setItems]);
const { onToggle: _rozieProp_onToggle } = props;
const toggle = useCallback((id: any) => {
setItems(prev => prev.map((i: any) => i.id === id ? {
...i,
done: !i.done
} : i));
_rozieProp_onToggle && _rozieProp_onToggle(id);
}, [_rozieProp_onToggle, setItems]);
const { onRemove: _rozieProp_onRemove } = props;
const removeItem = useCallback((id: any) => {
setItems(prev => prev.filter((i: any) => i.id !== id));
_rozieProp_onRemove && _rozieProp_onRemove(id);
}, [_rozieProp_onRemove, setItems]);
return (
<>
<div {...attrs} className={clsx("todo-list", (attrs.className as string | undefined))} data-rozie-s-52bec3de="">
<header data-rozie-s-52bec3de="">
{(props.renderHeader ?? props.slots?.['header']) ? ((props.renderHeader ?? props.slots?.['header']) as Function)({ remaining, total: items.length }) : <h3 data-rozie-s-52bec3de="">{props.title} ({rozieDisplay(remaining)} remaining)</h3>}
</header>
<form onSubmit={($event) => { $event.preventDefault(); ((add) as ((...args: any[]) => any))($event); }} data-rozie-s-52bec3de="">
<input placeholder="What needs doing?" value={draft} onChange={e => setDraft(e.target.value)} data-rozie-s-52bec3de="" />
<button type="submit" disabled={!draft.trim()} data-rozie-s-52bec3de="">Add</button>
</form>
{(items.length > 0) ? <ul data-rozie-s-52bec3de="">
{items.map((item) => <li key={item.id} className={clsx({ done: item.done })} data-rozie-s-52bec3de="">
{typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ item, toggle: () => toggle(item.id), remove: () => removeItem(item.id) }) : ((props.children ?? props.slots?.['']) ?? <><label data-rozie-s-52bec3de=""><input type="checkbox" checked={item.done} onChange={($event) => { toggle(item.id); }} data-rozie-s-52bec3de="" /><span data-rozie-s-52bec3de="">{rozieDisplay(item.text)}</span></label><button aria-label="Remove" onClick={($event) => { removeItem(item.id); }} data-rozie-s-52bec3de="">×</button></>)}
</li>)}
</ul> : <p className={"empty"} data-rozie-s-52bec3de="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)() : "Nothing to do. ✨"}
</p>}</div>
</>
);
}svelte
<script lang="ts">
import { applyListeners, rozieDisplay } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
interface Props {
items?: any[];
title?: string;
header?: Snippet<[{ remaining: any; total: any }]>;
children?: Snippet<[{ item: any; toggle: any; remove: any }]>;
empty?: Snippet;
snippets?: Record<string, any>;
onadd?: (...args: unknown[]) => void;
ontoggle?: (...args: unknown[]) => void;
onremove?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let {
items = $bindable((() => [])()),
title = 'Todo',
header: __headerProp,
children: __childrenProp,
empty: __emptyProp,
snippets,
onadd,
ontoggle,
onremove,
...__rozieAttrs
}: Props = $props();
const header = $derived(__headerProp ?? snippets?.header);
const children = $derived(__childrenProp ?? snippets?.children);
const empty = $derived(__emptyProp ?? snippets?.empty);
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: any) => {
items = items.map((i: any) => i.id === id ? {
...i,
done: !i.done
} : i);
ontoggle?.(id);
};
// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
const removeItem = (id: any) => {
items = items.filter((i: any) => i.id !== id);
onremove?.(id);
};
const remaining = $derived(items.filter((i: any) => !i.done).length);
</script>
<div {...__rozieAttrs} class={["todo-list", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-52bec3de><header data-rozie-s-52bec3de>{#if header}{@render header({ remaining, total: items.length })}{:else}<h3 data-rozie-s-52bec3de>{title} ({rozieDisplay(remaining)} remaining)</h3>{/if}</header><form onsubmit={($event) => { $event.preventDefault(); (add as (...a: any[]) => any)($event); }} data-rozie-s-52bec3de><input bind:value={draft} placeholder="What needs doing?" data-rozie-s-52bec3de /><button type="submit" disabled={!draft.trim()} data-rozie-s-52bec3de>Add</button></form>{#if items.length > 0}<ul data-rozie-s-52bec3de>{#each items as item (item.id)}<li class={{ done: item.done }} data-rozie-s-52bec3de>{#if children}{@render children({ item, toggle: () => toggle(item.id), remove: () => removeItem(item.id) })}{:else}<label data-rozie-s-52bec3de><input type="checkbox" checked={item.done} onchange={($event) => { toggle(item.id); }} data-rozie-s-52bec3de /><span data-rozie-s-52bec3de>{rozieDisplay(item.text)}</span></label><button aria-label="Remove" onclick={($event) => { removeItem(item.id); }} data-rozie-s-52bec3de>×</button>{/if}</li>{/each}</ul>{:else}<p class="empty" data-rozie-s-52bec3de>{#if empty}{@render empty()}{:else}Nothing to do. ✨{/if}</p>{/if}</div>
<style>
:global {
.todo-list[data-rozie-s-52bec3de] { font-family: system-ui, sans-serif; }
ul[data-rozie-s-52bec3de] { list-style: none; padding: 0; }
li[data-rozie-s-52bec3de] { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done[data-rozie-s-52bec3de] span[data-rozie-s-52bec3de] { text-decoration: line-through; opacity: 0.5; }
.empty[data-rozie-s-52bec3de] { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form[data-rozie-s-52bec3de] { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { FormsModule, NG_VALUE_ACCESSOR } 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 {}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
@Component({
selector: 'rozie-todo-list',
standalone: true,
imports: [NgTemplateOutlet, FormsModule],
template: `
<div class="todo-list" #rozieSpread_0 #rozieListenersTarget_1>
<header>
@if ((headerTpl ?? templates()?.['header'])) {
<ng-container *ngTemplateOutlet="(headerTpl ?? templates()?.['header']); context: { $implicit: { remaining: remaining(), total: items().length }, remaining: remaining(), total: items().length }" />
} @else {
<h3>{{ title() }} ({{ rozieDisplay(remaining()) }} remaining)</h3>
}
</header>
<form (submit)="$event.preventDefault(); _add()">
<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 ?? templates()?.['defaultSlot'])) {
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: _defaultSlot_ctx_2(item)" />
} @else {
<label><input type="checkbox" [checked]="item.done" (change)="_toggle(item.id)" /><span>{{ rozieDisplay(item.text) }}</span></label>
<button aria-label="Remove" (click)="removeItem(item.id)">×</button>
}
</li>
}
</ul>
} @else {
<p class="empty">
@if ((emptyTpl ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? templates()?.['empty'])" />
} @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; }
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TodoList),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
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>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
remaining = computed(() => this.items().filter((i: any) => !i.done).length);
_add = () => {
const text = this.draft().trim();
if (!text) return;
this.items.set([...this.items(), {
id: crypto.randomUUID(),
text,
done: false
}]), this.__rozieCvaOnChange([...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: any) => i.id === id ? {
...i,
done: !i.done
} : i)), this.__rozieCvaOnChange(this.items().map((i: any) => i.id === id ? {
...i,
done: !i.done
} : i));
this.toggle.emit(id);
};
removeItem = (id: any) => {
this.items.set(this.items().filter((i: any) => i.id !== id)), this.__rozieCvaOnChange(this.items().filter((i: any) => i.id !== id));
this.remove.emit(id);
};
private __rozieCvaOnChange: (v: any[]) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: any[] | null): void {
this.items.set(v ?? (() => [])());
}
registerOnChange(fn: (v: any[]) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
static ngTemplateContextGuard(
_dir: TodoList,
_ctx: unknown,
): _ctx is HeaderCtx | DefaultCtx | EmptyCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
private _defaultSlot_ctx_2 = (item: any) => ({ $implicit: { item: item, toggle: () => this._toggle(item.id), remove: () => this.removeItem(item.id) }, item: item, toggle: () => this._toggle(item.id), remove: () => this.removeItem(item.id) });
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default TodoList;tsx
import type { JSX } from 'solid-js';
import { For, Show, children, createMemo, createSignal, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
__rozieInjectStyle('TodoList-52bec3de', `.todo-list[data-rozie-s-52bec3de] { font-family: system-ui, sans-serif; }
ul[data-rozie-s-52bec3de] { list-style: none; padding: 0; }
li[data-rozie-s-52bec3de] { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done[data-rozie-s-52bec3de] span[data-rozie-s-52bec3de] { text-decoration: line-through; opacity: 0.5; }
.empty[data-rozie-s-52bec3de] { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form[data-rozie-s-52bec3de] { display: flex; gap: 0.25rem; margin-block: 0.5rem; }`);
interface HeaderSlotCtx { remaining: any; total: any; }
interface TodoListProps {
items?: any[];
defaultItems?: any[];
onItemsChange?: (items: any[]) => 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;
slots?: Record<string, (ctx: any) => JSX.Element>;
}
export default function TodoList(_props: TodoListProps): JSX.Element {
const _merged = mergeProps({ title: 'Todo' }, _props);
const [local, attrs] = splitProps(_merged, ['items', 'title', 'children']);
const resolved = children(() => local.children);
const [items, setItems] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'items', (() => [])());
const [draft, setDraft] = createSignal('');
const remaining = createMemo(() => items().filter((i: any) => !i.done).length);
function add() {
const text = draft().trim();
if (!text) return;
setItems([...items(), {
id: crypto.randomUUID(),
text,
done: false
}]);
setDraft('');
_props.onAdd?.(text);
}
function toggle(id: any) {
setItems(items().map((i: any) => i.id === id ? {
...i,
done: !i.done
} : i));
_props.onToggle?.(id);
}
// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
function removeItem(id: any) {
setItems(items().filter((i: any) => i.id !== id));
_props.onRemove?.(id);
}
return (
<>
<div {...attrs} class={"todo-list" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-52bec3de="">
<header data-rozie-s-52bec3de="">
{(_props.headerSlot ?? _props.slots?.['header'])?.({ remaining: remaining(), total: items().length }) ?? <h3 data-rozie-s-52bec3de="">{local.title} ({rozieDisplay(remaining())} remaining)</h3>}
</header>
<form onSubmit={($event) => { $event.preventDefault(); add(); }} data-rozie-s-52bec3de="">
<input placeholder="What needs doing?" value={draft()} onInput={e => setDraft(e.currentTarget.value)} data-rozie-s-52bec3de="" />
<button type="submit" disabled={!draft().trim()} data-rozie-s-52bec3de="">Add</button>
</form>
{<Show when={items().length > 0} fallback={<p class={"empty"} data-rozie-s-52bec3de="">
{(_props.emptySlot ?? _props.slots?.['empty']?.({})) ?? "Nothing to do. ✨"}
</p>}><ul data-rozie-s-52bec3de="">
<For each={items()}>{(item) => <li class={rozieClass({ done: item.done })} data-rozie-s-52bec3de="">
{typeof local.children === 'function' ? (local.children as (s: any) => any)({ item, toggle: () => toggle(item.id), remove: () => removeItem(item.id) }) : (resolved() ?? <><label data-rozie-s-52bec3de=""><input type="checkbox" checked={item.done} onChange={($event) => { toggle(item.id); }} data-rozie-s-52bec3de="" /><span data-rozie-s-52bec3de="">{rozieDisplay(item.text)}</span></label><button aria-label="Remove" onClick={($event) => { removeItem(item.id); }} data-rozie-s-52bec3de="">×</button></>)}
</li>}</For>
</ul></Show>}</div>
</>
);
}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, rozieAttr, rozieDisplay, rozieListeners, rozieSpread } 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[data-rozie-s-52bec3de] { font-family: system-ui, sans-serif; }
ul[data-rozie-s-52bec3de] { list-style: none; padding: 0; }
li[data-rozie-s-52bec3de] { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done[data-rozie-s-52bec3de] span[data-rozie-s-52bec3de] { text-decoration: line-through; opacity: 0.5; }
.empty[data-rozie-s-52bec3de] { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form[data-rozie-s-52bec3de] { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
`;
@property({ type: Array, attribute: 'items' }) _items_attr: any[] = [];
private _itemsControllable = createLitControllableProperty<any[]>({ 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[];
@property({ attribute: false }) header?: (scope: { remaining: unknown; total: unknown }) => unknown;
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
@property({ attribute: false }) __rozieDefaultSlot__?: (scope: { item: unknown; toggle: unknown; remove: unknown }) => unknown;
@state() private _hasSlotEmpty = false;
@queryAssignedElements({ slot: 'empty', flatten: true }) private _slotEmptyElements!: Element[];
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
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 {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'header');
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
this._hasSlotEmpty = Array.from(this.children).some((el) => el.getAttribute('slot') === 'empty');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
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 any[]);
}
render() {
return html`
<div class="todo-list" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-52bec3de>
<header data-rozie-s-52bec3de>
${this.header !== undefined ? this.header({remaining: this.remaining, total: this.items.length}) : html`<slot name="header" data-rozie-params=${(() => { try { return JSON.stringify({remaining: this.remaining, total: this.items.length}); } catch { return '{}'; } })()}>
<h3 data-rozie-s-52bec3de>${this.title} (${rozieDisplay(this.remaining)} remaining)</h3>
</slot>`}
</header>
<form @submit=${($event: SubmitEvent) => { $event.preventDefault(); ((this.add) as (...args: any[]) => any)($event); }} data-rozie-s-52bec3de>
<input placeholder="What needs doing?" .value=${this._draft.value} @input=${($event) => this._draft.value = ($event.target as HTMLInputElement).value} data-rozie-s-52bec3de />
<button type="submit" ?disabled=${!this._draft.value.trim()} data-rozie-s-52bec3de>Add</button>
</form>
${this.items.length > 0 ? html`<ul data-rozie-s-52bec3de>
${repeat<any>(this.items, (item, _idx) => item.id, (item, _idx) => html`<li class="${Object.entries({ done: item.done }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieAttr(item.id)} data-rozie-s-52bec3de>
${this.__rozieDefaultSlot__ !== undefined ? this.__rozieDefaultSlot__({item: item, toggle: () => this.toggle(item.id), remove: () => this.removeItem(item.id)}) : html`<slot data-rozie-params=${(() => { try { return JSON.stringify({item: item}); } catch { return '{}'; } })()} @rozie-default-toggle=${($event: CustomEvent) => ((() => this.toggle(item.id)) as (...args: any[]) => any)($event.detail)} @rozie-default-remove=${($event: CustomEvent) => ((() => this.removeItem(item.id)) as (...args: any[]) => any)($event.detail)}>
<label data-rozie-s-52bec3de><input type="checkbox" ?checked=${item.done} @change=${($event: Event) => { this.toggle(item.id); }} data-rozie-s-52bec3de /><span data-rozie-s-52bec3de>${rozieDisplay(item.text)}</span></label>
<button aria-label="Remove" @click=${($event: Event) => { this.removeItem(item.id); }} data-rozie-s-52bec3de>×</button>
</slot>`}
</li>`)}
</ul>` : html`<p class="empty" data-rozie-s-52bec3de>
<slot name="empty">Nothing to do. ✨</slot>
</p>`}</div>
`;
}
get remaining() { return this.items.filter((i: any) => !i.done).length; }
add = () => {
const text = this._draft.value.trim();
if (!text) return;
this._itemsControllable.write([...this.items, {
id: crypto.randomUUID(),
text,
done: false
}]);
this._draft.value = '';
this.dispatchEvent(new CustomEvent("add", {
detail: text,
bubbles: true,
composed: true
}));
};
toggle = (id: any) => {
this._itemsControllable.write(this.items.map((i: any) => i.id === id ? {
...i,
done: !i.done
} : i));
this.dispatchEvent(new CustomEvent("toggle", {
detail: id,
bubbles: true,
composed: true
}));
};
removeItem = (id: any) => {
this._itemsControllable.write(this.items.filter((i: any) => i.id !== id));
this.dispatchEvent(new CustomEvent("remove", {
detail: id,
bubbles: true,
composed: true
}));
};
get items(): any[] { return this._itemsControllable.read(); }
set items(v: any[]) { this._itemsControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['items', 'title']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}