{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "select", "type": "registry:ui", "title": "Select", "description": "A select component that can be used to display a dropdown list of options.", "registryDependencies": [ "@tra-kit/popover", "@tra-kit/label", "@tra-kit/button" ], "files": [ { "path": "registry/tra-kit/components/select.tsx", "content": "import { cva } from 'class-variance-authority';\r\nimport React, { useEffect, useRef, useState } from 'react';\r\nimport { CaretDownIcon, CheckIcon, XIcon } from '@phosphor-icons/react';\r\nimport Label from '@/components/ui/label';\r\nimport { cn, preventScrollShift } from '@/lib/utils';\r\nimport Button from '@/components/ui/button';\r\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\r\n\r\ninterface ISearchInput {\r\n searchValue: string;\r\n onSearch: (e: React.ChangeEvent) => void;\r\n searchRef: React.RefObject;\r\n searchInputClassName: string;\r\n handleKeyDown: (e: React.KeyboardEvent) => void;\r\n disabled: boolean;\r\n showMenu: boolean;\r\n}\r\n\r\nconst SearchInput: React.FC = ({\r\n searchValue,\r\n onSearch,\r\n searchRef,\r\n searchInputClassName,\r\n handleKeyDown,\r\n disabled,\r\n showMenu,\r\n}) => (\r\n }\r\n style={{ width: searchValue ? `${searchValue.length + 1}ch` : '1ch' }}\r\n onKeyDown={handleKeyDown}\r\n />\r\n);\r\n\r\nconst selectVariants = cva(\r\n `custom--dropdown-container border-neutral data-[disabled=true]:border-input-light data-[disabled=true]:bg-input-light data-[disabled=true]:text-neutral-grey relative flex w-full cursor-pointer \r\n items-center rounded-md border text-left data-[disabled=true]:cursor-not-allowed\r\n disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus:border-ring`,\r\n {\r\n variants: {\r\n showMenu: {\r\n true: 'border-primary-focused',\r\n false: '',\r\n },\r\n size: {\r\n default: 'text-sm',\r\n sm: '',\r\n lg: '',\r\n },\r\n isMulti: {\r\n true: '',\r\n false: '',\r\n },\r\n isSearchable: {\r\n true: 'cursor-text',\r\n false: '',\r\n },\r\n error: {\r\n true: 'border-error focus-visible:outline-error focus-visible:shadow-none focus-visible:-outline-offset-1',\r\n false: '',\r\n },\r\n },\r\n defaultVariants: {\r\n showMenu: false,\r\n size: 'default',\r\n },\r\n compoundVariants: [\r\n {\r\n isMulti: true,\r\n size: 'default',\r\n className: 'min-h-14',\r\n },\r\n {\r\n isMulti: true,\r\n size: 'sm',\r\n className: 'min-h-13',\r\n },\r\n {\r\n isMulti: true,\r\n size: 'lg',\r\n className: 'min-h-15',\r\n },\r\n {\r\n isMulti: false,\r\n size: 'default',\r\n className: 'h-14',\r\n },\r\n {\r\n isMulti: false,\r\n size: 'sm',\r\n className: 'h-13',\r\n },\r\n {\r\n isMulti: false,\r\n size: 'lg',\r\n className: 'h-15',\r\n },\r\n ],\r\n },\r\n);\r\n\r\nexport interface ISelectOption {\r\n content: string | React.ReactNode;\r\n value: number | string | boolean;\r\n disabled?: boolean;\r\n className?: string;\r\n}\r\n\r\ninterface ISelect {\r\n placeholder?: string;\r\n size?: 'default' | 'sm' | 'lg';\r\n options: Array | ISelectOption;\r\n isMulti?: boolean;\r\n isSearchable?: boolean;\r\n onChange: (e: string | number | Array | Array | boolean) => void;\r\n label?: string;\r\n disabled?: boolean;\r\n error?: boolean;\r\n defaultValue?: string | number | Array | Array;\r\n completeButton?: boolean | 'mobile';\r\n completeButtonText?: string;\r\n searchInputClassName?: string;\r\n dropdownTriggerClassName?: string;\r\n dropdownTagClassName?: string;\r\n dropdownTagCloseButtonClassName?: string;\r\n className?: string;\r\n labelClassName?: string;\r\n containerClassName?: string;\r\n selectTextClassName?: string;\r\n iconClassName?: string;\r\n dropdownMenuClassName?: string;\r\n dropdownItemClassName?: string;\r\n completeButtonContainerClassName?: string;\r\n completeButtonClassName?: string;\r\n value?: string | number | Array | Array | boolean;\r\n id?: string;\r\n tooltip?: string | Array;\r\n showRequiredIcon?: boolean;\r\n dropdownAlign?: 'left' | 'right';\r\n noOptionsMessage?: string;\r\n forceTriggerWidth?: boolean;\r\n dropdownItemContainerClassName?: string;\r\n hideClearButton?: boolean;\r\n}\r\n\r\nconst Select: React.FC = ({\r\n placeholder = 'Select...',\r\n label,\r\n size = 'default',\r\n value,\r\n disabled = false,\r\n error = false,\r\n options,\r\n isMulti = false,\r\n isSearchable = false,\r\n onChange = () => {},\r\n defaultValue,\r\n completeButton = false,\r\n completeButtonText,\r\n searchInputClassName = '',\r\n dropdownTagClassName = '',\r\n dropdownTagCloseButtonClassName = '',\r\n className = '',\r\n labelClassName = '',\r\n containerClassName = '',\r\n selectTextClassName = '',\r\n iconClassName = '',\r\n dropdownTriggerClassName,\r\n dropdownMenuClassName = '',\r\n dropdownItemClassName = '',\r\n completeButtonContainerClassName = '',\r\n completeButtonClassName = '',\r\n id,\r\n tooltip,\r\n showRequiredIcon,\r\n dropdownAlign,\r\n noOptionsMessage,\r\n forceTriggerWidth = true,\r\n dropdownItemContainerClassName,\r\n hideClearButton = false,\r\n}) => {\r\n const [showMenu, setShowMenu] = useState(false);\r\n const [selectedValue, setSelectedValue] = useState | null>(\r\n isMulti ? [] : null,\r\n );\r\n const [searchValue, setSearchValue] = useState(isSearchable ? '' : null);\r\n const [highlightedIndex, setHighlightedIndex] = useState(-1);\r\n const searchRef = useRef(null);\r\n const inputRef = useRef(null);\r\n const dropdownRef = useRef(null);\r\n\r\n const getOptions = () => {\r\n if (!searchValue) {\r\n return options;\r\n }\r\n return Array.isArray(options)\r\n ? options.filter(\r\n (option) =>\r\n typeof option.content === 'string' &&\r\n option.content.toLowerCase().includes(searchValue.toLowerCase()),\r\n )\r\n : [];\r\n };\r\n\r\n const hasSelection = isMulti\r\n ? Array.isArray(selectedValue) && selectedValue.length > 0\r\n : !!selectedValue;\r\n\r\n const optionList = Array.isArray(getOptions()) ? getOptions() : [getOptions()].flat();\r\n\r\n const scrollToHighlightedItem = (index: number) => {\r\n const dropdownMenu = dropdownRef.current;\r\n const highlightedItem = dropdownMenu?.children[index] as HTMLElement;\r\n if (highlightedItem) {\r\n highlightedItem.scrollIntoView({ block: 'nearest' });\r\n }\r\n };\r\n\r\n useEffect(() => {\r\n const open = showMenu;\r\n if (!open) return;\r\n\r\n let currentOptions = Array.isArray(getOptions()) ? getOptions() : [getOptions()].flat();\r\n if (hasSelection) {\r\n currentOptions = [...(Array.isArray(currentOptions) ? currentOptions : [currentOptions])];\r\n }\r\n\r\n const findSelectedIndex = (): number => {\r\n if (!Array.isArray(currentOptions) || currentOptions.length === 0) return -1;\r\n if (isMulti && Array.isArray(selectedValue) && selectedValue.length > 0) {\r\n const firstSelected = selectedValue[0];\r\n return currentOptions.findIndex((opt) => opt.value === firstSelected?.value);\r\n }\r\n\r\n if (!isMulti && selectedValue) {\r\n return currentOptions.findIndex(\r\n (opt) => opt.value === (selectedValue as ISelectOption).value,\r\n );\r\n }\r\n return -1;\r\n };\r\n\r\n const index = findSelectedIndex();\r\n setTimeout(() => {\r\n setHighlightedIndex(index);\r\n if (index >= 0) {\r\n scrollToHighlightedItem(index);\r\n }\r\n }, 0);\r\n }, [showMenu, selectedValue, options, isMulti, hasSelection]);\r\n\r\n useEffect(() => {\r\n if (defaultValue) {\r\n if (isMulti && Array.isArray(defaultValue)) {\r\n const defaultOptions = (options as Array).filter((option) =>\r\n (defaultValue as Array).includes(option.value),\r\n );\r\n const t = setTimeout(() => setSelectedValue(defaultOptions), 0);\r\n return () => clearTimeout(t);\r\n } else if (!isMulti && !Array.isArray(defaultValue)) {\r\n const defaultOption: ISelectOption | undefined = (options as Array).find(\r\n (option: ISelectOption) => option.value === defaultValue,\r\n );\r\n const t = setTimeout(() => setSelectedValue(defaultOption || null), 0);\r\n return () => clearTimeout(t);\r\n }\r\n }\r\n }, [defaultValue, options, isMulti]);\r\n\r\n useEffect(() => {\r\n let timer: ReturnType | undefined;\r\n if (value !== null && value !== undefined) {\r\n if (isMulti && Array.isArray(value)) {\r\n const selectedOptions = (options as Array).filter((option) =>\r\n (value as Array).includes(option.value),\r\n );\r\n timer = setTimeout(() => setSelectedValue(selectedOptions), 0);\r\n } else if (!isMulti && !Array.isArray(value)) {\r\n const selectedOption = (options as Array).find(\r\n (option) => option.value === value,\r\n );\r\n timer = setTimeout(() => setSelectedValue(selectedOption || null), 0);\r\n }\r\n } else {\r\n timer = setTimeout(() => setSelectedValue(null), 0);\r\n }\r\n return () => {\r\n if (timer) clearTimeout(timer);\r\n };\r\n }, [value, options, isMulti]);\r\n\r\n useEffect(() => {\r\n const t = setTimeout(() => {\r\n setSearchValue('');\r\n if (showMenu && searchRef.current) {\r\n searchRef.current.focus();\r\n }\r\n }, 0);\r\n if (showMenu) {\r\n preventScrollShift.lock();\r\n } else {\r\n preventScrollShift.unlock();\r\n }\r\n return () => clearTimeout(t);\r\n }, [showMenu]);\r\n\r\n useEffect(() => {\r\n const handleClickOutside = (event: MouseEvent) => {\r\n if (\r\n inputRef.current &&\r\n !inputRef.current.contains(event.target as Node) &&\r\n dropdownRef.current &&\r\n !dropdownRef.current.contains(event.target as Node)\r\n ) {\r\n setShowMenu(false);\r\n }\r\n };\r\n\r\n document.addEventListener('mousedown', handleClickOutside);\r\n return () => {\r\n document.removeEventListener('mousedown', handleClickOutside);\r\n };\r\n }, []);\r\n\r\n const removeOption = (option: ISelectOption): Array | null =>\r\n selectedValue && Array.isArray(selectedValue)\r\n ? selectedValue.filter((o) => o.value !== option.value)\r\n : null;\r\n\r\n const getOptionValues = (opts: Array): Array | Array => {\r\n const values = opts\r\n .map((o) => o.value)\r\n .filter((v): v is string | number => typeof v !== 'boolean');\r\n if (values.every((v): v is number => typeof v === 'number')) return values as Array;\r\n return values.map(String) as Array;\r\n };\r\n\r\n const onTagRemove = (e: React.MouseEvent, option: ISelectOption): void => {\r\n e.stopPropagation();\r\n const newValue = removeOption(option);\r\n setSelectedValue(newValue);\r\n onChange(newValue ? getOptionValues(newValue) : []);\r\n };\r\n\r\n const onSearch = (e: React.ChangeEvent) => {\r\n setSearchValue(e.target.value);\r\n };\r\n\r\n const onItemClick = (option: ISelectOption): void => {\r\n let newValue: Array | ISelectOption | null;\r\n if (isMulti) {\r\n if (\r\n selectedValue &&\r\n Array.isArray(selectedValue) &&\r\n selectedValue.findIndex((o) => o.value === option.value) >= 0\r\n ) {\r\n newValue = removeOption(option);\r\n } else {\r\n newValue =\r\n selectedValue && Array.isArray(selectedValue) ? [...selectedValue, option] : [option];\r\n }\r\n setSelectedValue(newValue);\r\n onChange(newValue ? getOptionValues(newValue) : []);\r\n } else {\r\n newValue = option;\r\n setSelectedValue(newValue);\r\n onChange(newValue.value);\r\n setShowMenu(false);\r\n }\r\n if (isSearchable) {\r\n setSearchValue('');\r\n setTimeout(() => {\r\n if (searchRef.current) {\r\n searchRef.current.focus();\r\n }\r\n }, 0);\r\n }\r\n };\r\n\r\n const handleKeyDown = (e: React.KeyboardEvent) => {\r\n if (!showMenu) return;\r\n\r\n switch (e.key) {\r\n case 'ArrowDown':\r\n setHighlightedIndex((prevIndex) => {\r\n const newIndex = (prevIndex + 1) % (Array.isArray(optionList) ? optionList.length : 1);\r\n scrollToHighlightedItem(newIndex);\r\n return newIndex;\r\n });\r\n break;\r\n case 'ArrowUp':\r\n setHighlightedIndex((prevIndex) => {\r\n const newIndex =\r\n (prevIndex - 1 + (Array.isArray(optionList) ? optionList.length : 0)) %\r\n (Array.isArray(optionList) ? optionList.length : 1);\r\n scrollToHighlightedItem(newIndex);\r\n return newIndex;\r\n });\r\n break;\r\n case 'Enter':\r\n if (\r\n highlightedIndex >= 0 &&\r\n Array.isArray(optionList) &&\r\n highlightedIndex < optionList.length\r\n ) {\r\n onItemClick(optionList[highlightedIndex]);\r\n }\r\n break;\r\n case 'Escape':\r\n setShowMenu(false);\r\n break;\r\n default:\r\n break;\r\n }\r\n };\r\n\r\n const getDisplay = () => {\r\n if (!selectedValue || (Array.isArray(selectedValue) && selectedValue.length === 0)) {\r\n return (\r\n <>\r\n {!showMenu && (\r\n \r\n {placeholder}\r\n \r\n )}\r\n {isSearchable && showMenu && (\r\n
\r\n \r\n
\r\n )}\r\n \r\n );\r\n }\r\n if (isMulti) {\r\n return (\r\n
\r\n {Array.isArray(selectedValue) &&\r\n selectedValue.map((option, index) => (\r\n \r\n {option.content}\r\n {\r\n if (!disabled) onTagRemove(e, option);\r\n }}\r\n className={`TraSelect-dropdownTagCloseButton ${dropdownTagCloseButtonClassName} group-data-[disabled=true]:hover: hover:bg-neutral-light group-data-[disabled=false]:text-neutral-black\r\n ml-1.5 flex cursor-pointer items-center rounded-full p-0.5 group-data-[disabled=true]:cursor-not-allowed hover:group-data-[disabled=true]:bg-transparent`}\r\n >\r\n \r\n \r\n
\r\n ))}\r\n {isSearchable && showMenu && (\r\n
\r\n \r\n
\r\n )}\r\n \r\n );\r\n }\r\n return (\r\n
\r\n \r\n {(selectedValue as ISelectOption).content}\r\n \r\n {isSearchable && showMenu && (\r\n \r\n \r\n
\r\n )}\r\n \r\n );\r\n };\r\n const selectedItem = getDisplay();\r\n\r\n const isSelected = (option: ISelectOption): boolean => {\r\n if (isMulti) {\r\n if (!selectedValue || !Array.isArray(selectedValue)) return false;\r\n return (selectedValue as Array).some((o) => o.value === option.value);\r\n }\r\n return selectedValue ? (selectedValue as ISelectOption).value === option.value : false;\r\n };\r\n\r\n const getTextFromSelectedItem = (item: React.ReactElement): string => {\r\n if (item && item.props && (item.props as { children: React.ReactNode }).children) {\r\n const { children } = item.props as { children: React.ReactNode };\r\n if (Array.isArray(children)) {\r\n return (children as React.ReactNode[])\r\n .map((child: React.ReactNode) => {\r\n if (typeof child === 'string') {\r\n return child;\r\n }\r\n if (\r\n child !== null &&\r\n typeof child === 'object' &&\r\n 'props' in child &&\r\n ((child as React.ReactElement).props as { children: React.ReactNode })?.children\r\n ) {\r\n return getTextFromSelectedItem(child as React.ReactElement);\r\n }\r\n return '';\r\n })\r\n .join(' ');\r\n }\r\n if (typeof children === 'string') {\r\n return children;\r\n }\r\n }\r\n return '';\r\n };\r\n const textContent = getTextFromSelectedItem(selectedItem);\r\n\r\n return (\r\n
\r\n {label && (\r\n \r\n {label}\r\n \r\n )}\r\n \r\n \r\n \r\n \r\n {getDisplay()}\r\n
\r\n {hasSelection && !disabled && !hideClearButton && (\r\n {\r\n e.stopPropagation();\r\n if (hasSelection && !disabled) {\r\n if (isMulti) {\r\n setSelectedValue([]);\r\n onChange([]);\r\n } else {\r\n setSelectedValue(null);\r\n onChange('');\r\n }\r\n }\r\n }}\r\n >\r\n \r\n \r\n )}\r\n
\r\n \r\n
\r\n \r\n \r\n\r\n \r\n \r\n {Array.isArray(optionList) ? (\r\n optionList.map((option: ISelectOption, index) => (\r\n {\r\n if (!disabled) onItemClick(option);\r\n }}\r\n key={option.value as number}\r\n className={cn(\r\n 'TraSelect-dropdownItem text-neutral-black hover:bg-primary-5 flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-medium',\r\n dropdownItemClassName,\r\n isSelected(option) ? 'bg-primary-5 text-primary font-semibold' : '',\r\n highlightedIndex === index ? 'bg-primary-5' : '',\r\n )}\r\n >\r\n {option.content}\r\n {isMulti && (\r\n \r\n \r\n \r\n )}\r\n \r\n ))\r\n ) : (\r\n

{noOptionsMessage ?? 'No Options'}

\r\n )}\r\n {completeButton && (\r\n \r\n setShowMenu(false)}\r\n >\r\n {completeButtonText ?? 'Complete Selection'}\r\n \r\n \r\n )}\r\n \r\n \r\n \r\n \r\n );\r\n};\r\n\r\nexport default Select;\r\n", "type": "registry:ui" } ] }