From c731dd308cf347fff063985c2151cfa9c2c214bf Mon Sep 17 00:00:00 2001 From: Wahyu Kurniawan Date: Tue, 27 Feb 2024 12:05:16 +0700 Subject: [PATCH] [T-4840: feat] Dropdown/select component (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🎨 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 --- packages/frontend/.vscode/settings.json | 39 -- packages/frontend/package.json | 4 +- .../shared/Calendar/Calendar.theme.ts | 2 +- .../shared/CustomIcon/CheckRadioIcon.tsx | 21 + .../shared/CustomIcon/ChevronDownIcon.tsx | 22 + .../shared/CustomIcon/PencilIcon.tsx | 19 + .../src/components/shared/CustomIcon/index.ts | 3 + .../shared/DatePicker/DatePicker.tsx | 5 +- .../components/shared/Select/Select.theme.ts | 153 ++++++ .../src/components/shared/Select/Select.tsx | 438 ++++++++++++++++++ .../Select/SelectItem/SelectItem.theme.ts | 56 +++ .../shared/Select/SelectItem/SelectItem.tsx | 95 ++++ .../shared/Select/SelectItem/index.ts | 1 + .../Select/SelectValue/SelectValue.theme.ts | 30 ++ .../shared/Select/SelectValue/SelectValue.tsx | 60 +++ .../shared/Select/SelectValue/index.ts | 1 + .../src/components/shared/Select/index.ts | 1 + .../src/components/shared/Tabs/Tabs.theme.ts | 1 + .../shared/Tabs/TabsTrigger/TabsTrigger.tsx | 25 +- .../frontend/src/pages/components/index.tsx | 16 +- .../src/pages/components/renders/dropdown.tsx | 331 +++++++++++++ packages/frontend/src/types/common.ts | 9 + packages/frontend/tailwind.config.js | 3 +- 23 files changed, 1276 insertions(+), 59 deletions(-) delete mode 100644 packages/frontend/.vscode/settings.json create mode 100644 packages/frontend/src/components/shared/CustomIcon/CheckRadioIcon.tsx create mode 100644 packages/frontend/src/components/shared/CustomIcon/ChevronDownIcon.tsx create mode 100644 packages/frontend/src/components/shared/CustomIcon/PencilIcon.tsx create mode 100644 packages/frontend/src/components/shared/Select/Select.theme.ts create mode 100644 packages/frontend/src/components/shared/Select/Select.tsx create mode 100644 packages/frontend/src/components/shared/Select/SelectItem/SelectItem.theme.ts create mode 100644 packages/frontend/src/components/shared/Select/SelectItem/SelectItem.tsx create mode 100644 packages/frontend/src/components/shared/Select/SelectItem/index.ts create mode 100644 packages/frontend/src/components/shared/Select/SelectValue/SelectValue.theme.ts create mode 100644 packages/frontend/src/components/shared/Select/SelectValue/SelectValue.tsx create mode 100644 packages/frontend/src/components/shared/Select/SelectValue/index.ts create mode 100644 packages/frontend/src/components/shared/Select/index.ts create mode 100644 packages/frontend/src/pages/components/renders/dropdown.tsx create mode 100644 packages/frontend/src/types/common.ts diff --git a/packages/frontend/.vscode/settings.json b/packages/frontend/.vscode/settings.json deleted file mode 100644 index 3566c26..0000000 --- a/packages/frontend/.vscode/settings.json +++ /dev/null @@ -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\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] - ] -} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 5467e2a..caf87b8 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -9,8 +9,8 @@ "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@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-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", @@ -27,7 +27,7 @@ "axios": "^1.6.7", "clsx": "^2.1.0", "date-fns": "^3.3.1", - "downshift": "^8.2.3", + "downshift": "^8.3.2", "eslint-config-react-app": "^7.0.1", "gql-client": "^1.0.0", "luxon": "^3.4.4", diff --git a/packages/frontend/src/components/shared/Calendar/Calendar.theme.ts b/packages/frontend/src/components/shared/Calendar/Calendar.theme.ts index ce6e93d..88feb6e 100644 --- a/packages/frontend/src/components/shared/Calendar/Calendar.theme.ts +++ b/packages/frontend/src/components/shared/Calendar/Calendar.theme.ts @@ -5,7 +5,7 @@ export const calendarTheme = tv({ wrapper: [ 'max-w-[352px]', 'bg-surface-floating', - 'shadow-calendar', + 'shadow-dropdown', 'rounded-xl', ], calendar: ['flex', 'flex-col', 'py-2', 'px-2', 'gap-2'], diff --git a/packages/frontend/src/components/shared/CustomIcon/CheckRadioIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/CheckRadioIcon.tsx new file mode 100644 index 0000000..9795cb3 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/CheckRadioIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const CheckRadioIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/ChevronDownIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/ChevronDownIcon.tsx new file mode 100644 index 0000000..1e83d82 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/ChevronDownIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const ChevronDownIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/PencilIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/PencilIcon.tsx new file mode 100644 index 0000000..11da196 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/PencilIcon.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const PencilIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/index.ts b/packages/frontend/src/components/shared/CustomIcon/index.ts index d5a6535..2b9834f 100644 --- a/packages/frontend/src/components/shared/CustomIcon/index.ts +++ b/packages/frontend/src/components/shared/CustomIcon/index.ts @@ -22,3 +22,6 @@ export * from './EllipseIcon'; export * from './EllipsesIcon'; export * from './SnowballIcon'; export * from './NotificationBellIcon'; +export * from './PencilIcon'; +export * from './CheckRadioIcon'; +export * from './ChevronDownIcon'; diff --git a/packages/frontend/src/components/shared/DatePicker/DatePicker.tsx b/packages/frontend/src/components/shared/DatePicker/DatePicker.tsx index 69b44e8..99fd82a 100644 --- a/packages/frontend/src/components/shared/DatePicker/DatePicker.tsx +++ b/packages/frontend/src/components/shared/DatePicker/DatePicker.tsx @@ -85,7 +85,10 @@ export const DatePicker = ({ /> - setOpen(false)}> + setOpen(false)} + > ; diff --git a/packages/frontend/src/components/shared/Select/Select.tsx b/packages/frontend/src/components/shared/Select/Select.tsx new file mode 100644 index 0000000..963cc0b --- /dev/null +++ b/packages/frontend/src/components/shared/Select/Select.tsx @@ -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(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({ + 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) => { + e.stopPropagation(); + reset(); + setSelectedItem(null); + setInputValue(''); + onClear?.(); + }; + + const renderLabels = useMemo( + () => ( +
+

{label}

+

{description}

+
+ ), + [theme, label, description], + ); + + const renderLeftIcon = useMemo(() => { + return ( +
+ {cloneIcon(leftIcon, { className: theme.icon(), 'aria-hidden': true })} +
+ ); + }, [cloneIcon, theme, leftIcon]); + + const renderRightIcon = useMemo(() => { + return ( +
+ {clearable && (selectedItems.length > 0 || selectedItem) && ( + + )} + {rightIcon ? ( + cloneIcon(rightIcon, { className: theme.icon(), 'aria-hidden': true }) + ) : ( + + )} +
+ ); + }, [cloneIcon, theme, rightIcon]); + + const renderHelperText = useMemo( + () => ( +
+ {error && + cloneIcon(, { + 'aria-hidden': true, + })} +

{helperText}

+
+ ), + [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 ( +
+ {/* Label & description */} + {renderLabels} + + {/* Input */} +
!dropdownOpen && openMenu()} + > + {/* Left icon */} + {leftIcon && renderLeftIcon} + + {/* Multiple input values */} + {isMultipleHasValue && + !hideValues && + selectedItems.map((item, index) => ( + + ))} + + {/* Single input value or searchable area */} + 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} +
+ + {/* Helper text */} + {renderHelperText} + + {/* Popover */} +
    + {isOpen && filteredItems.length !== 0 ? ( + filteredItems.map((item, index) => ( + + )) + ) : ( + + )} +
+
+ ); +}; diff --git a/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.theme.ts b/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.theme.ts new file mode 100644 index 0000000..75ac68d --- /dev/null +++ b/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.theme.ts @@ -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; diff --git a/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.tsx b/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.tsx new file mode 100644 index 0000000..a6bbc48 --- /dev/null +++ b/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.tsx @@ -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, + 'index' | 'item' + > +>; + +export interface SelectItemProps + extends MergedComponentPropsWithoutRef, + SelectItemTheme { + selected: boolean; + option: SelectOption; + orientation?: SelectOrientation; + hovered?: boolean; +} + +const SelectItem = forwardRef( + ( + { 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 ( + + ); + } + return null; + }, [rightIcon, theme, cloneIcon, cn, selected]); + + return ( +
  • + {leftIcon && cloneIcon(leftIcon, { className: theme.icon() })} +
    +

    + {label} +

    + {orientation === 'horizontal' && } + {description && ( +

    + {description} +

    + )} +
    + {renderRightIcon} +
  • + ); + }, +); + +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 ( +
  • +

    + No results found +

    +
  • + ); +}; + +export { SelectItem }; diff --git a/packages/frontend/src/components/shared/Select/SelectItem/index.ts b/packages/frontend/src/components/shared/Select/SelectItem/index.ts new file mode 100644 index 0000000..fbd9e18 --- /dev/null +++ b/packages/frontend/src/components/shared/Select/SelectItem/index.ts @@ -0,0 +1 @@ +export * from './SelectItem'; diff --git a/packages/frontend/src/components/shared/Select/SelectValue/SelectValue.theme.ts b/packages/frontend/src/components/shared/Select/SelectValue/SelectValue.theme.ts new file mode 100644 index 0000000..7c3ca4f --- /dev/null +++ b/packages/frontend/src/components/shared/Select/SelectValue/SelectValue.theme.ts @@ -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; diff --git a/packages/frontend/src/components/shared/Select/SelectValue/SelectValue.tsx b/packages/frontend/src/components/shared/Select/SelectValue/SelectValue.tsx new file mode 100644 index 0000000..9eedfd4 --- /dev/null +++ b/packages/frontend/src/components/shared/Select/SelectValue/SelectValue.tsx @@ -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, + '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( + ({ className, size, option, onDelete, ...props }, ref) => { + const theme = selectValueTheme(); + + return ( + + {option.label} + + + ); + }, +); + +SelectValue.displayName = 'SelectValue'; + +export { SelectValue }; diff --git a/packages/frontend/src/components/shared/Select/SelectValue/index.ts b/packages/frontend/src/components/shared/Select/SelectValue/index.ts new file mode 100644 index 0000000..247ce0d --- /dev/null +++ b/packages/frontend/src/components/shared/Select/SelectValue/index.ts @@ -0,0 +1 @@ +export * from './SelectValue'; diff --git a/packages/frontend/src/components/shared/Select/index.ts b/packages/frontend/src/components/shared/Select/index.ts new file mode 100644 index 0000000..7868ecb --- /dev/null +++ b/packages/frontend/src/components/shared/Select/index.ts @@ -0,0 +1 @@ +export * from './Select'; diff --git a/packages/frontend/src/components/shared/Tabs/Tabs.theme.ts b/packages/frontend/src/components/shared/Tabs/Tabs.theme.ts index 4261fbb..8624350 100644 --- a/packages/frontend/src/components/shared/Tabs/Tabs.theme.ts +++ b/packages/frontend/src/components/shared/Tabs/Tabs.theme.ts @@ -61,6 +61,7 @@ export const tabsTheme = tv({ 'data-[orientation=horizontal]:focus-ring', // Vertical 'data-[orientation=vertical]:gap-2', + 'data-[orientation=vertical]:justify-start', ], triggerList: [ 'flex', diff --git a/packages/frontend/src/components/shared/Tabs/TabsTrigger/TabsTrigger.tsx b/packages/frontend/src/components/shared/Tabs/TabsTrigger/TabsTrigger.tsx index 61093bf..6cd7093 100644 --- a/packages/frontend/src/components/shared/Tabs/TabsTrigger/TabsTrigger.tsx +++ b/packages/frontend/src/components/shared/Tabs/TabsTrigger/TabsTrigger.tsx @@ -35,20 +35,23 @@ const TabsTrigger = forwardRef< // Disabled focus state for horizontal tabs tabIndex={orientation === 'horizontal' ? -1 : undefined} className={triggerWrapper({ className })} + asChild {...props} > {/* Need to add button in the trigger children because there's focus state inside the children */} - ); diff --git a/packages/frontend/src/pages/components/index.tsx b/packages/frontend/src/pages/components/index.tsx index 28f4572..aab2d65 100644 --- a/packages/frontend/src/pages/components/index.tsx +++ b/packages/frontend/src/pages/components/index.tsx @@ -1,9 +1,11 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Calendar } from 'components/shared/Calendar'; import { DatePicker } from 'components/shared/DatePicker'; import { Radio } from 'components/shared/Radio'; import { SegmentedControls } from 'components/shared/SegmentedControls'; 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 { renderBadges } from './renders/badge'; import { @@ -16,6 +18,7 @@ import { renderCheckbox, renderCheckboxWithDescription, } from './renders/checkbox'; +import { DropdownExample } from './renders/dropdown'; import { renderInlineNotificationWithDescriptions, renderInlineNotifications, @@ -32,9 +35,6 @@ import { renderDefaultTag, renderMinimalTag } from './renders/tag'; import { renderToast, renderToastsWithCta } from './renders/toast'; import { renderTooltips } from './renders/tooltip'; -type ValuePiece = Date | null; -type Value = ValuePiece | [ValuePiece, ValuePiece]; - const Page: React.FC = () => { const [singleDate, setSingleDate] = useState(); const [dateRange, setDateRange] = useState(); @@ -270,6 +270,14 @@ const Page: React.FC = () => {
    + {/* Dropdown */} +
    +

    Dropdown / Select

    + +
    + +
    + {/* Inline notification */}

    Inline Notification

    diff --git a/packages/frontend/src/pages/components/renders/dropdown.tsx b/packages/frontend/src/pages/components/renders/dropdown.tsx new file mode 100644 index 0000000..d4ccf26 --- /dev/null +++ b/packages/frontend/src/pages/components/renders/dropdown.tsx @@ -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: , + }, + { + value: 'banana', + label: 'Banana', + description: 'Banana is fruit', + leftIcon: , + }, + { + value: 'orange', + label: 'Orange', + description: 'Orange is fruit', + leftIcon: , + }, + { + value: 'watermelon', + label: 'Watermelon', + description: 'Watermelon is fruit', + disabled: true, + leftIcon: , + }, +]; + +export const DropdownExample = () => { + const [singleValue, setSingleValue] = useState(); + const [multipleValue, setMultipleValue] = useState([]); + + const handleSelect = ( + type: 'single' | 'multiple', + value: SelectOption | SelectOption[], + ) => { + if (type === 'single') { + setSingleValue(value as SelectOption); + } else { + setMultipleValue(value as SelectOption[]); + } + }; + + return ( + <> +

    Single – Small

    +
    + handleSelect('single', value)} + /> + handleSelect('single', value)} + /> +
    +

    Single – Medium

    +
    + handleSelect('single', value)} + /> + handleSelect('single', value)} + /> +
    +

    + Multiple – Small +

    +
    + handleSelect('multiple', value)} + /> + handleSelect('multiple', value)} + /> + handleSelect('multiple', value)} + /> + handleSelect('multiple', value)} + /> + handleSelect('multiple', value)} + /> +
    +

    + Single – With label, description, and helper text +

    +
    + handleSelect('single', value)} + /> + handleSelect('single', value)} + /> +
    +

    + Multiple – With label, description, and helper text +

    +
    + handleSelect('multiple', value)} + /> + handleSelect('multiple', value)} + /> +
    +

    + Error – With label, description, and helper text +

    +
    + + +
    + + ); +}; diff --git a/packages/frontend/src/types/common.ts b/packages/frontend/src/types/common.ts new file mode 100644 index 0000000..fc79628 --- /dev/null +++ b/packages/frontend/src/types/common.ts @@ -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 = Pick>; diff --git a/packages/frontend/tailwind.config.js b/packages/frontend/tailwind.config.js index ffd1763..2ddac40 100644 --- a/packages/frontend/tailwind.config.js +++ b/packages/frontend/tailwind.config.js @@ -151,7 +151,7 @@ export default withMT({ boxShadow: { button: '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)', field: '0px 1px 2px rgba(0, 0, 0, 0.04)', inset: 'inset 0px 1px 0px rgba(8, 47, 86, 0.06)', @@ -160,6 +160,7 @@ export default withMT({ 2.5: '0.625rem', 3.25: '0.8125rem', 3.5: '0.875rem', + 4.5: '1.125rem', }, zIndex: { toast: '9999',