Skip to content

Commit 42f6d49

Browse files
committed
feat: add range select component
1 parent d557f98 commit 42f6d49

6 files changed

Lines changed: 465 additions & 20 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React, { useState } from 'react';
2+
import { Meta, StoryFn } from '@storybook/react-webpack5';
3+
import { RangeSelect } from './RangeSelect';
4+
5+
export default {
6+
title: 'Components/RangeSelect',
7+
component: RangeSelect,
8+
argTypes: {
9+
placeholder: { control: { type: 'text' } },
10+
isDisabled: { control: { type: 'boolean' } },
11+
isInvalid: { control: { type: 'boolean' } },
12+
isFullWidth: { control: { type: 'boolean' } },
13+
onChange: { action: 'Range Changed' },
14+
onClose: { action: 'Dropdown Closed' },
15+
color: {
16+
control: { type: 'text' },
17+
},
18+
backgroundColor: {
19+
control: { type: 'text' },
20+
},
21+
borderColor: {
22+
control: { type: 'text' },
23+
},
24+
size: {
25+
options: ['xs', 'sm', 'md', 'lg'],
26+
defaultValue: 'md',
27+
control: { type: 'radio' },
28+
},
29+
variant: {
30+
options: ['filled', 'unstyled', 'flushed', 'outline'],
31+
defaultValue: 'outline',
32+
control: { type: 'radio' },
33+
},
34+
},
35+
} as Meta<typeof RangeSelect>;
36+
37+
const Template: StoryFn<typeof RangeSelect> = (args) => <RangeSelect {...args} />;
38+
39+
export const Default = Template.bind({});
40+
Default.args = {
41+
placeholder: 'Select a range',
42+
rangeFromLabel: 'From',
43+
rangeToLabel: 'To',
44+
};
45+
46+
export const WithValidation: StoryFn<typeof RangeSelect> = (args) => {
47+
const [value, setValue] = useState<string[]>(['', '']);
48+
const [fromError, setFromError] = useState<string>('');
49+
const [toError, setToError] = useState<string>('');
50+
51+
const handleChange = (newValue: string[]) => {
52+
setValue(newValue);
53+
54+
// Validation logic
55+
const rangeMin = 1;
56+
const rangeMax = 100;
57+
const fromStr = newValue[0] ?? '';
58+
const toStr = newValue[1] ?? '';
59+
const fromNum = fromStr === '' ? undefined : Number(fromStr);
60+
const toNum = toStr === '' ? undefined : Number(toStr);
61+
62+
let nextFromError = '';
63+
let nextToError = '';
64+
65+
if (fromNum !== undefined && !Number.isNaN(fromNum)) {
66+
if (fromNum < rangeMin) nextFromError = `Value must be at least ${rangeMin}`;
67+
else if (fromNum > rangeMax) nextFromError = `Value must not exceed ${rangeMax}`;
68+
}
69+
70+
if (toNum !== undefined && !Number.isNaN(toNum)) {
71+
if (toNum < rangeMin) nextToError = `Value must be at least ${rangeMin}`;
72+
else if (toNum > rangeMax) nextToError = `Value must not exceed ${rangeMax}`;
73+
}
74+
75+
if (
76+
nextFromError === '' &&
77+
nextToError === '' &&
78+
fromNum !== undefined &&
79+
toNum !== undefined &&
80+
!Number.isNaN(fromNum) &&
81+
!Number.isNaN(toNum) &&
82+
fromNum > toNum
83+
) {
84+
nextFromError = 'The minimum value cannot be greater than the maximum value';
85+
}
86+
87+
setFromError(nextFromError);
88+
setToError(nextToError);
89+
};
90+
91+
return (
92+
<RangeSelect
93+
{...args}
94+
rangeFromError={fromError}
95+
rangeToError={toError}
96+
value={value}
97+
onChange={handleChange}
98+
/>
99+
);
100+
};
101+
102+
WithValidation.args = {
103+
...Default.args,
104+
placeholder: 'Select a range (1-100)',
105+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import { act } from 'react';
3+
import { fireEvent, render, screen, waitFor } from '../../test/testUtils';
4+
import { RangeSelect } from '.';
5+
6+
describe('The RangeSelect component', () => {
7+
it('renders with placeholder text', () => {
8+
render(<RangeSelect placeholder="Select a range" />);
9+
expect(screen.getByText('Select a range')).toBeInTheDocument();
10+
});
11+
12+
it('opens dropdown and shows From/To labels when clicked', async () => {
13+
render(<RangeSelect />);
14+
15+
const combobox = screen.getByRole('combobox');
16+
await act(async () => {
17+
fireEvent.click(combobox);
18+
});
19+
20+
await waitFor(() => {
21+
expect(screen.getByText('From')).toBeInTheDocument();
22+
expect(screen.getByText('To')).toBeInTheDocument();
23+
});
24+
});
25+
26+
it('handles range input changes', async () => {
27+
const handleChange = jest.fn();
28+
29+
render(<RangeSelect onChange={handleChange} />);
30+
31+
const toggleButton = screen.getByRole('combobox');
32+
33+
await act(async () => {
34+
fireEvent.click(toggleButton);
35+
});
36+
37+
const inputFields = screen.getAllByRole('spinbutton');
38+
39+
fireEvent.change(inputFields[0], {
40+
target: { value: '1' },
41+
});
42+
fireEvent.change(inputFields[1], {
43+
target: { value: '100' },
44+
});
45+
46+
expect(handleChange).toHaveBeenCalledWith(['1', '100']);
47+
});
48+
49+
it('displays error messages when error props are provided', async () => {
50+
render(
51+
<RangeSelect
52+
rangeFromError="Value must be at least 5"
53+
rangeToError="Value must be at most 10"
54+
/>
55+
);
56+
57+
const toggleButton = screen.getByRole('combobox');
58+
await act(async () => {
59+
fireEvent.click(toggleButton);
60+
});
61+
62+
expect(screen.getByText('Value must be at least 5')).toBeInTheDocument();
63+
expect(screen.getByText('Value must be at most 10')).toBeInTheDocument();
64+
});
65+
66+
it('displays the range in the trigger when values are set', () => {
67+
render(<RangeSelect value={['10', '50']} />);
68+
expect(screen.getByText('10 – 50')).toBeInTheDocument();
69+
});
70+
71+
it('displays partial range when only one value is set', () => {
72+
render(<RangeSelect value={['10', '']} />);
73+
expect(screen.getByText('10 – ...')).toBeInTheDocument();
74+
});
75+
76+
it('calls onClose when dropdown is closed', async () => {
77+
const onCloseMock = jest.fn();
78+
render(<RangeSelect onClose={onCloseMock} />);
79+
80+
const toggleButton = screen.getByRole('combobox');
81+
82+
await act(async () => {
83+
fireEvent.click(toggleButton);
84+
});
85+
expect(toggleButton).toHaveAttribute('aria-expanded', 'true');
86+
87+
await act(async () => {
88+
fireEvent.click(toggleButton);
89+
});
90+
expect(toggleButton).toHaveAttribute('aria-expanded', 'false');
91+
92+
expect(onCloseMock).toHaveBeenCalledTimes(1);
93+
});
94+
});

0 commit comments

Comments
 (0)