Files
HMDesign/src/components/textarea.stories.tsx
T
shkim4 a7b56ff23e style(storybook): unify form element story layouts
- Button: Sizes | Variants side by side, States as Brand / Standard Solid /
  Standard Outline matrices (3 / 2 / 1 columns), 8px button gap; the
  Interact row stays only in the verification "Brand States" Story
- Input / Textarea / Select: state name in the field Label slot, repeated
  field Label and group titles removed, aria-label keeps field meaning
  with the visible state name; Select uses the Input field grid
- Combobox / Date Picker: Sizes / States use the same state-name Label
  and Input grid; Usage unchanged
- Toggle: top Variants / States in 2 columns, 8px toggle gap, 단독 toggle
  keeps its own width
- Component code, props, tokens and verification Stories unchanged;
  component docs updated to match
2026-11-12 17:11:56 +09:00

208 lines
9.0 KiB
TypeScript

import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Textarea } from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
// also exist as separate Stories tagged `verification`, hidden from the sidebar by default (see .storybook/main.ts).
// ui_viz docs/components/textarea.md §6 Reference Viewer: Default, Hover, Focus, Error, Error + Focus, Readonly, Disabled and Multiline.
//
// Label, Required star and Helper/Error message are not part of the Textarea component. Specimen composes them with plain
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color (Disabled keeps
// the secondary color) and Helper 12/18/400 in the secondary text color, Error message 11/18 in the destructive color, field gap 8.
function Specimen({
label = "Description",
message,
error,
required,
id,
...textarea
}: ComponentProps<typeof Textarea> & { label?: string; message?: string; error?: boolean }) {
const uid = useId()
const fieldId = id ?? `textarea-${uid}`
const messageId = `${fieldId}-message`
return (
<div className="grid gap-2">
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${textarea.disabled ? "text-muted-foreground" : "text-foreground"}`}>
{label}
{required ? (
<span aria-hidden="true" className="text-destructive">
{" *"}
</span>
) : null}
</label>
<Textarea
id={fieldId}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={message ? messageId : undefined}
{...textarea}
/>
{message ? (
<p id={messageId} className={error ? "text-[11px] leading-[18px] text-destructive" : "text-xs leading-[18px] text-muted-foreground"}>
{message}
</p>
) : null}
</div>
)
}
// Representative-page layout (Storybook presentation only). 2026-11-12, same rule as Input: the state name sits in the field
// Label slot (no repeated "Description" under a separate group title); the textarea keeps a meaningful accessible name through
// aria-label, which also contains the visible state name (Label in Name). PC (iframe width 1000px and up): 4 equal columns;
// below that the columns shrink to fit (220px minimum, down to 1). The grid takes the common section indent (.sb-page-indent).
function FieldGrid({ fields }: { fields: { id: string; label: string; props: ComponentProps<typeof Specimen> }[] }) {
return (
<div className="sb-page-indent">
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-4">
{fields.map(({ id, label, props }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
<Specimen label={label} aria-label={`Description (${label})`} {...props} />
</div>
))}
</div>
</div>
)
}
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
const placeholder = "Enter a description"
const multiline = "First line\nSecond line keeps its line break.\nThird line."
const readonlyNote =
"Readonly는 Default의 면, 테두리, typography를 유지하고 흐리게 표시하지 않습니다. 값 변경은 막고 선택·복사는 허용하며 resize 표시가 없습니다."
const meta = {
title: "Components/Form/Textarea",
component: Textarea,
parameters: {
layout: "padded",
docs: {
description: {
component:
"ui_viz docs/components/textarea.md: 최소 높이 112px, 안쪽 여백 8/12px(Textarea 한정 owner 승인, SCOPED_DESIGN_APPROVAL), 값 16/24/400, resize는 기본 vertical. Focus와 Error는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 Default와 같습니다. Label·Helper·Error 문구는 Textarea가 아니라 소비 조합입니다. size 체계는 없습니다.",
},
},
},
argTypes: {
resize: {
control: "inline-radio",
options: ["vertical", "none", "both"],
description: "기본 vertical. Readonly에서는 항상 none.",
},
},
} satisfies Meta<typeof Textarea>
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: every state on one page. Focus is placed by the play function. Named after the component, as in ui_viz.
export const All: Story = {
name: "Textarea",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Textarea" />
{/* PC (iframe width 1000px and up): States in 4 equal columns, Validation's 2 specimens on the same column width.
Below that the columns shrink to fit (220px minimum, down to 1). Multiline and Resize live in their own hidden Stories. */}
<StorySection title="States">
<FieldGrid
fields={[
{ id: "default", label: "Default", props: { placeholder } },
{ id: "focus", label: "Focus", props: { placeholder } },
{ id: "readonly", label: "Readonly", props: { readOnly: true, defaultValue: "Confirmed description" } },
{ id: "disabled", label: "Disabled", props: { disabled: true, defaultValue: "Unavailable description" } },
]}
/>
</StorySection>
<StorySection title="Validation">
{/* Same 4-column track as States, so the two fields keep the States width instead of stretching across the page. */}
<FieldGrid
fields={[
{ id: "error", label: "Error", props: { error: true, defaultValue: "Invalid value" } },
{ id: "error-message", label: "Error Message", props: { error: true, message: "설명을 입력하세요." } },
]}
/>
</StorySection>
</StoryPage>
),
play: async ({ canvasElement }) => {
// preventScroll: the page opens at its header, not at the States section.
canvasElement.querySelector<HTMLTextAreaElement>('[data-overview-state="focus"] textarea')?.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} />),
}
export const Focus: Story = {
tags: ["verification"],
render: () => narrow(<Specimen placeholder={placeholder} />),
play: async ({ canvasElement }) => canvasElement.querySelector("textarea")?.focus(),
parameters: { docs: { description: { story: "Focus는 실제로 focus를 받은 컨트롤에서만 보입니다. play 함수가 focus를 줍니다. Tab 또는 클릭으로도 확인합니다." } } },
}
export const Error: Story = {
tags: ["verification"],
render: () => narrow(<Specimen error defaultValue="Invalid value" />),
}
export const InvalidFocus: Story = {
tags: ["verification"],
render: () => narrow(<Specimen error defaultValue="Invalid value" />),
play: async ({ canvasElement }) => canvasElement.querySelector("textarea")?.focus(),
}
export const ErrorMessage: Story = {
tags: ["verification"],
name: "Error Message",
render: () => narrow(<Specimen error message="설명을 입력하세요." />),
}
export const Required: Story = {
tags: ["verification"],
render: () => narrow(<Specimen required />),
}
// Former Usage section of the representative page, kept as separate Stories.
export const Multiline: Story = {
tags: ["verification"],
name: "Multiline · Wrap",
render: () => narrow(<Specimen defaultValue={multiline} />),
}
export const ResizeNone: Story = {
tags: ["verification"],
name: "Resize · none",
render: () => narrow(<Specimen resize="none" defaultValue="This field cannot be resized." />),
}
export const Readonly: Story = {
tags: ["verification"],
render: () => narrow(<Specimen readOnly defaultValue="Confirmed description" />),
parameters: { docs: { description: { story: readonlyNote } } },
}
export const Disabled: Story = {
tags: ["verification"],
render: () => narrow(<Specimen disabled defaultValue="Unavailable description" />),
}
export const LongContent: Story = {
tags: ["verification"],
name: "Long Content",
render: () =>
narrow(
<Specimen
defaultValue={Array.from({ length: 12 }, (_, i) => `Line ${i + 1}: a long value keeps its line order and scrolls inside the control.`).join("\n")}
/>
),
}
// HM only: not part of the ui_viz Textarea Stories. Tagged verification so the default sidebar keeps only the representative Story.
export const Playground: Story = {
tags: ["verification"],
args: { resize: "vertical", placeholder, disabled: false, readOnly: false },
argTypes: {
placeholder: { control: "text" },
disabled: { control: "boolean" },
readOnly: { control: "boolean" },
"aria-invalid": { control: "boolean" },
className: { table: { disable: true } },
},
render: (args) => narrow(<Specimen {...args} />),
}