forked from cerc-io/snowballtools-base
[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:
parent
effa0bbf14
commit
c731dd308c
39
packages/frontend/.vscode/settings.json
vendored
39
packages/frontend/.vscode/settings.json
vendored
@ -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\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
||||
]
|
||||
}
|
@ -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",
|
||||
|
@ -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'],
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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';
|
||||
|
@ -85,7 +85,10 @@ export const DatePicker = ({
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content onInteractOutside={() => setOpen(false)}>
|
||||
<Popover.Content
|
||||
className="z-10"
|
||||
onInteractOutside={() => setOpen(false)}
|
||||
>
|
||||
<Calendar
|
||||
{...calendarProps}
|
||||
selectRange={selectRange}
|
||||
|
153
packages/frontend/src/components/shared/Select/Select.theme.ts
Normal file
153
packages/frontend/src/components/shared/Select/Select.theme.ts
Normal 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>;
|
438
packages/frontend/src/components/shared/Select/Select.tsx
Normal file
438
packages/frontend/src/components/shared/Select/Select.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>;
|
@ -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 };
|
@ -0,0 +1 @@
|
||||
export * from './SelectItem';
|
@ -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>;
|
@ -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 };
|
@ -0,0 +1 @@
|
||||
export * from './SelectValue';
|
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';
|
@ -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',
|
||||
|
@ -35,13 +35,15 @@ 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 */}
|
||||
<button
|
||||
data-orientation={orientation}
|
||||
<button className={triggerWrapper({ className })}>
|
||||
<span
|
||||
// Disabled focus state for vertical tabs
|
||||
tabIndex={orientation === 'vertical' ? -1 : undefined}
|
||||
tabIndex={orientation === 'horizontal' ? 0 : -1}
|
||||
data-orientation={orientation}
|
||||
className={trigger()}
|
||||
>
|
||||
{/* Put the icon on the left of the text for veritcal tab */}
|
||||
@ -49,6 +51,7 @@ const TabsTrigger = forwardRef<
|
||||
{children}
|
||||
{/* Put the icon on the right of the text for horizontal tab */}
|
||||
{orientation === 'horizontal' && icon}
|
||||
</span>
|
||||
</button>
|
||||
</Trigger>
|
||||
);
|
||||
|
@ -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<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" />
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex flex-col gap-10 items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Inline Notification</h1>
|
||||
|
331
packages/frontend/src/pages/components/renders/dropdown.tsx
Normal file
331
packages/frontend/src/pages/components/renders/dropdown.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
9
packages/frontend/src/types/common.ts
Normal file
9
packages/frontend/src/types/common.ts
Normal 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>>;
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user