forty-cdk
llms.txt

Primitives

Date Picker

A trigger that opens a floating calendar to pick a date, composing ForCalendar inside a dismissible popover with min / max bounds and per-date availability.

forty-cdk/date-picker WAI-ARIA APG

Open the popover from the trigger and pick a day: the trigger keeps data-placeholder until something is chosen, and its data-state follows the overlay.

Reinterpreted idiomatically for modern Angular: a focusable trigger that opens a floating surface wrapping a projected ForCalendar.

ForDatePicker is the root and the form value: it implements FormValueControl<D | null> from @angular/forms/signals, so it auto-wires with [formField]. The trigger is the focusable control that carries name / disabled / invalid; selection state flows root → projected calendar via [(value)]. The library reuses its existing overlay stack (trigger-anchored Popover positioning, dismissible layer, return-focus) rather than re-implementing positioning, dismissal, or focus return. The modal opt-in routes through the shared modal shell (focus trap + inert background + scroll lock).

When to choose

  • Date Picker: a trigger plus a floating Calendar, and the form value itself (FormValueControl<D | null>). Choose it when the date is found by looking and the grid should stay out of the way until asked for.
  • Calendar: the same grid inline and always visible. It exposes [(value)] as a model but implements no form-control contract, so a form binds the picker rather than the calendar.
  • Date Field: segmented keyboard entry with no grid and no popup. Choose it when the user already knows the date and typing is the fast path.
  • Both at once: a date field the user types into, with a calendar button beside it. That is this picker's field anatomy.

Date adapter

All date math and formatting go through a DateAdapter<D>, shared with ForCalendar, so the library hard-depends on no date library. Provide exactly one adapter in your application (or component) providers (required):

ProviderDate type DDependency
provideInternationalizedDateAdapter()CalendarDate (@internationalized/date)Recommended. From forty-cdk/internationalized-date; needs @internationalized/date (optional peer)
provideNativeDateAdapter()DateNone (zero-dependency fallback)

Anatomy

<div forDatePicker #picker="forDatePicker" [(value)]="date" [(open)]="open" name="dob">
  <!-- optional: wrap a decorated field box in [forDatePickerAnchor] to position against it -->
  <button forDatePickerTrigger>
    <span forDatePickerValue placeholder="Pick a date"></span>
  </button>

  <!-- @if (picker.open()) { -->
  <div forDatePickerContent>
    <div forCalendar [(value)]="date">
      <!-- …calendar header + grid… -->
    </div>
  </div>
  <!-- } -->
</div>

Bind the projected [forCalendar] to the picker: [(value)] to the same date signal, and forward [min] / [max] / [isDateUnavailable] from the picker's accessors (#picker="forDatePicker"). The picker observes the calendar's selection through a contentChild query and never mutates the calendar, so picking a date sets the value, flips touched, and (when closeOnSelect is on) closes the surface. The calendar takes the picker's readonly and disabled without a binding, so a read-only or disabled picker lets no pick through and paints no selection, whichever way the calendar is bound.

Presence in the DOM is yours: wrap [forDatePickerContent] in @if (picker.open()), and animate.enter / animate.leave drive its transitions.

Examples

Date & time picker

With granularity="minute" and a time-capable adapter the picker becomes a date-time control: a projected forTimeField sits beside the calendar, both bound one-way to picker.value(). Picking a different day preserves the time you entered, and a date-time minDate / maxDate clamps on the full instant while the boundary day stays selectable. At this granularity a calendar selection never closes the surface, so you can finish editing the time.

Constraints

minDate disables every day before today and isDateUnavailable blocks weekends. The picker forwards both to the projected calendar, where they reflect aria-disabled and refuse selection while the arrow keys still travel across them. Only an available weekday can be committed.

Range selection

Open the picker and click a first day: the trigger keeps its placeholder, because the value [formField] reads does not change until a second click commits the range. Close the panel with only one end picked and the required error appears. ForDateRangePicker documents the root, its bounds and native submission.

API

ForDatePicker

PropertyTypeDescription
value
model
Two-way bindable selected date. (valueChange) fires only on internal commits.
Default: null
open
model
Two-way bindable surface visibility. (openChange) fires only on internal transitions.
Default: false
minDate
input
Minimum selectable date (inclusive). Forward to the projected calendar's [min].
Default: null
maxDate
input
Maximum selectable date (inclusive). Forward to the projected calendar's [max].
Default: null
isDateUnavailable
input
Per-date predicate. Forward to the projected calendar's [isDateUnavailable].
Default: () => false
closeOnSelect
input
Close the surface after a date is picked. Honoured at granularity="day", and at any granularity with anatomy="field".
Default: true
anatomy
input
Which piece is the control. 'field' makes a projected [forDateField] the control and the trigger a plain button; see Field anatomy.
Default: 'trigger'
granularity
input
Date-time precision. 'day' (default) is a pure date picker; coarser-than-day off composes a time field.
Default: 'day'
hourCycle
input
12/24-hour cycle for the value display; a projected time field takes resolvedHourCycle().
Default: null → the scope's hourCycle (provideForDatePickerDefaults), then the locale
resolvedHourCycle
Signal
The effective cycle: hourCycle, then the scope's, or null for the locale. Bind it to a projected time field's [hourCycle].
Default: —
modal
input
Trap focus + inert background + scroll lock (centered dialog) instead of an anchored popover.
Default: false
dismissible
input
Escape / outside-pointer dismiss the surface.
Default: true
returnFocus
input
Return focus to the trigger on close.
Default: true
formatOptions
input
Options for the text rendered by [forDatePickerValue].
Default: { year: 'numeric', month: 'long', day: 'numeric' }
locale
input
BCP 47 locale for the text rendered by [forDatePickerValue]. Not forwarded to the projected calendar, so bind its [locale] too.
Default: null → the adapter's locale(), then the runtime locale
placeholder
input
Fallback text for [forDatePickerValue] when empty.
Default: ''
side / align
input
Anchored placement (popover mode only). Defaults from provideForDatePickerDefaults / provideForDateRangePickerDefaults.
Default: 'bottom' / 'start'
dir
input
Writing direction.
Default: null resolves the ambient direction; reflected to the host dir

Plus the shared FormUiControl inputs from the base (disabled, readonly, required, invalid, pending, dirty, name, errors, and the touched model) and the floating tunables (sideOffset, alignOffset, avoidCollisions, collisionPadding, sticky, hideWhenDetached).

locale only styles the trigger display. It drives the text rendered by [forDatePickerValue] (both here and on ForDateRangePicker); it is not forwarded to the projected ForCalendar. Bind the calendar's own [locale] to localize its heading / weekday / cell labels, exactly as you forward [min] / [max].

Why minDate / maxDate, not min / max? ForDatePicker is a FormValueControl, and FormUiControl reserves min / max for numeric validators (InputSignal<number | undefined>). A date-typed min / max would break that contract, so the date bounds use the *Date suffix. (ForCalendar is not a form control, so it keeps min / max.)

Data attributes

PieceAttributeValues
[forDatePicker]data-stateopen | closed
[forDatePicker]data-disabledpresent | absent
[forDatePicker]data-readonlypresent | absent
[forDateRangePicker]data-stateopen | closed
[forDateRangePicker]data-disabledpresent | absent
[forDateRangePicker]data-readonlypresent | absent
[forDatePickerTrigger]data-stateopen | closed
[forDatePickerTrigger]data-disabledpresent | absent
[forDatePickerTrigger]data-readonlypresent | absent
[forDatePickerContent]data-stateopen | closed
[forDatePickerValue]data-placeholderpresent | absent

Triggers stamped from outside-declared templates

Angular resolves ng-template DI at the template's declaration site, not where it is stamped. A [forDatePickerTrigger] declared in a template outside the root throws the orphan error even when the template is rendered inside the root via ngTemplateOutlet. For that case the selector attribute accepts the root reference as a value, routerLink-style. Grab that reference with #root="forDatePicker" and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.

<div forDatePicker #root="forDatePicker" [(value)]="date">
  <ng-container *ngTemplateOutlet="trig; context: { root }" />
  @if (root.open()) {
  <div forDatePickerContent>…</div>
  }
</div>

<ng-template #trig let-root="root">
  <button [forDatePickerTrigger]="root">
    <span forDatePickerValue>Pick a date</span>
  </button>
</ng-template>

Anchoring to a field box

By default the surface is positioned against [forDatePickerTrigger]. When the trigger lives inside a decorated field box (padding, a prefix icon, a clear / chevron button), anchoring to the inner button offsets the surface from the visible field's edge. Wrap the field box in [forDatePickerAnchor] so floating-ui positions the surface against the box instead:

<div forDatePicker #picker="forDatePicker" [(value)]="date">
  <div forDatePickerAnchor class="field-box">
    <icon name="calendar" />
    <button forDatePickerTrigger>
      <span forDatePickerValue placeholder="Pick a date"></span>
    </button>
    <button class="clear" (click)="date.set(null)">×</button>
  </div>
  @if (picker.open()) {
  <div forDatePickerContent>
    <div forCalendar [(value)]="date"><!-- …header + grid… --></div>
  </div>
  }
</div>

[forDatePickerAnchor] changes only positioning. The trigger keeps aria-haspopup / aria-expanded / aria-controls, the click toggle, focus return on close, and its exemption from outside-pointer dismissal. Without an anchor the surface falls back to the trigger, so existing markup is unaffected. It wins over a surrounding field's [forFieldAnchor]. Each [forDatePicker] accepts one [forDatePickerAnchor], and a second one warns in dev mode. (A calendar has its own intrinsic width and ignores --for-floating-anchor-width, so the anchor mainly affects start / side alignment to the box edge.)

Date-time picker

Set granularity to 'hour', 'minute', or 'second' to turn the picker into a date-time picker: project a ForTimeField beside the calendar and the value gains a time component. This needs a time-capable adapter: provideNativeDateAdapter() (Date) or provideInternationalizedDateTimeAdapter() (CalendarDateTime). The day-only provideInternationalizedDateAdapter() (CalendarDate) throws.

Bind the calendar and the time field one-way to picker.value() (not [(value)]). The picker is the single source of truth: when one-way bound to a timed value the calendar preserves the time-of-day on its own selection, and the picker re-grafts the previously entered time as a defensive fallback for the case where the calendar value was null or midnight (reading its own value, which the one-way children never clobber); a time-field edit emits a full date-time the picker mirrors in. A date-time picker never closes on a calendar selection, so the user can go on to set the time.

The content is a field boundary. Inside a [forField] the time field does not register with the field, which keeps reflecting the picker (its label target, invalid state and errors) while the panel is open.

<div
  forDatePicker
  [(value)]="when"
  [(open)]="open"
  granularity="minute"
  [hourCycle]="24"
  #picker="forDatePicker"
>
  <button forDatePickerTrigger class="date-picker-trigger">
    <span forDatePickerValue class="date-picker-value" [placeholder]="'Pick date & time'"></span>
  </button>

  @if (open()) {
  <div forDatePickerContent>
    <div forCalendar [value]="picker.value()" [min]="picker.minDate()" [max]="picker.maxDate()">
      <!-- …calendar header + grid… -->
    </div>

    <div
      forTimeField
      [value]="picker.value()"
      [hourCycle]="picker.resolvedHourCycle()"
      #field="forTimeField"
    >
      @for (seg of field.segments(); track seg.id) { @if (seg.isLiteral) {
      <span forTimeFieldLiteral>{{ seg.text }}</span>
      } @else {
      <span forTimeFieldSegment [segment]="seg.type!">{{ seg.text }}</span>
      } }
    </div>
  </div>
  }
</div>

The value display ([forDatePickerValue]) automatically appends a two-digit hour and minute to its formatting when granularity > 'day' and you haven't set time fields in formatOptions, so it matches the projected [forTimeField]. The resolved hourCycle applies to that default and to time fields you set yourself, unless formatOptions sets hour12 or hourCycle.

Bind the time field's [hourCycle] to picker.resolvedHourCycle(), not picker.hourCycle(). The raw input is null when the cycle comes from provideForDatePickerDefaults, and the time field would then fall back to its own defaults and the locale, so an en-US browser would show 14:30 on the trigger and 2:30 PM in the panel. The same applies to a projected [forTimePicker].

Field anatomy

The default anatomy makes the trigger the control: it shows the value and takes the label. The common form-field composite is the other way round, and so is the APG example: an editable field that holds the value, plus a separate button that opens the calendar. Set anatomy="field" and project a [forDateField] inside the picker to get that shape.

<div forField>
  <label forLabel>Appointment</label>
  <div
    forDatePicker
    anatomy="field"
    [formField]="form.when"
    granularity="minute"
    [minDate]="min"
    [maxDate]="max"
    #picker="forDatePicker"
  >
    <div forDateField #field="forDateField">
      @for (seg of field.segments(); track seg.id) { @if (seg.isLiteral) {
      <span forDateFieldLiteral>{{ seg.text }}</span>
      } @else {
      <span forDateFieldSegment [segment]="seg.type!">{{ seg.text }}</span>
      } }
    </div>
    <button forDatePickerTrigger aria-label="Open calendar">…icon…</button>

    @if (picker.open()) {
    <div forDatePickerContent>
      <div forCalendar [value]="picker.value()" [min]="picker.minDate()" [max]="picker.maxDate()">
        <!-- …calendar header + grid… -->
      </div>
    </div>
    }
  </div>
</div>

With the field adopted:

  • One form control. The picker stays the FormValueControl, so [formField] binds once, on the picker. A typed date and a calendar pick both write the picker's value, and both mark it dirty; a pick marks it touched, and so does focus leaving the field.
  • State is set once. minDate, maxDate, disabled, readonly, granularity, hourCycle and locale on the picker apply to the field. A read-only picker blocks typing and picking alike.
  • The field is the control. A surrounding [forField] names and describes the field's role="group", aria-invalid lands there, a label press focuses its first segment, and the picker's focus() goes there too.
  • The trigger is a plain button. It drops role="combobox" and the form-control aria-* state, and keeps aria-haspopup="dialog", aria-expanded and aria-controls. Give it a name of its own.
  • A pick closes the surface at any granularity. The time is typed in the field rather than in the surface, so a picked day keeps the field's time and the surface closes (unless closeOnSelect is off).

The anatomy is explicit, so a date field placed inside the surface of a trigger-anatomy picker changes nothing. With anatomy="field" and no projected [forDateField], opening the calendar or calling focus() throws FORCDK-DATE-PICKER-007 in dev mode.

Range selection — ForDateRangePicker

For date-range selection use the dedicated ForDateRangePicker root (selector [forDateRangePicker]). It is the root and the form value, implementing FormValueControl<DateRange<D> | null>, so the committed range auto-wires with [formField] exactly like any other control.

It reuses the same pieces ([forDatePickerTrigger], [forDatePickerContent], [forDatePickerValue], [forDatePickerAnchor]) through a shared base, and provides FOR_DATE_PICKER_CONTEXT so they resolve under it. Project a [forCalendar] in selectionMode="range" and bind its range to the picker's value; the two-click anchor → commit flow leaves value unchanged until both endpoints are chosen, so the form never sees a half-entered range, and abandoning a new selection with Escape or an outside click keeps the range committed before it. start <= end is an invariant. Range is day-granular (no time composition).

import { ForDateRangePicker } from 'forty-cdk/date-picker';
import type { DateRange } from 'forty-cdk/shared';
import { form } from '@angular/forms/signals';

interface Booking {
  stay: DateRange<CalendarDate> | null;
}
readonly model = signal<Booking>({ stay: null });
readonly booking = form(this.model, (p) => required(p.stay));
<div
  forDateRangePicker
  [formField]="booking.stay"
  [(open)]="open"
  [ariaLabel]="'Choose date range'"
  #picker="forDateRangePicker"
>
  <button forDatePickerTrigger>
    <span forDatePickerValue [placeholder]="'Pick a range'"></span>
  </button>

  @if (open()) {
  <div forDatePickerContent>
    <div
      forCalendar
      selectionMode="range"
      [(range)]="picker.value"
      [min]="picker.minDate()"
      [max]="picker.maxDate()"
    >
      <!-- …header + grid… -->
    </div>
  </div>
  }
</div>
  • Form value. The committed DateRange<D> | null is the value model. null is the empty state. Pair it with required(p.stay) so invalid() flips when the form demands a range and none is committed. touched fires on commit and on close, exactly like the single-date picker.
  • Validity. start <= end is guaranteed by construction and is never an error. Forward minDate / maxDate to the calendar's [min] / [max] so out-of-range days are disabled in the grid; a range picked in a calendar without them is clamped into [minDate, maxDate] on commit. Forward minRangeLength / maxRangeLength to the calendar's [minRangeLength] / [maxRangeLength] (a too-short / too-long range is rejected as a no-op by the calendar's two-click flow).
  • Native submission. When name is set, two hidden inputs <name>-start / <name>-end mirror the committed endpoints as ISO YYYY-MM-DD for native <form> posts.
  • Bounds naming. minDate / maxDate (not min / max) for the same reason as ForDatePicker, and additionally because FormUiControl.min / max are typed NonNullable<TValue> (the range object itself), which is meaningless as a bound.

Defaults are configured with provideForDateRangePickerDefaults (side / align / sideOffset / collisionPadding), and both wrapper patterns work via the exported FOR_DATE_RANGE_PICKER_HOST_DIRECTIVE_INPUTS / FOR_DATE_RANGE_PICKER_HOST_DIRECTIVE_OUTPUTS tuples. See Wrapping form primitives.

Keyboard

KeyBehavior
Enter / Space on triggerOpen the surface (native button activation).
ArrowDown / Alt+ArrowDown / ArrowUp on triggerOpen the surface and move focus to the calendar.
EscapeDismiss the surface and return focus to the trigger (when dismissible).

Inside the surface, the projected ForCalendar owns the full grid keyboard map (arrows / Home / End / PageUp / PageDown / Enter / Space). On open, focus lands on the calendar's focused cell (value ?? today, with today clamped into the calendar's [min, max]) in non-modal mode, or the first focusable element in modal mode.

Accessibility

Implements the WAI-ARIA Date Picker Dialog pattern.

  • role="combobox" on the trigger with aria-haspopup="dialog", aria-expanded reflecting open(), and aria-controls pointing at the surface while open. This is the same shape [forSelectTrigger] / [forTimePickerTrigger] ship, with the dialog popup token ARIA 1.2 allows for a combobox surface. The role is also what makes the form-control ARIA below legal: role="button" supports neither aria-readonly nor aria-required. In the field anatomy the trigger is a plain button with the same three popup attributes and none of the form-control state, which the date field carries instead.
  • role="dialog" on the surface, named by [ariaLabel] (or aria-labelledby the trigger when no label is set). aria-modal="true" only in modal mode (truthy-only).
  • Form-control ARIA (aria-readonly / aria-required / aria-invalid / aria-busy) is reflected on the focusable trigger so assistive tech announces validity on the element that takes focus, alongside the data-readonly styling hook. The disabled state is the exception. It reflects through one channel only: the native disabled attribute (plus data-disabled), never aria-disabled.
  • Inside a [forField] the labelled element is the trigger, not the [forDatePicker] / [forDateRangePicker] wrapper: the field's controlId and its aria-labelledby / aria-describedby / aria-errormessage land on [forDatePickerTrigger], so [forLabel]'s for points at the element that takes focus, clicking a non-<label> [forLabel] opens the surface, and Signal Forms' focus-on-error reaches the trigger. role="combobox" takes its name from the author, so this is the channel that names the control. The root's [ariaLabel] names the role="dialog" surface instead. In the field anatomy the same association lands on the date field's role="group" instead, and a label press focuses its first segment.
  • Focus management: focus enters the surface on open (the calendar's roving cell in non-modal mode) and returns to the trigger on close, both vetoable via (autoFocusOnOpen) / (autoFocusOnClose).
  • Dismissal: Escape ((escapeKeyDown)) and outside-pointer ((pointerDownOutside) / (interactOutside)) close the surface, each vetoable.

Styling

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).

[forDatePickerContent] is portaled to document.body, so it lives outside your component's view-encapsulated styles. Style it with global CSS (or a class you pass through) rather than component-scoped rules. See Styling floating content. In non-modal (anchored) mode the surface also exposes the shared positioner custom properties (--for-floating-anchor-width / --for-floating-anchor-height, --for-floating-available-width / --for-floating-available-height, --for-floating-content-transform-origin); that same guide tabulates the full set.

.date-picker-trigger .date-picker-value[data-placeholder] {
  color: var(--muted-foreground);
}

.date-picker-trigger .date-picker-chevron {
  transition: transform 150ms;
}
.date-picker-trigger[data-state='open'] .date-picker-chevron {
  transform: rotate(180deg);
}

Wrapping in a design system

Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_DATE_PICKER_HOST_DIRECTIVE_INPUTS / FOR_DATE_PICKER_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.