Skip to content

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>
`;
  }
}

Pre-v1.0 — internal monorepo.