⚡️ feat: create select component
This commit is contained in:
parent
3cf8769259
commit
b7fe301c81
196
packages/frontend/src/components/shared/Select/Select.theme.ts
Normal file
196
packages/frontend/src/components/shared/Select/Select.theme.ts
Normal 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>;
|
297
packages/frontend/src/components/shared/Select/Select.tsx
Normal file
297
packages/frontend/src/components/shared/Select/Select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
157
packages/frontend/src/components/shared/Select/SelectBackup.tsx
Normal file
157
packages/frontend/src/components/shared/Select/SelectBackup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
1
packages/frontend/src/components/shared/Select/index.ts
Normal file
1
packages/frontend/src/components/shared/Select/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './Select';
|
Loading…
Reference in New Issue
Block a user