{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "document-field",
  "title": "Document Field",
  "description": "A CPF and CNPJ field with masking, check-digit validation, alphanumeric CNPJ support, and rounded, pill, and compact variants.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "badge",
    "input",
    "label",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/components/document-field.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { CheckCircle2, CircleAlert } from \"lucide-react\";\nimport {\n  type ChangeEvent,\n  type ComponentProps,\n  createContext,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/uai-utils\";\n\nexport const DOCUMENT_FIELD_VARIANTS = [\"rounded\", \"pill\", \"compact\"] as const;\nexport const DOCUMENT_FIELD_ACCEPTS = [\"cpf\", \"cnpj\", \"any\"] as const;\n\nexport type DocumentFieldVariant = (typeof DOCUMENT_FIELD_VARIANTS)[number];\nexport type DocumentFieldAccept = (typeof DOCUMENT_FIELD_ACCEPTS)[number];\nexport type DocumentKind = \"cpf\" | \"cnpj\";\nexport type DocumentFieldStatus = \"empty\" | \"incomplete\" | \"valid\" | \"invalid\";\n\nconst CPF_LENGTH = 11;\nconst CNPJ_LENGTH = 14;\n\n/** Uppercases and strips punctuation. CNPJs issued from July 2026 may contain letters. */\nexport function normalizeDocument(value: string, accept: DocumentFieldAccept = \"any\") {\n  const upper = value.toUpperCase();\n  if (accept === \"cpf\") return upper.replace(/\\D/g, \"\").slice(0, CPF_LENGTH);\n  return upper.replace(/[^0-9A-Z]/g, \"\").slice(0, CNPJ_LENGTH);\n}\n\nexport function detectDocumentKind(\n  normalized: string,\n  accept: DocumentFieldAccept = \"any\",\n): DocumentKind {\n  if (accept !== \"any\") return accept;\n  return normalized.length > CPF_LENGTH || /[A-Z]/.test(normalized) ? \"cnpj\" : \"cpf\";\n}\n\nexport function formatDocument(normalized: string, kind: DocumentKind) {\n  const groups =\n    kind === \"cpf\"\n      ? [\n          [0, 3, \"\"],\n          [3, 6, \".\"],\n          [6, 9, \".\"],\n          [9, 11, \"-\"],\n        ]\n      : [\n          [0, 2, \"\"],\n          [2, 5, \".\"],\n          [5, 8, \".\"],\n          [8, 12, \"/\"],\n          [12, 14, \"-\"],\n        ];\n  let formatted = \"\";\n  for (const [start, end, separator] of groups as [number, number, string][]) {\n    const part = normalized.slice(start, end);\n    if (!part) break;\n    formatted += separator + part;\n  }\n  return formatted;\n}\n\nfunction checkDigit(values: number[], weights: number[]) {\n  const sum = values.reduce((total, value, index) => total + value * (weights[index] ?? 0), 0);\n  const remainder = sum % 11;\n  return remainder < 2 ? 0 : 11 - remainder;\n}\n\nexport function isValidCpf(value: string) {\n  const digits = value.replace(/\\D/g, \"\");\n  if (digits.length !== CPF_LENGTH || /^(\\d)\\1+$/.test(digits)) return false;\n  const numbers = [...digits].map(Number);\n  const first = checkDigit(numbers.slice(0, 9), [10, 9, 8, 7, 6, 5, 4, 3, 2]);\n  const second = checkDigit(numbers.slice(0, 10), [11, 10, 9, 8, 7, 6, 5, 4, 3, 2]);\n  return numbers[9] === first && numbers[10] === second;\n}\n\n/** Validates numeric and alphanumeric CNPJs; letters count as their ASCII code minus 48. */\nexport function isValidCnpj(value: string) {\n  const normalized = value.toUpperCase().replace(/[^0-9A-Z]/g, \"\");\n  if (!/^[0-9A-Z]{12}\\d{2}$/.test(normalized) || /^(.)\\1+$/.test(normalized)) return false;\n  const values = [...normalized].map((character) => character.charCodeAt(0) - 48);\n  const weights = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];\n  const first = checkDigit(values.slice(0, 12), weights.slice(1));\n  const second = checkDigit(values.slice(0, 13), weights);\n  return values[12] === first && values[13] === second;\n}\n\nexport function isValidDocument(value: string, kind: DocumentKind) {\n  return kind === \"cpf\" ? isValidCpf(value) : isValidCnpj(value);\n}\n\nexport type DocumentFieldDetails = {\n  kind: DocumentKind;\n  complete: boolean;\n  valid: boolean;\n};\n\nexport type DocumentFieldProps = Omit<ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  variant?: DocumentFieldVariant;\n  accept?: DocumentFieldAccept;\n  /** Unmasked value: digits, plus uppercase letters for alphanumeric CNPJs. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, details: DocumentFieldDetails) => void;\n  /** Forces the invalid state, for example when the server rejects a valid number. */\n  invalid?: boolean;\n  disabled?: boolean;\n};\n\nfunction documentFieldChrome(variant: DocumentFieldVariant) {\n  const compact = variant === \"compact\";\n  return {\n    labelClass: compact ? \"mb-1 text-[11.5px] leading-4\" : \"mb-1.5 text-[12px] leading-4\",\n    controlClass: cn(\n      compact ? \"gap-1 py-0.5 pr-1 pl-0.5\" : \"gap-1.5 py-1 pr-1.5 pl-1\",\n      variant === \"pill\" ? \"rounded-full\" : compact ? \"rounded-xl\" : \"rounded-[14px]\",\n    ),\n    inputClass: compact\n      ? \"h-7 px-2 text-[12.5px] leading-4 md:text-[12.5px]\"\n      : \"h-8 px-2.5 text-[13px] leading-[18px] md:text-[13px]\",\n    kindClass: compact ? \"h-5 px-1.5 text-[10.5px]\" : \"h-[22px] px-2 text-[11px]\",\n    messageClass: compact\n      ? \"mt-1.5 gap-1.5 px-0.5 text-[11.5px] leading-4\"\n      : \"mt-2 gap-2 px-1 text-[12px] leading-4\",\n    iconClass: compact ? \"size-3\" : \"size-3.5\",\n  };\n}\n\ntype DocumentFieldContextValue = {\n  accept: DocumentFieldAccept;\n  value: string;\n  kind: DocumentKind;\n  status: DocumentFieldStatus;\n  showInvalid: boolean;\n  disabled: boolean;\n  setValue: (value: string) => void;\n  markTouched: () => void;\n  inputId: string;\n  descriptionId: string;\n  errorId: string;\n  chrome: ReturnType<typeof documentFieldChrome>;\n};\n\nconst DocumentFieldContext = createContext<DocumentFieldContextValue | null>(null);\n\nfunction useDocumentField(name: string) {\n  const context = useContext(DocumentFieldContext);\n  if (!context) throw new Error(`${name} must be used within DocumentField`);\n  return context;\n}\n\nconst documentFieldVariants = cva(\"w-full text-foreground\", {\n  variants: { variant: { rounded: \"\", pill: \"\", compact: \"\" } },\n});\n\nexport function DocumentField({\n  variant = \"rounded\",\n  accept = \"any\",\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  invalid = false,\n  disabled = false,\n  children,\n  className,\n  ...props\n}: DocumentFieldProps) {\n  const inputId = useId();\n  const descriptionId = useId();\n  const errorId = useId();\n  const [internalValue, setInternalValue] = useState(() => normalizeDocument(defaultValue, accept));\n  const [touched, setTouched] = useState(false);\n  const current = normalizeDocument(value ?? internalValue, accept);\n  const kind = detectDocumentKind(current, accept);\n  const complete = current.length === (kind === \"cpf\" ? CPF_LENGTH : CNPJ_LENGTH);\n  const valid = complete && isValidDocument(current, kind);\n  const status: DocumentFieldStatus = invalid\n    ? \"invalid\"\n    : !current\n      ? \"empty\"\n      : valid\n        ? \"valid\"\n        : complete\n          ? \"invalid\"\n          : \"incomplete\";\n  const showInvalid = status === \"invalid\" || (touched && status === \"incomplete\");\n\n  const setValue = (raw: string) => {\n    const next = normalizeDocument(raw, accept);\n    if (value === undefined) setInternalValue(next);\n    const nextKind = detectDocumentKind(next, accept);\n    const nextComplete = next.length === (nextKind === \"cpf\" ? CPF_LENGTH : CNPJ_LENGTH);\n    onValueChange?.(next, {\n      kind: nextKind,\n      complete: nextComplete,\n      valid: nextComplete && isValidDocument(next, nextKind),\n    });\n  };\n\n  return (\n    <DocumentFieldContext.Provider\n      value={{\n        accept,\n        value: current,\n        kind,\n        status,\n        showInvalid,\n        disabled,\n        setValue,\n        markTouched: () => setTouched(true),\n        inputId,\n        descriptionId,\n        errorId,\n        chrome: documentFieldChrome(variant),\n      }}\n    >\n      <div\n        data-slot=\"document-field\"\n        data-variant={variant}\n        data-status={status}\n        data-kind={current ? kind : undefined}\n        className={cn(documentFieldVariants({ variant }), disabled && \"opacity-55\", className)}\n        {...props}\n      >\n        {children}\n      </div>\n    </DocumentFieldContext.Provider>\n  );\n}\n\nconst defaultLabels: Record<DocumentFieldAccept, string> = {\n  cpf: \"CPF\",\n  cnpj: \"CNPJ\",\n  any: \"CPF ou CNPJ\",\n};\n\nexport type DocumentFieldLabelProps = ComponentProps<\"label\">;\n\nexport function DocumentFieldLabel({ children, className, ...props }: DocumentFieldLabelProps) {\n  const context = useDocumentField(\"DocumentFieldLabel\");\n  return (\n    <Label\n      data-slot=\"document-field-label\"\n      className={cn(\n        \"block font-medium text-muted-foreground select-auto\",\n        context.chrome.labelClass,\n        className,\n      )}\n      {...props}\n      htmlFor={context.inputId}\n    >\n      {children ?? defaultLabels[context.accept]}\n    </Label>\n  );\n}\n\nexport type DocumentFieldControlProps = ComponentProps<\"div\">;\n\nexport function DocumentFieldControl({ children, className, ...props }: DocumentFieldControlProps) {\n  const context = useDocumentField(\"DocumentFieldControl\");\n  return (\n    <div\n      data-slot=\"document-field-control\"\n      className={cn(\n        \"flex min-w-0 items-center border bg-card transition-[border-color,box-shadow] duration-[120ms] ease-out hover:border-border-strong focus-within:border-border-strong focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_18%,transparent)] motion-reduce:transition-none\",\n        context.chrome.controlClass,\n        context.showInvalid\n          ? \"border-destructive/70 hover:border-destructive focus-within:border-destructive\"\n          : context.status === \"valid\"\n            ? \"border-[color-mix(in_oklab,var(--success)_45%,var(--border))]\"\n            : \"border-border\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\n/** Maps a caret after `count` value characters to its index in the formatted string. */\nfunction caretAfter(formatted: string, count: number) {\n  if (count === 0) return 0;\n  let seen = 0;\n  for (let index = 0; index < formatted.length; index += 1) {\n    if (/[0-9A-Z]/.test(formatted[index] ?? \"\")) seen += 1;\n    if (seen === count) return index + 1;\n  }\n  return formatted.length;\n}\n\nexport type DocumentFieldInputProps = Omit<\n  ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"onChange\"\n>;\n\nexport function DocumentFieldInput({\n  className,\n  disabled,\n  onBlur,\n  ...props\n}: DocumentFieldInputProps) {\n  const context = useDocumentField(\"DocumentFieldInput\");\n  const inputRef = useRef<HTMLInputElement>(null);\n  const pendingCaret = useRef<number | null>(null);\n  const formatted = formatDocument(context.value, context.kind);\n  const describedBy = [context.descriptionId, context.showInvalid ? context.errorId : null]\n    .filter(Boolean)\n    .join(\" \");\n\n  useLayoutEffect(() => {\n    const input = inputRef.current;\n    if (pendingCaret.current === null || !input || document.activeElement !== input) return;\n    const caret = caretAfter(input.value, pendingCaret.current);\n    input.setSelectionRange(caret, caret);\n    pendingCaret.current = null;\n  });\n\n  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {\n    const { value, selectionStart } = event.target;\n    pendingCaret.current = normalizeDocument(\n      value.slice(0, selectionStart ?? value.length),\n      context.accept,\n    ).length;\n    context.setValue(value);\n  };\n\n  return (\n    <Input\n      inputMode={context.accept === \"cpf\" ? \"numeric\" : \"text\"}\n      autoCapitalize=\"characters\"\n      autoComplete=\"off\"\n      spellCheck={false}\n      placeholder={context.accept === \"cnpj\" ? \"00.000.000/0000-00\" : \"000.000.000-00\"}\n      data-slot=\"document-field-input\"\n      className={cn(\n        \"min-w-0 flex-1 rounded-none border-0 bg-transparent py-0 font-medium tabular-nums tracking-[0.01em] shadow-none outline-none placeholder:font-normal placeholder:text-subtle-foreground focus-visible:ring-0 aria-invalid:ring-0 disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-100 dark:bg-transparent\",\n        context.chrome.inputClass,\n        className,\n      )}\n      {...props}\n      ref={inputRef}\n      id={context.inputId}\n      value={formatted}\n      onChange={handleChange}\n      onBlur={(event) => {\n        onBlur?.(event);\n        context.markTouched();\n      }}\n      disabled={context.disabled || disabled}\n      aria-invalid={context.showInvalid || undefined}\n      aria-describedby={describedBy || undefined}\n    />\n  );\n}\n\nexport type DocumentFieldKindProps = ComponentProps<\"span\">;\n\n/** Names the detected document type once the person starts typing. */\nexport function DocumentFieldKind({ className, ...props }: DocumentFieldKindProps) {\n  const context = useDocumentField(\"DocumentFieldKind\");\n  if (!context.value) return null;\n  return (\n    <Badge\n      key={context.kind}\n      variant=\"secondary\"\n      data-slot=\"document-field-kind\"\n      data-kind={context.kind}\n      className={cn(\n        \"shrink-0 animate-in rounded-full border-0 bg-muted py-0 font-medium text-muted-foreground duration-180 ease-out-quint fade-in-0 zoom-in-96 motion-reduce:animate-none\",\n        context.chrome.kindClass,\n        className,\n      )}\n      {...props}\n    >\n      {context.kind === \"cpf\" ? \"CPF\" : \"CNPJ\"}\n    </Badge>\n  );\n}\n\nexport type DocumentFieldStatusIconProps = ComponentProps<\"span\">;\n\nexport function DocumentFieldStatusIcon({ className, ...props }: DocumentFieldStatusIconProps) {\n  const context = useDocumentField(\"DocumentFieldStatusIcon\");\n  if (context.status !== \"valid\" && !context.showInvalid) return null;\n  return (\n    <span\n      key={context.showInvalid ? \"invalid\" : \"valid\"}\n      data-slot=\"document-field-status-icon\"\n      aria-hidden=\"true\"\n      className={cn(\n        \"inline-flex shrink-0 animate-in items-center duration-180 ease-out-quint fade-in-0 motion-reduce:animate-none\",\n        context.showInvalid ? \"zoom-in-96 text-destructive-ink\" : \"zoom-in-75 text-success-ink\",\n        className,\n      )}\n      {...props}\n    >\n      {context.showInvalid ? (\n        <CircleAlert className=\"size-4\" strokeWidth={1.9} />\n      ) : (\n        <CheckCircle2 className=\"size-4\" strokeWidth={1.9} />\n      )}\n    </span>\n  );\n}\n\nexport type DocumentFieldDescriptionProps = ComponentProps<\"p\">;\n\nexport function DocumentFieldDescription({ className, ...props }: DocumentFieldDescriptionProps) {\n  const context = useDocumentField(\"DocumentFieldDescription\");\n  if (context.showInvalid) return null;\n  return (\n    <p\n      data-slot=\"document-field-description\"\n      className={cn(\"flex text-subtle-foreground\", context.chrome.messageClass, className)}\n      {...props}\n      id={context.descriptionId}\n    />\n  );\n}\n\nexport type DocumentFieldErrorProps = ComponentProps<\"p\">;\n\nexport function DocumentFieldError({ children, className, ...props }: DocumentFieldErrorProps) {\n  const context = useDocumentField(\"DocumentFieldError\");\n  if (!context.showInvalid) return null;\n  const name = context.kind === \"cpf\" ? \"CPF\" : \"CNPJ\";\n  const fallback =\n    context.status === \"incomplete\"\n      ? `Digite o ${name} completo.`\n      : `${name} inválido. Confira os ${context.kind === \"cpf\" ? \"números\" : \"caracteres\"}.`;\n  return (\n    <p\n      data-slot=\"document-field-error\"\n      className={cn(\n        \"flex animate-in items-start text-destructive-ink duration-240 ease-out-quint fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none\",\n        context.chrome.messageClass,\n        className,\n      )}\n      {...props}\n      id={context.errorId}\n      role=\"alert\"\n    >\n      {children ?? fallback}\n    </p>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/uai/document-field.tsx"
    }
  ],
  "categories": [
    "brazil",
    "forms"
  ],
  "type": "registry:ui"
}