193 lines
8.6 KiB
TypeScript
193 lines
8.6 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 { StateBoard } from "@/components/story-board"
|
|
import { STORY_KICKER, 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 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 text-muted-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>
|
|
)
|
|
}
|
|
|
|
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 hoverNote = "Hover는 Default와 같습니다(사용자 결정 TEXTAREA-05). 포인터를 올려도 면, 테두리, 글자색이 바뀌지 않습니다."
|
|
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
|
|
kicker={STORY_KICKER.components}
|
|
title="Textarea"
|
|
description="여러 줄 텍스트를 입력하는 컨트롤입니다. 최소 높이 112px, resize 기본 vertical이며 size 체계는 없습니다. Label·Helper·Error 문구는 소비 조합입니다."
|
|
/>
|
|
<StorySection title="Overview">
|
|
<StateBoard states={[{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }]} />
|
|
</StorySection>
|
|
<StorySection title="States" description="Focus는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 Default와 같습니다.">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "hover", label: "Hover (= Default)", note: hoverNote, noteAfter: true, children: <Specimen defaultValue="Hover over this field" /> },
|
|
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> },
|
|
{ id: "readonly", label: "Readonly", note: readonlyNote, noteAfter: true, children: <Specimen readOnly defaultValue="Confirmed description" /> },
|
|
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시하고, 오류 문구는 소비 조합으로 연결합니다.">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> },
|
|
{ id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="Usage" description="줄바꿈 유지와 resize 해제입니다.">
|
|
<StateBoard
|
|
states={[
|
|
{ id: "multiline", label: "Multiline · Wrap", children: <Specimen defaultValue={multiline} /> },
|
|
{ id: "resize", label: "Resize · none", children: <Specimen resize="none" defaultValue="This field cannot be resized." /> },
|
|
]}
|
|
/>
|
|
</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 />),
|
|
}
|
|
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} />),
|
|
}
|