forty-cdk
llms.txt

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.

forty-cdk/date-field WAI-ARIA APG

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.

mmddyyyy

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:

ProviderDate type DDependency
provideInternationalizedDateAdapter()CalendarDate (@internationalized/date)Recommended. From forty-cdk/internationalized-date; needs @internationalized/date (optional peer)
provideNativeDateAdapter()DateNone (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.

061520240230PM

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.

150620240230p. m.

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.

mmddyyyy

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.

10062026
10112026

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.

061520240300PM
061820241100AM

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.

mmddyyyy
mmddyyyy

API

ForDateField

PropertyTypeDescription
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, not min / max? FormUiControl.min / max are reserved members typed number | undefined for numeric validators bound by [formField]. A date-typed min / max would break the FormValueControl contract, so the date bounds use distinct names.

Data attributes

PieceAttributeValues
[forDateField]data-disabledpresent | absent
[forDateField]data-readonlypresent | absent
[forDateField]data-emptypresent | absent
[forDateFieldSegment]data-highlightedpresent | absent
[forDateFieldSegment]data-placeholderpresent | absent
[forDateFieldSegment]data-disabledpresent | absent
[forDateFieldSegment]data-readonlypresent | 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

PropertyTypeDescription
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, not min / max? Beyond the reason above, FormUiControl.min / max are additionally typed NonNullable<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".

KeyBehavior
0–9Type the value; auto-advances to the next segment when full.
ArrowUp / ArrowDownStep the value. Day and month wrap; year clamps. Empty seeds from today.
ArrowLeft / ArrowRightMove to the previous / next segment (no wrap).
Home / EndJump to the segment minimum / maximum.
BackspaceDelete the last entered digit; the value becomes null when the last digit is removed.
DeleteClear 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 native aria-labelledby at a visible label).
  • role="spinbutton" per segment, with aria-valuemin / aria-valuemax / aria-valuenow reflected; the month segment also exposes a localized aria-valuetext ("March"), so screen readers read the name rather than the number.
  • Roving tabindex: exactly one segment is tabbable, so Tab enters and leaves the whole field in one stop; arrows move between segments.
  • Literals are aria-hidden and 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-readonly belongs on the segments, not the group. WAI-ARIA supports it on role="spinbutton" but not on role="group", so each segment carries aria-readonly="true" while the group reflects the data-readonly styling 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.