Appearance
Table
A slot-driven UI-library table renderer. The producer exposes three named scoped slots: header (the full header row, scoped on columns), cell (a per-cell renderer scoped on { column, row, value, rowIndex }), and empty (the no-data placeholder). It also exposes two switchable footer slots — footerSummary and footerPagination — that demonstrate Rozie's consumer-side dynamic slot fills via <template #[expr]>.
Producer slot outlets are static-name in Rozie — per-column customization is dispatched inside a single shared cell slot. The consumer's #cell template (see TableDemo.rozie below) uses Rozie's r-match / r-case / r-default switch construct on column.key to pick a per-column renderer — a badge span for status, a bold <strong> for score, and a plain span for everything else. The dynamic-slot showcase also lives on the consumer: a template-literal slot name (`footer${$data.footerMode}`) selects which static-name producer slot the consumer fills.
A naming nuance: footer slot names are camelCase (not kebab-case) so the producer can gate them with r-if="$slots.footerSummary". Rozie's magic accessors require static dot keys; $slots['footer-summary'] would be a computed access (ROZ106).
Live demo
Click the Toggle footer button to swap the <tfoot> content between the summary slot (total score) and the pagination slot (page indicator). That's the dynamic slot fill working end-to-end — the same <template #[expr]> template node binds to a different producer outlet each click.
Source — Table.rozie
rozie
<!--
Table.rozie
Demonstrates:
- Slot-driven UI-library table — consumer-provided header / cell / empty renderers
- Per-column dispatch inside a single shared `cell` slot (the cross-framework
alternative to PrimeVue's per-column-keyed cell slot — Rozie producers
only support static slot names)
- r-if gating on $slots.* for optional footer slots
- r-for with :key over $props.rows
- Default slot fallback content for "no data" empty state
Columns are `{ key: string, label: string }`. Per-column rendering happens
CONSUMER-side: the consumer's `#cell` template inspects `column.key` and
dispatches accordingly. Producer slot outlets are static-name in Rozie —
dynamic slot names live on the consumer (`<template #[expr]>`).
Slot naming: footer slots use camelCase (`footerSummary`, `footerPagination`)
rather than kebab-case so `r-if="$slots.footerSummary"` works — magic
accessors require static dot keys, not computed access.
-->
<rozie name="Table">
<props>
{
rows: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
striped: { type: Boolean, default: false },
caption: { type: String, default: '' },
}
</props>
<script>
const getCellValue = (row, column) => row?.[column.key]
</script>
<template>
<table class="rozie-table" :class="{ striped: $props.striped }">
<caption r-if="$props.caption">{{ $props.caption }}</caption>
<thead>
<tr>
<slot name="header" :columns="$props.columns">
<th r-for="column in $props.columns" :key="column.key">{{ column.label }}</th>
</slot>
</tr>
</thead>
<tbody r-if="$props.rows.length > 0">
<tr r-for="row, rowIndex in $props.rows" :key="rowIndex">
<td r-for="column in $props.columns" :key="column.key">
<slot name="cell" :column="column" :row="row" :value="getCellValue(row, column)" :rowIndex="rowIndex">
{{ getCellValue(row, column) }}
</slot>
</td>
</tr>
</tbody>
<tbody r-else>
<tr>
<td :colspan="$props.columns.length">
<slot name="empty" :columns="$props.columns">No data</slot>
</td>
</tr>
</tbody>
<tfoot r-if="$slots.footerSummary || $slots.footerPagination">
<tr r-if="$slots.footerSummary">
<td :colspan="$props.columns.length">
<slot name="footerSummary" :rows="$props.rows" />
</td>
</tr>
<tr r-if="$slots.footerPagination">
<td :colspan="$props.columns.length">
<slot name="footerPagination" :rows="$props.rows" />
</td>
</tr>
</tfoot>
</table>
</template>
<style>
.rozie-table { border-collapse: collapse; width: 100%; font: 14px system-ui, sans-serif; }
.rozie-table th, .rozie-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid rgba(0,0,0,0.08); }
.rozie-table thead th { font-weight: 600; background: rgba(0,0,0,0.03); }
.rozie-table.striped tbody tr:nth-child(even) td { background: rgba(0,0,0,0.02); }
.rozie-table tfoot td { font-size: 0.85em; color: rgba(0,0,0,0.6); padding: 0.5rem 0.75rem; }
</style>
</rozie>Compiled output
vue
<template>
<table :class="['rozie-table', { striped: props.striped }]" v-bind="$attrs">
<caption v-if="props.caption">{{ props.caption }}</caption><thead>
<tr>
<slot name="header" :columns="props.columns">
<th v-for="column in props.columns" :key="column.key">{{ column.label }}</th>
</slot>
</tr>
</thead>
<tbody v-if="props.rows.length > 0">
<tr v-for="(row, rowIndex) in props.rows" :key="rowIndex">
<td v-for="column in props.columns" :key="column.key">
<slot name="cell" :column="column" :row="row" :value="getCellValue(row, column)" :rowIndex="rowIndex">
{{ getCellValue(row, column) }}
</slot>
</td>
</tr>
</tbody><tbody v-else>
<tr>
<td :colspan="props.columns.length">
<slot name="empty" :columns="props.columns">No data</slot>
</td>
</tr>
</tbody><tfoot v-if="$slots.footerSummary || $slots.footerPagination">
<tr v-if="$slots.footerSummary">
<td :colspan="props.columns.length">
<slot name="footerSummary" :rows="props.rows"></slot>
</td>
</tr><tr v-if="$slots.footerPagination">
<td :colspan="props.columns.length">
<slot name="footerPagination" :rows="props.rows"></slot>
</td>
</tr></tfoot></table>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{ rows?: any[]; columns?: any[]; striped?: boolean; caption?: string }>(),
{ rows: () => [], columns: () => [], striped: false, caption: '' }
);
defineSlots<{
header(props: { columns: any }): any;
cell(props: { column: any; row: any; value: any; rowIndex: any }): any;
empty(props: { columns: any }): any;
footerSummary(props: { rows: any }): any;
footerPagination(props: { rows: any }): any;
}>();
const getCellValue = (row: any, column: any) => row?.[column.key];
</script>
<style scoped>
.rozie-table { border-collapse: collapse; width: 100%; font: 14px system-ui, sans-serif; }
.rozie-table th, .rozie-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid rgba(0,0,0,0.08); }
.rozie-table thead th { font-weight: 600; background: rgba(0,0,0,0.03); }
.rozie-table.striped tbody tr:nth-child(even) td { background: rgba(0,0,0,0.02); }
.rozie-table tfoot td { font-size: 0.85em; color: rgba(0,0,0,0.6); padding: 0.5rem 0.75rem; }
</style>tsx
import { useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieDisplay } from '@rozie/runtime-react';
import './Table.css';
interface HeaderCtx { columns: any; }
interface CellCtx { column: any; row: any; value: any; rowIndex: any; }
interface EmptyCtx { columns: any; }
interface FooterSummaryCtx { rows: any; }
interface FooterPaginationCtx { rows: any; }
interface TableProps {
rows?: any[];
columns?: any[];
striped?: boolean;
caption?: string;
renderHeader?: (ctx: HeaderCtx) => ReactNode;
renderCell?: (ctx: CellCtx) => ReactNode;
renderEmpty?: (ctx: EmptyCtx) => ReactNode;
renderFooterSummary?: (ctx: FooterSummaryCtx) => ReactNode;
renderFooterPagination?: (ctx: FooterPaginationCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export default function Table(_props: TableProps): JSX.Element {
const __defaultRows = useState(() => (() => [])())[0];
const __defaultColumns = useState(() => (() => [])())[0];
const props: Omit<TableProps, 'rows' | 'columns' | 'striped' | 'caption'> & { rows: any[]; columns: any[]; striped: boolean; caption: string } = {
..._props,
rows: _props.rows ?? __defaultRows,
columns: _props.columns ?? __defaultColumns,
striped: _props.striped ?? false,
caption: _props.caption ?? '',
};
const attrs: Record<string, unknown> = (() => {
const { rows, columns, striped, caption, ...rest } = _props as TableProps & Record<string, unknown>;
void rows; void columns; void striped; void caption;
return rest;
})();
function getCellValue(row: any, column: any) {
return row?.[column.key];
}
return (
<>
<table {...attrs} className={clsx(clsx("rozie-table", { striped: props.striped }), (attrs.className as string | undefined))} data-rozie-s-d14b2344="">
{(props.caption) && <caption data-rozie-s-d14b2344="">{props.caption}</caption>}<thead data-rozie-s-d14b2344="">
<tr data-rozie-s-d14b2344="">
{(props.renderHeader ?? props.slots?.['header']) ? ((props.renderHeader ?? props.slots?.['header']) as Function)({ columns: props.columns }) : props.columns.map((column) => <th key={column.key} data-rozie-s-d14b2344="">{rozieDisplay(column.label)}</th>)}
</tr>
</thead>
{(props.rows.length > 0) ? <tbody data-rozie-s-d14b2344="">
{props.rows.map((row, rowIndex) => <tr key={rowIndex} data-rozie-s-d14b2344="">
{props.columns.map((column) => <td key={column.key} data-rozie-s-d14b2344="">
{(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ column, row, value: getCellValue(row, column), rowIndex }) : rozieDisplay(getCellValue(row, column))}
</td>)}
</tr>)}
</tbody> : <tbody data-rozie-s-d14b2344="">
<tr data-rozie-s-d14b2344="">
<td colSpan={props.columns.length} data-rozie-s-d14b2344="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ columns: props.columns }) : "No data"}
</td>
</tr>
</tbody>}{((props.renderFooterSummary ?? props.slots?.['footerSummary']) || (props.renderFooterPagination ?? props.slots?.['footerPagination'])) && <tfoot data-rozie-s-d14b2344="">
{((props.renderFooterSummary ?? props.slots?.['footerSummary'])) && <tr data-rozie-s-d14b2344="">
<td colSpan={props.columns.length} data-rozie-s-d14b2344="">
{(props.renderFooterSummary ?? props.slots?.['footerSummary'])?.({ rows: props.rows })}
</td>
</tr>}{((props.renderFooterPagination ?? props.slots?.['footerPagination'])) && <tr data-rozie-s-d14b2344="">
<td colSpan={props.columns.length} data-rozie-s-d14b2344="">
{(props.renderFooterPagination ?? props.slots?.['footerPagination'])?.({ rows: props.rows })}
</td>
</tr>}</tfoot>}</table>
</>
);
}svelte
<script lang="ts">
import { applyListeners, rozieDisplay } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
interface Props {
rows?: any[];
columns?: any[];
striped?: boolean;
caption?: string;
header?: Snippet<[{ columns: any }]>;
cell?: Snippet<[{ column: any; row: any; value: any; rowIndex: any }]>;
empty?: Snippet<[{ columns: any }]>;
footerSummary?: Snippet<[{ rows: any }]>;
footerPagination?: Snippet<[{ rows: any }]>;
snippets?: Record<string, any>;
[key: string]: unknown;
}
let __defaultRows = (() => [])();
let __defaultColumns = (() => [])();
let {
rows = __defaultRows,
columns = __defaultColumns,
striped = false,
caption = '',
header: __headerProp,
cell: __cellProp,
empty: __emptyProp,
footerSummary: __footerSummaryProp,
footerPagination: __footerPaginationProp,
snippets,
...__rozieAttrs
}: Props = $props();
const header = $derived(__headerProp ?? snippets?.header);
const cell = $derived(__cellProp ?? snippets?.cell);
const empty = $derived(__emptyProp ?? snippets?.empty);
const footerSummary = $derived(__footerSummaryProp ?? snippets?.footerSummary);
const footerPagination = $derived(__footerPaginationProp ?? snippets?.footerPagination);
const getCellValue = (row: any, column: any) => row?.[column.key];
</script>
<table {...__rozieAttrs} class={["rozie-table", { striped: striped }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-d14b2344>{#if caption}<caption data-rozie-s-d14b2344>{caption}</caption>{/if}<thead data-rozie-s-d14b2344><tr data-rozie-s-d14b2344>{#if header}{@render header({ columns })}{:else}{#each columns as column (column.key)}<th data-rozie-s-d14b2344>{rozieDisplay(column.label)}</th>{/each}{/if}</tr></thead>{#if rows.length > 0}<tbody data-rozie-s-d14b2344>{#each rows as row, rowIndex (rowIndex)}<tr data-rozie-s-d14b2344>{#each columns as column (column.key)}<td data-rozie-s-d14b2344>{#if cell}{@render cell({ column, row, value: getCellValue(row, column), rowIndex })}{:else}{rozieDisplay(getCellValue(row, column))}{/if}</td>{/each}</tr>{/each}</tbody>{:else}<tbody data-rozie-s-d14b2344><tr data-rozie-s-d14b2344><td colspan={columns.length} data-rozie-s-d14b2344>{#if empty}{@render empty({ columns })}{:else}No data{/if}</td></tr></tbody>{/if}{#if footerSummary || footerPagination}<tfoot data-rozie-s-d14b2344>{#if footerSummary}<tr data-rozie-s-d14b2344><td colspan={columns.length} data-rozie-s-d14b2344>{#if footerSummary}{@render footerSummary({ rows })}{/if}</td></tr>{/if}{#if footerPagination}<tr data-rozie-s-d14b2344><td colspan={columns.length} data-rozie-s-d14b2344>{#if footerPagination}{@render footerPagination({ rows })}{/if}</td></tr>{/if}</tfoot>{/if}</table>
<style>
:global {
.rozie-table[data-rozie-s-d14b2344] { border-collapse: collapse; width: 100%; font: 14px system-ui, sans-serif; }
.rozie-table[data-rozie-s-d14b2344] th[data-rozie-s-d14b2344], .rozie-table[data-rozie-s-d14b2344] td[data-rozie-s-d14b2344] { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid rgba(0,0,0,0.08); }
.rozie-table[data-rozie-s-d14b2344] thead[data-rozie-s-d14b2344] th[data-rozie-s-d14b2344] { font-weight: 600; background: rgba(0,0,0,0.03); }
.rozie-table.striped[data-rozie-s-d14b2344] tbody[data-rozie-s-d14b2344] tr[data-rozie-s-d14b2344]:nth-child(even) td[data-rozie-s-d14b2344] { background: rgba(0,0,0,0.02); }
.rozie-table[data-rozie-s-d14b2344] tfoot[data-rozie-s-d14b2344] td[data-rozie-s-d14b2344] { font-size: 0.85em; color: rgba(0,0,0,0.6); padding: 0.5rem 0.75rem; }
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, inject, input, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
interface HeaderCtx {
$implicit: { columns: any };
columns: any;
}
interface CellCtx {
$implicit: { column: any; row: any; value: any; rowIndex: any };
column: any;
row: any;
value: any;
rowIndex: any;
}
interface EmptyCtx {
$implicit: { columns: any };
columns: any;
}
interface FooterSummaryCtx {
$implicit: { rows: any };
rows: any;
}
interface FooterPaginationCtx {
$implicit: { rows: any };
rows: any;
}
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-table',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<table class="rozie-table" [ngClass]="{ striped: striped() }" #rozieSpread_0 #rozieListenersTarget_1>
@if (caption()) {
<caption>{{ caption() }}</caption>
}<thead>
<tr>
@if ((headerTpl ?? templates()?.['header'])) {
<ng-container *ngTemplateOutlet="(headerTpl ?? templates()?.['header']); context: { $implicit: { columns: columns() }, columns: columns() }" />
} @else {
@for (column of columns(); track column.key) {
<th>{{ rozieDisplay(column.label) }}</th>
}
}
</tr>
</thead>
@if (rows().length > 0) {
<tbody>
@for (row of rows(); track rowIndex; let rowIndex = $index) {
<tr>
@for (column of columns(); track column.key) {
<td>
@if ((cellTpl ?? templates()?.['cell'])) {
<ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { column: column, row: row, value: getCellValue(row, column), rowIndex: rowIndex }, column: column, row: row, value: getCellValue(row, column), rowIndex: rowIndex }" />
} @else {
{{ rozieDisplay(getCellValue(row, column)) }}
}
</td>
}
</tr>
}
</tbody>
} @else {
<tbody>
<tr>
<td [colSpan]="columns().length">
@if ((emptyTpl ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? templates()?.['empty']); context: { $implicit: { columns: columns() }, columns: columns() }" />
} @else {
No data
}
</td>
</tr>
</tbody>
}@if ((footerSummaryTpl ?? templates()?.['footerSummary']) || (footerPaginationTpl ?? templates()?.['footerPagination'])) {
<tfoot>
@if ((footerSummaryTpl ?? templates()?.['footerSummary'])) {
<tr>
<td [colSpan]="columns().length">
@if ((footerSummaryTpl ?? templates()?.['footerSummary'])) {
<ng-container *ngTemplateOutlet="(footerSummaryTpl ?? templates()?.['footerSummary']); context: { $implicit: { rows: rows() }, rows: rows() }" />
}
</td>
</tr>
}@if ((footerPaginationTpl ?? templates()?.['footerPagination'])) {
<tr>
<td [colSpan]="columns().length">
@if ((footerPaginationTpl ?? templates()?.['footerPagination'])) {
<ng-container *ngTemplateOutlet="(footerPaginationTpl ?? templates()?.['footerPagination']); context: { $implicit: { rows: rows() }, rows: rows() }" />
}
</td>
</tr>
}</tfoot>
}</table>
`,
styles: [`
.rozie-table { border-collapse: collapse; width: 100%; font: 14px system-ui, sans-serif; }
.rozie-table th, .rozie-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid rgba(0,0,0,0.08); }
.rozie-table thead th { font-weight: 600; background: rgba(0,0,0,0.03); }
.rozie-table.striped tbody tr:nth-child(even) td { background: rgba(0,0,0,0.02); }
.rozie-table tfoot td { font-size: 0.85em; color: rgba(0,0,0,0.6); padding: 0.5rem 0.75rem; }
`],
})
export class Table {
rows = input<any[]>((() => [])());
columns = input<any[]>((() => [])());
striped = input<boolean>(false);
caption = input<string>('');
@ContentChild('header', { read: TemplateRef }) headerTpl?: TemplateRef<HeaderCtx>;
@ContentChild('cell', { read: TemplateRef }) cellTpl?: TemplateRef<CellCtx>;
@ContentChild('empty', { read: TemplateRef }) emptyTpl?: TemplateRef<EmptyCtx>;
@ContentChild('footerSummary', { read: TemplateRef }) footerSummaryTpl?: TemplateRef<FooterSummaryCtx>;
@ContentChild('footerPagination', { read: TemplateRef }) footerPaginationTpl?: TemplateRef<FooterPaginationCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
getCellValue = (row: any, column: any) => row?.[column.key];
static ngTemplateContextGuard(
_dir: Table,
_ctx: unknown,
): _ctx is HeaderCtx | CellCtx | EmptyCtx | FooterSummaryCtx | FooterPaginationCtx {
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 = [];
});
}
});
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default Table;tsx
import type { JSX } from 'solid-js';
import { For, Show, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
__rozieInjectStyle('Table-d14b2344', `.rozie-table[data-rozie-s-d14b2344] { border-collapse: collapse; width: 100%; font: 14px system-ui, sans-serif; }
.rozie-table[data-rozie-s-d14b2344] th[data-rozie-s-d14b2344], .rozie-table[data-rozie-s-d14b2344] td[data-rozie-s-d14b2344] { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid rgba(0,0,0,0.08); }
.rozie-table[data-rozie-s-d14b2344] thead[data-rozie-s-d14b2344] th[data-rozie-s-d14b2344] { font-weight: 600; background: rgba(0,0,0,0.03); }
.rozie-table.striped[data-rozie-s-d14b2344] tbody[data-rozie-s-d14b2344] tr[data-rozie-s-d14b2344]:nth-child(even) td[data-rozie-s-d14b2344] { background: rgba(0,0,0,0.02); }
.rozie-table[data-rozie-s-d14b2344] tfoot[data-rozie-s-d14b2344] td[data-rozie-s-d14b2344] { font-size: 0.85em; color: rgba(0,0,0,0.6); padding: 0.5rem 0.75rem; }`);
interface HeaderSlotCtx { columns: any; }
interface CellSlotCtx { column: any; row: any; value: any; rowIndex: any; }
interface EmptySlotCtx { columns: any; }
interface FooterSummarySlotCtx { rows: any; }
interface FooterPaginationSlotCtx { rows: any; }
interface TableProps {
rows?: any[];
columns?: any[];
striped?: boolean;
caption?: string;
headerSlot?: (ctx: HeaderSlotCtx) => JSX.Element;
cellSlot?: (ctx: CellSlotCtx) => JSX.Element;
emptySlot?: (ctx: EmptySlotCtx) => JSX.Element;
footerSummarySlot?: (ctx: FooterSummarySlotCtx) => JSX.Element;
footerPaginationSlot?: (ctx: FooterPaginationSlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
}
export default function Table(_props: TableProps): JSX.Element {
const _merged = mergeProps({ rows: (() => [])(), columns: (() => [])(), striped: false, caption: '' }, _props);
const [local, attrs] = splitProps(_merged, ['rows', 'columns', 'striped', 'caption']);
function getCellValue(row: any, column: any) {
return row?.[column.key];
}
return (
<>
<table {...attrs} class={"rozie-table" + " " + rozieClass({ striped: local.striped }) + (((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-d14b2344="">
{<Show when={local.caption}><caption data-rozie-s-d14b2344="">{local.caption}</caption></Show>}<thead data-rozie-s-d14b2344="">
<tr data-rozie-s-d14b2344="">
{(_props.headerSlot ?? _props.slots?.['header'])?.({ columns: local.columns }) ?? <For each={local.columns}>{(column) => <th data-rozie-s-d14b2344="">{rozieDisplay(column.label)}</th>}</For>}
</tr>
</thead>
{<Show when={local.rows.length > 0} fallback={<tbody data-rozie-s-d14b2344="">
<tr data-rozie-s-d14b2344="">
<td colSpan={local.columns.length} data-rozie-s-d14b2344="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ columns: local.columns }) ?? "No data"}
</td>
</tr>
</tbody>}><tbody data-rozie-s-d14b2344="">
<For each={local.rows}>{(row, rowIndex) => <tr data-rozie-s-d14b2344="">
<For each={local.columns}>{(column) => <td data-rozie-s-d14b2344="">
{(_props.cellSlot ?? _props.slots?.['cell'])?.({ column, row, value: getCellValue(row, column), rowIndex: rowIndex() }) ?? rozieDisplay(getCellValue(row, column))}
</td>}</For>
</tr>}</For>
</tbody></Show>}{<Show when={(_props.footerSummarySlot ?? _props.slots?.['footerSummary']) || (_props.footerPaginationSlot ?? _props.slots?.['footerPagination'])}><tfoot data-rozie-s-d14b2344="">
{<Show when={(_props.footerSummarySlot ?? _props.slots?.['footerSummary'])}><tr data-rozie-s-d14b2344="">
<td colSpan={local.columns.length} data-rozie-s-d14b2344="">
{(_props.footerSummarySlot ?? _props.slots?.['footerSummary'])?.({ rows: local.rows })}
</td>
</tr></Show>}{<Show when={(_props.footerPaginationSlot ?? _props.slots?.['footerPagination'])}><tr data-rozie-s-d14b2344="">
<td colSpan={local.columns.length} data-rozie-s-d14b2344="">
{(_props.footerPaginationSlot ?? _props.slots?.['footerPagination'])?.({ rows: local.rows })}
</td>
</tr></Show>}</tfoot></Show>}</table>
</>
);
}ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { rozieAttr, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
interface RozieHeaderSlotCtx {
columns: unknown;
}
interface RozieCellSlotCtx {
column: unknown;
row: unknown;
value: unknown;
rowIndex: unknown;
}
interface RozieEmptySlotCtx {
columns: unknown;
}
interface RozieFooterSummarySlotCtx {
rows: unknown;
}
interface RozieFooterPaginationSlotCtx {
rows: unknown;
}
@customElement('rozie-table')
export default class Table extends SignalWatcher(LitElement) {
static styles = css`
.rozie-table[data-rozie-s-d14b2344] { border-collapse: collapse; width: 100%; font: 14px system-ui, sans-serif; }
.rozie-table[data-rozie-s-d14b2344] th[data-rozie-s-d14b2344], .rozie-table[data-rozie-s-d14b2344] td[data-rozie-s-d14b2344] { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid rgba(0,0,0,0.08); }
.rozie-table[data-rozie-s-d14b2344] thead[data-rozie-s-d14b2344] th[data-rozie-s-d14b2344] { font-weight: 600; background: rgba(0,0,0,0.03); }
.rozie-table.striped[data-rozie-s-d14b2344] tbody[data-rozie-s-d14b2344] tr[data-rozie-s-d14b2344]:nth-child(even) td[data-rozie-s-d14b2344] { background: rgba(0,0,0,0.02); }
.rozie-table[data-rozie-s-d14b2344] tfoot[data-rozie-s-d14b2344] td[data-rozie-s-d14b2344] { font-size: 0.85em; color: rgba(0,0,0,0.6); padding: 0.5rem 0.75rem; }
`;
@property({ type: Array }) rows: any[] = [];
@property({ type: Array }) columns: any[] = [];
@property({ type: Boolean, reflect: true }) striped: boolean = false;
@property({ type: String, reflect: true }) caption: string = '';
@state() private _hasSlotHeader = false;
@queryAssignedElements({ slot: 'header', flatten: true }) private _slotHeaderElements!: Element[];
@property({ attribute: false }) header?: (scope: { columns: unknown }) => unknown;
@state() private _hasSlotCell = false;
@queryAssignedElements({ slot: 'cell', flatten: true }) private _slotCellElements!: Element[];
@property({ attribute: false }) cell?: (scope: { column: unknown; row: unknown; value: unknown; rowIndex: unknown }) => unknown;
@state() private _hasSlotEmpty = false;
@queryAssignedElements({ slot: 'empty', flatten: true }) private _slotEmptyElements!: Element[];
@property({ attribute: false }) empty?: (scope: { columns: unknown }) => unknown;
@state() private _hasSlotFooterSummary = false;
@queryAssignedElements({ slot: 'footerSummary', flatten: true }) private _slotFooterSummaryElements!: Element[];
@property({ attribute: false }) footerSummary?: (scope: { rows: unknown }) => unknown;
@state() private _hasSlotFooterPagination = false;
@queryAssignedElements({ slot: 'footerPagination', flatten: true }) private _slotFooterPaginationElements!: Element[];
@property({ attribute: false }) footerPagination?: (scope: { rows: unknown }) => unknown;
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[name="cell"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotCell = this._slotCellElements.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();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="footerSummary"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotFooterSummary = this._slotFooterSummaryElements.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="footerPagination"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotFooterPagination = this._slotFooterPaginationElements.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._hasSlotCell = Array.from(this.children).some((el) => el.getAttribute('slot') === 'cell');
this._hasSlotEmpty = Array.from(this.children).some((el) => el.getAttribute('slot') === 'empty');
this._hasSlotFooterSummary = Array.from(this.children).some((el) => el.getAttribute('slot') === 'footerSummary');
this._hasSlotFooterPagination = Array.from(this.children).some((el) => el.getAttribute('slot') === 'footerPagination');
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 = [];
});
}
render() {
return html`
<table class="${Object.entries({ "rozie-table": true, striped: this.striped }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-d14b2344>
${this.caption ? html`<caption data-rozie-s-d14b2344>${this.caption}</caption>` : nothing}<thead data-rozie-s-d14b2344>
<tr data-rozie-s-d14b2344>
${this.header !== undefined ? this.header({columns: this.columns}) : html`<slot name="header" data-rozie-params=${(() => { try { return JSON.stringify({columns: this.columns}); } catch { return '{}'; } })()}>
${repeat<any>(this.columns, (column, _idx) => column.key, (column, _idx) => html`<th key=${rozieAttr(column.key)} data-rozie-s-d14b2344>${rozieDisplay(column.label)}</th>`)}
</slot>`}
</tr>
</thead>
${this.rows.length > 0 ? html`<tbody data-rozie-s-d14b2344>
${repeat<any>(this.rows, (row, rowIndex) => rowIndex, (row, rowIndex) => html`<tr key=${rozieAttr(rowIndex)} data-rozie-s-d14b2344>
${repeat<any>(this.columns, (column, _idx) => column.key, (column, _idx) => html`<td key=${rozieAttr(column.key)} data-rozie-s-d14b2344>
${this.cell !== undefined ? this.cell({column: column, row: row, value: this.getCellValue(row, column), rowIndex: rowIndex}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({column: column, row: row, value: this.getCellValue(row, column), rowIndex: rowIndex}); } catch { return '{}'; } })()}>
${rozieDisplay(this.getCellValue(row, column))}
</slot>`}
</td>`)}
</tr>`)}
</tbody>` : html`<tbody data-rozie-s-d14b2344>
<tr data-rozie-s-d14b2344>
<td colspan=${this.columns.length} data-rozie-s-d14b2344>
${this.empty !== undefined ? this.empty({columns: this.columns}) : html`<slot name="empty" data-rozie-params=${(() => { try { return JSON.stringify({columns: this.columns}); } catch { return '{}'; } })()}>No data</slot>`}
</td>
</tr>
</tbody>`}${this._hasSlotFooterSummary || this.footerSummary !== undefined || this._hasSlotFooterPagination || this.footerPagination !== undefined ? html`<tfoot data-rozie-s-d14b2344>
${this._hasSlotFooterSummary || this.footerSummary !== undefined ? html`<tr data-rozie-s-d14b2344>
<td colspan=${this.columns.length} data-rozie-s-d14b2344>
${this.footerSummary !== undefined ? this.footerSummary({rows: this.rows}) : html`<slot name="footerSummary" data-rozie-params=${(() => { try { return JSON.stringify({rows: this.rows}); } catch { return '{}'; } })()}></slot>`}
</td>
</tr>` : nothing}${this._hasSlotFooterPagination || this.footerPagination !== undefined ? html`<tr data-rozie-s-d14b2344>
<td colspan=${this.columns.length} data-rozie-s-d14b2344>
${this.footerPagination !== undefined ? this.footerPagination({rows: this.rows}) : html`<slot name="footerPagination" data-rozie-params=${(() => { try { return JSON.stringify({rows: this.rows}); } catch { return '{}'; } })()}></slot>`}
</td>
</tr>` : nothing}</tfoot>` : nothing}</table>
`;
}
getCellValue = (row: any, column: any) => row?.[column.key];
/**
* 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>(['rows', 'columns', 'striped', 'caption']);
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;
}
}Demo source — TableDemo.rozie
rozie
<!--
Demo wrapper for Table — used by the live demo on docs/examples/table.md.
Demonstrates custom #header (default — the producer's fallback row), per-column
dispatch inside a single shared #cell slot, and the marquee showcase: a
dynamic-slot footer fill via `<template #[\`footer${$data.footerMode}\`]>`
that swaps which static-name producer slot the consumer fills.
Not registered with tests/visual-regression — Table is demo/dogfood only.
No VR baselines, no expected.*.{tsx,vue,svelte,ts} snapshot files. The docs
site live-compiles this through @rozie/core's rozie-codegen plugin on every
build, so the per-target outputs on /examples/table are always current.
Slot-name nuance: footer slots on the producer are camelCase (footerSummary,
footerPagination) because Rozie's $slots magic accessor requires static dot
keys — kebab-case would force `$slots['footer-summary']` which is a computed
access (ROZ106). The consumer's dynamic fill follows the same convention.
-->
<rozie name="TableDemo">
<components>
{
Table: '../Table.rozie',
}
</components>
<data>
{
footerMode: 'Summary',
rows: [
{ id: 1, name: 'Alpha', status: 'active', score: 92 },
{ id: 2, name: 'Beta', status: 'pending', score: 67 },
{ id: 3, name: 'Gamma', status: 'inactive', score: 41 },
],
columns: [
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status' },
{ key: 'score', label: 'Score' },
],
}
</data>
<script>
const toggleFooter = () => {
$data.footerMode = $data.footerMode === 'Summary' ? 'Pagination' : 'Summary'
}
const total = $computed(() => $data.rows.reduce((s, r) => s + r.score, 0))
</script>
<template>
<div class="table-demo">
<button @click="toggleFooter">Toggle footer (current: {{ $data.footerMode }})</button>
<Table :rows="$data.rows" :columns="$data.columns" :striped="true" caption="Members">
<template #cell="{ column, row, value }">
<template r-match="column.key">
<span r-case="'status'" :class="`badge badge-${value}`">{{ value }}</span>
<strong r-case="'score'">{{ value }}</strong>
<span r-default>{{ value }}</span>
</template>
</template>
<template #[`footer${$data.footerMode}`]>
<span r-if="$data.footerMode === 'Summary'">Total score: {{ total }}</span>
<span r-else>Page 1 of 1 — {{ $data.rows.length }} rows</span>
</template>
</Table>
</div>
</template>
<style>
/* Fixed width decouples the table from the toggle button's `fit-content`
size. The button text interpolates `{{ footerMode }}`, which JSX/Lit emit
as multiple text nodes (vs one for Vue/Svelte/Angular); Chromium accrues
~1/64px of kerning per text-node boundary, so a fit-content button is
~0.016px wider on react/solid/lit. The `width:100%` table tracked that
drift and shifted the `-webkit-center` <caption>, failing the VR matcher.
A fixed width keeps all six targets pixel-stable. */
.table-demo { display: flex; flex-direction: column; gap: 0.75rem; font-family: system-ui, sans-serif; width: 280px; }
.badge { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.8em; }
.badge-active { background: #d1fae5; color: #065f46; }
.badge-pending { background: #fef3c7; color: #92400e; }
.badge-inactive { background: #f3f4f6; color: #4b5563; }
button { width: fit-content; padding: 0.4rem 0.75rem; cursor: pointer; }
</style>
</rozie>