️ feat: create select component

This commit is contained in:
Wahyu Kurniawan 2024-02-24 04:54:02 +07:00
parent 3cf8769259
commit b7fe301c81
No known key found for this signature in database
GPG Key ID: 040A1549143A8E33
4 changed files with 651 additions and 0 deletions

View File

@ -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<typeof selectTheme>;

View File

@ -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<ComponentPropsWithoutRef<'input'>, '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<MultiSelectProps> = ({
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<DropdownItem | null>(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<DropdownItem>({
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<SVGSVGElement, globalThis.MouseEvent>) => {
e.stopPropagation();
reset();
setSelectedItem(null);
setInputValue('');
};
const renderLabels = useMemo(
() => (
<div className="space-y-1">
<p className={theme.label()}>{label}</p>
<p className={theme.description()}>{description}</p>
</div>
),
[theme, label, description],
);
const renderLeftIcon = useMemo(() => {
return (
<div className={theme.iconContainer({ class: 'left-0 pl-4' })}>
{cloneIcon(leftIcon, { className: theme.icon(), 'aria-hidden': true })}
</div>
);
}, [cloneIcon, theme, leftIcon]);
const renderRightIcon = useMemo(() => {
return (
<div className={theme.iconContainer({ class: 'pr-4 right-0' })}>
{clearable && (selectedItems.length > 0 || selectedItem) && (
<CrossIcon
className={theme.icon({ class: 'h-4 w-4' })}
onClick={handleClear}
/>
)}
{rightIcon ? (
cloneIcon(rightIcon, { className: theme.icon(), 'aria-hidden': true })
) : (
<ChevronDownIcon className={theme.icon()} />
)}
</div>
);
}, [cloneIcon, theme, rightIcon]);
const renderHelperText = useMemo(
() => (
<div className={theme.helperText()}>
{state === 'error' &&
cloneIcon(<WarningIcon className={theme.helperIcon()} />, {
'aria-hidden': true,
})}
<p>{helperText}</p>
</div>
),
[cloneIcon, state, theme, helperText],
);
const isMultipleHasValueButNotSearchable =
multiple && !searchable && selectedItems.length > 0;
const placeholder = isMultipleHasValueButNotSearchable ? '' : placeholderProp;
return (
<div className={theme.container()}>
{renderLabels}
<div
className={theme.inputWrapper({
hasValue: multiple && selectedItems.length > 0,
})}
onClick={() => !dropdownOpen && openMenu()}
>
{leftIcon && renderLeftIcon}
{multiple &&
selectedItems.length > 0 &&
selectedItems.map((item, index) => (
<span
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>
))}
<input
{...getInputProps(getDropdownProps())}
placeholder={placeholder}
readOnly={!searchable} // Control readOnly based on searchable
className={cn(theme.input({ searchable }), {
// Make the input width smaller because we don't need it (not searchable)
'w-6': isMultipleHasValueButNotSearchable,
// Add margin to the X icon
'ml-6': isMultipleHasValueButNotSearchable && clearable,
})}
/>
{renderRightIcon}
</div>
{renderHelperText}
<ul {...getMenuProps()} className={theme.popover({ isOpen })}>
{isOpen && filteredItems.length !== 0 ? (
filteredItems.map((item, index) => (
<li
{...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>
)}
</ul>
</div>
);
};

View File

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

View File

@ -0,0 +1 @@
export * from './Select';