{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "dropdown", "type": "registry:ui", "title": "Dropdown", "description": "Comprehensive dropdown component with search functionality, keyboard navigation, and accessibility features", "dependencies": [ "react", "." ], "registryDependencies": [], "files": [ { "path": "registry/react/ui/dropdown/dropdown.tsx", "content": "import React from \"react\";\nimport type { DropdownProps } from \"./types\";\nimport { useDropdown } from \"./useDropdown\";\n\nexport const Dropdown: React.FC = (props) => {\n const {\n options,\n placeholder,\n disabled,\n enableSearch = false,\n required,\n error,\n errorMessage,\n noOptionsMessage = \"No options available\",\n searchPlaceholder = \"Search...\",\n className = \"\",\n onChange,\n value,\n name,\n id,\n form,\n autoFocus,\n size,\n multiple,\n helperText,\n label,\n initialValue,\n ...restProps\n } = props;\n\n const propsWithDefaults = {\n ...props,\n options: props.options || options,\n initialValue: value as string,\n };\n\n const {\n state,\n handlers,\n refs,\n dropdownIds,\n filteredOptions,\n setSelectedValue,\n } = useDropdown(propsWithDefaults);\n\n React.useEffect(() => {\n if (onChange && state.selectedValue) {\n const syntheticEvent = {\n target: {\n name,\n value: state.selectedValue,\n },\n };\n // @ts-ignore - We're creating a simplified version of the event\n onChange(syntheticEvent);\n }\n }, [state.selectedValue, onChange, name]);\n\n React.useEffect(() => {\n if (autoFocus) {\n const button = document.getElementById(dropdownIds.dropdownTriggerId);\n if (button) {\n button.focus();\n }\n }\n }, [autoFocus, dropdownIds.dropdownTriggerId]);\n\n const { isOpen, selectedValue, activeDescendant } = state;\n const { toggleDropdown, handleOptionClick, handleSearchChange } = handlers;\n const { dropdownRef, searchInputRef } = refs;\n const {\n dropdownTriggerId,\n dropdownLabelId,\n dropdownListId,\n dropdownSearchId,\n } = dropdownIds;\n\n return (\n \n {/* Hidden input to store the value for form submission */}\n \n
\n {enableSearch ? (\n \n \n
\n \n \n \n \n {selectedValue || placeholder}\n \n \n \n \n
\n \n
\n ) : (\n \n \n
\n \n \n \n \n {selectedValue || placeholder}\n \n \n \n \n
\n \n \n )}\n e.stopPropagation()}\n >\n {enableSearch && (\n
\n
\n \n \n \n e.stopPropagation()}\n />\n
\n
\n )}\n \n {filteredOptions.length > 0 ? (\n filteredOptions.map((option, index) => (\n handleOptionClick(option, e)}\n tabIndex={-1}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n handleOptionClick(\n option,\n e as unknown as React.MouseEvent\n );\n }\n }}\n >\n {option}\n \n ))\n ) : (\n \n
\n {noOptionsMessage}\n
\n \n )}\n \n \n \n \n );\n};\n\nexport default Dropdown;\n", "type": "registry:ui", "target": "react/ui/dropdown/dropdown.tsx" }, { "path": "registry/react/ui/dropdown/focusManagement.ts", "content": "import type { DropdownIds } from \"./types\";\n\nexport const clearFocusIndicators = (): void => {\n document.querySelectorAll(\".option-focused\").forEach((el) => {\n el.classList.remove(\"option-focused\");\n });\n};\n\nexport const focusElementById = (\n id: string,\n addFocusClass: boolean = false\n): boolean => {\n const element = document.getElementById(id);\n if (element) {\n if (addFocusClass) {\n element.classList.add(\"option-focused\");\n }\n element.setAttribute(\"tabindex\", \"0\");\n element.focus();\n return true;\n }\n return false;\n};\n\nexport const focusSearchInput = (\n searchInputRef: React.RefObject\n): boolean => {\n if (searchInputRef.current) {\n searchInputRef.current.focus();\n return true;\n }\n return false;\n};\n\nexport const setInitialFocus = ({\n isOpen,\n wasJustOpened,\n openedByKeyboard,\n enableSearch,\n searchInputRef,\n selectedValue,\n filteredOptions,\n dropdownIds,\n setActiveDescendant,\n}: {\n isOpen: boolean;\n wasJustOpened: boolean;\n openedByKeyboard: boolean;\n enableSearch: boolean;\n searchInputRef: React.RefObject;\n selectedValue: string | null;\n filteredOptions: string[];\n dropdownIds: DropdownIds;\n setActiveDescendant: (activeDescendant: string | undefined) => void;\n}): void => {\n if (!isOpen || wasJustOpened) return;\n\n clearFocusIndicators();\n\n let targetOptionId: string | null = null;\n\n if (filteredOptions.length > 0) {\n if (selectedValue) {\n const selectedIndex = filteredOptions.findIndex(\n (option) => option === selectedValue\n );\n if (selectedIndex >= 0) {\n targetOptionId = `${dropdownIds.dropdownListId}-option-${\n selectedIndex + 1\n }`;\n }\n }\n\n if (!targetOptionId) {\n targetOptionId = `${dropdownIds.dropdownListId}-option-1`;\n }\n\n setActiveDescendant(targetOptionId);\n } else {\n setActiveDescendant(undefined);\n }\n\n if (openedByKeyboard && enableSearch && searchInputRef.current) {\n setTimeout(() => {\n focusSearchInput(searchInputRef);\n }, 0);\n } else if (filteredOptions.length > 0 && targetOptionId) {\n setTimeout(() => {\n if (!(openedByKeyboard && enableSearch)) {\n focusElementById(targetOptionId, true);\n }\n }, 0);\n } else {\n setTimeout(() => {\n focusElementById(dropdownIds.dropdownTriggerId);\n }, 0);\n }\n};\n\nexport const scrollOptionIntoView = (optionId: string): void => {\n const option = document.getElementById(optionId);\n if (option) {\n option.scrollIntoView({ block: \"nearest\" });\n }\n};\n\nexport const addFocusStyles = (): void => {\n if (!document.getElementById(\"dropdown-focus-styles\")) {\n const style = document.createElement(\"style\");\n style.id = \"dropdown-focus-styles\";\n style.innerHTML = `\n .option-focused {\n outline-offset: -2px;\n border-radius: var(--radius-site);\n }\n `;\n document.head.appendChild(style);\n }\n};\n\nexport const removeFocusStyles = (): void => {\n const styleElement = document.getElementById(\"dropdown-focus-styles\");\n if (styleElement) {\n styleElement.remove();\n }\n};\n", "type": "registry:ui", "target": "react/ui/dropdown/focusManagement.ts" }, { "path": "registry/react/ui/dropdown/keyboardNavigation.ts", "content": "import type { DropdownIds } from \"./types\";\nimport { getActiveOptionIndex, getOptionId } from \"./utils\";\nimport { clearFocusIndicators, scrollOptionIntoView } from \"./focusManagement\";\n\ninterface KeyboardNavigationParams {\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n setOpenedByKeyboard: (openedByKeyboard: boolean) => void;\n activeDescendant: string | undefined;\n setActiveDescendant: (activeDescendant: string | undefined) => void;\n filteredOptions: string[];\n selectedValue: string | null;\n setSelectedValue: (selectedValue: string | null) => void;\n searchValue: string;\n setSearchValue: (searchValue: string) => void;\n enableSearch: boolean;\n searchInputRef: React.RefObject;\n dropdownIds: DropdownIds;\n}\n\nexport const handleKeyboardNavigation = (\n e: React.KeyboardEvent,\n params: KeyboardNavigationParams\n): boolean => {\n const {\n isOpen,\n setIsOpen,\n setOpenedByKeyboard,\n activeDescendant,\n setActiveDescendant,\n filteredOptions,\n selectedValue,\n setSelectedValue,\n searchValue,\n setSearchValue,\n enableSearch,\n searchInputRef,\n dropdownIds,\n } = params;\n\n if (!isOpen) {\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n setOpenedByKeyboard(true);\n setIsOpen(true);\n return true;\n }\n return false;\n }\n\n const optionCount = filteredOptions.length;\n if (optionCount === 0) return false;\n\n // Find the index of the currently active option\n const currentIndex = getActiveOptionIndex(activeDescendant);\n\n // Check if we're in the search input\n const isInSearchInput =\n enableSearch && document.activeElement === searchInputRef.current;\n\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n\n clearFocusIndicators();\n\n if (isInSearchInput || currentIndex === -1) {\n const newId = getOptionId(dropdownIds.dropdownListId, 0);\n setActiveDescendant(newId);\n\n const firstOption = document.getElementById(newId);\n if (firstOption) {\n firstOption.classList.add(\"option-focused\");\n scrollOptionIntoView(newId);\n firstOption.setAttribute(\"tabindex\", \"0\");\n firstOption.focus();\n }\n } else if (currentIndex < optionCount - 1) {\n const newIndex = currentIndex + 1;\n const newId = getOptionId(dropdownIds.dropdownListId, newIndex);\n setActiveDescendant(newId);\n\n const nextOption = document.getElementById(newId);\n if (nextOption) {\n nextOption.classList.add(\"option-focused\");\n scrollOptionIntoView(newId);\n nextOption.setAttribute(\"tabindex\", \"0\");\n nextOption.focus();\n }\n } else if (currentIndex === optionCount - 1) {\n const currentId = getOptionId(dropdownIds.dropdownListId, currentIndex);\n const currentOption = document.getElementById(currentId);\n if (currentOption) {\n currentOption.classList.add(\"option-focused\");\n currentOption.focus();\n }\n }\n return true;\n\n case \"ArrowUp\":\n e.preventDefault();\n\n clearFocusIndicators();\n\n if (currentIndex === -1) {\n // If no item is currently focused, focus the first option instead of the last\n const newId = getOptionId(dropdownIds.dropdownListId, 0);\n setActiveDescendant(newId);\n\n const firstOption = document.getElementById(newId);\n if (firstOption) {\n firstOption.classList.add(\"option-focused\");\n scrollOptionIntoView(newId);\n firstOption.setAttribute(\"tabindex\", \"0\");\n firstOption.focus();\n }\n } else if (currentIndex === 0 && enableSearch) {\n setActiveDescendant(undefined);\n if (searchInputRef.current) {\n searchInputRef.current.focus();\n }\n } else if (currentIndex === 0 && !enableSearch) {\n // If we're on the first item and search is not enabled, stay on the first item\n // This prevents looping from first to last\n const currentId = getOptionId(dropdownIds.dropdownListId, currentIndex);\n const currentOption = document.getElementById(currentId);\n if (currentOption) {\n currentOption.classList.add(\"option-focused\");\n currentOption.focus();\n }\n } else if (currentIndex > 0) {\n const newIndex = currentIndex - 1;\n const newId = getOptionId(dropdownIds.dropdownListId, newIndex);\n setActiveDescendant(newId);\n\n const prevOption = document.getElementById(newId);\n if (prevOption) {\n prevOption.classList.add(\"option-focused\");\n scrollOptionIntoView(newId);\n prevOption.setAttribute(\"tabindex\", \"0\");\n prevOption.focus();\n }\n }\n return true;\n\n case \"Home\":\n e.preventDefault();\n\n clearFocusIndicators();\n\n if (enableSearch) {\n setActiveDescendant(undefined);\n if (searchInputRef.current) {\n searchInputRef.current.focus();\n }\n } else {\n const newId = getOptionId(dropdownIds.dropdownListId, 0);\n setActiveDescendant(newId);\n\n const firstOption = document.getElementById(newId);\n if (firstOption) {\n firstOption.classList.add(\"option-focused\");\n scrollOptionIntoView(newId);\n firstOption.setAttribute(\"tabindex\", \"0\");\n firstOption.focus();\n }\n }\n return true;\n\n case \"End\":\n e.preventDefault();\n\n clearFocusIndicators();\n\n const newId = getOptionId(dropdownIds.dropdownListId, optionCount - 1);\n setActiveDescendant(newId);\n\n const lastOption = document.getElementById(newId);\n if (lastOption) {\n lastOption.classList.add(\"option-focused\");\n scrollOptionIntoView(newId);\n lastOption.setAttribute(\"tabindex\", \"0\");\n lastOption.focus();\n }\n return true;\n\n case \"Enter\":\n case \" \":\n if (isInSearchInput) {\n if (e.key === \" \") {\n return false;\n }\n e.preventDefault();\n return true;\n }\n\n e.preventDefault();\n\n if (activeDescendant && currentIndex >= 0) {\n setSelectedValue(filteredOptions[currentIndex]);\n setIsOpen(false);\n setSearchValue(\"\");\n const button = document.getElementById(dropdownIds.dropdownTriggerId);\n if (button) {\n setTimeout(() => button.focus(), 0);\n }\n }\n return true;\n\n case \"Escape\":\n e.preventDefault();\n setIsOpen(false);\n setSearchValue(\"\");\n const button = document.getElementById(dropdownIds.dropdownTriggerId);\n if (button) {\n setTimeout(() => button.focus(), 0);\n }\n return true;\n\n case \"Tab\":\n setIsOpen(false);\n setSearchValue(\"\");\n return false;\n\n default:\n if (enableSearch && e.key.length === 1 && !isInSearchInput) {\n if (searchInputRef.current) {\n searchInputRef.current.focus();\n }\n }\n return false;\n }\n};\n", "type": "registry:ui", "target": "react/ui/dropdown/keyboardNavigation.ts" }, { "path": "registry/react/ui/dropdown/types.ts", "content": "import type { SelectHTMLAttributes } from \"react\";\n\nexport interface DropdownProps extends SelectHTMLAttributes {\n label?: string;\n options?: string[];\n placeholder?: string;\n error?: boolean;\n errorMessage?: string;\n enableSearch?: boolean;\n noOptionsMessage?: string;\n searchPlaceholder?: string;\n helperText?: string;\n className?: string;\n initialValue?: string;\n}\n\nexport interface DropdownState {\n isOpen: boolean;\n searchValue: string;\n selectedValue: string | null;\n activeDescendant: string | undefined;\n wasJustOpened: boolean;\n openedByKeyboard: boolean;\n}\n\nexport interface DropdownHandlers {\n toggleDropdown: (e: React.MouseEvent) => boolean;\n handleOptionClick: (option: string, e: React.MouseEvent) => void;\n handleSearchChange: (e: React.ChangeEvent) => void;\n handleKeyDown: (e: React.KeyboardEvent) => void;\n setOnClickOutside: (callback: (wasOpen: boolean) => void) => void;\n}\n\nexport interface DropdownIds {\n dropdownTriggerId: string;\n dropdownLabelId: string;\n dropdownListId: string;\n dropdownSearchId: string;\n dropdownHelperId: string;\n dropdownErrorId: string;\n}\n", "type": "registry:ui", "target": "react/ui/dropdown/types.ts" }, { "path": "registry/react/ui/dropdown/useDropdown.ts", "content": "import { useState, useRef, useEffect, useId, useCallback } from \"react\";\nimport type {\n DropdownProps,\n DropdownState,\n DropdownHandlers,\n DropdownIds,\n} from \"./types\";\nimport { filterOptions, generateDropdownIds } from \"./utils\";\nimport {\n addFocusStyles,\n removeFocusStyles,\n setInitialFocus,\n} from \"./focusManagement\";\nimport { handleKeyboardNavigation } from \"./keyboardNavigation\";\n\ninterface UseDropdownReturn {\n state: DropdownState;\n handlers: DropdownHandlers;\n refs: {\n dropdownRef: React.RefObject;\n searchInputRef: React.RefObject;\n };\n dropdownIds: DropdownIds;\n filteredOptions: string[];\n setSelectedValue: (value: string | null) => void;\n}\n\nexport const useDropdown = (props: DropdownProps): UseDropdownReturn => {\n const {\n options,\n disabled = false,\n enableSearch = true,\n id: externalId,\n initialValue,\n } = props;\n\n // Ensure options is always an array\n const optionsArray = options || [];\n\n const [isOpen, setIsOpen] = useState(false);\n const [searchValue, setSearchValue] = useState(\"\");\n const [selectedValue, setSelectedValue] = useState(\n initialValue || null\n );\n const [activeDescendant, setActiveDescendant] = useState(\n undefined\n );\n const [wasJustOpened, setWasJustOpened] = useState(false);\n const [openedByKeyboard, setOpenedByKeyboard] = useState(false);\n\n const dropdownRef = useRef(null);\n const searchInputRef = useRef(null);\n\n const instanceId = useId();\n const uniqueId = externalId || instanceId;\n const dropdownIds = generateDropdownIds(uniqueId);\n\n const filteredOptions = filterOptions(optionsArray, searchValue);\n\n useEffect(() => {\n if (initialValue !== undefined && initialValue !== selectedValue) {\n setSelectedValue(initialValue);\n }\n }, [initialValue, selectedValue]);\n\n useEffect(() => {\n addFocusStyles();\n return () => {\n removeFocusStyles();\n };\n }, []);\n\n useEffect(() => {\n setInitialFocus({\n isOpen,\n wasJustOpened,\n openedByKeyboard,\n enableSearch,\n searchInputRef,\n selectedValue,\n filteredOptions,\n dropdownIds,\n setActiveDescendant,\n });\n\n if (!isOpen) {\n setWasJustOpened(false);\n setActiveDescendant(undefined);\n } else if (isOpen && !wasJustOpened) {\n setWasJustOpened(true);\n }\n }, [\n isOpen,\n wasJustOpened,\n openedByKeyboard,\n filteredOptions,\n selectedValue,\n enableSearch,\n dropdownIds,\n ]);\n\n const onClickOutsideRef = useRef<((wasOpen: boolean) => void) | null>(null);\n\n const setOnClickOutside = useCallback(\n (callback: (wasOpen: boolean) => void) => {\n onClickOutsideRef.current = callback;\n },\n []\n );\n\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (\n dropdownRef.current &&\n !dropdownRef.current.contains(event.target as Node) &&\n isOpen\n ) {\n const wasOpen = isOpen;\n setIsOpen(false);\n\n if (onClickOutsideRef.current) {\n onClickOutsideRef.current(wasOpen);\n }\n }\n };\n\n document.addEventListener(\"mousedown\", handleClickOutside);\n\n return () => {\n document.removeEventListener(\"mousedown\", handleClickOutside);\n };\n }, [isOpen]);\n\n const isClosingDropdown = (currentIsOpen: boolean, newIsOpen: boolean) => {\n return currentIsOpen && !newIsOpen;\n };\n\n // Event handlers\n const toggleDropdown = (e: React.MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n\n if (!disabled) {\n setOpenedByKeyboard(false);\n const newIsOpen = !isOpen;\n setIsOpen(newIsOpen);\n\n return isClosingDropdown(isOpen, newIsOpen);\n }\n\n return false;\n };\n\n const handleOptionClick = (option: string, e: React.MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n\n setSelectedValue(option);\n setIsOpen(false);\n setSearchValue(\"\");\n };\n\n const handleSearchChange = (e: React.ChangeEvent) => {\n setSearchValue(e.target.value);\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n handleKeyboardNavigation(e, {\n isOpen,\n setIsOpen,\n setOpenedByKeyboard,\n activeDescendant,\n setActiveDescendant,\n filteredOptions,\n selectedValue,\n setSelectedValue,\n searchValue,\n setSearchValue,\n enableSearch,\n searchInputRef,\n dropdownIds,\n });\n };\n\n useEffect(() => {\n const container = dropdownRef.current;\n if (!container) return;\n\n const handleKeyDownEvent = (e: KeyboardEvent) => {\n handleKeyDown(e as unknown as React.KeyboardEvent);\n };\n\n container.addEventListener(\"keydown\", handleKeyDownEvent);\n\n return () => {\n container.removeEventListener(\"keydown\", handleKeyDownEvent);\n };\n }, [\n isOpen,\n enableSearch,\n activeDescendant,\n filteredOptions.length,\n dropdownIds.dropdownTriggerId,\n ]);\n\n const setSelectedValueCallback = useCallback((value: string | null) => {\n setSelectedValue(value);\n }, []);\n\n const state: DropdownState = {\n isOpen,\n searchValue,\n selectedValue,\n activeDescendant,\n wasJustOpened,\n openedByKeyboard,\n };\n\n const handlers: DropdownHandlers = {\n toggleDropdown,\n handleOptionClick,\n handleSearchChange,\n handleKeyDown,\n setOnClickOutside,\n };\n\n return {\n state,\n handlers,\n refs: {\n dropdownRef,\n searchInputRef,\n },\n dropdownIds,\n filteredOptions,\n setSelectedValue: setSelectedValueCallback,\n };\n};\n", "type": "registry:ui", "target": "react/ui/dropdown/useDropdown.ts" }, { "path": "registry/react/ui/dropdown/utils.ts", "content": "export const filterOptions = (\n options: string[],\n searchValue: string\n): string[] => {\n return options.filter((option) =>\n option.toLowerCase().includes(searchValue.toLowerCase())\n );\n};\n\nexport const generateDropdownIds = (uniqueId: string) => {\n return {\n dropdownTriggerId: `dropdown-trigger-${uniqueId}`,\n dropdownLabelId: `dropdown-label-${uniqueId}`,\n dropdownListId: `dropdown-list-${uniqueId}`,\n dropdownSearchId: `dropdown-search-${uniqueId}`,\n dropdownHelperId: `dropdown-helper-${uniqueId}`,\n dropdownErrorId: `dropdown-error-${uniqueId}`,\n };\n};\n\nexport const getActiveOptionIndex = (\n activeDescendant: string | undefined\n): number => {\n if (!activeDescendant) return -1;\n\n const match = activeDescendant.match(/-(\\d+)$/);\n if (match) {\n return parseInt(match[1], 10) - 1;\n }\n\n return -1;\n};\n\nexport const getOptionId = (dropdownListId: string, index: number): string => {\n return `${dropdownListId}-option-${index + 1}`;\n};\n", "type": "registry:ui", "target": "react/ui/dropdown/utils.ts" } ] }