{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "id-number-input",
  "title": "证件号输入",
  "description": "身份证号、银行卡号与统一社会信用代码输入：自动整理与分组、保持光标、失焦校验，支持打码显示和原始值提交。",
  "registryDependencies": [
    "https://yiui.dev/r/bar-field.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/id-number-input/id-number-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport { DEFAULT_LABELS, KIND_LABELS } from \"./config\"\nimport { idNumberInputControlVariants } from \"./id-number-input-variants\"\nimport type { IdNumberInputProps } from \"./types\"\nimport { useIdNumberInputState } from \"./use-id-number-input-state\"\nimport {\n  formatIdNumber,\n  isValidBankCard,\n  isValidIdCard,\n  isValidIdCardAtDate,\n  isValidUscc,\n  maskIdNumber,\n  parseIdCard,\n  parseIdCardAtDate,\n} from \"./utils\"\n\n/** 三种号码共用方条外观；显示分组与打码不改变表单提交值。 */\nfunction IdNumberInput({\n  kind = \"idcard\",\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  label,\n  size = \"default\",\n  error,\n  allowLegacy15 = false,\n  bankcardCheck = \"length\",\n  showParsed = true,\n  showLength = false,\n  mask = false,\n  labels: labelsProp,\n  className,\n  id: idProp,\n  name,\n  form,\n  required,\n  disabled,\n  readOnly,\n  placeholder,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-describedby\": describedByProp,\n  onFocus,\n  onBlur,\n  onCompositionStart,\n  onCompositionEnd,\n  onInvalid,\n  ref,\n  ...props\n}: IdNumberInputProps) {\n  const labels = { ...DEFAULT_LABELS, ...KIND_LABELS[kind], ...labelsProp }\n  const autoId = React.useId()\n  const id = idProp ?? autoId\n  const errorId = `${id}-error`\n  const hintId = `${id}-hint`\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)\n  const {\n    today,\n    value,\n    focused,\n    setFocused,\n    touched,\n    setTouched,\n    composingRef,\n    draft,\n    setDraft,\n    setCaret,\n    commit,\n  } = useIdNumberInputState({\n    kind,\n    value: valueProp,\n    defaultValue,\n    onValueChange,\n    form,\n    inputRef,\n  })\n  const valid =\n    kind === \"idcard\"\n      ? isValidIdCardAtDate(value, { allowLegacy15 }, today)\n      : kind === \"bankcard\"\n        ? isValidBankCard(value, { luhn: bankcardCheck === \"luhn\" })\n        : isValidUscc(value)\n  const ownMessage =\n    value === \"\"\n      ? required\n        ? labels.required\n        : \"\"\n      : valid\n        ? \"\"\n        : labels.invalid\n  const errorMessage = error || (touched ? ownMessage : \"\")\n  const invalid =\n    Boolean(errorMessage) || ariaInvalid === true || ariaInvalid === \"true\"\n  React.useEffect(() => {\n    inputRef.current?.setCustomValidity(\n      error ? (typeof error === \"string\" ? error : labels.invalid) : ownMessage,\n    )\n  }, [error, labels.invalid, ownMessage])\n\n  const parsed =\n    kind === \"idcard\" && showParsed && valid\n      ? parseIdCardAtDate(value, { allowLegacy15 }, today)\n      : null\n  const hint = parsed\n    ? labels.parsed\n        .replace(\n          \"{birthDate}\",\n          // 数字与「年月日」之间留空格，和全站「第 2 步」「28 / 32 / 36」的排版一致\n          parsed.birthDate\n            .split(\"-\")\n            .map(Number)\n            .map((part, i) => `${part} ${\"年月日\"[i]}`)\n            .join(\" \"),\n        )\n        .replace(\"{gender}\", labels[parsed.gender])\n    : kind === \"bankcard\" && showLength && value\n      ? labels.length.replace(\n          \"{length}\",\n          new Intl.NumberFormat(\"zh-CN\").format(value.length),\n        )\n      : \"\"\n  // 打码时也不在提示中暴露出生日期。\n  const visibleHint =\n    hint && !(mask && !focused && kind === \"idcard\") && !errorMessage\n      ? hint\n      : \"\"\n  const describedBy =\n    [\n      describedByProp,\n      visibleHint ? hintId : undefined,\n      errorMessage ? errorId : undefined,\n    ]\n      .filter(Boolean)\n      .join(\" \") || undefined\n\n  return (\n    <div\n      data-slot=\"id-number-input\"\n      data-kind={kind}\n      data-size={size}\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        \"@container/id-number-input in-data-[slot=grid-toolbar]:h-full flex w-full min-w-0 flex-col gap-1.5\",\n        className,\n      )}\n    >\n      {/* 窄于 20rem 时条内放不下「标签 + 号码」：标签挪到条上方显示，条内那份只留给读屏。\n          这一份 aria-hidden，只负责看和点（点它聚焦输入框），可访问名称仍来自条内的 label */}\n      {label && (\n        <label\n          aria-hidden=\"true\"\n          htmlFor={id}\n          data-slot=\"id-number-input-label-above\"\n          className=\"hidden text-xs text-muted-foreground @max-[20rem]/id-number-input:block in-data-[slot=grid-toolbar]:hidden!\"\n        >\n          {label}\n        </label>\n      )}\n      <div\n        data-slot=\"id-number-input-control\"\n        className={cn(\n          idNumberInputControlVariants({ size }),\n          \"@max-[24rem]/id-number-input:gap-2 @max-[24rem]/id-number-input:px-2.5\",\n          invalid\n            ? \"border-destructive ring-3 ring-destructive/20 dark:border-destructive/50 dark:ring-destructive/40\"\n            : \"border-edge focus-within:border-foreground/25 focus-within:bg-background hover:not-focus-within:bg-muted dark:focus-within:bg-input/30\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          readOnly && \"bg-muted/30\",\n        )}\n        onMouseDown={(event) => {\n          if (event.target === event.currentTarget) {\n            event.preventDefault()\n            inputRef.current?.focus()\n          }\n        }}\n      >\n        {label && (\n          <label\n            data-slot=\"id-number-input-label\"\n            htmlFor={id}\n            className=\"shrink-0 text-muted-foreground max-md:text-sm @max-[20rem]/id-number-input:sr-only in-data-[slot=grid-toolbar]:not-sr-only! in-data-[slot=grid-toolbar]:whitespace-nowrap!\"\n          >\n            {label}\n          </label>\n        )}\n        <input\n          {...props}\n          ref={inputRef}\n          id={id}\n          form={form}\n          data-slot=\"id-number-input-input\"\n          type=\"text\"\n          inputMode={kind === \"bankcard\" ? \"numeric\" : \"text\"}\n          autoCapitalize={kind === \"bankcard\" ? \"off\" : \"characters\"}\n          autoComplete={props.autoComplete ?? \"off\"}\n          spellCheck={false}\n          value={\n            draft ??\n            (mask && !focused\n              ? maskIdNumber(kind, value)\n              : formatIdNumber(kind, value))\n          }\n          placeholder={placeholder ?? labels.placeholder}\n          disabled={disabled}\n          readOnly={readOnly}\n          required={required}\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          className={cn(\n            \"h-full min-w-0 flex-1 bg-transparent font-medium tabular-nums outline-none placeholder:font-normal placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n            label && \"text-right @max-[20rem]/id-number-input:text-left\",\n          )}\n          onChange={(event) => {\n            const input = event.currentTarget\n            if (composingRef.current) {\n              setDraft(input.value)\n              return\n            }\n            commit(input.value, input.selectionStart ?? input.value.length)\n          }}\n          onCompositionStart={(event) => {\n            composingRef.current = true\n            setDraft(event.currentTarget.value)\n            onCompositionStart?.(event)\n          }}\n          onCompositionEnd={(event) => {\n            composingRef.current = false\n            setDraft(null)\n            commit(\n              event.currentTarget.value,\n              event.currentTarget.selectionStart ??\n                event.currentTarget.value.length,\n            )\n            onCompositionEnd?.(event)\n          }}\n          onKeyDown={(event) => {\n            props.onKeyDown?.(event)\n            const input = event.currentTarget\n            const { selectionStart: start, selectionEnd: end } = input\n            if (\n              event.defaultPrevented ||\n              readOnly ||\n              composingRef.current ||\n              event.nativeEvent.isComposing ||\n              start === null ||\n              start !== end\n            )\n              return\n            const current = input.value\n            if (event.key === \"Backspace\" && current[start - 1] === \" \") {\n              event.preventDefault()\n              commit(\n                current.slice(0, start - 2) + current.slice(start),\n                start - 2,\n              )\n            } else if (event.key === \"Delete\" && current[start] === \" \") {\n              event.preventDefault()\n              commit(current.slice(0, start) + current.slice(start + 2), start)\n            }\n          }}\n          onFocus={(event) => {\n            setFocused(true)\n            if (mask) setCaret({ position: formatIdNumber(kind, value).length })\n            onFocus?.(event)\n          }}\n          onBlur={(event) => {\n            setFocused(false)\n            setTouched(true)\n            onBlur?.(event)\n          }}\n          onInvalid={(event) => {\n            setTouched(true)\n            onInvalid?.(event)\n          }}\n        />\n      </div>\n      {visibleHint && (\n        <p\n          id={hintId}\n          data-slot=\"id-number-input-hint\"\n          className=\"text-xs text-muted-foreground\"\n          aria-live=\"polite\"\n        >\n          {visibleHint}\n        </p>\n      )}\n      {errorMessage && (\n        <p\n          id={errorId}\n          data-slot=\"id-number-input-error\"\n          className=\"text-xs text-destructive\"\n          aria-live=\"polite\"\n        >\n          {errorMessage}\n        </p>\n      )}\n      {name && (\n        <input\n          type=\"hidden\"\n          data-slot=\"id-number-input-value\"\n          name={name}\n          form={form}\n          value={value}\n          disabled={disabled}\n        />\n      )}\n    </div>\n  )\n}\n\nexport {\n  IdNumberInput,\n  formatIdNumber,\n  idNumberInputControlVariants,\n  isValidBankCard,\n  isValidIdCard,\n  isValidUscc,\n  maskIdNumber,\n  parseIdCard,\n}\nexport type {\n  IdNumberInputProps,\n  IdNumberInputSize,\n  IdNumberKind,\n} from \"./types\"\nexport type { IdNumberInputLabels } from \"./config\"\nexport type { IdCardOptions, ParsedIdCard } from \"./utils\"\n",
      "type": "registry:ui",
      "target": "@ui/id-number-input/id-number-input.tsx"
    },
    {
      "path": "src/registry/ui/id-number-input/id-number-input-variants.ts",
      "content": "import { cn } from \"@/lib/utils\"\nimport { barFieldVariants } from \"@/components/ui/bar-field/bar-field\"\n\nconst idNumberInputControlVariants: typeof barFieldVariants = (props) =>\n  cn(barFieldVariants(props), \"motion-reduce:transition-none\")\n\nexport { idNumberInputControlVariants }\n",
      "type": "registry:ui",
      "target": "@ui/id-number-input/id-number-input-variants.ts"
    },
    {
      "path": "src/registry/ui/id-number-input/use-id-number-input-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useCaretRestore } from \"@/hooks/use-caret-restore\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\n\nimport type { IdNumberInputProps } from \"./types\"\nimport { currentIdCardDate, normalizeIdNumber, sanitizeIdNumber } from \"./utils\"\n\n// 服务端使用稳定快照；当前日期只在挂载后读取，并在跨日后重新校验。\nfunction subscribeToDate(callback: () => void) {\n  const timer = window.setInterval(callback, 60_000)\n  return () => window.clearInterval(timer)\n}\nfunction serverDate() {\n  return 0\n}\n\n/** 统一管理原始值、组合输入、光标恢复与所属表单的重置。 */\nfunction useIdNumberInputState({\n  kind,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  form,\n  inputRef,\n}: Required<Pick<IdNumberInputProps, \"kind\" | \"defaultValue\">> &\n  Pick<IdNumberInputProps, \"value\" | \"onValueChange\" | \"form\"> & {\n    inputRef: React.RefObject<HTMLInputElement | null>\n  }) {\n  const today = React.useSyncExternalStore(\n    subscribeToDate,\n    currentIdCardDate,\n    serverDate,\n  )\n  const initialValue = React.useRef(defaultValue)\n  const [state, setValue] = useControllableState({\n    prop: valueProp,\n    defaultProp: normalizeIdNumber(kind, defaultValue),\n    onChange: onValueChange,\n  })\n  const value = normalizeIdNumber(kind, state)\n  const [focused, setFocused] = React.useState(false)\n  const [touched, setTouched] = React.useState(false)\n  const composingRef = React.useRef(false)\n  const [draft, setDraft] = React.useState<string | null>(null)\n  const setCaret = useCaretRestore(inputRef)\n\n  const commit = (raw: string, rawCaret: number) => {\n    const next = sanitizeIdNumber(kind, raw, rawCaret)\n    setValue(next.value)\n    setCaret({ position: next.caret })\n  }\n  const controlled = valueProp !== undefined\n  const resetRef = React.useRef(() => {})\n  React.useEffect(() => {\n    resetRef.current = () => {\n      setValue(normalizeIdNumber(kind, initialValue.current))\n      setTouched(false)\n      setDraft(null)\n      composingRef.current = false\n    }\n  })\n  React.useEffect(() => {\n    const element = inputRef.current?.form\n    if (!element || controlled) return\n    let timer: ReturnType<typeof setTimeout> | undefined\n    const reset = (event: Event) => {\n      timer = setTimeout(() => {\n        if (!event.defaultPrevented) resetRef.current()\n      })\n    }\n    element.addEventListener(\"reset\", reset)\n    return () => {\n      element.removeEventListener(\"reset\", reset)\n      clearTimeout(timer)\n    }\n  }, [controlled, form, inputRef])\n\n  return {\n    today,\n    value,\n    focused,\n    setFocused,\n    touched,\n    setTouched,\n    composingRef,\n    draft,\n    setDraft,\n    setCaret,\n    commit,\n  }\n}\n\nexport { useIdNumberInputState }\n",
      "type": "registry:ui",
      "target": "@ui/id-number-input/use-id-number-input-state.ts"
    },
    {
      "path": "src/registry/ui/id-number-input/types.ts",
      "content": "import type * as React from \"react\"\n\nimport type { IdNumberInputLabels } from \"./config\"\n\ntype IdNumberKind = \"idcard\" | \"bankcard\" | \"uscc\"\ntype IdNumberInputSize = \"sm\" | \"default\" | \"lg\"\n\ntype IdNumberInputProps = Omit<\n  React.ComponentProps<\"input\">,\n  | \"value\"\n  | \"defaultValue\"\n  | \"onChange\"\n  | \"type\"\n  | \"size\"\n  | \"children\"\n  | \"inputMode\"\n  | \"autoCapitalize\"\n> & {\n  /** 号码类型，默认身份证号。 */\n  kind?: IdNumberKind\n  /** 不带分隔符的原始号码；小写字母自动转大写。 */\n  value?: string\n  /** 非受控初始值，默认空串。 */\n  defaultValue?: string\n  /** 变化后的原始号码。 */\n  onValueChange?: (value: string) => void\n  /** 方条内左侧标签。 */\n  label?: React.ReactNode\n  /** 高度 28 / 32 / 36px，触屏上 36 / 40 / 44px。 */\n  size?: IdNumberInputSize\n  /** 外部错误优先显示，并阻止表单提交。 */\n  error?: React.ReactNode\n  /** 接受 15 位旧身份证，只检查位数和日期，不转换。默认 false。 */\n  allowLegacy15?: boolean\n  /** 银行卡默认只校验 16–19 位；可选 Luhn 校验。 */\n  bankcardCheck?: \"length\" | \"luhn\"\n  /** 身份证校验通过后显示出生日期与性别，默认 true。 */\n  showParsed?: boolean\n  /** 银行卡显示位数，默认 false。 */\n  showLength?: boolean\n  /** 未聚焦时打码；只影响显示，默认 false。 */\n  mask?: boolean\n  labels?: Partial<IdNumberInputLabels>\n}\n\nexport type { IdNumberInputProps, IdNumberInputSize, IdNumberKind }\n",
      "type": "registry:ui",
      "target": "@ui/id-number-input/types.ts"
    },
    {
      "path": "src/registry/ui/id-number-input/config.ts",
      "content": "const KIND_LABELS = {\n  idcard: {\n    placeholder: \"请输入身份证号\",\n    invalid: \"请输入正确的身份证号\",\n    required: \"请输入身份证号\",\n  },\n  bankcard: {\n    placeholder: \"请输入银行卡号\",\n    invalid: \"请输入正确的银行卡号\",\n    required: \"请输入银行卡号\",\n  },\n  uscc: {\n    placeholder: \"请输入统一社会信用代码\",\n    invalid: \"请输入正确的统一社会信用代码\",\n    required: \"请输入统一社会信用代码\",\n  },\n}\n\nconst DEFAULT_LABELS = {\n  ...KIND_LABELS.idcard,\n  male: \"男\",\n  female: \"女\",\n  parsed: \"{birthDate} · {gender}\",\n  length: \"{length} 位\",\n}\n\ntype IdNumberInputLabels = typeof DEFAULT_LABELS\n\nexport { DEFAULT_LABELS, KIND_LABELS }\nexport type { IdNumberInputLabels }\n",
      "type": "registry:ui",
      "target": "@ui/id-number-input/config.ts"
    },
    {
      "path": "src/registry/ui/id-number-input/utils.ts",
      "content": "import type { IdNumberKind } from \"./types\"\n\nconst USCC_CHARACTERS = \"0123456789ABCDEFGHJKLMNPQRTUWXY\"\nconst ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]\nconst USCC_WEIGHTS = [\n  1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28,\n]\n\ntype IdCardOptions = { allowLegacy15?: boolean }\ntype ParsedIdCard = { birthDate: string; gender: \"male\" | \"female\" }\n\n/** 仅用于输入整理；校验函数不会把不合法的原始值悄悄修正。 */\nfunction normalizeIdNumber(kind: IdNumberKind, value: string) {\n  let result = \"\"\n  for (const character of value.normalize(\"NFKC\").toUpperCase()) {\n    if (kind === \"uscc\") {\n      if (USCC_CHARACTERS.includes(character)) result += character\n    } else if (\n      /\\d/.test(character) ||\n      (kind === \"idcard\" && character === \"X\" && result.length === 17)\n    ) {\n      result += character\n    }\n    if (result.length === (kind === \"bankcard\" ? 19 : 18)) break\n  }\n  return result\n}\n\nfunction groupNumber(kind: IdNumberKind, value: string) {\n  if (kind === \"uscc\") return value\n  if (kind === \"bankcard\") return value.match(/.{1,4}/g)?.join(\" \") ?? \"\"\n  return [value.slice(0, 6), value.slice(6, 14), value.slice(14)]\n    .filter(Boolean)\n    .join(\" \")\n}\n\nfunction formatIdNumber(kind: IdNumberKind, value: string) {\n  return groupNumber(kind, normalizeIdNumber(kind, value))\n}\n\n/** 短号码也隐藏敏感部分，避免输入未完成时泄露全部内容。 */\nfunction maskIdNumber(kind: IdNumberKind, value: string) {\n  const raw = normalizeIdNumber(kind, value)\n  if (raw.length <= 4) return \"*\".repeat(raw.length)\n  // 打码后不再沿用输入时的分组：分组会把露出的末四位拆散（如 ***0 123）。\n  // 银行卡固定写成「**** **** **** 末四位」，长度不同的卡看起来一致；其余连写\n  if (kind === \"bankcard\") return `**** **** **** ${raw.slice(-4)}`\n  const prefix = kind === \"idcard\" ? 4 : 2\n  if (raw.length <= prefix + 4) return \"*\".repeat(raw.length)\n  return (\n    raw.slice(0, prefix) + \"*\".repeat(raw.length - prefix - 4) + raw.slice(-4)\n  )\n}\n\nfunction currentIdCardDate() {\n  const now = new Date()\n  return now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate()\n}\n\nfunction birthDateOf(value: string, today: number) {\n  const digits =\n    value.length === 15 ? `19${value.slice(6, 12)}` : value.slice(6, 14)\n  const year = Number(digits.slice(0, 4))\n  const month = Number(digits.slice(4, 6))\n  const day = Number(digits.slice(6, 8))\n  if (year < 1 || month < 1 || month > 12 || day < 1) return null\n  const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)\n  if (\n    day >\n    ([31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ??\n      0)\n  )\n    return null\n  if (Number(digits) > today) return null\n  return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`\n}\n\nfunction isValidIdCardAtDate(\n  value: string,\n  { allowLegacy15 = false }: IdCardOptions,\n  today: number,\n) {\n  if (allowLegacy15 && /^\\d{15}$/.test(value))\n    return birthDateOf(value, today) !== null\n  if (!/^\\d{17}[\\dX]$/.test(value) || !birthDateOf(value, today)) return false\n  const sum = ID_WEIGHTS.reduce(\n    (total, weight, index) => total + Number(value.charAt(index)) * weight,\n    0,\n  )\n  return \"10X98765432\"[sum % 11] === value[17]\n}\n\nfunction parseIdCardAtDate(\n  value: string,\n  options: IdCardOptions,\n  today: number,\n): ParsedIdCard | null {\n  if (!isValidIdCardAtDate(value, options, today)) return null\n  return {\n    birthDate: birthDateOf(value, today)!,\n    gender:\n      Number(value[value.length === 15 ? 14 : 16]) % 2 ? \"male\" : \"female\",\n  }\n}\n\nfunction isValidIdCard(value: string, options: IdCardOptions = {}) {\n  return isValidIdCardAtDate(value, options, currentIdCardDate())\n}\n\nfunction parseIdCard(\n  value: string,\n  options: IdCardOptions = {},\n): ParsedIdCard | null {\n  return parseIdCardAtDate(value, options, currentIdCardDate())\n}\n\nfunction isValidBankCard(\n  value: string,\n  { luhn = false }: { luhn?: boolean } = {},\n) {\n  if (!/^\\d{16,19}$/.test(value)) return false\n  if (!luhn) return true\n  let sum = 0\n  for (let index = value.length - 1; index >= 0; index--) {\n    let digit = Number(value.charAt(index))\n    if ((value.length - 1 - index) % 2 === 1) {\n      digit *= 2\n      if (digit > 9) digit -= 9\n    }\n    sum += digit\n  }\n  return sum % 10 === 0\n}\n\nfunction isValidUscc(value: string) {\n  if (\n    value.length !== 18 ||\n    [...value].some((character) => !USCC_CHARACTERS.includes(character))\n  )\n    return false\n  const sum = USCC_WEIGHTS.reduce(\n    (total, weight, index) =>\n      total + USCC_CHARACTERS.indexOf(value.charAt(index)) * weight,\n    0,\n  )\n  return USCC_CHARACTERS[(31 - (sum % 31)) % 31] === value[17]\n}\n\n/** 光标按整理后保留的字符数换算，非法字符和分隔符不占位置。 */\nfunction sanitizeIdNumber(kind: IdNumberKind, raw: string, caret: number) {\n  const value = normalizeIdNumber(kind, raw)\n  const count = Math.min(\n    normalizeIdNumber(kind, raw.slice(0, caret)).length,\n    value.length,\n  )\n  const text = formatIdNumber(kind, value)\n  let position = 0\n  let seen = 0\n  while (position < text.length && seen < count) {\n    if (text[position] !== \" \") seen++\n    position++\n  }\n  return { value, caret: position }\n}\n\nexport {\n  currentIdCardDate,\n  isValidIdCardAtDate,\n  parseIdCardAtDate,\n  formatIdNumber,\n  isValidBankCard,\n  isValidIdCard,\n  isValidUscc,\n  maskIdNumber,\n  normalizeIdNumber,\n  parseIdCard,\n  sanitizeIdNumber,\n}\nexport type { IdCardOptions, ParsedIdCard }\n",
      "type": "registry:ui",
      "target": "@ui/id-number-input/utils.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"
    },
    {
      "path": "src/registry/hooks/use-caret-restore.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/** 格式化提交后恢复光标；即使文本未变，也用新请求触发恢复。 */\nfunction useCaretRestore(inputRef: React.RefObject<HTMLInputElement | null>) {\n  const [caret, setCaret] = React.useState<{ position: number } | null>(null)\n  React.useLayoutEffect(() => {\n    const input = inputRef.current\n    if (caret && input && document.activeElement === input)\n      input.setSelectionRange(caret.position, caret.position)\n  }, [caret, inputRef])\n  return setCaret\n}\n\nexport { useCaretRestore }\n",
      "type": "registry:ui",
      "target": "hooks/use-caret-restore.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"
}
