Appearance
Card (with CardHeader)
A wrapper-pair. Card.rozie declares CardHeader in its <components> block and renders <CardHeader title=... :on-close=... /> at the top of its template; the rest of the body comes from a default slot.
Live demo
The X button only renders because we passed an onClose handler — CardHeader does r-if="$props.onClose" on its close button.
Each target picks its idiomatic import + child-tag form for the cross-component reference:
- Vue / Svelte — import with the target extension (
./CardHeader.vue,./CardHeader.svelte). - React / Solid — bare-path import (
./CardHeader). - Angular — named import + class added to
@Component({ imports: [...] }), and the<CardHeader>tag rewritten to selector form<rozie-card-header>.
Note the auto kebab/camel conversion: the source writes :on-close="$props.onClose", and each emitter reconciles that against the onClose prop declaration in the appropriate per-target idiom.
Card — source
rozie
<!--
Card.rozie
Demonstrates Phase 06.2 wrapper composition (D-119):
- <components> block declaring CardHeader as a child component.
- Template embeds <CardHeader title="..." :on-close="..." /> at the top
of the body, with a default <slot /> in the body content area.
- tagKind: 'component' on the <CardHeader> tag (P1 lowering); P2 emit
synthesizes a top-of-file import per target — Vue/Svelte get the
target-extension form, React gets bare './CardHeader', Angular gets
named import + class added to @Component({ imports: [...] }) AND the
tag is rewritten to selector form `<rozie-card-header>`.
Wrapper-pair partner of CardHeader.rozie.
-->
<rozie name="Card">
<components>
{
CardHeader: './CardHeader.rozie',
}
</components>
<props>
{
title: { type: String, default: '' },
onClose: { type: Function, default: null },
}
</props>
<template>
<article class="card">
<CardHeader :title="$props.title" :on-close="$props.onClose" />
<div class="card__body">
<slot />
</div>
</article>
</template>
<style>
.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }
</style>
</rozie>Card — Vue output
vue
<template>
<article class="card">
<CardHeader :title="props.title" :on-close="props.onClose"></CardHeader>
<div class="card__body">
<slot></slot>
</div>
</article>
</template>
<script setup lang="ts">
import CardHeader from './CardHeader.vue';
const props = withDefaults(
defineProps<{ title?: string; onClose?: (...args: any[]) => any }>(),
{ title: '', onClose: null }
);
defineSlots<{
default(props: { }): any;
}>();
</script>
<style scoped>
.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }
</style>Card — React output
tsx
import type { ReactNode } from 'react';
import styles from './Card.module.css';
import CardHeader from './CardHeader';
interface CardProps {
title?: string;
onClose?: (...args: unknown[]) => unknown;
children?: ReactNode;
}
export default function Card(_props: CardProps): JSX.Element {
const props: CardProps = {
..._props,
title: _props.title ?? '',
onClose: _props.onClose ?? null,
};
return (
<>
<article className={styles.card}>
<CardHeader title={props.title} onClose={props.onClose} />
<div className={styles.card__body}>
{props.children}
</div>
</article>
</>
);
}Card — Svelte output
svelte
<script lang="ts">
import CardHeader from './CardHeader.svelte';
import type { Snippet } from 'svelte';
interface Props {
title?: string;
onClose?: (...args: any[]) => any;
children?: Snippet;
}
let {
title = '',
onClose = null,
children,
}: Props = $props();
</script>
<article class="card">
<CardHeader title={title} on-close={onClose}></CardHeader>
<div class="card__body">
{@render children?.()}
</div>
</article>
<style>
.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }
</style>Card — Angular output
ts
import { Component, ContentChild, TemplateRef, ViewEncapsulation, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { CardHeader } from './CardHeader';
interface DefaultCtx {}
@Component({
selector: 'rozie-card',
standalone: true,
imports: [NgTemplateOutlet, CardHeader],
template: `
<article class="card">
<rozie-card-header [title]="title()" [onClose]="onClose()"></rozie-card-header>
<div class="card__body">
<ng-container *ngTemplateOutlet="defaultTpl" />
</div>
</article>
`,
styles: [`
.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }
`],
})
export class Card {
title = input<string>('');
onClose = input<((...args: unknown[]) => unknown) | null>(null);
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
static ngTemplateContextGuard(
_dir: Card,
_ctx: unknown,
): _ctx is DefaultCtx {
return true;
}
}
export default Card;Card — Solid output
tsx
import type { JSX } from 'solid-js';
import { children, mergeProps, splitProps } from 'solid-js';
import CardHeader from './CardHeader';
interface CardProps {
title?: string;
onClose?: (...args: unknown[]) => unknown;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
}
export default function Card(_props: CardProps): JSX.Element {
const _merged = mergeProps({ title: '' }, _props);
const [local, rest] = splitProps(_merged, ['title', 'onClose', 'children']);
const resolved = children(() => local.children);
return (
<>
<style>{`.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }`}</style>
<>
<article class={"card"}>
<CardHeader title={local.title} onClose={local.onClose} />
<div class={"card__body"}>
{resolved()}
</div>
</article>
</>
</>
);
}Card — Lit output
ts
import { LitElement, css, html } from 'lit';
import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import './CardHeader.rozie';
@customElement('rozie-card')
export default class Card extends SignalWatcher(LitElement) {
static styles = css`
.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }
`;
@property({ type: String, reflect: true }) title: string = '';
@property({ type: Function }) onClose: ((...args: unknown[]) => unknown) | null = null;
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
private _disconnectCleanups: Array<() => void> = [];
private _armListeners(): void {
{
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();
}
}
}
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated) this._armListeners();
}
firstUpdated(): void {
this._armListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
}
render() {
return html`
<article class="card">
<rozie-card-header .title=${this.title} .onClose=${this.onClose}></rozie-card-header>
<div class="card__body">
<slot></slot>
</div>
</article>
`;
}
}CardHeader — source
A tiny leaf component (~30 lines) — no <components> block, no slots, no lifecycle. Stands alone and is consumed by Card. Worth seeing because the contrast with Card highlights the cost-of-features model: leaves are cheap, only components that actually need composition/listeners/refs pay for them.
rozie
<!--
CardHeader.rozie
Small leaf component (~30 LOC) — the wrapper-target of Card.rozie.
Demonstrates that components consumed via <components> blocks don't need
to declare any composition themselves; this file has no <components> block
and no recursion.
Phase 06.2 D-119 wrapper-pair partner of Card.rozie.
-->
<rozie name="CardHeader">
<props>
{
title: { type: String, default: '' },
onClose: { type: Function, default: null },
}
</props>
<template>
<header class="card-header">
<h3 class="card-header__title">{{ $props.title }}</h3>
<button r-if="$props.onClose" class="card-header__close" @click="$props.onClose">×</button>
</header>
</template>
<style>
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
</style>
</rozie>CardHeader — Vue output
vue
<template>
<header class="card-header">
<h3 class="card-header__title">{{ props.title }}</h3>
<button v-if="props.onClose" class="card-header__close" @click="props.onClose">×</button></header>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{ title?: string; onClose?: (...args: any[]) => any }>(),
{ title: '', onClose: null }
);
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
</style>CardHeader — React output
tsx
import styles from './CardHeader.module.css';
interface CardHeaderProps {
title?: string;
onClose?: (...args: unknown[]) => unknown;
}
export default function CardHeader(_props: CardHeaderProps): JSX.Element {
const props: CardHeaderProps = {
..._props,
title: _props.title ?? '',
onClose: _props.onClose ?? null,
};
return (
<>
<header className={styles["card-header"]}>
<h3 className={styles["card-header__title"]}>{props.title}</h3>
{(props.onClose) && <button className={styles["card-header__close"]} onClick={(e) => { props.onClose; }}>×</button>}</header>
</>
);
}CardHeader — Svelte output
svelte
<script lang="ts">
interface Props {
title?: string;
onClose?: (...args: any[]) => any;
}
let { title = '', onClose = null }: Props = $props();
</script>
<header class="card-header">
<h3 class="card-header__title">{title}</h3>
{#if onClose}<button class="card-header__close" onclick={(e) => { (onClose)(e); }}>×</button>{/if}</header>
<style>
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
</style>CardHeader — Angular output
ts
import { Component, ViewEncapsulation, input } from '@angular/core';
@Component({
selector: 'rozie-card-header',
standalone: true,
template: `
<header class="card-header">
<h3 class="card-header__title">{{ title() }}</h3>
@if (onClose()) {
<button class="card-header__close" (click)="(onClose())($event)">×</button>
}</header>
`,
styles: [`
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
`],
})
export class CardHeader {
title = input<string>('');
onClose = input<((...args: unknown[]) => unknown) | null>(null);
}
export default CardHeader;CardHeader — Solid output
tsx
import type { JSX } from 'solid-js';
import { Show, mergeProps, splitProps } from 'solid-js';
interface CardHeaderProps {
title?: string;
onClose?: (...args: unknown[]) => unknown;
}
export default function CardHeader(_props: CardHeaderProps): JSX.Element {
const _merged = mergeProps({ title: '' }, _props);
const [local, rest] = splitProps(_merged, ['title', 'onClose']);
return (
<>
<style>{`.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }`}</style>
<>
<header class={"card-header"}>
<h3 class={"card-header__title"}>{local.title}</h3>
{<Show when={local.onClose}><button class={"card-header__close"} onClick={(e) => { local.onClose; }}>×</button></Show>}</header>
</>
</>
);
}CardHeader — Lit output
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
@customElement('rozie-card-header')
export default class CardHeader extends SignalWatcher(LitElement) {
static styles = css`
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
`;
@property({ type: String, reflect: true }) title: string = '';
@property({ type: Function }) onClose: ((...args: unknown[]) => unknown) | null = null;
private _disconnectCleanups: Array<() => void> = [];
disconnectedCallback(): void {
super.disconnectedCallback();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
}
render() {
return html`
<header class="card-header">
<h3 class="card-header__title">${this.title}</h3>
${this.onClose ? html`<button class="card-header__close" @click=${this.onClose}>×</button>` : nothing}</header>
`;
}
}