feat(onboarding): pick the birth place by level instead of by search
The single search field asked people to type a place name and then judge which of several near-identical results was theirs, which is the one thing they cannot verify about their own birth record. Province, city and district are now chosen from the dataset the app already ships, so there is nothing to type and nothing to disambiguate. Levels that offer no choice collapse: municipalities show one level, and prefecture cities without districts stop at the city. A district can be left as the city centre, which is accurate enough because the chart only needs coordinates and a timezone. The timezone is resolved once for the chosen place rather than on every keystroke, through a dedicated route that both this picker and the Geoapify path share. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { Check, LoaderCircle, MapPin, RotateCcw } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
birthPlaceDraftFromLevels,
|
||||
chinaProvinces,
|
||||
findCityNode,
|
||||
findProvinceNode,
|
||||
formatBirthPlaceCoordinate,
|
||||
formatBirthPlaceTimezone,
|
||||
hasVirtualCityLevel,
|
||||
resolveBirthPlaceNode,
|
||||
} from "@/lib/china-birth-place";
|
||||
|
||||
/** Sentinel for "this city has districts but the user cannot name one". */
|
||||
const cityCentreOption = "__city_centre__";
|
||||
const cityCentreLabel = "不确定,用市区中心";
|
||||
|
||||
export type BirthPlaceSelection = {
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
districtCode: string;
|
||||
label: string;
|
||||
placeType: "province" | "city" | "district";
|
||||
providerPlaceId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timezoneId: string;
|
||||
timezoneOffset: number | null;
|
||||
};
|
||||
|
||||
export type SavedBirthPlace = {
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
districtCode: string;
|
||||
timezoneId: string;
|
||||
timezoneOffset: number | null;
|
||||
};
|
||||
|
||||
type BirthPlacePickerProps = {
|
||||
value: SavedBirthPlace;
|
||||
birthDate?: string;
|
||||
birthTime?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (selection: BirthPlaceSelection | null) => void;
|
||||
};
|
||||
|
||||
type TimezoneState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; timezoneId: string; timezoneOffset: number | null }
|
||||
| { status: "error" };
|
||||
|
||||
/** The outcome of one lookup, tagged with the place it was requested for. */
|
||||
type TimezoneOutcome = { key: string; state: Extract<TimezoneState, { status: "ready" | "error" }> };
|
||||
|
||||
export function BirthPlacePicker({
|
||||
value,
|
||||
birthDate = "",
|
||||
birthTime = "",
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: BirthPlacePickerProps) {
|
||||
const { provinceCode, cityCode, districtCode } = value;
|
||||
const fieldPrefix = useId();
|
||||
// Read by the lookup effect so that a re-rendered parent does not restart it.
|
||||
const onChangeRef = useRef(onChange);
|
||||
const savedRef = useRef(value);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
savedRef.current = value;
|
||||
});
|
||||
|
||||
const externalKey = `${provinceCode}/${cityCode}/${districtCode}`;
|
||||
const [draft, setDraft] = useState(() => birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode }));
|
||||
const [syncedKey, setSyncedKey] = useState(externalKey);
|
||||
const [outcome, setOutcome] = useState<TimezoneOutcome | null>(null);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
|
||||
// Adopt a profile that was loaded or replaced outside this picker.
|
||||
if (syncedKey !== externalKey) {
|
||||
setSyncedKey(externalKey);
|
||||
if (provinceCode) setDraft(birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode }));
|
||||
}
|
||||
|
||||
const province = findProvinceNode(draft.provinceCode);
|
||||
const virtualCity = Boolean(province && hasVirtualCityLevel(province));
|
||||
const city = province && virtualCity ? province.cities[0] : findCityNode(province, draft.cityCode);
|
||||
const districts = city?.districts ?? [];
|
||||
const districtChosen = Boolean(draft.districtCode) && districts.some((item) => item.code === draft.districtCode);
|
||||
|
||||
// Levels that offer no choice are skipped, so completeness stops at the deepest real level.
|
||||
const complete = Boolean(city) && (districts.length === 0 || districtChosen || draft.cityCentre);
|
||||
const resolved = complete
|
||||
? resolveBirthPlaceNode({
|
||||
provinceCode: draft.provinceCode,
|
||||
cityCode: city?.code ?? "",
|
||||
districtCode: districtChosen ? draft.districtCode : "",
|
||||
})
|
||||
: null;
|
||||
// The offset the profile already carries is only authoritative while the birth
|
||||
// moment it was computed against is unchanged.
|
||||
const birthMoment = `${birthDate}/${birthTime}`;
|
||||
const [mountedMoment] = useState(birthMoment);
|
||||
const savedIsCurrent = resolved !== null
|
||||
&& mountedMoment === birthMoment
|
||||
&& value.timezoneId.trim() !== ""
|
||||
&& provinceCode === resolved.province.code
|
||||
&& cityCode === resolved.city.code
|
||||
&& districtCode === (resolved.district?.code ?? "");
|
||||
|
||||
const syncKey = savedIsCurrent
|
||||
? "saved"
|
||||
: resolved
|
||||
? `${resolved.node.code}/${birthMoment}/${retryToken}`
|
||||
: "empty";
|
||||
|
||||
useEffect(() => {
|
||||
if (syncKey === "saved") return;
|
||||
if (syncKey === "empty") {
|
||||
const saved = savedRef.current;
|
||||
if (saved.provinceCode || saved.timezoneId) onChangeRef.current(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = resolved as NonNullable<typeof resolved>;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/locations/timezone", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
latitude: target.latitude,
|
||||
longitude: target.longitude,
|
||||
...(birthDate ? { birthDate } : {}),
|
||||
...(birthDate && birthTime ? { birthTime } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("timezone_unavailable");
|
||||
const payload = await response.json() as { timezoneId?: unknown; timezoneOffset?: unknown };
|
||||
const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : "";
|
||||
if (!timezoneId) throw new Error("timezone_unavailable");
|
||||
const timezoneOffset = typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null;
|
||||
|
||||
setOutcome({ key: syncKey, state: { status: "ready", timezoneId, timezoneOffset } });
|
||||
onChangeRef.current({
|
||||
provinceCode: target.province.code,
|
||||
cityCode: target.city.code,
|
||||
districtCode: target.district?.code ?? "",
|
||||
label: target.label,
|
||||
placeType: target.placeType,
|
||||
providerPlaceId: target.node.code,
|
||||
latitude: target.latitude,
|
||||
longitude: target.longitude,
|
||||
timezoneId,
|
||||
timezoneOffset,
|
||||
});
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
setOutcome({ key: syncKey, state: { status: "error" } });
|
||||
onChangeRef.current(null);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
// syncKey collapses the node plus the birth moment the historical offset depends on.
|
||||
}, [syncKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function chooseProvince(nextCode: string) {
|
||||
const nextProvince = findProvinceNode(nextCode);
|
||||
setDraft({
|
||||
provinceCode: nextCode,
|
||||
cityCode: nextProvince && hasVirtualCityLevel(nextProvince) ? nextProvince.cities[0].code : "",
|
||||
districtCode: "",
|
||||
cityCentre: false,
|
||||
});
|
||||
}
|
||||
|
||||
function chooseCity(nextCode: string) {
|
||||
setDraft((current) => ({ ...current, cityCode: nextCode, districtCode: "", cityCentre: false }));
|
||||
}
|
||||
|
||||
function chooseDistrict(nextCode: string) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
districtCode: nextCode === cityCentreOption ? "" : nextCode,
|
||||
cityCentre: nextCode === cityCentreOption,
|
||||
}));
|
||||
}
|
||||
|
||||
// A place with no lookup recorded against it is still being looked up.
|
||||
const settled: TimezoneState = syncKey === "empty"
|
||||
? { status: "idle" }
|
||||
: syncKey === "saved"
|
||||
? { status: "ready", timezoneId: value.timezoneId, timezoneOffset: value.timezoneOffset }
|
||||
: outcome?.key === syncKey
|
||||
? outcome.state
|
||||
: { status: "loading" };
|
||||
const statusText = settled.status === "loading"
|
||||
? "正在确认该地点的时区…"
|
||||
: settled.status === "ready" && resolved
|
||||
? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}`
|
||||
: settled.status === "error"
|
||||
? "时区服务暂时不可用,稍后重试即可。"
|
||||
: "按省、市、区县逐级选择即可。";
|
||||
|
||||
return (
|
||||
<div className="birth-place-picker">
|
||||
<div className="birth-place-levels">
|
||||
<label htmlFor={`${fieldPrefix}-province`}>
|
||||
<span>省 / 直辖市</span>
|
||||
<Select
|
||||
value={draft.provinceCode || null}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => { if (typeof next === "string") chooseProvince(next); }}
|
||||
>
|
||||
<SelectTrigger id={`${fieldPrefix}-province`} aria-label="出生省份">
|
||||
<SelectValue placeholder="请选择">
|
||||
{(selected) => findProvinceNode(String(selected ?? ""))?.name ?? "请选择"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chinaProvinces.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
{province && !virtualCity && (
|
||||
<label htmlFor={`${fieldPrefix}-city`}>
|
||||
<span>市 / 自治州</span>
|
||||
<Select
|
||||
value={draft.cityCode || null}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => { if (typeof next === "string") chooseCity(next); }}
|
||||
>
|
||||
<SelectTrigger id={`${fieldPrefix}-city`} aria-label="出生城市">
|
||||
<SelectValue placeholder="请选择">
|
||||
{(selected) => findCityNode(province, String(selected ?? ""))?.name ?? "请选择"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{province.cities.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{city && districts.length > 0 && (
|
||||
<label htmlFor={`${fieldPrefix}-district`}>
|
||||
<span>区 / 县</span>
|
||||
<Select
|
||||
value={draft.cityCentre ? cityCentreOption : draft.districtCode || null}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => { if (typeof next === "string") chooseDistrict(next); }}
|
||||
>
|
||||
<SelectTrigger id={`${fieldPrefix}-district`} aria-label="出生区县">
|
||||
<SelectValue placeholder="请选择">
|
||||
{(selected) => selected === cityCentreOption
|
||||
? cityCentreLabel
|
||||
: districts.find((item) => item.code === String(selected ?? ""))?.name ?? "请选择"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={cityCentreOption}>{cityCentreLabel}</SelectItem>
|
||||
{districts.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="birth-place-note">排盘只需要经纬度和时区,精确到区 / 县就足够,同一个县内的差异不会改变盘面。</p>
|
||||
|
||||
<p
|
||||
className={`birth-place-status${settled.status === "error" ? " is-error" : ""}${settled.status === "ready" ? " is-ready" : ""}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{settled.status === "loading" && <LoaderCircle className="is-spinning" aria-hidden="true" />}
|
||||
{settled.status === "ready" && <Check aria-hidden="true" />}
|
||||
{(settled.status === "idle" || settled.status === "error") && <MapPin aria-hidden="true" />}
|
||||
<span>{statusText}</span>
|
||||
{settled.status === "error" && (
|
||||
<button type="button" className="birth-place-retry" onClick={() => setRetryToken((token) => token + 1)}>
|
||||
<RotateCcw aria-hidden="true" />重试
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Check, LoaderCircle, MapPin, Search, X } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
export type ResolvedBirthLocation = {
|
||||
id: string;
|
||||
label: string;
|
||||
placeType: string;
|
||||
provider: string;
|
||||
providerPlaceId: string;
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
admin1: string;
|
||||
admin2: string;
|
||||
locality: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timezoneId: string;
|
||||
timezoneOffset: number | null;
|
||||
timezoneSource: string;
|
||||
};
|
||||
|
||||
type LocationSearchComboboxProps = {
|
||||
value: ResolvedBirthLocation | null;
|
||||
birthDate?: string;
|
||||
birthTime?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (location: ResolvedBirthLocation | null) => void;
|
||||
};
|
||||
|
||||
type SearchState = "idle" | "loading" | "ready" | "empty" | "error";
|
||||
|
||||
function cleanString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function parseLocationSearchResults(payload: unknown): ResolvedBirthLocation[] {
|
||||
const rawResults = Array.isArray(payload)
|
||||
? payload
|
||||
: payload && typeof payload === "object" && Array.isArray((payload as { results?: unknown }).results)
|
||||
? (payload as { results: unknown[] }).results
|
||||
: payload && typeof payload === "object" && Array.isArray((payload as { locations?: unknown }).locations)
|
||||
? (payload as { locations: unknown[] }).locations
|
||||
: [];
|
||||
|
||||
return rawResults.flatMap((candidate): ResolvedBirthLocation[] => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const item = candidate as Record<string, unknown>;
|
||||
const latitude = typeof item.latitude === "number" ? item.latitude : Number.NaN;
|
||||
const longitude = typeof item.longitude === "number" ? item.longitude : Number.NaN;
|
||||
const timezoneOffset = item.timezoneOffset === null
|
||||
? null
|
||||
: typeof item.timezoneOffset === "number"
|
||||
? item.timezoneOffset
|
||||
: Number.NaN;
|
||||
const provider = cleanString(item.provider);
|
||||
const providerPlaceId = cleanString(item.providerPlaceId);
|
||||
const id = cleanString(item.id) || (provider && providerPlaceId ? `${provider}:${providerPlaceId}` : "");
|
||||
const label = cleanString(item.label);
|
||||
const countryCode = cleanString(item.countryCode).toUpperCase();
|
||||
const timezoneId = cleanString(item.timezoneId);
|
||||
if (!id || !label || !countryCode || !timezoneId
|
||||
|| !Number.isFinite(latitude) || latitude < -90 || latitude > 90
|
||||
|| !Number.isFinite(longitude) || longitude < -180 || longitude > 180
|
||||
|| (timezoneOffset !== null
|
||||
&& (!Number.isFinite(timezoneOffset) || timezoneOffset < -12 || timezoneOffset > 14))) return [];
|
||||
|
||||
return [{
|
||||
id,
|
||||
label,
|
||||
placeType: cleanString(item.placeType),
|
||||
provider,
|
||||
providerPlaceId,
|
||||
countryCode,
|
||||
countryName: cleanString(item.countryName),
|
||||
admin1: cleanString(item.admin1) || cleanString(item.regionName),
|
||||
admin2: cleanString(item.admin2) || cleanString(item.districtName),
|
||||
locality: cleanString(item.locality) || cleanString(item.localityName),
|
||||
latitude,
|
||||
longitude,
|
||||
timezoneId,
|
||||
timezoneOffset,
|
||||
timezoneSource: cleanString(item.timezoneSource),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function LocationSearchCombobox({
|
||||
value,
|
||||
birthDate = "",
|
||||
birthTime = "",
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: LocationSearchComboboxProps) {
|
||||
const inputId = useId();
|
||||
const listboxId = `${inputId}-listbox`;
|
||||
const statusId = `${inputId}-status`;
|
||||
const requestSequence = useRef(0);
|
||||
const [query, setQuery] = useState(value?.label ?? "");
|
||||
const [results, setResults] = useState<ResolvedBirthLocation[]>([]);
|
||||
const [state, setState] = useState<SearchState>("idle");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = query.trim();
|
||||
if (disabled || value || normalized.length < 2) return;
|
||||
|
||||
const sequence = requestSequence.current + 1;
|
||||
requestSequence.current = sequence;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
setState("loading");
|
||||
setExpanded(true);
|
||||
try {
|
||||
const search = new URLSearchParams({ q: normalized, locale: "zh-CN" });
|
||||
if (birthDate) search.set("birthDate", birthDate);
|
||||
if (birthDate && birthTime) search.set("birthTime", birthTime);
|
||||
const response = await fetch(`/api/locations/search?${search.toString()}`, {
|
||||
credentials: "same-origin",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error("location_search_failed");
|
||||
const nextResults = parseLocationSearchResults(await response.json());
|
||||
if (requestSequence.current !== sequence) return;
|
||||
setResults(nextResults);
|
||||
setState(nextResults.length > 0 ? "ready" : "empty");
|
||||
setActiveIndex(nextResults.length > 0 ? 0 : -1);
|
||||
} catch {
|
||||
if (controller.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setResults([]);
|
||||
setState("error");
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, 260);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [birthDate, birthTime, disabled, query, value]);
|
||||
|
||||
function choose(location: ResolvedBirthLocation) {
|
||||
requestSequence.current += 1;
|
||||
setQuery(location.label);
|
||||
setExpanded(false);
|
||||
setResults([]);
|
||||
setState("idle");
|
||||
setActiveIndex(-1);
|
||||
onChange(location);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
requestSequence.current += 1;
|
||||
setQuery("");
|
||||
setExpanded(false);
|
||||
setResults([]);
|
||||
setState("idle");
|
||||
setActiveIndex(-1);
|
||||
onChange(null);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (value && (event.key === "Backspace" || event.key === "Delete")) {
|
||||
event.preventDefault();
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
if (!expanded || results.length === 0) {
|
||||
if (event.key === "ArrowDown" && results.length > 0) setExpanded(true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => (current + 1) % results.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => (current <= 0 ? results.length - 1 : current - 1));
|
||||
} else if (event.key === "Enter" && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
choose(results[activeIndex]);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setExpanded(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
const isExpanded = expanded && !disabled && !value && query.trim().length >= 2;
|
||||
const activeDescendant = isExpanded && activeIndex >= 0
|
||||
? `${listboxId}-option-${activeIndex}`
|
||||
: undefined;
|
||||
const statusText = state === "loading"
|
||||
? "正在搜索地点"
|
||||
: state === "empty"
|
||||
? "没有找到匹配地点,请尝试城市、区县或英文地名"
|
||||
: state === "error"
|
||||
? "地点搜索暂时不可用,请稍后重试"
|
||||
: state === "ready"
|
||||
? `找到 ${results.length} 个地点`
|
||||
: value
|
||||
? `已选择 ${value.label}`
|
||||
: "输入至少两个字开始搜索";
|
||||
|
||||
return (
|
||||
<div className={`location-combobox${value ? " is-selected" : ""}`}>
|
||||
<label htmlFor={inputId}>搜索出生地点</label>
|
||||
<div className="location-combobox-input-wrap">
|
||||
{state === "loading" ? <LoaderCircle className="location-combobox-leading is-spinning" aria-hidden="true" /> : <Search className="location-combobox-leading" aria-hidden="true" />}
|
||||
<input
|
||||
id={inputId}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-describedby={statusId}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
placeholder="输入城市、区县或地标,例如:上海、Taipei"
|
||||
value={value?.label ?? query}
|
||||
onChange={(event) => {
|
||||
if (value) onChange(null);
|
||||
const nextQuery = event.target.value;
|
||||
setQuery(nextQuery);
|
||||
if (nextQuery.trim().length < 2) {
|
||||
requestSequence.current += 1;
|
||||
setExpanded(false);
|
||||
setResults([]);
|
||||
setState("idle");
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!value && (results.length > 0 || state === "loading" || state === "empty" || state === "error")) setExpanded(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{(query || value) && (
|
||||
<button className="location-combobox-clear" type="button" onClick={clear} disabled={disabled} aria-label="清除出生地点">
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p id={statusId} className={`location-combobox-status${state === "error" ? " is-error" : ""}`} role="status" aria-live="polite">
|
||||
{value ? <Check aria-hidden="true" /> : <MapPin aria-hidden="true" />}
|
||||
<span>{statusText}</span>
|
||||
</p>
|
||||
|
||||
{isExpanded && (
|
||||
<ul id={listboxId} className="location-combobox-results" role="listbox" aria-label="地点搜索结果">
|
||||
{state === "loading" && <li className="location-combobox-feedback">正在查找与出生日期对应的地点和时区…</li>}
|
||||
{state === "empty" && <li className="location-combobox-feedback">没有匹配结果。可以尝试更完整的城市名或英文拼写。</li>}
|
||||
{state === "error" && <li className="location-combobox-feedback is-error">搜索暂时失败,请检查网络后修改关键词重试。</li>}
|
||||
{state === "ready" && results.map((location, index) => (
|
||||
<li
|
||||
id={`${listboxId}-option-${index}`}
|
||||
key={location.id}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
className={index === activeIndex ? "is-active" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => choose(location)}
|
||||
>
|
||||
<MapPin aria-hidden="true" />
|
||||
<span><b>{location.label}</b><small>{[location.countryName, location.timezoneId].filter(Boolean).join(" · ")}</small></span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user