カンバンボードKanbanBoardExperimental

フラットな項目を列へ分類し、件数、空列、選択可能カードを含むカンバンとして表示します。

プレビュー

未着手
1
進行中
3
レビュー
1
完了
1

状態とバリエーション

選択可能カード

onCardSelect を渡すとカードがボタンになります。

未着手
1
進行中
3
レビュー
1
完了
1

空列

該当カードがない列には emptyLabel を表示します。

未着手
1
進行中
3
レビュー
0

なし

完了
1

件数なし

列数が十分に少ない場合は showCount={false} で件数を隠せます。

未着手
進行中
レビュー
完了

プロパティ

表は横にスクロールできます
プロパティ初期値説明
columnsKanbanColumn[]-順序付きの列です。tone は見出しのアクセント点に使います。
itemsT[]-列分け前のフラットなカード配列です。
getItemId(item: T) => string-カードの安定したキーを返します。
getColumnId(item: T) => string-カードが入る列 id を返します。
renderCard(item: T) => ReactNode-カード本体を描画します。関数propのため Client Component からのみ渡すこと(Server Component から渡すと next build が落ちる)。JSX を返すため serializable な代替は無く、RSC からは "use client" ラッパーで包む。(#338)
onCardSelect(item: T) => void-渡すとカードがボタンになり、クリックやキーボードで選択できます。
showCountbooleantrue列見出しに件数 Badge を表示します。
emptyLabelReactNode"なし"空列のプレースホルダーです。
columnWidthnumber260横スクロール内の列幅です。

使い方

import * as React from "react";
import {
  Badge,
  KanbanBoard,
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
  type KanbanColumn,
} from "@gunjo/ui";

type Task = { id: string; status: string; title: string; assignee: string; priority: string; due: string; summary: string };

const columns: KanbanColumn[] = [
  { id: "todo", title: "未着手", tone: "muted" },
  { id: "doing", title: "進行中", tone: "info" },
  { id: "review", title: "レビュー", tone: "warning" },
  { id: "done", title: "完了", tone: "success" },
];

const tasks: Task[] = [
  {
    id: "t1",
    status: "todo",
    title: "申請フォームの入力確認",
    assignee: "青井",
    priority: "通常",
    due: "7月10日",
    summary: "必須項目、エラー文言、入力補助の整合性を確認します。",
  },
  {
    id: "t2",
    status: "doing",
    title: "通知メールの文面調整",
    assignee: "三宅",
    priority: "高",
    due: "7月8日",
    summary: "審査完了と差し戻し時のメール文面を最終確認します。",
  },
  {
    id: "t5",
    status: "doing",
    title: "権限変更ログの表示",
    assignee: "宇佐美",
    priority: "通常",
    due: "7月8日",
    summary: "管理者が確認できる変更履歴の表示項目を整理します。",
  },
  {
    id: "t6",
    status: "doing",
    title: "添付ファイルの容量確認",
    assignee: "青井",
    priority: "通常",
    due: "7月9日",
    summary: "アップロード上限とエラー表示が仕様どおりか確認します。",
  },
  {
    id: "t3",
    status: "review",
    title: "公開前チェックリスト",
    assignee: "黒川",
    priority: "高",
    due: "7月9日",
    summary: "公開前レビューの残項目と承認者を確認します。",
  },
  {
    id: "t4",
    status: "done",
    title: "権限ロールの棚卸し",
    assignee: "宇佐美",
    priority: "通常",
    due: "完了",
    summary: "既存ロールの利用状況を整理し、不要な権限を削除しました。",
  },
];

export function ReviewKanban() {
  const [selectedId, setSelectedId] = React.useState<string | null>(null);
  const [
    portalContainer,
    setPortalContainer,
  ] = React.useState<HTMLDivElement | null>(null);
  const selectedTask = selectedId ? tasks.find((task) => task.id === selectedId) : undefined;
  const selectedColumn = selectedTask ? columns.find((column) => column.id === selectedTask.status) : undefined;

  return (
    <div ref={setPortalContainer} className="relative flex w-full max-w-4xl flex-col gap-4 overflow-hidden rounded-lg border bg-card p-4">
      <KanbanBoard<Task>
        columns={columns}
        items={tasks}
        getItemId={(task) => task.id}
        getColumnId={(task) => task.status}
        emptyLabel="なし"
        onCardSelect={(task) => setSelectedId(task.id)}
        renderCard={({ title, assignee, priority }) => (
          <div className="flex flex-col gap-1">
            <span className="text-sm font-medium">{title}</span>
            <span className="text-xs text-muted-foreground">担当: {assignee}</span>
            <Badge variant={priority === "高" ? "destructive" : "secondary"}>{priority}</Badge>
          </div>
        )}
      />
      <Sheet modal={false} open={selectedTask != null} onOpenChange={(open) => !open && setSelectedId(null)}>
        <SheetContent
          portalContainer={portalContainer}
          overlayClassName="rounded-lg"
          closeLabel="閉じる"
          className="w-80 max-w-[calc(100%-1rem)] overflow-y-auto p-4"
        >
          {selectedTask ? (
            <>
              <SheetHeader className="pr-8">
                <SheetTitle>タスク詳細</SheetTitle>
              </SheetHeader>
              <div className="rounded-lg border border-l-4 border-warning-border bg-warning-subtle/65 p-3 text-sm shadow-sm">
                <div className="flex items-start justify-between gap-3">
                  <p className="min-w-0 truncate text-base font-semibold text-foreground">{selectedTask.title}</p>
                  <Badge variant={selectedTask.priority === "高" ? "destructive" : "outline"}>{selectedTask.priority}</Badge>
                </div>
                <p className="mt-2 text-sm text-muted-foreground">{selectedTask.summary}</p>
                <dl className="mt-3 grid gap-2 text-xs">
                  <div><dt className="text-muted-foreground">ステータス</dt><dd className="font-medium text-foreground">{selectedColumn?.title}</dd></div>
                  <div><dt className="text-muted-foreground">担当</dt><dd className="font-medium text-foreground">{selectedTask.assignee}</dd></div>
                  <div><dt className="text-muted-foreground">期限</dt><dd className="font-medium text-foreground">{selectedTask.due}</dd></div>
                </dl>
              </div>
            </>
          ) : null}
        </SheetContent>
      </Sheet>
    </div>
  );
}

使用コンポーネント