"use client"
import * as React from "react"
import { MapPin } from "lucide-react"
import { RegionPicker } from "@/components/ui/region-picker/region-picker"
// 放在组件外保持引用稳定;import() 让约 200KB 的数据单独成块,首次需要时才下载
const loadRegions = () =>
import("@/lib/china-regions").then((m) => m.chinaRegions)
/** 默认示例:有初始值,挂载后就加载数据并显示完整路径;打开后可搜 hz、hangzhou、330106。 */
export default function RegionPickerDemo() {
const [code, setCode] = React.useState<string | null>("330106")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">收货地址</p>
<p className="truncate text-xs text-muted-foreground">
按省、市、区三步选择,也可以直接搜拼音
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
{code ?? "—"}
</p>
</div>
<RegionPicker
label="所在地区"
regions={loadRegions}
value={code}
onValueChange={setCode}
/>
<p className="mt-3 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<MapPin className="size-3" aria-hidden="true" />
值只存最末一级的代码,右上角就是它
</p>
</div>
)
}
安装
组件和数据是两个条目,分开安装:
pnpm dlx shadcn@latest add https://yiui.dev/r/region-picker.json数据用国家地名信息库的官方行政区划,见 china-regions;用自己的数据就不必装:
pnpm dlx shadcn@latest add https://yiui.dev/r/china-regions.json组件本身不引用数据,regions 传什么它就用什么。
用法
import { RegionPicker } from "@/components/ui/region-picker/region-picker"
export function AddressForm() {
return (
<RegionPicker
label="所在地区"
name="region"
required
regions={() =>
import("@/lib/china-regions").then((m) => m.chinaRegions)
}
/>
)
}value、defaultValue、onValueChange 的值都是一个代码字符串(最末一级的 code),没选时是 null。onValueChange(code, path) 的第二个参数是从最顶层到它的完整路径,需要名称时不用自己再查。
<RegionPicker
regions={regions}
value={code}
onValueChange={(code, path) => {
setCode(code)
console.log(path.map((node) => node.name).join(" / ")) // 浙江省 / 杭州市 / 西湖区
}}
/>数据标准
regions 接收一棵树,节点结构就是组件认的「标准」,任何符合它的数据都能用:
type RegionOption = {
code: string // 全树唯一,也是组件对外的值
name: string // 显示名称
pinyin?: string // 无声调拼音,音节用空格分隔:"hang zhou shi"
children?: RegionOption[] // 没有或为空数组 = 末级
}- 层级深度不固定。直辖市只有「市 → 区」两级(北京市 → 东城区),东莞、儋州这类不设区的市没有下级。选到没有
children的节点就选完了,组件不会凭空造出一层同名节点。 pinyin可以没有。没有时搜索只按名称和代码;有的话才支持全拼和首字母。code不必是国标码,任何字符串都行,只要在整棵树里唯一。
组件的类型叫 RegionOption,和 china-regions 的 ChinaRegion 结构相同,TypeScript 按结构兼容,直接传即可。
惰性加载
regions 可以是数组,也可以是返回 Promise 的函数。官方数据有 3211 条、约 200KB,打进首屏很不划算,推荐写成动态 import():
<RegionPicker
regions={() => import("@/lib/china-regions").then((m) => m.chinaRegions)}
/>函数形式的调用时机:
- 用户第一次打开浮层时调用;
- 如果已经有初始值(
value/defaultValue非空),需要显示「浙江省 / 杭州市 / 西湖区」,则挂载后就调用,不用等打开; - 只调用一次,之后复用结果。每次渲染都写内联箭头函数也不会重复请求。
加载中,触发器显示「正在加载地区数据…」(有 fallbackLabel 时显示它),浮层里是加载提示;失败时浮层显示错误和「重试」按钮。数组形式则没有这些状态。
数组形式请保持引用稳定(放在组件外,或 useMemo),否则每次渲染都会重建搜索索引。
值存代码,不存名称
只存一个 code,是因为它短、稳定、能进数据库的索引,完整路径随时可由数据反查:
- 名称会改(「县」改「区」),代码在改名后一般不变;
- 一个代码就能还原三级,不会出现「省存了、市存了、区对不上」的半截数据;
- 表单提交、接口入参只有一个字段。
后端回显或导出时把代码转成名称,用 findRegionPath(纯函数,服务端可用):
import { findRegionPath, formatRegionPath } from "@/components/ui/region-picker/utils"
import { chinaRegions } from "@/lib/china-regions"
const path = findRegionPath(chinaRegions, "330106")
formatRegionPath(path) // "浙江省 / 杭州市 / 西湖区"
formatRegionPath(path, "") // "浙江省杭州市西湖区"
path.map((node) => node.name) // ["浙江省", "杭州市", "西湖区"]
findRegionPath(chinaRegions, "999999") // [],找不到返回空数组代码失效时
行政区划会调整:地区被撤销、合并,或者你的数据版本比存下来的旧。这时代码在数据里找不到。触发器不会空白,而是:
- 显示
fallbackLabel——调用方保存数据时一并存下的名称,用户看到的仍是当时选的地址; - 没传
fallbackLabel就显示「未知地区(330199)」。
<RegionPicker
regions={regions}
value={order.regionCode}
fallbackLabel={order.regionName}
/>用户重新打开选择,选了新的值,fallbackLabel 就不再用到。建议保存地址时同时存 code 和当时的名称。
搜索
搜索框在任意层级命中,结果显示完整路径并高亮命中部分:
| 输入 | 命中 |
|---|---|
杭州、西湖 | 名称包含 |
hangzhou、hang zhou | 全拼,去掉空格后匹配 |
hz | 拼音首字母 |
3301 | 代码前缀 |
杭州 西湖、浙江 杭州、hangzhou xihu | 带路径:最后一个词命中本级,其余的词命中本级或任一上级 |
排序:完全匹配与开头匹配优先,同级时层级浅的优先,再同级保持数据里的顺序;最多显示 50 条,超出时列表下方提示缩小范围。选中末级节点直接确认;选中非末级节点则跳到它的下级继续选。
输入拼音时,输入法组词阶段的回车与方向键不会被当作列表操作。
限制层级与选到中间级
depth={2}:最多选到第 2 级(省市),到达这一级的节点视为末级。allowPartial:允许不选到底。浏览到非顶层时,底部出现「选择「浙江省」」按钮,直接确认当前路径。
示例
手机上的体验
手机与触屏设备上,选择器从底部弹出面板。这个示例把一台按真机尺寸画的「手机」放在页面里(402 × 874pt,6.3 英寸标准款 iPhone 的逻辑分辨率):presentation="sheet" 固定用底部面板,container 让面板挂进手机屏幕、只盖住屏幕,在桌面上也能试分步选择、字母索引和点步骤回退。
"use client"
import * as React from "react"
import { BatteryFull, ChevronLeft, Signal, Wifi } from "lucide-react"
import { Button } from "@/components/ui/button"
import { RegionPicker } from "@/components/ui/region-picker/region-picker"
const loadRegions = () =>
import("@/lib/china-regions").then((m) => m.chinaRegions)
/** 文本框也做成方条:标签在条内左侧、内容靠右,和选择器排在一起是一套样式 */
const FIELD =
"flex h-9 items-center gap-3 rounded-md border border-edge bg-muted/60 px-3 text-sm pointer-coarse:h-11 focus-within:ring-3 focus-within:ring-ring/50"
const FIELD_INPUT =
"min-w-0 flex-1 bg-transparent text-right text-base outline-none placeholder:text-muted-foreground md:text-sm"
/**
* 手机上的体验:在桌面页面里放一台 6.3 英寸的「手机」(402 × 874pt,与当前标准款 iPhone 的逻辑分辨率一致),
* 选择器固定用底部面板,并挂进手机屏幕里,不用切换设备也能试分步选择、字母索引、点步骤回退。
*/
export default function RegionPickerMobileDemo() {
const screenRef = React.useRef<HTMLDivElement>(null)
const [code, setCode] = React.useState<string | null>(null)
return (
<div className="max-w-full rounded-[3.875rem] border border-edge bg-muted p-2 shadow-lg max-sm:contents">
{/* 屏幕:relative + overflow-clip,让底部面板和遮罩只盖住它;clip 不像 hidden 那样能被滚动。
页面比手机窄时(真在手机上看)随宽度收窄 */}
<div
ref={screenRef}
className="relative flex aspect-[402/874] w-[402px] max-w-full flex-col overflow-clip rounded-[3.375rem] bg-background max-sm:aspect-auto max-sm:h-[calc(100dvh-3rem)] max-sm:w-full max-sm:rounded-none"
>
{/* 状态栏 54pt,灵动岛 126 × 37pt;宽度按屏幕比例取,页面比手机窄时一起收窄 */}
<div className="relative flex h-[54px] shrink-0 items-center justify-between px-[8%] pt-1 text-[15px] font-semibold tabular-nums max-sm:hidden">
<span>9:41</span>
<span
aria-hidden="true"
className="absolute top-[11px] left-1/2 h-[37px] w-[31%] -translate-x-1/2 rounded-full bg-foreground dark:bg-background dark:ring-1 dark:ring-border"
/>
<span aria-hidden="true" className="flex items-center gap-1.5">
<Signal className="size-4" strokeWidth={2.5} />
<Wifi className="size-4" strokeWidth={2.5} />
<BatteryFull className="size-5" />
</span>
</div>
<div className="flex h-11 shrink-0 items-center gap-1 border-b border-border px-3">
<ChevronLeft
aria-hidden="true"
className="size-6 text-muted-foreground"
/>
<p className="text-[17px] font-semibold">新增收货地址</p>
</div>
<div className="flex flex-1 flex-col gap-2.5 px-4 pt-5">
<label className={FIELD}>
<span className="shrink-0 text-muted-foreground">收货人</span>
<input defaultValue="王小明" className={FIELD_INPUT} />
</label>
<label className={FIELD}>
<span className="shrink-0 text-muted-foreground">手机号</span>
<input
defaultValue="138 0000 0000"
inputMode="tel"
className={FIELD_INPUT}
/>
</label>
<RegionPicker
label="所在地区"
regions={loadRegions}
presentation="sheet"
container={screenRef}
size="lg"
value={code}
onValueChange={setCode}
/>
<label className={FIELD}>
<span className="shrink-0 text-muted-foreground">详细地址</span>
<input placeholder="街道、门牌号" className={FIELD_INPUT} />
</label>
</div>
{/* 底部留出 34pt 的主屏指示条安全区 */}
<div className="shrink-0 px-4 pt-3 pb-[34px] max-sm:pb-[max(1rem,env(safe-area-inset-bottom))]">
<Button type="button" size="lg" className="w-full">
保存地址
</Button>
</div>
{/* 主屏指示条盖在底部面板之上,和真机一样 */}
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-2 left-1/2 z-[60] h-[5px] w-[134px] -translate-x-1/2 rounded-full bg-foreground max-sm:hidden"
/>
</div>
</div>
)
}
表单提交
设了 name,表单提交的是最末一级的代码,未选时是空串;required 未选时拦住提交,浏览器提示「请选择地区」。非受控的表单 reset 会回到 defaultValue。
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { RegionPicker } from "@/components/ui/region-picker/region-picker"
const loadRegions = () =>
import("@/lib/china-regions").then((m) => m.chinaRegions)
/** 文本框也做成方条:标签在条内左侧、内容靠右,和选择器排在一起是一套样式 */
const FIELD =
"flex h-8 items-center gap-3 rounded-md border border-edge bg-muted/60 px-3 text-sm pointer-coarse:h-10 focus-within:ring-3 focus-within:ring-ring/50"
const FIELD_INPUT =
"min-w-0 flex-1 bg-transparent text-right text-base outline-none placeholder:text-muted-foreground md:text-sm"
/**
* 表单提交:设了 `name`,FormData 里是最末一级的代码("330106"),未选时是空串;
* `required` 未选会拦住提交,浏览器会在选择器上给出提示。
*/
export default function RegionPickerFormDemo() {
const [submitted, setSubmitted] = React.useState<{
consignee: string
region: string
address: string
} | null>(null)
return (
<form
className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs"
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
setSubmitted({
consignee: String(data.get("consignee")),
region: String(data.get("region")),
address: String(data.get("address")),
})
}}
>
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">收货地址</p>
<p className="truncate text-xs text-muted-foreground">
不选地区无法提交
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
required
</p>
</div>
<div className="flex flex-col gap-2">
<label className={FIELD}>
<span className="shrink-0 text-muted-foreground">收货人</span>
<input
name="consignee"
defaultValue="林小满"
className={FIELD_INPUT}
/>
</label>
<RegionPicker
label="所在地区"
name="region"
required
regions={loadRegions}
/>
<label className={FIELD}>
<span className="shrink-0 text-muted-foreground">详细地址</span>
<input
name="address"
placeholder="街道、门牌号"
className={FIELD_INPUT}
/>
</label>
</div>
<div className="mt-3 flex justify-end">
<Button type="submit">保存</Button>
</div>
{submitted && (
<dl className="mt-3 rounded-lg border border-dashed border-edge bg-muted/30 p-3 font-mono text-xs">
<dt className="text-muted-foreground">FormData</dt>
<dd>consignee = {JSON.stringify(submitted.consignee)}</dd>
<dd>region = {JSON.stringify(submitted.region)}</dd>
<dd>address = {JSON.stringify(submitted.address)}</dd>
</dl>
)}
</form>
)
}
限制层级与选到中间级
第一个只选到省市;第二个允许停在中间层级,展开后进入下级,底部能确认当前路径。
"use client"
import * as React from "react"
import { RegionPicker } from "@/components/ui/region-picker/region-picker"
const loadRegions = () =>
import("@/lib/china-regions").then((m) => m.chinaRegions)
/**
* `depth={2}` 只选到省市,到了第二级的节点就是末级;
* `allowPartial` 允许停在中间层级,浏览到上级后底部出现确认按钮。
*/
export default function RegionPickerDepthDemo() {
const [city, setCity] = React.useState<string | null>("440300")
const [area, setArea] = React.useState<string | null>("330000")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">门店设置</p>
<p className="truncate text-xs text-muted-foreground">
城市只选到市,服务范围可以停在省
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
depth · partial
</p>
</div>
<div className="flex flex-col gap-2">
<RegionPicker
label="所在城市"
regions={loadRegions}
depth={2}
value={city}
onValueChange={setCity}
/>
<RegionPicker
label="服务范围"
regions={loadRegions}
allowPartial
value={area}
onValueChange={setArea}
/>
</div>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 font-mono text-[11px] text-muted-foreground">
<dt>depth=2</dt>
<dd>value = {JSON.stringify(city)}</dd>
<dt>allowPartial</dt>
<dd>value = {JSON.stringify(area)}</dd>
</dl>
</div>
)
}
自己的数据
不必用官方数据。第一个只开通了几个城市,代码用自己的编号,部分节点没有 pinyin(搜索只按名称和代码);第二个演示代码失效:数据里没有 "330199",触发器显示调用方存下的 fallbackLabel。
"use client"
import * as React from "react"
import {
RegionPicker,
type RegionOption,
} from "@/components/ui/region-picker/region-picker"
/**
* 自己的数据:只开通了几个城市的配送范围。代码不必是国标码,pinyin 也可以不写
* (此时搜索只按名称和代码);层级深浅不一,选到没有 children 的节点即选完。
*/
const DELIVERY_REGIONS: RegionOption[] = [
{
code: "SH",
name: "上海",
pinyin: "shang hai",
children: [
{ code: "SH-PD", name: "浦东新区", pinyin: "pu dong xin qu" },
{ code: "SH-XH", name: "徐汇区", pinyin: "xu hui qu" },
{ code: "SH-MH", name: "闵行区", pinyin: "min hang qu" },
],
},
{
code: "HZ",
name: "杭州",
pinyin: "hang zhou",
children: [
{ code: "HZ-XH", name: "西湖区", pinyin: "xi hu qu" },
{ code: "HZ-BJ", name: "滨江区", pinyin: "bin jiang qu" },
],
},
{ code: "SZ", name: "苏州", pinyin: "su zhou" },
{
code: "CD",
name: "成都",
children: [
{ code: "CD-WH", name: "武侯区" },
{ code: "CD-JJ", name: "锦江区" },
],
},
]
/** 第二个选择器演示代码失效:数据里已经没有 "330199",触发器改显示调用方存下的名称。 */
export default function RegionPickerCustomDemo() {
const [code, setCode] = React.useState<string | null>("HZ-XH")
const [saved, setSaved] = React.useState<string | null>("330199")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">配送范围</p>
<p className="truncate text-xs text-muted-foreground">
自己的数据:只开通了四个城市
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
4 城
</p>
</div>
<div className="flex flex-col gap-2">
<RegionPicker
label="配送城市"
regions={DELIVERY_REGIONS}
placeholder="选择配送区域"
value={code}
onValueChange={setCode}
/>
<RegionPicker
label="历史地址"
regions={DELIVERY_REGIONS}
fallbackLabel="某某省 / 某某市(已撤销)"
value={saved}
onValueChange={setSaved}
/>
</div>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 font-mono text-[11px] text-muted-foreground">
<dt>配送城市</dt>
<dd>value = {JSON.stringify(code)}</dd>
<dt>代码已失效</dt>
<dd>value = {JSON.stringify(saved)}</dd>
</dl>
</div>
)
}
界面与适配
交互取级联选择最常见的三点:分步、选完自动前进、点已完成的步骤回退。
-
触发器是一条方条,和方条开关、分段选择、数值滑块同一套外观与 28 / 32 / 36px 三档高度(触屏设备上 36 / 40 / 44px)。传
label时标签写在条内左侧、路径靠右,放进同一张设置卡里能对齐;不传时条内只有路径,标签照常写在外面。 -
手机与触屏设备(视口窄于 640px,或
pointer: coarse):从底部弹出面板,高 85% 视口。- 顶部标题栏:「选择地区」、清除(有值且非必填时)、关闭。
- 下面是竖排步骤条:①省份 ②城市 ③区县,每步一行 44px;已完成的打勾、可点回去,当前步垫底色,后面的步骤灰显。
- 选项单列整行,每行 44px、14px 字;超过 16 项时按拼音首字母分组,组标题吸顶并横贯到右侧的字母索引条下面;索引条跟着滚动高亮当前字母,点一下跳到对应字母。
- 搜索框不自动聚焦,免得软键盘弹出来盖住列表;底部留出安全区。
-
桌面:输入框下方弹出浮层,宽 30rem(不小于触发器、不超出视口)。
- 步骤条横排在顶部,搜索框打开即聚焦。
- 选项是带边框的紧凑按钮;分组时按字母两栏排布。
- 内容区固定 16rem 高,超出滚动。每一步选项多少不一,高度固定后浮层不会在输入框上下方之间来回翻转。
-
用
presentation固定形态("sheet"/"popover"),默认"auto"按上面的规则自动切换;container把底部面板挂进指定元素,只盖住它、不锁整页滚动,适合预览窗格、手机外框这类局部区域。 -
步骤数一开始就显示:数据最深三级就显示三步;选了直辖市后剩两步,第二步名称自动对齐为「区县」(
labels.levels从最深一级往上对齐)。 -
选完一步自动进入下一步,最后一步选完即确认并关闭;换步骤时列表向前或向后滑入,系统开启「减弱动态效果」时不播放。
-
搜索时步骤条暂时隐藏,结果单列显示完整路径与代码。
API
boolean | undefined地区数据。传数组直接使用(请保持引用稳定); 传函数则惰性加载:首次打开浮层、或有初始值需要显示路径时才调用,且只调用一次。
类型RegionsSource已选地区的代码(最末一级);null 表示未选。受控时传入
string | null | undefined非受控时的初始代码
类型string | null | undefinednull选中变化时回调:最末一级代码(清除时为 null)与从最顶层到它的完整路径
((code: string | null, path: RegionOption[]) => void) | undefined最多选到第几级(从 1 数);到达这一级的节点视为末级,不再展开下级
类型number | undefined是否允许选中非末级节点就确认(如只选到「浙江省」); 开启后浏览到非顶层时底部出现确认按钮
类型boolean | undefinedfalse随表单提交的字段名,提交的是最末一级的代码,未选时为空串
类型string | undefined关联到另一个表单的 id,同 <input form>
string | undefined未选时拦住表单提交
类型boolean | undefined写在触发器条内左侧的标签,如「所在地区」;选中的路径随之靠右。 不传时触发器里只有路径,标签可以照常写在外面
类型React.ReactNode错误提示:显示在触发器下方,触发器变红并带 aria-invalid,读屏聚焦时一并朗读。
业务校验(比如「该地区暂不配送」)自己判断后传入
React.ReactNode未选时触发器显示的提示,默认取 labels.placeholder
string | undefined代码在数据里找不到时(地区被撤销、数据版本不同)触发器显示的名称, 通常是调用方保存数据时一并存下的名称;不传则显示「未知地区(代码)」
类型string | undefined触发器高度档位:28 / 32 / 36px
类型RegionPickerSize | undefined"default"选择面板的形态:auto 在手机与触屏设备上用底部面板、桌面用下拉浮层;
sheet / popover 固定其中一种
"auto" | "popover" | "sheet" | undefined"auto"底部面板挂载到哪个元素里,默认 document.body。
传入后面板只盖住这个元素(元素需要 position: relative 与 overflow: clip,用 clip 而不是 hidden,元素就不会被滚动),也不锁定整页滚动;
用于把选择器放进手机外框、预览窗格这类局部区域
HTMLElement | React.RefObject<HTMLElement | null> | null | undefined浮层是否打开,受控时传入
类型boolean | undefined非受控时浮层初始是否打开
类型boolean | undefinedfalse((open: boolean) => void) | undefined覆盖用户可见文案
类型Partial<{ placeholder: string; title: string; close: string; searchPlaceholder: string; searchLabel: string; levels: string[]; stepFallback: string; stepPending: string; stepsNav: string; levelStatus: string; resultStatus: string; resultTruncated: string; letterIndex: string; empty: string; loading: string; loadFailed: string; retry: string; clear: string; confirmPartial: string; confirmIdle: string; unknown: string; required: string; }> | undefinedclassName 作用于根元素,其余未列出的属性(id、aria-label、aria-invalid、onBlur……)交给触发器按钮,ref 指向触发器。name 与 form 加在一个覆盖在触发器上、不可见的文本输入上(data-slot="region-picker-value"),它提交最末一级的代码,并让 required 参与浏览器的表单校验。
labels
type RegionPickerLabels = {
placeholder: string // "请选择省 / 市 / 区"
title: string // "选择地区",手机底部面板的标题
close: string // "关闭"
searchPlaceholder: string // "搜索名称、拼音或首字母"
searchLabel: string // "搜索地区",搜索框与列表的读屏名称
levels: string[] // ["省份", "城市", "区县", "乡镇"],各步名称,从最深一级往上对齐
stepFallback: string // "第 {step} 级",数据比 levels 更深时的步骤名
stepPending: string // "请选择{level}",当前步的占位
stepsNav: string // "选择步骤",步骤条的读屏名称
levelStatus: string // "第 {step} 步,选择{level},共 {count} 项",读屏播报
resultStatus: string // "找到 {count} 个结果",读屏播报
resultTruncated: string // "仅显示前 {limit} 条,输入更多字符可缩小范围"
letterIndex: string // "按拼音首字母跳转",手机字母索引条的读屏名称
empty: string // "没有匹配的地区"
loading: string // "正在加载地区数据…"
loadFailed: string // "地区数据加载失败"
retry: string // "重试"
clear: string // "清除"
confirmPartial: string // "确定:{name}",allowPartial 时底部的确认按钮
confirmIdle: string // "确定",allowPartial 但还没有可确认的路径时(按钮置灰)
unknown: string // "未知地区",代码找不到且没有 fallbackLabel 时,后面自动加(代码)
required: string // "请选择地区",必填未选时的校验提示
}工具函数
都在 region-picker/utils.ts,没有 "use client",服务端组件和 Route Handler 里可以直接用。
// 按代码找完整路径,找不到返回 []
function findRegionPath(regions: RegionOption[], code: string): RegionOption[]
// 路径转文字,默认用 " / " 连接
function formatRegionPath(path: RegionOption[], separator?: string): string
// 一次性搜索,每次调用都重建索引;返回命中节点、完整路径、各级高亮区间
function searchRegions(
regions: RegionOption[],
query: string,
options?: { depth?: number; limit?: number }, // limit 默认 50
): RegionSearchResult[]
// 高频搜索:先建一次索引,再反复查
function createRegionIndex(regions: RegionOption[], options?: { depth?: number }): RegionIndex
function searchRegionIndex(index: RegionIndex, query: string, limit?: number): {
results: RegionSearchResult[]
total: number // 命中总数,results 最多 limit 条
}searchRegions 的用法:做一个自己的联想输入,或者在服务端按关键字查地区。
import { searchRegions } from "@/components/ui/region-picker/utils"
const [first] = searchRegions(regions, "hz xh")
first.path.map((node) => node.name) // ["浙江省", "杭州市", "西湖区"]类型
type RegionPickerSize = "sm" | "default" | "lg"
type RegionsSource = RegionOption[] | (() => Promise<RegionOption[]>)
type RegionPickerProps = {
regions: RegionsSource
value?: string | null
defaultValue?: string | null
onValueChange?: (code: string | null, path: RegionOption[]) => void
depth?: number
allowPartial?: boolean
name?: string
form?: string
required?: boolean
disabled?: boolean
label?: React.ReactNode
error?: React.ReactNode
placeholder?: string
fallbackLabel?: string
size?: RegionPickerSize
presentation?: "auto" | "sheet" | "popover"
container?: HTMLElement | React.RefObject<HTMLElement | null> | null
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
labels?: Partial<RegionPickerLabels>
} & Omit<React.ComponentProps<"button">, /* 以上已定义的属性 */ "value" | "defaultValue" | "onChange" | "type" | "children">data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | region-picker |
| 触发器 | region-picker-trigger |
| 触发器里的标签 / 路径文字 | region-picker-label / region-picker-value-text |
| 提交与校验用的隐藏输入 | region-picker-value |
面板内容(带 data-variant="sheet" 或 "popover") | region-picker-panel |
| 手机底部面板 / 遮罩 | region-picker-sheet / region-picker-backdrop |
| 搜索框 | region-picker-search |
步骤条 / 单个步骤(带 data-state:done / current / todo) | region-picker-steps / region-picker-step |
| 拼音首字母分组 / 手机字母索引条 | region-picker-group / region-picker-index |
| 选项列表 / 单项 | region-picker-list / region-picker-option |
| 搜索命中高亮 | region-picker-mark |
| 加载中 / 出错 / 无结果 | region-picker-notice |
| 结果被截断的提示 | region-picker-hint |
| 错误提示 | region-picker-error |
| 底部操作区 | region-picker-footer |
根元素带 data-size、data-disabled,出错时带 data-invalid;选项带 data-active(键盘或鼠标高亮)与 data-selected(在已选路径上);浮层打开时触发器带 data-popup-open。
可访问性
- 搜索框是 combobox:焦点始终留在输入框,选项通过
aria-activedescendant表达高亮,选项是role="option",列表是role="listbox"。 - 键盘:
↑↓逐项移动,搜索框为空时←→也能逐项移动;Enter选择;Backspace在搜索框为空时回到上一步;Esc关闭并把焦点还给触发器。 - 屏幕阅读器:每次换步骤播报「第 2 步,选择城市,共 21 项」,搜索时播报「找到 12 个结果」,通过隐藏的
role="status"区域;步骤条是有序列表,当前步带aria-current="step";手机面板是带标题的对话框,打开时焦点落在关闭按钮上。 - 触发器是
button,带aria-haspopup、aria-expanded;传了label时可访问名称就是条内的文字(「所在地区 浙江省 / 杭州市 / 西湖区」);不传时用<label htmlFor>或aria-label命名。 - 焦点环、选项高亮都有可见样式;已选项除了底色,还有对勾(末级)或箭头(非末级)。
注意事项
- 触发器是个
button,不是文本输入;required的原生气泡提示挂在覆盖其上的隐藏输入,聚焦会转交给触发器。 - 受控模式下记得同时处理
onValueChange,否则选择后触发器不更新。 - 业务校验(比如「该地区暂不配送」)自己判断后传
error:文字显示在触发器下方,触发器变红并带aria-invalid,读屏聚焦时一并朗读。和金额输入的error是同一种做法。 depth小于已选值所在的层级时,值照常回显完整路径,只是浮层里到不了更深的节点。- 约 3000 条数据的索引构建与单次搜索都在毫秒级,不需要防抖;数据达到几十万条时请自己换服务端搜索。
源码
"use client"
import * as React from "react"
import { Dialog } from "@base-ui/react/dialog"
import { ChevronDown, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { DEFAULT_LABELS, SHEET_QUERY } from "./config"
import { RegionPickerPanel } from "./region-picker-panel"
import { regionPickerTriggerVariants } from "./region-picker-parts"
import type { RegionOption, RegionPickerProps } from "./types"
import { createRegionIndex, formatRegionPath } from "./utils"
const EMPTY_PATH: RegionOption[] = []
// 底部面板的遮罩:用半透明的背景色把后面的页面淡化,亮色下是白雾、暗色下是暗层,随主题走
const SCRIM = "bg-background/70 backdrop-blur-xs"
/** 订阅媒体查询;服务端与首帧按桌面渲染,只影响打开后的容器,触发器两边一致 */
function useMediaQuery(query: string) {
return React.useSyncExternalStore(
(onChange) => {
const list = window.matchMedia(query)
list.addEventListener("change", onChange)
return () => list.removeEventListener("change", onChange)
},
() => window.matchMedia(query).matches,
() => false,
)
}
/**
* 省市区选择:触发器 + 分步选择面板(①省份 ②城市 ③区县,支持拼音搜索)。
* 手机与触屏设备从底部弹出面板,桌面在输入框下方弹出浮层。
* 数据通过 `regions` 传入,函数形式会惰性加载。
*/
function RegionPicker({
regions,
value: valueProp,
defaultValue = null,
onValueChange,
depth,
allowPartial = false,
name,
form,
required = false,
disabled,
label,
error,
placeholder,
fallbackLabel,
title: titleProp,
size = "default",
presentation = "auto",
container,
open: openProp,
defaultOpen = false,
onOpenChange,
labels: labelsProp,
className,
ref,
...props
}: RegionPickerProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp }
const maxDepth =
depth === undefined ? Infinity : Math.max(1, Math.floor(depth))
const triggerRef = React.useRef<HTMLButtonElement>(null)
React.useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement)
const searchRef = React.useRef<HTMLInputElement>(null)
const closeRef = React.useRef<HTMLButtonElement>(null)
const narrow = useMediaQuery(SHEET_QUERY)
const sheet = presentation === "sheet" || (presentation === "auto" && narrow)
// 挂进指定容器时(如文档里的手机外框),面板只盖住容器、不锁整页滚动
const contained = container !== undefined
const proxyRef = React.useRef<HTMLInputElement>(null)
const [open, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
// 惰性加载:浮层第一次打开、或已有值需要显示路径时才调用,且只调用一次。
// 加载函数放进 ref,调用方每次渲染都写内联箭头函数也不会重复请求。
const [lazyData, setLazyData] = React.useState<RegionOption[] | null>(null)
const [failed, setFailed] = React.useState(false)
const [attempt, setAttempt] = React.useState(0)
const loaderRef = React.useRef(regions)
const requestRef = React.useRef<Promise<RegionOption[]> | null>(null)
React.useEffect(() => {
loaderRef.current = regions
})
// 受控与非受控都要拿到当前值,下面的 onChange 里用它反查路径
const indexRef = React.useRef<ReturnType<typeof createRegionIndex> | null>(
null,
)
const [code, setCode] = useControllableState<string | null>({
prop: valueProp,
defaultProp: defaultValue,
onChange: (next) =>
onValueChange?.(
next,
(next && indexRef.current?.byCode.get(next)) || EMPTY_PATH,
),
})
const lazy = typeof regions === "function"
const wantsData = lazy && (open || code !== null)
React.useEffect(() => {
if (!wantsData || lazyData) return
const loader = loaderRef.current
if (typeof loader !== "function") return
const request = (requestRef.current ??= loader())
let stale = false
request.then(
(data) => {
if (!stale) setLazyData(data)
},
() => {
if (!stale) setFailed(true)
},
)
return () => {
stale = true
}
}, [wantsData, lazyData, attempt])
const retry = () => {
requestRef.current = null
setFailed(false)
setAttempt((count) => count + 1)
}
const data = lazy ? lazyData : regions
// 数据一到就建一次索引(搜索、回显路径都靠它),数据与 depth 不变就不重建
const index = React.useMemo(
() => (data ? createRegionIndex(data, { depth: maxDepth }) : null),
[data, maxDepth],
)
React.useEffect(() => {
indexRef.current = index
})
const valuePath = (code !== null && index?.byCode.get(code)) || EMPTY_PATH
const status = index ? "ready" : failed ? "error" : "loading"
// 触发器文字:找得到就显示路径;加载中或代码失效时用调用方存的名称兜底
let text: React.ReactNode = null
let title: string | undefined
if (code !== null) {
if (valuePath.length) {
title = formatRegionPath(valuePath)
// 放不下时只截上级,最末一级(真正选中的那个)始终完整显示
const last = valuePath[valuePath.length - 1]
const parents = valuePath.slice(0, -1)
const sep = <span className="text-muted-foreground/60"> / </span>
text = (
<>
{parents.length > 0 && (
<span className="min-w-0 truncate">
{parents.map((node, i) => (
<React.Fragment key={node.code}>
{i > 0 && sep}
{node.name}
</React.Fragment>
))}
</span>
)}
{parents.length > 0 && (
<span className="shrink-0 whitespace-pre">{sep}</span>
)}
<span className="shrink-0">{last.name}</span>
</>
)
} else {
title =
fallbackLabel ??
(status === "loading" && lazy
? labels.loading
: `${labels.unknown}(${code})`)
text = title
}
}
const commit = (path: RegionOption[]) => {
setCode(path.length ? path[path.length - 1].code : null)
setOpen(false)
}
// 表单:同名的隐藏输入负责提交代码;它是可校验的文本输入,所以 required 能拦住提交、
// 浏览器的气泡提示也有地方挂。覆盖在触发器上,聚焦时转交给触发器。
React.useEffect(() => {
proxyRef.current?.setCustomValidity(
required && code === null ? labels.required : "",
)
}, [required, code, labels.required])
// 非受控的表单重置回到初始值;reset 事件在表单清空之前触发,等一帧再覆盖
const controlled = valueProp !== undefined
React.useEffect(() => {
const element = proxyRef.current?.form
if (!element || controlled) return
const reset = () => window.setTimeout(() => setCode(defaultValue))
element.addEventListener("reset", reset)
return () => element.removeEventListener("reset", reset)
}, [controlled, defaultValue, setCode])
const errorId = React.useId()
const invalid =
Boolean(error) ||
props["aria-invalid"] === true ||
props["aria-invalid"] === "true"
const describedBy =
[props["aria-describedby"], error ? errorId : undefined]
.filter(Boolean)
.join(" ") || undefined
const triggerProps = {
...props,
"aria-invalid": invalid || undefined,
"aria-describedby": describedBy,
ref: triggerRef,
"data-slot": "region-picker-trigger",
disabled,
title: title ?? titleProp,
"aria-busy": code !== null && !index && !failed && lazy ? true : undefined,
className: regionPickerTriggerVariants({ size }),
children: (
<>
{label && (
<span
data-slot="region-picker-label"
className="shrink-0 text-muted-foreground"
>
{label}
</span>
)}
<span
data-slot="region-picker-value-text"
className={cn(
"flex min-w-0 flex-1",
// 有条内标签时值靠右,与方条开关、数值滑块的「左标签右数值」一致
label && "justify-end",
code === null && "text-muted-foreground",
)}
>
{typeof text === "string" || code === null ? (
<span className="min-w-0 truncate">
{code === null ? (placeholder ?? labels.placeholder) : text}
</span>
) : (
text
)}
</span>
<ChevronDown
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
</>
),
}
const panel = (variant: "sheet" | "popover") => (
<RegionPickerPanel
variant={variant}
index={index}
status={status}
labels={labels}
value={code}
valuePath={valuePath}
depth={maxDepth}
allowPartial={allowPartial}
required={required}
inputRef={searchRef}
onRetry={retry}
onCommit={commit}
/>
)
return (
<div
data-slot="region-picker"
data-size={size}
data-invalid={invalid ? "" : undefined}
data-disabled={disabled ? "" : undefined}
className={cn("relative w-full", className)}
>
{sheet ? (
<Dialog.Root
open={open}
onOpenChange={(next) => setOpen(next)}
modal={contained ? "trap-focus" : true}
>
<Dialog.Trigger {...triggerProps} />
<Dialog.Portal container={container}>
<Dialog.Backdrop
data-slot="region-picker-backdrop"
className={cn(
`inset-0 z-50 ${SCRIM} transition-opacity duration-[320ms] data-ending-style:opacity-0 data-starting-style:opacity-0`,
contained ? "absolute" : "fixed",
)}
/>
<Dialog.Popup
data-slot="region-picker-sheet"
// 不自动聚焦搜索框,免得软键盘弹出来盖住列表;焦点先落在关闭按钮上
// 聚焦时 preventScroll:面板起点在容器下方之外,默认的 focus 会把带
// overflow 的容器整体滚上去,再随面板上滑滚回来,看起来像遮罩从上面落下
initialFocus={() => {
closeRef.current?.focus({ preventScroll: true })
return false
}}
className={cn(
"inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-xl flex-col rounded-t-xl border-t bg-popover text-sm text-popover-foreground shadow-lg transition-transform duration-[320ms] ease-[cubic-bezier(0.32,0.72,0,1)] outline-none data-ending-style:translate-y-full data-ending-style:duration-200 data-ending-style:ease-in data-starting-style:translate-y-full motion-reduce:transition-none sm:border-x",
contained ? "absolute h-[85%]" : "fixed h-[85dvh]",
)}
>
<div className="flex h-12 shrink-0 items-center justify-between gap-1 pr-2 pl-4">
<Dialog.Title className="text-base font-medium">
{labels.title}
</Dialog.Title>
{/* 标题栏里的按钮同一档 lg:清除与关闭并排 */}
{code !== null && !required && (
<Button
type="button"
variant="ghost"
size="lg"
className="ml-auto text-muted-foreground"
onClick={() => commit(EMPTY_PATH)}
>
{labels.clear}
</Button>
)}
<Dialog.Close
ref={closeRef}
aria-label={labels.close}
render={
<Button type="button" variant="ghost" size="icon-lg" />
}
>
<X aria-hidden="true" />
</Dialog.Close>
</div>
{panel("sheet")}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
) : (
<Popover open={open} onOpenChange={(next) => setOpen(next)}>
<PopoverTrigger {...triggerProps} />
<PopoverContent
align="start"
className="w-[30rem] max-w-[calc(100vw-1rem)] min-w-(--anchor-width) gap-0 p-0"
initialFocus={searchRef}
>
{panel("popover")}
</PopoverContent>
</Popover>
)}
<input
ref={proxyRef}
data-slot="region-picker-value"
tabIndex={-1}
aria-hidden="true"
autoComplete="off"
name={name}
form={form}
required={required}
disabled={disabled}
value={code ?? ""}
onChange={() => {}}
onFocus={() => triggerRef.current?.focus()}
// 只盖住触发器那一行,原生的必填气泡指向触发器而不是下面的错误文字
className="pointer-events-none absolute inset-x-0 top-0 h-full max-h-11 w-full opacity-0"
/>
{error && (
<p
id={errorId}
data-slot="region-picker-error"
className="mt-1.5 text-xs text-destructive"
>
{error}
</p>
)}
</div>
)
}
export { RegionPicker, regionPickerTriggerVariants }
export type {
RegionOption,
RegionPickerProps,
RegionPickerSize,
RegionsSource,
} from "./types"
"use client"
import * as React from "react"
import { Search } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { GROUP_THRESHOLD, type RegionPickerLabels } from "./config"
import {
RegionPickerHighlight,
RegionPickerNotice,
RegionPickerOption,
RegionPickerSteps,
type RegionPickerStep,
type RegionPickerVariant,
} from "./region-picker-parts"
import type { RegionOption } from "./types"
import {
formatRegionPath,
groupRegionsByInitial,
isLeafRegion,
regionTreeDepth,
searchRegionIndex,
type RegionGroup,
type RegionHighlight,
type RegionIndex,
} from "./utils"
type Row = {
node: RegionOption
/** 从 0 数的层级 */
level: number
/** 从最顶层到该节点 */
path: RegionOption[]
highlights?: (RegionHighlight | null)[]
}
const EMPTY_PATH: RegionOption[] = []
/**
* 只滚动列表自身,把 `element` 移进可视区。
* 不用 scrollIntoView:它会连带滚动整个页面,浮层跟着触发器一起上下跳。
*/
function scrollWithin(
list: HTMLElement,
element: HTMLElement,
block: "start" | "center" | "nearest",
) {
const listRect = list.getBoundingClientRect()
const rect = element.getBoundingClientRect()
const top = rect.top - listRect.top + list.scrollTop
if (block === "start") list.scrollTop = top
else if (block === "center")
list.scrollTop = top - (list.clientHeight - rect.height) / 2
// 上方可能有吸顶的字母标题,往上滚时多留一行的位置
else if (rect.top < listRect.top + 28) list.scrollTop = top - 28
else if (rect.bottom > listRect.bottom)
list.scrollTop = top + rect.height - list.clientHeight
}
const fill = (template: string, values: Record<string, string | number>) =>
template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ""))
/**
* 面板内容:步骤条 → 搜索框 → 当前这一步的选项(或搜索结果)→ 底部操作。
* 选完一步自动进入下一步,最后一步选完即确认;点已完成的步骤回到那一步重选。
* 浮层 / 面板关闭时整体卸载,所以浏览位置、搜索词每次打开都是全新的。
*/
function RegionPickerPanel({
variant,
index,
status,
labels,
value,
valuePath,
depth,
allowPartial,
required,
inputRef,
onRetry,
onCommit,
}: {
variant: RegionPickerVariant
/** 数据没加载好时为 null */
index: RegionIndex | null
status: "loading" | "error" | "ready"
labels: RegionPickerLabels
value: string | null
valuePath: RegionOption[]
depth: number
allowPartial: boolean
required: boolean
inputRef: React.RefObject<HTMLInputElement | null>
onRetry: () => void
/** 确认选择;空数组表示清除 */
onCommit: (path: RegionOption[]) => void
}) {
const sheet = variant === "sheet"
const baseId = React.useId()
const listId = `${baseId}-list`
const scrollRef = React.useRef<HTMLDivElement>(null)
const [query, setQuery] = React.useState("")
// null = 还没手动浏览过,跟随当前值所在的那一步;这样懒加载的数据到了之后也能自动定位
const [trailState, setTrailState] = React.useState<RegionOption[] | null>(
null,
)
// 前进从右滑入、后退从左滑入,让人感觉到「下一步 / 上一步」
// null = 刚打开,列表不做横向动画,只跟着面板整体出现,免得两个方向的动作叠在一起
const [direction, setDirection] = React.useState<"forward" | "back" | null>(
null,
)
const defaultTrail = React.useMemo(
() =>
valuePath.slice(
0,
Math.max(0, Math.min(valuePath.length - 1, depth - 1)),
),
[valuePath, depth],
)
const trail = trailState ?? defaultTrail
const level = trail.length
const roots = index?.roots ?? EMPTY_PATH
const options = trail.length
? (trail[level - 1].children ?? EMPTY_PATH)
: roots
// 当前这一步里属于已选路径的那一项(上级路径一致时才算)
const selectedHere =
valuePath.length > level &&
trail.every((node, i) => node.code === valuePath[i].code)
? valuePath[level]
: undefined
// ---------- 步骤条 ----------
// 全局最深几级,与当前这条路径最深几级;名称从最深一级往上对齐(直辖市第二步叫「区县」)
const totalSteps = Math.min(depth, regionTreeDepth(roots))
const stepCount = Math.max(
level + 1,
Math.min(depth, level + regionTreeDepth(options)),
)
const stepLabel = (step: number) => {
const at = step === 0 ? 0 : step + Math.max(0, totalSteps - stepCount)
return labels.levels[at] ?? fill(labels.stepFallback, { step: step + 1 })
}
const steps: RegionPickerStep[] = Array.from(
{ length: stepCount },
(_, step) => {
const label = stepLabel(step)
if (step < level) return { label, value: trail[step].name, state: "done" }
if (step === level)
return {
label,
value:
selectedHere?.name ?? fill(labels.stepPending, { level: label }),
state: "current",
}
return { label, state: "todo" }
},
)
// ---------- 列表 ----------
const searching = query.trim() !== ""
const search = React.useMemo(
() => (index && searching ? searchRegionIndex(index, query) : null),
[index, query, searching],
)
// 选项多时像通讯录一样按拼音首字母分组;数据没拼音就保持原顺序
const groups = React.useMemo<RegionGroup[] | null>(
() =>
options.length >= GROUP_THRESHOLD ? groupRegionsByInitial(options) : null,
[options],
)
const rows = React.useMemo<Row[]>(() => {
if (search)
return search.results.map(({ node, level, path, highlights }) => ({
node,
level,
path,
highlights,
}))
const ordered = groups ? groups.flatMap((group) => group.items) : options
return ordered.map((node) => ({ node, level, path: [...trail, node] }))
}, [search, groups, options, trail, level])
// 列表一换(进入下一步、搜索词变化),高亮项回到已选项或第一项
const listKey = searching
? `s:${query}`
: `b:${trail.map((node) => node.code).join(",")}`
const selectedCode = searching ? value : selectedHere?.code
const selectedAt = rows.findIndex((row) => row.node.code === selectedCode)
const fallbackActive = rows.length ? Math.max(0, selectedAt) : -1
const [activeState, setActiveState] = React.useState({
key: listKey,
index: fallbackActive,
})
const active = Math.min(
activeState.key === listKey ? activeState.index : fallbackActive,
rows.length - 1,
)
const setActive = (next: number) =>
setActiveState({ key: listKey, index: next })
const optionId = (i: number) => `${baseId}-opt-${i}`
const activeId = active >= 0 ? optionId(active) : undefined
// 进入新一步时把已选项滚到中间(重新打开能直接看到上次的选择),键盘移动时只滚进视野
const lastKey = React.useRef<string | null>(null)
React.useEffect(() => {
if (!activeId) return
const element = document.getElementById(activeId)
if (!element) return
const fresh = lastKey.current !== listKey
lastKey.current = listKey
if (fresh && selectedAt < 0) {
scrollRef.current?.scrollTo({ top: 0 })
return
}
if (scrollRef.current)
scrollWithin(scrollRef.current, element, fresh ? "center" : "nearest")
}, [activeId, listKey, selectedAt])
const choose = (row: Row) => {
if (isLeafRegion(row.node, row.level, depth)) return onCommit(row.path)
setDirection("forward")
setTrailState(row.path)
setQuery("")
// 手机上不主动聚焦搜索框,免得软键盘弹出来挡住列表
if (!sheet) inputRef.current?.focus()
}
const goTo = (step: number) => {
setDirection("back")
setTrailState(trail.slice(0, step))
setQuery("")
if (!sheet) inputRef.current?.focus()
}
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
// 拼音输入法组词时的方向键、回车是在选候选词,不能当作列表操作
if (event.nativeEvent.isComposing) return
const count = rows.length
switch (event.key) {
case "ArrowDown":
if (!count) return
event.preventDefault()
setActive(Math.min(count - 1, active + 1))
return
case "ArrowUp":
if (!count) return
event.preventDefault()
setActive(Math.max(0, active - 1))
return
// 左右键平时是移动光标,搜索框为空时才用来在选项间移动
case "ArrowRight":
if (!count || query) return
event.preventDefault()
setActive(Math.min(count - 1, active + 1))
return
case "ArrowLeft":
if (!count || query) return
event.preventDefault()
setActive(Math.max(0, active - 1))
return
case "Enter":
event.preventDefault()
if (active >= 0) choose(rows[active])
return
case "Backspace":
if (!query && trail.length) {
event.preventDefault()
goTo(trail.length - 1)
}
return
}
}
const ready = status === "ready" && index !== null
const announcement = !ready
? status === "error"
? labels.loadFailed
: labels.loading
: searching
? search && search.total
? fill(labels.resultStatus, { count: search.total })
: labels.empty
: fill(labels.levelStatus, {
step: level + 1,
level: stepLabel(level),
count: rows.length,
})
// 允许选中间级时「确定」常驻,没有可确认的路径就置灰:按钮时有时无会让面板高度跳动
const showConfirm = allowPartial && ready
const canConfirm = showConfirm && !searching && trail.length > 0
// 底部面板的「清除」放在标题栏(由外层渲染),这里只管浮层
const showClear = !sheet && value !== null && !required
// ---------- 渲染 ----------
// 内容区高度固定:面板里撑满剩余空间;浮层里固定 16rem。
// 浮层高度一旦随每一步的选项数变化,定位就会在输入框上方、下方之间来回翻,看起来像在抖
const bodyClass = sheet ? "flex-1" : "h-64"
const renderOption = (
row: Row,
i: number,
layout: "row" | "chip",
className?: string,
) => (
<RegionPickerOption
key={row.path.map((node) => node.code).join("/")}
className={className}
id={optionId(i)}
title={formatRegionPath(row.path)}
variant={variant}
layout={layout}
active={i === active}
selected={row.node.code === selectedCode}
branch={!isLeafRegion(row.node, row.level, depth)}
onSelect={() => choose(row)}
onHover={() => i !== active && setActive(i)}
>
{searching ? (
<>
<span className="min-w-0 flex-1 truncate">
{row.path.map((node, at) => (
<React.Fragment key={node.code}>
{at > 0 && (
<span className="text-muted-foreground/60">{" / "}</span>
)}
<span className={cn(at < row.level && "text-muted-foreground")}>
<RegionPickerHighlight
text={node.name}
range={row.highlights?.[at]?.name}
/>
</span>
</React.Fragment>
))}
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
<RegionPickerHighlight
text={row.node.code}
range={row.highlights?.[row.level]?.code}
/>
</span>
</>
) : (
<span className="min-w-0 truncate">{row.node.name}</span>
)}
</RegionPickerOption>
)
// 浏览时的选项:面板里整行单列,浮层里是一格格按钮;分组时每组前面标字母
// 每组第一项在 rows 里的序号,渲染时据此拼出选项 id
const groupStarts = groups
? groups.map((_, g) =>
groups.slice(0, g).reduce((sum, group) => sum + group.items.length, 0),
)
: []
const browseList = groups ? (
// 浮层里两栏排布:多数字母下只有一两个地名,单栏会留下大片空白
<div className={cn(!sheet && "gap-x-3 sm:columns-2")}>
{groups.map((group, g) => {
const start = groupStarts[g]
const groupId = `${baseId}-group-${group.letter}`
return (
<div
key={group.letter}
role="group"
aria-labelledby={groupId}
data-slot="region-picker-group"
data-group-letter={group.letter}
className={cn(
sheet
? ""
: "grid break-inside-avoid grid-cols-[1.25rem_minmax(0,1fr)] items-start gap-x-1.5 py-1",
)}
>
<div
id={groupId}
data-letter={group.letter}
className={cn(
"font-mono text-xs font-medium text-muted-foreground",
sheet
? "sticky top-0 z-10 bg-muted px-4 py-1"
: "flex h-8 items-center justify-center",
)}
>
{group.letter}
</div>
<div className={cn(sheet ? "" : "flex flex-wrap gap-1.5")}>
{group.items.map((node, i) =>
renderOption(
rows[start + i],
start + i,
sheet ? "row" : "chip",
// 右侧给字母索引条留出位置,箭头和对勾不被它压住
sheet ? "pr-9" : undefined,
),
)}
</div>
</div>
)
})}
</div>
) : (
<div
className={cn(
sheet ? "" : "grid grid-cols-2 gap-1.5 py-1 sm:grid-cols-3",
)}
>
{rows.map((row, i) => renderOption(row, i, sheet ? "row" : "chip"))}
</div>
)
const groupElement = (letter: string) =>
scrollRef.current?.querySelector<HTMLElement>(
`[data-group-letter="${letter}"]`,
)
const jumpTo = (letter: string) => {
const group = groupElement(letter)
if (group && scrollRef.current)
scrollWithin(scrollRef.current, group, "start")
}
// 字母栏跟着列表滚动高亮当前所在的字母,让两者连成一体;换一步时回到第一组
const [spy, setSpy] = React.useState<{ key: string; letter: string } | null>(
null,
)
const spyGroups = sheet && !searching ? groups : null
const currentLetter =
spy?.key === listKey ? spy.letter : (spyGroups?.[0]?.letter ?? null)
const onListScroll = () => {
const list = scrollRef.current
if (!list || !spyGroups) return
const top = list.getBoundingClientRect().top
let letter = spyGroups[0].letter
for (const group of spyGroups) {
const element = groupElement(group.letter)
if (!element || element.getBoundingClientRect().top - top > 1) break
letter = group.letter
}
if (letter !== currentLetter) setSpy({ key: listKey, letter })
}
return (
<div
data-slot="region-picker-panel"
data-variant={variant}
className={cn("flex min-h-0 flex-col", sheet ? "flex-1" : "gap-2 py-2.5")}
>
{ready && !searching && (
<RegionPickerSteps
steps={steps}
variant={variant}
ariaLabel={labels.stepsNav}
onSelect={goTo}
/>
)}
<div className={cn("relative", sheet ? "px-4 py-2" : "px-2.5")}>
<Search
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 text-muted-foreground",
sheet ? "left-7" : "left-5",
)}
/>
<Input
ref={inputRef}
data-slot="region-picker-search"
type="search"
role="combobox"
aria-label={labels.searchLabel}
aria-expanded="true"
aria-autocomplete="list"
aria-controls={ready && rows.length ? listId : undefined}
aria-activedescendant={ready ? activeId : undefined}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="search"
placeholder={labels.searchPlaceholder}
// 面板里用 16px 字号与 40px 高,iOS 聚焦时不会放大页面,手指也好点
className={cn("pl-8", sheet && "h-10 bg-muted/50 text-base")}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onKeyDown}
/>
</div>
{!ready ? (
status === "error" ? (
<RegionPickerNotice className={bodyClass}>
<span>{labels.loadFailed}</span>
<Button
type="button"
variant="outline"
size={sheet ? "lg" : "sm"}
onClick={onRetry}
>
{labels.retry}
</Button>
</RegionPickerNotice>
) : (
<RegionPickerNotice loading className={bodyClass}>
{labels.loading}
</RegionPickerNotice>
)
) : rows.length ? (
<div
className={cn(
"relative min-h-0",
sheet ? "flex-1 border-t border-border" : "h-64",
)}
>
<div
ref={scrollRef}
id={listId}
role="listbox"
aria-label={searching ? labels.searchLabel : stepLabel(level)}
data-slot="region-picker-list"
onScroll={onListScroll}
className={cn(
"overflow-y-auto overscroll-contain",
sheet
? "h-full pb-[env(safe-area-inset-bottom)]"
: "h-full px-2.5",
)}
>
<div
key={listKey}
className={cn(
direction &&
!searching &&
"motion-safe:animate-in motion-safe:duration-200 motion-safe:fade-in-0",
direction === "forward" &&
!searching &&
"motion-safe:slide-in-from-right-4",
direction === "back" &&
!searching &&
"motion-safe:slide-in-from-left-4",
searching && !sheet && "flex flex-col gap-0.5",
)}
>
{searching
? rows.map((row, i) => renderOption(row, i, "row"))
: browseList}
</div>
{/* 截断提示放在列表末尾一起滚动,不额外占高度 */}
{search && search.total > search.results.length && (
<p
data-slot="region-picker-hint"
className={cn(
"py-2 text-xs text-muted-foreground",
sheet && "px-4",
)}
>
{fill(labels.resultTruncated, { limit: search.results.length })}
</p>
)}
</div>
{/* 手机上的字母索引条:长列表一点就跳到对应字母 */}
{sheet && groups && !searching && (
<nav
aria-label={labels.letterIndex}
data-slot="region-picker-index"
className="absolute top-8 right-1 bottom-2 z-20 flex w-6 flex-col items-center justify-center"
>
{groups.map((group) => {
const current = group.letter === currentLetter
return (
<button
key={group.letter}
type="button"
aria-current={current ? "location" : undefined}
className="group/letter flex h-5 w-6 items-center justify-center"
onClick={() => jumpTo(group.letter)}
>
<span
className={cn(
"flex size-4 items-center justify-center rounded-full font-mono text-[10px] leading-none",
current
? "bg-primary font-semibold text-primary-foreground"
: "text-muted-foreground group-active/letter:text-primary",
)}
>
{group.letter}
</span>
</button>
)
})}
</nav>
)}
</div>
) : (
<RegionPickerNotice className={bodyClass}>
{labels.empty}
</RegionPickerNotice>
)}
{(showConfirm || showClear) && (
<div
data-slot="region-picker-footer"
className={cn(
"flex items-center gap-2 border-t border-border",
sheet
? "px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "mx-2.5 pt-2.5",
)}
>
{showClear && (
<Button
type="button"
variant="outline"
size={sheet ? "lg" : "sm"}
onClick={() => onCommit(EMPTY_PATH)}
>
{labels.clear}
</Button>
)}
{showConfirm && (
<Button
type="button"
size={sheet ? "lg" : "sm"}
className={cn("ml-auto min-w-0", sheet && "flex-1")}
disabled={!canConfirm}
onClick={() => onCommit(trail)}
>
<span className="truncate">
{canConfirm
? fill(labels.confirmPartial, {
name: formatRegionPath(trail),
})
: labels.confirmIdle}
</span>
</Button>
)}
</div>
)}
<span role="status" className="sr-only">
{announcement}
</span>
</div>
)
}
export { RegionPickerPanel }
import * as React from "react"
import { cva } from "class-variance-authority"
import { Check, ChevronRight, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
/** 浮层形态:手机与触屏用底部面板,桌面用输入框下方的浮层 */
type RegionPickerVariant = "sheet" | "popover"
/**
* 触发器是一条方条:与方条开关、分段选择同一套外观(浅灰底、细边框、标签在条内左侧),
* 放进同一个面板里能对齐。高度只用既有三档:28 / 32 / 36px,触屏设备上各加高一档(36 / 40 / 44px)
*/
const regionPickerTriggerVariants = cva(
"flex w-full min-w-0 items-center gap-3 rounded-md border border-edge bg-muted/60 px-3 text-left transition-colors outline-none select-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-popup-open:bg-muted data-popup-open:ring-3 data-popup-open:ring-ring/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
{
variants: {
size: {
sm: "h-7 text-xs pointer-coarse:h-9",
default: "h-8 text-sm pointer-coarse:h-10",
lg: "h-9 text-sm pointer-coarse:h-11",
},
},
defaultVariants: { size: "default" },
},
)
/** 按区间高亮一段文字,区间为空时原样输出 */
function RegionPickerHighlight({
text,
range,
}: {
text: string
range?: [number, number]
}) {
if (!range || range[0] >= range[1]) return text
return (
<>
{text.slice(0, range[0])}
<mark
data-slot="region-picker-mark"
className="rounded-xs bg-primary/15 px-px text-foreground"
>
{text.slice(range[0], range[1])}
</mark>
{text.slice(range[1])}
</>
)
}
type RegionPickerStep = {
/** 这一步的名称,如「城市」 */
label: string
/** 已完成显示所选名称;当前步显示「请选择城市」或重新打开时的已选名称 */
value?: string
state: "done" | "current" | "todo"
}
/**
* 步骤条:①省份 ②城市 ③区县。已完成的步骤可点,回到那一步重选;后面的步骤灰显,让人一开始就知道要选几步。
* 底部面板里竖排(每步一行 44px,好点),桌面浮层里横排省高度。
*/
function RegionPickerSteps({
steps,
variant,
ariaLabel,
onSelect,
}: {
steps: RegionPickerStep[]
variant: RegionPickerVariant
ariaLabel: string
/** 回到第 `index` 步(0 = 第一步) */
onSelect: (index: number) => void
}) {
const sheet = variant === "sheet"
return (
<ol
data-slot="region-picker-steps"
aria-label={ariaLabel}
className={cn(
sheet
? "flex flex-col px-2"
: "flex min-w-0 items-center gap-0.5 overflow-x-auto px-2.5",
)}
>
{steps.map((step, index) => {
const done = step.state === "done"
const current = step.state === "current"
const last = index === steps.length - 1
return (
<li
key={index}
data-slot="region-picker-step"
data-state={step.state}
className={cn(
"relative flex min-w-0",
sheet ? "" : "shrink-0 items-center gap-0.5",
)}
>
{/* 竖排时圆点之间的连线:上一步完成后变成主题色 */}
{sheet && !last && (
<span
aria-hidden="true"
className={cn(
"absolute top-[1.875rem] bottom-[-0.875rem] left-[1.125rem] w-px",
done ? "bg-primary" : "bg-border",
)}
/>
)}
<button
type="button"
disabled={!done}
aria-current={current ? "step" : undefined}
onClick={() => onSelect(index)}
// 当前步不能点,但要保持可读:disabled 只给未完成的步骤
className={cn(
"flex min-w-0 items-center text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-default",
sheet
? "h-11 w-full gap-3 rounded-lg px-2 active:bg-muted"
: "h-8 max-w-[11rem] gap-1.5 rounded-md px-1.5 enabled:hover:bg-muted",
// 当前这一步垫一层底色:默认主题的主色接近黑色,只靠颜色区分不够醒目
current && "bg-muted",
)}
>
<span
aria-hidden="true"
className={cn(
"relative z-10 flex size-5 shrink-0 items-center justify-center rounded-full border font-mono text-[11px] tabular-nums",
done && "border-primary bg-primary text-primary-foreground",
current &&
"border-primary bg-background text-primary ring-3 ring-primary/15",
step.state === "todo" &&
"border-border bg-background text-muted-foreground",
)}
>
{done ? (
<Check className="size-3" strokeWidth={3} />
) : (
index + 1
)}
</span>
{sheet && (
<span className="w-10 shrink-0 text-xs text-muted-foreground">
{step.label}
</span>
)}
<span
className={cn(
"min-w-0 flex-1 truncate text-sm",
done && "text-foreground",
current && "font-medium text-primary",
step.state === "todo" && "text-muted-foreground",
)}
>
{/* 竖排时左边已经有步骤名,未到的步骤不再重复 */}
{step.value ?? (sheet ? "" : step.label)}
</span>
{sheet && done && (
<ChevronRight
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
)}
</button>
{!sheet && !last && (
<ChevronRight
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground/60"
/>
)}
</li>
)
})}
</ol>
)
}
/**
* 列表中的一项。鼠标按下不抢走搜索框的焦点,选项的“焦点”由 aria-activedescendant 表达。
* 面板里是 44px 高的整行;浮层里是带边框的紧凑按钮,看得出能点。
*/
function RegionPickerOption({
className,
variant,
layout,
active,
selected,
branch,
onSelect,
onHover,
children,
...props
}: Omit<React.ComponentProps<"div">, "onSelect"> & {
variant: RegionPickerVariant
/** `row` 整行(面板、搜索结果);`chip` 浮层里浏览时的紧凑按钮 */
layout: "row" | "chip"
active: boolean
selected: boolean
/** 还有下级:整行形态右侧显示箭头 */
branch: boolean
onSelect: () => void
onHover: () => void
}) {
const sheet = variant === "sheet"
return (
<div
role="option"
aria-selected={selected}
data-slot="region-picker-option"
data-active={active ? "" : undefined}
data-selected={selected ? "" : undefined}
className={cn(
"flex min-w-0 cursor-pointer items-center gap-2 select-none",
layout === "chip"
? "h-8 rounded-md border border-border px-2.5 text-sm transition-colors hover:border-foreground/25 hover:bg-muted data-selected:border-primary data-selected:bg-primary/10 data-selected:font-medium data-selected:text-primary data-active:border-ring data-active:ring-2 data-active:ring-ring/30"
: sheet
? "h-11 px-4 text-sm active:bg-muted data-selected:font-medium data-selected:text-primary"
: "h-8 rounded-md px-2.5 text-sm data-selected:font-medium data-selected:text-primary data-active:bg-muted",
className,
)}
onMouseDown={(event) => event.preventDefault()}
onClick={onSelect}
onMouseMove={onHover}
{...props}
>
{children}
{layout === "row" &&
(selected ? (
<Check
aria-hidden="true"
className="ml-auto size-4 shrink-0 text-primary"
/>
) : (
branch && (
<ChevronRight
aria-hidden="true"
className="ml-auto size-4 shrink-0 text-muted-foreground/60"
/>
)
))}
</div>
)
}
/** 加载中、出错、无结果的占位,高度固定避免浮层来回跳 */
function RegionPickerNotice({
loading,
className,
children,
...props
}: React.ComponentProps<"div"> & { loading?: boolean }) {
return (
<div
data-slot="region-picker-notice"
className={cn(
"flex min-h-32 flex-col items-center justify-center gap-2 px-3 text-center text-sm text-muted-foreground",
className,
)}
{...props}
>
{loading && (
<Loader2
aria-hidden="true"
className="size-4 motion-safe:animate-spin"
/>
)}
{children}
</div>
)
}
export {
RegionPickerHighlight,
RegionPickerNotice,
RegionPickerOption,
RegionPickerSteps,
regionPickerTriggerVariants,
}
export type { RegionPickerStep, RegionPickerVariant }
import type * as React from "react"
import type { RegionPickerLabels } from "./config"
/**
* 省市区选择认的数据结构:任何符合它的树都能用。
* 组件不绑定具体数据,china-regions 只是一份可选的官方数据。
*/
type RegionOption = {
/** 全树唯一的代码,也是组件对外的值(行政区划用 6 位国标码) */
code: string
/** 显示名称 */
name: string
/** 无声调拼音,音节用空格分隔(`hang zhou shi`);没有时搜索只按名称和代码 */
pinyin?: string
/** 下级;没有或为空数组表示末级 */
children?: RegionOption[]
}
/** 地区数据,或首次需要时才调用的异步加载函数 */
type RegionsSource = RegionOption[] | (() => Promise<RegionOption[]>)
type RegionPickerSize = "sm" | "default" | "lg"
type RegionPickerProps = Omit<
React.ComponentProps<"button">,
| "value"
| "defaultValue"
| "onChange"
| "type"
| "name"
| "form"
| "children"
| "placeholder"
> & {
/**
* 地区数据。传数组直接使用(请保持引用稳定);
* 传函数则惰性加载:首次打开浮层、或有初始值需要显示路径时才调用,且只调用一次。
*/
regions: RegionsSource
/** 已选地区的代码(最末一级);`null` 表示未选。受控时传入 */
value?: string | null
/**
* 非受控时的初始代码
* @default null
*/
defaultValue?: string | null
/** 选中变化时回调:最末一级代码(清除时为 `null`)与从最顶层到它的完整路径 */
onValueChange?: (code: string | null, path: RegionOption[]) => void
/** 最多选到第几级(从 1 数);到达这一级的节点视为末级,不再展开下级 */
depth?: number
/**
* 是否允许选中非末级节点就确认(如只选到「浙江省」);
* 开启后浏览到非顶层时底部出现确认按钮
* @default false
*/
allowPartial?: boolean
/** 随表单提交的字段名,提交的是最末一级的代码,未选时为空串 */
name?: string
/** 关联到另一个表单的 id,同 `<input form>` */
form?: string
/** 未选时拦住表单提交 */
required?: boolean
disabled?: boolean
/**
* 写在触发器条内左侧的标签,如「所在地区」;选中的路径随之靠右。
* 不传时触发器里只有路径,标签可以照常写在外面
*/
label?: React.ReactNode
/**
* 错误提示:显示在触发器下方,触发器变红并带 `aria-invalid`,读屏聚焦时一并朗读。
* 业务校验(比如「该地区暂不配送」)自己判断后传入
*/
error?: React.ReactNode
/** 未选时触发器显示的提示,默认取 `labels.placeholder` */
placeholder?: string
/**
* 代码在数据里找不到时(地区被撤销、数据版本不同)触发器显示的名称,
* 通常是调用方保存数据时一并存下的名称;不传则显示「未知地区(代码)」
*/
fallbackLabel?: string
/**
* 触发器高度档位:28 / 32 / 36px
* @default "default"
*/
size?: RegionPickerSize
/**
* 选择面板的形态:`auto` 在手机与触屏设备上用底部面板、桌面用下拉浮层;
* `sheet` / `popover` 固定其中一种
* @default "auto"
*/
presentation?: "auto" | "sheet" | "popover"
/**
* 底部面板挂载到哪个元素里,默认 `document.body`。
* 传入后面板只盖住这个元素(元素需要 `position: relative` 与 `overflow: clip`,用 clip 而不是 hidden,元素就不会被滚动),也不锁定整页滚动;
* 用于把选择器放进手机外框、预览窗格这类局部区域
*/
container?: HTMLElement | React.RefObject<HTMLElement | null> | null
/** 浮层是否打开,受控时传入 */
open?: boolean
/**
* 非受控时浮层初始是否打开
* @default false
*/
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
/** 覆盖用户可见文案 */
labels?: Partial<RegionPickerLabels>
}
export type { RegionOption, RegionPickerProps, RegionPickerSize, RegionsSource }
import { SEARCH_LIMIT } from "./config"
import type { RegionOption } from "./types"
/** 搜索用的节点快照:名称、拼音、首字母都预先小写、去空格,每个节点只算一次 */
type IndexNode = {
node: RegionOption
name: string
code: string
/** 全拼,去掉音节间空格 */
pinyin: string
/** 每个音节在 `pinyin` 里的起点,用来限定「包含」只能从音节开头匹配 */
bounds: number[]
/** 各音节首字母 */
initials: string
/** 音节数与名称字数一致时,拼音命中的区间才能对应回具体的字 */
aligned: boolean
}
type IndexEntry = {
/** 从最顶层到该节点 */
path: RegionOption[]
nodes: IndexNode[]
/** 从 0 数的层级 */
level: number
}
type RegionIndex = {
/** 最顶层节点,浏览模式的起点 */
roots: RegionOption[]
entries: IndexEntry[]
/** 代码 → 路径,覆盖整棵树(不受 depth 限制),用来回显已保存的值 */
byCode: Map<string, RegionOption[]>
}
/** 命中在某个节点上的高亮区间,左闭右开;`name` 对应名称的字,`code` 对应代码的字符 */
type RegionHighlight = {
name?: [number, number]
code?: [number, number]
}
type RegionSearchResult = {
/** 命中的节点 */
node: RegionOption
/** 从最顶层到命中节点的完整路径 */
path: RegionOption[]
/** 命中节点所在层级,从 0 数 */
level: number
/** 与 `path` 一一对应的高亮区间,没有命中的节点为 `null` */
highlights: (RegionHighlight | null)[]
}
type RegionSearchOptions = {
/** 只搜索到第几级,同组件的 `depth` */
depth?: number
/**
* 结果上限
* @default 50
*/
limit?: number
}
function toIndexNode(node: RegionOption): IndexNode {
const syllables = (node.pinyin ?? "")
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
const bounds: number[] = []
let offset = 0
for (const syllable of syllables) {
bounds.push(offset)
offset += syllable.length
}
return {
node,
name: node.name.toLowerCase(),
code: node.code.toLowerCase(),
pinyin: syllables.join(""),
bounds,
initials: syllables.map((syllable) => syllable[0]).join(""),
aligned: syllables.length === node.name.length,
}
}
/** 末级判定:没有下级,或已经到达可选的最大层级 */
function isLeafRegion(node: RegionOption, level: number, depth = Infinity) {
return !node.children?.length || level + 1 >= depth
}
/**
* 为一棵地区树建立搜索与回显索引。
* 数据不变时只建一次(组件里用 useMemo);一次性查询可直接用 `searchRegions`。
*/
function createRegionIndex(
regions: RegionOption[],
options: { depth?: number } = {},
): RegionIndex {
const depth = options.depth ?? Infinity
const entries: IndexEntry[] = []
const byCode = new Map<string, RegionOption[]>()
const visit = (
nodes: RegionOption[],
path: RegionOption[],
indexed: IndexNode[],
) => {
for (const node of nodes) {
const here = [...path, node]
const hereIndexed = [...indexed, toIndexNode(node)]
if (!byCode.has(node.code)) byCode.set(node.code, here)
const level = here.length - 1
if (level < depth) entries.push({ path: here, nodes: hereIndexed, level })
if (node.children?.length) visit(node.children, here, hereIndexed)
}
}
visit(regions, [], [])
return { roots: regions, entries, byCode }
}
type Hit = {
/** 越小越靠前:0 完全相等,1 名称开头,2 拼音 / 首字母 / 代码开头,3 名称包含,4 拼音包含 */
tier: number
highlight: RegionHighlight
}
/** 拼音命中 [start, end) 对应到名称里的字 */
function syllableRange(
target: IndexNode,
start: number,
end: number,
): [number, number] | undefined {
if (!target.aligned) return undefined
let first = 0
let last = 0
target.bounds.forEach((bound, i) => {
if (bound <= start) first = i
if (bound < end) last = i
})
return [first, last + 1]
}
/** 一个检索词在一个节点上的最佳命中;各字段取 tier 最小的 */
function matchNode(target: IndexNode, token: string): Hit | null {
let best: Hit | null = null
const consider = (tier: number, highlight: RegionHighlight) => {
if (!best || tier < best.tier) best = { tier, highlight }
}
const nameAt = target.name.indexOf(token)
if (nameAt !== -1) {
const range: [number, number] = [nameAt, nameAt + token.length]
consider(target.name === token ? 0 : nameAt === 0 ? 1 : 3, { name: range })
}
if (target.code.startsWith(token))
consider(target.code === token ? 0 : 2, { code: [0, token.length] })
// 拼音只对纯字母检索词有意义,避免汉字、数字去白白扫一遍
if (target.pinyin && /^[a-z]+$/.test(token)) {
if (target.pinyin === token) {
consider(0, { name: syllableRange(target, 0, token.length) })
} else if (target.pinyin.startsWith(token)) {
consider(2, { name: syllableRange(target, 0, token.length) })
} else {
for (
let at = target.pinyin.indexOf(token);
at !== -1;
at = target.pinyin.indexOf(token, at + 1)
) {
if (target.bounds.includes(at)) {
consider(4, { name: syllableRange(target, at, at + token.length) })
break
}
}
}
if (target.initials === token)
consider(0, { name: target.aligned ? [0, token.length] : undefined })
else if (target.initials.startsWith(token))
consider(2, { name: target.aligned ? [0, token.length] : undefined })
}
return best
}
/** 空格、斜杠、顿号、逗号都当作检索词的分隔 */
function splitQuery(query: string) {
return query
.trim()
.toLowerCase()
.split(/[\s/、,,]+/)
.filter(Boolean)
}
type Match = {
tier: number
highlights: (RegionHighlight | null)[]
}
function matchEntry(
entry: IndexEntry,
compact: string,
tokens: string[],
): Match | null {
let best: Match | null = null
const own = entry.nodes[entry.level]
// 整句去掉分隔后直接匹配本节点:「hang zhou」「hangzhou」都能命中杭州市
const whole = matchNode(own, compact)
if (whole) {
const highlights = entry.nodes.map(() => null) as Match["highlights"]
highlights[entry.level] = whole.highlight
best = { tier: whole.tier, highlights }
}
// 带路径的检索:「杭州 西湖」——最后一个词命中本节点,其余的词命中本节点或任一上级。
// 整句已经命中的不再走这里(路径检索的 tier 恒大于直接命中)
if (!best && tokens.length > 1) {
const last = matchNode(own, tokens[tokens.length - 1])
if (last) {
const highlights = entry.nodes.map(() => null) as Match["highlights"]
highlights[entry.level] = last.highlight
let ok = true
for (const token of tokens.slice(0, -1)) {
let found = false
for (let i = 0; i <= entry.level && !found; i++) {
const hit = matchNode(entry.nodes[i], token)
if (hit) {
highlights[i] = { ...hit.highlight, ...highlights[i] }
found = true
}
}
if (!found) {
ok = false
break
}
}
if (ok) best = { tier: last.tier + 5, highlights }
}
}
return best
}
/**
* 在已建好的索引上搜索,返回前 `limit` 条与命中总数。
* 支持汉字(包含)、全拼(去空格)、拼音首字母、代码前缀,以及带路径的写法(「杭州 西湖」)。
* 完全匹配、开头匹配优先,同级时层级浅的优先,再同级保持数据里的顺序。
*/
function searchRegionIndex(
index: RegionIndex,
query: string,
limit = SEARCH_LIMIT,
): { results: RegionSearchResult[]; total: number } {
const tokens = splitQuery(query)
if (!tokens.length) return { results: [], total: 0 }
const compact = tokens.join("")
const matched: { entry: IndexEntry; match: Match }[] = []
for (const entry of index.entries) {
const match = matchEntry(entry, compact, tokens)
if (match) matched.push({ entry, match })
}
// Array.prototype.sort 是稳定排序,同 tier 同层级时保持数据顺序
matched.sort(
(a, b) => a.match.tier - b.match.tier || a.entry.level - b.entry.level,
)
return {
total: matched.length,
results: matched.slice(0, limit).map(({ entry, match }) => ({
node: entry.path[entry.level],
path: entry.path,
level: entry.level,
highlights: match.highlights,
})),
}
}
/**
* 一次性搜索,内部每次重建索引。
* 高频场景(输入框边敲边搜)请先 `createRegionIndex` 再 `searchRegionIndex`。
*/
function searchRegions(
regions: RegionOption[],
query: string,
options: RegionSearchOptions = {},
): RegionSearchResult[] {
const index = createRegionIndex(regions, { depth: options.depth })
return searchRegionIndex(index, query, options.limit).results
}
/** 按代码找到从最顶层到它的完整路径;找不到返回空数组 */
function findRegionPath(regions: RegionOption[], code: string): RegionOption[] {
for (const node of regions) {
if (node.code === code) return [node]
if (node.children?.length) {
const rest = findRegionPath(node.children, code)
if (rest.length) return [node, ...rest]
}
}
return []
}
const depthCache = new WeakMap<RegionOption[], number>()
/** 一组节点往下最多有几级(自身算一级),用来决定步骤条显示几步;按数组引用缓存 */
function regionTreeDepth(nodes: RegionOption[] | undefined): number {
if (!nodes?.length) return 0
const cached = depthCache.get(nodes)
if (cached !== undefined) return cached
let below = 0
for (const node of nodes)
below = Math.max(below, regionTreeDepth(node.children))
depthCache.set(nodes, below + 1)
return below + 1
}
type RegionGroup = {
/** 大写首字母 */
letter: string
items: RegionOption[]
}
/**
* 按拼音首字母分组并按拼音排序,像通讯录一样;有节点缺拼音时返回 `null`,保持数据原顺序。
* 多音字已在数据的 pinyin 里处理好,这里只取第一个字母。
*/
function groupRegionsByInitial(nodes: RegionOption[]): RegionGroup[] | null {
if (!nodes.every((node) => node.pinyin?.trim())) return null
const sorted = [...nodes].sort((a, b) =>
a.pinyin!.localeCompare(b.pinyin!, "en"),
)
const groups: RegionGroup[] = []
for (const node of sorted) {
const letter = node.pinyin!.trim()[0].toUpperCase()
const last = groups[groups.length - 1]
if (last?.letter === letter) last.items.push(node)
else groups.push({ letter, items: [node] })
}
return groups
}
/** 路径转成文字:`formatRegionPath(path)` → 「浙江省 / 杭州市」,`formatRegionPath(path, "")` → 「浙江省杭州市」 */
function formatRegionPath(path: RegionOption[], separator = " / "): string {
return path.map((node) => node.name).join(separator)
}
export {
createRegionIndex,
findRegionPath,
formatRegionPath,
groupRegionsByInitial,
isLeafRegion,
regionTreeDepth,
searchRegionIndex,
searchRegions,
}
export type {
RegionGroup,
RegionHighlight,
RegionIndex,
RegionSearchOptions,
RegionSearchResult,
}
/** 用户可见文案。`{level}`、`{count}`、`{limit}`、`{name}`、`{step}` 会被替换成对应的值。 */
const DEFAULT_LABELS = {
/** 未选时触发器的提示 */
placeholder: "请选择省 / 市 / 区",
/** 手机底部面板的标题 */
title: "选择地区",
close: "关闭",
searchPlaceholder: "搜索名称、拼音或首字母",
searchLabel: "搜索地区",
/**
* 各步的名称,按「从最深一级往上对齐」取用:直辖市只有两级时,第二步叫「区县」而不是「城市」。
* 数据比这里更深时,多出的步骤用 `stepFallback`
*/
levels: ["省份", "城市", "区县", "乡镇"] as string[],
stepFallback: "第 {step} 级",
/** 当前正在选的那一步 */
stepPending: "请选择{level}",
/** 步骤条的读屏名称 */
stepsNav: "选择步骤",
/** 读屏播报:进入某一步 */
levelStatus: "第 {step} 步,选择{level},共 {count} 项",
/** 读屏播报:搜索结果数 */
resultStatus: "找到 {count} 个结果",
/** 结果被截断时列表下方的提示 */
resultTruncated: "仅显示前 {limit} 条,输入更多字符可缩小范围",
/** 手机上右侧字母索引条的读屏名称 */
letterIndex: "按拼音首字母跳转",
empty: "没有匹配的地区",
loading: "正在加载地区数据…",
loadFailed: "地区数据加载失败",
retry: "重试",
clear: "清除",
/** 允许选非末级时,底部确认当前路径的按钮 */
confirmPartial: "确定:{name}",
/** 允许选非末级、但还没有可确认的路径时,确认按钮的文字(按钮置灰) */
confirmIdle: "确定",
/** 代码找不到又没传 fallbackLabel 时的名称,后面自动加(代码) */
unknown: "未知地区",
/** 必填未选时的校验提示 */
required: "请选择地区",
}
type RegionPickerLabels = typeof DEFAULT_LABELS
/** 搜索结果上限 */
const SEARCH_LIMIT = 50
/** 一级里的选项达到这个数(且都有拼音)时按拼音首字母分组,少了分组反而碍事 */
const GROUP_THRESHOLD = 16
/** 视口窄于 640px 或主要靠触屏时,改用底部面板 */
const SHEET_QUERY = "(max-width: 639px), (pointer: coarse)"
export { DEFAULT_LABELS, GROUP_THRESHOLD, SEARCH_LIMIT, SHEET_QUERY }
export type { RegionPickerLabels }
"use client"
import * as React from "react"
type UseControllableStateParams<T> = {
/** 受控值;传入后组件不再维护内部状态 */
prop?: T
/** 非受控时的初始值 */
defaultProp: T
/** 值变化时回调(受控与非受控都会触发) */
onChange?: (value: T) => void
}
/**
* 同时支持受控与非受控的状态 hook。
* 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
*/
function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateParams<T>) {
const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolled
// 用 ref 保存最新的回调和值,保证 setValue 引用稳定
const onChangeRef = React.useRef(onChange)
const valueRef = React.useRef(value)
React.useEffect(() => {
onChangeRef.current = onChange
valueRef.current = value
})
const setValue = React.useCallback(
(next: React.SetStateAction<T>) => {
const resolved =
typeof next === "function"
? (next as (prev: T) => T)(valueRef.current)
: next
if (Object.is(resolved, valueRef.current)) return
valueRef.current = resolved
if (!isControlled) setUncontrolled(resolved)
onChangeRef.current?.(resolved)
},
[isControlled],
)
return [value, setValue] as const
}
export { useControllableState }