ngx-numeric-range-form-field
v5.1.0
Published
Angular Signal Forms numeric range input — two number fields, one composite value, custom-styled and theme-friendly.
Maintainers
Readme
ngx-numeric-range-form-field
A reactive Angular custom form control for a composite numeric range — two
number inputs (minimum, maximum) exposed as a single value. Built on Angular
21 Signal Forms (FormValueControl) with no ControlValueAccessor, no
Angular Material, no third-party runtime dependencies.

Features
- Two-input composite numeric range rendered as one field
- Plug & play with Angular Signal Forms via
FormValueControl([formField]) - Ships four composable validator helpers —
numericRangeOrderValid,numericRangeBounds,numericRangeBothFilled,numericRangeWidth - Typed
NumericRangeErrorKindcontract for error-key matching - Schema-driven validation (
required,readonly,disabled,validate, …) owned by the consumer'sform()definition - Accessible by default — visible label wires to the group via
aria-labelledby; per-input announcements compose<group> <side> - Native numeric-input attributes pass-through (
step,autocomplete, per-sidename/min/max) - Custom outlined field styling — reskin via CSS custom properties without
::ng-deep - Tree-shakable (
sideEffects: false) - Zero runtime dependencies — no Angular Material, no CDK
@angular/forms/signalsis marked@experimental 21.0.0. Consumers of[email protected]adopt the same experimental surface.
At a glance
| | |
| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Empty
| Filled
|
| Read-only
| Invalid order
|
Install
npm install ngx-numeric-range-form-fieldPeer dependencies: @angular/common, @angular/core, @angular/forms (all
>=21.0.0 <22.0.0).
Usage
import { Component, signal } from '@angular/core'
import { form, FormField, required } from '@angular/forms/signals'
import {
INumericRange,
NumericRangeFormFieldComponent,
numericRangeOrderValid
} from 'ngx-numeric-range-form-field'
@Component({
selector: 'app-range-demo',
standalone: true,
imports: [NumericRangeFormFieldComponent, FormField],
template: `
<ngx-numeric-range-form-field
[formField]="rangeForm"
label="Pick a range"
/>
`
})
export class RangeDemoComponent {
readonly rangeValue = signal<INumericRange | null>({
minimum: 10,
maximum: 50
})
readonly rangeForm = form<INumericRange | null>(this.rangeValue, p => {
required(p)
numericRangeOrderValid(p)
})
}The emitted value type:
type INumericRange = {
minimum: number | null
maximum: number | null
}Either side may be null to represent a half-filled range. When both sides are
null, the value itself becomes null.
Inputs
All inputs are signal inputs. Inputs marked (schema-driven) are
automatically written by the FormField directive from the attached form()
schema — bind them directly only when using the component without [formField].
| Input | Type | Default | Description |
| -------------------------- | -------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| label | string | '' | Visible label rendered above the field. When set, becomes the group's aria-labelledby target. |
| minPlaceholder | string | 'From' | Placeholder for the minimum input. |
| maxPlaceholder | string | 'To' | Placeholder for the maximum input. |
| minLabel | string \| null | null | Override the minimum input's accessible name. Defaults to minPlaceholder when null. |
| maxLabel | string \| null | null | Override the maximum input's accessible name. Defaults to maxPlaceholder when null. |
| resetLabel | string | 'Reset range' | aria-label for the reset (✕) button. |
| resettable | boolean | true | Show the reset (✕) button when a value is present. |
| minReadonly | boolean | false | Make the minimum input read-only while the maximum remains editable. |
| maxReadonly | boolean | false | Mirror of minReadonly for the maximum input. |
| step | number \| string \| null | null | Native step attribute forwarded to both inputs (e.g. 0.5). |
| autocomplete | string \| null | null | Native autocomplete attribute forwarded to both inputs (e.g. 'off'). |
| minName / maxName | string \| null | null | Per-side native name attribute — useful inside a native <form>. |
| minMin / minMax | number \| string \| null | null | Per-side native HTML min / max attributes for the minimum input. Steers the browser spinner only — schema validation remains the source of truth. |
| maxMin / maxMax | number \| string \| null | null | Per-side native HTML min / max for the maximum input. |
| value (schema-driven) | INumericRange \| null | null | Composite value. Two-way via [(value)] or through form(). |
| disabled (schema-driven) | boolean | false | Disable both inputs. |
| readonly (schema-driven) | boolean | false | Render both inputs read-only. |
| required (schema-driven) | boolean | false | Show the required marker on the label. Visual flag — the actual validity comes from your form() schema. |
| touched (schema-driven) | boolean | false | Marks the field as touched — flips the invalid styling on when paired with errors. |
| errors (schema-driven) | readonly ValidationError.WithOptionalFieldTree[] | [] | Error list. Non-empty + touched paints the field red. |
Schema validators
The lib ships four helpers that compose into any form() schema. Unless noted
otherwise they treat a null on either side as "not yet set" and pass in that
case — pair them with required(p) or numericRangeBothFilled(p) when
"half-filled" should be rejected.
numericRangeOrderValid(path)
Fails with { kind: 'invalidRange' } when maximum < minimum.

import { numericRangeOrderValid } from 'ngx-numeric-range-form-field'
rangeForm = form<INumericRange | null>(this.rangeValue, p => {
numericRangeOrderValid(p)
})numericRangeBounds(path, { min, max })
Keeps both sides within consumer-supplied bounds. Emits
{ kind: 'min', message: 'Minimum must be at least …' } when a side is below
the floor and { kind: 'max', message: 'Maximum must not exceed …' } when a
side is above the ceiling. Pass min or max alone for one-sided bounds.

import { numericRangeBounds } from 'ngx-numeric-range-form-field'
rangeForm = form<INumericRange | null>(this.rangeValue, p => {
numericRangeBounds(p, { min: 1, max: 10 })
})numericRangeBothFilled(path)
Fails with { kind: 'incomplete' } until both sides are populated.
required(p) alone only checks that the composite value is not null, so
{ minimum: 5, maximum: null } passes it — use this helper when you need the
stronger guarantee.
import { numericRangeBothFilled } from 'ngx-numeric-range-form-field'
rangeForm = form<INumericRange | null>(this.rangeValue, p => {
numericRangeBothFilled(p)
})numericRangeWidth(path, { min, max })
Constrains the span of the range (maximum - minimum), not the endpoints.
Emits { kind: 'minWidth' } when the span is below bounds.min and
{ kind: 'maxWidth' } when it exceeds bounds.max; both carry a readable
message. Skipped while either side is null or the range is mis-ordered (let
numericRangeOrderValid own that case).
import { numericRangeWidth } from 'ngx-numeric-range-form-field'
rangeForm = form<INumericRange | null>(this.rangeValue, p => {
numericRangeWidth(p, { min: 5, max: 30 })
})Reading errors in a template:
@for (err of rangeForm().errors(); track $index) {
<p class="error">{{ err.message || err.kind }}</p>
}Typed error-kind contract
Filter errors by typed constants instead of duplicating string literals:
import { NumericRangeErrorKind } from 'ngx-numeric-range-form-field'
const hasOrderError = rangeForm()
.errors()
.some(e => e.kind === NumericRangeErrorKind.OutOfOrder)| Constant | Emitted by | kind value |
| ---------------------------------- | ---------------------------------- | ---------------- |
| NumericRangeErrorKind.OutOfOrder | numericRangeOrderValid | 'invalidRange' |
| NumericRangeErrorKind.BoundsMin | numericRangeBounds (lower bound) | 'min' |
| NumericRangeErrorKind.BoundsMax | numericRangeBounds (upper bound) | 'max' |
| NumericRangeErrorKind.Incomplete | numericRangeBothFilled | 'incomplete' |
| NumericRangeErrorKind.WidthMin | numericRangeWidth (lower span) | 'minWidth' |
| NumericRangeErrorKind.WidthMax | numericRangeWidth (upper span) | 'maxWidth' |
The string values match the kinds the validators emit today, so existing comparisons against the raw strings keep working.
Accessibility
When label is set the component wires the visible label to the role="group"
via aria-labelledby using stable per-instance IDs, and each input's
aria-label composes <group label> <side label> — a screen reader announces
e.g. "Price range From" and "Price range To" instead of just "From" /
"To". Override the per-side announcement with minLabel / maxLabel, or the
reset button announcement with resetLabel.
Styling
The component ships a minimal outlined field. Override these custom properties on the host (or anywhere in the cascade) to restyle:
| Property | Default |
| ------------------------------- | --------------------- |
| --ngx-nrff-font-family | inherit |
| --ngx-nrff-font-size | 0.95rem |
| --ngx-nrff-label-font-size | 0.8rem |
| --ngx-nrff-label-color | rgba(0, 0, 0, 0.6) |
| --ngx-nrff-text-color | rgba(0, 0, 0, 0.87) |
| --ngx-nrff-placeholder-color | rgba(0, 0, 0, 0.4) |
| --ngx-nrff-border-color | rgba(0, 0, 0, 0.23) |
| --ngx-nrff-border-hover-color | rgba(0, 0, 0, 0.52) |
| --ngx-nrff-focus-color | #1976d2 |
| --ngx-nrff-error-color | #b3261e |
| --ngx-nrff-background | transparent |
| --ngx-nrff-disabled-color | rgba(0, 0, 0, 0.38) |
| --ngx-nrff-radius | 6px |
| --ngx-nrff-padding-y | 10px |
| --ngx-nrff-padding-x | 12px |
| --ngx-nrff-gap | 8px |
License
MIT License — Copyright (c) 2022-2026 Dino Klicek
