diff --git a/src/components/BoemlyFormControl/BoemlyFormControl.test.tsx b/src/components/BoemlyFormControl/BoemlyFormControl.test.tsx index d3c506f..94779b2 100644 --- a/src/components/BoemlyFormControl/BoemlyFormControl.test.tsx +++ b/src/components/BoemlyFormControl/BoemlyFormControl.test.tsx @@ -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 '.'; @@ -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', () => { diff --git a/src/components/BoemlyFormControl/BoemlyFormControl.tsx b/src/components/BoemlyFormControl/BoemlyFormControl.tsx index 8c0ddfd..85243d6 100644 --- a/src/components/BoemlyFormControl/BoemlyFormControl.tsx +++ b/src/components/BoemlyFormControl/BoemlyFormControl.tsx @@ -16,8 +16,6 @@ import { NumberInputField, NumberInputProps, NumberInputStepper, - Select, - SelectProps, StyleProps, Text, Textarea, @@ -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; @@ -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; @@ -116,17 +114,11 @@ export const BoemlyFormControl: React.FC = ({ ); case 'Select': return ( - + ; + +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' }, + ], +}; diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx new file mode 100644 index 0000000..d6818f2 --- /dev/null +++ b/src/components/Select/Select.test.tsx @@ -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( + ); + fireEvent.click(screen.getByRole('button', { name: /toggle dropdown/i })); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + it('selects an option when clicked', () => { + render(); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + it('handles multiple selection mode', () => { + render( + + ); + 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( + + ); + + 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( setSearchTerm(e.target.value)} + placeholder={searchPlaceholder} + isDisabled={isDisabled} + borderColor="gray.200" + focusBorderColor="black" + ref={inputRef} + aria-label="Search options" + /> + + )} + {isMultiple && ( + + + + + )} + + {filteredOptions.length > 0 ? ( + filteredOptions.map(({ value, label, disabled = false }, index) => { + const searchIndex = label.toLowerCase().indexOf(searchTerm.toLowerCase()); + const isMatch = searchIndex !== -1; + + let beforeMatch = label.slice(0, searchIndex); + let match = label.slice(searchIndex, searchIndex + searchTerm.length); + let afterMatch = label.slice(searchIndex + searchTerm.length); + + return ( + handleOptionSelect(value, disabled)} + cursor={disabled ? 'not-allowed' : 'pointer'} + _focus={{ outline: 'none', bg: 'gray.100' }} + _hover={disabled ? {} : { bg: 'gray.100' }} + borderRadius="md" + opacity={disabled ? 0.5 : 1} + role="option" + aria-selected={selectedOptions.includes(value)} + bg={index === focusedOptionIndex ? 'gray.100' : 'white'} + tabIndex={0} + onFocus={() => setFocusedOptionIndex(index)} + > + + {isMatch ? ( + <> + {beforeMatch} + + {match} + + {afterMatch} + + ) : ( + label + )} + + {isMultiple ? ( + + ) : ( + selectedOptions.includes(value) && + )} + + ); + }) + ) : ( + No options available + )} + + + + )} + + ); +}; diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx new file mode 100644 index 0000000..37f46b0 --- /dev/null +++ b/src/components/Select/index.tsx @@ -0,0 +1 @@ +export { BoemlySelect as Select, BoemlySelectProps } from './Select'; diff --git a/src/components/Slider/index.ts b/src/components/Slider/index.ts index e52479b..8d55df4 100644 --- a/src/components/Slider/index.ts +++ b/src/components/Slider/index.ts @@ -1 +1 @@ -export { BoemlySlider as Slider } from './Slider'; +export { BoemlySlider as Slider, SliderProps } from './Slider'; diff --git a/src/constants/componentCustomizations.tsx b/src/constants/componentCustomizations.tsx index 5a5f732..8d86f3e 100644 --- a/src/constants/componentCustomizations.tsx +++ b/src/constants/componentCustomizations.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { CaretDown } from '@phosphor-icons/react'; +import BorderBottomStyles from '../types/BorderBottomStyles'; +import { FONT_SIZES } from './customizations'; export const CustomizedHeading = { baseStyle: { @@ -392,6 +392,12 @@ const inputSizes = { height: '8', borderRadius: 'md', }, + xs: { + ...CustomizedText.sizes.xsLowNormal, + px: '2', + height: '6', + borderRadius: 'md', + }, }; export const CustomizedInput = { @@ -502,38 +508,67 @@ export const CustomizedTextarea = { }; export const CustomizedSelect = { - defaultProps: { - variant: 'outline', - icon: , - focusBorderColor: 'black', - }, sizes: { - xl: { - field: inputSizes.xl, + xs: { + height: inputSizes.xs.height, + fontSize: inputSizes.xs.fontSize, + borderRadius: inputSizes.xs.borderRadius, + badgeSize: FONT_SIZES.xs, }, - lg: { - field: { - fontSize: inputSizes.lg.fontSize, - borderRadius: inputSizes.lg.borderRadius, - }, + sm: { + ...CustomizedText.sizes.smLowNormal, + height: inputSizes.sm.height, + fontSize: inputSizes.sm.fontSize, + borderRadius: inputSizes.sm.borderRadius, + badgeSize: FONT_SIZES.xs, }, md: { - field: { - fontSize: inputSizes.md.fontSize, - borderRadius: inputSizes.md.borderRadius, - }, + ...CustomizedText.sizes.smRegularNormal, + height: inputSizes.md.height, + fontSize: inputSizes.md.fontSize, + borderRadius: inputSizes.md.borderRadius, + badgeSize: FONT_SIZES.xs, }, - sm: { - field: { - fontSize: inputSizes.sm.fontSize, - borderRadius: inputSizes.sm.borderRadius, - }, + lg: { + ...CustomizedText.sizes.mdLowNormal, + height: inputSizes.lg.height, + fontSize: inputSizes.lg.fontSize, + borderRadius: inputSizes.lg.borderRadius, + badgeSize: FONT_SIZES.sm, }, - xs: { - field: { - fontSize: 'xs', - borderRadius: inputSizes.sm.borderRadius, - }, + }, + variants: { + filled: { + backgroundColor: 'gray.100', + border: '0.063rem', + borderColor: 'transparent', + borderRadius: 'md', + borderBottomWidth: '0.063rem', + borderBottomStyle: 'solid' as BorderBottomStyles, + }, + unstyled: { + backgroundColor: 'transparent', + border: '0px', + borderColor: 'white', + borderRadius: '0px', + borderBottomWidth: '0px', + borderBottomStyle: 'solid' as BorderBottomStyles, + }, + flushed: { + backgroundColor: 'transparent', + border: '0px', + borderColor: 'gray.200', + borderRadius: '0px', + borderBottomWidth: '0.063rem', + borderBottomStyle: 'solid' as BorderBottomStyles, + }, + outline: { + backgroundColor: 'transparent', + border: '0.063rem solid', + borderColor: 'gray.200', + borderRadius: 'md', + borderBottomWidth: '0.063rem', + borderBottomStyle: 'solid' as BorderBottomStyles, }, }, }; diff --git a/src/index.tsx b/src/index.tsx index 910d8ad..4864bef 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -38,6 +38,7 @@ export * from './components/BoemlyTabs'; export * from './components/BoemlyThemeProvider'; export * from './components/Wrapper'; export * from './components/ConfirmAction'; +export * from './components/Select'; export { Avatar, @@ -63,7 +64,6 @@ export { MenuList, MenuItem, Progress, - Select, SimpleGrid, Spacer, Spinner, diff --git a/src/stories/components/Select.stories.tsx b/src/stories/components/Select.stories.tsx deleted file mode 100644 index 3431813..0000000 --- a/src/stories/components/Select.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Meta, StoryFn } from '@storybook/react'; - -import { Select } from '../..'; - -export default { - title: 'Components/Select', - component: Select, - argTypes: { - variant: { - options: ['outline', 'filled', 'flushed', 'unstyled'], - control: { type: 'radio' }, - }, - size: { - options: ['xs', 'sm', 'md', 'lg'], - control: { type: 'radio' }, - }, - placeholder: { control: { type: 'text' } }, - isDisabled: { control: { type: 'boolean' } }, - isInvalid: { control: { type: 'boolean' } }, - isFullWidth: { control: { type: 'boolean' } }, - onChange: { action: 'Select changed' }, - value: { control: { type: 'text' } }, - }, -} as Meta; - -const Template: StoryFn = (args) => ( - -); - -export const Default = Template.bind({}); -Default.args = {}; - -export const WithPlaceholder = Template.bind({}); -WithPlaceholder.args = { - placeholder: 'Placeholder', -}; - -export const WithSelectedValue = Template.bind({}); -WithSelectedValue.args = { - value: 'option_2', -}; diff --git a/src/types/BorderBottomStyles.ts b/src/types/BorderBottomStyles.ts new file mode 100644 index 0000000..c89d25a --- /dev/null +++ b/src/types/BorderBottomStyles.ts @@ -0,0 +1,3 @@ +type BorderBottomStyles = 'solid' | 'none'; + +export default BorderBottomStyles; diff --git a/src/utils/getTheme.ts b/src/utils/getTheme.ts index 98cab33..562db6a 100644 --- a/src/utils/getTheme.ts +++ b/src/utils/getTheme.ts @@ -12,7 +12,6 @@ import { CustomizedNumberInput, CustomizedPinInput, CustomizedProgress, - CustomizedSelect, CustomizedTable, CustomizedText, CustomizedTextarea, @@ -64,7 +63,6 @@ const getTheme = ({ customColors, customFonts, customRadii }: Options) => { NumberInput: CustomizedNumberInput, PinInput: CustomizedPinInput, Progress: CustomizedProgress, - Select: CustomizedSelect, Table: CustomizedTable, Text: CustomizedText, Textarea: CustomizedTextarea,