282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* AddressInput — combobox с автокомплитом адресов в ЕКБ.
|
||
* Стиль: `.ac-list .ac-item .ac-main .ac-sub` из tradein.html mockup.
|
||
*
|
||
* Особенности:
|
||
* - 200ms debounce
|
||
* - Открывается при focus и при наборе
|
||
* - Закрывается по click outside + Esc
|
||
* - Выбор по mouseDown (предотвращает blur)
|
||
* - ↑↓ навигация, Enter / Tab выбор
|
||
* - Поддерживает typo-tolerance через backend (Цвилинга → Цвиллинга)
|
||
*/
|
||
import { useEffect, useRef, useState } from "react";
|
||
|
||
import { apiFetch } from "@/lib/api";
|
||
|
||
interface SuggestItem {
|
||
label: string;
|
||
full_address: string;
|
||
lat: number;
|
||
lon: number;
|
||
kind: string;
|
||
// ФИАС-идентификатор дома (заполнен только для house-level кандидатов
|
||
// DaData-тира; иначе null/absent). Пробрасывается наверх через onPickFias.
|
||
fias_id?: string | null;
|
||
}
|
||
|
||
interface SuggestResponse {
|
||
items: SuggestItem[];
|
||
}
|
||
|
||
// Pre-filled тестовые адреса — клик подставляет в поле и заодно дёргает suggest.
|
||
// Все эти адреса дают «medium» или «high» confidence на 2-room 50м² (хорошее покрытие).
|
||
const PRESET_ADDRESSES: { label: string; full: string; kind: string }[] = [
|
||
{ label: "ул. Малышева, 84 · Парковый", full: "Екатеринбург, ул. Малышева, 84", kind: "house" },
|
||
{ label: "ул. Куйбышева, 48 · Парковый", full: "Екатеринбург, ул. Куйбышева, 48", kind: "house" },
|
||
{ label: "ул. 8 Марта, 50 · Ленинский р-н", full: "Екатеринбург, ул. 8 Марта, 50", kind: "house" },
|
||
{ label: "ул. Цвиллинга, 58 · Парковый", full: "Екатеринбург, улица Цвиллинга, 58", kind: "house" },
|
||
{ label: "ул. Белинского, 200 · ЮЗ", full: "Екатеринбург, ул. Белинского, 200", kind: "house" },
|
||
{ label: "ул. Куйбышева, 30 · центр", full: "Екатеринбург, ул. Куйбышева, 30", kind: "house" },
|
||
];
|
||
|
||
interface Props {
|
||
value: string;
|
||
onChange: (val: string) => void;
|
||
onPickCoords?: (coords: { lat: number; lon: number } | null) => void;
|
||
// ФИАС-ключ выбранного дома. Зеркалит поведение onPickCoords: значение при
|
||
// выборе house-level подсказки, null при ручной правке текста.
|
||
onPickFias?: (fiasId: string | null) => void;
|
||
placeholder?: string;
|
||
inputId?: string;
|
||
}
|
||
|
||
const KIND_BADGE: Record<string, { tag: string; label: string }> = {
|
||
house: { tag: "ДОМ", label: "house" },
|
||
street: { tag: "УЛ", label: "street" },
|
||
locality: { tag: "Р-Н", label: "locality" },
|
||
};
|
||
|
||
function AreaSubtitle({ item }: { item: SuggestItem }) {
|
||
const kindBadge = KIND_BADGE[item.kind] ?? { tag: item.kind.toUpperCase(), label: item.kind };
|
||
return (
|
||
<span className="ac-sub" style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||
<span
|
||
style={{
|
||
padding: "1px 5px",
|
||
background: "var(--surface-3)",
|
||
color: "var(--muted)",
|
||
borderRadius: 3,
|
||
fontSize: 9,
|
||
fontWeight: 600,
|
||
letterSpacing: "0.05em",
|
||
}}
|
||
>
|
||
{kindBadge.tag}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function presetsToItems(): SuggestItem[] {
|
||
return PRESET_ADDRESSES.map((p) => ({
|
||
label: p.label,
|
||
full_address: p.full,
|
||
lat: 0,
|
||
lon: 0,
|
||
kind: p.kind,
|
||
fias_id: null,
|
||
}));
|
||
}
|
||
|
||
export function AddressInput({ value, onChange, onPickCoords, onPickFias, placeholder, inputId = "addr" }: Props) {
|
||
const [open, setOpen] = useState(false);
|
||
const [items, setItems] = useState<SuggestItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [highlight, setHighlight] = useState(0);
|
||
const [showPresets, setShowPresets] = useState(false);
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const lastPickedRef = useRef<string>("");
|
||
|
||
// Debounced fetch на каждое изменение value
|
||
useEffect(() => {
|
||
const q = value.trim();
|
||
if (q.length < 2) {
|
||
setItems([]);
|
||
setOpen(false);
|
||
return;
|
||
}
|
||
setShowPresets(false);
|
||
// Если value совпадает с последним выбранным — не делаем запрос
|
||
if (q === lastPickedRef.current) return;
|
||
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const r = await apiFetch<SuggestResponse>(
|
||
`/api/v1/geocode/suggest?q=${encodeURIComponent(q)}&limit=8`,
|
||
);
|
||
setItems(r.items);
|
||
setOpen(r.items.length > 0);
|
||
setHighlight(0);
|
||
} catch {
|
||
setItems([]);
|
||
setOpen(false);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, 200);
|
||
|
||
return () => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
};
|
||
}, [value]);
|
||
|
||
// Click outside → close
|
||
useEffect(() => {
|
||
function onClick(e: MouseEvent) {
|
||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||
setOpen(false);
|
||
setShowPresets(false);
|
||
}
|
||
}
|
||
document.addEventListener("mousedown", onClick);
|
||
return () => document.removeEventListener("mousedown", onClick);
|
||
}, []);
|
||
|
||
function pick(item: SuggestItem) {
|
||
lastPickedRef.current = item.full_address;
|
||
onChange(item.full_address);
|
||
const hasCoords =
|
||
Number.isFinite(item.lat) &&
|
||
Number.isFinite(item.lon) &&
|
||
item.lat !== 0 &&
|
||
item.lon !== 0;
|
||
onPickCoords?.(hasCoords ? { lat: item.lat, lon: item.lon } : null);
|
||
onPickFias?.(item.fias_id ?? null);
|
||
setOpen(false);
|
||
setShowPresets(false);
|
||
setItems([]);
|
||
}
|
||
|
||
// Активный список для UI (suggest result ИЛИ presets)
|
||
const activeItems: SuggestItem[] = showPresets ? presetsToItems() : items;
|
||
const dropdownOpen = (open && items.length > 0) || (showPresets && activeItems.length > 0);
|
||
|
||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||
if (!dropdownOpen) {
|
||
if (e.key === "ArrowDown") {
|
||
// Открываем dropdown — либо результаты suggest, либо presets
|
||
if (items.length > 0) setOpen(true);
|
||
else if (value.trim().length < 2) setShowPresets(true);
|
||
e.preventDefault();
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
setHighlight((h) => Math.min(activeItems.length - 1, h + 1));
|
||
} else if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
setHighlight((h) => Math.max(0, h - 1));
|
||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||
e.preventDefault();
|
||
pick(activeItems[highlight]);
|
||
} else if (e.key === "Escape") {
|
||
setOpen(false);
|
||
setShowPresets(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div ref={containerRef} style={{ position: "relative", width: "100%" }}>
|
||
<input
|
||
id={inputId}
|
||
type="text"
|
||
className="control"
|
||
placeholder={placeholder}
|
||
value={value}
|
||
autoComplete="off"
|
||
onChange={(e) => {
|
||
onChange(e.target.value);
|
||
lastPickedRef.current = ""; // юзер начал редактировать — снимаем guard
|
||
onPickCoords?.(null); // ручное редактирование сбрасывает ранее выбранные coords
|
||
onPickFias?.(null); // ...и ранее выбранный ФИАС (иначе stale перебьёт новый адрес)
|
||
setShowPresets(false);
|
||
}}
|
||
onFocus={() => {
|
||
// Если есть свежий suggest result — открываем его
|
||
if (items.length > 0) {
|
||
setOpen(true);
|
||
return;
|
||
}
|
||
// Если поле пустое — показываем тестовые адреса
|
||
if (value.trim().length < 2) {
|
||
setShowPresets(true);
|
||
setHighlight(0);
|
||
}
|
||
}}
|
||
onKeyDown={onKeyDown}
|
||
minLength={3}
|
||
maxLength={500}
|
||
required
|
||
/>
|
||
{loading && (
|
||
<span
|
||
aria-hidden="true"
|
||
style={{
|
||
position: "absolute",
|
||
right: 10,
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
fontSize: 10,
|
||
color: "var(--muted-2)",
|
||
}}
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ animation: "ti-spin 1s linear infinite" }}>
|
||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||
</svg>
|
||
</span>
|
||
)}
|
||
<ul className={`ac-list ${dropdownOpen ? "is-open" : ""}`} role="listbox">
|
||
{showPresets && (
|
||
<li
|
||
style={{
|
||
padding: "6px 10px",
|
||
fontSize: 10,
|
||
color: "var(--muted-2)",
|
||
fontWeight: 600,
|
||
letterSpacing: "0.08em",
|
||
textTransform: "uppercase",
|
||
borderBottom: "1px solid var(--border)",
|
||
cursor: "default",
|
||
}}
|
||
>
|
||
Подсказки · нажмите для подстановки
|
||
</li>
|
||
)}
|
||
{activeItems.map((it, i) => (
|
||
<li
|
||
key={`${it.full_address}-${i}`}
|
||
className={`ac-item ${i === highlight ? "is-focus" : ""}`}
|
||
role="option"
|
||
aria-selected={i === highlight}
|
||
onMouseEnter={() => setHighlight(i)}
|
||
onMouseDown={(e) => {
|
||
// mousedown (не click) — иначе input blur'ится до того как обработаем
|
||
e.preventDefault();
|
||
pick(it);
|
||
}}
|
||
>
|
||
<span className="ac-main">{it.label}</span>
|
||
<AreaSubtitle item={it} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|