♻️ refactor: adjust style and refactor to a new component

This commit is contained in:
Wahyu Kurniawan 2024-02-24 11:41:55 +07:00
parent 5d0dd93271
commit 100e03b7a4
No known key found for this signature in database
GPG Key ID: 040A1549143A8E33
3 changed files with 116 additions and 282 deletions

View File

@ -23,17 +23,6 @@ export const selectTheme = tv({
'disabled:border-none', 'disabled:border-none',
], ],
input: ['outline-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: [ iconContainer: [
'absolute', 'absolute',
'inset-y-0', 'inset-y-0',
@ -53,7 +42,7 @@ export const selectTheme = tv({
'py-1', 'py-1',
'flex-col', 'flex-col',
'gap-0.5', 'gap-0.5',
'w-full', 'min-w-full',
'bg-surface-floating', 'bg-surface-floating',
'shadow-dropdown', 'shadow-dropdown',
'w-auto', 'w-auto',
@ -63,46 +52,22 @@ export const selectTheme = tv({
'border-gray-200', 'border-gray-200',
'rounded-xl', '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: { variants: {
orientation: { orientation: {
horizontal: { horizontal: {
item: ['items-center'], container: [],
itemContent: ['flex-row', 'items-center', 'gap-2'],
}, },
vertical: { vertical: {
itemContent: ['flex-col', 'gap-0.5'], container: [],
}, },
}, },
variant: { variant: {
default: { default: {
item: [], container: [],
}, },
danger: { danger: {
item: [], container: [],
}, },
}, },
state: { state: {
@ -147,19 +112,9 @@ export const selectTheme = tv({
popover: ['hidden'], popover: ['hidden'],
}, },
}, },
selected: {
true: {
item: ['bg-base-bg-emphasized', 'data-[disabled]:bg-transparent'],
},
},
hasValue: { hasValue: {
true: '', true: '',
}, },
multiple: {
true: {
inputValue: ['pr-1'],
},
},
searchable: { searchable: {
true: '', true: '',
false: { false: {

View File

@ -10,41 +10,91 @@ import React, {
} from 'react'; } from 'react';
import { useMultipleSelection, useCombobox } from 'downshift'; import { useMultipleSelection, useCombobox } from 'downshift';
import { SelectTheme, selectTheme } from './Select.theme'; import { SelectTheme, selectTheme } from './Select.theme';
import { Button } from '../Button';
import { import {
CheckRadioIcon,
ChevronDownIcon, ChevronDownIcon,
CrossIcon, CrossIcon,
WarningIcon, WarningIcon,
} from '../CustomIcon'; } from 'components/shared/CustomIcon';
import { cloneIcon } from 'utils/cloneIcon'; import { cloneIcon } from 'utils/cloneIcon';
import { cn } from 'utils/classnames'; 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; value: string;
/**
* The label of the option
*/
label: string; label: string;
/**
* The description of the option
*/
description?: string; description?: string;
/**
* Custom left icon for the option
*/
leftIcon?: ReactNode; leftIcon?: ReactNode;
/**
* Custom right icon for the option
*/
rightIcon?: ReactNode; rightIcon?: ReactNode;
/**
* Whether the option is disabled
*/
disabled?: boolean; disabled?: boolean;
}; };
interface MultiSelectProps /**
* The orientation of the select
*/
export type SelectOrientation = 'horizontal' | 'vertical';
interface SelectProps
extends Omit<ComponentPropsWithoutRef<'input'>, 'size'>, extends Omit<ComponentPropsWithoutRef<'input'>, 'size'>,
SelectTheme { SelectTheme {
items: DropdownItem[]; /**
* The options of the select
*/
options: SelectOption[];
/**
* The label of the select
*/
label?: string; label?: string;
/**
* The description of the select
*/
description?: string; description?: string;
/**
* Wheter the select is multiple or not
*/
multiple?: boolean; multiple?: boolean;
/**
* Wheter the select is searchable or not
*/
searchable?: boolean; searchable?: boolean;
/**
* Wheter the select is clearable or not
*/
clearable?: boolean; clearable?: boolean;
/**
* Custom left icon for the select
*/
leftIcon?: ReactNode; leftIcon?: ReactNode;
/**
* Custom right icon for the select
*/
rightIcon?: ReactNode; rightIcon?: ReactNode;
/**
* The helper text of the select
*/
helperText?: string; helperText?: string;
} }
export const MultiSelect: React.FC<MultiSelectProps> = ({ export const Select = ({
items, options,
multiple = false, multiple = false,
searchable = false, searchable = false,
clearable, clearable,
@ -58,11 +108,11 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
rightIcon, rightIcon,
helperText, helperText,
placeholder: placeholderProp = 'Select an option', placeholder: placeholderProp = 'Select an option',
}) => { }: SelectProps) => {
const theme = selectTheme({ size, state, variant, orientation }); const theme = selectTheme({ size, state, variant, orientation });
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [selectedItem, setSelectedItem] = useState<DropdownItem | null>(null); const [selectedItem, setSelectedItem] = useState<SelectOption | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>( const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>(
'bottom', 'bottom',
@ -74,11 +124,11 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
useEffect(() => { useEffect(() => {
if (dropdownOpen && popoverRef.current && inputWrapperRef.current) { if (dropdownOpen && popoverRef.current && inputWrapperRef.current) {
const popover = popoverRef.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 input = inputWrapperRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - input.bottom; const spaceBelow = window.innerHeight - input.bottom;
const spaceAbove = input.top; 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; const popoverHeight = popover.offsetHeight;
// Determine if there's enough space below // 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 }, [dropdownOpen]); // Re-calculate whenever the dropdown is opened
const handleSelectedItemChange = (selectedItem: DropdownItem | null) => { const handleSelectedItemChange = (selectedItem: SelectOption | null) => {
setSelectedItem(selectedItem); setSelectedItem(selectedItem);
setInputValue(selectedItem ? selectedItem.label : ''); setInputValue(selectedItem ? selectedItem.label : '');
}; };
@ -105,7 +155,7 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
removeSelectedItem, removeSelectedItem,
selectedItems, selectedItems,
reset, reset,
} = useMultipleSelection<DropdownItem>({ } = useMultipleSelection<SelectOption>({
onSelectedItemsChange: multiple onSelectedItemsChange: multiple
? undefined ? undefined
: ({ selectedItems }) => { : ({ selectedItems }) => {
@ -115,15 +165,15 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
// Show all items if the dropdown is not multiple and not searchable // 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 // Show only the items that match the input value
if (!multiple && searchable) { if (!multiple && searchable) {
return items.filter((item) => return options.filter((item) =>
item.label.toLowerCase().includes(inputValue.toLowerCase()), item.label.toLowerCase().includes(inputValue.toLowerCase()),
); );
} }
// Show only the items that match the input value and are not already selected // Show only the items that match the input value and are not already selected
return items.filter( return options.filter(
(item) => (item) =>
item.label.toLowerCase().includes(inputValue.toLowerCase()) && item.label.toLowerCase().includes(inputValue.toLowerCase()) &&
!( !(
@ -132,7 +182,7 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
: [] : []
).includes(item), ).includes(item),
); );
}, [items, inputValue, selectedItem, selectedItems, multiple, searchable]); }, [options, inputValue, selectedItem, selectedItems, multiple, searchable]);
const { const {
isOpen, isOpen,
@ -172,7 +222,7 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
}); });
const isSelected = useCallback( const isSelected = useCallback(
(item: DropdownItem) => (item: SelectOption) =>
multiple ? selectedItems.includes(item) : selectedItem === item, multiple ? selectedItems.includes(item) : selectedItem === item,
[selectedItems, selectedItem, multiple], [selectedItems, selectedItem, multiple],
); );
@ -233,44 +283,45 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
[cloneIcon, state, theme, helperText], [cloneIcon, state, theme, helperText],
); );
const isMultipleHasValue = multiple && selectedItems.length > 0;
const isMultipleHasValueButNotSearchable = const isMultipleHasValueButNotSearchable =
multiple && !searchable && selectedItems.length > 0; multiple && !searchable && selectedItems.length > 0;
const placeholder = isMultipleHasValueButNotSearchable ? '' : placeholderProp; const placeholder = isMultipleHasValueButNotSearchable ? '' : placeholderProp;
return ( return (
<div className={theme.container()}> <div className={theme.container()}>
{/* Label & description */}
{renderLabels} {renderLabels}
{/* Input */}
<div <div
ref={inputWrapperRef} ref={inputWrapperRef}
className={theme.inputWrapper({ className={theme.inputWrapper({
hasValue: multiple && selectedItems.length > 0, hasValue: isMultipleHasValue,
})} })}
onClick={() => !dropdownOpen && openMenu()} onClick={() => !dropdownOpen && openMenu()}
> >
{/* Left icon */}
{leftIcon && renderLeftIcon} {leftIcon && renderLeftIcon}
{multiple &&
selectedItems.length > 0 && {/* Multiple input values */}
{isMultipleHasValue &&
selectedItems.map((item, index) => ( selectedItems.map((item, index) => (
<span <SelectValue
key={`selected-item-${index}`} key={`selected-item-${index}`}
{...getSelectedItemProps({ selectedItem: item, index })} {...getSelectedItemProps({ selectedItem: item, index })}
className={theme.inputValue({ multiple })} option={item}
> size={size}
{item.label} onDelete={removeSelectedItem}
<Button />
onClick={() => removeSelectedItem(item)}
iconOnly
variant="unstyled"
size="xs"
>
<CrossIcon size={14} />
</Button>
</span>
))} ))}
{/* Single input value or searchable area */}
<input <input
{...getInputProps(getDropdownProps())} {...getInputProps(getDropdownProps())}
placeholder={placeholder} placeholder={placeholder}
readOnly={!searchable} // Control readOnly based on searchable // Control readOnly based on searchable
readOnly={!searchable}
className={cn(theme.input({ searchable }), { className={cn(theme.input({ searchable }), {
// Make the input width smaller because we don't need it (not searchable) // Make the input width smaller because we don't need it (not searchable)
'w-6': isMultipleHasValueButNotSearchable, 'w-6': isMultipleHasValueButNotSearchable,
@ -278,58 +329,43 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
'ml-6': isMultipleHasValueButNotSearchable && clearable, 'ml-6': isMultipleHasValueButNotSearchable && clearable,
})} })}
/> />
{/* Right icon */}
{renderRightIcon} {renderRightIcon}
</div> </div>
{/* Helper text */}
{renderHelperText} {renderHelperText}
{/* Popover */}
<ul <ul
{...getMenuProps()} {...getMenuProps()}
id="popover" id="popover"
ref={popoverRef} ref={popoverRef}
className={cn(theme.popover({ isOpen }), { className={cn(theme.popover({ isOpen }), {
'top-1/4': dropdownPosition === 'bottom', // Position the popover based on the dropdown position
'bottom-[95%]': dropdownPosition === 'top', '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) => ( filteredItems.map((item, index) => (
<li <SelectItem
{...getItemProps({ item, index })} {...getItemProps({ item, index })}
key={item.value} key={item.value}
className={theme.item({ selected={isSelected(item)}
selected: highlightedIndex === index, option={item}
})} hovered={highlightedIndex === index}
data-disabled={item.disabled} orientation={orientation}
> empty={filteredItems.length === 0}
{item.leftIcon && variant={variant}
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>
)}
</ul> </ul>
</div> </div>
); );

View File

@ -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>
);
};