ifixkart-storefront/components/services/FormSelect.tsx

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>
);
}