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.
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):
| 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) |
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
| Property | Type | Description |
|---|---|---|
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).
localeonly styles the trigger display. It drives the text rendered by[forDatePickerValue](both here and onForDateRangePicker); it is not forwarded to the projectedForCalendar. Bind the calendar's own[locale]to localize its heading / weekday / cell labels, exactly as you forward[min]/[max].
Why
minDate/maxDate, notmin/max?ForDatePickeris aFormValueControl, andFormUiControlreservesmin/maxfor numeric validators (InputSignal<number | undefined>). A date-typedmin/maxwould break that contract, so the date bounds use the*Datesuffix. (ForCalendaris not a form control, so it keepsmin/max.)
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forDatePicker] | data-state | open | closed | |
[forDatePicker] | data-disabled | present | absent | |
[forDatePicker] | data-readonly | present | absent | |
[forDateRangePicker] | data-state | open | closed | |
[forDateRangePicker] | data-disabled | present | absent | |
[forDateRangePicker] | data-readonly | present | absent | |
[forDatePickerTrigger] | data-state | open | closed | |
[forDatePickerTrigger] | data-disabled | present | absent | |
[forDatePickerTrigger] | data-readonly | present | absent | |
[forDatePickerContent] | data-state | open | closed | |
[forDatePickerValue] | data-placeholder | present | 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.)
Modal vs non-modal
By default the surface is a non-modal popover: anchored to the trigger, no background inert, no scroll lock. Set [modal]="true" to route through the modal shell instead: focus is trapped inside the dialog, the background is inert, and body scroll is locked (a centered dialog you position with CSS, not trigger-anchored). Either way the surface is role="dialog" and aria-haspopup="dialog"-anchored; modal mode adds aria-modal="true".
The mode is read once when the surface mounts (it is structurally different per mode), so toggle modal while the surface is closed.
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,hourCycleandlocaleon 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'srole="group",aria-invalidlands there, a label press focuses its first segment, and the picker'sfocus()goes there too. - The trigger is a plain button. It drops
role="combobox"and the form-controlaria-*state, and keepsaria-haspopup="dialog",aria-expandedandaria-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
closeOnSelectis 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> | nullis thevaluemodel.nullis the empty state. Pair it withrequired(p.stay)soinvalid()flips when the form demands a range and none is committed.touchedfires on commit and on close, exactly like the single-date picker. - Validity.
start <= endis guaranteed by construction and is never an error. ForwardminDate/maxDateto 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. ForwardminRangeLength/maxRangeLengthto 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
nameis set, two hidden inputs<name>-start/<name>-endmirror the committed endpoints as ISOYYYY-MM-DDfor native<form>posts. - Bounds naming.
minDate/maxDate(notmin/max) for the same reason asForDatePicker, and additionally becauseFormUiControl.min/maxare typedNonNullable<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
| Key | Behavior | |
|---|---|---|
| Enter / Space on trigger | Open the surface (native button activation). | |
| ArrowDown / Alt+ArrowDown / ArrowUp on trigger | Open the surface and move focus to the calendar. | |
| Escape | Dismiss 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 witharia-haspopup="dialog",aria-expandedreflectingopen(), andaria-controlspointing at the surface while open. This is the same shape[forSelectTrigger]/[forTimePickerTrigger]ship, with thedialogpopup token ARIA 1.2 allows for a combobox surface. The role is also what makes the form-control ARIA below legal:role="button"supports neitheraria-readonlynoraria-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](oraria-labelledbythe 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 thedata-readonlystyling hook. The disabled state is the exception. It reflects through one channel only: the nativedisabledattribute (plusdata-disabled), neveraria-disabled. - Inside a
[forField]the labelled element is the trigger, not the[forDatePicker]/[forDateRangePicker]wrapper: the field'scontrolIdand itsaria-labelledby/aria-describedby/aria-errormessageland on[forDatePickerTrigger], so[forLabel]'sforpoints 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 therole="dialog"surface instead. In the field anatomy the same association lands on the date field'srole="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 todocument.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.