Files
HMDesign/src/components/select.stories.tsx
T
shkim4 4c29894e56 refactor(storybook): simplify component and preview examples
Drop page / section descriptions, state notes and repeated captions from
the representative Stories of 44 components, the Patterns, Center
Graphic and OrgChart pages. Value readouts, spec lists and captions hide
only on the representative page; hidden verification Stories keep them.
Tabs Default API and the Filter Bar draft / applied counts move to the
hidden DefaultApi and DraftApplied Stories; Center Overview is hidden
with its direct URL kept. Story IDs are unchanged.

Align the component, pattern and OrgChart docs with the pages, point the
quality reference check at the DraftApplied Story, and make the P8
styles check expect no section header on the preview-only Expanded page.
2026-10-23 00:28:05 +09:00

297 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useId, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
// one representative "Select" Story shows the default, selected, disabled and open states on one page; the same specimens
// also exist as separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
//
// The Label is not part of the Select component. SelectSpecimen composes it with a plain element and shadcn Token
// utilities, the way a consumer would: Label 14/20/500 in the secondary text color, field gap 8.
const statusOptions = [
{ value: "all", label: "All statuses" },
{ value: "active", label: "Active" },
{ value: "planned", label: "Planned" },
{ value: "completed", label: "Completed" },
]
const longOptions = [
{ value: "short", label: "Short option" },
{ value: "long", label: "A very long selected value that must truncate without moving the chevron" },
]
const scrollOptions = Array.from({ length: 32 }, (_, index) => ({
value: `option-${index + 1}`,
label: `Option ${index + 1}`,
}))
type SpecimenProps = {
listSize?: SelectSize
label?: string
placeholder?: string
size?: SelectSize
defaultValue?: string
defaultOpen?: boolean
disabled?: boolean
"aria-invalid"?: boolean
options?: ReadonlyArray<{ value: string; label: string }>
}
function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) {
const id = useId()
return (
<div className="grid gap-2">
<label htmlFor={id} className={`justify-self-start text-sm font-medium text-muted-foreground${disabled ? " opacity-60" : ""}`}>
{label}
</label>
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
<SelectTrigger id={id} size={size} aria-invalid={ariaInvalid} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent {...(listSize ? { "data-size": listSize } : {})}>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
// ui_viz docs/components/select.md §1.1, §9: display metadata only, never sets control geometry.
const sizes = [
{ size: "medium" as const, name: "Medium", outer: "34px / 32px", typography: "14 / 20 / 400", option: "42px minimum · 14 / 20 / 400", usage: "Business forms / Search" },
{ size: "large" as const, name: "Large", outer: "44px / 42px", typography: "16 / 24 / 400", option: "42px minimum · 16 / 24 / 400", usage: "Component default" },
]
function SizeCard({ spec, showSpec = true }: { spec: (typeof sizes)[number]; showSpec?: boolean }) {
return (
<section className="grid content-start gap-4" data-select-size={spec.size}>
<SelectSpecimen size={spec.size} label={spec.name} placeholder="Choose a status" />
{showSpec ? (
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">Outer Height / Trigger</dt>
<dd className="m-0">{spec.outer}</dd>
<dt className="text-muted-foreground">Trigger Typography</dt>
<dd className="m-0">{spec.typography}</dd>
<dt className="text-muted-foreground">Option</dt>
<dd className="m-0">{spec.option}</dd>
<dt className="text-muted-foreground">Usage</dt>
<dd className="m-0">{spec.usage}</dd>
</dl>
) : null}
</section>
)
}
// `showSpec={false}` keeps the representative page UI-only; the verification Story keeps the spec lists and the size note.
function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
return (
<div className="grid max-w-[1120px] gap-6">
<div className="grid gap-6 [grid-template-columns:repeat(auto-fit,minmax(240px,1fr))]">
{sizes.map((spec) => (
<SizeCard key={spec.size} spec={spec} showSpec={showSpec} />
))}
</div>
{showSpec ? (
<p className="text-sm text-muted-foreground">
Select는 Input과 독립된 두 크기(Medium 34 / Large 44)입니다. 각 Trigger를 클릭해 연결된 목록과 선택 상태를 확인하세요. 목록의 Option은 Trigger의 size를 그대로 따르며(최소 높이 42px, Medium 14/20/400, Large 16/24/400) 폭은 항상 Trigger와 같습니다.
</p>
) : null}
</div>
)
}
function DisabledStates() {
return (
<div className="grid max-w-[480px] gap-6">
<section className="grid gap-2">
<h3 className="text-base font-bold">Selected Value</h3>
<SelectSpecimen size="medium" label="Status" defaultValue="active" disabled />
</section>
<section className="grid gap-2">
<h3 className="text-base font-bold">Placeholder</h3>
<SelectSpecimen size="medium" label="Status" placeholder="Choose a status" disabled />
</section>
</div>
)
}
const meta = {
title: "Components/Form/Select",
component: SelectSpecimen,
parameters: {
layout: "padded",
docs: {
description: {
component:
"ui_viz docs/components/select.md: Medium 외곽 34px(Trigger 32), Large 외곽 44px(Trigger 42, 기본값). Trigger는 Medium 14/20/400, Large 16/24/400이고 화살표는 18×18입니다. 목록은 Trigger 폭에 연결되는 Overlay이며 Option은 Trigger의 size를 따릅니다(최소 42px, Medium 14/20/400, Large 16/24/400). Option 상태 면(.45)·글자색과 열린 구분선, 그림자는 ui_viz에서 Temporary/EXPERIMENTAL이라 shadcn 기본값을 유지합니다. Error는 ui_viz에 정의가 없습니다.",
},
},
},
argTypes: {
size: { control: "inline-radio", options: ["medium", "large"], description: "무지정 기본값 Large. 업무 폼·조회 소비 역할은 Medium." },
disabled: { control: "boolean" },
defaultOpen: { control: "boolean" },
defaultValue: { control: "select", options: [undefined, ...statusOptions.map((option) => option.value)] },
placeholder: { control: "text" },
label: { control: "text" },
},
} satisfies Meta<typeof SelectSpecimen>
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: every state on one page. Named after the component, as in ui_viz.
// It starts at Sizes; each trigger opens the real list (the open-on-load list is the hidden `Open` Story).
export const All: Story = {
name: "Select",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Select" />
<StorySection title="Sizes">
<SizeComparison showSpec={false} />
</StorySection>
<StorySection title="States">
<StateBoard
fixed
states={[
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" /> },
{ id: "disabled", label: "Disabled", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" disabled /> },
{ id: "disabled-placeholder", label: "Disabled · Placeholder", children: <SelectSpecimen size="medium" label="Status" disabled /> },
]}
/>
</StorySection>
<StorySection title="Invalid / Focus">
<StateBoard
fixed
states={[
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Status" defaultValue="active" /> },
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Status" /> },
]}
/>
</StorySection>
</StoryPage>
),
}
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
export const Default: Story = { tags: ["verification"], render: () => narrow(<SelectSpecimen />) }
export const InvalidFocus: Story = {
tags: ["verification"],
render: () => (
<div className="grid max-w-sm gap-6">
<button type="button">Before field</button>
<SelectSpecimen size="medium" aria-invalid label="Invalid Select" defaultValue="active" />
<SelectSpecimen size="medium" aria-invalid disabled label="Disabled Invalid Select" />
<button type="button">After field</button>
</div>
),
}
export const Open: Story = {
tags: ["verification"],
render: () => narrow(<div className="min-h-64"><SelectSpecimen size="medium" defaultOpen /></div>),
}
export const Selected: Story = { tags: ["verification"], render: () => narrow(<SelectSpecimen size="medium" defaultValue="active" />) }
export const Disabled: Story = { tags: ["verification"], render: () => <DisabledStates /> }
export const Sizes: Story = { tags: ["verification"], name: "Compare Sizes", render: () => <SizeComparison /> }
export const Medium: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[0]} />) }
export const Large: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[1]} />) }
// HM resilience checks only: ui_viz had no public long-content or scroll Story, so these are not parity evidence.
export const LongContent: Story = {
tags: ["verification"],
render: () => (
<div style={{ display: "grid", gap: 24, maxWidth: 280 }}>
<SelectSpecimen
size="medium"
label="Long placeholder"
placeholder="A very long placeholder that must truncate without moving the chevron"
options={longOptions}
/>
<SelectSpecimen size="medium" label="Long selected value" defaultValue="long" options={longOptions} />
</div>
),
}
// Open list with an option longer than the list: checks Viewport → Item → ItemText sizing (Trigger 14px vs Option 16px for Medium).
const longOpenOptions = [
{ value: "short", label: "Short option" },
{ value: "long", label: "A very long option label that is wider than the open list and must truncate inside it" },
]
export const OpenLargeShort: Story = {
tags: ["verification"],
render: () => narrow(<div className="min-h-64"><SelectSpecimen size="large" defaultOpen /></div>),
}
export const OpenMediumLong: Story = {
tags: ["verification"],
render: () => <div className="min-h-64" style={{ maxWidth: 280 }}><SelectSpecimen size="medium" defaultOpen defaultValue="long" options={longOpenOptions} /></div>,
}
export const OpenLargeLong: Story = {
tags: ["verification"],
render: () => <div className="min-h-64" style={{ maxWidth: 280 }}><SelectSpecimen size="large" defaultOpen defaultValue="long" options={longOpenOptions} /></div>,
}
// Place the real Trigger near the viewport bottom so Radix collision detection flips it.
const atBottom = (children: ReactNode) => (
<div style={{ minHeight: "calc(100dvh - 32px)", display: "grid", alignItems: "end", width: 280, maxWidth: "100%" }}>{children}</div>
)
export const OpenTop: Story = {
tags: ["verification"],
render: () => atBottom(<SelectSpecimen size="large" defaultOpen />),
}
export const OpenTopLong: Story = {
tags: ["verification"],
render: () => atBottom(<SelectSpecimen size="medium" defaultOpen defaultValue="long" options={longOpenOptions} />),
}
// Comparison only: pins the list profile independently of the Trigger to check whether a Medium Trigger + Large list
// breaks the open list. Production derives the list size from the Trigger; this Story overrides the data-size attribute
// that SelectContent already forwards, so no Select API exists for it. Both columns share options, selected value and width.
function ListSizeColumn({ title, listSize, width }: { title: string; listSize: SelectSize; width: number }) {
return (
<section className="grid min-w-0 content-start gap-2" style={{ width, maxWidth: "100%" }} data-list-size={listSize}>
<h3 className="m-0 text-sm font-bold">{title}</h3>
<div className="min-h-56">
<SelectSpecimen size="medium" listSize={listSize} defaultOpen defaultValue="long" options={longOpenOptions} label={"Trigger medium · 최대 " + width + "px"} />
</div>
</section>
)
}
export const ListSizeComparison: Story = {
tags: ["verification"],
name: "Medium Trigger / List Size Comparison",
parameters: { controls: { disable: true } },
render: () => (
<div className="grid gap-8 [grid-template-columns:repeat(auto-fit,minmax(min(100%,384px),max-content))]">
<ListSizeColumn title="A · Medium Trigger + Large List" listSize="large" width={280} />
<ListSizeColumn title="B · Medium Trigger + Medium List" listSize="medium" width={280} />
<ListSizeColumn title="A · Medium Trigger + Large List" listSize="large" width={384} />
<ListSizeColumn title="B · Medium Trigger + Medium List" listSize="medium" width={384} />
</div>
),
}
export const Scroll: Story = {
tags: ["verification"],
render: () => (
<div style={{ minHeight: 360, maxWidth: 280 }}>
<SelectSpecimen size="medium" label="Scrollable options" defaultOpen options={scrollOptions} />
</div>
),
}
// HM only: not part of the ui_viz Select Stories. Tagged verification so the default sidebar keeps only the representative Story.
export const Playground: Story = {
tags: ["verification"],
args: { label: "Select", placeholder: "Choose a status", size: "medium", disabled: false, defaultOpen: false },
render: (args) => narrow(<SelectSpecimen key={String(args.defaultOpen)} {...args} />),
}