Files
dukang/apps/h5-user/src/components/RegionPicker.tsx
T
2026-07-09 21:12:28 +08:00

227 lines
6.8 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import {
REGION_ALL,
getCities,
getCitiesForPicker,
getDistricts,
getDistrictsForPicker,
getProvincesForPicker,
normalizeRegionSelection,
toCityLevelRegion,
type RegionSelection,
} from '../lib/region-data';
type RegionPickerProps = {
open: boolean;
value: RegionSelection;
onClose: () => void;
onConfirm: (region: RegionSelection) => void;
/** 2 = 仅省/市(门店列表);3 = 省/市/区(地址等) */
levels?: 2 | 3;
};
type PickerLevel = 'province' | 'city' | 'district';
const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
{ key: 'province', label: '省份' },
{ key: 'city', label: '城市' },
{ key: 'district', label: '区县' },
];
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
if (levels === 2) {
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
}
if (normalized.district && normalized.district !== REGION_ALL) return 'district';
if (normalized.city && normalized.city !== REGION_ALL) return 'city';
return 'province';
}
function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) {
if (tab === 'province') {
return draft.province && draft.province !== REGION_ALL ? draft.province : fallback;
}
if (tab === 'city') {
return draft.city && draft.city !== REGION_ALL ? draft.city : fallback;
}
return draft.district && draft.district !== REGION_ALL ? draft.district : fallback;
}
export default function RegionPicker({
open,
value,
onClose,
onConfirm,
levels = 3,
}: RegionPickerProps) {
const [draft, setDraft] = useState<RegionSelection>(value);
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
const listRef = useRef<HTMLDivElement>(null);
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
useEffect(() => {
if (!open) return;
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
setDraft(normalized);
setActiveTab(initialTab(value, levels));
}, [open, value, levels]);
const listItems = useMemo(() => {
if (activeTab === 'province') return getProvincesForPicker();
if (activeTab === 'city') return getCitiesForPicker(draft.province);
return getDistrictsForPicker(draft.province, draft.city);
}, [activeTab, draft.province, draft.city]);
const selectedValue =
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
const canConfirm =
levels === 2
? Boolean(draft.province && draft.city)
: Boolean(draft.province && draft.city && draft.district);
useEffect(() => {
if (!open) return;
scrollActiveIntoView(listRef.current, selectedValue);
}, [open, activeTab, selectedValue, listItems.length]);
if (!open) return null;
function scrollActiveIntoView(container: HTMLDivElement | null, label: string) {
if (!container || !label) return;
const active = container.querySelector<HTMLElement>(`[data-label="${CSS.escape(label)}"]`);
active?.scrollIntoView({ block: 'nearest' });
}
function selectProvince(province: string) {
if (province === REGION_ALL) {
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
setActiveTab('city');
return;
}
const nextCities = getCities(province);
const city = nextCities[0] ?? '';
if (levels === 2) {
setDraft({
province,
city,
district: REGION_ALL,
});
setActiveTab('city');
return;
}
const nextDistricts = getDistricts(province, city);
setDraft({
province,
city,
district: nextDistricts[0] ?? '',
});
setActiveTab('city');
}
function selectCity(city: string) {
if (city === REGION_ALL) {
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
if (levels === 3) setActiveTab('district');
return;
}
if (levels === 2) {
setDraft({
...draft,
city,
district: REGION_ALL,
});
return;
}
const nextDistricts = getDistricts(draft.province, city);
setDraft({
...draft,
city,
district: nextDistricts[0] ?? '',
});
setActiveTab('district');
}
function selectDistrict(district: string) {
setDraft({ ...draft, district });
}
function onSelectItem(item: string) {
if (activeTab === 'province') selectProvince(item);
else if (activeTab === 'city') selectCity(item);
else selectDistrict(item);
}
function onTabClick(tab: PickerLevel) {
if (tab === 'city' && !draft.province) return;
if (tab === 'district' && (!draft.province || !draft.city)) return;
setActiveTab(tab);
}
function handleConfirm() {
if (!canConfirm) return;
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
onConfirm(next);
}
return (
<div className="region-picker-overlay" role="presentation" onClick={onClose}>
<div
className="region-picker-sheet"
role="dialog"
aria-label="选择地区"
onClick={(e) => e.stopPropagation()}
>
<div className="region-picker-toolbar">
<div className="region-picker-tabs" role="tablist">
{tabs.map((tab) => {
const disabled =
(tab.key === 'city' && !draft.province) ||
(tab.key === 'district' && (!draft.province || !draft.city));
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={activeTab === tab.key}
disabled={disabled}
className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => onTabClick(tab.key)}
>
{tabLabel(tab.key, draft, tab.label)}
</button>
);
})}
</div>
<button
type="button"
className={`region-picker-confirm${canConfirm ? ' ready' : ''}`}
disabled={!canConfirm}
onClick={handleConfirm}
>
确定
</button>
</div>
<div className="region-picker-list" ref={listRef}>
{listItems.map((item) => (
<button
key={item}
type="button"
data-label={item}
className={`region-picker-option${selectedValue === item ? ' selected' : ''}${
item === REGION_ALL ? ' region-picker-option--all' : ''
}`}
onClick={() => onSelectItem(item)}
>
{item}
</button>
))}
</div>
</div>
</div>
);
}