style(storybook): group overlay, disclosure and navigation story pages

- 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
This commit is contained in:
shkim4 committed 2026-11-13 17:57:53 +09:00
1 parent 71d504407d
commit bad2acfc49
35 files changed
+649 -521

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.sb-page.sb-accordion-story {
--sb-page-section-gap: 24px;
}
.sb-accordion-story .sb-page-section-body > [data-sb-board] {
/* Keep empty tracks aligned and cap the shared board at three columns. */
grid-template-columns: repeat(auto-fill, minmax(min(100%, max(320px, (100% - 48px) / 3)), 1fr));
}
+37 -30
View File
@@ -1,8 +1,16 @@
import type { ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import {
ClosedSpecimen as CollapsibleClosed,
ControlledSpecimen as CollapsibleControlled,
DisabledSpecimen as CollapsibleDisabled,
LongSpecimen as CollapsibleLong,
OpenSpecimen as CollapsibleOpen,
} from "./collapsible-examples"
import "./accordion-story.css"
// The representative "Accordion" Story puts each Radix mode on one page with real, clickable items. The same sections also exist
// as Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts). Modes use the Radix API
@@ -102,45 +110,44 @@ function LongSpecimen() {
)
}
const sections = [
{ id: "single", label: "Single", node: <SingleSpecimen /> },
{ id: "collapsible-single", label: "Collapsible Single", node: <CollapsibleSingleSpecimen /> },
{ id: "multiple", label: "Multiple", node: <MultipleSpecimen /> },
{ id: "disabled", label: "Disabled Item", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
]
// No `component` here: Accordion props are a union (single | multiple), so Stories without args would not type-check.
const meta = { title: "Components/Disclosure/Accordion", parameters: { layout: "padded" } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column
// below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title, divider and
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
// The combined disclosure page uses the same specimens as the separate verification Stories.
export const All: Story = {
name: "Accordion",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPage className="sb-accordion-story">
<StoryPageHeader title="Accordion" />
<StorySection title="Variants">{board(3, "single", "collapsible-single", "multiple")}</StorySection>
<StorySection title="States">{board(3, "disabled", "long")}</StorySection>
<StorySection title="Variants">
<StateBoard states={[
{ id: "single", label: "Single", children: <SingleSpecimen /> },
{ id: "collapsible-single", label: "Collapsible Single", children: <CollapsibleSingleSpecimen /> },
{ id: "multiple", label: "Multiple", children: <MultipleSpecimen /> },
]} />
</StorySection>
<StorySection title="States">
<StateBoard states={[
{ id: "disabled", label: "Disabled Item", children: <DisabledSpecimen /> },
]} />
</StorySection>
<StorySection title="Collapsible · States">
<StateBoard states={[
{ id: "collapsible-closed", label: "Closed", children: <CollapsibleClosed /> },
{ id: "collapsible-open", label: "Open", children: <CollapsibleOpen /> },
{ id: "collapsible-disabled", label: "Disabled", children: <CollapsibleDisabled /> },
]} />
</StorySection>
<StorySection title="Content · Usage">
<StateBoard states={[
{ id: "long", label: "Accordion · Long Content", children: <LongSpecimen /> },
{ id: "collapsible-long", label: "Collapsible · Long Content", children: <CollapsibleLong /> },
{ id: "collapsible-controlled", label: "Button trigger", children: <CollapsibleControlled /> },
]} />
</StorySection>
</StoryPage>
),
}
+64
View File
@@ -0,0 +1,64 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from "@hm-design/ui"
// Shared Alert Dialog specimens (Storybook only). Used by the Alert Dialog Stories (verification) and by the Dialog representative
// page, which shows general and confirmation / warning dialogs under one sidebar item ("대화상자").
// Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside
// dismiss. Action / Cancel are the HM Button: both confirmation and deletion use a Solid pair
// (deletion pairs Cancel with a destructive Action).
export function AlertDialogConfirmation({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<AlertDialog defaultOpen={defaultOpen}>
<AlertDialogTrigger asChild>
<Button variant="primary" appearance="outline">
로그아웃
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>로그아웃하시겠습니까?</AlertDialogTitle>
<AlertDialogDescription>저장하지 않은 변경 사항은 사라집니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="primary" appearance="solid">로그아웃</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function AlertDialogDestructive({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<AlertDialog defaultOpen={defaultOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" appearance="outline">
삭제
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>프로젝트를 삭제하시겠습니까?</AlertDialogTitle>
<AlertDialogDescription>삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="destructive" appearance="solid">
삭제
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+8 -63
View File
@@ -1,76 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from "@hm-design/ui"
import { AlertDialog } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { AlertDialogConfirmation as Confirmation, AlertDialogDestructive as Destructive } from "./alert-dialog-examples"
// Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside
// dismiss. The representative Story opens the real dialogs; the same cases start open in the verification Stories.
// Action / Cancel are the HM Button: ordinary confirmation uses a Solid pair; deletion uses an Outline pair.
function Confirmation({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<AlertDialog defaultOpen={defaultOpen}>
<AlertDialogTrigger asChild>
<Button variant="primary" appearance="outline">
로그아웃
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>로그아웃하시겠습니까?</AlertDialogTitle>
<AlertDialogDescription>저장하지 않은 변경 사항은 사라집니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="primary" appearance="solid">로그아웃</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function Destructive({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<AlertDialog defaultOpen={defaultOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" appearance="outline">
삭제
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>프로젝트를 삭제하시겠습니까?</AlertDialogTitle>
<AlertDialogDescription>삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="destructive" appearance="solid">
삭제
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// dismiss. The specimens live in ./alert-dialog-examples and are also shown on the Dialog representative page, which is the
// single sidebar item for both ("대화상자"). Every Story here keeps its ID and direct URL but is hidden from the sidebar.
const meta = { title: "Components/Overlay/Alert Dialog", component: AlertDialog, parameters: { layout: "centered" } } satisfies Meta<typeof AlertDialog>
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; each trigger opens the real dialog.
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
@@ -83,8 +26,10 @@ function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?:
)
}
// Representative Story: Overview and Variants are merged into one Variants section.
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Dialog page
// shows the same specimens.
export const All: Story = {
tags: ["verification"],
name: "Alert Dialog",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
+8 -8
View File
@@ -99,7 +99,7 @@ function CustomLink() {
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<a href="#router-home">홈 (custom link)</a>
<a href="#router-home">홈</a>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>/</BreadcrumbSeparator>
@@ -115,15 +115,15 @@ const sections = [
{ id: "basic", label: "Basic", node: <Basic /> },
{ id: "ellipsis", label: "Ellipsis", node: <Ellipsis /> },
{ id: "long", label: "Long path", node: <Long /> },
{ id: "custom", label: "Custom link · separator", node: <CustomLink /> },
{ id: "custom", label: "Custom", node: <CustomLink /> },
]
const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb, parameters: { layout: "padded" } } satisfies Meta<typeof Breadcrumb>
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): Variants use 3 equal columns on PC (iframe width 1000px and up),
// fewer below that (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so
// Representative-page layout (Storybook presentation only): Variants use 3 equal columns on PC (iframe width 1000px and up)
// and 1 column below that (2026-11-13, same 1 → 3 pattern as Accordion / Collapsible, so no 2 + 1 row). Each specimen sits in its own single-column StateBoard (min larger than any column), so
// StateBoard's auto-fill grid cannot leave an empty or stretched track.
const specimen = (id: string, children?: ReactNode): BoardState => {
const s = sections.find((section) => section.id === id)!
@@ -131,8 +131,8 @@ const specimen = (id: string, children?: ReactNode): BoardState => {
}
// Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (Basic is its first item). The Long path example gets up to 900px so it fits on one line on
// PC; BreadcrumbList is `flex-wrap`, so it wraps naturally (never clipped or forced nowrap) when the space is narrower.
// Overview is merged into Variants (Basic is its first item). Usage (Long path) takes the full section width (2026-11-13, the
// former 900px cap is removed); BreadcrumbList is `flex-wrap`, so it wraps naturally (never clipped or forced nowrap) when narrower.
export const All: Story = {
name: "Breadcrumb",
parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -140,14 +140,14 @@ export const All: Story = {
<StoryPage>
<StoryPageHeader title="Breadcrumb" />
<StorySection title="Variants">
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
<div className="grid grid-cols-1 items-start gap-6 min-[1000px]:grid-cols-3">
{["basic", "ellipsis", "custom"].map((id) => (
<StateBoard key={id} min={10000} states={[specimen(id)]} />
))}
</div>
</StorySection>
<StorySection title="Usage">
<StateBoard min={10000} states={[specimen("long", <div className="w-full max-w-[900px]">{sections.find((s) => s.id === "long")!.node}</div>)]} />
<StateBoard min={10000} states={[specimen("long", <div className="w-full">{sections.find((s) => s.id === "long")!.node}</div>)]} />
</StorySection>
</StoryPage>
),
+96
View File
@@ -0,0 +1,96 @@
import { useState, type ReactNode } from "react"
import { ChevronDownIcon } from "lucide-react"
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Collapsible specimens (Storybook only). Used by the Collapsible Stories (verification) and by the Accordion
// representative page, which shows Accordion and Collapsible under one sidebar item ("접기·펼치기").
// One independent disclosure per state with real, clickable triggers. Collapsible is a single disclosure: no divider system
// and no animation.
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6)
const narrow = (children: ReactNode) => <div className="w-full max-w-md">{children}</div>
function Frame({ label, defaultOpen, disabled, children }: { label: string; defaultOpen?: boolean; disabled?: boolean; children: ReactNode }) {
return narrow(
<Collapsible defaultOpen={defaultOpen} disabled={disabled}>
<CollapsibleTrigger>
{label}
<ChevronDownIcon aria-hidden />
</CollapsibleTrigger>
<CollapsibleContent>{children}</CollapsibleContent>
</Collapsible>
)
}
export const ClosedSpecimen = () => <Frame label="고급 설정 보기">추가 옵션이 여기에 표시됩니다.</Frame>
export const OpenSpecimen = () => (
<Frame label="고급 설정 보기" defaultOpen>
처음부터 열려 있는 영역입니다.
</Frame>
)
export const DisabledSpecimen = () => (
<Frame label="비활성 영역" disabled>
열 수 없습니다.
</Frame>
)
export const LongSpecimen = () => (
<Frame label="긴 콘텐츠" defaultOpen>
<p>{longText}</p>
<p className="mt-2">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</p>
</Frame>
)
// Controlled: the parent owns `open`; an HM Button is the trigger through asChild and keeps its style.
export function ControlledSpecimen() {
const [open, setOpen] = useState(false)
return narrow(
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button variant="primary" appearance="outline">
{open ? "접기" : "펼치기"}
</Button>
</CollapsibleTrigger>
{/* 2026-11-13: sample content instead of the former `controlled: open = …` debug text; the trigger label shows the state. */}
<CollapsibleContent>펼친 영역의 내용입니다.</CollapsibleContent>
</Collapsible>
)
}
const sections = [
{ id: "closed", label: "Closed", node: <ClosedSpecimen /> },
{ id: "open", label: "Open", node: <OpenSpecimen /> },
{ id: "disabled", label: "Disabled", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
{ id: "controlled", label: "Button trigger", node: <ControlledSpecimen /> },
]
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column
// below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title and
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// The Collapsible sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components.
export function CollapsibleSections({ titlePrefix, idPrefix = "" }: { titlePrefix?: string; idPrefix?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: `${idPrefix}${s.id}`, label: s.label, children: s.node }))} />
)
return (
<>
<StorySection title={title("States")}>{board(3, "closed", "open", "disabled")}</StorySection>
<StorySection title={title("Usage")}>{board(3, "long", "controlled")}</StorySection>
</>
)
}
+10 -85
View File
@@ -1,101 +1,26 @@
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { ChevronDownIcon } from "lucide-react"
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { Collapsible } from "@hm-design/ui"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { ClosedSpecimen, CollapsibleSections, DisabledSpecimen, LongSpecimen, OpenSpecimen } from "./collapsible-examples"
// The representative "Collapsible" Story shows one independent disclosure per state with real, clickable triggers. The same
// sections also exist as Stories tagged `verification` (hidden from the sidebar). Collapsible is a single disclosure: no divider
// system and no animation.
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6)
const narrow = (children: ReactNode) => <div className="w-full max-w-md">{children}</div>
function Frame({ label, defaultOpen, disabled, children }: { label: string; defaultOpen?: boolean; disabled?: boolean; children: ReactNode }) {
return narrow(
<Collapsible defaultOpen={defaultOpen} disabled={disabled}>
<CollapsibleTrigger>
{label}
<ChevronDownIcon aria-hidden />
</CollapsibleTrigger>
<CollapsibleContent>{children}</CollapsibleContent>
</Collapsible>
)
}
const ClosedSpecimen = () => <Frame label="고급 설정 보기">추가 옵션이 여기에 표시됩니다.</Frame>
const OpenSpecimen = () => (
<Frame label="고급 설정 보기" defaultOpen>
처음부터 열려 있는 영역입니다.
</Frame>
)
const DisabledSpecimen = () => (
<Frame label="비활성 영역" disabled>
열 수 없습니다.
</Frame>
)
const LongSpecimen = () => (
<Frame label="긴 콘텐츠" defaultOpen>
<p>{longText}</p>
<p className="mt-2">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</p>
</Frame>
)
// Controlled: the parent owns `open`; an HM Button is the trigger through asChild and keeps its style.
function ControlledSpecimen() {
const [open, setOpen] = useState(false)
return narrow(
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button variant="primary" appearance="outline">
{open ? "접기" : "펼치기"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>{`controlled: open = ${String(open)}`}</CollapsibleContent>
</Collapsible>
)
}
const sections = [
{ id: "closed", label: "Closed", node: <ClosedSpecimen /> },
{ id: "open", label: "Open", node: <OpenSpecimen /> },
{ id: "disabled", label: "Disabled", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
{ id: "controlled", label: "Button trigger", node: <ControlledSpecimen /> },
]
// Collapsible is a single disclosure. The specimens live in ./collapsible-examples and are also shown on the Accordion
// representative page ("접기·펼치기"), the single sidebar item for both. Every Story here keeps its ID and direct URL but is
// hidden from the sidebar (tag `verification`).
const meta = { title: "Components/Disclosure/Collapsible", component: Collapsible, parameters: { layout: "padded" } } satisfies Meta<typeof Collapsible>
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column
// below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title, divider and
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Accordion
// page shows the same sections.
export const All: Story = {
tags: ["verification"],
name: "Collapsible",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Collapsible" />
<StorySection title="States">{board(3, "closed", "open", "disabled")}</StorySection>
<StorySection title="Usage">{board(3, "long", "controlled")}</StorySection>
<CollapsibleSections />
</StoryPage>
),
}
+2 -2
View File
@@ -59,7 +59,7 @@ function Grouped() {
환경설정
<CommandShortcut>⌘,</CommandShortcut>
</CommandItem>
<CommandItem disabled>결제 (사용 불가)</CommandItem>
<CommandItem disabled>결제</CommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -80,7 +80,7 @@ function Long() {
)
}
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles and
// command boxes line up; sections with fewer specimens leave the remaining tracks empty.
const Grid = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
+72
View File
@@ -0,0 +1,72 @@
import { useState } from "react"
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@hm-design/ui"
// Shared Context Menu specimen (Storybook only). Used by the Context Menu Stories (verification) and by the Dropdown Menu
// representative page, which shows both menus under one sidebar item ("팝업 메뉴").
// Context Menu opens from pointer context (right click, the context-menu key / Shift+F10, long press) on its trigger area.
// Radix owns keyboard navigation, focus return and Escape.
export function ContextMenuFull() {
const [showStatus, setShowStatus] = useState(true)
const [showPanel, setShowPanel] = useState(false)
const [position, setPosition] = useState("bottom")
return (
<ContextMenu>
{/* tabIndex lets keyboard users focus the area and open the menu with Shift+F10 / the context-menu key; Radix Trigger is a plain span. */}
<ContextMenuTrigger tabIndex={0} className="flex h-40 w-80 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
이 영역에서 오른쪽 클릭
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuLabel>파일</ContextMenuLabel>
<ContextMenuGroup>
<ContextMenuItem>
열기 <ContextMenuShortcut>⌘O</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
이름 바꾸기 <ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled>붙여넣기</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuLabel>보기</ContextMenuLabel>
<ContextMenuCheckboxItem checked={showStatus} onCheckedChange={setShowStatus}>
상태 표시줄
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
패널
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel>위치</ContextMenuLabel>
<ContextMenuRadioGroup value={position} onValueChange={setPosition}>
<ContextMenuRadioItem value="top">상단</ContextMenuRadioItem>
<ContextMenuRadioItem value="bottom">하단</ContextMenuRadioItem>
<ContextMenuRadioItem value="right">오른쪽</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>더 보기</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>이메일로 공유</ContextMenuItem>
<ContextMenuItem>링크 복사</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">삭제</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
+8 -70
View File
@@ -1,83 +1,21 @@
import { useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@hm-design/ui"
import { ContextMenu } from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { ContextMenuFull as Full } from "./context-menu-examples"
// Context Menu opens from pointer context (right click, the context-menu key / Shift+F10, long press) on its trigger area.
// The representative Story is a target area: right click it. Radix owns keyboard navigation, focus return and Escape.
function Full() {
const [showStatus, setShowStatus] = useState(true)
const [showPanel, setShowPanel] = useState(false)
const [position, setPosition] = useState("bottom")
return (
<ContextMenu>
{/* tabIndex lets keyboard users focus the area and open the menu with Shift+F10 / the context-menu key; Radix Trigger is a plain span. */}
<ContextMenuTrigger tabIndex={0} className="flex h-40 w-80 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
이 영역에서 오른쪽 클릭
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuLabel>파일</ContextMenuLabel>
<ContextMenuGroup>
<ContextMenuItem>
열기 <ContextMenuShortcut>⌘O</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
이름 바꾸기 <ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled>붙여넣기 (사용 불가)</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuLabel>보기</ContextMenuLabel>
<ContextMenuCheckboxItem checked={showStatus} onCheckedChange={setShowStatus}>
상태 표시줄
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
패널
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel>위치</ContextMenuLabel>
<ContextMenuRadioGroup value={position} onValueChange={setPosition}>
<ContextMenuRadioItem value="top">상단</ContextMenuRadioItem>
<ContextMenuRadioItem value="bottom">하단</ContextMenuRadioItem>
<ContextMenuRadioItem value="right">오른쪽</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>더 보기</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>이메일로 공유</ContextMenuItem>
<ContextMenuItem>링크 복사</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">삭제</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
// The specimen lives in ./context-menu-examples and is also shown on the Dropdown Menu representative page, which is the single
// sidebar item for both ("팝업 메뉴"). Every Story here keeps its ID and direct URL but is hidden from the sidebar.
const meta = { title: "Components/Overlay/Context Menu", component: ContextMenu, parameters: { layout: "centered" } } satisfies Meta<typeof ContextMenu>
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: right click the area to open the full menu (item, disabled, separator, shortcut, checkbox, radio group, sub menu, destructive).
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Dropdown Menu
// page shows the same specimen. Right click the area to open the full menu (item, disabled, separator, shortcut, checkbox, radio
// group, sub menu, destructive).
export const All: Story = {
tags: ["verification"],
name: "Context Menu",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
+17 -9
View File
@@ -6,8 +6,9 @@ import { Input } from "@hm-design/ui"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { AlertDialogConfirmation, AlertDialogDestructive } from "./alert-dialog-examples"
// Dialog is an overlay (Radix Portal), so it does not fit the StateBoard. The representative Story is a row of triggers that open
// Dialog is an overlay (Radix Portal): each trigger sits in its own single-column StateBoard (SpecimenGrid) and opens
// the real Dialog for each ui_viz case (dialog.md §9 Reference Viewer examples); the same cases also exist as separate Stories tagged
// `verification` that start open. Pseudo-state helpers are not used to fake an open Dialog.
// Usage: native label below is a preserved Reference fixture. New HM React fields use public Label
@@ -176,16 +177,15 @@ export default meta
type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; every case opens from its trigger. Named after the component, as in ui_viz.
// It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item.
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
// It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item. It also shows the Alert
// Dialog (confirmation / warning) specimens from ./alert-dialog-examples, so "대화상자" is the one sidebar item for both; the Alert
// Dialog Stories keep their IDs and direct URLs but are hidden from the sidebar.
// Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid.
function SpecimenGrid({ states }: { states: BoardState[] }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4 [&_[data-overview-state]>header]:sr-only">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className="flex items-start">{state.children}</div> }]} />
))}
</div>
)
@@ -216,6 +216,14 @@ export const All: Story = {
]}
/>
</StorySection>
<StorySection title="Alert Dialog">
<SpecimenGrid
states={[
{ id: "alert-confirmation", label: "Confirmation", children: <AlertDialogConfirmation /> },
{ id: "alert-destructive", label: "Destructive", children: <AlertDialogDestructive /> },
]}
/>
</StorySection>
</StoryPage>
),
}
+127
View File
@@ -0,0 +1,127 @@
import type { ReactNode } from "react"
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label
// for supported fields (docs/components/label.md §5); copy only actions requested by the screen.
import { Button, Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, Input } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Drawer specimens (Storybook only). Used by the Drawer Stories (verification) and by the Sheet representative page,
// which shows Sheet and Drawer under one sidebar item ("시트·드로어").
// Drawer is a modal vaul overlay (Portal). The sections open the real Drawer from its triggers; drag the handle or the panel
// down to dismiss. The same cases start open in the verification Stories. vaul owns the drag, Radix the focus and ARIA.
const longText = "긴 내용은 Body 영역만 세로로 scroll 하고 Header와 Footer는 그대로 보입니다. "
function Frame({
trigger,
defaultOpen,
title,
description,
children,
actions,
}: {
trigger: string
defaultOpen?: boolean
title: string
description?: string
children: ReactNode
actions?: ReactNode
}) {
return (
<Drawer defaultOpen={defaultOpen}>
<DrawerTrigger asChild>
<Button variant="primary" appearance="outline">
{trigger}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerBody>{children}</DrawerBody>
<DrawerFooter>{actions}</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
const actions = (
<>
<DrawerClose asChild>
<Button variant="cancel" appearance="solid">
취소
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="primary" appearance="solid">
실행
</Button>
</DrawerClose>
</>
)
export function DrawerBasic({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="기본" defaultOpen={defaultOpen} title="작업 선택" description="아래에서 올라오는 패널입니다. 핸들을 아래로 끌어 닫을 수 있습니다." actions={actions}>
<p>짧은 내용은 내용 높이만큼만 차지합니다.</p>
</Frame>
)
}
export function DrawerForm({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}>
<div className="grid gap-2">
<label htmlFor="drawer-form-name" className="text-sm font-medium text-foreground">
프로젝트명
</label>
<Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
</div>
</Frame>
)
}
export function DrawerLong({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 scroll 합니다." actions={actions}>
{Array.from({ length: 30 }, (_, i) => (
<p key={i} className="mb-3">
{longText}
</p>
))}
</Frame>
)
}
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, hideSpecimenLabels }: { states: BoardState[]; hideSpecimenLabels: boolean }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4${hideSpecimenLabels ? " [&_[data-overview-state]>header]:sr-only" : ""}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className={hideSpecimenLabels ? "flex items-start" : undefined}>{state.children}</div> }]} />
))}
</div>
)
}
// The Drawer section of a representative page: Overview and Usage merged into one Usage section (Basic, Form, Long Content).
// On a shared page `title` names the section after the component (as "Alert Dialog" on the Dialog page) and `idPrefix`
// keeps the specimen ids (data-overview-state) unique.
export function DrawerSections({ title = "Usage", idPrefix = "", hideSpecimenLabels = false }: { title?: string; idPrefix?: string; hideSpecimenLabels?: boolean }) {
return (
<StorySection title={title}>
<SpecimenGrid
hideSpecimenLabels={hideSpecimenLabels}
states={[
{ id: `${idPrefix}basic`, label: "Basic", children: <DrawerBasic /> },
{ id: `${idPrefix}form`, label: "Form", children: <DrawerForm /> },
{ id: `${idPrefix}long-content`, label: "Long Content", children: <DrawerLong /> },
]}
/>
</StorySection>
)
}
+13 -118
View File
@@ -1,135 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label
// for supported fields (docs/components/label.md §5); copy only actions requested by the screen.
import { Button, Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, Input } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { Drawer } from "@hm-design/ui"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { DrawerBasic, DrawerForm, DrawerLong, DrawerSections } from "./drawer-examples"
// Drawer is a modal vaul overlay (Portal). The representative Story opens the real Drawer from its triggers; drag the handle or
// the panel down to dismiss. The same cases start open in the verification Stories. vaul owns the drag, Radix the focus and ARIA.
const longText = "긴 내용은 Body 영역만 세로로 scroll 하고 Header와 Footer는 그대로 보입니다. "
function Frame({
trigger,
defaultOpen,
title,
description,
children,
actions,
}: {
trigger: string
defaultOpen?: boolean
title: string
description?: string
children: React.ReactNode
actions?: React.ReactNode
}) {
return (
<Drawer defaultOpen={defaultOpen}>
<DrawerTrigger asChild>
<Button variant="primary" appearance="outline">
{trigger}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerBody>{children}</DrawerBody>
<DrawerFooter>{actions}</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
const actions = (
<>
<DrawerClose asChild>
<Button variant="cancel" appearance="solid">
취소
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="primary" appearance="solid">
실행
</Button>
</DrawerClose>
</>
)
function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="기본" defaultOpen={defaultOpen} title="작업 선택" description="아래에서 올라오는 패널입니다. 핸들을 아래로 끌어 닫을 수 있습니다." actions={actions}>
<p>짧은 내용은 내용 높이만큼만 차지합니다.</p>
</Frame>
)
}
function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}>
<div className="grid gap-2">
<label htmlFor="drawer-form-name" className="text-sm font-medium text-foreground">
프로젝트명
</label>
<Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
</div>
</Frame>
)
}
function LongCase({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 scroll 합니다." actions={actions}>
{Array.from({ length: 30 }, (_, i) => (
<p key={i} className="mb-3">
{longText}
</p>
))}
</Frame>
)
}
// Drawer is a modal vaul overlay (Portal). The specimens live in ./drawer-examples and are also shown on the Sheet
// representative page ("시트·드로어"), the single sidebar item for both. Every Story here keeps its ID and direct URL but is
// hidden from the sidebar (tag `verification`).
const meta = { title: "Components/Overlay/Drawer", component: Drawer, parameters: { layout: "centered" } } satisfies Meta<typeof Drawer>
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview and Usage are merged into one Usage section (Basic, Form, Long Content).
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Sheet
// page shows the same section.
export const All: Story = {
tags: ["verification"],
name: "Drawer",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Drawer" />
<StorySection title="Usage">
<SpecimenGrid
states={[
{ id: "basic", label: "Basic", children: <Basic /> },
{ id: "form", label: "Form", children: <FormCase /> },
{ id: "long-content", label: "Long Content", children: <LongCase /> },
]}
/>
</StorySection>
<DrawerSections />
</StoryPage>
),
}
export const Open: Story = { tags: ["verification"], render: () => <Basic defaultOpen /> }
export const Form: Story = { tags: ["verification"], render: () => <FormCase defaultOpen /> }
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongCase defaultOpen /> }
export const Open: Story = { tags: ["verification"], render: () => <DrawerBasic defaultOpen /> }
export const Form: Story = { tags: ["verification"], render: () => <DrawerForm defaultOpen /> }
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <DrawerLong defaultOpen /> }
+11 -4
View File
@@ -18,9 +18,12 @@ import {
DropdownMenuTrigger,
} from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { ContextMenuFull } from "./context-menu-examples"
// Dropdown Menu is a Radix overlay (Portal), so the representative Story is a trigger that opens the real menu, the same way as
// Dialog. The verification Story starts open. Radix owns keyboard navigation, focus return and Escape.
// The representative page also shows the Context Menu specimen from ./context-menu-examples, so "팝업 메뉴" is the one
// sidebar item for both; the Context Menu Stories keep their IDs and direct URLs but are hidden from the sidebar.
function Full({ defaultOpen }: { defaultOpen?: boolean }) {
const [showStatus, setShowStatus] = useState(true)
@@ -42,7 +45,7 @@ function Full({ defaultOpen }: { defaultOpen?: boolean }) {
<DropdownMenuItem>
설정 <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem disabled>결제 (사용 불가)</DropdownMenuItem>
<DropdownMenuItem disabled>결제</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>보기</DropdownMenuLabel>
@@ -78,18 +81,22 @@ const meta = { title: "Components/Overlay/Dropdown Menu", component: DropdownMen
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: the trigger opens the full menu (item, disabled, separator, checkbox, radio group, sub menu, destructive).
// Representative Story: the trigger opens the full menu (item, disabled, separator, checkbox, radio group, sub menu, destructive);
// right click the Context Menu area to open the same kinds of items.
export const All: Story = {
name: "Dropdown Menu",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Dropdown Menu"
title="Dropdown Menu · Context Menu"
/>
<StorySection title="Basic">
<StorySection title="Dropdown Menu">
<Full />
</StorySection>
<StorySection title="Context Menu">
<ContextMenuFull />
</StorySection>
</StoryPage>
),
}
+80
View File
@@ -0,0 +1,80 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Hover Card specimens (Storybook only). Used by the Hover Card Stories (verification) and by the Tooltip representative
// page, which shows Tooltip and Hover Card under one sidebar item ("툴팁·호버 카드").
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The section shows real triggers (hover or focus to
// open); the verification Story starts one open. Radix owns the open / close delay and the hover bridge.
const longText = "긴 콘텐츠는 카드 폭에서 줄바꿈되고 카드 높이만 늘어납니다. 공백 없는 긴 문자열도 폭을 밀어내지 않습니다. ".repeat(4)
export function HoverCardPreview({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<HoverCard defaultOpen={defaultOpen}>
<HoverCardTrigger asChild>
<a href="#preview" className="text-sm font-medium text-primary underline underline-offset-4">
한맥 프로젝트
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-1">
<p className="font-bold">한맥 프로젝트</p>
<p>노선과 시설물 정보를 관리하는 프로젝트입니다.</p>
<p className="pt-1 text-xs text-muted-foreground">담당 김한맥 · 최근 수정 2026-10-02</p>
</div>
</HoverCardContent>
</HoverCard>
)
}
function LongContent() {
return (
<HoverCard>
<HoverCardTrigger asChild>
<a href="#long" className="text-sm font-medium text-primary underline underline-offset-4">
긴 콘텐츠
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-1">
<p className="font-bold">긴 콘텐츠</p>
<p>{longText}</p>
</div>
</HoverCardContent>
</HoverCard>
)
}
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
// `columns` lets a shared page keep the column track of its other sections.
function SpecimenGrid({ states, columns = 4, hideSpecimenLabels }: { states: BoardState[]; columns?: 4 | 5; hideSpecimenLabels?: boolean }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}${hideSpecimenLabels ? " [&_[data-overview-state]>header]:sr-only" : ""}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className={hideSpecimenLabels ? "flex items-start" : undefined}>{state.children}</div> }]} />
))}
</div>
)
}
// The Hover Card section of a representative page: Overview and Usage merged into one Variants section (Basic, Long Content).
// On a shared page `title` names the section after the component (as "Alert Dialog" on the Dialog page), `columns` follows the
// page's track and `idPrefix` keeps the specimen ids (data-overview-state) unique.
export function HoverCardSections({ title = "Variants", idPrefix = "", columns = 4, hideSpecimenLabels = false }: { title?: string; idPrefix?: string; columns?: 4 | 5; hideSpecimenLabels?: boolean }) {
return (
<StorySection title={title}>
<SpecimenGrid
columns={columns}
hideSpecimenLabels={hideSpecimenLabels}
states={[
{ id: `${idPrefix}basic`, label: "Basic", children: <HoverCardPreview /> },
{ id: `${idPrefix}long-content`, label: "Long Content", children: <LongContent /> },
]}
/>
</StorySection>
)
}
+11 -68
View File
@@ -1,83 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { HoverCard } from "@hm-design/ui"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { HoverCardPreview, HoverCardSections } from "./hover-card-examples"
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The representative Story shows real triggers
// (hover or focus to open); the verification Story starts one open. Radix owns the open / close delay and the hover bridge.
const longText = "긴 콘텐츠는 카드 폭에서 줄바꿈되고 카드 높이만 늘어납니다. 공백 없는 긴 문자열도 폭을 밀어내지 않습니다. ".repeat(4)
function Preview({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<HoverCard defaultOpen={defaultOpen}>
<HoverCardTrigger asChild>
<a href="#preview" className="text-sm font-medium text-primary underline underline-offset-4">
한맥 프로젝트
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-1">
<p className="font-bold">한맥 프로젝트</p>
<p>노선과 시설물 정보를 관리하는 프로젝트입니다.</p>
<p className="pt-1 text-xs text-muted-foreground">담당 김한맥 · 최근 수정 2026-10-02</p>
</div>
</HoverCardContent>
</HoverCard>
)
}
function LongContent() {
return (
<HoverCard>
<HoverCardTrigger asChild>
<a href="#long" className="text-sm font-medium text-primary underline underline-offset-4">
긴 콘텐츠
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-1">
<p className="font-bold">긴 콘텐츠</p>
<p>{longText}</p>
</div>
</HoverCardContent>
</HoverCard>
)
}
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The specimens live in ./hover-card-examples and
// are also shown on the Tooltip representative page ("툴팁·호버 카드"), the single sidebar item for both. Every Story here
// keeps its ID and direct URL but is hidden from the sidebar (tag `verification`).
const meta = { title: "Components/Overlay/Hover Card", component: HoverCard, parameters: { layout: "centered" } } satisfies Meta<typeof HoverCard>
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview and Usage are merged into one Variants section (Basic, Long Content).
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Tooltip
// page shows the same section.
export const All: Story = {
tags: ["verification"],
name: "Hover Card",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Hover Card" />
<StorySection title="Variants">
<SpecimenGrid
states={[
{ id: "basic", label: "Basic", children: <Preview /> },
{ id: "long-content", label: "Long Content", children: <LongContent /> },
]}
/>
</StorySection>
<HoverCardSections />
</StoryPage>
),
}
@@ -87,7 +30,7 @@ export const Open: Story = {
parameters: { layout: "fullscreen" },
render: () => (
<div className="flex min-h-[260px] justify-center p-8">
<Preview defaultOpen />
<HoverCardPreview defaultOpen />
</div>
),
}
+1 -1
View File
@@ -37,7 +37,7 @@ function Specimen() {
<MenubarItem>
열기 <MenubarShortcut>⌘O</MenubarShortcut>
</MenubarItem>
<MenubarItem disabled>저장 (사용 불가)</MenubarItem>
<MenubarItem disabled>저장</MenubarItem>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger>내보내기</MenubarSubTrigger>
+4 -7
View File
@@ -75,15 +75,12 @@ const meta = { title: "Components/Overlay/Popover", component: Popover, paramete
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
// Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid.
function SpecimenGrid({ states }: { states: BoardState[] }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4 [&_[data-overview-state]>header]:sr-only">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className="flex items-start">{state.children}</div> }]} />
))}
</div>
)
+8 -7
View File
@@ -4,6 +4,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, Input, Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { DrawerSections } from "./drawer-examples"
// Sheet is a modal Radix overlay (Portal), so the representative Story is a row of triggers that open the real Sheet for each
// side and content case. The same cases start open in the verification Stories. Radix owns focus trap, Escape and focus return.
@@ -94,21 +95,20 @@ const meta = { title: "Components/Overlay/Sheet", component: Sheet, parameters:
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
// Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid.
function SpecimenGrid({ states }: { states: BoardState[] }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4 [&_[data-overview-state]>header]:sr-only">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className="flex items-start">{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: the common Story page. Overview is merged into Variants (Right is its first item).
// 2026-11-13 (user request): it also shows the Drawer section from ./drawer-examples, so "시트·드로어" is the one sidebar item
// for both edge panels; the Drawer Stories keep their IDs and direct URLs but are hidden from the sidebar.
export const All: Story = {
name: "Sheet",
parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -133,6 +133,7 @@ export const All: Story = {
]}
/>
</StorySection>
<DrawerSections title="Drawer" idPrefix="drawer-" hideSpecimenLabels />
</StoryPage>
),
}
+31 -20
View File
@@ -9,7 +9,7 @@ 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.
// D-01 applies to every Fill specimen: flat panels use the actual parent surface and padding 24px 0.
// 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"
@@ -21,6 +21,8 @@ const variantProps = {
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",
@@ -28,6 +30,7 @@ function Specimen({
count = 3,
labels,
listProps,
panelSpacing = "px-0 py-6",
}: {
variant?: Variant
defaultValue?: string
@@ -35,6 +38,7 @@ function Specimen({
count?: number
labels?: string[]
listProps?: ListProps
panelSpacing?: string
}) {
const { listProps: defaults, surface } = variantProps[variant]
const effectiveListProps: ListProps = listProps ?? defaults
@@ -51,7 +55,7 @@ function Specimen({
))}
</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"}>
<TabsContent key={value} value={value} className={legacy ? "rounded-b p-4 text-sm border bg-card" : `${panelSpacing} text-sm`}>
메뉴 {i + 1} 콘텐츠
</TabsContent>
))}
@@ -67,13 +71,16 @@ 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="px-0 py-6">
<TabsContent value="information" className={panelSpacing}>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor={`${id}-project`}>프로젝트명 (필수)</Label>
@@ -88,7 +95,7 @@ function SurfaceUsage({ inCard = false }: { inCard?: boolean }) {
{/* 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}>
<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>
@@ -127,17 +134,18 @@ function LongLabelSpecimens() {
)
}
// Equal two columns on PC (one column below 900px). Each board holds a single specimen: StateBoard's own auto-fill grid
// would leave an empty third column for two items, so `min` is forced above any column width (same approach as Button).
// 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="grid items-start gap-6 min-[900px]:grid-cols-[repeat(2,minmax(0,1fr))]">{children}</div>
<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} />
// Variants: equal three columns on PC (iframe width 1000px and up); fewer columns below that (240px minimum). `min-w-0` lets the
// Overflow Tabs keep its own internal scroll (previous / next buttons) inside its column instead of widening the page.
const ThreeColumns = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
)
const meta = {
title: "Components/Navigation/Tabs",
@@ -147,7 +155,7 @@ const meta = {
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 이력은 유지합니다.",
"기본 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 이력은 유지합니다.",
},
},
},
@@ -159,16 +167,19 @@ export const All: Story = {
name: "Tabs",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
// The common compact Story page (16–24px gutter, 24px section gap) fits this page on a 900px-high PC screen without an override.
// 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">
{/* The former States section (Overflow) is the third Variants item. */}
<ThreeColumns>
<SingleBoard states={[{ id: "primary", label: "Primary", children: <Specimen /> }]} />
<SingleBoard states={[{ id: "secondary", label: "Secondary", children: <Specimen variant="secondary" /> }]} />
<SingleBoard states={[{ id: "overflow", label: "Overflow", children: <div className="w-full min-w-0"><Specimen count={20} /></div> }]} />
</ThreeColumns>
<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>
+9 -9
View File
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { HoverCardSections } from "./hover-card-examples"
// Tooltip is a Radix overlay (Portal) with short, non-interactive text. The representative Story shows real triggers
// (hover or focus to open); the verification Story starts one open. Interactive content belongs in Popover / Hover Card.
@@ -67,21 +68,21 @@ const meta = { title: "Components/Overlay/Tooltip", component: Tooltip, paramete
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
// Keep the board's accessible headings and shared indent, with only the triggers visible in the five-column grid.
function SpecimenGrid({ states }: { states: BoardState[] }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-5 [&_[data-overview-state]>header]:sr-only">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className="flex items-start">{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview is merged into Variants (Basic is its first item, then the four sides): 5 columns on PC.
// 2026-11-13 (user request): it also shows the Hover Card section from ./hover-card-examples (same 5-column track), so
// "툴팁·호버 카드" is the one sidebar item for both hover previews; the Hover Card Stories keep their IDs and direct URLs but
// are hidden from the sidebar.
export const All: Story = {
name: "Tooltip",
parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -91,7 +92,6 @@ export const All: Story = {
<StoryPageHeader title="Tooltip" />
<StorySection title="Variants">
<SpecimenGrid
columns={5}
states={[
{ id: "basic", label: "Basic", children: <Basic /> },
{ id: "top", label: "Top", children: <Placement side="top" /> },
@@ -103,13 +103,13 @@ export const All: Story = {
</StorySection>
<StorySection title="Usage">
<SpecimenGrid
columns={5}
states={[
{ id: "long-text", label: "Long Text", children: <LongText /> },
{ id: "disabled", label: "Disabled", children: <Disabled /> },
]}
/>
</StorySection>
<HoverCardSections title="Hover Card" idPrefix="hover-card-" columns={5} hideSpecimenLabels />
</StoryPage>
</TooltipProvider>
),