141 lines
5.0 KiB
TypeScript
141 lines
5.0 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { Search, X } from 'lucide-react';
|
|
import Fuse from 'fuse.js';
|
|
import {
|
|
getAllMeasures,
|
|
getUnitsForMeasure,
|
|
getUnitInfo,
|
|
formatMeasureName,
|
|
type Measure,
|
|
type UnitInfo,
|
|
} from '@/lib/units/units';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface SearchResult {
|
|
unitInfo: UnitInfo;
|
|
measure: Measure;
|
|
}
|
|
|
|
interface SearchUnitsProps {
|
|
onSelectUnit: (unit: string, measure: Measure) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProps) {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const searchIndex = useRef<Fuse<SearchResult> | null>(null);
|
|
|
|
useEffect(() => {
|
|
const allData: SearchResult[] = [];
|
|
const measures = getAllMeasures();
|
|
for (const measure of measures) {
|
|
for (const unit of getUnitsForMeasure(measure)) {
|
|
const unitInfo = getUnitInfo(unit);
|
|
if (unitInfo) allData.push({ unitInfo, measure });
|
|
}
|
|
}
|
|
searchIndex.current = new Fuse(allData, {
|
|
keys: [
|
|
{ name: 'unitInfo.abbr', weight: 2 },
|
|
{ name: 'unitInfo.singular', weight: 1.5 },
|
|
{ name: 'unitInfo.plural', weight: 1.5 },
|
|
{ name: 'measure', weight: 1 },
|
|
],
|
|
threshold: 0.3,
|
|
includeScore: true,
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!query.trim() || !searchIndex.current) {
|
|
setResults([]);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
setResults(searchIndex.current.search(query).map((r) => r.item).slice(0, 10));
|
|
setIsOpen(true);
|
|
}, [query]);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelectUnit = (unit: string, measure: Measure) => {
|
|
onSelectUnit(unit, measure);
|
|
setQuery('');
|
|
setIsOpen(false);
|
|
inputRef.current?.blur();
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn('relative w-full', className)}>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/40 pointer-events-none" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="Search all units…"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onFocus={() => query && setIsOpen(true)}
|
|
className="w-full bg-transparent border border-border/40 rounded-lg pl-8 pr-7 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30"
|
|
/>
|
|
{query && (
|
|
<button
|
|
onClick={() => { setQuery(''); setIsOpen(false); }}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isOpen && results.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-1.5 bg-popover border border-border/60 rounded-xl shadow-xl max-h-72 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
|
|
{results.map((result, index) => (
|
|
<button
|
|
key={`${result.measure}-${result.unitInfo.abbr}`}
|
|
onClick={() => handleSelectUnit(result.unitInfo.abbr, result.measure)}
|
|
className={cn(
|
|
'w-full px-3 py-2.5 text-left hover:bg-primary/8 hover:text-foreground transition-colors',
|
|
'flex items-center justify-between gap-3',
|
|
index !== 0 && 'border-t border-border/20'
|
|
)}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium font-mono truncate">{result.unitInfo.plural}</div>
|
|
<div className="text-[10px] text-muted-foreground/50 flex items-center gap-1.5 mt-0.5">
|
|
<span className="font-mono">{result.unitInfo.abbr}</span>
|
|
<span>·</span>
|
|
<span>{formatMeasureName(result.measure)}</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground/30 font-mono shrink-0">
|
|
{result.measure}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isOpen && query && results.length === 0 && (
|
|
<div className="absolute z-50 w-full mt-1.5 bg-popover border border-border/60 rounded-xl p-4 text-center">
|
|
<p className="text-xs text-muted-foreground/40 font-mono italic">No units found for "{query}"</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|