forked from cerc-io/snowballtools-base
		
	♻️ refactor: adjust style and refactor to a new component
This commit is contained in:
		
							parent
							
								
									5d0dd93271
								
							
						
					
					
						commit
						100e03b7a4
					
				| @ -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: { | ||||
|  | ||||
| @ -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<ComponentPropsWithoutRef<'input'>, '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<MultiSelectProps> = ({ | ||||
|   items, | ||||
| export const Select = ({ | ||||
|   options, | ||||
|   multiple = false, | ||||
|   searchable = false, | ||||
|   clearable, | ||||
| @ -58,11 +108,11 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({ | ||||
|   rightIcon, | ||||
|   helperText, | ||||
|   placeholder: placeholderProp = 'Select an option', | ||||
| }) => { | ||||
| }: SelectProps) => { | ||||
|   const theme = selectTheme({ size, state, variant, orientation }); | ||||
| 
 | ||||
|   const [inputValue, setInputValue] = useState(''); | ||||
|   const [selectedItem, setSelectedItem] = useState<DropdownItem | null>(null); | ||||
|   const [selectedItem, setSelectedItem] = useState<SelectOption | null>(null); | ||||
|   const [dropdownOpen, setDropdownOpen] = useState(false); | ||||
|   const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>( | ||||
|     'bottom', | ||||
| @ -74,11 +124,11 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({ | ||||
|   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<MultiSelectProps> = ({ | ||||
|     } | ||||
|   }, [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<MultiSelectProps> = ({ | ||||
|     removeSelectedItem, | ||||
|     selectedItems, | ||||
|     reset, | ||||
|   } = useMultipleSelection<DropdownItem>({ | ||||
|   } = useMultipleSelection<SelectOption>({ | ||||
|     onSelectedItemsChange: multiple | ||||
|       ? undefined | ||||
|       : ({ selectedItems }) => { | ||||
| @ -115,15 +165,15 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({ | ||||
| 
 | ||||
|   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<MultiSelectProps> = ({ | ||||
|             : [] | ||||
|         ).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<MultiSelectProps> = ({ | ||||
|   }); | ||||
| 
 | ||||
|   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<MultiSelectProps> = ({ | ||||
|     [cloneIcon, state, theme, helperText], | ||||
|   ); | ||||
| 
 | ||||
|   const isMultipleHasValue = multiple && selectedItems.length > 0; | ||||
|   const isMultipleHasValueButNotSearchable = | ||||
|     multiple && !searchable && selectedItems.length > 0; | ||||
|   const placeholder = isMultipleHasValueButNotSearchable ? '' : placeholderProp; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={theme.container()}> | ||||
|       {/* Label & description */} | ||||
|       {renderLabels} | ||||
| 
 | ||||
|       {/* Input */} | ||||
|       <div | ||||
|         ref={inputWrapperRef} | ||||
|         className={theme.inputWrapper({ | ||||
|           hasValue: multiple && selectedItems.length > 0, | ||||
|           hasValue: isMultipleHasValue, | ||||
|         })} | ||||
|         onClick={() => !dropdownOpen && openMenu()} | ||||
|       > | ||||
|         {/* Left icon */} | ||||
|         {leftIcon && renderLeftIcon} | ||||
|         {multiple && | ||||
|           selectedItems.length > 0 && | ||||
| 
 | ||||
|         {/* Multiple input values */} | ||||
|         {isMultipleHasValue && | ||||
|           selectedItems.map((item, index) => ( | ||||
|             <span | ||||
|             <SelectValue | ||||
|               key={`selected-item-${index}`} | ||||
|               {...getSelectedItemProps({ selectedItem: item, index })} | ||||
|               className={theme.inputValue({ multiple })} | ||||
|             > | ||||
|               {item.label} | ||||
|               <Button | ||||
|                 onClick={() => removeSelectedItem(item)} | ||||
|                 iconOnly | ||||
|                 variant="unstyled" | ||||
|                 size="xs" | ||||
|               > | ||||
|                 <CrossIcon size={14} /> | ||||
|               </Button> | ||||
|             </span> | ||||
|               option={item} | ||||
|               size={size} | ||||
|               onDelete={removeSelectedItem} | ||||
|             /> | ||||
|           ))} | ||||
| 
 | ||||
|         {/* Single input value or searchable area */} | ||||
|         <input | ||||
|           {...getInputProps(getDropdownProps())} | ||||
|           placeholder={placeholder} | ||||
|           readOnly={!searchable} // Control readOnly based on searchable
 | ||||
|           // Control readOnly based on searchable
 | ||||
|           readOnly={!searchable} | ||||
|           className={cn(theme.input({ searchable }), { | ||||
|             // Make the input width smaller because we don't need it (not searchable)
 | ||||
|             'w-6': isMultipleHasValueButNotSearchable, | ||||
| @ -278,58 +329,43 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({ | ||||
|             'ml-6': isMultipleHasValueButNotSearchable && clearable, | ||||
|           })} | ||||
|         /> | ||||
| 
 | ||||
|         {/* Right icon */} | ||||
|         {renderRightIcon} | ||||
|       </div> | ||||
| 
 | ||||
|       {/* Helper text */} | ||||
|       {renderHelperText} | ||||
| 
 | ||||
|       {/* Popover */} | ||||
|       <ul | ||||
|         {...getMenuProps()} | ||||
|         id="popover" | ||||
|         ref={popoverRef} | ||||
|         className={cn(theme.popover({ isOpen }), { | ||||
|           'top-1/4': dropdownPosition === 'bottom', | ||||
|           'bottom-[95%]': dropdownPosition === 'top', | ||||
|           // Position the popover based on the dropdown position
 | ||||
|           'top-[12.5%]': dropdownPosition === 'bottom' && !label, | ||||
|           'top-[35%]': dropdownPosition === 'bottom' && label, | ||||
|           'top-[42.5%]': dropdownPosition === 'bottom' && label && description, | ||||
|           'bottom-[92.5%]': dropdownPosition === 'top' && !label, | ||||
|           'bottom-[75%]': dropdownPosition === 'top' && label, | ||||
|           'bottom-[65%]': dropdownPosition === 'top' && label && description, | ||||
|         })} | ||||
|       > | ||||
|         {isOpen && filteredItems.length !== 0 ? ( | ||||
|         {isOpen && | ||||
|           filteredItems.length !== 0 && | ||||
|           filteredItems.map((item, index) => ( | ||||
|             <li | ||||
|             <SelectItem | ||||
|               {...getItemProps({ item, index })} | ||||
|               key={item.value} | ||||
|               className={theme.item({ | ||||
|                 selected: highlightedIndex === index, | ||||
|               })} | ||||
|               data-disabled={item.disabled} | ||||
|             > | ||||
|               {item.leftIcon && | ||||
|                 cloneIcon(item.leftIcon, { className: theme.itemIcon() })} | ||||
|               <div className={theme.itemContent()}> | ||||
|                 <p className={theme.itemLabel()} data-disabled={item.disabled}> | ||||
|                   {item.label} | ||||
|                 </p> | ||||
|                 {orientation === 'horizontal' && ( | ||||
|                   <span className="h-1 w-1 rounded-full bg-border-interactive-hovered/[0.14]" /> | ||||
|                 )} | ||||
|                 {item.description && ( | ||||
|                   <p | ||||
|                     className={theme.itemDescription()} | ||||
|                     data-disabled={item.disabled} | ||||
|                   > | ||||
|                     {item.description} | ||||
|                   </p> | ||||
|                 )} | ||||
|               </div> | ||||
|               {item.rightIcon | ||||
|                 ? cloneIcon(item.rightIcon, { className: theme.itemIcon() }) | ||||
|                 : isSelected(item) && ( | ||||
|                     <CheckRadioIcon | ||||
|                       className={cn(theme.itemIcon(), 'text-controls-primary')} | ||||
|                     /> | ||||
|                   )} | ||||
|             </li> | ||||
|           )) | ||||
|         ) : ( | ||||
|           <li className={theme.item()}>{'No results found'}</li> | ||||
|         )} | ||||
|               selected={isSelected(item)} | ||||
|               option={item} | ||||
|               hovered={highlightedIndex === index} | ||||
|               orientation={orientation} | ||||
|               empty={filteredItems.length === 0} | ||||
|               variant={variant} | ||||
|             /> | ||||
|           ))} | ||||
|       </ul> | ||||
|     </div> | ||||
|   ); | ||||
|  | ||||
| @ -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<MultiSelectProps> = ({ | ||||
|   items, | ||||
|   multiple = false, | ||||
|   searchable: searchableProp, | ||||
|   size, | ||||
|   state, | ||||
| }) => { | ||||
|   const searchable = multiple ? true : searchableProp || false; | ||||
|   const [inputValue, setInputValue] = useState(''); | ||||
|   const [selectedItem, setSelectedItem] = useState<Item | null>(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<Item>({ | ||||
|     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 ( | ||||
|     <div className={container()}> | ||||
|       <div | ||||
|         className={inputWrapper({ | ||||
|           hasValue: multiple && selectedItems.length > 0, | ||||
|         })} | ||||
|         onClick={() => !dropdownOpen && openMenu()} | ||||
|       > | ||||
|         {multiple && | ||||
|           selectedItems.length > 0 && | ||||
|           selectedItems.map((item, index) => ( | ||||
|             <span | ||||
|               key={`selected-item-${index}`} | ||||
|               {...getSelectedItemProps({ selectedItem: item, index })} | ||||
|               className={inputValueClass({ multiple })} | ||||
|             > | ||||
|               {item.label} | ||||
|               <Button | ||||
|                 onClick={() => removeSelectedItem(item)} | ||||
|                 iconOnly | ||||
|                 variant="unstyled" | ||||
|                 size="xs" | ||||
|               > | ||||
|                 <CrossIcon size={14} /> | ||||
|               </Button> | ||||
|             </span> | ||||
|           ))} | ||||
|         <input | ||||
|           {...getInputProps(getDropdownProps())} | ||||
|           placeholder="Select an option..." | ||||
|           readOnly={!searchable} // Control readOnly based on searchable
 | ||||
|           className={inputClass({ searchable })} | ||||
|         /> | ||||
|       </div> | ||||
|       <ul {...getMenuProps()} className={popover({ isOpen })}> | ||||
|         {isOpen && (getFilteredItems().length !== 0 || !searchable) ? ( | ||||
|           getFilteredItems().map((item, index) => ( | ||||
|             <li | ||||
|               {...getItemProps({ item, index })} | ||||
|               key={item.value} | ||||
|               className={itemClass({ selected: highlightedIndex === index })} | ||||
|             > | ||||
|               {item.label} | ||||
|             </li> | ||||
|           )) | ||||
|         ) : ( | ||||
|           <li className={itemClass()}>{'No results found'}</li> | ||||
|         )} | ||||
|       </ul> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user