160 lines
6.6 KiB
TypeScript
160 lines
6.6 KiB
TypeScript
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)
|
||
},
|
||
}
|