174 lines
10 KiB
TypeScript
174 lines
10 KiB
TypeScript
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
|
|
import { useId, useLayoutEffect, useRef } from "react";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Input } from "@/shared/ui/input";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
|
|
import { MAX_PLACE_SEARCH_RESULTS, type PlaceSearchItem } from "../api/place-search-client";
|
|
|
|
const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]";
|
|
|
|
function PlaceSummary({ item, index, annotation = item.district }: { item: PlaceSearchItem; index?: number; annotation?: string }) {
|
|
return (
|
|
<>
|
|
{index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]">
|
|
{String(index + 1).padStart(2, "0")}
|
|
</span> : null}
|
|
<span className="min-w-0 flex-1 space-y-1">
|
|
<span className="flex items-start justify-between gap-2 leading-5">
|
|
<span className="min-w-0 flex-1 break-words font-semibold text-[var(--ink)]">{item.name}</span>
|
|
<span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span>
|
|
</span>
|
|
<span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span>
|
|
</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding }) {
|
|
const inputId = useId();
|
|
const statusId = useId();
|
|
const candidateListRef = useRef<HTMLUListElement | null>(null);
|
|
const changeSelectionRef = useRef<HTMLButtonElement | null>(null);
|
|
const { address, result, results, pending, error, enabled } = geocoding;
|
|
const mapResult = geocoding.origin === "map";
|
|
const selectionCollapsed = Boolean(result && !geocoding.choosing);
|
|
const hideSelectionHint = Boolean(results?.items.length && enabled && !error && !geocoding.picking);
|
|
useLayoutEffect(() => {
|
|
if (selectionCollapsed || !result) return;
|
|
const list = candidateListRef.current;
|
|
const selected = list?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
|
|
if (!list || !selected) return;
|
|
const viewport = list.getBoundingClientRect();
|
|
const row = selected.getBoundingClientRect();
|
|
list.scrollTop += row.top - viewport.top - (list.clientHeight - row.height) / 2;
|
|
}, [selectionCollapsed, result, results]);
|
|
const status = !geocoding.configured ? "当前项目暂未配置地名搜索" : error
|
|
?? (pending ? (mapResult ? "正在查询取点位置…" : "正在搜索地名…")
|
|
: !enabled ? "等待地图加载后即可定位"
|
|
: geocoding.picking ? "点击地图查询地名,按 Esc 结束取点"
|
|
: result ? "已在地图标出所选位置"
|
|
: results ? results.items.length ? "选择一个候选位置进行定位" : `${geocoding.region}内未找到匹配位置,请细化关键字`
|
|
: `搜索${geocoding.region}内地址或地名,支持回车查询`);
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
|
<form
|
|
className="flex shrink-0 flex-col gap-2"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void geocoding.search();
|
|
}}
|
|
>
|
|
<label htmlFor={inputId} className="block px-1 text-xs font-semibold text-[var(--ink-muted)]">
|
|
查询地址
|
|
</label>
|
|
<div className="flex items-start gap-2">
|
|
<Input
|
|
id={inputId}
|
|
value={address}
|
|
onChange={(event) => geocoding.changeAddress(event.target.value)}
|
|
placeholder="道路、门牌号或地名"
|
|
className="h-10 min-w-0 flex-1 rounded-xl border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs"
|
|
autoComplete="street-address"
|
|
maxLength={120}
|
|
aria-invalid={Boolean(error)}
|
|
aria-describedby={statusId}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
aria-label="搜索地名"
|
|
className="shrink-0 gap-1.5 px-3 text-xs"
|
|
disabled={!enabled || !address.trim()}
|
|
loading={pending && !mapResult}
|
|
loadingLabel="正在搜索地名"
|
|
>
|
|
<Search aria-hidden="true" />查询
|
|
</Button>
|
|
</div>
|
|
<Button type="button" variant="secondary" className="w-full text-xs"
|
|
aria-label="从地图取点查找地名" aria-pressed={geocoding.picking} disabled={!enabled}
|
|
onClick={geocoding.togglePicking}>
|
|
<Crosshair aria-hidden="true" />{geocoding.picking ? "结束取点" : "地图取点"}
|
|
</Button>
|
|
<div
|
|
id={statusId}
|
|
role={error ? "alert" : "status"}
|
|
aria-live={error ? "assertive" : "polite"}
|
|
className={hideSelectionHint ? "sr-only" : cn("flex min-h-5 items-start gap-1.5 px-1 text-[11px] leading-5", error ? "text-[var(--status-danger-foreground)]" : "text-[var(--ink-muted)]")}
|
|
>
|
|
{result && !error ? <MapPin size={13} className="mt-0.5 shrink-0" aria-hidden="true" /> : null}
|
|
<span>{status}</span>
|
|
</div>
|
|
</form>
|
|
{results && results.items.length > 0 ? (
|
|
<section aria-label="搜索结果" className="flex min-h-0 flex-1 flex-col gap-2 border-t border-[var(--surface-divider)] pt-3">
|
|
<div className={cn("flex shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]", mapResult ? "h-6" : "h-10")}>
|
|
<span className="text-xs font-semibold">{selectionCollapsed ? mapResult ? "取点结果" : "已选位置" : "选择位置"}</span>
|
|
{selectionCollapsed && !mapResult ? (
|
|
<Button ref={changeSelectionRef} type="button" variant="secondary" className="gap-1.5 px-3 text-xs" aria-expanded={false}
|
|
onClick={(event) => {
|
|
geocoding.changeSelection();
|
|
if (event.detail === 0) window.requestAnimationFrame(() => {
|
|
const list = candidateListRef.current;
|
|
const selected = list?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
|
|
(selected ?? list?.querySelector<HTMLButtonElement>("button"))?.focus({ preventScroll: true });
|
|
});
|
|
}}>
|
|
更换位置<ChevronDown aria-hidden="true" />
|
|
</Button>
|
|
) : mapResult ? <span>地图位置</span> : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
|
|
</div>
|
|
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
|
|
{results.items.map((item, index) => (
|
|
<li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0">
|
|
<Button type="button" variant="ghost" aria-pressed={result?.id === item.id} data-selection-tone="soft"
|
|
aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled}
|
|
className={cn("h-auto min-h-16 w-full justify-start gap-3 rounded-lg border-0 whitespace-normal px-1 py-3 text-left text-xs focus-visible:ring-inset focus-visible:ring-offset-0 [@media(hover:hover)]:hover:bg-[var(--surface-control)] [@media(hover:hover)]:hover:text-[var(--ink)]",
|
|
result?.id === item.id && "bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] [&_span]:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-selection-soft)]")}
|
|
onClick={(event) => {
|
|
geocoding.select(item.id);
|
|
if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus());
|
|
}}>
|
|
<PlaceSummary item={item} index={index} />
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul> : null}
|
|
{result && selectionCollapsed ? (
|
|
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden", mapResult && "h-auto shrink-0")}>
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
|
<div className={mapResult ? "space-y-2" : "space-y-3"}>
|
|
<div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}>
|
|
<PlaceSummary item={result} annotation={result.category ?? result.district} />
|
|
</div>
|
|
<dl aria-label="经纬度坐标" className="space-y-1 px-1">
|
|
<dt className="text-[11px] leading-4 text-[var(--ink-muted)]">经度 / 纬度</dt>
|
|
<dd className="whitespace-nowrap text-sm font-medium leading-5 tabular-nums text-[var(--ink)]">
|
|
{`${result.longitude.toFixed(6)}, ${result.latitude.toFixed(6)}`}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<div className="grid shrink-0 grid-cols-2 gap-2">
|
|
<Button type="button" variant="secondary" className="text-xs" disabled={!enabled} onClick={geocoding.locate}>
|
|
<LocateFixed aria-hidden="true" />重新定位
|
|
</Button>
|
|
<Button type="button" variant="secondary" className="text-xs" onClick={geocoding.clear}>
|
|
<Trash2 aria-hidden="true" />清除结果
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
<p className="flex min-h-5 shrink-0 items-start gap-1.5 px-1 text-[11px] leading-5 text-[var(--ink-muted)]">
|
|
{selectionCollapsed ? <><MapPin size={13} className="mt-0.5 shrink-0" aria-hidden="true" /><span>已在地图标出所选位置</span></>
|
|
: results.truncated ? `最多显示 ${MAX_PLACE_SEARCH_RESULTS} 个位置,请细化关键词`
|
|
: "选择一个候选位置进行定位"}
|
|
</p>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|