Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
commit b6a0a61103
486 files changed
+68432

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
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-semibold">Selected Value</h3>
<SelectSpecimen size="medium" label="Status" defaultValue="active" disabled />
</section>
<section className="grid gap-2">
<h3 className="text-base font-semibold">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 content-start gap-2" style={{ width }} data-list-size={listSize}>
<h3 className="m-0 text-sm font-semibold">{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(280px,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} />),
}