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 (
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 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(
,
}
// 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 (