- Sheet + Drawer, Tooltip + Hover Card, Accordion + Collapsible, Dialog + Alert Dialog, Dropdown Menu + Context Menu on one page each (specimens moved to *-examples.tsx; absorbed Stories keep their IDs) - Popover, Tabs content spacing, Breadcrumb, Menubar and Command tidied - Component docs updated to the new representative pages
239 lines
14 KiB
TypeScript
239 lines
14 KiB
TypeScript
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||
import { useId, useState, type ComponentProps, type CSSProperties, type ReactNode } 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, type BoardState } 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.
|
||
// Fill panels use the actual parent surface without a nested box. The representative page sets its own content padding.
|
||
// 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
|
||
|
||
const previewPanelSpacing = "px-4 pt-3 pb-6"
|
||
|
||
function Specimen({
|
||
variant = "primary",
|
||
defaultValue = "menu2",
|
||
disabledSecond,
|
||
count = 3,
|
||
labels,
|
||
listProps,
|
||
panelSpacing = "px-0 py-6",
|
||
}: {
|
||
variant?: Variant
|
||
defaultValue?: string
|
||
disabledSecond?: boolean
|
||
count?: number
|
||
labels?: string[]
|
||
listProps?: ListProps
|
||
panelSpacing?: string
|
||
}) {
|
||
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" : `${panelSpacing} 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("")
|
||
// 2026-11-13: both examples use the same panel inset (16 / 12 / 24). In the Card, the Card / CardContent padding stays as is;
|
||
// the extra inset applies only to the panel below the tab header, so the header keeps its size and position.
|
||
const panelSpacing = previewPanelSpacing
|
||
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={panelSpacing}>
|
||
<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={panelSpacing} 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>
|
||
)
|
||
}
|
||
|
||
// 2026-11-13 (user request): Variants, Behavior and Usage share one grid, equal two columns (24px gap) when the section's
|
||
// actual content width is 720px or more, one column below. The breakpoint is a container query on the wrapper (its content box,
|
||
// after the common board indent), so it follows the Storybook preview's usable width, not the iframe viewport.
|
||
// Each board holds a single specimen: StateBoard's own auto-fill grid would leave an empty column for two items, so `min` is
|
||
// forced above any column width (same approach as Button). A single child (Behavior) takes the left column; `min-w-0` lets the
|
||
// Overflow Tabs keep its own internal scroll (previous / next buttons) inside its column instead of widening the page.
|
||
const TwoColumns = ({ children }: { children: ReactNode }) => (
|
||
<div className="@container min-w-0">
|
||
<div className="grid items-start gap-6 @min-[720px]:grid-cols-[repeat(2,minmax(0,1fr))]">{children}</div>
|
||
</div>
|
||
)
|
||
const SingleBoard = ({ states }: { states: BoardState[] }) => <StateBoard min={10000} states={states} />
|
||
|
||
const meta = {
|
||
title: "Components/Navigation/Tabs",
|
||
component: Tabs,
|
||
parameters: {
|
||
layout: "padded",
|
||
docs: {
|
||
description: {
|
||
component:
|
||
"기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 Variants에서 기본 Primary와 Secondary(fill + panelSurface=\"surface\")를 비교하고 Behavior에서 Primary Overflow를 확인하며 명시적 Primary는 숨김 Default API에서 확인합니다. 대표 Story의 평면 패널은 좌우 16px, 상단 12px, 하단 24px 여백을 사용합니다. Usage의 Card 예제도 기존 Card 여백은 그대로 두고 탭 헤더 아래 패널에 같은 좌우 16px, 상단 12px, 하단 24px 여백을 적용합니다. 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: () => (
|
||
// The common compact Story page (16–24px gutter, 28px section gap) fits this page on a 900px-high PC screen without an override.
|
||
<StoryPage>
|
||
<StoryPageHeader title="Tabs" />
|
||
<StorySection title="Variants">
|
||
<TwoColumns>
|
||
<SingleBoard states={[{ id: "primary", label: "Primary", children: <Specimen panelSpacing={previewPanelSpacing} /> }]} />
|
||
<SingleBoard states={[{ id: "secondary", label: "Secondary", children: <Specimen variant="secondary" panelSpacing={previewPanelSpacing} /> }]} />
|
||
</TwoColumns>
|
||
</StorySection>
|
||
<StorySection title="Behavior">
|
||
<TwoColumns>
|
||
<SingleBoard states={[{ id: "overflow", label: "Overflow", children: <div className="w-full min-w-0"><Specimen count={20} panelSpacing={previewPanelSpacing} /></div> }]} />
|
||
</TwoColumns>
|
||
</StorySection>
|
||
<StorySection title="Usage">
|
||
<TwoColumns>
|
||
<SingleBoard states={[{ id: "usage-card", label: "Card 내부 · Secondary", children: <div className="w-full min-w-0 max-w-lg"><SurfaceUsage inCard /></div> }]} />
|
||
<SingleBoard states={[{ id: "usage-page", label: "페이지 직접 배치 · Primary", children: <div className="w-full min-w-0 max-w-lg"><SurfaceUsage /></div> }]} />
|
||
</TwoColumns>
|
||
</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>
|
||
),
|
||
}
|