turash/bugulma/frontend/components/ui/Combobox.tsx
Damir Mukimov 673e8d4361
Some checks failed
CI/CD Pipeline / backend-lint (push) Failing after 31s
CI/CD Pipeline / backend-build (push) Has been skipped
CI/CD Pipeline / frontend-lint (push) Failing after 1m37s
CI/CD Pipeline / frontend-build (push) Has been skipped
CI/CD Pipeline / e2e-test (push) Has been skipped
fix: resolve all frontend lint errors (85 issues fixed)
- Replace all 'any' types with proper TypeScript interfaces
- Fix React hooks setState in useEffect issues with lazy initialization
- Remove unused variables and imports across all files
- Fix React Compiler memoization dependency issues
- Add comprehensive i18n translation keys for admin interfaces
- Apply consistent prettier formatting throughout codebase
- Clean up unused bulk editing functionality
- Improve type safety and code quality across frontend

Files changed: 39
- ImpactMetrics.tsx: Fixed any types and interfaces
- AdminVerificationQueuePage.tsx: Added i18n keys, removed unused vars
- LocalizationUIPage.tsx: Fixed memoization, added translations
- LocalizationDataPage.tsx: Added type safety and translations
- And 35+ other files with various lint fixes
2025-12-25 14:14:58 +01:00

186 lines
5.5 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 { t } = useTranslation();
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>
);
};