Skip to content

Commit 60d0b21

Browse files
committed
feat(Slider): Add slider component
1 parent 06fd485 commit 60d0b21

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

docs/src/ui/slider.stories.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useState } from "react"
2+
3+
import { type StoryObj, type Meta } from "@storybook/react-vite"
4+
import { Slider, type SliderProps } from "boring-blocks"
5+
import { action } from "storybook/actions"
6+
7+
import { argType } from "../utils/arg-type"
8+
9+
const meta = {
10+
title: "Inputs/Slider",
11+
component: Slider,
12+
argTypes: {
13+
label: argType.string(),
14+
value: argType.disabled(),
15+
unit: argType.string(),
16+
min: argType.number(),
17+
max: argType.number(),
18+
step: argType.number(),
19+
disabled: argType.boolean(),
20+
21+
onChange: argType.callback(),
22+
onFocus: argType.callback(),
23+
onBlur: argType.callback(),
24+
},
25+
args: {
26+
label: "Length",
27+
value: 50,
28+
unit: "cm",
29+
min: 0,
30+
max: 100,
31+
step: 1,
32+
disabled: false,
33+
onChange: action("onChange"),
34+
onFocus: action("onFocus"),
35+
onBlur: action("onBlur"),
36+
},
37+
} satisfies Meta<typeof Slider>
38+
39+
export default meta
40+
41+
type Story = StoryObj<typeof meta>
42+
43+
const ControlledStory = ({
44+
value: initialValue,
45+
onChange,
46+
...props
47+
}: SliderProps) => {
48+
const [value, setValue] = useState(initialValue)
49+
return (
50+
<Slider
51+
{...props}
52+
value={value}
53+
onChange={(value, event) => {
54+
onChange?.(value, event)
55+
setValue(value)
56+
}}
57+
/>
58+
)
59+
}
60+
61+
export const Default: Story = { render: ControlledStory }

src/components/ui/slider.tsx

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { type HTMLProps, type ChangeEvent, useId } from "react"
2+
3+
import { css } from "goober"
4+
5+
import { hstack } from "../../styles/stack"
6+
import { theme } from "../../theme"
7+
import { cn } from "../../utils/cn"
8+
9+
const trackStyles = `
10+
background: ${theme.read("color.stroke.default")};
11+
height: var(--track-width);
12+
border-radius: 50vh;
13+
`
14+
15+
const thumbStyles = `
16+
appearance: none;
17+
height: var(--slider-size);
18+
width: var(--slider-size);
19+
background: ${theme.read("color.background.default")};
20+
outline: var(--track-width) solid ${theme.read("color.stroke.button")};
21+
margin-top: -0.4rem;
22+
border-radius: 50%;
23+
cursor: grab;
24+
border: none;
25+
`
26+
27+
const thumbActiveStyles = `
28+
cursor: grabbing;
29+
`
30+
31+
const slider = css`
32+
--track-width: 0.125rem;
33+
--slider-size: 1rem;
34+
35+
appearance: none;
36+
display: inline-block;
37+
background: transparent;
38+
cursor: pointer;
39+
height: 2rem;
40+
border-radius: 0.25rem;
41+
42+
&:active {
43+
cursor: grabbing;
44+
}
45+
46+
&::-moz-range-track {
47+
${trackStyles}
48+
}
49+
&::-moz-range-thumb {
50+
${thumbStyles}
51+
}
52+
53+
input[type="range"]& {
54+
&::-webkit-slider-runnable-track {
55+
${trackStyles}
56+
}
57+
&::-webkit-slider-thumb {
58+
${thumbStyles}
59+
}
60+
}
61+
62+
&:active {
63+
&::-moz-range-thumb {
64+
${thumbActiveStyles}
65+
}
66+
67+
input[type="range"]& {
68+
&::-webkit-slider-thumb {
69+
${thumbActiveStyles}
70+
}
71+
}
72+
}
73+
`
74+
75+
type BaseProps = HTMLProps<HTMLInputElement>
76+
77+
export interface SliderProps extends Pick<
78+
BaseProps,
79+
"className" | "style" | "onBlur" | "onFocus" | "disabled"
80+
> {
81+
label: string
82+
unit?: string
83+
value: number
84+
onChange?: (value: number, event: ChangeEvent<HTMLInputElement>) => void
85+
min?: number
86+
max?: number
87+
step?: number
88+
}
89+
90+
export const Slider = ({
91+
label,
92+
unit,
93+
style,
94+
className,
95+
value,
96+
onChange,
97+
...props
98+
}: SliderProps) => {
99+
const id = useId()
100+
101+
return (
102+
<div className={cn("w-full", className)} style={style}>
103+
<div className={hstack({ justify: "between", align: "center" })}>
104+
<label htmlFor={id} className="text-sm text-text-gentle">
105+
{label}
106+
</label>
107+
<span className="text-sm text-text-gentle">
108+
{value}
109+
{!unit ? null : ` ${unit}`}
110+
</span>
111+
</div>
112+
<input
113+
{...props}
114+
id={id}
115+
type="range"
116+
value={value}
117+
onChange={event => onChange?.(Number(event.currentTarget.value), event)}
118+
className={cn(slider, "w-full text-text-priority")}
119+
/>
120+
</div>
121+
)
122+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from "./components/ui/number-input"
1717
export * from "./components/ui/pagination"
1818
export * from "./components/ui/popover"
1919
export * from "./components/ui/select"
20+
export * from "./components/ui/slider"
2021
export * from "./components/ui/spinner"
2122
export * from "./components/ui/text-area"
2223
export * from "./components/ui/text-input"

0 commit comments

Comments
 (0)