chat / packages /client /src /components /InputWithDropDown.tsx
helloya20's picture
Upload 2345 files
f0743f4 verified
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;