| import * as React from 'react'; | |
| import { Input } from './Input'; | |
| import { cn } from '~/utils'; | |
| export type InputWithDropdownProps = React.InputHTMLAttributes<HTMLInputElement> & { | |
| options: string[]; | |
| onSelect?: (value: string) => void; | |
| }; | |
| const InputWithDropdown = React.forwardRef<HTMLInputElement, InputWithDropdownProps>( | |
| ({ className, options, onSelect, ...props }, ref) => { | |
| const [isOpen, setIsOpen] = React.useState(false); | |
| const [inputValue, setInputValue] = React.useState((props.value as string) || ''); | |
| const [highlightedIndex, setHighlightedIndex] = React.useState(-1); | |
| const inputRef = React.useRef<HTMLInputElement>(null); | |
| const handleSelect = (value: string) => { | |
| setInputValue(value); | |
| setIsOpen(false); | |
| setHighlightedIndex(-1); | |
| if (onSelect) { | |
| onSelect(value); | |
| } | |
| if (props.onChange) { | |
| props.onChange({ target: { value } } as React.ChangeEvent<HTMLInputElement>); | |
| } | |
| }; | |
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| setInputValue(e.target.value); | |
| if (props.onChange) { | |
| props.onChange(e); | |
| } | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |
| switch (e.key) { | |
| case 'ArrowDown': | |
| e.preventDefault(); | |
| if (!isOpen) { | |
| setIsOpen(true); | |
| } else { | |
| setHighlightedIndex((prevIndex) => | |
| prevIndex < options.length - 1 ? prevIndex + 1 : prevIndex, | |
| ); | |
| } | |
| break; | |
| case 'ArrowUp': | |
| e.preventDefault(); | |
| setHighlightedIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : 0)); | |
| break; | |
| case 'Enter': | |
| e.preventDefault(); | |
| if (isOpen && highlightedIndex !== -1) { | |
| handleSelect(options[highlightedIndex]); | |
| } | |
| setIsOpen(false); | |
| break; | |
| case 'Escape': | |
| setIsOpen(false); | |
| setHighlightedIndex(-1); | |
| break; | |
| } | |
| }; | |
| React.useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (inputRef.current && !inputRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| setHighlightedIndex(-1); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, []); | |
| return ( | |
| <div className="relative" ref={inputRef}> | |
| <div className="relative"> | |
| <Input | |
| {...props} | |
| value={inputValue} | |
| onChange={handleInputChange} | |
| onKeyDown={handleKeyDown} | |
| aria-haspopup="listbox" | |
| aria-controls="dropdown-list" | |
| className={cn('bg-surface-secondary', className ?? '')} | |
| ref={ref} | |
| /> | |
| <button | |
| type="button" | |
| className="text-tertiary absolute inset-y-0 right-0 flex items-center rounded-md px-2 hover:text-secondary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring-primary" | |
| onClick={() => setIsOpen(!isOpen)} | |
| aria-label={isOpen ? 'Close dropdown' : 'Open dropdown'} | |
| > | |
| <svg | |
| className="h-5 w-5" | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| xmlns="http://www.w3.org/2000/svg" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth={2} | |
| d="M19 9l-7 7-7-7" | |
| /> | |
| </svg> | |
| </button> | |
| </div> | |
| {isOpen && ( | |
| <ul | |
| id="dropdown-list" | |
| role="listbox" | |
| className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border-medium bg-surface-secondary shadow-lg focus:ring-1 focus:ring-inset focus:ring-ring-primary" | |
| > | |
| {options.map((option, index) => ( | |
| <li | |
| key={index} | |
| role="option" | |
| aria-selected={index === highlightedIndex} | |
| className={cn( | |
| 'cursor-pointer rounded-md px-3 py-2', | |
| 'focus:bg-surface-tertiary focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring-primary', | |
| index === highlightedIndex | |
| ? 'bg-surface-active text-primary' | |
| : 'text-secondary hover:bg-surface-tertiary', | |
| )} | |
| onClick={() => handleSelect(option)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| handleSelect(option); | |
| } | |
| }} | |
| tabIndex={0} | |
| > | |
| {option} | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| </div> | |
| ); | |
| }, | |
| ); | |
| InputWithDropdown.displayName = 'InputWithDropdown'; | |
| export default InputWithDropdown; | |