313 lines
15 KiB
TypeScript
313 lines
15 KiB
TypeScript
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 (
|
||
<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 }: { spec: (typeof sizes)[number] }) {
|
||
return (
|
||
<section className="grid content-start gap-4" data-select-size={spec.size}>
|
||
<SelectSpecimen size={spec.size} label={spec.name} placeholder="Choose a status" />
|
||
<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>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function SizeComparison() {
|
||
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} />
|
||
))}
|
||
</div>
|
||
<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>
|
||
</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.
|
||
// 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: () => (
|
||
<StoryPage>
|
||
<StoryPageHeader
|
||
kicker={STORY_KICKER.components}
|
||
title="Select"
|
||
description="목록에서 하나의 값을 고르는 컨트롤입니다. Option은 Trigger의 size를 따르고 목록 폭은 Trigger와 같습니다."
|
||
/>
|
||
{/* Open stays in the first section: the list opens on load, so its trigger must be on the first screen. */}
|
||
<StorySection title="Overview" description="size를 생략한 기본 Select(Large 44px)와 열린 목록입니다. 목록은 Escape 또는 바깥 클릭으로 닫히고, Trigger를 클릭하면 다시 열립니다.">
|
||
<StateBoard
|
||
fixed
|
||
states={[
|
||
{ id: "default", label: "Default · Large (size 생략)", children: <SelectSpecimen /> },
|
||
{
|
||
id: "open",
|
||
label: "Open",
|
||
children: (
|
||
<div className="min-h-64">
|
||
<SelectSpecimen size="medium" defaultOpen />
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</StorySection>
|
||
<StorySection title="Sizes" description="Medium 34 / Large 44px. Input과 독립된 두 크기입니다.">
|
||
<SizeComparison />
|
||
</StorySection>
|
||
<StorySection title="States" description="값이 선택된 상태와 Disabled입니다.">
|
||
<StateBoard
|
||
fixed
|
||
states={[
|
||
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" defaultValue="active" /> },
|
||
{ id: "disabled", label: "Disabled", children: <DisabledStates /> },
|
||
]}
|
||
/>
|
||
</StorySection>
|
||
<StorySection title="Invalid / Focus" description="aria-invalid는 Error 테두리로 표시합니다. Tab으로 Trigger에 포커스해 Invalid + Focus를 확인합니다.">
|
||
<StateBoard
|
||
fixed
|
||
states={[
|
||
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Invalid Select" defaultValue="active" /> },
|
||
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Disabled Invalid Select" /> },
|
||
]}
|
||
/>
|
||
</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} />),
|
||
}
|