@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.
@@ -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";