Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -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} />),
|
||||
}
|
||||
Reference in new issue
Block a user