/** * Showcase · table-expandable-rows — 展開行 (V9) * * Expandable detail row pattern for a 勤怠 (attendance) admin list. Clicking a row * reveals an inline detail panel (border-left 3px primary) directly beneath it; * the toggle is EXCLUSIVE — opening one row collapses any other. * * Why compose from instead of : * DataTable.Content renders each row from the column defs and offers no slot to * inject an extra (the detail panel) between data rows, nor an expanded / * exclusive-open state. So the master list is built from the real low-level * Table family (Table / TableHeader / TableRow / TableHead / TableCell) — the * same primitives DataTable itself uses — and the panel is a spanning . * See gapNotes. Everything else (Badge tone, Button, Card, StatCard) is a real * @godxjp/ui primitive; no hand-rolled controls, tokens only. * * DNA: compact density, tabular-nums on numbers, fixed color signaling * (success 若竹 / warning 山吹 / attention 朱 via destructive tone reserved for * 欠勤), small headings, quiet JP copy, no emoji. */ import * as React from "react"; import { ChevronRight, Clock, Coffee, MapPin } from "lucide-react"; import { Badge, Card, CardAction, CardContent, CardHeader, CardTitle, StatCard, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, type BadgeProps, } from "@godxjp/ui/data-display"; import { Button, Text } from "@godxjp/ui/general"; import { Flex, PageContainer } from "@godxjp/ui/layout"; type BadgeTone = NonNullable; // ── Mock attendance data ────────────────────────────────────────────────────────── type ShiftStatus = "出勤" | "遅刻" | "早退" | "欠勤"; const STATUS_TONE: Record< ShiftStatus, Extract > = { 出勤: "success", 遅刻: "warning", 早退: "warning", 欠勤: "destructive", }; type DayLog = { date: string; weekday: string; status: ShiftStatus; clockIn: string; clockOut: string; break: string; work: string; note?: string; }; type Employee = { id: string; code: string; name: string; dept: string; site: string; workDays: number; lateCount: number; overtime: string; // 残業 累計 status: ShiftStatus; // 直近の状態 days: DayLog[]; }; const EMPLOYEES: Employee[] = [ { id: "E-1042", code: "1042", name: "佐藤 健太", dept: "製造一課", site: "埼玉センター", workDays: 19, lateCount: 0, overtime: "12:30", status: "出勤", days: [ { date: "06/02", weekday: "月", status: "出勤", clockIn: "08:58", clockOut: "18:05", break: "1:00", work: "8:07", }, { date: "06/03", weekday: "火", status: "出勤", clockIn: "08:55", clockOut: "19:40", break: "1:00", work: "9:45", note: "残業 1:40(出荷対応)", }, { date: "06/04", weekday: "水", status: "出勤", clockIn: "08:52", clockOut: "18:02", break: "1:00", work: "8:10", }, ], }, { id: "E-1043", code: "1043", name: "鈴木 美咲", dept: "品質管理課", site: "埼玉センター", workDays: 18, lateCount: 2, overtime: "03:10", status: "遅刻", days: [ { date: "06/02", weekday: "月", status: "出勤", clockIn: "09:00", clockOut: "18:00", break: "1:00", work: "8:00", }, { date: "06/03", weekday: "火", status: "遅刻", clockIn: "09:24", clockOut: "18:00", break: "1:00", work: "7:36", note: "電車遅延(承認待ち)", }, { date: "06/04", weekday: "水", status: "遅刻", clockIn: "09:18", clockOut: "18:05", break: "1:00", work: "7:47", note: "申請理由 未入力", }, ], }, { id: "E-1044", code: "1044", name: "高橋 大輔", dept: "物流課", site: "千葉センター", workDays: 17, lateCount: 1, overtime: "06:45", status: "早退", days: [ { date: "06/02", weekday: "月", status: "出勤", clockIn: "08:50", clockOut: "18:10", break: "1:00", work: "8:20", }, { date: "06/03", weekday: "火", status: "早退", clockIn: "08:55", clockOut: "15:30", break: "0:45", work: "5:50", note: "通院(承認済)", }, { date: "06/04", weekday: "水", status: "出勤", clockIn: "08:48", clockOut: "18:02", break: "1:00", work: "8:14", }, ], }, { id: "E-1045", code: "1045", name: "田中 由紀", dept: "総務課", site: "埼玉センター", workDays: 16, lateCount: 0, overtime: "00:00", status: "欠勤", days: [ { date: "06/02", weekday: "月", status: "出勤", clockIn: "08:59", clockOut: "17:45", break: "1:00", work: "7:46", }, { date: "06/03", weekday: "火", status: "欠勤", clockIn: "—", clockOut: "—", break: "—", work: "0:00", note: "有給休暇(承認済)", }, { date: "06/04", weekday: "水", status: "出勤", clockIn: "08:57", clockOut: "17:50", break: "1:00", work: "7:53", }, ], }, ]; // ── Shared cell padding to match compact DataTable density ───────────────────── const CELL = "align-middle"; function StatusBadge({ status }: { status: ShiftStatus }) { return ( {status} ); } // ── Inline detail panel (border-left 3px primary) ────────────────────────────── function DetailPanel({ employee }: { employee: Employee }) { return ( {employee.name} · {employee.dept} {/* KPI mini-row inside the panel — real StatCard primitives */} {employee.overtime}} layout="inline" /> {/* Per-day breakdown — quiet nested list, real Card chrome */} 今週の打刻
日付 状態 出勤 退勤 休憩 実働 備考 {employee.days.map((d) => ( {d.date}({d.weekday}) {d.clockIn} {d.clockOut} {d.work} {d.note ?? "—"} ))}
); } // ── Master list with exclusive expandable rows ───────────────────────────────── const COLSPAN = 6; // toggle + 従業員 + 部署 + 直近 + 出勤日数 + 残業 function ExpandableList() { // Exclusive open: a single id (or null) — opening one collapses any other. const [openId, setOpenId] = React.useState("E-1043"); const toggle = (id: string) => { setOpenId((cur) => (cur === id ? null : id)); }; return ( 従業員 部署 直近の状態 出勤日数 残業 累計 {EMPLOYEES.map((emp) => { const isOpen = openId === emp.id; const panelId = `row-detail-${emp.id}`; return ( {/* THE DISCLOSURE IS THE BUTTON, NOT THE ROW (gh#643, found by check:frame-axe). This row used to carry `tabIndex={0}` + `aria-expanded` + `aria-controls` and be the control itself. `aria-expanded` is valid on a TREEGRID row and not on a table row, so axe scored it `aria-conditional-attr` — and the row was a focus stop with no role and no name, which is the worse half of the same mistake. The chevron is now a real button: it owns the state, the name and the tab stop. Clicking the row still works, as a pointer convenience that claims no semantics of its own. */} { toggle(emp.id); }} className="hover:bg-muted/50 cursor-pointer" > {emp.name} {emp.id} · No.{emp.code} {emp.dept} {emp.workDays} {emp.overtime} {isOpen && ( {/* `flush` — the detail panel owns its own inset, so it spans the full cell width instead of being indented by the cell padding. */} )} ); })}
); } export default function Demo() { return ( 従業員の勤怠(今週) 4 名 · 06/04 時点 行を選択すると直下に詳細パネル(左罫線 3px primary)が開きます。別の行を開くと前の行は自動的に閉じます。 キーボードでは行に Tab で移動し、Enter / Space で開閉できます。 ); }