173 lines
9.3 KiB
TypeScript
173 lines
9.3 KiB
TypeScript
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
import type { ComponentProps } from "react"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@hm-design/ui"
|
|
import { StateBoard } from "@/components/story-board"
|
|
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
|
|
|
// Representative "Tabs" Story shows default Primary, explicit API comparisons and Secondary; Legacy stays verification-only.
|
|
// Selected connects to the content surface, so every specimen puts the Tabs on a plain bordered panel supplied by the consumer
|
|
// (TabsContent here). The panel is layout, not part of Tabs.
|
|
|
|
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: {}, panel: "border bg-background" },
|
|
secondary: { listProps: { fill: true, panelSurface: "surface" }, panel: "border 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, panel } = variantProps[variant]
|
|
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}>
|
|
<TabsList aria-label="업무 필터" {...(listProps ?? defaults)}>
|
|
{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={`rounded-b p-4 text-sm ${panel}`}>
|
|
메뉴 {i + 1} 콘텐츠
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
)
|
|
}
|
|
|
|
const longLabels = ["기본 정보", "보상 협의 및 계약 진행 현황", "토지 및 지장물 조사 내역"]
|
|
|
|
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는 기본 API와 명시적 Primary, Secondary(fill + panelSurface=\"surface\"), Primary Overflow를 비교합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Selected는 패널 배경과 이어지고 Hover는 비선택에만 Black 20%입니다. 넘칠 때만 이전/다음 이동 버튼이 나타납니다. 곡선 반경 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
|
|
kicker={STORY_KICKER.components}
|
|
title="Tabs"
|
|
description="같은 영역의 콘텐츠를 전환하는 탭입니다. 선택된 탭은 하단으로 콘텐츠 면과 이어지며, 넘칠 때만 이전/다음 이동 버튼이 나타납니다."
|
|
/>
|
|
<StorySection title="Overview">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "primary", label: "Primary · 기본 TabsList", noteAfter: true, note: "fill / panelSurface 생략 → Primary. 선택 면이 위 White → 아래 content panel색 gradient로 콘텐츠와 이어집니다. 비선택 탭에 hover하면 Black 20% overlay가 적용됩니다.", children: <Specimen /> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="Default API">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "primary-fill", label: "Primary · TabsList fill", noteAfter: true, note: "panelSurface 생략 → content. 기본 TabsList와 같은 Primary입니다.", children: <Specimen listProps={{ fill: true }} /> },
|
|
{ id: "primary-explicit", label: "Primary · fill + panelSurface content", noteAfter: true, note: "두 Props를 명시해도 기본 API와 같은 Primary입니다.", children: <Specimen listProps={{ fill: true, panelSurface: "content" }} /> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="Variants">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "secondary", label: "Secondary · Fill + panelSurface surface", noteAfter: true, note: "서브형. Surface(White) 위에서 사용하며 선택 면이 White로 이어집니다.", children: <Specimen variant="secondary" /> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="States">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "overflow", label: "Overflow · Primary + 이동 버튼", noteAfter: true, note: "Primary Tabs에 탭이 많거나 가용 폭이 좁으면 이전/다음 이동 버튼이 나타납니다. 버튼은 선택을 바꾸지 않고 목록만 한 항목씩 이동합니다. 260px 폭은 Story wrapper의 검증 폭이며 Tabs 자체 폭이 아닙니다.", children: <div className="max-w-[260px]"><Specimen count={8} /></div> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
</StoryPage>
|
|
),
|
|
}
|
|
|
|
// 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>
|
|
),
|
|
}
|