跳至正文
组件

Region Picker省市区选择

分步选择省 / 市 / 区:步骤条清楚显示第几步、可点回上一步,长列表按拼音首字母分组,支持汉字、全拼、首字母搜索;手机上从底部弹出面板。值只存最末一级的代码,数据由 props 传入、可惰性加载。

正在加载…

安装

组件和数据是两个条目,分开安装:

pnpm dlx shadcn@latest add https://yiui.dev/r/region-picker.json
会连带安装 shadcn 组件 button、input、popover。已存在的文件会询问是否覆盖。

数据用国家地名信息库的官方行政区划,见 china-regions;用自己的数据就不必装:

pnpm dlx shadcn@latest add https://yiui.dev/r/china-regions.json

组件本身不引用数据,regions 传什么它就用什么。

用法

components/address-form.tsx
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 让面板挂进手机屏幕、只盖住屏幕,在桌面上也能试分步选择、字母索引和点步骤回退。

正在加载…

表单提交

设了 name,表单提交的是最末一级的代码,未选时是空串;required 未选时拦住提交,浏览器提示「请选择地区」。非受控的表单 reset 会回到 defaultValue。

正在加载…

限制层级与选到中间级

第一个只选到省市;第二个允许停在中间层级,展开后进入下级,底部能确认当前路径。

正在加载…

自己的数据

不必用官方数据。第一个只开通了几个城市,代码用自己的编号,部分节点没有 pinyin(搜索只按名称和代码);第二个演示代码失效:数据里没有 "330199",触发器显示调用方存下的 fallbackLabel。

正在加载…

界面与适配

交互取级联选择最常见的三点:分步、选完自动前进、点已完成的步骤回退。

  • 触发器是一条方条,和方条开关、分段选择、数值滑块同一套外观与 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

className 作用于根元素,其余未列出的属性(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 条数据的索引构建与单次搜索都在毫秒级,不需要防抖;数据达到几十万条时请自己换服务端搜索。

源码

components/ui/region-picker/region-picker.tsx
"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"
components/ui/region-picker/region-picker-panel.tsx
"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 }
components/ui/region-picker/region-picker-parts.tsx
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 }
components/ui/region-picker/types.ts
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 }
components/ui/region-picker/utils.ts
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,
}
components/ui/region-picker/config.ts
/** 用户可见文案。`{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 }
hooks/use-controllable-state.ts
"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 }