Files
HMDesign/src/components/input.stories.tsx
T

223 lines
11 KiB
TypeScript

import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Input, type InputSize } 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/Input.stories.jsx and InputSizes.stories.jsx):
// one representative "Input" Story shows the sizes and every state on one page; the same specimens also exist as
// separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
//
// Label, Required star and Helper/Error message are not part of the Input component. Specimen below composes them with
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the
// secondary text color, Error message 11/18 in the destructive color, field gap 8.
function Specimen({
label = "Project name",
message,
error,
required,
id,
...input
}: ComponentProps<typeof Input> & { label?: string; message?: string; error?: boolean }) {
const uid = useId()
const fieldId = id ?? `input-${uid}`
const messageId = `${fieldId}-message`
return (
<div className="grid gap-2">
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium text-muted-foreground${input.disabled ? " opacity-60" : ""}`}>
{label}
{required ? (
<span aria-hidden="true" className="text-destructive">
{" *"}
</span>
) : null}
</label>
<Input
id={fieldId}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={message ? messageId : undefined}
{...input}
/>
{message ? (
<p id={messageId} className={error ? "text-[11px] leading-[18px] text-destructive" : "text-xs leading-[18px] text-muted-foreground"}>
{message}
</p>
) : null}
</div>
)
}
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
const placeholder = "Enter a project name"
// ui_viz docs/components/input.md §9, §24: display metadata only, never sets control geometry.
const sizes: { size: InputSize; name: string; height: number; padding: number; typography: string; usage: string }[] = [
{ size: "small", name: "Small", height: 28, padding: 10, typography: "12 / 18 / 400", usage: "Small control" },
{ size: "medium", name: "Medium", height: 34, padding: 12, typography: "14 / 20 / 400", usage: "Business forms / Search" },
{ size: "large", name: "Large", height: 42, padding: 12, typography: "16 / 24 / 400", usage: "Component default" },
]
function SizeCard({ spec }: { spec: (typeof sizes)[number] }) {
return (
<section className="grid content-start gap-4" data-input-size={spec.size}>
<Specimen size={spec.size} label={spec.name} defaultValue={`${spec.name} ${spec.height}px`} />
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">Height</dt>
<dd className="m-0">{spec.height}px</dd>
<dt className="text-muted-foreground">Padding</dt>
<dd className="m-0">0 / {spec.padding}px</dd>
<dt className="text-muted-foreground">Typography</dt>
<dd className="m-0">{spec.typography}</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">
높이는 입력 컨트롤의 외곽 높이입니다. 값·Placeholder 글자는 size를 따르고 Label 14/20/500은 공통입니다. 업무 폼·조회 조합은 Medium을 쓰며, size를 생략하면 Large입니다.
</p>
</div>
)
}
const readonlyNote =
"Readonly는 입력 상자 없이 확정된 텍스트만 표시합니다. size와 무관하게 Large 기준 typography와 42px 정렬 높이를 유지하며, Disabled처럼 흐리게 표시하지 않습니다."
const meta = {
title: "Components/Form/Input",
component: Input,
parameters: {
layout: "padded",
docs: {
description: {
component:
"ui_viz docs/components/input.md: Small 28 / Medium 34 / Large 42px, size를 생략하면 Large. Focus와 Error는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 없습니다. Label·Helper·Error 문구는 Input이 아니라 소비 조합입니다. Disabled 컨트롤은 흰 면과 opacity 0.6을 유지합니다(2026-10-02 사용자 결정). 이 Story 소비 조합의 Disabled Label도 0.6입니다(2026-10-11 최종 사용자 결정). 기본·Readonly·Error Label은 1, 공통 Label Component Disabled는 기존 0.5이며 Helper·Message는 변경하지 않습니다.",
},
},
},
argTypes: {
size: {
control: "inline-radio",
options: ["small", "medium", "large"],
description: "무지정 기본값 Large. 업무 폼·조회 소비 역할은 Medium.",
},
},
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: the common Story page (Overview → Sizes → States → Validation). Focus is placed by the play function.
// Named after the component, as in ui_viz.
export const All: Story = {
name: "Input",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.components}
title="Input"
description="한 줄 텍스트를 입력하는 컨트롤입니다. Label·Helper·Error 문구는 Input이 아니라 소비 조합입니다."
/>
<StorySection title="Overview" description="size를 생략한 기본 Input: Large 42px.">
<StateBoard states={[{ id: "default", label: "Default · size 생략(Large)", children: <Specimen placeholder={placeholder} /> }]} />
</StorySection>
<StorySection title="Sizes" description="Small 28 / Medium 34 / Large 42px. 업무 폼·조회 조합은 Medium을 씁니다.">
<SizeComparison />
</StorySection>
<StorySection title="States" description="Focus와 Error는 1.5px 테두리로 구분하며 Hover는 없습니다. Disabled는 흰 면을 유지하고 컨트롤과 소비 Label 각각 60%로 표시합니다. Readonly Label은 100%입니다.">
<StateBoard
states={[
{ id: "focus", label: "Focus", children: <Specimen size="medium" placeholder={placeholder} /> },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable project" /> },
{ id: "readonly", label: "Readonly", note: readonlyNote, noteAfter: true, children: <Specimen readOnly value="Confirmed project" /> },
]}
/>
</StorySection>
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시하고, 오류 문구는 소비 조합으로 연결합니다.">
<StateBoard
states={[
{ id: "error", label: "Error", children: <Specimen size="medium" error defaultValue="Invalid value" /> },
{ id: "error-message", label: "Error Message", children: <Specimen size="medium" placeholder={placeholder} error message="프로젝트명을 입력하세요." /> },
]}
/>
</StorySection>
</StoryPage>
),
play: async ({ canvasElement }) => {
// preventScroll: the page opens at its header, not at the States section.
canvasElement.querySelector<HTMLInputElement>('[data-overview-state="focus"] input')?.focus({ preventScroll: true })
},
}
// 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(<Specimen placeholder={placeholder} />),
parameters: { docs: { description: { story: "size를 생략한 실제 기본값(Large 42px)입니다. 업무 폼·조회 역할의 Medium 34px과 구분합니다." } } },
}
export const Focus: Story = {
tags: ["verification"],
render: () => narrow(<Specimen size="medium" placeholder={placeholder} />),
play: async ({ canvasElement }) => canvasElement.querySelector("input")?.focus(),
parameters: { docs: { description: { story: "Focus는 실제로 focus를 받은 컨트롤에서만 보입니다. play 함수가 focus를 줍니다. Tab 또는 클릭으로도 확인합니다." } } },
}
export const Error: Story = {
tags: ["verification"],
render: () => narrow(<Specimen size="medium" error defaultValue="Invalid value" />),
}
export const InvalidFocus: Story = {
tags: ["verification"],
render: () => narrow(<Specimen size="medium" error defaultValue="Invalid value" />),
play: async ({ canvasElement }) => canvasElement.querySelector("input")?.focus(),
}
export const ErrorMessage: Story = {
tags: ["verification"],
name: "Error Message",
render: () => narrow(<Specimen size="medium" placeholder={placeholder} error message="프로젝트명을 입력하세요." />),
}
export const Required: Story = {
tags: ["verification"],
render: () => narrow(<Specimen size="medium" placeholder={placeholder} required />),
}
export const Disabled: Story = {
tags: ["verification"],
render: () => narrow(<Specimen disabled defaultValue="Unavailable project" />),
parameters: { docs: { description: { story: "2026-10-11 최종 사용자 결정: Input과 이 소비 조합의 Label 각각 opacity 0.6, 부모는 1. Label 원본 색은 #6E6759이며 공통 Label Component의 Disabled 0.5와 구분합니다. Helper·Message는 이번 변경 대상이 아닙니다." } } },
}
export const Readonly: Story = {
tags: ["verification"],
render: () => narrow(<Specimen readOnly value="Confirmed project" />),
parameters: { docs: { description: { story: readonlyNote } } },
}
export const Sizes: Story = { tags: ["verification"], name: "Compare Sizes", render: () => <SizeComparison /> }
export const Small: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[0]} />) }
export const Medium: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[1]} />) }
export const Large: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[2]} />) }
// HM only: not part of the ui_viz Input Stories. Tagged verification so the default sidebar keeps only the representative Story.
export const Playground: Story = {
tags: ["verification"],
args: { size: "medium", type: "text", placeholder, disabled: false, readOnly: false },
argTypes: {
type: { control: "select", options: ["text", "email", "password", "search", "number", "tel", "url"] },
placeholder: { control: "text" },
disabled: { control: "boolean" },
readOnly: { control: "boolean" },
"aria-invalid": { control: "boolean" },
className: { table: { disable: true } },
},
render: (args) => narrow(<Specimen {...args} />),
}