@purpurds/slider 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LICENSE.txt +193 -0
- package/dist/slider.cjs.js +6 -0
- package/dist/slider.cjs.js.map +1 -0
- package/dist/slider.d.ts +21 -0
- package/dist/slider.d.ts.map +1 -0
- package/dist/slider.es.js +836 -0
- package/dist/slider.es.js.map +1 -0
- package/dist/styles.css +1 -0
- package/package.json +60 -0
- package/readme.mdx +81 -0
- package/src/global.d.ts +4 -0
- package/src/slider.module.scss +82 -0
- package/src/slider.stories.tsx +177 -0
- package/src/slider.test.tsx +63 -0
- package/src/slider.tsx +73 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useArgs } from "@storybook/client-api";
|
|
3
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
4
|
+
|
|
5
|
+
import { Slider } from "./slider";
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof Slider> = {
|
|
8
|
+
title: "Inputs/Slider",
|
|
9
|
+
component: Slider,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof Slider>;
|
|
14
|
+
|
|
15
|
+
const decorators: Story["decorators"] = [
|
|
16
|
+
(Story) => (
|
|
17
|
+
<div style={{ maxWidth: "22rem", height: "10rem" }}>
|
|
18
|
+
<Story />
|
|
19
|
+
</div>
|
|
20
|
+
),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const parameters = {
|
|
24
|
+
// Design is missing and component is in Alpha-phase.
|
|
25
|
+
// TODO: [Flamingo] - Uncomment once design is approved
|
|
26
|
+
// design: [
|
|
27
|
+
// {
|
|
28
|
+
// name: "Slider",
|
|
29
|
+
// type: "figma",
|
|
30
|
+
// url: "https://www.figma.com/file/...",
|
|
31
|
+
// },
|
|
32
|
+
// ],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const ControlledSingle: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
"data-testid": "test-id",
|
|
38
|
+
"aria-label": "Slider thumb",
|
|
39
|
+
id: "slider",
|
|
40
|
+
max: 100,
|
|
41
|
+
min: 0,
|
|
42
|
+
minStepsBetweenThumbs: 0,
|
|
43
|
+
name: "slider",
|
|
44
|
+
step: 1,
|
|
45
|
+
value: [25],
|
|
46
|
+
orientation: "horizontal",
|
|
47
|
+
},
|
|
48
|
+
argTypes: {
|
|
49
|
+
defaultValue: {
|
|
50
|
+
table: {
|
|
51
|
+
disable: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
parameters,
|
|
56
|
+
decorators,
|
|
57
|
+
render: ({ ...args }) => {
|
|
58
|
+
const [{ value = args.defaultValue }, updateArgs] = useArgs(); // eslint-disable-line react-hooks/rules-of-hooks
|
|
59
|
+
const setValue = (value: [number] | [number, number]) => updateArgs({ value });
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<h3>Controlled (single)</h3>
|
|
63
|
+
<p>Value: {value?.[0]}</p>
|
|
64
|
+
<Slider {...args} onValueChange={setValue} />
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const ControlledMulti: Story = {
|
|
71
|
+
args: {
|
|
72
|
+
"data-testid": "test-id",
|
|
73
|
+
"aria-label": "Slider thumb",
|
|
74
|
+
id: "slider",
|
|
75
|
+
max: 100,
|
|
76
|
+
min: 0,
|
|
77
|
+
minStepsBetweenThumbs: 0,
|
|
78
|
+
name: "slider",
|
|
79
|
+
step: 1,
|
|
80
|
+
value: [25, 75],
|
|
81
|
+
orientation: "horizontal",
|
|
82
|
+
},
|
|
83
|
+
argTypes: {
|
|
84
|
+
defaultValue: {
|
|
85
|
+
table: {
|
|
86
|
+
disable: true,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
parameters,
|
|
91
|
+
decorators,
|
|
92
|
+
render: ({ ...args }) => {
|
|
93
|
+
const [{ value = args.defaultValue }, updateArgs] = useArgs(); // eslint-disable-line react-hooks/rules-of-hooks
|
|
94
|
+
const setValue = (value: [number] | [number, number]) => updateArgs({ value });
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
<h3>Controlled (multi)</h3>
|
|
98
|
+
<p>Thumb #1 value: {value?.[0]}</p>
|
|
99
|
+
<p>Thumb #2 value: {value?.[1]}</p>
|
|
100
|
+
<Slider {...args} onValueChange={setValue} />
|
|
101
|
+
</>
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const UncontrolledSingle: Story = {
|
|
107
|
+
args: {
|
|
108
|
+
"data-testid": "test-id",
|
|
109
|
+
"aria-label": "Slider thumb",
|
|
110
|
+
defaultValue: [50],
|
|
111
|
+
max: 100,
|
|
112
|
+
min: 0,
|
|
113
|
+
minStepsBetweenThumbs: 0,
|
|
114
|
+
name: "slider",
|
|
115
|
+
step: 1,
|
|
116
|
+
orientation: "horizontal",
|
|
117
|
+
onValueChange: undefined,
|
|
118
|
+
},
|
|
119
|
+
argTypes: {
|
|
120
|
+
onValueChange: {
|
|
121
|
+
table: {
|
|
122
|
+
disable: true,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
value: {
|
|
126
|
+
table: {
|
|
127
|
+
disable: true,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
parameters,
|
|
132
|
+
decorators,
|
|
133
|
+
render: ({ ...args }) => {
|
|
134
|
+
return (
|
|
135
|
+
<form style={{ height: "100%" }}>
|
|
136
|
+
<h3>Uncontrolled (single)</h3>
|
|
137
|
+
<Slider {...args} />
|
|
138
|
+
</form>
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const UncontrolledMulti: Story = {
|
|
144
|
+
args: {
|
|
145
|
+
"data-testid": "test-id",
|
|
146
|
+
"aria-label": "Slider thumb",
|
|
147
|
+
defaultValue: [25, 75],
|
|
148
|
+
max: 100,
|
|
149
|
+
min: 0,
|
|
150
|
+
minStepsBetweenThumbs: 0,
|
|
151
|
+
name: "slider",
|
|
152
|
+
step: 1,
|
|
153
|
+
orientation: "horizontal",
|
|
154
|
+
},
|
|
155
|
+
argTypes: {
|
|
156
|
+
onValueChange: {
|
|
157
|
+
table: {
|
|
158
|
+
disable: true,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
value: {
|
|
162
|
+
table: {
|
|
163
|
+
disable: true,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
parameters,
|
|
168
|
+
decorators,
|
|
169
|
+
render: ({ ...args }) => {
|
|
170
|
+
return (
|
|
171
|
+
<form style={{ height: "100%" }}>
|
|
172
|
+
<h3>Uncontrolled (multi)</h3>
|
|
173
|
+
<Slider {...args} />
|
|
174
|
+
</form>
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import * as matchers from "@testing-library/jest-dom/matchers";
|
|
3
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { Slider } from "./slider";
|
|
7
|
+
|
|
8
|
+
expect.extend(matchers);
|
|
9
|
+
|
|
10
|
+
describe("Slider", () => {
|
|
11
|
+
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
|
12
|
+
observe: vi.fn(),
|
|
13
|
+
unobserve: vi.fn(),
|
|
14
|
+
disconnect: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
afterEach(cleanup);
|
|
17
|
+
|
|
18
|
+
it("should render a slider with one thumb when default value contains a single number", async () => {
|
|
19
|
+
render(<Slider aria-label="thumb-aria-label" data-testid="slider-root" value={[20]} />);
|
|
20
|
+
|
|
21
|
+
const rootElement = screen.getByTestId("slider-root");
|
|
22
|
+
expect(rootElement).toBeInTheDocument();
|
|
23
|
+
|
|
24
|
+
expect(screen.getAllByRole("slider")).toHaveLength(1);
|
|
25
|
+
|
|
26
|
+
const thumbElement = screen.getByRole("slider");
|
|
27
|
+
expect(thumbElement).toHaveAttribute("aria-valuenow", "20");
|
|
28
|
+
|
|
29
|
+
// Only possible with input field from uncontrolled / using form.
|
|
30
|
+
expect(screen.queryByDisplayValue("30")).not.toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should render a slider with two thumbs when default value contains two numbers", async () => {
|
|
34
|
+
render(<Slider aria-label="thumb-aria-label" data-testid="slider-root" value={[30, 50]} />);
|
|
35
|
+
|
|
36
|
+
const rootElement = screen.getByTestId("slider-root");
|
|
37
|
+
expect(rootElement).toBeInTheDocument();
|
|
38
|
+
|
|
39
|
+
const thumbElements = screen.getAllByRole("slider");
|
|
40
|
+
expect(thumbElements).toHaveLength(2);
|
|
41
|
+
|
|
42
|
+
expect(thumbElements[0]).toHaveAttribute("aria-valuenow", "30");
|
|
43
|
+
expect(thumbElements[1]).toHaveAttribute("aria-valuenow", "50");
|
|
44
|
+
|
|
45
|
+
// Only possible with input field from uncontrolled / using form.
|
|
46
|
+
expect(screen.queryByDisplayValue("30")).not.toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should render form input", async () => {
|
|
50
|
+
render(
|
|
51
|
+
<form>
|
|
52
|
+
<Slider
|
|
53
|
+
aria-label="thumb-aria-label"
|
|
54
|
+
data-testid="slider-root"
|
|
55
|
+
defaultValue={[30]}
|
|
56
|
+
name="slider"
|
|
57
|
+
/>
|
|
58
|
+
</form>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(screen.getByDisplayValue("30")).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/slider.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React, { ForwardedRef, forwardRef, useId } from "react";
|
|
2
|
+
import { Range, Root, Thumb, Track } from "@radix-ui/react-slider";
|
|
3
|
+
import c from "classnames/bind";
|
|
4
|
+
|
|
5
|
+
import styles from "./slider.module.scss";
|
|
6
|
+
const cx = c.bind(styles);
|
|
7
|
+
|
|
8
|
+
type SliderValue = [number] | [number, number];
|
|
9
|
+
|
|
10
|
+
export type SliderProps<T extends SliderValue> = {
|
|
11
|
+
["data-testid"]?: string;
|
|
12
|
+
/* The aria-label is set to each thumb. */
|
|
13
|
+
["aria-label"]: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
/* The value of the slider when initially rendered. Use when you do not need to control the state of the slider. */
|
|
16
|
+
defaultValue?: T;
|
|
17
|
+
/* The id of the slider. */
|
|
18
|
+
id?: string;
|
|
19
|
+
/* The maximum value for the range. */
|
|
20
|
+
max?: number;
|
|
21
|
+
/* The minimum value for the range. */
|
|
22
|
+
min?: number;
|
|
23
|
+
/* The minimum permitted steps between multiple thumbs. */
|
|
24
|
+
minStepsBetweenThumbs?: number;
|
|
25
|
+
/* The name of the slider. Submitted with its owning form as part of a name/value pair. */
|
|
26
|
+
name?: string;
|
|
27
|
+
/* The orientation of the slider. */
|
|
28
|
+
orientation?: "horizontal" | "vertical";
|
|
29
|
+
/* The stepping interval. */
|
|
30
|
+
step?: number;
|
|
31
|
+
/* The controlled value of the slider. Must be used in conjunction with onValueChange. */
|
|
32
|
+
value?: T;
|
|
33
|
+
/* Event handler called when the value changes. */
|
|
34
|
+
onValueChange?(value: T): void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const rootClassName = "purpur-slider";
|
|
38
|
+
|
|
39
|
+
const getKey = (index: number) => String(index);
|
|
40
|
+
|
|
41
|
+
const SliderComponent = <T extends SliderValue>(
|
|
42
|
+
{ "aria-label": ariaLabel, className, defaultValue, id, value, ...props }: SliderProps<T>,
|
|
43
|
+
ref: ForwardedRef<HTMLButtonElement>
|
|
44
|
+
) => {
|
|
45
|
+
const internalId = useId();
|
|
46
|
+
const internalValue = value || defaultValue;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Root
|
|
50
|
+
{...props}
|
|
51
|
+
id={id || internalId}
|
|
52
|
+
ref={ref}
|
|
53
|
+
className={cx(rootClassName, className)}
|
|
54
|
+
value={value}
|
|
55
|
+
defaultValue={defaultValue}
|
|
56
|
+
>
|
|
57
|
+
<Track className={cx(`${rootClassName}__track`)}>
|
|
58
|
+
<Range className={cx(`${rootClassName}__range`)} />
|
|
59
|
+
</Track>
|
|
60
|
+
{internalValue?.map((_, index) => (
|
|
61
|
+
<Thumb
|
|
62
|
+
key={getKey(index)}
|
|
63
|
+
className={cx(`${rootClassName}__thumb`)}
|
|
64
|
+
aria-label={ariaLabel}
|
|
65
|
+
/>
|
|
66
|
+
))}
|
|
67
|
+
</Root>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const Slider = forwardRef(SliderComponent);
|
|
72
|
+
|
|
73
|
+
Slider.displayName = "Slider";
|