197 lines
8.2 KiB
TypeScript
197 lines
8.2 KiB
TypeScript
import { useState, type CSSProperties, 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 { 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")
|
|
|
|
// `showCaption={false}` drops the visible caption on the representative page; the caption text becomes the table's
|
|
// accessible name. The verification Story keeps the TableCaption.
|
|
function BasicTable({ showCaption = true }: { showCaption?: boolean }) {
|
|
return (
|
|
<Table aria-label={showCaption ? undefined : "프로젝트별 계약 금액(원)"}>
|
|
<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>
|
|
{showCaption ? <TableCaption>프로젝트별 계약 금액(원)</TableCaption> : null}
|
|
</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", node: <BasicTable showCaption={false} /> },
|
|
{ id: "selected", label: "Selected row", node: <SelectableTable /> },
|
|
{ id: "long", label: "Long text", node: <LongTextTable /> },
|
|
{ id: "wide", label: "Horizontal overflow", 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>
|
|
|
|
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
|
// (400px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
|
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
|
// The default 520px Table minimum would make the 445px columns scroll sideways, so Basic, Selected row and Long text reset the
|
|
// documented `--hm-table-min-width` token for their own cell (same as the Tabs usage panel): the table then follows its column
|
|
// and long text wraps there. Horizontal overflow keeps its inline `minWidth: 1100`, so it still overflows and scrolls inside its box.
|
|
const specimenColumns = {
|
|
2: "min-[1000px]:grid-cols-2",
|
|
3: "min-[1000px]:grid-cols-3",
|
|
4: "min-[1000px]:grid-cols-4",
|
|
} as const
|
|
|
|
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
|
|
return (
|
|
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,400px),1fr))] ${specimenColumns[columns]}`}>
|
|
{ids.map((id) => {
|
|
const s = sections.find((section) => section.id === id)!
|
|
return (
|
|
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
|
|
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.id === "wide" ? box(s.node) : <div style={{ "--hm-table-min-width": "0px" } as CSSProperties}>{box(s.node)}</div> }]} />
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Representative Story: the common Story page; the sections group the same specimens.
|
|
// Representative Story: Overview / States / Usage are regrouped into Variants (Basic, Selected row) and Usage (Long text,
|
|
// Horizontal overflow), two equal columns each on PC.
|
|
export const All: Story = {
|
|
name: "Table",
|
|
parameters: { layout: "fullscreen", controls: { disable: true } },
|
|
render: () => (
|
|
<StoryPage>
|
|
<StoryPageHeader title="Table" />
|
|
<StorySection title="Variants">
|
|
<SpecimenGrid ids={["basic", "selected"]} columns={2} />
|
|
</StorySection>
|
|
<StorySection title="Usage">
|
|
<SpecimenGrid ids={["long", "wide"]} columns={2} />
|
|
</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 />) }
|