{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plate-input",
  "title": "Plate Input",
  "description": "An Iranian vehicle license plate input with digit segments, a letter picker including wheelchair plates, and the Iran serial tab.",
  "dependencies": [
    "@base-ui/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@persianlabsui/input-group",
    "@persianlabsui/select",
    "@persianlabsui/normalize-persian-digits"
  ],
  "files": [
    {
      "path": "registry/base/ui/plate-input.tsx",
      "content": "\"use client\"\n\nimport { AccessibilityIcon } from \"lucide-react\"\nimport * as React from \"react\"\n\nimport { InputGroup } from \"@/components/ui/input-group\"\nimport { InputGroupInput } from \"@/components/ui/input-group\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport { normalizePersianDigits } from \"@/lib/normalize-persian-digits\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface PlateValue {\n  /** The two digits on the right of the plate, e.g. \"57\". */\n  twoDigit: string\n  /** The Persian letter, or {@link DISABLED_PLATE_LETTER} for the wheelchair plate. */\n  letter: string\n  /** The three digits left of the letter, e.g. \"555\". */\n  threeDigit: string\n  /** The two-digit Iran code next to the ایران caption, e.g. \"11\". */\n  serial: string\n}\n\n/** Value used for the accessibility (wheelchair) plate option. */\nexport const DISABLED_PLATE_LETTER = \"معلولان\"\n\n/** Every plate letter the picker offers — the same set and order AZKI uses. */\nexport const PLATE_LETTERS: ReadonlyArray<{\n  value: string\n  label: string\n}> = [\n  { value: \"ا\", label: \"الف\" },\n  { value: \"ب\", label: \"ب\" },\n  { value: \"پ\", label: \"پ\" },\n  { value: \"ت\", label: \"ت\" },\n  { value: \"ث\", label: \"ث\" },\n  { value: \"ج\", label: \"ج\" },\n  { value: \"ح\", label: \"ح\" },\n  { value: \"د\", label: \"د\" },\n  { value: \"ر\", label: \"ر\" },\n  { value: \"ز\", label: \"ز\" },\n  { value: \"ژ\", label: \"ژ\" },\n  { value: \"س\", label: \"س\" },\n  { value: \"ش\", label: \"ش\" },\n  { value: \"ص\", label: \"ص\" },\n  { value: \"ض\", label: \"ض\" },\n  { value: \"ط\", label: \"ط\" },\n  { value: \"ظ\", label: \"ظ\" },\n  { value: \"ع\", label: \"ع\" },\n  { value: \"ف\", label: \"ف\" },\n  { value: \"ق\", label: \"ق\" },\n  { value: \"ک\", label: \"ک\" },\n  { value: \"گ\", label: \"گ\" },\n  // Latin-lettered special plates (diplomatic / service types).\n  { value: \"D\", label: \"D\" },\n  { value: \"S\", label: \"S\" },\n]\n\n/**\n * The value an untouched PlateInput holds — letter defaults to الف (\"ا\").\n * Seed controlled state with this so external mirrors stay in sync from\n * the first render.\n */\nexport const DEFAULT_PLATE_VALUE: PlateValue = {\n  twoDigit: \"\",\n  letter: \"ا\",\n  threeDigit: \"\",\n  serial: \"\",\n}\n\nfunction mergePlate(patch?: Partial<PlateValue>): PlateValue {\n  return { ...DEFAULT_PLATE_VALUE, ...patch }\n}\n\nfunction onlyDigits(raw: string, maxLength: number) {\n  return normalizePersianDigits(raw).replace(/\\D/g, \"\").slice(0, maxLength)\n}\n\n/** Strips separators/ZWNJ before comparing against the known letters. */\nfunction matchLetter(candidate: string): string {\n  const clean = candidate.replace(/[\\s\\u200C._\\-|/\\\\]/g, \"\")\n  if (/^(معلولان|الف|♿)$/.test(clean)) {\n    return /^(معلولان|♿)$/.test(clean) ? DISABLED_PLATE_LETTER : \"ا\"\n  }\n  const found = PLATE_LETTERS.find(\n    (item) =>\n      item.value === clean || item.label.replace(/\\u200C/g, \"\") === clean\n  )\n  return found?.value ?? \"\"\n}\n\n/**\n * Understands full-plate pastes in any reasonable shape — \"57-الف-555-55\",\n * \"57 ب 555 ایران 11\", \"57الف55511\", \"۵۷/ص/۵۵۵/۱۱\" — by splitting the text\n * into digit runs and Persian-letter runs. Returns null when the text isn't\n * recognizably a plate (or more than one segment's worth), so plain pastes\n * keep filling just the focused segment.\n */\nfunction parsePlateText(raw: string): Partial<PlateValue> | null {\n  const tokens = normalizePersianDigits(raw).match(/\\d+|[^\\d]+/g)\n  if (!tokens) return null\n\n  const digitRuns: string[] = []\n  let letter = \"\"\n\n  for (const token of tokens) {\n    if (/^\\d/.test(token)) {\n      digitRuns.push(token)\n    } else if (!letter && /[\\u0600-\\u06FF\\u267F]/.test(token)) {\n      letter = matchLetter(token)\n    }\n  }\n\n  const [first = \"\", second = \"\", third = \"\"] = digitRuns\n\n  if (third) {\n    return {\n      twoDigit: first.slice(0, 2),\n      ...(letter ? { letter } : {}),\n      threeDigit: second.slice(0, 3),\n      serial: third.slice(0, 2),\n    }\n  }\n\n  if (second && letter) {\n    return {\n      twoDigit: first.slice(0, 2),\n      letter,\n      threeDigit: second.slice(0, 3),\n    }\n  }\n\n  return null\n}\n\nexport interface PlateInputProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> {\n  /** The controlled plate value. */\n  value?: Partial<PlateValue>\n  /** The initial plate value when uncontrolled. */\n  defaultValue?: Partial<PlateValue>\n  /** Called with the full value whenever any segment changes. */\n  onValueChange?: (value: PlateValue) => void\n  disabled?: boolean\n  /** Marks the whole plate invalid, e.g. when validation fails on submit. */\n  invalid?: boolean\n}\n\n/**\n * A segmented Iranian vehicle license plate input. Plates have no RTL\n * rendering — the flag/IR/IRAN band sits on the physical left — so the\n * component is forced LTR regardless of the surrounding direction.\n *\n * An `id` passed to the component lands on the first digit input, so\n * `<label htmlFor>` clicks (and Field/label association generally) focus\n * the plate where typing starts.\n */\nexport function PlateInput({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  disabled,\n  invalid,\n  className,\n  id,\n  ...props\n}: PlateInputProps) {\n  // The value is an object, so the shared useControllableState hook (which\n  // switches on reference equality) would fight callers passing inline\n  // literals — control detection is done manually instead.\n  const isControlled = valueProp !== undefined\n  const [internalValue, setInternalValue] = React.useState<PlateValue>(() =>\n    mergePlate(defaultValue)\n  )\n  const value = isControlled ? mergePlate(valueProp) : internalValue\n\n  const twoDigitRef = React.useRef<HTMLInputElement>(null)\n  const letterRef = React.useRef<HTMLSpanElement>(null)\n  const threeDigitRef = React.useRef<HTMLInputElement>(null)\n  const serialRef = React.useRef<HTMLInputElement>(null)\n\n  // The letter picker opens itself when the flow reaches it, even if a\n  // letter was already chosen — picking again stays one keystroke away.\n  const [letterOpen, setLetterOpen] = React.useState(false)\n\n  // The select trigger's ref is owned by SelectTrigger (for direction\n  // measurement), so focusing goes through a wrapper instead.\n  const focusLetter = React.useCallback(() => {\n    letterRef.current?.querySelector<HTMLButtonElement>(\"button\")?.focus()\n    setLetterOpen(true)\n  }, [])\n\n  // Clicking dead space (padding, gaps, the band, the divider) lands on the\n  // first empty segment, mirroring how single-input fields behave when their\n  // label or container is clicked. Interactive children opt out.\n  const handleGroupClick = React.useCallback(\n    (event: React.MouseEvent<HTMLDivElement>) => {\n      if ((event.target as HTMLElement).closest(\"input, button\")) return\n      if (value.twoDigit.length < 2) return twoDigitRef.current?.focus()\n      if (!value.letter) return focusLetter()\n      if (value.threeDigit.length < 3) return threeDigitRef.current?.focus()\n      if (value.serial.length < 2) return serialRef.current?.focus()\n      twoDigitRef.current?.focus()\n    },\n    [value, focusLetter]\n  )\n\n  const setValue = React.useCallback(\n    (patch: Partial<PlateValue>) => {\n      const next = { ...value, ...patch }\n      if (!isControlled) setInternalValue(next)\n      onValueChange?.(next)\n    },\n    [value, isControlled, onValueChange]\n  )\n\n  // Full-plate pastes (any format) replace every segment; unrecognized text\n  // falls through to the browser's default paste and fills one segment.\n  const applyParsedPlate = React.useCallback(\n    (raw: string): boolean => {\n      const parsed = parsePlateText(raw)\n      if (!parsed) return false\n      setValue(parsed)\n      return true\n    },\n    [setValue]\n  )\n\n  const handleSegmentPaste = (\n    event: React.ClipboardEvent<HTMLInputElement>\n  ) => {\n    if (applyParsedPlate(event.clipboardData.getData(\"text\"))) {\n      event.preventDefault()\n    }\n  }\n\n  const complete =\n    value.twoDigit.length === 2 &&\n    value.letter !== \"\" &&\n    value.threeDigit.length === 3 &&\n    value.serial.length === 2\n\n  return (\n    <InputGroup\n      dir=\"ltr\"\n      role=\"group\"\n      data-slot=\"plate-input\"\n      data-complete={complete ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      aria-invalid={invalid || undefined}\n      onClick={handleGroupClick}\n      className={cn(\n        \"h-fit w-fit gap-1 bg-background p-1.5 select-none dark:bg-background\",\n        invalid &&\n          \"border-destructive ring-3 ring-destructive/20 dark:border-destructive/50 dark:ring-destructive/40\",\n        className\n      )}\n      {...props}\n    >\n      {/* Country band — flag over IR over IRAN, aligned to the plate's edge. */}\n      <div\n        data-slot=\"plate-input-band\"\n        aria-hidden\n        className=\"flex shrink-0 flex-col items-start gap-0.5 self-stretch rounded-md bg-blue-700 px-1 py-1 text-blue-50 dark:bg-blue-800\"\n      >\n        <IranFlag className=\"h-2.5 w-auto\" />\n        <span className=\"text-[9px] leading-none font-bold\">IR</span>\n        <span className=\"text-[8px] leading-none tracking-widest\">IRAN</span>\n      </div>\n\n      <PlateSegment\n        ref={twoDigitRef}\n        id={id}\n        value={value.twoDigit}\n        maxLength={2}\n        disabled={disabled}\n        invalidEmpty={invalid && value.twoDigit.length === 0}\n        aria-label=\"دو رقم پلاک\"\n        placeholder=\"55\"\n        className=\"w-10\"\n        onValueChange={(twoDigit) => setValue({ twoDigit })}\n        moveNext={focusLetter}\n        onPaste={handleSegmentPaste}\n      />\n\n      <span ref={letterRef} className=\"inline-flex\">\n        <Select\n          open={letterOpen}\n          onOpenChange={setLetterOpen}\n          value={value.letter || null}\n          onValueChange={(letter) => {\n            setValue({ letter: letter ?? \"\" })\n            threeDigitRef.current?.focus()\n          }}\n          disabled={disabled}\n        >\n          <SelectTrigger\n            // Persian letters read RTL even inside the forced-LTR plate; the\n            // repo's Select measures this and mirrors the portaled popup too.\n            dir=\"rtl\"\n            aria-label=\"حرف پلاک\"\n            aria-invalid={(invalid && !value.letter) || undefined}\n            className={cn(\n              \"w-16 rounded-md border-0 bg-muted/60 px-1.5 shadow-none focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 data-[popup-open]:ring-3 data-[popup-open]:ring-ring/50 [&_svg:not([class*='size-'])]:size-3\",\n              \"aria-invalid:bg-destructive/10 aria-invalid:text-destructive\"\n            )}\n          >\n            <SelectValue className=\"text-base\">\n              {(letter: string) => {\n                if (letter === DISABLED_PLATE_LETTER) {\n                  return <AccessibilityIcon className=\"size-4! text-info\" />\n                }\n                return (\n                  PLATE_LETTERS.find((item) => item.value === letter)?.label ??\n                  letter\n                )\n              }}\n            </SelectValue>\n          </SelectTrigger>\n          {/* Fixed narrow width; min-w-0 beats the content part's built-in\n              min-w-40, which would otherwise force a wide popup. finalFocus\n              keeps Base UI from restoring focus to the trigger on close —\n              the three-digit segment is the next stop instead. */}\n          <SelectContent\n            className=\"w-[66px] min-w-0\"\n            finalFocus={threeDigitRef}\n          >\n            {PLATE_LETTERS.map((item) => (\n              <SelectItem\n                key={item.value}\n                value={item.value}\n                // Center the letters (and the wheelchair glyph) so they sit on\n                // the popup's axis instead of hugging the check-indicator\n                // gutter.\n                className=\"justify-center px-2\"\n              >\n                {item.label}\n              </SelectItem>\n            ))}\n            <SelectItem\n              value={DISABLED_PLATE_LETTER}\n              className=\"justify-center px-2\"\n            >\n              <AccessibilityIcon className=\"text-info\" />\n              <span className=\"sr-only\">{DISABLED_PLATE_LETTER}</span>\n            </SelectItem>\n          </SelectContent>\n        </Select>\n      </span>\n\n      <PlateSegment\n        ref={threeDigitRef}\n        value={value.threeDigit}\n        maxLength={3}\n        disabled={disabled}\n        invalidEmpty={invalid && value.threeDigit.length === 0}\n        aria-label=\"سه رقم پلاک\"\n        placeholder=\"555\"\n        className=\"w-12\"\n        onValueChange={(threeDigit) => setValue({ threeDigit })}\n        moveNext={() => serialRef.current?.focus()}\n        movePrevious={() => twoDigitRef.current?.focus()}\n        onPaste={handleSegmentPaste}\n      />\n\n      <span aria-hidden className=\"w-px self-stretch bg-border\" />\n\n      <div\n        data-slot=\"plate-input-serial\"\n        className=\"flex cursor-default flex-col items-center justify-center gap-0.5 self-stretch rounded-md bg-muted/60 px-1 py-0.5\"\n        onClick={(event) => {\n          event.currentTarget.querySelector(\"input\")?.focus()\n        }}\n      >\n        <span className=\"pointer-events-none text-[9px] leading-none text-muted-foreground\">\n          ایران\n        </span>\n        <PlateSegment\n          ref={serialRef}\n          value={value.serial}\n          maxLength={2}\n          disabled={disabled}\n          invalidEmpty={(invalid && value.serial.length === 0) || undefined}\n          aria-label=\"کد ایران\"\n          placeholder=\"55\"\n          className={cn(\n            \"w-8 rounded-none bg-transparent px-0.5\",\n            invalid &&\n              value.serial.length === 0 &&\n              \"border border-destructive/60 bg-destructive/10 text-destructive\"\n          )}\n          onValueChange={(serial) => setValue({ serial })}\n          movePrevious={() => threeDigitRef.current?.focus()}\n          onPaste={handleSegmentPaste}\n        />\n      </div>\n    </InputGroup>\n  )\n}\ninterface PlateSegmentProps extends Omit<\n  React.ComponentProps<typeof InputGroupInput>,\n  \"value\" | \"onChange\"\n> {\n  value: string\n  maxLength: number\n  /** When marking failures, this segment is one of the empty culprits. */\n  invalidEmpty?: boolean\n  onValueChange: (value: string) => void\n  moveNext?: () => void\n  movePrevious?: () => void\n}\n\nfunction PlateSegment({\n  value,\n  maxLength,\n  invalidEmpty,\n  onValueChange,\n  moveNext,\n  movePrevious,\n  className,\n  ...props\n}: PlateSegmentProps) {\n  return (\n    <InputGroupInput\n      type=\"tel\"\n      inputMode=\"numeric\"\n      autoComplete=\"off\"\n      spellCheck={false}\n      value={value}\n      aria-invalid={invalidEmpty || undefined}\n      onChange={(event) => {\n        const next = onlyDigits(event.target.value, maxLength)\n        if (next === value) return\n        onValueChange(next)\n        if (next.length === maxLength && moveNext) {\n          moveNext()\n        }\n      }}\n      onKeyDown={(event) => {\n        if (event.key === \"Backspace\" && !value && movePrevious) {\n          event.preventDefault()\n          movePrevious()\n        }\n      }}\n      className={cn(\n        \"rounded-md bg-muted/60 px-1 text-center text-sm font-medium tracking-[0.15em]\",\n        invalidEmpty &&\n          \"border border-destructive/60 bg-destructive/10 text-destructive\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/**\n * Twemoji's Iran flag (Twitter, CC BY 4.0), inlined so the band needs no\n * image asset.\n */\nfunction IranFlag({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 36 36\"\n      aria-hidden\n      className={className}\n    >\n      <path fill=\"#da0001\" d=\"M0 27a4 4 0 0 0 4 4h28a4 4 0 0 0 4-4v-4H0z\" />\n      <path fill=\"#eee\" d=\"M0 13h36v10H0z\" />\n      <path fill=\"#239f40\" d=\"M36 13V9a4 4 0 0 0-4-4H4a4 4 0 0 0-4 4v4z\" />\n      <path fill=\"#e96667\" d=\"M0 23h36v1H0z\" />\n      <g fill=\"#be1931\">\n        <path d=\"M19.465 14.969c.957.49 3.038 2.953.798 5.731c1.391-.308 3.162-4.408-.798-5.731m-2.937 0c-3.959 1.323-2.189 5.423-.798 5.731c-2.24-2.778-.159-5.241.798-5.731m1.453-.143c.04.197 1.101.436.974-.573c-.168.408-.654.396-.968.207c-.432.241-.835.182-.988-.227c-.148.754.587.975.982.593\" />\n        <path d=\"M20.538 17.904c-.015-1.248-.677-2.352-1.329-2.799c.43.527 1.752 3.436-.785 5.351l.047-5.097l-.475-.418l-.475.398l.08 5.146l-.018-.015c-2.563-1.914-1.233-4.837-.802-5.365c-.652.447-1.315 1.551-1.329 2.799c-.013 1.071.477 2.243 1.834 3.205a6.4 6.4 0 0 1-1.678.201c.464.253 1.34.192 2.007.131l.001.068l.398.437l.4-.455v-.052c.672.062 1.567.129 2.039-.128a6.3 6.3 0 0 1-1.732-.213c1.344-.961 1.83-2.127 1.817-3.194\" />\n      </g>\n      <path fill=\"#7bc58c\" d=\"M0 12h36v1H0z\" />\n    </svg>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}