Skip to content

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>

Pre-v1.0 — internal monorepo.