138 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|