Files
HMDesign/src/components/tabs.stories.tsx
T
shkim4 205864b8a0
Deploy Storybook / deploy (push) Successful in 1m13s
fix(sw): align header logo styles and refine viewer UI
2026-10-23 09:55:13 +09:00

228 lines
12 KiB
TypeScript

import type { Meta, StoryObj } from "@storybook/react-vite"
import { useId, useState, type ComponentProps, type CSSProperties } 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>
{/* 2026-10-23: this panel can be narrower than the Table default 520px minimum (--hm-table-min-width), which made the
table container scroll. The documented token is reset for this panel only: the table follows the panel width
and long cell text wraps (Table overflow-wrap). */}
<TabsContent value="schedule" className="px-0 py-6" style={{ "--hm-table-min-width": "0px" } as CSSProperties}>
<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>
),
}