[T-4840: feat] Dropdown/select component (#108)

* 🎨 style: adjust z index of the date picker popover

* 🎨 style: add new spacing and rename box shadow from calendar to dropdown

* 🐛 fix: console error becasue button inside button

* ♻️ refactor: rename shadow calendar to shador dropdown on calendar component

* 🚀 perf: remove vscode settings inside `packages/frontend`

* ️ feat: create check radio icon and chevron down icon component

* 🔧 chore: install `downshift`

* ️ feat: create select component

* 🎨 style: adjust the popover position based on the user screen

* ️ feat: separate select item to be a component

* ️ feat: separate select value to be a component

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

* ️ feat: create a type for merge two interface but keep the last value

* 🐛 fix: forward ref the component to fix console error

* ️ feat: add `hideValues` prop to hide the values when on multiple

* 🐛 fix: no result not showing

* ️ feat: make the select to be controller component

* ♻️ refactor: remove console log

* ♻️ refactor: update pr review
This commit is contained in:
Wahyu Kurniawan 2024-02-27 12:05:16 +07:00 committed by GitHub
parent effa0bbf14
commit c731dd308c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1276 additions and 59 deletions

View File

@ -1,39 +0,0 @@
{
// eslint extension options
"eslint.enable": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"css.customData": [".vscode/tailwind.json"],
// prettier extension setting
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.rulers": [80],
"editor.codeActionsOnSave": [
"source.addMissingImports",
"source.fixAll",
"source.organizeImports"
],
// Show in vscode "Problems" tab when there are errors
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
// Use absolute import for typescript files
"typescript.preferences.importModuleSpecifier": "non-relative",
// IntelliSense for taiwind variants
"tailwindCSS.experimental.classRegex": [
["tv\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
}

View File

@ -9,8 +9,8 @@
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
@ -27,7 +27,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"downshift": "^8.2.3", "downshift": "^8.3.2",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"gql-client": "^1.0.0", "gql-client": "^1.0.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",

View File

@ -5,7 +5,7 @@ export const calendarTheme = tv({
wrapper: [ wrapper: [
'max-w-[352px]', 'max-w-[352px]',
'bg-surface-floating', 'bg-surface-floating',
'shadow-calendar', 'shadow-dropdown',
'rounded-xl', 'rounded-xl',
], ],
calendar: ['flex', 'flex-col', 'py-2', 'px-2', 'gap-2'], calendar: ['flex', 'flex-col', 'py-2', 'px-2', 'gap-2'],

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const CheckRadioIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 16.5C13.1421 16.5 16.5 13.1421 16.5 9C16.5 4.85786 13.1421 1.5 9 1.5C4.85786 1.5 1.5 4.85786 1.5 9C1.5 13.1421 4.85786 16.5 9 16.5ZM7.93079 12.3663L12.3055 7.01944L11.1446 6.06958L7.81944 10.1336L6.37511 8.68932L5.31445 9.74998L7.93079 12.3663Z"
fill="currentColor"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,22 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const ChevronDownIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M20 9L13.4142 15.5858C12.6332 16.3668 11.3669 16.3668 10.5858 15.5858L4 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,19 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const PencilIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
{...props}
>
<path
d="M8.625 3.125C8.90114 3.125 9.125 2.90114 9.125 2.625C9.125 2.34886 8.90114 2.125 8.625 2.125V3.125ZM15.875 9.375C15.875 9.09886 15.6511 8.875 15.375 8.875C15.0989 8.875 14.875 9.09886 14.875 9.375H15.875ZM3.44401 15.2115L3.67101 14.766L3.67101 14.766L3.44401 15.2115ZM2.78849 14.556L3.23399 14.329H3.23399L2.78849 14.556ZM14.556 15.2115L14.329 14.766L14.329 14.766L14.556 15.2115ZM15.2115 14.556L14.766 14.329L14.766 14.329L15.2115 14.556ZM2.78849 3.44401L2.34299 3.21702L2.78849 3.44401ZM3.44401 2.78849L3.21702 2.34299V2.34299L3.44401 2.78849ZM9.375 6.375L9.02145 6.02145C8.92768 6.11521 8.875 6.24239 8.875 6.375H9.375ZM9.375 8.625H8.875C8.875 8.90114 9.09886 9.125 9.375 9.125V8.625ZM11.625 8.625V9.125C11.7576 9.125 11.8848 9.07232 11.9786 8.97855L11.625 8.625ZM13.3768 2.37316L13.7304 2.72671V2.72671L13.3768 2.37316ZM15.4982 2.37316L15.8517 2.01961L15.8517 2.01961L15.4982 2.37316ZM15.6268 2.50184L15.2733 2.85539V2.85539L15.6268 2.50184ZM15.6268 4.62316L15.9804 4.97671V4.97671L15.6268 4.62316ZM12.975 14.875H5.025V15.875H12.975V14.875ZM3.125 12.975V5.025H2.125V12.975H3.125ZM5.025 3.125H8.625V2.125H5.025V3.125ZM14.875 9.375V12.975H15.875V9.375H14.875ZM5.025 14.875C4.59671 14.875 4.30556 14.8746 4.08052 14.8562C3.86131 14.8383 3.74921 14.8059 3.67101 14.766L3.21702 15.657C3.45969 15.7807 3.71804 15.8299 3.99909 15.8529C4.2743 15.8754 4.61321 15.875 5.025 15.875V14.875ZM2.125 12.975C2.125 13.3868 2.12461 13.7257 2.1471 14.0009C2.17006 14.282 2.21934 14.5403 2.34299 14.783L3.23399 14.329C3.19415 14.2508 3.16169 14.1387 3.14378 13.9195C3.12539 13.6944 3.125 13.4033 3.125 12.975H2.125ZM3.67101 14.766C3.48285 14.6701 3.32987 14.5172 3.23399 14.329L2.34299 14.783C2.53473 15.1593 2.8407 15.4653 3.21702 15.657L3.67101 14.766ZM12.975 15.875C13.3868 15.875 13.7257 15.8754 14.0009 15.8529C14.282 15.8299 14.5403 15.7807 14.783 15.657L14.329 14.766C14.2508 14.8059 14.1387 14.8383 13.9195 14.8562C13.6944 14.8746 13.4033 14.875 12.975 14.875V15.875ZM14.875 12.975C14.875 13.4033 14.8746 13.6944 14.8562 13.9195C14.8383 14.1387 14.8059 14.2508 14.766 14.329L15.657 14.783C15.7807 14.5403 15.8299 14.282 15.8529 14.0009C15.8754 13.7257 15.875 13.3868 15.875 12.975H14.875ZM14.783 15.657C15.1593 15.4653 15.4653 15.1593 15.657 14.783L14.766 14.329C14.6701 14.5172 14.5172 14.6701 14.329 14.766L14.783 15.657ZM3.125 5.025C3.125 4.59671 3.12539 4.30556 3.14378 4.08052C3.16169 3.86131 3.19415 3.74921 3.23399 3.67101L2.34299 3.21702C2.21934 3.45969 2.17006 3.71804 2.1471 3.99909C2.12461 4.2743 2.125 4.61321 2.125 5.025H3.125ZM5.025 2.125C4.61321 2.125 4.2743 2.12461 3.99909 2.1471C3.71804 2.17006 3.45969 2.21934 3.21702 2.34299L3.67101 3.23399C3.74921 3.19415 3.86131 3.16169 4.08052 3.14378C4.30556 3.12539 4.59671 3.125 5.025 3.125V2.125ZM3.23399 3.67101C3.32987 3.48285 3.48285 3.32987 3.67101 3.23399L3.21702 2.34299C2.84069 2.53473 2.53473 2.84069 2.34299 3.21702L3.23399 3.67101ZM8.875 6.375V8.625H9.875V6.375H8.875ZM9.375 9.125H11.625V8.125H9.375V9.125ZM9.72855 6.72855L13.7304 2.72671L13.0233 2.01961L9.02145 6.02145L9.72855 6.72855ZM15.1446 2.72671L15.2733 2.85539L15.9804 2.14829L15.8517 2.01961L15.1446 2.72671ZM15.2733 4.26961L11.2714 8.27145L11.9786 8.97855L15.9804 4.97671L15.2733 4.26961ZM15.2733 2.85539C15.6638 3.24592 15.6638 3.87908 15.2733 4.26961L15.9804 4.97671C16.7614 4.19566 16.7614 2.92933 15.9804 2.14829L15.2733 2.85539ZM13.7304 2.72671C14.1209 2.33619 14.7541 2.33619 15.1446 2.72671L15.8517 2.01961C15.0707 1.23856 13.8043 1.23856 13.0233 2.01961L13.7304 2.72671Z"
fill="currentColor"
/>
</CustomIcon>
);
};

View File

@ -22,3 +22,6 @@ export * from './EllipseIcon';
export * from './EllipsesIcon'; export * from './EllipsesIcon';
export * from './SnowballIcon'; export * from './SnowballIcon';
export * from './NotificationBellIcon'; export * from './NotificationBellIcon';
export * from './PencilIcon';
export * from './CheckRadioIcon';
export * from './ChevronDownIcon';

View File

@ -85,7 +85,10 @@ export const DatePicker = ({
/> />
</Popover.Trigger> </Popover.Trigger>
<Popover.Portal> <Popover.Portal>
<Popover.Content onInteractOutside={() => setOpen(false)}> <Popover.Content
className="z-10"
onInteractOutside={() => setOpen(false)}
>
<Calendar <Calendar
{...calendarProps} {...calendarProps}
selectRange={selectRange} selectRange={selectRange}

View File

@ -0,0 +1,153 @@
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'],
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-low-em'],
popover: [
'z-20',
'absolute',
'px-1',
'py-1',
'flex-col',
'gap-0.5',
'min-w-full',
'bg-surface-floating',
'shadow-dropdown',
'w-auto',
'max-h-60',
'overflow-auto',
'border',
'border-gray-200',
'rounded-xl',
],
},
variants: {
orientation: {
horizontal: {
container: [],
},
vertical: {
container: [],
},
},
variant: {
default: {
container: [],
},
danger: {
container: [],
},
},
error: {
true: {
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'],
},
},
hasValue: {
true: '',
},
searchable: {
true: '',
false: {
input: ['cursor-pointer'],
},
},
hideValues: {
true: {
input: ['placeholder:text-elements-mid-em'],
},
},
},
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',
error: false,
isOpen: false,
hasValue: false,
},
});
export type SelectTheme = VariantProps<typeof selectTheme>;

View File

@ -0,0 +1,438 @@
import React, {
ReactNode,
useState,
ComponentPropsWithoutRef,
useMemo,
useCallback,
MouseEvent,
useRef,
useEffect,
} from 'react';
import { useMultipleSelection, useCombobox } from 'downshift';
import { SelectTheme, selectTheme } from './Select.theme';
import {
ChevronDownIcon,
CrossIcon,
WarningIcon,
} from 'components/shared/CustomIcon';
import { cloneIcon } from 'utils/cloneIcon';
import { cn } from 'utils/classnames';
import { SelectItem, EmptySelectItem } from './SelectItem';
import { SelectValue } from './SelectValue';
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;
};
/**
* The orientation of the select
*/
export type SelectOrientation = 'horizontal' | 'vertical';
interface SelectProps
extends Omit<
ComponentPropsWithoutRef<'input'>,
'size' | 'value' | 'onChange'
>,
SelectTheme {
/**
* 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;
/**
* Show the values of the select if it's multiple
*/
hideValues?: boolean;
/**
* The value of the select
*/
value?: SelectOption | SelectOption[];
/**
* Callback function when reset the select
*/
onClear?: () => void;
/**
* Callback function when the value of the select changes
*/
onChange?: (value: SelectOption | SelectOption[]) => void;
}
export const Select = ({
options,
multiple = false,
searchable = false,
clearable,
size,
error,
orientation = 'horizontal',
variant,
label,
description,
leftIcon,
rightIcon,
helperText,
hideValues = false,
placeholder: placeholderProp = 'Select an option',
value,
onChange,
onClear,
}: SelectProps) => {
const theme = selectTheme({ size, error, variant, orientation });
const [inputValue, setInputValue] = useState('');
const [selectedItem, setSelectedItem] = useState<SelectOption | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>(
'bottom',
);
const popoverRef = useRef(null); // Ref for the popover
const inputWrapperRef = useRef(null); // Ref for the input wrapper
// Calculate and update popover position
useEffect(() => {
if (dropdownOpen && popoverRef.current && inputWrapperRef.current) {
const popover = popoverRef.current;
// @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 lol
const popoverHeight = popover.offsetHeight;
// Determine if there's enough space below
if (spaceBelow >= popoverHeight) {
setDropdownPosition('bottom');
} else if (spaceAbove >= popoverHeight) {
setDropdownPosition('top');
} else {
// Default to bottom if neither has enough space, but you could also set logic to choose the side with more space
setDropdownPosition('bottom');
}
}
}, [dropdownOpen]); // Re-calculate whenever the dropdown is opened
useEffect(() => {
// If multiple selection is enabled, ensure the internal state is an array
if (multiple) {
if (Array.isArray(value)) {
// Directly use the provided array
setSelectedItems(value);
} else {
// Reset or set to empty array if the value is not an array
setSelectedItems([]);
}
} else {
// For single selection, directly set the selected item
setSelectedItem(value as SelectOption);
}
}, [value, multiple]);
const handleSelectedItemChange = (selectedItem: SelectOption | null) => {
setSelectedItem(selectedItem);
setInputValue(selectedItem ? selectedItem.label : '');
onChange?.(selectedItem as SelectOption);
};
const {
getSelectedItemProps,
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems,
setSelectedItems,
reset,
} = useMultipleSelection<SelectOption>({
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 options;
// Show only the items that match the input value
if (!multiple && searchable) {
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 options.filter(
(item) =>
item.label.toLowerCase().includes(inputValue.toLowerCase()) &&
!(
multiple && searchable // If the dropdown is multiple and searchable, show filtered items
? selectedItems
: []
).includes(item),
);
}, [options, 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);
// Callback for `onChange`
const newSelectedItems = [...selectedItems, selectedItem];
onChange?.(newSelectedItems);
} else {
// If the item is already selected, remove it from the selected items
removeSelectedItem(selectedItem);
// Callback for `onChange`
const newSelectedItems = selectedItems.filter(
(item) => selectedItem !== item,
);
onChange?.(newSelectedItems);
}
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: SelectOption) =>
multiple ? selectedItems.includes(item) : selectedItem === item,
[selectedItems, selectedItem, multiple],
);
const handleClear = (e: MouseEvent<SVGSVGElement, globalThis.MouseEvent>) => {
e.stopPropagation();
reset();
setSelectedItem(null);
setInputValue('');
onClear?.();
};
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()}>
{error &&
cloneIcon(<WarningIcon className={theme.helperIcon()} />, {
'aria-hidden': true,
})}
<p>{helperText}</p>
</div>
),
[cloneIcon, error, theme, helperText],
);
const isMultipleHasValue = multiple && selectedItems.length > 0;
const isMultipleHasValueButNotSearchable =
multiple && !searchable && selectedItems.length > 0;
const displayPlaceholder = useMemo(() => {
if (hideValues && isMultipleHasValue) {
return `${selectedItems.length} selected`;
}
if (isMultipleHasValueButNotSearchable) {
return '';
}
return placeholderProp;
}, [hideValues, multiple, selectedItems.length, placeholderProp]);
return (
<div className={theme.container()}>
{/* Label & description */}
{renderLabels}
{/* Input */}
<div
ref={inputWrapperRef}
className={theme.inputWrapper({
hasValue: isMultipleHasValue && !hideValues,
})}
onClick={() => !dropdownOpen && openMenu()}
>
{/* Left icon */}
{leftIcon && renderLeftIcon}
{/* Multiple input values */}
{isMultipleHasValue &&
!hideValues &&
selectedItems.map((item, index) => (
<SelectValue
key={`selected-item-${index}`}
{...getSelectedItemProps({ selectedItem: item, index })}
option={item}
size={size}
onDelete={removeSelectedItem}
/>
))}
{/* Single input value or searchable area */}
<input
{...getInputProps(getDropdownProps())}
placeholder={displayPlaceholder}
// Control readOnly based on searchable
readOnly={!searchable || hideValues}
className={cn(
theme.input({
searchable,
hideValues: hideValues && selectedItems.length > 0,
}),
{
// Make the input width smaller because we don't need it (not searchable)
'w-6': isMultipleHasValueButNotSearchable && !hideValues,
// Add margin to the X icon
'ml-6': isMultipleHasValueButNotSearchable && clearable,
},
)}
/>
{/* Right icon */}
{renderRightIcon}
</div>
{/* Helper text */}
{renderHelperText}
{/* Popover */}
<ul
{...getMenuProps({ ref: popoverRef }, { suppressRefError: true })}
id="popover"
ref={popoverRef}
className={cn(theme.popover({ isOpen }), {
// Position the popover based on the dropdown position
'top-[27.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 ? (
filteredItems.map((item, index) => (
<SelectItem
{...getItemProps({ item, index })}
key={item.value}
selected={isSelected(item)}
option={item}
hovered={highlightedIndex === index}
orientation={orientation}
variant={variant}
/>
))
) : (
<EmptySelectItem />
)}
</ul>
</div>
);
};

View File

@ -0,0 +1,56 @@
import { tv, VariantProps } from 'tailwind-variants';
export const selectItemTheme = tv({
slots: {
wrapper: [
'p-2',
'gap-3',
'flex',
'items-start',
'justify-between',
'rounded-lg',
'group',
'data-[disabled]:cursor-not-allowed',
],
icon: ['h-4.5', 'w-4.5', 'text-elements-high-em'],
content: ['flex', 'flex-1', 'whitespace-nowrap'],
label: [
'text-sm',
'text-elements-high-em',
'tracking-[-0.006em]',
'data-[disabled]:text-elements-disabled',
],
description: [
'text-xs',
'text-elements-low-em',
'data-[disabled]:text-elements-disabled',
],
dot: ['h-1', 'w-1', 'rounded-full', 'bg-border-interactive-hovered/[0.14]'],
},
variants: {
orientation: {
horizontal: {
wrapper: ['items-center'],
content: ['flex-row', 'items-center', 'gap-2'],
},
vertical: {
content: ['flex-col', 'gap-0.5'],
},
},
variant: {
default: {
wrapper: [],
},
danger: {
wrapper: [],
},
},
active: {
true: {
wrapper: ['bg-base-bg-emphasized', 'data-[disabled]:bg-transparent'],
},
},
},
});
export type SelectItemTheme = VariantProps<typeof selectItemTheme>;

View File

@ -0,0 +1,95 @@
import React, { forwardRef, ComponentPropsWithoutRef, useMemo } from 'react';
import { Overwrite, UseComboboxGetItemPropsReturnValue } from 'downshift';
import { SelectOption, SelectOrientation } from 'components/shared/Select';
import { selectItemTheme, SelectItemTheme } from './SelectItem.theme';
import { cloneIcon } from 'utils/cloneIcon';
import { cn } from 'utils/classnames';
import { CheckRadioIcon } from 'components/shared/CustomIcon';
import { OmitCommon } from 'types/common';
/**
* Represents a type that merges ComponentPropsWithoutRef<'li'> with certain exclusions.
* @type {MergedComponentPropsWithoutRef}
*/
type MergedComponentPropsWithoutRef = OmitCommon<
ComponentPropsWithoutRef<'li'>,
Omit<
Overwrite<UseComboboxGetItemPropsReturnValue, SelectOption[]>,
'index' | 'item'
>
>;
export interface SelectItemProps
extends MergedComponentPropsWithoutRef,
SelectItemTheme {
selected: boolean;
option: SelectOption;
orientation?: SelectOrientation;
hovered?: boolean;
}
const SelectItem = forwardRef<HTMLLIElement, SelectItemProps>(
(
{ className, selected, orientation, hovered, variant, option, ...props },
ref,
) => {
const theme = selectItemTheme({ active: hovered, orientation, variant });
const { label, description, leftIcon, rightIcon, disabled } = option;
const renderRightIcon = useMemo(() => {
if (rightIcon) {
return cloneIcon(rightIcon, { className: theme.icon() });
} else if (selected) {
return (
<CheckRadioIcon
className={cn(theme.icon(), 'text-controls-primary')}
/>
);
}
return null;
}, [rightIcon, theme, cloneIcon, cn, selected]);
return (
<li
{...props}
ref={ref}
className={theme.wrapper({ className })}
data-disabled={disabled}
>
{leftIcon && cloneIcon(leftIcon, { className: theme.icon() })}
<div className={theme.content()}>
<p className={theme.label()} data-disabled={disabled}>
{label}
</p>
{orientation === 'horizontal' && <span className={theme.dot()} />}
{description && (
<p className={theme.description()} data-disabled={disabled}>
{description}
</p>
)}
</div>
{renderRightIcon}
</li>
);
},
);
SelectItem.displayName = 'SelectItem';
/**
* Represents an empty select item.
* @returns {JSX.Element} - A JSX element representing the empty select item.
*/
export const EmptySelectItem = () => {
const theme = selectItemTheme();
return (
<li className={theme.wrapper()}>
<p className={theme.label({ class: 'text-elements-disabled' })}>
No results found
</p>
</li>
);
};
export { SelectItem };

View File

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

View File

@ -0,0 +1,30 @@
import { tv, VariantProps } from 'tailwind-variants';
export const selectValueTheme = tv({
slots: {
wrapper: [
'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',
],
icon: ['h-3.5', 'w-3.5'],
},
variants: {
size: {
sm: {
wrapper: ['pl-1', 'pr-0.5', 'gap-0.5'],
},
md: {
wrapper: ['pl-2', 'pr-1', 'gap-1'],
},
},
},
});
export type SelectValueTheme = VariantProps<typeof selectValueTheme>;

View File

@ -0,0 +1,60 @@
import React, { forwardRef, ComponentPropsWithoutRef } from 'react';
import {
Overwrite,
UseMultipleSelectionGetSelectedItemReturnValue,
} from 'downshift';
import { SelectValueTheme, selectValueTheme } from './SelectValue.theme';
import { OmitCommon } from 'types/common';
import { SelectOption } from 'components/shared/Select';
import { Button } from 'components/shared/Button';
import { CrossIcon } from 'components/shared/CustomIcon';
type MergedComponentPropsWithoutRef = OmitCommon<
ComponentPropsWithoutRef<'span'>,
Omit<
Overwrite<UseMultipleSelectionGetSelectedItemReturnValue, SelectOption[]>,
'index' | 'selectedItem'
>
>;
export interface SelectValueProps
extends MergedComponentPropsWithoutRef,
SelectValueTheme {
/**
* The option of the select value
*/
option: SelectOption;
/**
* The function to call when the delete button is clicked
* @param item
* @returns none;
*/
onDelete?: (item: SelectOption) => void;
}
/**
* The SelectValue component is used to display the selected value of a select component
*/
const SelectValue = forwardRef<HTMLSpanElement, SelectValueProps>(
({ className, size, option, onDelete, ...props }, ref) => {
const theme = selectValueTheme();
return (
<span {...props} ref={ref} className={theme.wrapper({ className, size })}>
{option.label}
<Button
onClick={() => onDelete?.(option)}
iconOnly
variant="unstyled"
size="xs"
>
<CrossIcon className={theme.icon()} />
</Button>
</span>
);
},
);
SelectValue.displayName = 'SelectValue';
export { SelectValue };

View File

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

View File

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

View File

@ -61,6 +61,7 @@ export const tabsTheme = tv({
'data-[orientation=horizontal]:focus-ring', 'data-[orientation=horizontal]:focus-ring',
// Vertical // Vertical
'data-[orientation=vertical]:gap-2', 'data-[orientation=vertical]:gap-2',
'data-[orientation=vertical]:justify-start',
], ],
triggerList: [ triggerList: [
'flex', 'flex',

View File

@ -35,13 +35,15 @@ const TabsTrigger = forwardRef<
// Disabled focus state for horizontal tabs // Disabled focus state for horizontal tabs
tabIndex={orientation === 'horizontal' ? -1 : undefined} tabIndex={orientation === 'horizontal' ? -1 : undefined}
className={triggerWrapper({ className })} className={triggerWrapper({ className })}
asChild
{...props} {...props}
> >
{/* Need to add button in the trigger children because there's focus state inside the children */} {/* Need to add button in the trigger children because there's focus state inside the children */}
<button <button className={triggerWrapper({ className })}>
data-orientation={orientation} <span
// Disabled focus state for vertical tabs // Disabled focus state for vertical tabs
tabIndex={orientation === 'vertical' ? -1 : undefined} tabIndex={orientation === 'horizontal' ? 0 : -1}
data-orientation={orientation}
className={trigger()} className={trigger()}
> >
{/* Put the icon on the left of the text for veritcal tab */} {/* Put the icon on the left of the text for veritcal tab */}
@ -49,6 +51,7 @@ const TabsTrigger = forwardRef<
{children} {children}
{/* Put the icon on the right of the text for horizontal tab */} {/* Put the icon on the right of the text for horizontal tab */}
{orientation === 'horizontal' && icon} {orientation === 'horizontal' && icon}
</span>
</button> </button>
</Trigger> </Trigger>
); );

View File

@ -1,9 +1,11 @@
import React, { useState } from 'react'; import React from 'react';
import { Calendar } from 'components/shared/Calendar'; import { Calendar } from 'components/shared/Calendar';
import { DatePicker } from 'components/shared/DatePicker'; import { DatePicker } from 'components/shared/DatePicker';
import { Radio } from 'components/shared/Radio'; import { Radio } from 'components/shared/Radio';
import { SegmentedControls } from 'components/shared/SegmentedControls'; import { SegmentedControls } from 'components/shared/SegmentedControls';
import { Switch } from 'components/shared/Switch'; import { Switch } from 'components/shared/Switch';
import { useState } from 'react';
import { Value } from 'react-calendar/dist/cjs/shared/types';
import { avatars, avatarsFallback } from './renders/avatar'; import { avatars, avatarsFallback } from './renders/avatar';
import { renderBadges } from './renders/badge'; import { renderBadges } from './renders/badge';
import { import {
@ -16,6 +18,7 @@ import {
renderCheckbox, renderCheckbox,
renderCheckboxWithDescription, renderCheckboxWithDescription,
} from './renders/checkbox'; } from './renders/checkbox';
import { DropdownExample } from './renders/dropdown';
import { import {
renderInlineNotificationWithDescriptions, renderInlineNotificationWithDescriptions,
renderInlineNotifications, renderInlineNotifications,
@ -32,9 +35,6 @@ import { renderDefaultTag, renderMinimalTag } from './renders/tag';
import { renderToast, renderToastsWithCta } from './renders/toast'; import { renderToast, renderToastsWithCta } from './renders/toast';
import { renderTooltips } from './renders/tooltip'; import { renderTooltips } from './renders/tooltip';
type ValuePiece = Date | null;
type Value = ValuePiece | [ValuePiece, ValuePiece];
const Page: React.FC = () => { const Page: React.FC = () => {
const [singleDate, setSingleDate] = useState<Value>(); const [singleDate, setSingleDate] = useState<Value>();
const [dateRange, setDateRange] = useState<Value>(); const [dateRange, setDateRange] = useState<Value>();
@ -270,6 +270,14 @@ const Page: React.FC = () => {
<div className="w-full h border border-gray-200 px-20 my-10" /> <div className="w-full h border border-gray-200 px-20 my-10" />
{/* Dropdown */}
<div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Dropdown / Select</h1>
<DropdownExample />
</div>
<div className="w-full h border border-gray-200 px-20 my-10" />
{/* Inline notification */} {/* Inline notification */}
<div className="flex flex-col gap-10 items-center justify-between"> <div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Inline Notification</h1> <h1 className="text-2xl font-bold">Inline Notification</h1>

View File

@ -0,0 +1,331 @@
import React, { useState } from 'react';
import { PencilIcon } from 'components/shared/CustomIcon';
import { SelectOption, Select } from 'components/shared/Select';
export const DROPDOWN_ITEMS: SelectOption[] = [
{
value: 'apple',
label: 'Apple',
description: 'Apple is fruit',
leftIcon: <PencilIcon />,
},
{
value: 'banana',
label: 'Banana',
description: 'Banana is fruit',
leftIcon: <PencilIcon />,
},
{
value: 'orange',
label: 'Orange',
description: 'Orange is fruit',
leftIcon: <PencilIcon />,
},
{
value: 'watermelon',
label: 'Watermelon',
description: 'Watermelon is fruit',
disabled: true,
leftIcon: <PencilIcon />,
},
];
export const DropdownExample = () => {
const [singleValue, setSingleValue] = useState<SelectOption>();
const [multipleValue, setMultipleValue] = useState<SelectOption[]>([]);
const handleSelect = (
type: 'single' | 'multiple',
value: SelectOption | SelectOption[],
) => {
if (type === 'single') {
setSingleValue(value as SelectOption);
} else {
setMultipleValue(value as SelectOption[]);
}
};
return (
<>
<p className="text-sm text-center text-gray-500 -mb-8">Single Small</p>
<div className="flex gap-4 flex-wrap justify-center">
<Select
size="sm"
placeholder="Default"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
size="sm"
placeholder="Clearable"
clearable
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
size="sm"
searchable
placeholder="Searchable"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
size="sm"
placeholder="Vertical"
orientation="vertical"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
</div>
<p className="text-sm text-center text-gray-500 -mb-8">Single Medium</p>
<div className="flex gap-4 flex-wrap justify-center">
<Select
placeholder="Default"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
placeholder="Clearable"
clearable
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
searchable
placeholder="Searchable"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
placeholder="Vertical"
orientation="vertical"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
</div>
<p className="text-sm text-center text-gray-500 -mb-8">
Multiple Small
</p>
<div className="flex gap-4 flex-wrap justify-center">
<Select
multiple
size="sm"
placeholder="Default"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
multiple
size="sm"
placeholder="Clearable"
clearable
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
searchable
multiple
size="sm"
placeholder="Searchable"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
multiple
size="sm"
placeholder="Vertical"
orientation="vertical"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
multiple
hideValues
size="sm"
orientation="vertical"
placeholder="Hide values"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
</div>
<p className="text-sm text-center text-gray-500 -mb-8">
Multiple Medium
</p>
<div className="flex gap-4 flex-wrap justify-center">
<Select
multiple
placeholder="Default"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
multiple
placeholder="Clearable"
clearable
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
searchable
multiple
placeholder="Searchable"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
multiple
placeholder="Vertical"
orientation="vertical"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
multiple
hideValues
orientation="vertical"
placeholder="Hide values"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
</div>
<p className="text-sm text-center text-gray-500 -mb-4">
Single With label, description, and helper text
</p>
<div className="flex gap-4 flex-wrap justify-center">
<Select
label="Default"
description="Single select component"
helperText="This is a helper text"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
label="Clearable"
description="Single select component"
helperText="This is a helper text"
clearable
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
searchable
label="Searchable"
description="Single select component"
helperText="This is a helper text"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
<Select
label="Vertical"
description="Single select component"
helperText="This is a helper text"
orientation="vertical"
options={DROPDOWN_ITEMS}
value={singleValue}
onChange={(value) => handleSelect('single', value)}
/>
</div>
<p className="text-sm text-center text-gray-500 -mb-4">
Multiple With label, description, and helper text
</p>
<div className="flex gap-4 flex-wrap justify-center">
<Select
label="Default"
description="Multiple select component"
helperText="This is a helper text"
multiple
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
label="Clearable"
description="Multiple select component"
helperText="This is a helper text"
multiple
clearable
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
searchable
label="Searchable"
description="Multiple select component"
helperText="This is a helper text"
multiple
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
<Select
label="Vertical"
description="Multiple select component"
helperText="This is a helper text"
multiple
orientation="vertical"
options={DROPDOWN_ITEMS}
value={multipleValue}
onChange={(value) => handleSelect('multiple', value)}
/>
</div>
<p className="text-sm text-center text-gray-500 -mb-4">
Error With label, description, and helper text
</p>
<div className="flex gap-4 flex-wrap justify-center">
<Select
error
label="Default"
description="Multiple select component"
helperText="This is a helper text"
options={DROPDOWN_ITEMS}
/>
<Select
error
label="Clearable"
description="Multiple select component"
helperText="This is a helper text"
clearable
options={DROPDOWN_ITEMS}
/>
<Select
error
searchable
label="Searchable"
description="Multiple select component"
helperText="This is a helper text"
options={DROPDOWN_ITEMS}
/>
<Select
error
label="Vertical"
description="Multiple select component"
helperText="This is a helper text"
orientation="vertical"
options={DROPDOWN_ITEMS}
/>
</div>
</>
);
};

View File

@ -0,0 +1,9 @@
/**
* Construct a type by excluding common keys from one type to another.
* @template T - The type from which to omit properties.
* @template U - The type whose properties to omit from T.
* @param {T} - The source type.
* @param {U} - The target type.
* @returns A new type that includes all properties from T except those that are common with U.
*/
export type OmitCommon<T, U> = Pick<T, Exclude<keyof T, keyof U>>;

View File

@ -151,7 +151,7 @@ export default withMT({
boxShadow: { boxShadow: {
button: button:
'inset 0px 0px 4px rgba(255, 255, 255, 0.25), inset 0px -2px 0px rgba(0, 0, 0, 0.04)', 'inset 0px 0px 4px rgba(255, 255, 255, 0.25), inset 0px -2px 0px rgba(0, 0, 0, 0.04)',
calendar: dropdown:
'0px 3px 20px rgba(8, 47, 86, 0.1), 0px 0px 4px rgba(8, 47, 86, 0.14)', '0px 3px 20px rgba(8, 47, 86, 0.1), 0px 0px 4px rgba(8, 47, 86, 0.14)',
field: '0px 1px 2px rgba(0, 0, 0, 0.04)', field: '0px 1px 2px rgba(0, 0, 0, 0.04)',
inset: 'inset 0px 1px 0px rgba(8, 47, 86, 0.06)', inset: 'inset 0px 1px 0px rgba(8, 47, 86, 0.06)',
@ -160,6 +160,7 @@ export default withMT({
2.5: '0.625rem', 2.5: '0.625rem',
3.25: '0.8125rem', 3.25: '0.8125rem',
3.5: '0.875rem', 3.5: '0.875rem',
4.5: '1.125rem',
}, },
zIndex: { zIndex: {
toast: '9999', toast: '9999',