# NumberInput

**When to use:** Numeric input with formatting — prices, quantities, percentages, phone numbers with separators. Wraps `react-number-format` with the same floating-label style as `Input`.

**Keywords:** currency input, quantity field, numeric field, spinner, stepper, up down arrows, increment, decrement, plus minus, range, bounds, disabled

**Import:** `import { NumberInput } from '@ahrowe/ui'`

```tsx
import { NumberInput, FormValidator, Validators } from '@ahrowe/ui';

// Basic
<NumberInput
  label="Price"
  value={price}
  onChange={(value) => setPrice(value)}
/>

// Currency formatting
<NumberInput
  label="Amount"
  value={amount}
  prefix="€"
  thousandSeparator="."
  decimalSeparator=","
  decimalScale={2}
  onChange={(value) => setAmount(value)}
/>

// With FormValidator
const priceValidator = new FormValidator<number | undefined>(undefined, [Validators.required()]);
<NumberInput label="Price" formValidator={priceValidator} prefix="€" decimalScale={2} />

// Percentage
<NumberInput label="Discount" suffix="%" decimalScale={1} />

// Stepper buttons — hold to repeat, shift for ±10, clamped to min/max
<NumberInput
  label="Guests"
  value={guests}
  onChange={setGuests}
  decimalScale={0}
  showArrows
  min={0}
  max={10}
/>

// Max decimals without padding — cap at 2 decimals, but keep what the user typed
// (12 stays "12", 12.1 stays "12.1"; 12.111 is truncated to "12.11")
<NumberInput label="Amount" decimalScale={2} fixedDecimalScale={false} />
```

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `label` | `string` | Floating label |
| `value` | `number \| string` | Controlled value |
| `onChange` | `(value: number \| undefined) => void` | Change handler (typed as number) |
| `formValidator` | `FormValidator` | Connects to validation |
| `prefix` | `string` | Text before number (e.g. `'€'`) |
| `suffix` | `string` | Text after number (e.g. `'%'`) |
| `decimalScale` | `number` | Maximum number of decimal places |
| `fixedDecimalScale` | `boolean` | Pad to exactly `decimalScale` decimals (default `true`). Set `false` to treat `decimalScale` as a cap only — fewer decimals are kept as typed, `12` stays `12` |
| `decimalSeparator` | `string` | Decimal character |
| `thousandSeparator` | `string \| boolean` | Thousand grouping character |
| `allowNegative` | `boolean` | Default: `false` |
| `readOnly` | `boolean` | Make the field read-only — typing is blocked, and the arrow steppers (`showArrows`) and ↑/↓ keys are disabled. Default: `false` |
| `isRequired` | `boolean` | Shows the required mark (`*`) next to the label. Also inferred automatically when `formValidator` has a `required` validator. Default: `false` |
| `placeholder` | `string` | |
| `error` | `string` | Manual error message |
| `showArrows` | `boolean` | Show stepper buttons. Default: `false` |
| `step` | `number` | Amount one step changes the value by. Default: `1` (hold shift for ×10) |
| `min` | `number` | Lower bound — see the note below |
| `max` | `number` | Upper bound — see the note below |
| `arrowsLayout` | `'auto' \| 'stacked' \| 'split'` | Stepper layout. Default: `'auto'` |
| `labels.increase` | `string` | Accessible name for the increase button. Default: `'Increase'` |
| `labels.decrease` | `string` | Accessible name for the decrease button. Default: `'Decrease'` |
| `ref` | `Ref<HTMLInputElement>` | Ref to the inner `<input>` |

**Steppers (`showArrows`):** two layouts, picked by `arrowsLayout`:

- `'stacked'` — chevrons inside the field's right edge, each roughly 27x17 at the default text
  size. Compact and mouse-oriented: two targets stacked inside a 34px field cannot reach the 24px
  minimum that WCAG 2.5.8 asks for, which is why `'auto'` prefers `'split'` on touch.
- `'split'` — full-height `−` / `+` buttons flanking the field, each a 44px touch target. The two
  are far apart, so a mis-tap can't step the value the wrong way.
- `'auto'` (default) — `split` on a coarse (touch) pointer, `stacked` otherwise, detected with
  `matchMedia('(pointer: coarse)')` and re-evaluated when the pointer type changes.

The split buttons are the shared `Button` component, so press feedback is the same ripple as everywhere else in the library.

Press and hold a button to repeat: one step immediately, then a repeat after 400ms that accelerates
after about a second. Hold shift for ×10. The field itself carries `role="spinbutton"` with
`aria-valuenow` / `aria-valuemin` / `aria-valuemax`, so ↑/↓ stepping is announced; the buttons stay
out of the tab order rather than adding two tab stops per field.

**`min` / `max` clamp, they do not block typing.** Both bounds constrain the steppers and the
↑/↓ keys, disable the corresponding button once reached, and pull a typed value back into range
when it stops being mid-edit: on blur, and on Enter (which submits a surrounding form without ever
blurring the field). An empty field is never clamped, so blurring an untouched field does not fill
in `min`.

Nothing is rejected as it is typed, deliberately. Editing inserts digits into an existing value, so
the intermediate state routinely leaves the range even when the destination is inside it — with
`max={10}`, putting the caret in front of `8` and typing `1` on the way to `18` would simply do
nothing, with no feedback. Blocking input suits *format* rules (digits only, a fixed length), where
every rejected keystroke is unambiguously wrong; a value range is not one of those.

The Enter clamp is exact for a `formValidator`-driven field, because `formValidator.set` is
imperative and lands before the submit handler runs. For a plain `value`/`onChange` field it is
best-effort: the consumer's own state update may not have flushed by the time their `onSubmit`
reads it. If an out-of-range value must be *impossible* to submit rather than merely corrected, add
a validator — `@ahrowe/form-validation` has no numeric range validator (`minLength`/`maxLength` are
string length), so write one:

```tsx
const inRange = (min: number, max: number): ValidatorFunction => (value) =>
  typeof value === 'number' && (value < min || value > max)
    ? { id: 'range', message: `Must be between ${min} and ${max}` }
    : null;

const qty = new FormValidator<number | undefined>(undefined, [inRange(10, 100)]);
<NumberInput label="Quantity" formValidator={qty} min={10} max={100} showArrows decimalScale={0} />
```

**Mobile keyboard layout:** shows the numeric on-screen keyboard automatically (`inputMode="numeric"` when `decimalScale={0}`, `"decimal"` otherwise, so a decimal separator key is available whenever fractional values are allowed). Pass `inputMode` directly to override.

**Labels:** `increase` `decrease` — the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; keys merge individually. Type: `NumberInputLabels`.

**Slots:** `root` `input` `arrows` `increaseButton` `decreaseButton`

With `arrowsLayout='split'` the root is the group around the two buttons and the field; otherwise it is the field itself.
