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