diff --git a/packages/frontend/src/components/shared/Select/Select.theme.ts b/packages/frontend/src/components/shared/Select/Select.theme.ts new file mode 100644 index 00000000..17158173 --- /dev/null +++ b/packages/frontend/src/components/shared/Select/Select.theme.ts @@ -0,0 +1,196 @@ +import { VariantProps, tv } from 'tailwind-variants'; + +export const selectTheme = tv({ + slots: { + container: ['flex', 'flex-col', 'relative', 'gap-2'], + label: ['text-sm', 'text-elements-high-em'], + description: ['text-xs', 'text-elements-low-em'], + inputWrapper: [ + 'relative', + 'flex', + 'flex-wrap', + 'gap-1', + 'min-w-[200px]', + 'w-full', + 'rounded-lg', + 'bg-transparent', + 'text-elements-mid-em', + 'shadow-sm', + 'border', + 'border-border-interactive', + 'focus-ring', + 'disabled:shadow-none', + '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', + 'flex', + 'items-center', + 'gap-2', + 'z-10', + 'cursor-pointer', + ], + icon: ['text-elements-mid-em'], + helperIcon: [], + helperText: ['flex', 'gap-2', 'items-center', 'text-elements-danger'], + popover: [ + 'z-20', + 'absolute', + 'px-1', + 'py-1', + 'flex-col', + 'gap-0.5', + 'w-full', + 'bg-surface-floating', + 'shadow-dropdown', + 'w-auto', + 'max-h-60', + 'overflow-auto', + 'border', + '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'], + }, + vertical: { + itemContent: ['flex-col', 'gap-0.5'], + }, + }, + variant: { + default: { + item: [], + }, + danger: { + item: [], + }, + }, + state: { + default: { + inputWrapper: '', + helperText: ['text-elements-low-em'], + }, + error: { + inputWrapper: [ + 'outline', + 'outline-offset-0', + 'outline-border-danger', + 'shadow-none', + 'focus:outline-border-danger', + ], + helperText: ['text-elements-danger'], + }, + }, + size: { + md: { + container: ['min-h-11'], + inputWrapper: ['min-h-11', 'text-sm', 'pl-4', 'pr-4', 'py-1'], + icon: ['h-[18px]', 'w-[18px]'], + helperText: 'text-sm', + helperIcon: ['h-5', 'w-5'], + popover: ['mt-12'], + }, + sm: { + container: ['min-h-8'], + inputWrapper: ['min-h-8', 'text-xs', 'pl-3', 'pr-3', 'py-0.5'], + icon: ['h-4', 'w-4'], + helperText: 'text-xs', + helperIcon: ['h-4', 'w-4'], + popover: ['mt-9'], + }, + }, + isOpen: { + true: { + popover: ['flex'], + }, + false: { + popover: ['hidden'], + }, + }, + selected: { + true: { + item: ['bg-base-bg-emphasized', 'data-[disabled]:bg-transparent'], + }, + }, + hasValue: { + true: '', + }, + multiple: { + true: { + inputValue: ['pr-1'], + }, + }, + searchable: { + true: '', + false: { + input: ['cursor-pointer'], + }, + }, + }, + compoundVariants: [ + { + size: 'md', + hasValue: true, + class: { + inputWrapper: ['pl-1'], + }, + }, + { + size: 'sm', + hasValue: true, + class: { + inputWrapper: ['pl-0.5'], + }, + }, + ], + defaultVariants: { + orientation: 'horizontal', + variant: 'default', + size: 'md', + state: 'default', + isOpen: false, + hasValue: false, + }, +}); + +export type SelectTheme = VariantProps; diff --git a/packages/frontend/src/components/shared/Select/Select.tsx b/packages/frontend/src/components/shared/Select/Select.tsx new file mode 100644 index 00000000..7c04252c --- /dev/null +++ b/packages/frontend/src/components/shared/Select/Select.tsx @@ -0,0 +1,297 @@ +import React, { + ReactNode, + useState, + ComponentPropsWithoutRef, + useMemo, + useCallback, + MouseEvent, +} from 'react'; +import { useMultipleSelection, useCombobox } from 'downshift'; +import { SelectTheme, selectTheme } from './Select.theme'; +import { Button } from '../Button'; +import { + CheckRadioIcon, + ChevronDownIcon, + CrossIcon, + WarningIcon, +} from '../CustomIcon'; +import { cloneIcon } from 'utils/cloneIcon'; +import { cn } from 'utils/classnames'; + +export type DropdownItem = { + value: string; + label: string; + description?: string; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + disabled?: boolean; +}; + +interface MultiSelectProps + extends Omit, 'size'>, + SelectTheme { + items: DropdownItem[]; + label?: string; + description?: string; + multiple?: boolean; + searchable?: boolean; + clearable?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + helperText?: string; +} + +export const MultiSelect: React.FC = ({ + items, + multiple = false, + searchable = false, + clearable, + size, + state, + orientation = 'horizontal', + variant, + label, + description, + leftIcon, + rightIcon, + helperText, + placeholder: placeholderProp = 'Select an option', +}) => { + const theme = selectTheme({ size, state, variant, orientation }); + + const [inputValue, setInputValue] = useState(''); + const [selectedItem, setSelectedItem] = useState(null); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const handleSelectedItemChange = (selectedItem: DropdownItem | null) => { + setSelectedItem(selectedItem); + setInputValue(selectedItem ? selectedItem.label : ''); + }; + + const { + getSelectedItemProps, + getDropdownProps, + addSelectedItem, + removeSelectedItem, + selectedItems, + reset, + } = useMultipleSelection({ + onSelectedItemsChange: multiple + ? undefined + : ({ selectedItems }) => { + handleSelectedItemChange(selectedItems?.[0] || null); + }, + }); + + const filteredItems = useMemo(() => { + // Show all items if the dropdown is not multiple and not searchable + if (!multiple && !searchable) return items; + // Show only the items that match the input value + if (!multiple && searchable) { + return items.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( + (item) => + item.label.toLowerCase().includes(inputValue.toLowerCase()) && + !( + multiple && searchable // If the dropdown is multiple and searchable, show filtered items + ? selectedItems + : [] + ).includes(item), + ); + }, [items, inputValue, selectedItem, selectedItems, multiple, searchable]); + + const { + isOpen, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + openMenu, + } = useCombobox({ + items: filteredItems, + // @ts-expect-error – there are two params but we don't need the second one + isItemDisabled: (item) => item.disabled, + onInputValueChange: ({ inputValue = '' }) => setInputValue(inputValue), + onSelectedItemChange: ({ selectedItem }) => { + if (!multiple && selectedItem) { + handleSelectedItemChange(selectedItem); + } else if (multiple && selectedItem) { + // If the item is not already selected, add it to the selected items + if (!selectedItems.includes(selectedItem)) { + addSelectedItem(selectedItem); + } else { + // If the item is already selected, remove it from the selected items + removeSelectedItem(selectedItem); + } + setInputValue(''); + } + }, + onIsOpenChange: ({ isOpen }) => { + setDropdownOpen(isOpen ?? false); + if (isOpen && !multiple && selectedItem && searchable) { + setInputValue(''); + } + }, + selectedItem: multiple ? null : selectedItem, + // TODO: Make the input value empty when the dropdown is open, has a value, it is not multiple, and searchable + itemToString: (item) => (item && !multiple ? item.label : ''), + }); + + const isSelected = useCallback( + (item: DropdownItem) => + multiple ? selectedItems.includes(item) : selectedItem === item, + [selectedItems, selectedItem, multiple], + ); + + const handleClear = (e: MouseEvent) => { + e.stopPropagation(); + reset(); + setSelectedItem(null); + setInputValue(''); + }; + + const renderLabels = useMemo( + () => ( +
+

{label}

+

{description}

+
+ ), + [theme, label, description], + ); + + const renderLeftIcon = useMemo(() => { + return ( +
+ {cloneIcon(leftIcon, { className: theme.icon(), 'aria-hidden': true })} +
+ ); + }, [cloneIcon, theme, leftIcon]); + + const renderRightIcon = useMemo(() => { + return ( +
+ {clearable && (selectedItems.length > 0 || selectedItem) && ( + + )} + {rightIcon ? ( + cloneIcon(rightIcon, { className: theme.icon(), 'aria-hidden': true }) + ) : ( + + )} +
+ ); + }, [cloneIcon, theme, rightIcon]); + + const renderHelperText = useMemo( + () => ( +
+ {state === 'error' && + cloneIcon(, { + 'aria-hidden': true, + })} +

{helperText}

+
+ ), + [cloneIcon, state, theme, helperText], + ); + + const isMultipleHasValueButNotSearchable = + multiple && !searchable && selectedItems.length > 0; + const placeholder = isMultipleHasValueButNotSearchable ? '' : placeholderProp; + + return ( +
+ {renderLabels} +
0, + })} + onClick={() => !dropdownOpen && openMenu()} + > + {leftIcon && renderLeftIcon} + {multiple && + selectedItems.length > 0 && + selectedItems.map((item, index) => ( + + {item.label} + + + ))} + + {renderRightIcon} +
+ {renderHelperText} +
    + {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'}
  • + )} +
+
+ ); +}; diff --git a/packages/frontend/src/components/shared/Select/SelectBackup.tsx b/packages/frontend/src/components/shared/Select/SelectBackup.tsx new file mode 100644 index 00000000..8756dd0b --- /dev/null +++ b/packages/frontend/src/components/shared/Select/SelectBackup.tsx @@ -0,0 +1,157 @@ +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'}
  • + )} +
+
+ ); +}; diff --git a/packages/frontend/src/components/shared/Select/index.ts b/packages/frontend/src/components/shared/Select/index.ts new file mode 100644 index 00000000..7868ecba --- /dev/null +++ b/packages/frontend/src/components/shared/Select/index.ts @@ -0,0 +1 @@ +export * from './Select';