perfect-postcode/frontend/src/components/ui/PlaceSearchInput.tsx
2026-03-15 17:38:26 +00:00

138 lines
4.2 KiB
TypeScript

import { useRef } from 'react';
import { createPortal } from 'react-dom';
import type React from 'react';
import type { SearchResult } from '../../hooks/useLocationSearch';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
import { SearchIcon } from './icons/SearchIcon';
import { MapPinIcon } from './icons/MapPinIcon';
interface SearchHook {
query: string;
results: SearchResult[];
activeIndex: number;
setActiveIndex: (idx: number) => void;
open: boolean;
setOpen: (open: boolean) => void;
handleInputChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
}
interface PlaceSearchInputProps {
search: SearchHook;
onSelect: (result: SearchResult) => void;
loading?: boolean;
placeholder?: string;
size?: 'sm' | 'xs';
inputClassName?: string;
inputRef?: React.Ref<HTMLInputElement>;
onInputChange?: () => void;
portal?: boolean;
}
export function PlaceSearchInput({
search,
onSelect,
loading,
placeholder,
size = 'sm',
inputClassName,
inputRef,
onInputChange,
portal,
}: PlaceSearchInputProps) {
const sm = size === 'sm';
const iconSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const spinnerSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const wrapperRef = useRef<HTMLDivElement>(null);
const dropdownPos = useDropdownPosition(wrapperRef, portal ? search.open : false);
const showDropdown = search.open && search.results.length > 0;
const dropdown = showDropdown && (
<div
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
style={
portal && dropdownPos
? {
position: 'fixed',
top: dropdownPos.top,
left: dropdownPos.left,
width: dropdownPos.width,
zIndex: 50,
}
: undefined
}
>
{search.results.map((result, idx) => (
<button
key={
result.type === 'postcode' ? `pc-${result.label}` : `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
} ${
idx === search.activeIndex
? 'bg-teal-50 dark:bg-teal-900/30'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => search.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(result);
}}
>
{result.type === 'postcode' ? (
<>
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</>
) : (
<>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
)}
</span>
</>
)}
</button>
))}
</div>
);
return (
<div ref={wrapperRef} className="relative flex-1 min-w-0">
<input
ref={inputRef}
type="text"
value={search.query}
onChange={(e) => {
search.handleInputChange(e.target.value);
onInputChange?.();
}}
onFocus={() => {
if (search.results.length > 0) search.setOpen(true);
}}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
placeholder={placeholder}
className={inputClassName}
/>
{loading && (
<div
className={`absolute right-2 top-1/2 -translate-y-1/2 ${spinnerSize} border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin`}
/>
)}
{showDropdown &&
(portal ? (
createPortal(dropdown, document.body)
) : (
<div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</div>
))}
</div>
);
}