{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vehicle-registration-input",
  "title": "Vehicle Registration Input",
  "description": "A UK vehicle-registration field that auto-formats as you type across all five plate formats (current, prefix, suffix, dateless, Northern Ireland), with a number-plate visual variant, live format/age/region decoding and a validation parse exposed on change.",
  "registryDependencies": [
    "@nx-ui/utils",
    "@nx-ui/vehicle-registration"
  ],
  "files": [
    {
      "path": "components/ui/vehicle-registration-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  parseUkRegistration,\n  type UkRegistrationParse,\n} from \"@/lib/vehicle-registration\"\n\n/** Longest possible normalised plate this input accepts (current/prefix/suffix\n * top out at 7; dateless/NI top out at 7 too — see lib/vehicle-registration.ts\n * header for the format table). One extra char of headroom for the space. */\nconst MAX_NORMALISED_LENGTH = 7\n\n/**\n * Count \"significant\" (non-space) characters up to `index` in `display` — the\n * unit the caret is measured in so it survives a reformat. Spaces are purely\n * cosmetic filler inserted by this component, never something the user\n * intentionally positioned the caret relative to.\n */\nfunction significantCharsBefore(display: string, index: number): number {\n  let count = 0\n  for (let i = 0; i < index && i < display.length; i += 1) {\n    if (display[i] !== \" \") count += 1\n  }\n  return count\n}\n\n/** Find the index in `display` immediately after `count` significant\n * (non-space) characters — the inverse of `significantCharsBefore`, used to\n * re-place the caret after reformatting. If `display` has fewer than `count`\n * significant characters, returns its length (caret goes to the end). */\nfunction indexAfterSignificantChars(display: string, count: number): number {\n  if (count <= 0) return 0\n  let seen = 0\n  for (let i = 0; i < display.length; i += 1) {\n    if (display[i] !== \" \") {\n      seen += 1\n      if (seen === count) return i + 1\n    }\n  }\n  return display.length\n}\n\nexport type VehicleRegistrationInputProps = Omit<\n  React.ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"onChange\"\n> & {\n  /** Controlled value — the NORMALISED registration, e.g. \"AB12CDE\". */\n  value?: string\n  /** Uncontrolled initial value — also normalised on first render. */\n  defaultValue?: string\n  /** Fires with the normalised value and the full parse on every change. */\n  onValueChange?: (value: string, parse: UkRegistrationParse) => void\n  /** Plate-styled visual. `true` is shorthand for `\"rear\"` (yellow). Default\n   * `false` renders a plain nx-ui text input. */\n  plate?: boolean | \"front\" | \"rear\"\n  /** Show the small blue \"UK\" badge strip on the plate variant. Default true. */\n  showBadge?: boolean\n  /** Show a live decode line under the input (format / age / region). Default false. */\n  showHint?: boolean\n  /** Mark `aria-invalid` when the value is complete but not a recognised\n   * format. Default true — set false to suppress the invalid ring while a\n   * caller shows its own validation state instead. */\n  strict?: boolean\n}\n\n/**\n * VehicleRegistrationInput — a UK vehicle registration field that\n * auto-uppercases and auto-spaces as you type (current/prefix/suffix/\n * dateless/Northern Ireland formats — see `lib/vehicle-registration.ts`),\n * reporting the normalised value plus the full decoded parse via\n * `onValueChange`. Optionally renders as a physical number-plate.\n *\n * Works controlled (`value`) or uncontrolled (`defaultValue`), and composes\n * inside `Field`/`Label` like any other input — it renders a single real\n * `<input>` under the hood (plus decorative, non-interactive plate chrome).\n */\nfunction VehicleRegistrationInput({\n  className,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  plate = false,\n  showBadge = true,\n  showHint = false,\n  strict = true,\n  disabled,\n  id,\n  name,\n  onFocus,\n  onBlur,\n  onKeyDown,\n  maxLength,\n  ...props\n}: VehicleRegistrationInputProps) {\n  const isControlled = value !== undefined\n\n  const [internal, setInternal] = React.useState(() => normaliseInitial(defaultValue))\n  const normalised = isControlled ? normaliseInitial(value) : internal\n\n  const parse = React.useMemo(() => parseUkRegistration(normalised), [normalised])\n\n  const [touched, setTouched] = React.useState(false)\n\n  // A complete dateless/NI plate that's also a live prefix of a longer format\n  // reports `partial: true` from the parser (see lib/vehicle-registration.ts\n  // — \"Ambiguity\" section) so typing never flickers between spacings. Once\n  // the field is `touched` (i.e. the user has blurred it at least once),\n  // typing has practically \"finished\" for this pass, so we accept that\n  // dateless/NI interpretation instead of showing it as unresolved partial\n  // input. Before the first blur, `effectiveParse` is just `parse` — no\n  // behaviour change while actively typing.\n  const effectiveParse =\n    touched && parse.partial && parse.completeAs ? parse.completeAs : parse\n  const display = effectiveParse.formatted\n\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  // Significant-character caret offset to restore once `display` re-renders\n  // with the reformatted string. Set synchronously in the change handler,\n  // consumed (and cleared) by the layout effect below — this survives both\n  // the uncontrolled path (state update triggers the re-render itself) and\n  // the controlled path (the new `display` only appears once the caller\n  // flows `value` back in, however many renders later that takes).\n  const pendingCaretRef = React.useRef<number | null>(null)\n\n  function commit(nextNormalised: string) {\n    if (!isControlled) setInternal(nextNormalised)\n    const nextParse = parseUkRegistration(nextNormalised)\n    onValueChange?.(nextParse.normalised, nextParse)\n  }\n\n  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {\n    const rawDisplay = e.target.value\n    const caretIndex = e.target.selectionStart ?? rawDisplay.length\n\n    // How many significant characters preceded the caret in what the user\n    // just produced (browser-applied edit, still in the OLD formatted\n    // shape/spacing) — this count is what we preserve across reformatting.\n    const significantBefore = significantCharsBefore(rawDisplay, caretIndex)\n    pendingCaretRef.current = significantBefore\n\n    const nextNormalised = rawDisplay\n      .toUpperCase()\n      .replace(/[^A-Z0-9]/g, \"\")\n      .slice(0, MAX_NORMALISED_LENGTH)\n\n    commit(nextNormalised)\n  }\n\n  // Runs after every render where `display` changed (typing, paste,\n  // programmatic value change) and before the browser paints, so the caret\n  // never visibly jumps to the end and back. Only `display` needs to\n  // retrigger this — `pendingCaretRef` is a ref (read fresh every run) and\n  // intentionally omitted from the dependency list.\n  React.useLayoutEffect(() => {\n    const pending = pendingCaretRef.current\n    if (pending === null) return\n    pendingCaretRef.current = null\n    const el = inputRef.current\n    if (!el || document.activeElement !== el) return\n    const pos = indexAfterSignificantChars(display, pending)\n    el.setSelectionRange(pos, pos)\n  }, [display])\n\n  const invalid =\n    strict &&\n    touched &&\n    normalised.length > 0 &&\n    !effectiveParse.valid &&\n    !effectiveParse.partial\n\n  const sharedProps = {\n    ref: inputRef,\n    id,\n    name,\n    type: \"text\" as const,\n    inputMode: \"text\" as const,\n    autoComplete: \"off\",\n    autoCapitalize: \"characters\",\n    spellCheck: false,\n    disabled,\n    \"aria-invalid\": invalid || undefined,\n    value: display,\n    onChange: handleChange,\n    onFocus: (e: React.FocusEvent<HTMLInputElement>) => onFocus?.(e),\n    onBlur: (e: React.FocusEvent<HTMLInputElement>) => {\n      setTouched(true)\n      // A complete-but-ambiguous dateless/NI entry (see `effectiveParse`\n      // above) is accepted at the moment of blur — let the caller know its\n      // value now resolves to that interpretation rather than staying\n      // \"partial\" forever once the user has moved on from the field.\n      if (parse.partial && parse.completeAs) {\n        onValueChange?.(parse.completeAs.normalised, parse.completeAs)\n      }\n      onBlur?.(e)\n    },\n    onKeyDown,\n    maxLength: MAX_NORMALISED_LENGTH + 1,\n    ...props,\n  }\n\n  if (plate) {\n    return (\n      <div className=\"inline-flex flex-col gap-1.5\">\n        <PlateFrame\n          variant={plate === true ? \"rear\" : plate}\n          showBadge={showBadge}\n          disabled={disabled}\n          invalid={invalid}\n          className={className}\n        >\n          <input\n            {...sharedProps}\n            className=\"h-full w-full min-w-0 bg-transparent text-center font-[system-ui,'Arial_Narrow',sans-serif] text-2xl font-bold tracking-[0.12em] text-[#0c1220] uppercase outline-none [font-stretch:condensed] placeholder:text-[#0c1220]/40 disabled:cursor-not-allowed disabled:opacity-50\"\n          />\n        </PlateFrame>\n        {showHint ? <HintLine parse={effectiveParse} touched={touched} /> : null}\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex flex-col gap-1.5\">\n      <input\n        data-slot=\"vehicle-registration-input\"\n        {...sharedProps}\n        className={cn(\n          \"h-10 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-md font-medium tracking-[0.15em] tabular-nums uppercase shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground placeholder:tracking-normal placeholder:normal-case focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/25\",\n          className\n        )}\n      />\n      {showHint ? <HintLine parse={effectiveParse} touched={touched} /> : null}\n    </div>\n  )\n}\n\n/** Normalise an initial controlled/uncontrolled value the same way typed\n * input gets normalised, so callers can pass \"AB12 CDE\" or \"ab12cde\" freely. */\nfunction normaliseInitial(raw: string): string {\n  return raw\n    .toUpperCase()\n    .replace(/[^A-Z0-9]/g, \"\")\n    .slice(0, MAX_NORMALISED_LENGTH)\n}\n\n// ---------------------------------------------------------------------------\n// Plate chrome\n// ---------------------------------------------------------------------------\n\n/**\n * The plate's fixed \"physical identity\" colours — a UK plate is legally\n * yellow (rear) or white (front) with charcoal text, regardless of app\n * theme or brand. Fixed hex per the component spec (nx-ui's usual\n * token-only rule doesn't apply to real-world physical artefacts, same\n * exception as the nx-app-icon brand tiles).\n */\nconst PLATE_COLOURS = {\n  rear: \"#FBBF3F\",\n  front: \"#F5F6F7\",\n  badge: \"#12245C\",\n}\n\nfunction PlateFrame({\n  variant,\n  showBadge,\n  disabled,\n  invalid,\n  className,\n  children,\n}: {\n  variant: \"front\" | \"rear\"\n  showBadge: boolean\n  disabled?: boolean\n  invalid?: boolean\n  className?: string\n  children: React.ReactNode\n}) {\n  return (\n    <div\n      data-slot=\"vehicle-registration-plate\"\n      data-variant={variant}\n      data-disabled={disabled ? true : undefined}\n      className={cn(\n        // A fixed-colour plate needs its own edge definition to read on both\n        // light and dark surfaces: a hairline border plus a soft dark-mode\n        // ring (border alone all but disappears against near-black\n        // backgrounds) and a small drop shadow for physical lift.\n        \"relative flex h-14 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-black/15 shadow-[0_1px_2px_rgba(0,0,0,0.15)] outline-none transition-shadow dark:ring-1 dark:ring-white/10\",\n        \"focus-within:ring-4 focus-within:ring-ring/25\",\n        invalid && \"ring-4 ring-destructive/25\",\n        disabled && \"pointer-events-none opacity-50\",\n        className\n      )}\n      style={{ backgroundColor: variant === \"rear\" ? PLATE_COLOURS.rear : PLATE_COLOURS.front }}\n    >\n      {showBadge ? (\n        <div\n          aria-hidden\n          className=\"flex w-8 shrink-0 flex-col items-center justify-center gap-0.5 rounded-l-[calc(var(--radius-md)-1px)]\"\n          style={{ backgroundColor: PLATE_COLOURS.badge }}\n        >\n          <span className=\"text-[9px] leading-none font-bold tracking-tight text-white\">UK</span>\n          <StarsGlyph />\n        </div>\n      ) : null}\n      <div className=\"flex flex-1 items-center px-3\">{children}</div>\n    </div>\n  )\n}\n\n/** A minimal ring of stars standing in for the EU/UK badge's star motif —\n * plain inline SVG using currentColor, no external asset. */\nfunction StarsGlyph() {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 24 8\"\n      className=\"h-1.5 w-5 text-[#F7EC33]\"\n      fill=\"currentColor\"\n    >\n      {[0, 4, 8, 12, 16, 20].map((x) => (\n        <circle key={x} cx={x + 2} cy={4} r={1} />\n      ))}\n    </svg>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Hint line\n// ---------------------------------------------------------------------------\n\nconst FORMAT_LABELS: Record<string, string> = {\n  current: \"Current format\",\n  prefix: \"Prefix format\",\n  suffix: \"Suffix format\",\n  dateless: \"Dateless format\",\n  \"northern-ireland\": \"Northern Ireland format\",\n}\n\nfunction HintLine({ parse, touched }: { parse: UkRegistrationParse; touched: boolean }) {\n  const text = hintText(parse, touched)\n  const isError = touched && parse.normalised.length > 0 && !parse.valid && !parse.partial\n\n  return (\n    <p\n      aria-live=\"polite\"\n      className={cn(\n        \"min-h-4 text-xs\",\n        isError ? \"text-destructive\" : \"text-muted-foreground\"\n      )}\n    >\n      {text}\n    </p>\n  )\n}\n\nfunction hintText(parse: UkRegistrationParse, touched: boolean): string {\n  if (parse.normalised.length === 0) return \"Enter a UK registration\"\n  if (parse.valid && parse.format) {\n    const segments = [FORMAT_LABELS[parse.format]]\n    if (parse.ageIdentifier && parse.format === \"current\") {\n      segments.push(`registered ${parse.ageIdentifier.from}–${parse.ageIdentifier.to}`)\n    }\n    if (parse.memoryTag) segments.push(parse.memoryTag.region)\n    return segments.join(\" · \")\n  }\n  if (parse.partial) return \"Keep typing…\"\n  if (touched) return \"Not a recognised UK format\"\n  return \"Not a recognised UK format yet\"\n}\n\nexport { VehicleRegistrationInput }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}