import React, { forwardRef, useEffect, useRef, useState } from 'react'; // @ts-ignore - web-specific import import { getWebProps } from 'react-native-unistyles/web'; import { IconSvg } from '../Icon/IconSvg/IconSvg.web'; import { IconRegistry } from '../Icon/IconRegistry'; import useMergeRefs from '../hooks/useMergeRefs'; import { PositionedPortal } from '../internal/PositionedPortal'; import { selectStyles } from './Select.styles'; import { SelectOption, SelectProps } from './types'; import type { IdealystElement } from '../utils/refTypes'; import { mdiChevronDown } from '@mdi/js'; // Self-register icons used internally by Select IconRegistry.registerMany({ 'chevron-down': mdiChevronDown, }); /** * Dropdown selection component with search, keyboard navigation, and custom option rendering. * Supports single selection with filterable options list. */ const Select = forwardRef(({ options, value, onChange, placeholder = 'Select an option', disabled = false, error, helperText, label, type = 'outlined', intent = 'neutral', size = 'md', searchable = false, filterOption, maxHeight = 240, // Spacing variants from FormInputStyleProps margin, marginVertical, marginHorizontal, style, testID, accessibilityLabel, id, }, ref) => { // Derive hasError boolean from error prop const hasError = Boolean(error); // Get error message if error is a string const errorMessage = typeof error === 'string' ? error : undefined; const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [focusedIndex, setFocusedIndex] = useState(-1); const triggerRef = useRef(null); const searchInputRef = useRef(null); const selectedOption = options.find(option => option.value === value); // Filter options based on search term const filteredOptions = searchable && searchTerm ? options.filter(option => { if (filterOption) { return filterOption(option, searchTerm); } return option.label.toLowerCase().includes(searchTerm.toLowerCase()); }) : options; // Get the index of the currently selected option const getSelectedIndex = () => { if (!value) return 0; const index = filteredOptions.findIndex(option => option.value === value); return index >= 0 ? index : 0; }; // Apply styles with variants selectStyles.useVariants({ type, size, disabled, hasError, focused: isOpen, margin, marginVertical, marginHorizontal, }); // Handle keyboard navigation on the trigger button const handleKeyDown = (event: React.KeyboardEvent) => { // Handle opening with arrow keys when closed if (!isOpen) { if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setIsOpen(true); setFocusedIndex(getSelectedIndex()); } return; } switch (event.key) { case 'ArrowDown': event.preventDefault(); setFocusedIndex(prev => prev < filteredOptions.length - 1 ? prev + 1 : 0 ); break; case 'ArrowUp': event.preventDefault(); setFocusedIndex(prev => prev > 0 ? prev - 1 : filteredOptions.length - 1 ); break; case 'Tab': if (event.shiftKey) { // Shift+Tab: go to previous option or exit if (focusedIndex <= 0) { setIsOpen(false); } else { event.preventDefault(); setFocusedIndex(prev => prev - 1); } } else { // Tab: go to next option or exit if (focusedIndex >= filteredOptions.length - 1) { setIsOpen(false); } else { event.preventDefault(); setFocusedIndex(prev => prev < 0 ? 0 : prev + 1); } } break; case 'Enter': event.preventDefault(); if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) { const option = filteredOptions[focusedIndex]; if (!option.disabled) { handleOptionSelect(option); } } break; case ' ': event.preventDefault(); if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) { const option = filteredOptions[focusedIndex]; if (!option.disabled) { handleOptionSelect(option); } } break; case 'Escape': event.preventDefault(); setIsOpen(false); break; } }; // Focus search input when dropdown opens useEffect(() => { if (isOpen && searchable && searchInputRef.current) { setTimeout(() => { searchInputRef.current?.focus(); }, 50); } }, [isOpen, searchable]); const handleTriggerClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); }; const handleTriggerFocus = () => { if (!disabled && !isOpen) { setIsOpen(true); setSearchTerm(''); // Focus on selected option, or first option if none selected setFocusedIndex(getSelectedIndex()); } }; const handleOptionSelect = (option: SelectOption) => { if (!option.disabled) { onChange?.(option.value); setIsOpen(false); setSearchTerm(''); triggerRef.current?.focus(); } }; const handleSearchChange = (e: React.ChangeEvent) => { setSearchTerm(e.target.value); setFocusedIndex(0); }; // Get dynamic styles - call as functions for theme reactivity const containerStyle = (selectStyles.container as any)({}); const labelStyle = (selectStyles.label as any)({}); const triggerStyle = (selectStyles.trigger as any)({ type, intent, disabled, hasError, focused: isOpen }); const triggerContentStyle = (selectStyles.triggerContent as any)({}); const triggerTextStyle = (selectStyles.triggerText as any)({}); const placeholderStyle = (selectStyles.placeholder as any)({}); const iconStyle = (selectStyles.icon as any)({}); const chevronStyle = (selectStyles.chevron as any)({}); const chevronOpenStyle = (selectStyles.chevronOpen as any)({}); const dropdownStyle = (selectStyles.dropdown as any)({}); const searchContainerStyle = (selectStyles.searchContainer as any)({}); const searchInputStyle = (selectStyles.searchInput as any)({}); const optionsListStyle = (selectStyles.optionsList as any)({}); const optionStyle = (selectStyles.option as any)({}); const optionFocusedStyle = (selectStyles.optionFocused as any)({}); const optionDisabledStyle = (selectStyles.optionDisabled as any)({}); const optionContentStyle = (selectStyles.optionContent as any)({}); const optionIconStyle = (selectStyles.optionIcon as any)({}); const optionTextStyle = (selectStyles.optionText as any)({}); const optionTextDisabledStyle = (selectStyles.optionTextDisabled as any)({}); const helperTextStyle = (selectStyles.helperText as any)({ hasError }); const containerWebProps = getWebProps([containerStyle, style as any]); const triggerWebProps = getWebProps([triggerStyle]); const handleClose = () => { setIsOpen(false); triggerRef.current?.focus(); }; const mergedRef = useMergeRefs(ref, containerWebProps.ref); return (
{label && ( )}
} placement="bottom-start" offset={4} onClickOutside={handleClose} onEscapeKey={handleClose} matchWidth={false} zIndex={1000} >
{searchable && (
)}
{filteredOptions.map((option, index) => { const isFocused = index === focusedIndex; return (
handleOptionSelect(option)} role="option" aria-selected={option.value === value} onMouseEnter={() => setFocusedIndex(index)} {...getWebProps([ optionStyle, isFocused && optionFocusedStyle, option.disabled && optionDisabledStyle, ])} >
{option.icon && ( {option.icon} )} {option.label}
); })} {filteredOptions.length === 0 && (
No options found
)}
{(errorMessage || helperText) && (
{errorMessage || helperText}
)}
); }); Select.displayName = 'Select'; export default Select;