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.
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.
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.5displays as"50%"), whilestepstill defaults to1. Because stepping snaps to themin ?? 0± k·stepgrid, ArrowUp from0.5would jump to1(100%). Set[step]="0.01"so one arrow press moves one percentage point.stepis never derived fromformatOptions, 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
| Property | Type | Description |
|---|---|---|
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 attribute | Values | |
|---|---|---|
data-empty | present (while value() is null) / absent | |
data-disabled | present / absent | |
data-readonly | present / absent | |
data-touched | present / absent | |
data-dirty | present / absent | |
data-pending | present / absent | |
data-invalid | present / 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
ariaLabelinput (ariaLabel="Increase"), not the nativearia-labelattribute. Like every forty-cdk primitive, the directive host-bindsaria-labelfrom that input and clears it when empty.
[forNumberInputIncrement]:
| Data attribute | Values | |
|---|---|---|
data-disabled | present (at max, or disabled / read-only) / absent |
[forNumberInputDecrement]:
| Data attribute | Values | |
|---|---|---|
data-disabled | present (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.
| Key | Behavior | |
|---|---|---|
ArrowUp / ArrowDown | value ± step, clamped. | |
PageUp / PageDown | value ± step × stepMultiplier, clamped. | |
Home / End | Set to min / max (when defined). | |
Enter | Commit (round to the format's precision, then clamp). | |
| typing | Numeric 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-valuemaxreflect the value and bounds;aria-valuetextis emitted only whenformatOptionsis set (so the formatted text, such as "$1,234.50", is announced instead of the bare number).inputmodeisnumeric, ordecimalwhen 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 precisionformatOptionsdisplays. - Hidden input for submission. Because the displayed text can be formatted, the visible input does not carry
name; settingnamemounts 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
disabledattribute already exposes the unavailable state through HTML-AAM, so noaria-disabledis emitted alongside it. Style the disabled state with:disabledor[data-disabled]. - Falsy state styling selects on absence.
aria-readonly/aria-required/aria-invalid/aria-busyare emitted only when truthy, so style the off state with:not([aria-invalid]), never[aria-invalid="false"]. @angular/formsis 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/signalsreference 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.