136 lines
6.5 KiB
TypeScript
136 lines
6.5 KiB
TypeScript
import { useId, useState, type ReactNode } from "react"
|
|
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
import { Checkbox, Label } from "@hm-design/ui"
|
|
import { StateBoard, type BoardState } from "@/components/story-board"
|
|
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
|
|
|
// Current HM Checkbox. ui-viz has no Checkbox spec, so it was reference only.
|
|
// The option label is the HM Label; its foreground role for Checkbox options is still TBD in ui_viz.
|
|
|
|
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
|
|
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
|
|
// text color; a Label next to a disabled Checkbox (`peer-disabled`) keeps the secondary color. State titles are untouched.
|
|
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
|
|
|
|
function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
|
|
const id = useId()
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
{children(id)}
|
|
<Label htmlFor={id} className={optionLabel}>{label}</Label>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking
|
|
// to fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's
|
|
// auto-fill grid cannot leave an empty or stretched track.
|
|
function StateGrid({ states }: { states: BoardState[] }) {
|
|
return (
|
|
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-3">
|
|
{states.map((state) => (
|
|
<StateBoard key={state.id} min={10000} states={[state]} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// The wrapping Long Label example (former Usage section of the representative page), also used by the hidden `LongLabel` Story.
|
|
function LongLabelExample() {
|
|
return (
|
|
<div className="max-w-[220px]">
|
|
<div className="flex items-start gap-2">
|
|
<Checkbox id="cb-long" className="mt-0.5" />
|
|
<Label htmlFor="cb-long" className={optionLabel}>A very long checkbox label that wraps onto multiple lines</Label>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Starts indeterminate; a click makes it checked (Radix), so the Indeterminate example is also the interactive one.
|
|
function Controlled() {
|
|
const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate")
|
|
return (
|
|
<Option label="Option">
|
|
{(id) => <Checkbox id={id} checked={checked} onCheckedChange={setChecked} />}
|
|
</Option>
|
|
)
|
|
}
|
|
|
|
const meta = {
|
|
title: "Components/Form/Checkbox",
|
|
component: Checkbox,
|
|
parameters: {
|
|
layout: "padded",
|
|
docs: {
|
|
description: {
|
|
component:
|
|
"현재 HM 규격: 18px 박스, radius 4, border 1px(stroke), Checked/Indeterminate는 state-active 테두리·면 + on-state-active 아이콘. Radix Checkbox의 data-state, indeterminate, 키보드(Space)와 접근성을 유지합니다. ui-viz에는 Checkbox 규격이 없어 참고만 했습니다.",
|
|
},
|
|
},
|
|
},
|
|
} satisfies Meta<typeof Checkbox>
|
|
export default meta
|
|
type Story = StoryObj<typeof meta>
|
|
|
|
export const All: Story = {
|
|
name: "Checkbox",
|
|
parameters: { layout: "fullscreen", controls: { disable: true } },
|
|
render: () => (
|
|
<StoryPage>
|
|
<StoryPageHeader title="Checkbox" />
|
|
<StorySection title="States">
|
|
<StateGrid
|
|
states={[
|
|
{ id: "unchecked", label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
|
|
{ id: "checked", label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> },
|
|
{ id: "indeterminate", label: "Indeterminate", children: <Controlled /> },
|
|
{
|
|
id: "hover",
|
|
label: "Hover",
|
|
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
|
|
children: (
|
|
<div className="grid gap-3">
|
|
<Option label="Unchecked">{(id) => <Checkbox id={id} data-sb-hover="" />}</Option>
|
|
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked data-sb-hover="" />}</Option>
|
|
</div>
|
|
),
|
|
},
|
|
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
|
|
{
|
|
id: "disabled",
|
|
label: "Disabled",
|
|
children: (
|
|
<div className="grid gap-3">
|
|
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
|
|
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
<StorySection title="Validation">
|
|
<StateGrid states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
|
|
</StorySection>
|
|
</StoryPage>
|
|
),
|
|
play: async ({ canvasElement }) => {
|
|
// preventScroll: the page opens at its header, not at the States section.
|
|
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="focus"] [role="checkbox"]')?.focus({ preventScroll: true })
|
|
},
|
|
}
|
|
|
|
export const Unchecked: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} />}</Option> }
|
|
export const Checked: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> }
|
|
export const Indeterminate: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> }
|
|
export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option> }
|
|
export const Error: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }
|
|
// Former Usage section of the representative page, kept as its own Story.
|
|
export const LongLabel: Story = { tags: ["verification"], name: "Long Label", render: () => <LongLabelExample /> }
|
|
export const Playground: Story = {
|
|
tags: ["verification"],
|
|
args: { disabled: false, defaultChecked: false },
|
|
render: (args) => <Option label="Option">{(id) => <Checkbox id={id} {...args} />}</Option>,
|
|
}
|