diff --git a/packages/frontend/src/components/shared/Select/Select.theme.ts b/packages/frontend/src/components/shared/Select/Select.theme.ts index 17158173..a0324fd1 100644 --- a/packages/frontend/src/components/shared/Select/Select.theme.ts +++ b/packages/frontend/src/components/shared/Select/Select.theme.ts @@ -23,17 +23,6 @@ export const selectTheme = tv({ 'disabled:border-none', ], input: ['outline-none'], - inputValue: [ - 'flex', - 'items-center', - 'gap-1', - 'pl-2', - 'pr-2', - 'rounded-md', - 'text-elements-mid-em', - 'bg-base-bg-emphasized', - 'hover:bg-base-bg-emphasized/80', - ], iconContainer: [ 'absolute', 'inset-y-0', @@ -53,7 +42,7 @@ export const selectTheme = tv({ 'py-1', 'flex-col', 'gap-0.5', - 'w-full', + 'min-w-full', 'bg-surface-floating', 'shadow-dropdown', 'w-auto', @@ -63,46 +52,22 @@ export const selectTheme = tv({ 'border-gray-200', 'rounded-xl', ], - item: [ - 'p-2', - 'gap-3', - 'flex', - 'items-start', - 'justify-between', - 'rounded-lg', - 'group', - 'data-[disabled]:cursor-not-allowed', - ], - itemContent: ['flex', 'flex-1', 'whitespace-nowrap'], - itemLabel: [ - 'text-sm', - 'text-elements-high-em', - 'tracking-[-0.006em]', - 'data-[disabled]:text-elements-disabled', - ], - itemDescription: [ - 'text-xs', - 'text-elements-low-em', - 'data-[disabled]:text-elements-disabled', - ], - itemIcon: ['h-4.5', 'w-4.5', 'text-elements-high-em'], }, variants: { orientation: { horizontal: { - item: ['items-center'], - itemContent: ['flex-row', 'items-center', 'gap-2'], + container: [], }, vertical: { - itemContent: ['flex-col', 'gap-0.5'], + container: [], }, }, variant: { default: { - item: [], + container: [], }, danger: { - item: [], + container: [], }, }, state: { @@ -147,19 +112,9 @@ export const selectTheme = tv({ popover: ['hidden'], }, }, - selected: { - true: { - item: ['bg-base-bg-emphasized', 'data-[disabled]:bg-transparent'], - }, - }, hasValue: { true: '', }, - multiple: { - true: { - inputValue: ['pr-1'], - }, - }, searchable: { true: '', false: { diff --git a/packages/frontend/src/components/shared/Select/Select.tsx b/packages/frontend/src/components/shared/Select/Select.tsx index 13c3d4a8..a8debbb4 100644 --- a/packages/frontend/src/components/shared/Select/Select.tsx +++ b/packages/frontend/src/components/shared/Select/Select.tsx @@ -10,41 +10,91 @@ import React, { } from 'react'; import { useMultipleSelection, useCombobox } from 'downshift'; import { SelectTheme, selectTheme } from './Select.theme'; -import { Button } from '../Button'; import { - CheckRadioIcon, ChevronDownIcon, CrossIcon, WarningIcon, -} from '../CustomIcon'; +} from 'components/shared/CustomIcon'; import { cloneIcon } from 'utils/cloneIcon'; import { cn } from 'utils/classnames'; +import { SelectItem } from './SelectItem'; +import { SelectValue } from './SelectValue'; -export type DropdownItem = { +export type SelectOption = { + /** + * The value of the option + */ value: string; + /** + * The label of the option + */ label: string; + /** + * The description of the option + */ description?: string; + /** + * Custom left icon for the option + */ leftIcon?: ReactNode; + /** + * Custom right icon for the option + */ rightIcon?: ReactNode; + /** + * Whether the option is disabled + */ disabled?: boolean; }; -interface MultiSelectProps +/** + * The orientation of the select + */ +export type SelectOrientation = 'horizontal' | 'vertical'; + +interface SelectProps extends Omit, 'size'>, SelectTheme { - items: DropdownItem[]; + /** + * The options of the select + */ + options: SelectOption[]; + /** + * The label of the select + */ label?: string; + /** + * The description of the select + */ description?: string; + /** + * Wheter the select is multiple or not + */ multiple?: boolean; + /** + * Wheter the select is searchable or not + */ searchable?: boolean; + /** + * Wheter the select is clearable or not + */ clearable?: boolean; + /** + * Custom left icon for the select + */ leftIcon?: ReactNode; + /** + * Custom right icon for the select + */ rightIcon?: ReactNode; + /** + * The helper text of the select + */ helperText?: string; } -export const MultiSelect: React.FC = ({ - items, +export const Select = ({ + options, multiple = false, searchable = false, clearable, @@ -58,11 +108,11 @@ export const MultiSelect: React.FC = ({ rightIcon, helperText, placeholder: placeholderProp = 'Select an option', -}) => { +}: SelectProps) => { const theme = selectTheme({ size, state, variant, orientation }); const [inputValue, setInputValue] = useState(''); - const [selectedItem, setSelectedItem] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>( 'bottom', @@ -74,11 +124,11 @@ export const MultiSelect: React.FC = ({ useEffect(() => { if (dropdownOpen && popoverRef.current && inputWrapperRef.current) { const popover = popoverRef.current; - // @ts-expect-error – we know it's not null + // @ts-expect-error – we know it's not null lol const input = inputWrapperRef.current.getBoundingClientRect(); const spaceBelow = window.innerHeight - input.bottom; const spaceAbove = input.top; - // @ts-expect-error – we know it's not null + // @ts-expect-error – we know it's not null lol const popoverHeight = popover.offsetHeight; // Determine if there's enough space below @@ -93,7 +143,7 @@ export const MultiSelect: React.FC = ({ } }, [dropdownOpen]); // Re-calculate whenever the dropdown is opened - const handleSelectedItemChange = (selectedItem: DropdownItem | null) => { + const handleSelectedItemChange = (selectedItem: SelectOption | null) => { setSelectedItem(selectedItem); setInputValue(selectedItem ? selectedItem.label : ''); }; @@ -105,7 +155,7 @@ export const MultiSelect: React.FC = ({ removeSelectedItem, selectedItems, reset, - } = useMultipleSelection({ + } = useMultipleSelection({ onSelectedItemsChange: multiple ? undefined : ({ selectedItems }) => { @@ -115,15 +165,15 @@ export const MultiSelect: React.FC = ({ const filteredItems = useMemo(() => { // Show all items if the dropdown is not multiple and not searchable - if (!multiple && !searchable) return items; + if (!multiple && !searchable) return options; // Show only the items that match the input value if (!multiple && searchable) { - return items.filter((item) => + return options.filter((item) => item.label.toLowerCase().includes(inputValue.toLowerCase()), ); } // Show only the items that match the input value and are not already selected - return items.filter( + return options.filter( (item) => item.label.toLowerCase().includes(inputValue.toLowerCase()) && !( @@ -132,7 +182,7 @@ export const MultiSelect: React.FC = ({ : [] ).includes(item), ); - }, [items, inputValue, selectedItem, selectedItems, multiple, searchable]); + }, [options, inputValue, selectedItem, selectedItems, multiple, searchable]); const { isOpen, @@ -172,7 +222,7 @@ export const MultiSelect: React.FC = ({ }); const isSelected = useCallback( - (item: DropdownItem) => + (item: SelectOption) => multiple ? selectedItems.includes(item) : selectedItem === item, [selectedItems, selectedItem, multiple], ); @@ -233,44 +283,45 @@ export const MultiSelect: React.FC = ({ [cloneIcon, state, theme, helperText], ); + const isMultipleHasValue = multiple && selectedItems.length > 0; const isMultipleHasValueButNotSearchable = multiple && !searchable && selectedItems.length > 0; const placeholder = isMultipleHasValueButNotSearchable ? '' : placeholderProp; return (
+ {/* Label & description */} {renderLabels} + + {/* Input */}
0, + hasValue: isMultipleHasValue, })} onClick={() => !dropdownOpen && openMenu()} > + {/* Left icon */} {leftIcon && renderLeftIcon} - {multiple && - selectedItems.length > 0 && + + {/* Multiple input values */} + {isMultipleHasValue && selectedItems.map((item, index) => ( - - {item.label} - - + option={item} + size={size} + onDelete={removeSelectedItem} + /> ))} + + {/* Single input value or searchable area */} = ({ 'ml-6': isMultipleHasValueButNotSearchable && clearable, })} /> + + {/* Right icon */} {renderRightIcon}
+ + {/* Helper text */} {renderHelperText} + + {/* Popover */}
    - {isOpen && filteredItems.length !== 0 ? ( + {isOpen && + filteredItems.length !== 0 && filteredItems.map((item, index) => ( -
  • - {item.leftIcon && - cloneIcon(item.leftIcon, { className: theme.itemIcon() })} -
    -

    - {item.label} -

    - {orientation === 'horizontal' && ( - - )} - {item.description && ( -

    - {item.description} -

    - )} -
    - {item.rightIcon - ? cloneIcon(item.rightIcon, { className: theme.itemIcon() }) - : isSelected(item) && ( - - )} -
  • - )) - ) : ( -
  • {'No results found'}
  • - )} + selected={isSelected(item)} + option={item} + hovered={highlightedIndex === index} + orientation={orientation} + empty={filteredItems.length === 0} + variant={variant} + /> + ))}
); diff --git a/packages/frontend/src/components/shared/Select/SelectBackup.tsx b/packages/frontend/src/components/shared/Select/SelectBackup.tsx deleted file mode 100644 index 8756dd0b..00000000 --- a/packages/frontend/src/components/shared/Select/SelectBackup.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useState } from 'react'; -import { useMultipleSelection, useCombobox } from 'downshift'; -import { SelectTheme, selectTheme } from './Select.theme'; -import { Button } from '../Button'; -import { CrossIcon } from '../CustomIcon'; - -type Item = { - value: string; - label: string; -}; - -interface MultiSelectProps extends SelectTheme { - items: Item[]; - multiple?: boolean; - searchable?: boolean; -} - -export const MultiSelect: React.FC = ({ - items, - multiple = false, - searchable: searchableProp, - size, - state, -}) => { - const searchable = multiple ? true : searchableProp || false; - const [inputValue, setInputValue] = useState(''); - const [selectedItem, setSelectedItem] = useState(null); - const [dropdownOpen, setDropdownOpen] = useState(false); // Track dropdown open state - - const { - container, - inputWrapper, - input: inputClass, - inputValue: inputValueClass, - popover, - item: itemClass, - } = selectTheme({ size, state }); - - const getFilteredItems = () => { - if (!multiple && !searchable) { - return items; - } - return items.filter( - (item) => - item.label.toLowerCase().includes(inputValue.toLowerCase()) && - !( - multiple ? selectedItems : selectedItem ? [selectedItem] : [] - ).includes(item), - ); - }; - - const { - getSelectedItemProps, - getDropdownProps, - addSelectedItem, - removeSelectedItem, - selectedItems, - } = useMultipleSelection({ - onSelectedItemsChange: multiple - ? undefined - : ({ selectedItems }) => { - setSelectedItem(selectedItems?.[0] || null); - }, - }); - - const { - isOpen, - getMenuProps, - getInputProps, - highlightedIndex, - getItemProps, - openMenu, - } = useCombobox({ - items: getFilteredItems(), - onInputValueChange: ({ inputValue = '' }) => setInputValue(inputValue), - onSelectedItemChange: ({ selectedItem }) => { - if (!multiple && selectedItem) { - setSelectedItem(selectedItem); - if (searchable) { - setInputValue(''); // Clear input value for searchable when an item is selected - } else { - setInputValue(selectedItem.label); // Set input value to selectedItem's label if searchable is off - } - } else if (multiple && selectedItem) { - addSelectedItem(selectedItem); - setInputValue(''); - } - }, - onIsOpenChange: ({ isOpen }) => { - // @ts-expect-error - isOpen is not defined in Downshift's types - setDropdownOpen(isOpen); - if (!isOpen) { - // Reset input value to selected item's label when dropdown is closed and it's not multiple - if (!multiple && selectedItem && searchable) { - setInputValue(''); - } - } else if (isOpen && !multiple && selectedItem && searchable) { - // Clear input value and show selectedItem's label as placeholder when dropdown opens - setInputValue(''); - } - }, - selectedItem: multiple ? null : selectedItem, - itemToString: (item) => (item && !multiple ? item.label : ''), - }); - - return ( -
-
0, - })} - onClick={() => !dropdownOpen && openMenu()} - > - {multiple && - selectedItems.length > 0 && - selectedItems.map((item, index) => ( - - {item.label} - - - ))} - -
-
    - {isOpen && (getFilteredItems().length !== 0 || !searchable) ? ( - getFilteredItems().map((item, index) => ( -
  • - {item.label} -
  • - )) - ) : ( -
  • {'No results found'}
  • - )} -
-
- ); -};