| 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; |
|
|