mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
191 lines
5.5 KiB
TypeScript
191 lines
5.5 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
||
import { clsx } from 'clsx';
|
||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||
import Input from './Input';
|
||
import Button from './Button';
|
||
|
||
export interface ComboboxOption {
|
||
value: string;
|
||
label: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
export interface ComboboxProps {
|
||
options: ComboboxOption[];
|
||
value?: string;
|
||
onChange?: (value: string) => void;
|
||
onSearch?: (searchTerm: string) => void;
|
||
placeholder?: string;
|
||
searchPlaceholder?: string;
|
||
className?: string;
|
||
disabled?: boolean;
|
||
allowClear?: boolean;
|
||
filterOptions?: (options: ComboboxOption[], searchTerm: string) => ComboboxOption[];
|
||
}
|
||
|
||
/**
|
||
* Combobox/Autocomplete component
|
||
*/
|
||
export const Combobox = ({
|
||
options,
|
||
value,
|
||
onChange,
|
||
onSearch,
|
||
placeholder = 'Select...',
|
||
searchPlaceholder = 'Search...',
|
||
className,
|
||
disabled,
|
||
allowClear = false,
|
||
filterOptions,
|
||
}: ComboboxProps) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const comboboxRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const defaultFilter = (opts: ComboboxOption[], term: string) => {
|
||
if (!term) return opts;
|
||
const lowerTerm = term.toLowerCase();
|
||
return opts.filter(
|
||
(opt) =>
|
||
opt.label.toLowerCase().includes(lowerTerm) ||
|
||
opt.value.toLowerCase().includes(lowerTerm)
|
||
);
|
||
};
|
||
|
||
const filter = filterOptions || defaultFilter;
|
||
const filteredOptions = filter(options, searchTerm);
|
||
|
||
const selectedOption = options.find((opt) => opt.value === value);
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (
|
||
comboboxRef.current &&
|
||
!comboboxRef.current.contains(event.target as Node)
|
||
) {
|
||
setIsOpen(false);
|
||
setSearchTerm('');
|
||
}
|
||
};
|
||
|
||
const handleEscape = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape' && isOpen) {
|
||
setIsOpen(false);
|
||
setSearchTerm('');
|
||
}
|
||
};
|
||
|
||
if (isOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
document.addEventListener('keydown', handleEscape);
|
||
inputRef.current?.focus();
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
document.removeEventListener('keydown', handleEscape);
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const handleSelect = (optionValue: string) => {
|
||
onChange?.(optionValue);
|
||
setIsOpen(false);
|
||
setSearchTerm('');
|
||
};
|
||
|
||
const handleSearchChange = (term: string) => {
|
||
setSearchTerm(term);
|
||
onSearch?.(term);
|
||
};
|
||
|
||
const handleClear = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
onChange?.('');
|
||
setSearchTerm('');
|
||
};
|
||
|
||
return (
|
||
<div ref={comboboxRef} className={clsx('relative', className)}>
|
||
<div className="relative">
|
||
<Input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={isOpen ? searchTerm : selectedOption?.label || ''}
|
||
onChange={(e) => {
|
||
if (!isOpen) setIsOpen(true);
|
||
handleSearchChange(e.target.value);
|
||
}}
|
||
onFocus={() => setIsOpen(true)}
|
||
placeholder={placeholder}
|
||
disabled={disabled}
|
||
className="pr-20"
|
||
/>
|
||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||
{allowClear && value && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleClear}
|
||
className="h-6 w-6 p-0"
|
||
aria-label="Clear"
|
||
>
|
||
×
|
||
</Button>
|
||
)}
|
||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground pointer-events-none" />
|
||
</div>
|
||
</div>
|
||
|
||
{isOpen && (
|
||
<div
|
||
className={clsx(
|
||
'absolute z-50 w-full mt-1',
|
||
'bg-background border rounded-lg shadow-lg',
|
||
'max-h-60 overflow-auto',
|
||
'animate-in fade-in-0 zoom-in-95 duration-200'
|
||
)}
|
||
>
|
||
{filteredOptions.length === 0 ? (
|
||
<div className="p-4 text-sm text-muted-foreground text-center">
|
||
No options found
|
||
</div>
|
||
) : (
|
||
<ul className="p-1" role="listbox">
|
||
{filteredOptions.map((option) => (
|
||
<li key={option.value}>
|
||
<button
|
||
type="button"
|
||
onClick={() => !option.disabled && handleSelect(option.value)}
|
||
disabled={option.disabled}
|
||
className={clsx(
|
||
'w-full flex items-center gap-2 px-3 py-2 text-sm text-left',
|
||
'rounded-md hover:bg-muted focus:bg-muted',
|
||
'focus:outline-none transition-colors',
|
||
{
|
||
'opacity-50 cursor-not-allowed': option.disabled,
|
||
'bg-primary/10': value === option.value,
|
||
}
|
||
)}
|
||
role="option"
|
||
aria-selected={value === option.value}
|
||
>
|
||
{value === option.value && (
|
||
<Check className="h-4 w-4 text-primary flex-shrink-0" />
|
||
)}
|
||
<span className={clsx({ 'font-medium': value === option.value })}>
|
||
{option.label}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|