perfect-postcode/frontend/src/components/ui/PlaceSearchInput.tsx
2026-05-31 20:20:41 +01:00

177 lines
5.7 KiB
TypeScript

import { useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
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';
import { HouseIcon } from './icons/HouseIcon';
interface SearchHook {
query: string;
results: SearchResult[];
activeIndex: number;
setActiveIndex: (idx: number) => void;
open: boolean;
searching?: boolean;
handleInputChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
showEmptySearches: () => void;
}
interface PlaceSearchInputProps {
search: SearchHook;
onSelect: (result: SearchResult) => void;
loading?: boolean;
placeholder?: string;
ariaLabel?: string;
name?: string;
size?: 'sm' | 'xs';
inputClassName?: string;
inputRef?: React.Ref<HTMLInputElement>;
onInputChange?: () => void;
portal?: boolean;
}
export function PlaceSearchInput({
search,
onSelect,
loading,
placeholder,
ariaLabel,
name,
size = 'sm',
inputClassName,
inputRef,
onInputChange,
portal,
}: PlaceSearchInputProps) {
const { t } = useTranslation();
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 showEmptyResults =
search.open &&
!search.searching &&
search.query.trim().length >= 2 &&
search.results.length === 0;
const showDropdown = search.open && (search.results.length > 0 || showEmptyResults);
const showSpinner = loading || search.searching;
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
}
>
{showEmptyResults ? (
<div
className={`text-warm-500 dark:text-warm-400 ${sm ? 'px-3 py-2 text-sm' : 'px-2 py-1.5 text-xs'}`}
role="status"
>
{t('locationSearch.noResults')}
</div>
) : (
search.results.map((result, idx) => (
<button
key={
result.type === 'postcode'
? `pc-${result.label}`
: result.type === 'address'
? `addr-${result.postcode}-${result.address}-${result.lat}`
: `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>
</>
) : result.type === 'address' ? (
<>
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="min-w-0 text-warm-700 dark:text-warm-200">
<span className="block truncate">{result.address}</span>
<span className="block truncate text-warm-400 dark:text-warm-500">
{result.postcode}
</span>
</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"
name={name}
value={search.query}
onChange={(e) => {
search.handleInputChange(e.target.value);
onInputChange?.();
}}
onFocus={() => {
search.showEmptySearches();
}}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
aria-label={ariaLabel ?? placeholder}
placeholder={placeholder}
className={inputClassName}
/>
{showSpinner && (
<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>
);
}