Form
Number Field
Usage#
import { WdsNumberFieldComponent } from '@wds/angular/number-field';wds-field-label in a wds-field (or label). The value is a number, or null when the field is empty; it is submitted under name as a plain number, whatever the format.
Examples#
Default#
step and stop at min and max. Holding a button repeats the step.
import { Component } from '@angular/core';
import { WdsFieldComponent, WdsFieldLabelComponent } from '@wds/angular/field';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
@Component({
selector: 'app-number-field-default-demo',
imports: [WdsFieldComponent, WdsFieldLabelComponent, WdsNumberFieldComponent],
template: `
<wds-field class="w-40">
<wds-field-label>Quantity</wds-field-label>
<wds-number-field value="1" min="1" max="99"></wds-number-field>
</wds-field>
`,
})
export class NumberFieldDefaultDemo {}Step#
step sets the change of the buttons and arrow keys; typed values snap to it after a button or key.
import { Component } from '@angular/core';
import { WdsFieldComponent, WdsFieldDescriptionComponent, WdsFieldLabelComponent } from '@wds/angular/field';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
@Component({
selector: 'app-number-field-step-demo',
imports: [WdsFieldComponent, WdsFieldLabelComponent, WdsFieldDescriptionComponent, WdsNumberFieldComponent],
template: `
<div class="flex flex-wrap gap-6">
<wds-field class="w-40">
<wds-field-label>Rating</wds-field-label>
<wds-number-field value="3.5" min="0" max="5" step="0.5"></wds-number-field>
</wds-field>
<wds-field class="w-40">
<wds-field-label>Seats</wds-field-label>
<wds-number-field value="10" min="5" max="500" step="5"></wds-number-field>
<wds-field-description>In packs of 5.</wds-field-description>
</wds-field>
</div>
`,
})
export class NumberFieldStepDemo {}Format#
format-options takes Intl.NumberFormat options as JSON (or an object as the formatOptions property): a currency, a percentage (the value is a fraction: 0.15 is 15%) or a unit. The value stays a plain number.
import { Component } from '@angular/core';
import { WdsFieldComponent, WdsFieldLabelComponent } from '@wds/angular/field';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
@Component({
selector: 'app-number-field-format-demo',
imports: [WdsFieldComponent, WdsFieldLabelComponent, WdsNumberFieldComponent],
template: `
<div class="flex flex-wrap gap-6">
<wds-field class="w-44">
<wds-field-label>Price</wds-field-label>
<wds-number-field value="49.9" min="0" step="0.1" format-options='{"style":"currency","currency":"EUR"}'></wds-number-field>
</wds-field>
<wds-field class="w-44">
<wds-field-label>Discount</wds-field-label>
<wds-number-field value="0.15" min="0" max="1" step="0.05" format-options='{"style":"percent"}'></wds-number-field>
</wds-field>
<wds-field class="w-44">
<wds-field-label>Width</wds-field-label>
<wds-number-field value="120" min="0" format-options='{"style":"unit","unit":"centimeter"}'></wds-number-field>
</wds-field>
</div>
`,
})
export class NumberFieldFormatDemo {}Locale#
lang: typing 12.500,5 in German gives 12500.5.
import { Component } from '@angular/core';
import { WdsFieldComponent, WdsFieldLabelComponent } from '@wds/angular/field';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
@Component({
selector: 'app-number-field-locale-demo',
imports: [WdsFieldComponent, WdsFieldLabelComponent, WdsNumberFieldComponent],
template: `
<div class="flex flex-wrap gap-6">
<wds-field class="w-48" lang="de-DE">
<wds-field-label>Budget (de-DE)</wds-field-label>
<wds-number-field value="12500.5" min="0" step="100" format-options='{"style":"currency","currency":"EUR"}'></wds-number-field>
</wds-field>
<wds-field class="w-48" lang="pl-PL">
<wds-field-label>Budget (pl-PL)</wds-field-label>
<wds-number-field value="12500.5" min="0" step="100" format-options='{"style":"currency","currency":"PLN"}'></wds-number-field>
</wds-field>
</div>
`,
})
export class NumberFieldLocaleDemo {}Empty value#
value the field is empty (null) and shows the placeholder. The first button press starts from min, or 0.
import { Component } from '@angular/core';
import { WdsFieldComponent, WdsFieldDescriptionComponent, WdsFieldLabelComponent } from '@wds/angular/field';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
@Component({
selector: 'app-number-field-placeholder-demo',
imports: [WdsFieldComponent, WdsFieldLabelComponent, WdsFieldDescriptionComponent, WdsNumberFieldComponent],
template: `
<wds-field class="w-48">
<wds-field-label>Age</wds-field-label>
<wds-number-field min="0" max="120" placeholder="Optional"></wds-number-field>
<wds-field-description>Leave empty if you prefer not to say.</wds-field-description>
</wds-field>
`,
})
export class NumberFieldPlaceholderDemo {}Disabled, read only and invalid#
disabled dims the field, readonly hides the buttons and keeps the value selectable, invalid on wds-field marks the error.
import { Component } from '@angular/core';
import { WdsFieldComponent, WdsFieldErrorComponent, WdsFieldLabelComponent } from '@wds/angular/field';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
@Component({
selector: 'app-number-field-states-demo',
imports: [WdsFieldComponent, WdsFieldLabelComponent, WdsFieldErrorComponent, WdsNumberFieldComponent],
template: `
<div class="flex flex-wrap gap-6">
<wds-field class="w-40" disabled>
<wds-field-label>Disabled</wds-field-label>
<wds-number-field value="4" disabled></wds-number-field>
</wds-field>
<wds-field class="w-40">
<wds-field-label>Read only</wds-field-label>
<wds-number-field value="4" readonly></wds-number-field>
</wds-field>
<wds-field class="w-40" invalid>
<wds-field-label>Guests</wds-field-label>
<wds-number-field value="12" max="8"></wds-number-field>
<wds-field-error>At most 8 guests.</wds-field-error>
</wds-field>
</div>
`,
})
export class NumberFieldStatesDemo {}Controlled#
wds-input fires on every change (typing, buttons, keys) with value; wds-change fires after a button or key, or when the field loses focus after typing.
import { Component, computed, signal } from '@angular/core';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
import { WdsSeparatorComponent } from '@wds/angular/separator';
@Component({
selector: 'app-number-field-controlled-demo',
imports: [...WDS_FIELD, WdsNumberFieldComponent, WdsSeparatorComponent],
template: `
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-end gap-4">
<wds-field class="w-36">
<wds-field-label>Licenses</wds-field-label>
<!-- wds-input fires on every change: typing, the buttons and the arrow keys. -->
<wds-number-field [value]="licenses()" min="1" max="50" (wdsInput)="licenses.set($event.value ?? 0)" />
</wds-field>
<div class="pb-1.5 text-sm text-muted-foreground">× $12 / month</div>
</div>
<wds-separator />
<div class="flex justify-between text-sm font-medium">
<span>Total</span>
<span class="tabular-nums">\${{ total() }} / month</span>
</div>
</div>
`,
})
export class NumberFieldControlledDemo {
protected readonly licenses = signal(3);
protected readonly total = computed(() => this.licenses() * 12);
}Forms#
Integration with Angular forms. Pick the API your app uses.
Signal forms#
WdsNumberFieldField connects the field to [formField]; the model is number | null. With [formField] Angular does not allow min and max attributes: set them in the schema (min(), max()), which also limits the buttons.
import { Component, computed, signal } from '@angular/core';
import { FormField, FormRoot, form, max, min, required } from '@angular/forms/signals';
import { WdsButtonComponent } from '@wds/angular/button';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsNumberFieldField } from '@wds/angular/forms';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
interface Booking {
adults: number | null;
children: number | null;
}
@Component({
selector: 'app-number-field-signal-forms-demo',
imports: [FormField, FormRoot, WdsButtonComponent, ...WDS_FIELD, WdsNumberFieldComponent, WdsNumberFieldField],
template: `
<form class="flex w-xs max-w-full flex-col gap-6" [formRoot]="bookingForm">
<wds-field-group>
<wds-field [invalid]="showAdultsError()">
<wds-field-label>Adults</wds-field-label>
<wds-number-field [formField]="bookingForm.adults" />
<wds-field-error [errors]="showAdultsError() ? bookingForm.adults().errors() : []" />
</wds-field>
<wds-field>
<wds-field-label>Children</wds-field-label>
<wds-number-field [formField]="bookingForm.children" placeholder="None" />
</wds-field>
</wds-field-group>
<wds-button type="submit" class="self-start">Book</wds-button>
<p class="m-0 font-mono text-sm text-muted-foreground">Submitted: {{ submitted() }}</p>
</form>
`,
})
export class NumberFieldSignalFormsDemo {
protected readonly booking = signal<Booking>({ adults: 2, children: null });
protected readonly submitted = signal('—');
protected readonly bookingForm = form(
this.booking,
(path) => {
// With [formField] the bounds come from the schema, not from min/max attributes.
required(path.adults, { message: 'Enter the number of adults.' });
min(path.adults, 1, { message: 'At least 1 adult.' });
max(path.adults, 8, { message: 'At most 8 adults.' });
min(path.children, 0);
max(path.children, 6);
},
{
submission: {
action: async (form) => {
this.submitted.set(JSON.stringify(form().value()));
},
},
}
);
/** The error appears after the user leaves the field or submits the form. */
protected readonly showAdultsError = computed(() => this.bookingForm.adults().touched() && this.bookingForm.adults().invalid());
}Template-driven#
WdsNumberFieldValueAccessor makes ngModel work on wds-number-field. Inside a <form> each field needs a name.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { WdsButtonComponent } from '@wds/angular/button';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsNumberFieldValueAccessor } from '@wds/angular/forms';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
@Component({
selector: 'app-number-field-template-driven-demo',
imports: [FormsModule, WdsButtonComponent, ...WDS_FIELD, WdsNumberFieldComponent, WdsNumberFieldValueAccessor],
template: `
<form class="flex w-xs max-w-full flex-col gap-6" (ngSubmit)="submitted.set(JSON.stringify({ adults, children }))">
<wds-field-group>
<wds-field>
<wds-field-label>Adults</wds-field-label>
<wds-number-field name="adults" [(ngModel)]="adults" min="1" max="8" />
</wds-field>
<wds-field>
<wds-field-label>Children</wds-field-label>
<wds-number-field name="children" [(ngModel)]="children" min="0" max="6" placeholder="None" />
</wds-field>
</wds-field-group>
<wds-button type="submit" class="self-start">Book</wds-button>
<p class="m-0 font-mono text-sm text-muted-foreground">Submitted: {{ submitted() }}</p>
</form>
`,
})
export class NumberFieldTemplateDrivenDemo {
protected adults: number | null = 2;
protected children: number | null = null;
protected readonly submitted = signal('—');
protected readonly JSON = JSON;
}Reactive forms#
WdsNumberFieldValueAccessor works with formControl and formControlName. An empty field gives null.
import { Component, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { WdsButtonComponent } from '@wds/angular/button';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsNumberFieldValueAccessor } from '@wds/angular/forms';
import { WdsNumberFieldComponent } from '@wds/angular/number-field';
@Component({
selector: 'app-number-field-reactive-forms-demo',
imports: [ReactiveFormsModule, WdsButtonComponent, ...WDS_FIELD, WdsNumberFieldComponent, WdsNumberFieldValueAccessor],
template: `
<form class="flex w-xs max-w-full flex-col gap-6" [formGroup]="bookingForm" (ngSubmit)="save()">
<wds-field-group>
<wds-field [invalid]="adults.touched && adults.invalid">
<wds-field-label>Adults</wds-field-label>
<wds-number-field formControlName="adults" min="1" max="8" />
@if (adults.touched && adults.hasError('required')) {
<wds-field-error>Enter the number of adults.</wds-field-error>
}
</wds-field>
<wds-field>
<wds-field-label>Children</wds-field-label>
<wds-number-field formControlName="children" min="0" max="6" placeholder="None" />
</wds-field>
</wds-field-group>
<wds-button type="submit" class="self-start">Book</wds-button>
<p class="m-0 font-mono text-sm text-muted-foreground">Submitted: {{ submitted() }}</p>
</form>
`,
})
export class NumberFieldReactiveFormsDemo {
protected readonly bookingForm = new FormGroup({
adults: new FormControl<number | null>(2, Validators.required),
// An empty field is null.
children: new FormControl<number | null>(null),
});
protected readonly adults = this.bookingForm.controls.adults;
protected readonly submitted = signal('—');
protected save(): void {
this.bookingForm.markAllAsTouched();
if (this.bookingForm.valid) this.submitted.set(JSON.stringify(this.bookingForm.getRawValue()));
}
}API References#
wds-number-field#
step. The value can also be typed, and
changed with the arrow keys (Page Up/Down by ten steps, Home/End to min/max). format-options shows the
number as a currency, a percentage or a unit, in the language of the nearest lang.
Out-of-range values are clamped to min and max when the field loses focus. The field takes part in forms
(the value is submitted as a plain number under name) and works with wds-field.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
| value | number | null | null | null when the field is empty. The attribute takes a number: value="5". |
| min | number | undefined | ||
| max | number | undefined | ||
| step | number | 1 | min. |
| format-options.formatOptions | Intl.NumberFormatOptions | null | null | Intl.NumberFormat options, e.g. { style: 'currency', currency: 'EUR' }. The
attribute takes JSON: format-options='{"style":"percent"}'. With percent the value is a fraction (0.25 = 25%). |
| name | string | '' | |
| placeholder | string | '' | |
| disabled | boolean | false | |
| readonly | boolean | false | |
| required | boolean | false | |
| invalid | boolean | false | aria-invalid. |
| label | string | '' | wds-field with a label. |
| decrement-label.decrementLabel | string | 'Decrease' | |
| increment-label.incrementLabel | string | 'Increase' |
Events
| Event | Description |
|---|---|
| wds-inputCustomEvent<WdsNumberFieldChangeDetail> | |
| wds-changeCustomEvent<WdsNumberFieldChangeDetail> |
Methods
| Method | Description |
|---|---|
| formResetCallback() | |
| formDisabledCallback(disabled: boolean) | |
| formStateRestoreCallback(state: string | File | FormData | null) | |
| stepUp(count: unknown) | step (or by count steps). |
| stepDown(count: unknown) | step (or by count steps). |
| select() | |
| checkValidity() | true when the value is valid; otherwise fires invalid on the element. |
| reportValidity() | checkValidity(), and shows the browser validation bubble when the value is invalid. |
| setCustomValidity(message: string) | input.setCustomValidity(). |
CSS parts (::part)
| Part | Description |
|---|---|
| group | |
| control | <input>. |
| decrement | |
| increment |
Accessibility#
Screen reader#
spinbutton with aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext with the formatted value (e.g. "€49.90").
wds-field-label of a wds-field, or by label. The buttons are named "Decrease" and "Increase" (decrement-label, increment-label) and are left out of the tab order: the arrow keys do the same.
invalid sets aria-invalid. The field takes part in form validation: required, min, max and step give the browser messages, text that is not a number gives badInput.
Keyboard support#
| Key | Function |
|---|---|
step. | |
step. | |
min or max (when set). | |