Primitives
Date Field
A segmented date (and optional time) input over a pluggable date adapter, with one spinbutton per part, keyboard stepping, locale-driven segment order and min / max clamping.
Focus a segment and type, or step it with the arrow keys. Each segment is a spinbutton of its own, and one still holding its placeholder carries data-placeholder.
The keyboard-first counterpart to Calendar: each day / month / year part is an independent role="spinbutton" segment inside a labelled role="group", so entry is unambiguous and locale-correct, with no free-text parsing and no 03/04-is-it-March-4th guesswork. Segment order and separators follow the runtime locale (MM/DD/YYYY vs DD.MM.YYYY vs YYYY/MM/DD).
ForDateField implements FormValueControl<D | null> from @angular/forms/signals, so it auto-wires with [formField] and auto-associates inside a [forField] (label / description / error) with no extra markup. The value stays null until every segment is filled.
When to choose
- Date Field: typed entry, with one
role="spinbutton"per date part in the runtime locale's own order and no popup at all. Choose it when the user knows the date (a birth date, an expiry) and typing beats pointing. - Date Picker: a trigger that opens a floating Calendar, and the form value itself. Choose it when the date is found by looking: the next free Tuesday, a day near the end of the month.
- Calendar: that same grid inline and always visible. It is a widget with a
[(value)]model rather than a form control.
Date adapter
Pick one (required). All date math goes through the same pluggable DateAdapter<D> as ForCalendar, so the library hard-depends on no date library. Provide exactly one adapter in your application (or component) providers:
| Provider | Date type D | Dependency | |
|---|---|---|---|
provideInternationalizedDateAdapter() | CalendarDate (@internationalized/date) | Recommended. From forty-cdk/internationalized-date; needs @internationalized/date (optional peer) | |
provideNativeDateAdapter() | Date | None (zero-dependency fallback) |
import { bootstrapApplication } from '@angular/platform-browser';
import { provideInternationalizedDateAdapter } from 'forty-cdk/internationalized-date';
bootstrapApplication(App, {
providers: [provideInternationalizedDateAdapter()],
});
Anatomy
<div forDateField [(value)]="date" ariaLabel="Date" #field="forDateField">
<!-- field.segments() yields the locale-ordered parts; render each one: -->
<!-- literal separator (/, ., -) — aria-hidden, out of the tab order -->
<span forDateFieldLiteral>{{ seg.text }}</span>
<!-- editable part — role="spinbutton", one roving tab stop -->
<span forDateFieldSegment [segment]="seg.type">{{ seg.text }}</span>
</div>
Examples
Date & time
With a time-capable adapter, a granularity coarser than 'day' appends hour / minute segments and the value becomes a CalendarDateTime. A 12-hour cycle adds an AM/PM segment you toggle with ↑ / ↓ or by typing a / p.
Localized segment labels
provideForDateFieldDefaults({ segmentLabels }) overrides the accessible name each segment announces, scoped to this injector. A screen reader reads the focused segment as 'día' / 'mes' / 'año' instead of the English default.
Signal Forms
ForDateField implements FormValueControl<CalendarDate | null>, so a single [formField] binding wires the committed value into the form and pulls validity and touched back out. No ControlValueAccessor is involved.
Date range
ForDateRangeField is the range variant, shipped from this same entry point. Two labelled endpoint groups share locale, granularity and bounds; each is its own tab stop, so Tab steps start → end while the arrows move between segments inside one endpoint.
Date & time range
With a time-capable adapter, a granularity coarser than 'day' appends time segments to both endpoints and the value becomes a CalendarDateTime range, which is handy for a check-in to check-out with times. A 12-hour hourCycle adds an AM/PM segment to each side.
Range in Signal Forms
ForDateRangeField implements FormValueControl<DateRange | null>, so [formField] binds the committed range into the form and pulls validation back out. A half-entered or out-of-order range keeps value() null, so a required field stays invalid until both endpoints are filled and ordered.
API
ForDateField
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable entered date, or null while any segment is empty. The FormValueControl backing.Default: null |
minDate | input | Minimum date (inclusive). A composed value below it is clamped up. Named minDate (see note below).Default: null |
maxDate | input | Maximum date (inclusive). A composed value above it is clamped down. Default: null |
granularity | input | Date-time precision. 'day' is date-only; coarser-than-day appends time segments. See below.Default: 'day' |
hourCycle | input | 12/24-hour cycle for the time segments. null → the scope's hourCycle, then the locale. 12-hour adds the AM/PM segment.Default: null |
locale | input | BCP 47 locale driving segment order, separators, and month name. null → the adapter's locale(), then the runtime locale.Default: null |
placeholder | input | Per-segment placeholder while empty. Unspecified parts fall back to the scope's placeholder, then to dd / mm / yyyy / hh / mm / ss / --.Default: {} |
ariaLabel | input | Accessible name for the group. Emits no aria-label while null.Default: null |
dir | input | Writing direction. null resolves the ambient direction; mirrors ArrowLeft / ArrowRight segment navigation.Default: null |
Plus the shared FormUiControl members from @angular/forms/signals: disabled, readonly, required, invalid, name, errors, touched (bound automatically by [formField]).
Why
minDate/maxDate, notmin/max?FormUiControl.min/maxare reserved members typednumber | undefinedfor numeric validators bound by[formField]. A date-typedmin/maxwould break theFormValueControlcontract, so the date bounds use distinct names.
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forDateField] | data-disabled | present | absent | |
[forDateField] | data-readonly | present | absent | |
[forDateField] | data-empty | present | absent | |
[forDateFieldSegment] | data-highlighted | present | absent | |
[forDateFieldSegment] | data-placeholder | present | absent | |
[forDateFieldSegment] | data-disabled | present | absent | |
[forDateFieldSegment] | data-readonly | present | absent |
data-empty marks the field only while every editable segment is empty (nothing has been entered); a partially-typed field is not empty. data-placeholder marks each individual segment that is still empty. data-highlighted is the current roving-tabindex segment and the only focus hook the consumer gets, shared with the other roving primitives. [forDateFieldLiteral] carries no data-* attributes (it is aria-hidden and out of the tab order).
Date-time field
Set granularity to 'hour', 'minute', or 'second' (granularity > 'day') to append time segments (hour / minute / second and, in 12-hour mode, an AM·PM dayPeriod) after the date segments in the same role="group". The whole field stays a single tab stop with one roving cursor across all segments; field.segments() already returns the combined, locale-ordered list, so the same @for template renders it. This needs a time-capable adapter, either provideNativeDateAdapter() (Date) or provideInternationalizedDateTimeAdapter() (CalendarDateTime). The day-only provideInternationalizedDateAdapter() (CalendarDate) throws.
<div
forDateField
class="date-field"
[(value)]="when"
granularity="minute"
[hourCycle]="24"
#field="forDateField"
>
@for (seg of field.segments(); track seg.id) { @if (seg.isLiteral) {
<span forDateFieldLiteral>{{ seg.text }}</span>
} @else {
<span forDateFieldSegment class="date-field-segment" [segment]="seg.type!">{{ seg.text }}</span>
} }
</div>
On the AM/PM segment, a / p set the period and ArrowUp / ArrowDown toggle it; the period is derived from the entered hour, so clearing it is a no-op (clear or step the hour instead). The value stays null until every visible segment (date and time) is filled.
Scoped defaults
import { provideForDateFieldDefaults } from 'forty-cdk/defaults';
// app config or a component's providers — localize segment labels, the
// empty-segment announcement and the placeholders for every nested [forDateField].
providers: [
provideForDateFieldDefaults({
emptySegmentText: 'Vacío',
segmentLabels: { day: 'día', month: 'mes', year: 'año', dayPeriod: 'AM/PM' },
placeholder: { day: 'dd', month: 'mm', year: 'aaaa' },
}),
];
segmentLabels supplies each segment's default aria-label, keyed by part type. Unset keys keep the library default (the part name, and 'AM/PM' for the dayPeriod segment), so overriding a single key never wipes the rest. A segment's own [ariaLabel] still wins over the scope default.
placeholder is the text an empty segment shows, keyed the same way. A field's own [placeholder] wins for the parts it names and only those, and a part neither names keeps the letter-repeat default. provideForDateRangeFieldDefaults takes the same keys for [forDateRangeField].
hourCycle sets the 12- or 24-hour cycle of every field that doesn't bind [hourCycle], so a product on a 24-hour clock sets it once instead of on each field. Its fallback null derives the cycle from the locale.
For a language the app sets or switches after bootstrap, pass the text keys as functions and the overrides as a factory, as Localizing default text shows.
Range selection — ForDateRangeField
For a date range use the dedicated ForDateRangeField root (selector [forDateRangeField]), shipped from this same entry point. It is the keyboard-first, form-capable counterpart to DateRangePicker: two labelled role="group" endpoints (start / end) nested inside one outer role="group". Each endpoint holds a row of spinbutton segments built on the same machinery as ForDateField. It implements FormValueControl<DateRange<D> | null>, the same contract as ForDateRangePicker, so the committed range auto-wires with [formField]. The value stays null until both endpoints are fully entered and ordered (start <= end).
The pieces are the range-specific [forDateRangeFieldStart] / [forDateRangeFieldEnd] endpoint groups plus [forDateRangeFieldSegment] / [forDateRangeFieldLiteral]; each endpoint exposes its own segments() list, so the same @for template renders both sides.
<div forDateRangeField [(value)]="stay" ariaLabel="Stay">
<div forDateRangeFieldStart #start="forDateRangeFieldStart">
@for (seg of start.segments(); track seg.id) { @if (seg.isLiteral) {
<span forDateRangeFieldLiteral>{{ seg.text }}</span>
} @else {
<span forDateRangeFieldSegment [segment]="seg.type!">{{ seg.text }}</span>
} }
</div>
<span aria-hidden="true">–</span>
<div forDateRangeFieldEnd #end="forDateRangeFieldEnd">
@for (seg of end.segments(); track seg.id) { @if (seg.isLiteral) {
<span forDateRangeFieldLiteral>{{ seg.text }}</span>
} @else {
<span forDateRangeFieldSegment [segment]="seg.type!">{{ seg.text }}</span>
} }
</div>
</div>
import {
ForDateRangeField,
ForDateRangeFieldEnd,
ForDateRangeFieldLiteral,
ForDateRangeFieldSegment,
ForDateRangeFieldStart,
} from 'forty-cdk/date-field';
import type { DateRange } from 'forty-cdk/shared';
readonly model = signal({ stay: null as DateRange<CalendarDate> | null });
readonly booking = form(this.model);
ForDateRangeField API
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable committed range, or null while incomplete or out of order. The FormValueControl backing.Default: null |
minDate | input | Minimum date (inclusive) for both endpoints. A composed endpoint below it is clamped up. Named minDate (see note below).Default: null |
maxDate | input | Maximum date (inclusive) for both endpoints. A composed endpoint above it is clamped down. Default: null |
granularity | input | Date-time precision shared by both endpoints. 'day' is date-only; coarser-than-day appends time segments.Default: 'day' |
hourCycle | input | 12/24-hour cycle for the time segments. null → the scope's hourCycle, then the locale. 12-hour adds the AM/PM segment.Default: null |
locale | input | BCP 47 locale driving segment order, separators, and month name. null → the adapter's locale(), then the runtime locale.Default: null |
placeholder | input | Per-segment placeholder while empty, applied to both endpoints. Unspecified parts fall back to the scope's placeholder.Default: {} |
ariaLabel | input | Accessible name for the whole range field group. Emits no aria-label while null.Default: null |
dir | input | Writing direction. null resolves the ambient direction; mirrors ArrowLeft / ArrowRight segment navigation.Default: null |
The endpoint groups each accept an ariaLabel input for their own group label, falling back to the scope defaults ('Start date' / 'End date'). Plus the shared FormUiControl members bound automatically by [formField].
Why
minDate/maxDate, notmin/max? Beyond the reason above,FormUiControl.min/maxare additionally typedNonNullable<TValue>(the range object itself), which is meaningless as a bound.
[forDateRangeField] reflects the same data-disabled / data-readonly / data-empty hooks as [forDateField], plus data-range-error; [forDateRangeFieldSegment] reflects the same four segment hooks. data-empty marks the field only while both endpoints are entirely empty; a partially-filled or complete-but-disordered range is not empty.
Ordering
The two endpoints are typed independently, so order is not guaranteed by construction the way the picker's two-click flow guarantees it. The field preserves the DateRange end >= start invariant by never emitting an out-of-order range: when both endpoints are complete but start > end, the typed segments are kept (not silently rewritten), value stays null, and the root reflects aria-invalid="true" + data-range-error so the disorder is perceivable and stylable. Editing either endpoint back into order emits the range.
Range keyboard and accessibility
Each endpoint is its own tab stop, so Tab moves start group → end group → next control; arrows move between segments within an endpoint. Every other key behaves as in the Keyboard table below. Roving tabindex is per endpoint, and aria-invalid="true" is reflected on the root when the form marks it invalid or when two complete endpoints are out of order; everything else matches the Accessibility notes below.
Range scoped defaults
provideForDateRangeFieldDefaults mirrors provideForDateFieldDefaults and adds startLabel / endLabel for the two endpoint group aria-labels ('Start date' / 'End date' by default). Both wrapper patterns work via FOR_DATE_RANGE_FIELD_HOST_DIRECTIVE_INPUTS / FOR_DATE_RANGE_FIELD_HOST_DIRECTIVE_OUTPUTS. See Wrapping form primitives.
Keyboard
Key behavior applies per segment. Horizontal arrows mirror under dir="rtl".
| Key | Behavior | |
|---|---|---|
| 0–9 | Type the value; auto-advances to the next segment when full. | |
| ArrowUp / ArrowDown | Step the value. Day and month wrap; year clamps. Empty seeds from today. | |
| ArrowLeft / ArrowRight | Move to the previous / next segment (no wrap). | |
| Home / End | Jump to the segment minimum / maximum. | |
| Backspace | Delete the last entered digit; the value becomes null when the last digit is removed. | |
| Delete | Clear the whole segment (the value becomes null until refilled). |
The day clamps to the current month's length (e.g. 31 → 28 in February), and a composed value is clamped into [minDate, maxDate].
Accessibility
Composes the WAI-ARIA Spinbutton pattern. There is no single APG pattern for a date field, so each segment is an independent spinbutton inside a labelled role="group".
role="group"on the root carries the field's accessible name (ariaLabel, or point nativearia-labelledbyat a visible label).role="spinbutton"per segment, witharia-valuemin/aria-valuemax/aria-valuenowreflected; the month segment also exposes a localizedaria-valuetext("March"), so screen readers read the name rather than the number.- Roving tabindex: exactly one segment is tabbable, so
Tabenters and leaves the whole field in one stop; arrows move between segments. - Literals are
aria-hiddenand never focusable, so assistive tech reads only the spinbutton segments. - Boolean
data-*on each segment, present when true and absent when false:data-highlighted(focused/roving),data-placeholder(empty),data-disabled,data-readonly. aria-readonlybelongs on the segments, not the group. WAI-ARIA supports it onrole="spinbutton"but not onrole="group", so each segment carriesaria-readonly="true"while the group reflects thedata-readonlystyling hook only.
Styling
The library is styleless, so style the boolean data-* hooks yourself: [data-highlighted] (the focused/roving segment), [data-placeholder] (empty), [data-disabled] and [data-readonly] on the segments, and [data-empty] / [data-disabled] / [data-readonly] on the root group.
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under Data attributes, not off the for* selectors (Styling forty-cdk explains why).
.date-field-segment[data-placeholder] {
color: GrayText;
}
.date-field-segment[data-highlighted] {
background: Highlight;
color: HighlightText;
}
.date-field[data-disabled] {
opacity: 0.5;
}
Wrapping in a design system
Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_DATE_FIELD_HOST_DIRECTIVE_INPUTS / FOR_DATE_FIELD_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.