turash/bugulma/frontend/components/ui/Combobox.tsx
Damir Mukimov 7310b98664
fix: continue linting fixes - fix i18n strings in UI components
- Fix i18n literal strings in Paywall, Combobox, Dialog, ResourceFlowCard
- Add translation hooks where needed
- Continue systematic reduction of linting errors (down to 248)
2025-12-25 00:38:40 +01:00

183 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { useTranslation } from '@/hooks/useI18n';
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;
className?: string;
disabled?: boolean;
allowClear?: boolean;
filterOptions?: (options: ComboboxOption[], searchTerm: string) => ComboboxOption[];
}
/**
* Combobox/Autocomplete component
*/
export const Combobox = ({
options,
value,
onChange,
onSearch,
placeholder = 'Select...',
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">{t('ui.noOptionsFound')}</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>
);
};