Files
HMDesign/src/components/table.stories.tsx
T

171 lines
6.8 KiB
TypeScript

import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Badge, Checkbox, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Table is a static display primitive, so the representative Story is a board of specimens. Checkbox and Badge appear only as
// cell content (Story composition); Table has no dependency on them and no selection, sorting or paging behavior.
// The Story keeps the selected rows in local state and passes `data-state="selected"` itself.
const projects = [
{ id: "P-001", name: "한맥 노선 정비", status: "진행", amount: 1280000 },
{ id: "P-002", name: "시설물 점검 리포트", status: "완료", amount: 450000 },
{ id: "P-003", name: "교량 안전 진단", status: "대기", amount: 9800000 },
]
const won = (n: number) => n.toLocaleString("ko-KR")
function BasicTable() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>번호</TableHead>
<TableHead>프로젝트</TableHead>
<TableHead>상태</TableHead>
<TableHead className="text-right">금액</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.id}</TableCell>
<TableCell>{p.name}</TableCell>
<TableCell>
<Badge>{p.status}</Badge>
</TableCell>
<TableCell className="text-right">{won(p.amount)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>합계</TableCell>
<TableCell className="text-right">{won(projects.reduce((s, p) => s + p.amount, 0))}</TableCell>
</TableRow>
</TableFooter>
<TableCaption>프로젝트별 계약 금액(원)</TableCaption>
</Table>
)
}
function SelectableTable() {
const [selected, setSelected] = useState<string[]>(["P-002"])
const toggle = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
aria-label="전체 선택"
checked={selected.length === projects.length ? true : selected.length ? "indeterminate" : false}
onCheckedChange={(v) => setSelected(v ? projects.map((p) => p.id) : [])}
/>
</TableHead>
<TableHead>프로젝트</TableHead>
<TableHead className="text-right">금액</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((p) => (
<TableRow key={p.id} data-state={selected.includes(p.id) ? "selected" : undefined}>
<TableCell>
<Checkbox aria-label={`${p.name} 선택`} checked={selected.includes(p.id)} onCheckedChange={() => toggle(p.id)} />
</TableCell>
<TableCell>{p.name}</TableCell>
<TableCell className="text-right">{won(p.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function LongTextTable() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>제목</TableHead>
<TableHead>비고</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>긴 텍스트는 셀 안에서 줄바꿈되고 행 높이만 늘어납니다. 공백 없는 문자열도 열 폭을 밀어내지 않습니다.</TableCell>
<TableCell>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</TableCell>
</TableRow>
<TableRow>
<TableCell>짧은 행</TableCell>
<TableCell>-</TableCell>
</TableRow>
</TableBody>
</Table>
)
}
function WideTable() {
const cols = Array.from({ length: 10 }, (_, i) => `컬럼 ${i + 1}`)
return (
<Table style={{ minWidth: 1100 }}>
<TableHeader>
<TableRow>
{cols.map((c) => (
<TableHead key={c}>{c}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 3 }, (_, r) => (
<TableRow key={r}>
{cols.map((c) => (
<TableCell key={c}>{`값 ${r + 1}-${c.slice(3)}`}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)
}
const box = (node: ReactNode) => <div style={{ width: "100%", maxWidth: 720, minWidth: 0 }}>{node}</div>
const sections = [
{ id: "basic", label: "Basic + Badge + numeric + footer + caption", node: <BasicTable /> },
{ id: "selected", label: "Selected row", note: "Checkbox와 data-state=\"selected\"는 Story가 조합합니다(Table에는 선택 로직이 없음)", node: <SelectableTable /> },
{ id: "long", label: "Long text", node: <LongTextTable /> },
{ id: "wide", label: "Horizontal overflow", note: "container가 가로로 scroll 하고 페이지는 밀리지 않습니다", node: <WideTable /> },
]
const meta = { title: "Components/Data & Display/Table", component: Table, parameters: { layout: "padded" } } satisfies Meta<typeof Table>
export default meta
type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, wide: true, children: box(s.node) }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
export const All: Story = {
name: "Table",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.components}
title="Table"
description="행과 열로 데이터를 보여주는 정적 표시 primitive입니다. 정렬·선택·페이지 로직은 Data Table Pattern이 담당합니다."
/>
<StorySection title="Overview">{board("basic")}</StorySection>
<StorySection title="States">{board("selected")}</StorySection>
<StorySection title="Usage" description="긴 텍스트와 가로 넘침입니다.">{board("long", "wide")}</StorySection>
</StoryPage>
),
}
export const Basic: Story = { tags: ["verification"], render: () => box(<BasicTable />) }
export const Selected: Story = { tags: ["verification"], render: () => box(<SelectableTable />) }
export const LongText: Story = { tags: ["verification"], name: "Long Text", render: () => box(<LongTextTable />) }
export const Overflow: Story = { tags: ["verification"], name: "Horizontal Overflow", render: () => box(<WideTable />) }