199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Check, ChevronDown, Loader2, Search } from 'lucide-react';
|
|
|
|
export type FormSelectOption = {
|
|
value: string;
|
|
label: string;
|
|
hint?: string;
|
|
imageUrl?: string;
|
|
};
|
|
|
|
type FormSelectProps = {
|
|
label: string;
|
|
placeholder: string;
|
|
value: string;
|
|
options: FormSelectOption[];
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
searchable?: boolean;
|
|
onChange: (value: string) => void;
|
|
};
|
|
|
|
export function FormSelect({
|
|
label,
|
|
placeholder,
|
|
value,
|
|
options,
|
|
disabled = false,
|
|
loading = false,
|
|
searchable = false,
|
|
onChange,
|
|
}: FormSelectProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
|
|
const selected = options.find((option) => option.value === value);
|
|
const isDisabled = disabled || loading;
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return options;
|
|
return options.filter((option) =>
|
|
`${option.label} ${option.hint || ''}`.toLowerCase().includes(q)
|
|
);
|
|
}, [options, query]);
|
|
|
|
useEffect(() => {
|
|
const onDocClick = (event: MouseEvent) => {
|
|
if (!rootRef.current?.contains(event.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', onDocClick);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDocClick);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setQuery('');
|
|
return;
|
|
}
|
|
const id = window.setTimeout(() => searchRef.current?.focus(), 0);
|
|
return () => window.clearTimeout(id);
|
|
}, [open]);
|
|
|
|
const showSearch = searchable && options.length > 6;
|
|
|
|
return (
|
|
<div ref={rootRef} className="relative">
|
|
<span className="block font-semibold text-[#1a1a1a] text-[11px] mb-1.5">
|
|
{label}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={isDisabled}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
onClick={() => {
|
|
if (!isDisabled) setOpen((prev) => !prev);
|
|
}}
|
|
className={`w-full min-h-[46px] px-3.5 rounded-lg border text-left flex items-center gap-2.5 transition ${
|
|
open
|
|
? 'border-primary bg-white ring-2 ring-primary/15'
|
|
: selected
|
|
? 'border-gray-200 bg-white hover:border-primary/40'
|
|
: 'border-gray-200 bg-gray-50 hover:border-gray-300'
|
|
} ${
|
|
isDisabled
|
|
? 'opacity-60 cursor-not-allowed bg-gray-100'
|
|
: 'cursor-pointer'
|
|
}`}
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="w-4 h-4 animate-spin text-primary shrink-0" />
|
|
) : selected?.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={selected.imageUrl}
|
|
alt=""
|
|
className="w-6 h-6 object-contain shrink-0"
|
|
/>
|
|
) : null}
|
|
<span
|
|
className={`flex-1 text-[13px] truncate ${
|
|
selected ? 'font-semibold text-[#1a1a1a]' : 'text-gray-400'
|
|
}`}
|
|
>
|
|
{loading ? 'Loading...' : selected?.label || placeholder}
|
|
</span>
|
|
<ChevronDown
|
|
className={`w-4 h-4 shrink-0 transition-transform ${
|
|
open ? 'rotate-180 text-primary' : 'text-gray-400'
|
|
}`}
|
|
/>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute z-30 mt-1.5 w-full bg-white border border-gray-200 rounded-xl shadow-[0_12px_32px_rgba(15,23,42,0.12)] overflow-hidden">
|
|
{showSearch && (
|
|
<div className="p-2 border-b border-gray-100">
|
|
<div className="relative">
|
|
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
ref={searchRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder={`Search ${label.toLowerCase()}...`}
|
|
className="w-full h-9 pl-8 pr-3 rounded-lg bg-gray-50 border border-gray-200 text-[12px] text-[#1a1a1a] outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<ul role="listbox" className="max-h-56 overflow-y-auto py-1">
|
|
{filtered.length === 0 ? (
|
|
<li className="px-3.5 py-6 text-center text-[12px] text-gray-400">
|
|
No matches found
|
|
</li>
|
|
) : (
|
|
filtered.map((option) => {
|
|
const isSelected = option.value === value;
|
|
return (
|
|
<li key={option.value}>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
onClick={() => {
|
|
onChange(option.value);
|
|
setOpen(false);
|
|
}}
|
|
className={`w-full px-3.5 py-2.5 flex items-center gap-2.5 text-left transition ${
|
|
isSelected
|
|
? 'bg-primary/5 text-primary'
|
|
: 'text-[#1a1a1a] hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
{option.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={option.imageUrl}
|
|
alt=""
|
|
className="w-6 h-6 object-contain shrink-0"
|
|
/>
|
|
) : null}
|
|
<span className="flex-1 min-w-0">
|
|
<span className="block text-[13px] font-semibold truncate">
|
|
{option.label}
|
|
</span>
|
|
{option.hint ? (
|
|
<span className="block text-[11px] text-gray-400 truncate">
|
|
{option.hint}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
{isSelected ? (
|
|
<Check className="w-4 h-4 text-primary shrink-0" />
|
|
) : null}
|
|
</button>
|
|
</li>
|
|
);
|
|
})
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|