Skip to content

Commit

Permalink
feat(select): implement select with search and multiple select (#212)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The Boemly select component has been updated.

change `onChange` now returns an array of selected strings instead of the event
`value` is now a string array instead of a string

Migration Steps:

update `onChange` handlers to handle the new array structure
update `value` to be a string array
  • Loading branch information
HabRonan authored Sep 19, 2024
1 parent 2fd7bc8 commit b0b5fff
Show file tree
Hide file tree
Showing 12 changed files with 806 additions and 99 deletions.
14 changes: 8 additions & 6 deletions src/components/BoemlyFormControl/BoemlyFormControl.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { InputLeftElement, InputRightElement } from '@chakra-ui/react';
import { Heart } from '@phosphor-icons/react';
import { render, screen } from '../../test/testUtils';
import { fireEvent, render, screen } from '../../test/testUtils';
import { BoemlyFormControlProps } from './BoemlyFormControl';
import { BoemlyFormControl } from '.';

Expand Down Expand Up @@ -47,11 +47,13 @@ describe('The BoemlyFormControl component', () => {
],
});

expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getAllByRole('option')[0]).toHaveAttribute('value', 'value-1');
expect(screen.getAllByRole('option')[0]).not.toHaveAttribute('disabled');
expect(screen.getAllByRole('option')[1]).toHaveAttribute('value', 'value-2');
expect(screen.getAllByRole('option')[1]).toHaveAttribute('disabled');
fireEvent.click(screen.getByRole('button', { name: /toggle dropdown/i }));
const option1 = screen.getByText('Label 1');
const option1Div = option1.closest('div');
expect(option1Div).toHaveStyle('cursor: pointer');
const option2 = screen.getByText('Label 2');
const option2Div = option2.closest('div');
expect(option2Div).toHaveStyle('cursor: not-allowed');
});

it('displays a checkbox field if the inputType checkbox is given', () => {
Expand Down
24 changes: 8 additions & 16 deletions src/components/BoemlyFormControl/BoemlyFormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import {
NumberInputField,
NumberInputProps,
NumberInputStepper,
Select,
SelectProps,
StyleProps,
Text,
Textarea,
Expand All @@ -28,9 +26,9 @@ import {
import { CaretDown, CaretUp, Check, WarningOctagon } from '@phosphor-icons/react';
import { DatePicker, DatePickerProps } from '../DatePicker/DatePicker';
import InputSize from '../../types/InputSize';
import { SliderProps } from '../Slider/Slider';
import { Slider } from '../Slider';
import { Slider, SliderProps } from '../..';
import { BREAKPOINT_MD_QUERY } from '../../constants/breakpoints';
import { Select, BoemlySelectProps } from '../Select';

export interface BoemlyFormControlProps extends StyleProps {
id: string;
Expand All @@ -50,7 +48,7 @@ export interface BoemlyFormControlProps extends StyleProps {
| 'Slider';
inputProps?: InputProps;
numberInputProps?: NumberInputProps;
selectProps?: SelectProps;
selectProps?: BoemlySelectProps;
selectOptions?: { value: string; label: string; disabled?: boolean }[];
checkboxProps?: CheckboxProps;
datePickerProps?: DatePickerProps;
Expand Down Expand Up @@ -116,17 +114,11 @@ export const BoemlyFormControl: React.FC<BoemlyFormControlProps> = ({
);
case 'Select':
return (
<Select bgColor="white" {...selectProps}>
{selectOptions.map((selectOption) => (
<option
key={selectOption.value}
value={selectOption.value}
disabled={selectOption.disabled}
>
{selectOption.label}
</option>
))}
</Select>
<Select
bgColor="white"
{...{ isDisabled, isInvalid, ...selectProps }}
options={selectOptions}
/>
);
case 'Checkbox':
return <Checkbox {...checkboxProps} />;
Expand Down
107 changes: 107 additions & 0 deletions src/components/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from 'react';
import { Meta, StoryFn } from '@storybook/react';
import { BoemlySelect as Select } from './Select';

export default {
title: 'Components/Select',
component: Select,
argTypes: {
placeholder: { control: { type: 'text' } },
isDisabled: { control: { type: 'boolean' } },
isInvalid: { control: { type: 'boolean' } },
isFullWidth: { control: { type: 'boolean' } },
isSearchable: { control: { type: 'boolean' } },
isMultiple: { control: { type: 'boolean' } },
onChange: { action: 'Select Changed' },
options: { control: 'object' },
color: {
control: { type: 'text' },
},
backgroundColor: {
control: { type: 'text' },
},
borderColor: {
control: { type: 'text' },
},
size: {
options: ['xs', 'sm', 'md', 'lg'],
defaultValue: 'md',
control: { type: 'radio' },
},
variant: {
options: ['filled', 'unstyled', 'flushed', 'outline'],
defaultValue: 'filled',
control: { type: 'radio' },
},
},
} as Meta<typeof Select>;

const Template: StoryFn<typeof Select> = (args) => <Select {...args} />;

export const Default = Template.bind({});
Default.args = {
placeholder: 'Select an option',
options: [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' },
],
};

export const WithSelectedValue = Template.bind({});
WithSelectedValue.args = {
value: ['option_2'],
placeholder: 'Select an option',
options: [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' },
],
};

export const WithPlaceholder = Template.bind({});
WithPlaceholder.args = {
placeholder: 'Placeholder',
color: 'black',
options: [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' },
],
};

export const Searchable = Template.bind({});
Searchable.args = {
isSearchable: true,
placeholder: 'Search options...',
options: [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' },
],
};

export const MultiSelect = Template.bind({});
MultiSelect.args = {
isMultiple: true,
placeholder: 'Select multiple options',
options: [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' },
],
};

export const SearchableMultiSelect = Template.bind({});
SearchableMultiSelect.args = {
isSearchable: true,
isMultiple: true,
placeholder: 'Search and select multiple options',
searchPlaceholder: 'Search for a content...',
options: [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' },
{ label: 'Option 4', value: 'option_4' },
],
};
153 changes: 153 additions & 0 deletions src/components/Select/Select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Select } from '.';

const mockOptions = [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
];

describe('The Select component', () => {
it('renders with placeholder text', () => {
render(
<Select
options={mockOptions}
color="white"
placeholder="Select an option from these options"
/>
);
expect(screen.getByText('Select an option from these options')).toBeInTheDocument();
});

it('opens dropdown when clicked', () => {
render(<Select color="black" options={mockOptions} />);
fireEvent.click(screen.getByRole('button', { name: /toggle dropdown/i }));
expect(screen.getByText('Option 1')).toBeInTheDocument();
});

it('selects an option when clicked', () => {
render(<Select color="black" options={mockOptions} />);
fireEvent.click(screen.getByRole('button', { name: /toggle dropdown/i }));
fireEvent.click(screen.getByText('Option 1'));
expect(screen.getByText('Option 1')).toBeInTheDocument();
});

it('handles single selection mode', () => {
render(<Select color="black" options={mockOptions} value={['1']} />);
expect(screen.getByText('Option 1')).toBeInTheDocument();
});

it('handles multiple selection mode', () => {
render(
<Select
color="white"
options={mockOptions}
isMultiple
selectAllText="Select All"
clearAllText="Clear All"
/>
);
fireEvent.click(screen.getByRole('button', { name: /toggle dropdown/i }));
fireEvent.click(screen.getByText('Option 1'));
fireEvent.click(screen.getByText('Option 2'));
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
});

it('filters options when search term is entered', () => {
render(
<Select
color="black"
options={mockOptions}
isSearchable
selectAllText="Select All"
clearAllText="Clear All"
/>
);
fireEvent.click(screen.getByRole('button', { name: /toggle dropdown/i }));
fireEvent.change(screen.getByPlaceholderText('Select an option'), {
target: { value: 'Option 1' },
});
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.queryByText('Option 2')).toBeNull();
});

it('selects all options in multiple mode', () => {
render(
<Select
color="black"
options={mockOptions}
isMultiple
selectAllText="Select All"
clearAllText="Clear All"
/>
);
fireEvent.click(screen.getByRole('button', { name: /toggle dropdown/i }));
fireEvent.click(screen.getByText('Select All'));

// Find the options and navigate to their checkboxes
const option1Text = screen.queryByText('Option 1');
const option2Text = screen.queryByText('Option 2');
const option3Text = screen.queryByText('Option 3');

// Get the closest parent div for each option
const option1Div = option1Text?.closest('div');
const option2Div = option2Text?.closest('div');
const option3Div = option3Text?.closest('div');

// Check if the checkbox within the div have a data-checked attribute
expect(option1Div?.querySelector('label')).toHaveAttribute('data-checked');
expect(option2Div?.querySelector('label')).toHaveAttribute('data-checked');
expect(option3Div?.querySelector('label')).toHaveAttribute('data-checked');
});

it('clears all selected options in multiple mode', () => {
render(
<Select
color="black"
options={mockOptions}
isMultiple
selectAllText="Select All"
clearAllText="Clear All"
/>
);

fireEvent.click(screen.getByRole('button', { name: /toggle dropdown/i }));

fireEvent.click(screen.getByText('Select All'));

fireEvent.click(screen.getByText('Clear All'));

// Find the options and navigate to their checkboxes
const option1Text = screen.queryByText('Option 1');
const option2Text = screen.queryByText('Option 2');
const option3Text = screen.queryByText('Option 3');

// Get the closest parent div for each option
const option1Div = option1Text?.closest('div');
const option2Div = option2Text?.closest('div');
const option3Div = option3Text?.closest('div');

// Check if the checkbox within the div does not have a data-checked attribute
expect(option1Div?.querySelector('label')).not.toHaveAttribute('data-checked');
expect(option2Div?.querySelector('label')).not.toHaveAttribute('data-checked');
expect(option3Div?.querySelector('label')).not.toHaveAttribute('data-checked');
});

it('when dropdown is opened and closed, aria-expanded value changes', () => {
render(<Select color="black" options={mockOptions} />);

const toggleButton = screen.getByRole('combobox');

expect(toggleButton).toHaveAttribute('aria-expanded', 'false');

fireEvent.click(toggleButton);

expect(toggleButton).toHaveAttribute('aria-expanded', 'true');

fireEvent.click(toggleButton);

expect(toggleButton).toHaveAttribute('aria-expanded', 'false');
});
});
Loading

0 comments on commit b0b5fff

Please sign in to comment.