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

160 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Slider } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative Story shows each Slider mode with real, draggable controls; the same specimens also exist as Stories tagged
// `verification` (hidden from the sidebar). The value label is the Story's composition (Slider has no built-in label).
// Names (`aria-label`) are provided by the consumer.
function Labeled({ label, children, valueText }: { label: string; children: ReactNode; valueText: string }) {
return (
<div className="grid gap-3">
<div className="flex justify-between text-sm">
<span className="font-medium text-foreground">{label}</span>
<span className="text-muted-foreground" data-testid="value">{valueText}</span>
</div>
{children}
</div>
)
}
function Single() {
const [value, setValue] = useState([40])
return (
<Labeled label="볼륨" valueText={`${value[0]}`}>
<Slider aria-label="볼륨" value={value} onValueChange={setValue} max={100} step={1} />
</Labeled>
)
}
function Range() {
const [value, setValue] = useState([25, 75])
return (
<Labeled label="범위" valueText={`${value[0]} – ${value[1]}`}>
<Slider aria-label="범위"value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
</Labeled>
)
}
function Step() {
const [value, setValue] = useState([50])
return (
<Labeled label="단계 (step 25)" valueText={`${value[0]}`}>
<Slider aria-label="단계" value={value} onValueChange={setValue} max={100} step={25} />
</Labeled>
)
}
function Disabled() {
return <Slider aria-label="비활성" defaultValue={[60]} disabled />
}
function Vertical() {
return (
<div style={{ height: 180 }} className="flex justify-center">
<Slider aria-label="세로" defaultValue={[30]} orientation="vertical" />
</div>
)
}
const sections = [
{ id: "single", label: "Single", node: <Single /> },
{ id: "range", label: "Range", node: <Range /> },
{ id: "step", label: "Step", node: <Step /> },
{ id: "disabled", label: "Disabled", node: <Disabled /> },
{ id: "vertical", label: "Vertical", node: <Vertical /> },
]
const meta = { title: "Components/Form/Slider", component: Slider, parameters: { layout: "padded" } } satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): 4 equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that (200px minimum per column). 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. A section with fewer than 4 specimens
// keeps the same column width and leaves the remaining tracks empty.
function SpecimenGrid({ ids }: { ids: string[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4">
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return <StateBoard key={id} min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (Single is its first item). `Vertical` centers its Slider in its column.
export const All: Story = {
name: "Slider",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Slider" />
<StorySection title="Variants">
<SpecimenGrid ids={["single", "range", "step", "vertical"]} />
</StorySection>
<StorySection title="States">
<SpecimenGrid ids={["disabled"]} />
</StorySection>
</StoryPage>
),
}
export const SingleValue: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
export const RangeValue: Story = { tags: ["verification"], name: "Range", render: () => <Range /> }
export const DisabledSlider: Story = { tags: ["verification"], name: "Disabled", render: () => <Disabled /> }
export const Focus: Story = {
tags: ["verification"],
render: () => (
<div className="grid gap-8">
<Single />
<Labeled label="비활성" valueText="60"><Disabled /></Labeled>
<output data-testid="slider-focus-result" hidden>Ready</output>
</div>
),
play: async ({ canvasElement }) => {
const thumb = canvasElement.querySelector<HTMLElement>('[data-slot="slider-thumb"]')!
const disabled = canvasElement.querySelector<HTMLElement>('[data-disabled] [data-slot="slider-thumb"]')!
const result = canvasElement.querySelector<HTMLOutputElement>('[data-testid="slider-focus-result"]')!
const states: Record<string, string> = {}
const read = (name: string, element = thumb) => { states[name] = getComputedStyle(element).boxShadow }
const assert = (condition: boolean, message: string) => { if (!condition) throw Error(message) }
thumb.blur()
read("Default")
// Existing Storybook pseudo-state mirroring preserves production selector order/specificity.
thumb.setAttribute("data-sb-hover", "")
read("Hover")
thumb.setAttribute("data-sb-active", "")
read("Drag")
thumb.removeAttribute("data-sb-hover")
thumb.removeAttribute("data-sb-active")
thumb.focus()
assert(thumb.matches(":focus-visible"), "Keyboard focus must be visible")
read("Focus")
thumb.setAttribute("data-sb-hover", "")
read("Focus + Hover")
thumb.setAttribute("data-sb-active", "")
read("Focus + Drag")
disabled.setAttribute("data-sb-hover", "")
disabled.setAttribute("data-sb-active", "")
disabled.focus()
read("Disabled", disabled)
assert(document.activeElement !== disabled, "Disabled thumb cannot receive focus")
assert(states.Default === "none" && states.Disabled === "none", "Default/Disabled have no halo")
assert(states.Hover.includes("4px") && states.Drag === states.Hover.replace("4px", "6px"), "Interaction keeps 4/6px and its color")
assert(states.Focus.includes("4px") && states["Focus + Hover"] === states.Focus, "Hover cannot mask Focus")
assert(states["Focus + Drag"] === states.Focus.replace("4px", "6px"), "Drag keeps 6px without masking Focus color")
for (const element of [thumb, disabled]) {
element.removeAttribute("data-sb-hover")
element.removeAttribute("data-sb-active")
}
thumb.focus()
result.textContent = JSON.stringify(states)
},
}