Drop page / section descriptions, state notes and repeated captions from the representative Stories of 44 components, the Patterns, Center Graphic and OrgChart pages. Value readouts, spec lists and captions hide only on the representative page; hidden verification Stories keep them. Tabs Default API and the Filter Bar draft / applied counts move to the hidden DefaultApi and DraftApplied Stories; Center Overview is hidden with its direct URL kept. Story IDs are unchanged. Align the component, pattern and OrgChart docs with the pages, point the quality reference check at the DraftApplied Story, and make the P8 styles check expect no section header on the preview-only Expanded page.
225 lines
12 KiB
TypeScript
225 lines
12 KiB
TypeScript
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
import { useId, useState, type ComponentProps } from "react"
|
|
import {
|
|
Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea,
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
Tabs, TabsContent, TabsList, TabsTrigger,
|
|
} from "@hm-design/ui"
|
|
import { StateBoard } from "@/components/story-board"
|
|
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
|
|
|
// Representative "Tabs" Story shows default Primary, explicit API comparisons and Secondary; Legacy stays verification-only.
|
|
// D-01 applies to every Fill specimen: flat panels use the actual parent surface and padding 24px 0.
|
|
// D-02 is shipped by the public Tabs theme mapping (Primary top White alpha .8). Legacy stays compatibility-only.
|
|
|
|
type Variant = "primary" | "secondary"
|
|
type ListProps = Pick<ComponentProps<typeof TabsList>, "fill" | "panelSurface">
|
|
|
|
// Primary = Fill + panelSurface="content", Secondary = Fill + panelSurface="surface" (2026-10-06 사용자 결정).
|
|
const variantProps = {
|
|
primary: { listProps: {}, surface: "bg-background" },
|
|
secondary: { listProps: { fill: true, panelSurface: "surface" }, surface: "bg-card" },
|
|
} as const
|
|
|
|
function Specimen({
|
|
variant = "primary",
|
|
defaultValue = "menu2",
|
|
disabledSecond,
|
|
count = 3,
|
|
labels,
|
|
listProps,
|
|
}: {
|
|
variant?: Variant
|
|
defaultValue?: string
|
|
disabledSecond?: boolean
|
|
count?: number
|
|
labels?: string[]
|
|
listProps?: ListProps
|
|
}) {
|
|
const { listProps: defaults, surface } = variantProps[variant]
|
|
const effectiveListProps: ListProps = listProps ?? defaults
|
|
const legacy = effectiveListProps.fill === false
|
|
const items = labels?.map((label, i) => ({ value: `menu${i + 1}`, label }))
|
|
?? Array.from({ length: count }, (_, i) => ({ value: `menu${i + 1}`, label: `메뉴 ${i + 1}` }))
|
|
return (
|
|
<Tabs defaultValue={defaultValue} className={legacy ? undefined : `min-w-0 ${surface}`}>
|
|
<TabsList aria-label="업무 필터" {...effectiveListProps}>
|
|
{items.map(({ value, label }, i) => (
|
|
<TabsTrigger key={value} value={value} disabled={disabledSecond && i === 1}>
|
|
{label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
{items.map(({ value }, i) => (
|
|
<TabsContent key={value} value={value} className={legacy ? "rounded-b p-4 text-sm border bg-card" : "px-0 py-6 text-sm"}>
|
|
메뉴 {i + 1} 콘텐츠
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
)
|
|
}
|
|
|
|
const longLabels = ["기본 정보", "보상 협의 및 계약 진행 현황", "토지 및 지장물 조사 내역"]
|
|
|
|
// Component composition reference, not a new application screen. All visual controls use public HM exports.
|
|
// Input state lives above the panels so the draft survives Radix's inactive-panel unmount.
|
|
function SurfaceUsage({ inCard = false }: { inCard?: boolean }) {
|
|
const id = useId()
|
|
const [project, setProject] = useState("")
|
|
const [purpose, setPurpose] = useState("")
|
|
const tabs = (
|
|
<Tabs defaultValue="information" className="min-w-0">
|
|
<TabsList panelSurface={inCard ? "surface" : "content"} aria-label={inCard ? "Card 내부 프로젝트 정보" : "페이지 프로젝트 정보"}>
|
|
<TabsTrigger value="information">기본 정보</TabsTrigger>
|
|
<TabsTrigger value="schedule">일정·산출물</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="information" className="px-0 py-6">
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor={`${id}-project`}>프로젝트명 (필수)</Label>
|
|
<Input id={`${id}-project`} required value={project} onChange={event => setProject(event.target.value)} placeholder="프로젝트명을 입력하세요" />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor={`${id}-purpose`}>목적 및 배경</Label>
|
|
<Textarea id={`${id}-purpose`} value={purpose} onChange={event => setPurpose(event.target.value)} placeholder="프로젝트의 목적을 입력하세요" />
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent value="schedule" className="px-0 py-6">
|
|
<Table aria-label="프로젝트 산출물 일정">
|
|
<TableHeader><TableRow><TableHead scope="col">산출물</TableHead><TableHead scope="col">마감일</TableHead></TableRow></TableHeader>
|
|
<TableBody><TableRow><TableCell>화면 설계서</TableCell><TableCell>10월 14일</TableCell></TableRow></TableBody>
|
|
</Table>
|
|
</TabsContent>
|
|
</Tabs>
|
|
)
|
|
return inCard ? (
|
|
<Card className="min-w-0">
|
|
<CardHeader><CardTitle>프로젝트 정보</CardTitle></CardHeader>
|
|
<CardContent className="min-w-0">{tabs}</CardContent>
|
|
</Card>
|
|
) : <div className="min-w-0 bg-background">{tabs}</div>
|
|
}
|
|
|
|
function LongLabelSpecimens() {
|
|
return (
|
|
<div className="grid min-w-0 max-w-full gap-5">
|
|
<div className="min-w-0">
|
|
<p className="mb-2 text-xs text-muted-foreground">짧은 label</p>
|
|
<div className="w-full min-w-0 max-w-sm"><Specimen defaultValue="menu1" /></div>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="mb-2 text-xs text-muted-foreground">긴 label 1개</p>
|
|
<div className="w-full min-w-0 max-w-lg"><Specimen labels={["기본 정보", longLabels[1], "조사 내역"]} /></div>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="mb-2 text-xs text-muted-foreground">긴 label 여러 개</p>
|
|
<div className="w-full min-w-0 max-w-2xl"><Specimen labels={longLabels} /></div>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="mb-2 text-xs text-muted-foreground">좁은 container · nowrap · overflow controls</p>
|
|
<div className="w-full min-w-0 max-w-[260px]"><Specimen labels={longLabels} /></div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const meta = {
|
|
title: "Components/Navigation/Tabs",
|
|
component: Tabs,
|
|
parameters: {
|
|
layout: "padded",
|
|
docs: {
|
|
description: {
|
|
component:
|
|
"기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 기본 Primary, Secondary(fill + panelSurface=\"surface\"), Primary Overflow를 비교하며 명시적 Primary는 숨김 Default API에서 확인합니다. 모든 Fill 예제는 중첩 박스 없는 TabsContent(px-0 py-6)를 사용하며 Usage는 Card 내부 surface와 페이지 content 위의 조합입니다. panelSurface는 List의 선택 면만 바꾸므로 실제 상위 배경에 맞춰 선택합니다. Primary 선택 탭의 gradient는 상단 White alpha 0.8 → content이며 전체 opacity는 1입니다. Secondary는 기존 불투명 White를 유지합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Hover는 비선택에만 Black 20%이며 넘칠 때만 이전/다음 이동 버튼이 나타납니다. aria-label과 연결된 입력 Label을 제공하고, 방향키·Home/End·포커스·Disabled·모바일 내부 스크롤을 확인합니다. 곡선 반경 8px과 비선택 합성색의 과거 REVIEW / TEMPORARY 이력은 유지합니다.",
|
|
},
|
|
},
|
|
},
|
|
} satisfies Meta<typeof Tabs>
|
|
export default meta
|
|
type Story = StoryObj<typeof meta>
|
|
|
|
export const All: Story = {
|
|
name: "Tabs",
|
|
parameters: { layout: "fullscreen", controls: { disable: true } },
|
|
render: () => (
|
|
<StoryPage>
|
|
<StoryPageHeader title="Tabs" />
|
|
<StorySection title="Overview">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "primary", label: "Primary", children: <Specimen /> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="Variants">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "secondary", label: "Secondary", children: <Specimen variant="secondary" /> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="States">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "overflow", wide: true, label: "Overflow", children: <div className="w-full min-w-0 max-w-4xl"><Specimen count={20} /></div> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="Usage">
|
|
<StateBoard states={[
|
|
{ id: "usage-card", label: "Card 내부 · Secondary", children: <SurfaceUsage inCard /> },
|
|
{ id: "usage-page", label: "페이지 직접 배치 · Primary", children: <SurfaceUsage /> },
|
|
]} />
|
|
</StorySection>
|
|
</StoryPage>
|
|
),
|
|
}
|
|
|
|
// Verification: `fill`만, `fill` + `panelSurface="content"`를 명시해도 기본 Primary와 같다(대표 페이지의 이전 Default API 구역).
|
|
export const DefaultApi: Story = {
|
|
tags: ["verification"],
|
|
name: "Default API",
|
|
render: () => (
|
|
<div className="grid max-w-md gap-6">
|
|
<Specimen listProps={{ fill: true }} />
|
|
<Specimen listProps={{ fill: true, panelSurface: "content" }} />
|
|
</div>
|
|
),
|
|
}
|
|
|
|
// Verification: Primary 기준 상태와 Secondary 비교. Secondary도 같은 CSS 경로를 쓴다.
|
|
export const Secondary: Story = { tags: ["verification"], render: () => <div className="max-w-md"><Specimen variant="secondary" /></div> }
|
|
export const SelectedFirst: Story = { tags: ["verification"], name: "Selected First", render: () => <div className="max-w-md"><Specimen defaultValue="menu1" /></div> }
|
|
export const SelectedLast: Story = { tags: ["verification"], name: "Selected Last", render: () => <div className="max-w-md"><Specimen defaultValue="menu3" /></div> }
|
|
export const Focus: Story = {
|
|
tags: ["verification"],
|
|
render: () => <div className="max-w-md"><Specimen /></div>,
|
|
play: async ({ canvasElement }) => canvasElement.querySelector<HTMLButtonElement>('[role="tab"][data-state="active"]')?.focus(),
|
|
}
|
|
export const Disabled: Story = { tags: ["verification"], render: () => <div className="max-w-md"><Specimen disabledSecond /></div> }
|
|
export const Overflow: Story = { tags: ["verification"], render: () => <div className="max-w-[260px]"><Specimen count={8} /></div> }
|
|
export const SecondaryOverflow: Story = { tags: ["verification"], name: "Secondary Overflow", render: () => <div className="max-w-[260px]"><Specimen variant="secondary" count={8} /></div> }
|
|
export const LongLabel: Story = { tags: ["verification"], name: "Long Label", render: () => <LongLabelSpecimens /> }
|
|
|
|
export const Compatibility: Story = {
|
|
tags: ["verification"],
|
|
render: () => (
|
|
<StoryPage>
|
|
<StoryPageHeader kicker="VERIFICATION ONLY" title="Tabs Compatibility" description="Legacy non-fill은 OLD_TEST_ONLY / REMOVE_CANDIDATE 이력을 보존하며 명시적으로 사용할 수 있습니다. 사용자 별도 승인 없이 삭제하지 않습니다." />
|
|
<StorySection title="Legacy non-fill">
|
|
<StateBoard states={[
|
|
{ id: "legacy", label: "TabsList fill={false}", noteAfter: true, note: "panelSurface 기본 content는 non-fill 시각에 영향을 주지 않습니다. 패널은 기존 White surface를 유지합니다.", children: <Specimen variant="secondary" listProps={{ fill: false }} /> },
|
|
{ id: "legacy-surface", label: "TabsList fill={false} panelSurface surface", noteAfter: true, note: "이전 기본값을 모두 명시한 경우에도 같은 Legacy 디자인입니다.", children: <Specimen variant="secondary" listProps={{ fill: false, panelSurface: "surface" }} /> },
|
|
]} />
|
|
</StorySection>
|
|
<StorySection title="Explicit panelSurface">
|
|
<StateBoard states={[
|
|
{ id: "surface-only", label: "TabsList panelSurface surface", noteAfter: true, note: "fill 생략 → true이므로 Secondary가 됩니다.", children: <Specimen variant="secondary" listProps={{ panelSurface: "surface" }} /> },
|
|
]} />
|
|
</StorySection>
|
|
</StoryPage>
|
|
),
|
|
}
|