Skip to content

Commit 4828145

Browse files
juliajforestiggazzotassoevan
authored
feat: Slider component (#826)
Co-authored-by: Guilherme Gazzo <5263975+ggazzo@users.noreply.github.com> Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>
1 parent 702c1ee commit 4828145

File tree

10 files changed

+2668
-137
lines changed

10 files changed

+2668
-137
lines changed

packages/fuselage/.storybook/main.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module.exports = {
77
features: {
88
postcss: false,
99
},
10-
addons: ['@storybook/addon-essentials'],
10+
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
1111
stories: ['../src/**/*.stories.{mdx,js,tsx}'],
1212
webpackFinal: (config) => {
1313
config.module.rules.push({

packages/fuselage/package.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@
5959
"@rocket.chat/memo": "workspace:~",
6060
"@rocket.chat/styled": "workspace:~",
6161
"invariant": "^2.2.4",
62-
"react-keyed-flatten-children": "^1.3.0"
62+
"react-aria": "~3.19.0",
63+
"react-keyed-flatten-children": "^1.3.0",
64+
"react-stately": "~3.17.0"
6365
},
6466
"devDependencies": {
6567
"@babel/core": "~7.17.2",
@@ -73,13 +75,16 @@
7375
"@rocket.chat/icons": "workspace:~",
7476
"@rocket.chat/prettier-config": "workspace:~",
7577
"@storybook/addon-essentials": "~6.4.18",
78+
"@storybook/addon-interactions": "~6.5.10",
7679
"@storybook/addon-links": "~6.4.18",
7780
"@storybook/addons": "~6.4.18",
7881
"@storybook/builder-webpack5": "~6.4.18",
7982
"@storybook/client-api": "~6.4.19",
83+
"@storybook/jest": "~0.0.10",
8084
"@storybook/manager-webpack5": "~6.4.18",
8185
"@storybook/react": "~6.4.18",
8286
"@storybook/source-loader": "~6.4.18",
87+
"@storybook/testing-library": "~0.0.13",
8388
"@storybook/testing-react": "~1.3.0",
8489
"@storybook/theming": "~6.4.18",
8590
"@testing-library/jest-dom": "~5.16.2",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
import { composeStories } from '@storybook/testing-react';
3+
import { fireEvent, render, screen } from '@testing-library/react';
4+
import React from 'react';
5+
6+
import * as stories from './Slider.stories';
7+
8+
const { Default, WithLabel, MultiThumb, WithDefaultValue } =
9+
composeStories(stories);
10+
11+
describe('[Slider Component]', () => {
12+
it('renders without crashing', () => {
13+
render(<Default />);
14+
});
15+
16+
it('should display the label when passed', () => {
17+
render(<WithLabel />);
18+
const label = screen.queryByText('Range');
19+
expect(label).toBeInTheDocument();
20+
expect(label?.textContent).toBe('Range');
21+
});
22+
23+
it('should output the defaultValue when passed', () => {
24+
render(<WithDefaultValue />);
25+
const output = screen.queryByTestId('slider-output');
26+
expect(output?.textContent).toBe('25');
27+
});
28+
29+
it('should have two thumbs when multiThumb prop is true', () => {
30+
render(<MultiThumb />);
31+
const thumbs = screen.queryAllByRole('slider');
32+
expect(thumbs.length).toBe(2);
33+
});
34+
35+
it("should update Thumb's position when Thumb is clicked and dragged", () => {
36+
render(<Default />);
37+
38+
const slider = screen.getByRole<HTMLFormElement>('slider');
39+
40+
slider.focus();
41+
fireEvent.keyDown(slider, { key: 'ArrowRight' });
42+
fireEvent.keyDown(slider, { key: 'ArrowRight' });
43+
fireEvent.keyDown(slider, { key: 'ArrowRight' });
44+
fireEvent.keyDown(slider, { key: 'ArrowRight' });
45+
46+
expect(slider.value).toBe('4');
47+
});
48+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import {
2+
Title,
3+
Description,
4+
Primary,
5+
Stories,
6+
ArgsTable,
7+
} from '@storybook/addon-docs';
8+
import type { ComponentMeta, ComponentStory } from '@storybook/react';
9+
import { screen, userEvent } from '@storybook/testing-library';
10+
import React, { useState } from 'react';
11+
12+
import Box from '../Box';
13+
import { Slider } from './Slider';
14+
15+
export default {
16+
title: 'Inputs/Slider',
17+
component: Slider,
18+
parameters: {
19+
docs: {
20+
page: () => (
21+
<>
22+
<Title />
23+
<Description />
24+
<Primary />
25+
<Stories title={''} />
26+
<ArgsTable />
27+
</>
28+
),
29+
},
30+
},
31+
} as ComponentMeta<typeof Slider>;
32+
33+
const Template: ComponentStory<typeof Slider> = (args) => (
34+
<Box width='500px' minHeight='100%' display='flex' alignItems='center'>
35+
<Slider {...args} />
36+
</Box>
37+
);
38+
39+
export const SliderPlayExample: ComponentStory<typeof Slider> = Template.bind(
40+
{}
41+
);
42+
SliderPlayExample.args = {
43+
'aria-label': 'aria-range-label',
44+
'maxValue': 50,
45+
} as const;
46+
SliderPlayExample.play = async () => {
47+
const slider = screen.getByRole<HTMLFormElement>('slider');
48+
userEvent.tab();
49+
await userEvent.type(slider, '{arrowright}'.repeat(50), {
50+
skipClick: true,
51+
delay: 1000,
52+
});
53+
};
54+
55+
export const Disabled = Template.bind({});
56+
Disabled.args = {
57+
disabled: true,
58+
};
59+
60+
export const Default: ComponentStory<typeof Slider> = Template.bind({});
61+
Default.args = {
62+
'aria-label': 'aria-range-label',
63+
'maxValue': 500,
64+
} as const;
65+
66+
export const Small: ComponentStory<typeof Slider> = Template.bind({});
67+
Small.args = {
68+
'aria-label': 'aria-range-label',
69+
'small': true,
70+
} as const;
71+
72+
export const Large: ComponentStory<typeof Slider> = Template.bind({});
73+
Large.args = {
74+
'aria-label': 'aria-range-label',
75+
'large': true,
76+
} as const;
77+
78+
export const NoOutput: ComponentStory<typeof Slider> = Template.bind({});
79+
NoOutput.args = {
80+
'showOutput': false,
81+
'aria-label': 'range',
82+
} as const;
83+
84+
export const WithLabel: ComponentStory<typeof Slider> = Template.bind({});
85+
WithLabel.args = {
86+
'label': 'Range',
87+
'aria-label': 'range',
88+
} as const;
89+
90+
export const Vertical: ComponentStory<typeof Slider> = Template.bind({});
91+
Vertical.args = {
92+
'label': 'Range',
93+
'aria-label': 'range',
94+
'orientation': 'vertical',
95+
} as const;
96+
97+
export const VerticalMultiThumb: ComponentStory<typeof Slider> = Template.bind(
98+
{}
99+
);
100+
VerticalMultiThumb.args = {
101+
'label': 'Range',
102+
'aria-label': 'range',
103+
'orientation': 'vertical',
104+
'multiThumb': true,
105+
} as const;
106+
107+
export const VerticalSmall: ComponentStory<typeof Slider> = Template.bind({});
108+
VerticalSmall.args = {
109+
'aria-label': 'aria-range-label',
110+
'small': true,
111+
'orientation': 'vertical',
112+
} as const;
113+
114+
export const WithDefaultValue: ComponentStory<typeof Slider> = Template.bind(
115+
{}
116+
);
117+
WithDefaultValue.args = {
118+
'defaultValue': 25,
119+
'aria-label': 'range',
120+
} as const;
121+
122+
export const MultiThumb: ComponentStory<typeof Slider> = Template.bind({});
123+
MultiThumb.args = {
124+
'aria-label': 'range',
125+
'multiThumb': true,
126+
'maxValue': 500,
127+
'step': 10,
128+
} as const;
129+
130+
export const ControlledValue: ComponentStory<typeof Slider> = () => {
131+
const [value, setValue] = useState<number>(20);
132+
return (
133+
<Box width='500px' height='80px' display='flex' alignItems='center'>
134+
<Slider label='Range' value={value} onChange={setValue} />
135+
</Box>
136+
);
137+
};
138+
139+
export const NumberFormatOptions: ComponentStory<typeof Slider> = () => (
140+
<Box width='500px' height='80px' display='flex' alignItems='center'>
141+
<Slider
142+
multiThumb
143+
label='Price range'
144+
defaultValue={[100, 350]}
145+
maxValue={500}
146+
step={10}
147+
formatOptions={{ style: 'currency', currency: 'USD' }}
148+
/>
149+
</Box>
150+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/* eslint-disable no-nested-ternary */
2+
import { css } from '@rocket.chat/css-in-js';
3+
import type { AriaAttributes, ReactElement } from 'react';
4+
import React, { useMemo, useRef } from 'react';
5+
import type { AriaSliderProps } from 'react-aria';
6+
import { useNumberFormatter, useSlider } from 'react-aria';
7+
import { useSliderState } from 'react-stately';
8+
9+
import { useStyle } from '../../hooks/useStyle';
10+
import { SliderHead } from './SliderHead';
11+
import { SliderThumb } from './SliderThumb';
12+
import { SliderTrack } from './SliderTrack';
13+
14+
type SliderProps<T extends number | number[]> = AriaAttributes & {
15+
/**
16+
* The display format of the value output.
17+
*/
18+
formatOptions?: Intl.NumberFormatOptions;
19+
label?: string;
20+
showOutput?: boolean;
21+
/**
22+
* Slider with multiple thumbs.
23+
* @default false
24+
*/
25+
multiThumb?: T extends number[] ? true : false;
26+
step?: number;
27+
/**
28+
* @default 0
29+
*/
30+
minValue?: number;
31+
/**
32+
* @default 100
33+
*/
34+
maxValue?: number;
35+
orientation?: 'horizontal' | 'vertical';
36+
disabled?: boolean;
37+
defaultValue?: T;
38+
small?: boolean;
39+
/**
40+
* 100% of parent's dimention
41+
*/
42+
large?: boolean;
43+
} & (
44+
| {
45+
value: T;
46+
onChange: (value: T) => void;
47+
}
48+
| {
49+
value?: never;
50+
onChange?: never;
51+
}
52+
);
53+
54+
export function Slider<T extends number | [min: number, max: number]>(
55+
props: SliderProps<T>
56+
): ReactElement {
57+
const {
58+
label,
59+
formatOptions,
60+
showOutput = true,
61+
multiThumb,
62+
maxValue,
63+
minValue,
64+
small,
65+
large,
66+
} = props;
67+
68+
// Get a defaultValue in the range for multiThumb
69+
const getMultiThumbDefaultValue = (): T | undefined => {
70+
if (multiThumb && !defaultValue) {
71+
if (minValue && maxValue) {
72+
return [minValue, maxValue] as T;
73+
}
74+
if (minValue) {
75+
return [minValue, 100] as T;
76+
}
77+
if (maxValue) {
78+
return [0, maxValue] as T;
79+
}
80+
return [0, 100] as T;
81+
}
82+
};
83+
84+
const { defaultValue = getMultiThumbDefaultValue() } = props;
85+
86+
const sliderProps = {
87+
...props,
88+
isDisabled: props.disabled,
89+
} as AriaSliderProps<number | number[]>;
90+
91+
const trackRef = useRef(null);
92+
const numberFormatter = useNumberFormatter(formatOptions);
93+
const sliderState = useSliderState({
94+
defaultValue,
95+
...sliderProps,
96+
numberFormatter,
97+
});
98+
99+
const { groupProps, trackProps, labelProps, outputProps } = useSlider(
100+
sliderProps,
101+
sliderState,
102+
trackRef
103+
);
104+
105+
const isHorizontal = useMemo(
106+
() => sliderState.orientation === 'horizontal',
107+
[sliderState.orientation]
108+
);
109+
const isVertical = useMemo(
110+
() => sliderState.orientation === 'vertical',
111+
[sliderState.orientation]
112+
);
113+
114+
const slider = useStyle(
115+
css`
116+
display: flex;
117+
${isHorizontal &&
118+
css`
119+
flex-direction: column;
120+
width: ${small ? '150px' : large ? '100%' : '300px'};
121+
`};
122+
${isVertical &&
123+
css`
124+
flex-direction: row-reverse;
125+
height: ${small ? '50px' : large ? '100%' : '100px'};
126+
`}
127+
`,
128+
sliderState
129+
);
130+
131+
return (
132+
<div {...groupProps} className={slider}>
133+
<SliderHead
134+
labelProps={labelProps}
135+
outputProps={outputProps}
136+
state={sliderState}
137+
showOutput={showOutput}
138+
label={label}
139+
multiThumb={multiThumb}
140+
/>
141+
<SliderTrack
142+
state={sliderState}
143+
trackProps={trackProps}
144+
trackRef={trackRef}
145+
multiThumb={multiThumb}
146+
>
147+
<SliderThumb index={0} state={sliderState} trackRef={trackRef} />
148+
{multiThumb && (
149+
<SliderThumb index={1} state={sliderState} trackRef={trackRef} />
150+
)}
151+
</SliderTrack>
152+
</div>
153+
);
154+
}

0 commit comments

Comments
 (0)