mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-07 19:18:52 +01:00
155 lines
5 KiB
TypeScript
155 lines
5 KiB
TypeScript
|
|
import * as React from 'react';
|
||
|
|
import { Input } from '~/components/ui/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 hover:text-secondary absolute inset-y-0 right-0 flex items-center rounded-md px-2 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
|
||
|
|
? 'text-primary bg-surface-active'
|
||
|
|
: '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;
|