# PrefixedInput

> A fixed-format code with a set prefix: an invoice number, an account code, a membership number.

Category: Forms. Docs page: https://rime.mjsons.net/components/prefixed-input

## Installation

```bash
npx shadcn@latest add KezLahd/rime/prefixed-input
```

PrefixedInput is the TextInput box with a fixed prefix segment set into its left end ("INV", "ACC-") and a counter at the right. The prefix is part of the box, never typed. charset is digits (the default) or alphanumeric, upper-cased as typed.

It normalises whatever is pasted or autofilled (with or without the prefix, spaces or dashes) and reports both the full code and the body after the prefix. Leaving it part-filled marks it invalid; an empty field is left to the form's required check.

## Usage

```tsx
import { Field, PrefixedInput } from "@/components/ui";

<Field label="Invoice number">
  <PrefixedInput prefix="INV" length={6} value={code} onValueChange={(full) => setCode(full)} />
</Field>
```

## Props: PrefixedInput

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `prefix` | `string` |  | The fixed segment set into the box and never typed: "INV", "ACC", "#". Omit when passing prefixOptions; the selected option's value takes over. |
| `length` | `number` |  | How many characters follow the prefix. Omit when passing prefixOptions; each option carries its own length. |
| `charset` | `PrefixedFormat["charset"]` | `"digits"` | digits = 0-9 only (the default). alphanumeric = A-Z and 0-9, upper-cased as typed. |
| `prefixOptions` | `ReadonlyArray<PrefixedOption>` |  | A list of switchable prefix choices, each with its own length (and charset). The prefix slot becomes a dropdown button; pasting a full code picks the matching option automatically. The first option is the default. For a single hardcoded prefix, use prefix + length instead. |
| `prefixValue` | `string` |  | Controlled: which option is active. onPrefixChange fires when it changes. |
| `onPrefixChange` | `(value: string, option: PrefixedOption) => void` |  |  |
| `value` (required) | `string` |  | The full code ("INV004213") or the body alone; either is read. |
| `onValueChange` (required) | `(code: string, body: string) => void` |  | Every edit. `code` is the full form ("" while nothing is entered, never a bare prefix); `body` is what follows the prefix. |
| `invalid` | `boolean` |  | Overrides the Field's invalid state and the built-in check on blur. |
| `showCounter` | `boolean` | `true` | The "4/6" count at the right of the box. On by default. |
| `boxClassName` | `string` |  | Class for the outer box rather than the <input>. |

Also accepts the native attributes of: AriaAttributes, DOMAttributes, HTMLAttributes, InputHTMLAttributes.

## Examples

### Digits

```tsx
<PrefixedInput prefix="INV" length={6} value={v} onValueChange={(code) => setV(code)} />
```

### Letters and numbers

```tsx
<PrefixedInput prefix="ACC-" length={8} charset="alphanumeric" value={v} onValueChange={(code) => setV(code)} />
```

### Switchable prefix (phone)

prefixOptions turns the prefix into a dropdown. Each option carries its own length so a +61 mobile, a +61 landline, a 1300, a 13 and a 1800 all fit the same field. Pasting a full number picks the matching option automatically. Add validate(body) to catch invalid formats (AU numbers after +61 start with 2, 3, 4, 7, or 8) and detail(body) to show a plain-English hint under the field as the user types.

```tsx
const AU_AREAS = {
  "2": "NSW / ACT landline",
  "3": "VIC / TAS landline",
  "4": "Mobile",
  "7": "QLD landline",
  "8": "WA / SA / NT landline",
};

<PrefixedInput
  prefixOptions={[
    {
      value: "+61",
      label: "Mobile or landline",
      length: 9,
      validate: (b) =>
        AU_AREAS[b[0]]
          ? null
          : "Australian numbers after +61 start with 2 (NSW/ACT), 3 (VIC/TAS), 4 (mobile), 7 (QLD), or 8 (WA/SA/NT).",
      detail: (b) => AU_AREAS[b[0]] ?? null,
    },
    { value: "1300", label: "Business", length: 6, detail: () => "Local-rate business number" },
    { value: "13", label: "Short code", length: 4, detail: () => "National short code (max 4 digits)" },
    { value: "1800", label: "Toll-free", length: 6, detail: () => "Free for the caller" },
  ]}
  value={p}
  onValueChange={(code) => setP(code)}
/>
```

## Theme tokens

Per-component tokens. Undeclared by default: the default below is the fallback the CSS uses. Set any of them at :root or on a wrapper to restyle every instance.

| Token | Default |
| --- | --- |
| `--field-focus-border` | `rgba(var(--rgb-brand), 0.7)` / `var(--focus-color)` |
| `--field-focus-ring` | `var(--focus-halo)` |
| `--field-focus-ring-invalid` | `0 0 0 4px rgba(var(--rgb-danger), 0.14)` |
| `--field-hover-border` | `rgba(var(--rgb-brand-deep), 0.3)` |
| `--input-bg` | `var(--surface-solid)` |
| `--input-border` | `var(--line-input)` |
| `--input-border-width` | `var(--border-w-field)` |
| `--input-font-size` | `var(--text-input)` |
| `--input-height-md` | `var(--field-h-md)` |
| `--input-height-sm` | `var(--field-h-sm)` |
| `--input-pad-x` | `calc(14px * var(--density))` |
| `--input-radius` | `var(--r-md)` / `var(--r-sm)` |
| `--input-shadow` | `var(--shadow-input)` |

System tokens it reads: `--border-w-field`, `--danger-base`, `--danger-field-fill`, `--danger-ink`, `--danger-line`, `--density`, `--dur`, `--ease`, `--ease-out`, `--field-h-md`, `--field-h-sm`, `--focus-color`, `--focus-halo`, `--font-body`, `--font-mono`, `--ink-body`, `--ink-brand`, `--ink-disabled`, `--ink-heading`, `--ink-muted`, `--ink-placeholder`, `--ink-secondary`, `--line`, `--line-input`, `--r-md`, `--r-sm`, `--r-xs`, `--recess-fill`, `--rgb-brand`, `--rgb-brand-deep`, `--rgb-danger`, `--rgb-white`, `--shadow-float`, `--shadow-input`, `--success-ink`, `--surface-solid`, `--surface-sunken`, `--text-body`, `--text-caption`, `--text-input`, `--text-small`, `--wash-active`, `--wash-hover`, `--wash-subtle`, `--z-popover`.

## Accessibility

- Wrap it in <Field label=…>: Field wires the label, hint and error to the control (id, aria-describedby, aria-invalid) through context, so the control needs no ids of its own.
- The prefix and the expected length are read out through a visually hidden description tied to the input.
- A refused key shows a short note in the counter and announces it politely, so a refusal is never silent.
- In switchable-prefix mode the dropdown is a listbox: arrow keys move between options, Enter picks, Escape closes and returns focus to the trigger.

## Do

- Validate the full code on the server too.
- Use it only where the format is truly fixed.
- Reach for prefixOptions when the user must pick from a small set of fixed-length formats (phone type, country code, SKU family).

## Don't

- Don't use it for free text that merely starts with a word; use TextInput with a leading adornment.
- Don't put >5 options in the prefix dropdown; use a Combobox or a dedicated phone-country picker instead.

Related: https://rime.mjsons.net/components/text-input
