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 { STORY_KICKER, 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 (
) } const narrow = (children: ReactNode) =>
{children}
// 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 }: { spec: (typeof sizes)[number] }) { return (
Outer Height / Trigger
{spec.outer}
Trigger Typography
{spec.typography}
Option
{spec.option}
Usage
{spec.usage}
) } function SizeComparison() { return (
{sizes.map((spec) => ( ))}

Select는 Input과 독립된 두 크기(Medium 34 / Large 44)입니다. 각 Trigger를 클릭해 연결된 목록과 선택 상태를 확인하세요. 목록의 Option은 Trigger의 size를 그대로 따르며(최소 높이 42px, Medium 14/20/400, Large 16/24/400) 폭은 항상 Trigger와 같습니다.

) } function DisabledStates() { return (

Selected Value

Placeholder

) } 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 export default meta type Story = StoryObj // Representative Story: every state on one page. Named after the component, as in ui_viz. // The open list is a Radix popover: press Escape or click outside to close it, click the trigger to reopen. export const All: Story = { name: "Select", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( {/* Open stays in the first section: the list opens on load, so its trigger must be on the first screen. */} }, { id: "open", label: "Open", children: (
), }, ]} />
}, { id: "disabled", label: "Disabled", children: }, ]} /> }, { id: "invalid-disabled", label: "Invalid · Disabled", children: }, ]} />
), } // 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() } export const InvalidFocus: Story = { tags: ["verification"], render: () => (
), } export const Open: Story = { tags: ["verification"], render: () => narrow(
), } export const Selected: Story = { tags: ["verification"], render: () => narrow() } export const Disabled: Story = { tags: ["verification"], render: () => } export const Sizes: Story = { tags: ["verification"], name: "Compare Sizes", render: () => } export const Medium: Story = { tags: ["verification"], render: () => narrow() } export const Large: Story = { tags: ["verification"], render: () => narrow() } // 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: () => (
), } // 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(
), } export const OpenMediumLong: Story = { tags: ["verification"], render: () =>
, } export const OpenLargeLong: Story = { tags: ["verification"], render: () =>
, } // Place the real Trigger near the viewport bottom so Radix collision detection flips it. const atBottom = (children: ReactNode) => (
{children}
) export const OpenTop: Story = { tags: ["verification"], render: () => atBottom(), } export const OpenTopLong: Story = { tags: ["verification"], render: () => atBottom(), } // 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 (

{title}

) } export const ListSizeComparison: Story = { tags: ["verification"], name: "Medium Trigger / List Size Comparison", parameters: { controls: { disable: true } }, render: () => (
), } export const Scroll: Story = { tags: ["verification"], render: () => (
), } // 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(), }