Drop the category kicker from the 83 sidebar Stories (kicker is now optional; hidden verification Stories keep it) and shorten or remove repeated, obvious or internal page/section descriptions. Required specs, limits, virtual-data and undecided-value notices stay. Story IDs, exports, fixtures, shared state notes and package code are unchanged. Record: docs/reports/storybook-ui-simplification-2026-10-22.md
222 lines
11 KiB
TypeScript
222 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 { 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
|
|
title="Input"
|
|
description="Label·Helper·Error 문구는 소비 조합"
|
|
/>
|
|
<StorySection title="Overview" description="기본값: Large 42px">
|
|
<StateBoard states={[{ id: "default", label: "Default · size 생략(Large)", children: <Specimen placeholder={placeholder} /> }]} />
|
|
</StorySection>
|
|
<StorySection title="Sizes" description="28 / 34 / 42px · 업무 폼·조회는 Medium">
|
|
<SizeComparison />
|
|
</StorySection>
|
|
<StorySection title="States" description="Focus·Error 1.5px 테두리 · Hover 없음 · Disabled 60%">
|
|
<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} />),
|
|
}
|