forty-cdk
llms.txt

Primitives

Number Input

A numeric spinbutton with keyboard stepping, optional +/− buttons, min / max / step clamping and Intl number formatting for the displayed text and aria-valuetext.

forty-cdk/number-input WAI-ARIA APG

Type a number, press the arrow keys or click a stepper button. The host carries data-empty while the value is null. data-dirty is a reflection of the dirty input, so it appears only when [formField] or a [dirty] binding supplies it, never from this standalone [(value)] binding.

Headless and implementing Angular's FormValueControl<number | null> from @angular/forms/signals, so it auto-wires with [formField] and auto-associates inside a [forField] (label, description, and error wiring) with zero extra markup. It owns parsing, clamping to [min, max], the full Spinbutton keyboard map, and optional Intl.NumberFormat-based display formatting. The directive sits on a <input type="text"> (not type="number", whose native UI is unstylable and locale-quirky); the focusable spinbutton input itself is the FormValueControl, mirroring <button forSwitch>.

Anatomy

<div forNumberInputGroup>
  <button forNumberInputDecrement ariaLabel="Decrease">−</button>
  <input forNumberInput [(value)]="qty" [min]="0" [max]="10" [step]="1" />
  <button forNumberInputIncrement ariaLabel="Increase">+</button>
</div>

<!-- Keyboard-only — no buttons, no group: -->
<input forNumberInput [(value)]="qty" [min]="0" [max]="100" />

Why the group? A <input> is a void element, so the stepper buttons can't be its DOM descendants and therefore can't inject its context directly. [forNumberInputGroup] provides that context and forwards it to the [forNumberInput] registered beneath it. A standalone spinbutton (keyboard / [(value)] only) needs no group.

Examples

States

One class and one directive, three states. disabled reflects data-disabled on the spinbutton and both stepper buttons and removes the control from the tab order; readonly reflects data-readonly and keeps it focusable while refusing every edit. The example's stylesheet keys on nothing else.

Default
Disabled
Read-only

Formatting & precision

Type an amount: value() under the field follows it as a raw number, and when you press Enter or leave the field the text reformats through formatOptions as US dollars and value() rounds to the cents it shows. ↑ / ↓ step it by 1. Formatting covers locale, what a surrounding form submits, and the step a percent style needs.

value() is 1234.5. Use ↑ / ↓ to step.

Formatting

formatOptions (+ optional locale) drives both the displayed text and aria-valuetext; value() stays a number, and that number is what a surrounding <form> submits (via a hidden input). Committing typed text (Enter or blur) rounds the value to the precision the format displays, so typing 1.239 into the field below commits 1.24, and the text, aria-valuetext, aria-valuenow and the submitted value agree. min / max win over that rounding, and compact, scientific and engineering notations commit the typed value unrounded.

<input
  forNumberInput
  [(value)]="price"
  locale="en-US"
  [formatOptions]="{ style: 'currency', currency: 'USD' }"
/>
<!-- value() === 1234.5 → displayed "$1,234.50", aria-valuenow="1234.5" -->

Percent style needs a matching step. With { style: 'percent' } the model value is the fraction Intl formats from (0.5 displays as "50%"), while step still defaults to 1. Because stepping snaps to the min ?? 0 ± k·step grid, ArrowUp from 0.5 would jump to 1 (100%). Set [step]="0.01" so one arrow press moves one percentage point. step is never derived from formatOptions, so the grid is always exactly what you bind.

Field composition

Drop the spinbutton inside a [forField] and it auto-associates with the label, description, and error region, with no id / aria-* wiring by hand.

import { Component, signal } from '@angular/core';
import { form, min, required, FormField } from '@angular/forms/signals';
import { ForField, ForFieldError, ForLabel } from 'forty-cdk/field';
import { ForNumberInput } from 'forty-cdk/number-input';

@Component({
  selector: 'demo-order',
  imports: [ForField, ForLabel, ForFieldError, ForNumberInput, FormField],
  template: `
    <div forField>
      <label forLabel>Quantity</label>
      <input forNumberInput [formField]="order.qty" [min]="1" />
      @if (err.shown()) {
        <p forFieldError #err="forFieldError">{{ err.messages().join(', ') }}</p>
      }
    </div>
  `,
})
export class DemoOrder {
  readonly model = signal({ qty: null as number | null });
  readonly order = form(this.model, (o) => {
    required(o.qty, { message: 'Quantity is required' });
    min(o.qty, 1, { message: 'At least one' });
  });
}

API

ForNumberInput

PropertyTypeDescription
value
model
Two-way bindable value. null is the empty input (reflected as data-empty).
Default: —
min
input
Lower bound. Reflected as aria-valuemin. Unset → no lower bound.
Default: —
max
input
Upper bound. Reflected as aria-valuemax. Unset → no upper bound.
Default: —
step
input
Increment for ArrowUp / ArrowDown and the buttons.
Default: 1
stepMultiplier
input
Multiplier over step for PageUp / PageDown (configurable via provideForNumberInputDefaults).
Default: 10
formatOptions
input
When set, drives the displayed text and aria-valuetext.
Default: —
locale
input
BCP 47 locale for parsing and formatting. Defaults to the runtime locale.
Default: —
disabled / readonly / required / invalid / pending / dirty
input
Shared form-control flags (see Field).
Default: —
name
input
Mounts a hidden <input> carrying the raw number for native form submission.
Default: —
touched
model
Set to true on blur, and on an increment / decrement button click.
Default: —
Data attributeValues
data-emptypresent (while value() is null) / absent
data-disabledpresent / absent
data-readonlypresent / absent
data-touchedpresent / absent
data-dirtypresent / absent
data-pendingpresent / absent
data-invalidpresent / absent

Stepper buttons

Both [forNumberInputIncrement] / [forNumberInputDecrement] take the uniform ariaLabel input for their accessible name and stay tabindex="-1" (focus belongs on the spinbutton). They reflect [disabled] + data-disabled at the bound (max for increment, min for decrement) or when the control is disabled / read-only. Clicking either button also marks the spinbutton touched. The buttons are outside the tab order, so a pointer-only user would otherwise never blur the input and never engage touched-gated error display.

Set the accessible name with the ariaLabel input (ariaLabel="Increase"), not the native aria-label attribute. Like every forty-cdk primitive, the directive host-binds aria-label from that input and clears it when empty.

[forNumberInputIncrement]:

Data attributeValues
data-disabledpresent (at max, or disabled / read-only) / absent

[forNumberInputDecrement]:

Data attributeValues
data-disabledpresent (at min, or disabled / read-only) / absent

[forNumberInputGroup] is a behavior-only coordination wrapper, so it carries no styling attributes.

Keyboard

The following shortcuts implement the Spinbutton APG keyboard map.

KeyBehavior
ArrowUp / ArrowDownvalue ± step, clamped.
PageUp / PageDownvalue ± step × stepMultiplier, clamped.
Home / EndSet to min / max (when defined).
EnterCommit (round to the format's precision, then clamp).
typingNumeric characters parsed live; clamped on blur / Enter / step.

Stepping from an empty field lands on the clamped baseline (min ?? 0).

Values live on the min ?? 0 ± k·step grid. A value already on the grid travels the full amount (step, or step × stepMultiplier for the page keys); a value off the grid (a server-supplied 37.4 with [step]="5", say) lands on the adjacent grid point in the direction of travel and the page multiplier is discarded, exactly as the platform's HTMLInputElement.stepUp() / stepDown() behave. So ArrowUp from 0.55 with [step]="1" gives 1, not 1.55, and one arrow press is all it takes to correct an off-grid value.

Accessibility

Implements the WAI-ARIA Spinbutton pattern.

  • role="spinbutton" on a text input. aria-valuenow / aria-valuemin / aria-valuemax reflect the value and bounds; aria-valuetext is emitted only when formatOptions is set (so the formatted text, such as "$1,234.50", is announced instead of the bare number). inputmode is numeric, or decimal when fractional values are possible.
  • Clamp on commit, validate on input. Keystrokes update the parsed value live so you can type transient out-of-range text without fighting the caret; clamping to [min, max] happens on blur / Enter / step actions, and blur / Enter also round to the precision formatOptions displays.
  • Hidden input for submission. Because the displayed text can be formatted, the visible input does not carry name; setting name mounts a hidden <input> with the raw number so native <form> serialization sees the value, not "$1,234.50". A disabled control is skipped automatically.
  • Disabled reflects through one channel. The native disabled attribute already exposes the unavailable state through HTML-AAM, so no aria-disabled is emitted alongside it. Style the disabled state with :disabled or [data-disabled].
  • Falsy state styling selects on absence. aria-readonly / aria-required / aria-invalid / aria-busy are emitted only when truthy, so style the off state with :not([aria-invalid]), never [aria-invalid="false"].
  • @angular/forms is an optional peer. If you're not using Signal Forms, don't install it: the directive runs fine on a plain [(value)] binding (the only @angular/forms/signals reference is a type import, erased at build).

Styling

forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).

.step-btn[data-disabled] {
  opacity: 0.4;
  cursor: not-allowed;
}

Wrapping in a design system

Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_NUMBER_INPUT_HOST_DIRECTIVE_INPUTS / FOR_NUMBER_INPUT_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.