Drop the category kicker from the 83 sidebar Stories (kicker is now optional; hidden verification Stories keep it) and shorten or remove repeated, obvious or internal page/section descriptions. Required specs, limits, virtual-data and undecided-value notices stay. Story IDs, exports, fixtures, shared state notes and package code are unchanged. Record: docs/reports/storybook-ui-simplification-2026-10-22.md
174 lines
8.0 KiB
TypeScript
174 lines
8.0 KiB
TypeScript
import { useMemo, useState } from "react"
|
|
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
import {
|
|
Card, CardContent, PageHeader, PageHeaderContent, PageHeaderTitle, PageHeaderDescription,
|
|
Badge,
|
|
Button,
|
|
Combobox,
|
|
DataTable,
|
|
DataTableColumnHeader,
|
|
DataTableViewOptions,
|
|
DatePicker,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
FilterBar,
|
|
FilterBarActions,
|
|
FilterBarSearch,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
dataTableSelectColumn,
|
|
toast,
|
|
useDataTable,
|
|
type DataTableColumnDef,
|
|
} from "@hm-design/ui"
|
|
import "../list-screen.css"
|
|
import { MoreHorizontalIcon } from "lucide-react"
|
|
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
|
import { formatDate, sameDay, sampleUsers, statusLabel, type SampleUser } from "./sample-users"
|
|
|
|
// Data Table is a pattern: the HM Table primitive driven by TanStack Table v9 (useDataTable). Columns and data stay referentially stable
|
|
// (module scope / useMemo). Row actions use the HM Dropdown Menu and report through Sonner `toast` (Story composition only).
|
|
|
|
const statusBadge = (status: SampleUser["status"]) => (
|
|
<Badge variant={status === "active" ? "default" : status === "pending" ? "secondary" : "outline"}>{statusLabel[status]}</Badge>
|
|
)
|
|
|
|
const columnLabels = { name: "이름", department: "부서", status: "상태", createdAt: "등록일" }
|
|
|
|
const columns: DataTableColumnDef<SampleUser>[] = [
|
|
dataTableSelectColumn<SampleUser>(),
|
|
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="이름" /> },
|
|
{ accessorKey: "department", header: "부서" },
|
|
{
|
|
accessorKey: "status",
|
|
header: "상태",
|
|
cell: ({ row }) => statusBadge(row.original.status),
|
|
filterFn: "equalsString",
|
|
enableSorting: false,
|
|
},
|
|
{
|
|
accessorKey: "createdAt",
|
|
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
|
|
cell: ({ row }) => formatDate(row.original.createdAt),
|
|
filterFn: (row, columnId, value: Date) => sameDay(row.getValue<string>(columnId), value),
|
|
},
|
|
{
|
|
id: "actions",
|
|
enableHiding: false,
|
|
cell: ({ row }) => (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button type="button" variant="primary" appearance="outline" size="icon-sm" aria-label={`${row.original.name} 작업`}>
|
|
<MoreHorizontalIcon />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onSelect={() => toast(`${row.original.name} 보기`)}>보기</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => toast(`${row.original.name} 수정`)}>수정</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem variant="destructive" onSelect={() => toast.error(`${row.original.name} 삭제 요청`)}>
|
|
삭제
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
),
|
|
},
|
|
]
|
|
|
|
function Basic({ data = sampleUsers, loading }: { data?: SampleUser[]; loading?: boolean }) {
|
|
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
|
|
return (
|
|
<div className="grid w-full gap-3">
|
|
<div className="flex justify-end">
|
|
<DataTableViewOptions table={table} labels={columnLabels} />
|
|
</div>
|
|
<DataTable table={table} loading={loading} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const statusOptions = [
|
|
{ value: "active", label: "활성" },
|
|
{ value: "pending", label: "대기" },
|
|
{ value: "inactive", label: "비활성" },
|
|
]
|
|
const departmentOptions = [...new Set(sampleUsers.map((u) => u.department))].map((d) => ({ value: d, label: d }))
|
|
|
|
// Filter Bar → filter state → Data Table: search is the TanStack global filter, status / date are column filters.
|
|
function WithFilterBar({ extended = true }: { extended?: boolean }) {
|
|
const data = useMemo(() => sampleUsers, [])
|
|
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
|
|
const [search, setSearch] = useState("")
|
|
const [status, setStatus] = useState("all")
|
|
const [date, setDate] = useState<Date | undefined>()
|
|
const [department, setDepartment] = useState<string | undefined>()
|
|
const apply = () => {
|
|
table.setGlobalFilter(search || undefined)
|
|
table.getColumn("status")?.setFilterValue(status === "all" ? undefined : status)
|
|
table.getColumn("department")?.setFilterValue(department)
|
|
table.getColumn("createdAt")?.setFilterValue(date)
|
|
}
|
|
const reset = () => {
|
|
setSearch(""); setStatus("all"); setDate(undefined); setDepartment(undefined)
|
|
table.resetGlobalFilter(true); table.resetColumnFilters(true)
|
|
}
|
|
return <div className="grid min-w-0 gap-4">
|
|
<PageHeader><PageHeaderContent><PageHeaderTitle>사용자 목록</PageHeaderTitle><PageHeaderDescription>조건을 입력한 뒤 조회합니다.</PageHeaderDescription></PageHeaderContent></PageHeader>
|
|
<Card className="min-w-0"><CardContent>
|
|
<div className="grid min-w-0 gap-4">
|
|
<div className="hm-data-table-tools">
|
|
<form className="hm-data-table-search" onSubmit={event => { event.preventDefault(); apply() }}>
|
|
<FilterBar className={extended ? "hm-list-search-extended" : "hm-list-search-basic"}>
|
|
<Select value={status} onValueChange={setStatus}>
|
|
<SelectTrigger size="medium" aria-label="상태"><SelectValue placeholder="상태" /></SelectTrigger>
|
|
<SelectContent><SelectItem value="all">전체 상태</SelectItem>{statusOptions.map(option => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
{extended && <><Combobox controlled size="medium" aria-label="부서" options={departmentOptions} value={department} placeholder="부서" onValueChange={setDepartment} />
|
|
<DatePicker controlled size="medium" aria-label="등록일" placeholder="등록일" value={date} onValueChange={setDate} /></>}
|
|
<FilterBarSearch value={search} onValueChange={setSearch} placeholder="이름·부서 검색" />
|
|
<FilterBarActions><Button type="submit" variant="primary" appearance="solid">조회</Button><Button type="button" variant="cancel" appearance="outline" onClick={reset}>초기화</Button></FilterBarActions>
|
|
</FilterBar>
|
|
</form>
|
|
<div className="hm-data-table-view-options"><DataTableViewOptions table={table} labels={columnLabels} /></div>
|
|
</div>
|
|
<DataTable table={table} emptyMessage="조건에 맞는 사용자가 없습니다." />
|
|
</div>
|
|
</CardContent></Card>
|
|
</div>
|
|
}
|
|
|
|
const meta = { title: "Patterns/Data/Data Table", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
|
|
export default meta
|
|
type Story = StoryObj<typeof meta>
|
|
|
|
// Representative Story: selection (mixed header), sorting, column visibility, row actions and pagination with page size.
|
|
export const All: Story = {
|
|
name: "Data Table",
|
|
parameters: { layout: "fullscreen" },
|
|
render: () => (
|
|
<StoryPage>
|
|
<StoryPageHeader
|
|
title="Data Table"
|
|
description="선택 · 정렬 · 열 표시 · 페이지 이동"
|
|
/>
|
|
<StorySection title="Overview">
|
|
<Basic />
|
|
</StorySection>
|
|
<StorySection title="기본 목록 검색" description="8px 간격 가운데 정렬 · 열 버튼은 공간 부족 시만 다음 행 · 제목은 Card 밖"><WithFilterBar extended={false} /></StorySection>
|
|
<StorySection title="추가 조건 목록 검색" description="부서·등록일 구성은 왼쪽 정렬 · 모바일 버튼 행은 우측 정렬"><WithFilterBar /></StorySection>
|
|
</StoryPage>
|
|
),
|
|
}
|
|
|
|
// Combination Story: Filter Bar (search, status, department, date, reset) filtering the Data Table rows.
|
|
export const WithFilterBarStory: Story = { tags: ["verification"], name: "With Filter Bar", render: () => <WithFilterBar /> }
|
|
|
|
export const Loading: Story = { tags: ["verification"], render: () => <Basic loading /> }
|
|
export const Empty: Story = { tags: ["verification"], render: () => <Basic data={[]} /> }
|