Caroa UI

コンポジション

Caroa UIの部品を正しい親子関係で組み合わせ、拡張可能な画面を作るためのガイド

概要

コンポジションは、小さな部品を正しい親子関係で組み合わせてUIを作る考え方です。Caroa UIでは、1つのコンポーネントに多数のpropsを足して画面全体を任せるのではなく、CardHeader、DialogFooter、FieldControlのような役割の明確な部品を組み合わせます。

構造をツリーで先に確認すると、必要なラッパーの欠落、不正なネスト、責務の大きすぎるvariantを防げます。この方針はshadcn/uiのComponent Compositionを参考に、Caroa UIのasChild、data-slot、Server / Clientエントリポイントへ合わせたものです。

最初に決めること

実装前に、次の順番で考えます。

  1. 目的に合う既存コンポーネントがあるか確認する
  2. 複合コンポーネントなら、親子構造をツリーで確認する
  3. 見た目だけの差はvariant、構造や役割の差は子要素の組み合わせで表す
  4. HTML要素やルーターのLinkへ差し替える必要がある場合だけasChildを使う
  5. インタラクションを含む最小の範囲だけClient Componentにする

基本原則

部品の役割を保つ

CardTitleはタイトル、DialogFooterはダイアログ下部のアクション、FieldControlは1つの入力コントロールを受け持ちます。見た目が似ていても、別の役割の部品で代用しません。

構造はchildrenで表す

タイトル、説明、本文、アクションを個別propsへ詰め込むより、公開されているサブコンポーネントをchildrenとして配置します。これにより、内容や並びを利用側で調整しながら、共通の余白とセマンティクスを保てます。

variantは構造を変えない

variantは、同じ責務とほぼ同じDOM構造のまま、色、密度、枠線などを切り替えるために使います。ヘッダーやフッターの有無、フォームの種類、操作フローまでvariantで切り替えないでください。

代表的な構成

Card

Card
├── CardHeader
│   ├── CardTitle
│   ├── CardDescription (任意)
│   └── CardAction (任意)
├── CardContent
└── CardFooter (任意)
import {
  Button,
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@caroainc/ui-components/server'

export function ProjectCard() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Webサイトリニューアル</CardTitle>
        <CardDescription>最終更新: 9月2日</CardDescription>
        <CardAction>
          <Button variant="ghost" size="sm">詳細</Button>
        </CardAction>
      </CardHeader>
      <CardContent>
        <p>進行中のタスクは3件です。</p>
      </CardContent>
      <CardFooter>
        <Button>開く</Button>
      </CardFooter>
    </Card>
  )
}

CardActionはCardHeader内に置きます。タイトルとアクションを同じ行で厳密に揃えたい場合は、CardHeader内にflexのラッパーを追加し、その中でグループ化してください。

Dialog

Dialog
├── DialogTrigger (任意、通常はasChild)
└── DialogContent
    ├── DialogHeader
    │   ├── DialogTitle
    │   └── DialogDescription (任意)
    ├── 本文
    └── DialogFooter
        ├── DialogClose (任意、通常はasChild)
        └── 実行ボタン
'use client'

import { Button } from '@caroainc/ui-components/server'
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@caroainc/ui-components/client'

export function DeleteProjectDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">削除を確認</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>プロジェクトを削除しますか</DialogTitle>
          <DialogDescription>この操作は取り消せません。</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose asChild>
            <Button type="button" variant="outline">キャンセル</Button>
          </DialogClose>
          <Button type="button" variant="destructive">削除</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

通常はDialogContentだけを置けば、Portal、Overlay、右上の閉じるボタンが内部で構成されます。独自実装が必要でない限り、DialogPortalやDialogOverlayを重ねて追加しません。

Field

Field
├── FieldLabel
├── FieldControl
│   └── 1つのInput / Textarea / CommandCombobox
├── FieldDescription (任意)
└── FieldError (任意)
'use client'

import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@caroainc/ui-components/client'
import { Input } from '@caroainc/ui-components/server'

export function EmailField({ errorMessage }: { errorMessage?: string }) {
  return (
    <Field>
      <FieldLabel>メールアドレス</FieldLabel>
      <FieldControl>
        <Input type="email" aria-invalid={Boolean(errorMessage)} />
      </FieldControl>
      <FieldDescription>業務用のメールアドレスを入力してください。</FieldDescription>
      <FieldError>{errorMessage}</FieldError>
    </Field>
  )
}

FieldControlは常にRadix Slotで子へpropsを合成します。子は有効なReact要素を1つだけ渡してください。react-hook-formと連携する場合は、同じ目的のFormを使用します。

asChildとSlot

asChildは、Caroa UIが新しいDOM要素を追加せず、単一の子要素へclassName、data属性、イベント、アクセシビリティ属性を合成する仕組みです。

LinkをButtonの見た目にする

import { Button } from '@caroainc/ui-components/server'

<Button asChild>
  <a href="/projects">プロジェクト一覧</a>
</Button>

ネストしたbuttonを避ける

RadixのTriggerはデフォルトでbuttonを描画します。Buttonを子にする場合はasChildを指定し、buttonの中へbuttonを作らないようにします。

// Good: 最終DOMはbuttonが1つ
<DialogTrigger asChild>
  <Button>開く</Button>
</DialogTrigger>

// Bad: buttonの中にbuttonが入る
<DialogTrigger>
  <Button>開く</Button>
</DialogTrigger>

asChildの制約

  • 子要素は1つだけにする。配列、Fragment、複数要素は渡さない
  • 最終要素の意味は子が決める。リンクはhref、操作はbuttonなど、適切なHTML要素を使う
  • divへButtonの見た目を移しても、キーボード操作やbuttonの意味は自動では得られない
  • Button asChildのloadingは無効状態を適用するが、Slotの単一子制約によりスピナーは描画しない
  • asChildを重ねるほどpropsとイベントの由来が追いにくくなる。必要な境界だけで使う

data-slotで構造を確認する

DOMを描画するCaroa UIの部品には、data-caroa-uiと役割別のdata-slotが付きます。Compositionツリーと実DOMが一致しているかを、ブラウザの開発者ツールやテストで確認できます。

data-slot="card"
├── data-slot="card-header"
│   ├── data-slot="card-title"
│   └── data-slot="card-description"
├── data-slot="card-content"
└── data-slot="card-footer"

限定的なスタイル調整が必要な場合も、構造を壊すwrapperを増やす前にslotを利用できます。

[data-caroa-ui][data-slot='dialog-content'] {
  max-height: 85vh;
  overflow-y: auto;
}

DialogやSelectのルートはContextを提供するだけで、自前のDOMを描画しません。この場合、ルート名のslotが実DOMに現れないことがあります。dialog-contentやselect-triggerなど、実際に描画される部品を確認してください。

Server / Clientエントリポイント

Composition全体をClient Componentにする必要はありません。静的な外枠はServer Componentに保ち、状態やイベントを持つ小さな部品だけをClient Componentへ切り出します。

Server Page
└── Card (/server)
    ├── CardHeader (/server)
    ├── CardContent (/server)
    └── ProjectActions ('use client'の小さな境界)
        └── Dialog (/client)
用途エントリポイント例
表示とスタイルだけ/serverButton、Card、Badge、Input
状態、Context、Radixの操作/clientDialog、Tabs、Select、DropdownMenu、Field
react-hook-form連携/formForm、FormField、FormControl

asChildはDOMの合成方法を変えるだけで、Server / Client境界は変えません。クリックハンドラやローカルstateが必要な部分は'use client'のファイルへ置き、できるだけ小さな葉コンポーネントにします。

既存コンポーネントの選び方

作りたいもの推奨する組み合わせ
タイトル、本文、アクションを持つ面Card + CardHeader + CardContent + CardFooter
確認や短い入力フローDialog + DialogHeader + DialogFooter
ラベル付き入力Field + FieldLabel + FieldControl
react-hook-formの入力Form + FormField + FormItem + FormControl
複数の関連ボタンButtonGroup + Button
タイトル、説明、操作を持つ行Item + ItemContent + ItemActions
選択肢が多く検索が必要CommandCombobox
ページタイトルと右側アクションPageHeader + actions prop

既存部品で表現できる場合、似たラッパーを新しく作りません。複数画面で同じCompositionが繰り返され、構造と振る舞いを一括で保つ必要が出た時点で、プロダクト側の合成コンポーネントとして切り出します。

巨大variantを避ける

次のようなpropsが増え始めたら、variantではなくCompositionへ分けるサインです。

// Bad: 1つのコンポーネントが構造と業務ロジックを抱えている
<ProjectCard
  variant="withOwnerAndActions"
  title="Webサイトリニューアル"
  description="進行中"
  owner={owner}
  showMenu
  confirmBeforeDelete
/>
// Good: 部品の役割と構造が見える
<Card>
  <CardHeader>
    <CardTitle>Webサイトリニューアル</CardTitle>
    <CardDescription>進行中</CardDescription>
    <CardAction>{menu}</CardAction>
  </CardHeader>
  <CardContent>{owner}</CardContent>
</Card>
variantが向く変更Compositionが向く変更
色、枠線、密度、サイズ子要素の追加、削除、並び替え
同じ操作の強調度タイトル、本文、操作など役割の追加
同じDOM構造の見た目Context、状態、業務フローの追加

実装チェックリスト

  • 既存コンポーネントで表現できないか確認した
  • 複合コンポーネントの親子構造をツリーで確認した
  • TriggerやCloseでbuttonが二重になっていない
  • asChildまたはSlotへ有効な子要素を1つだけ渡した
  • variantに構造や業務ロジックを詰め込んでいない
  • /server、/client、/formを責務に合わせて選んだ
  • Client境界を必要な範囲に限定した
  • data-slotと実DOMを確認した
  • 色や余白の追加が必要な場合も、Caroa UIのセマンティックトークンを使った