Primitives
Calendar
A single-date calendar grid implementing the APG Grid pattern over a pluggable date adapter: roving-tabindex day navigation, month / year paging, and min / max / per-date availability.
Move across the grid with the arrow keys, page with PageUp / PageDown, and select with Enter. Every cell reflects data-selected, data-today and data-outside-month.
October 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 1 | 2 | 3 |
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
It is the headless, styleless date table at the heart of the APG Date Picker Dialog example. Full grid keyboard interaction (arrows / Home / End / PageUp / PageDown / Shift+PageUp / Shift+PageDown), focus paging across month boundaries, aria-current="date" on today, RTL arrow mirroring, and a pluggable, date-library-agnostic DateAdapter<D>.
ForCalendar is the grid widget, not a form value. It exposes [(value)] as a model<D | null>. The form-control contract (FormValueControl<D>) arrives with the follow-up ForDatePicker / ForDateField.
When to choose
- Calendar: the date grid itself, always visible, exposing
[(value)]as a model. It is a widget rather than a form control and implements noFormValueControlcontract, so[formField]binds a picker or a field instead. - Date Picker: wraps this same grid in a trigger-anchored floating surface and is the form value. Choose it when a form owns the date and the grid should stay collapsed until asked for.
- Date Field: segmented typed entry with no grid. Choose it when the date is known rather than browsed.
Date adapter
All date math goes through a DateAdapter<D>, 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) |
import { bootstrapApplication } from '@angular/platform-browser';
import { provideInternationalizedDateAdapter } from 'forty-cdk/internationalized-date';
bootstrapApplication(App, {
providers: [provideInternationalizedDateAdapter()],
});
@internationalized/date is a widely-used immutable date primitive; it works in every browser today with no polyfill, and its reference-equality-on-mutation makes it signal-friendly.
→ Date adapters covers picking one, the optional peer dependency, and writing your own.
Calendar system (Gregorian). The adapter seam abstracts the date library and locale-aware formatting, not the calendar system's month structure. Both @internationalized/date adapters build Gregorian dates, so the grid stays Gregorian regardless of the runtime locale, and the grid, the month picker and the date field all assume a Gregorian-structured year: exactly twelve months, month 1-12, the year ending at month 12. Adapters over calendars with a different month structure (e.g. a 13-month year) are not supported. The optional compareDate hook overrides day-only ordering only and does not make the grid non-Gregorian.
Anatomy
<div forCalendar [(value)]="date">
<header>
<button forCalendarPrevButton [ariaLabel]="'Previous month'">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
d="m15.75 19.5-7.5-7.5 7.5-7.5"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<h2 forCalendarHeading #heading="forCalendarHeading">{{ heading.label() }}</h2>
<button forCalendarNextButton [ariaLabel]="'Next month'">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
d="m8.25 4.5 7.5 7.5-7.5 7.5"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</header>
<table forCalendarGrid #grid="forCalendarGrid">
<thead forCalendarGridHeader>
<tr>
<!-- @for (day of grid.weekDays(); track day.key) -->
<th scope="col" [attr.aria-label]="day.long">{{ day.short }}</th>
</tr>
</thead>
<tbody>
<!-- @for (week of grid.weeks(); track week.key) -->
<tr>
<!-- @for (cell of week.days; track cell.key) -->
<td forCalendarCell [date]="cell.date">{{ cell.label }}</td>
</tr>
</tbody>
</table>
</div>
Examples
States
One class and one directive, three states. disabled turns off focus movement and selection for the whole calendar; readonly keeps days focusable and the grid still pages, but clicking or pressing Enter no longer changes the selection. Each reflects a root hook (data-disabled and data-readonly), and the example's stylesheet keys on nothing else.
October 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 1 | 2 | 3 |
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
October 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 1 | 2 | 3 |
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
October 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 1 | 2 | 3 |
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Constraints & week start
min disables past dates and isDateUnavailable blocks weekends. Both reflect aria-disabled and refuse selection, while arrows still move across them so navigation is never trapped. firstDayOfWeek starts the week on Monday.
October 2026
| M | T | W | T | F | S | S |
|---|---|---|---|---|---|---|
| 28 | 29 | 30 | 1 | 2 | 3 | 4 |
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 | 1 |
Range mode
In selectionMode="range" the demo opens with a week already committed. Click a day to anchor a new range, hover or use the arrow keys to preview the band, then click or press Enter on a second day, on either side of the anchor, to commit it. Range selection lists the data-range-* facets to style and the length limits.
October 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 1 | 2 | 3 |
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Month / year dropdowns
The [forCalendarMonthSelect] and [forCalendarYearSelect] selects jump straight to a month or a year. The demo bounds the calendar from February of last year to the end of next year, so the first and last years in the list are disabled, and so is January while last year is showing. Both directives are documented under native <select> dropdowns.
October 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 1 | 2 | 3 |
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
View switching (month / year picker)
Click the heading button to cycle from day → month → year view. Click a month to drill down to days; click a year to drill down to months. Prev/next pages by month, year, or block depending on the active view, and min / max disable out-of-range cells.
October 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 1 | 2 | 3 |
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
API
ForCalendar
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable selected date, or null. Used in selectionMode="single". (valueChange) fires only on internal selection.Default: null |
selectionMode | input | 'single' (default) keeps the single-date value flow. 'range' switches to anchor → commit and exposes range.Default: 'single' |
range | model | Two-way bindable committed range. Only used in selectionMode="range". (rangeChange) fires only on internal commits.Default: null |
minRangeLength | input | Minimum inclusive day count. A commit shorter than this is a no-op. Default: null (no minimum) |
maxRangeLength | input | Maximum inclusive day count. A commit longer than this is a no-op. Default: null (no maximum) |
min | input | Minimum selectable date (inclusive). Earlier dates are unavailable. Default: null |
max | input | Maximum selectable date (inclusive). Later dates are unavailable. Default: null |
isDateUnavailable | input | Per-date predicate marking a date unavailable (present but not selectable). Default: () => false |
dateLabel | input | Formats each gridcell's aria-label (full accessible date).Default: localized full date, outside-month days through the scope's outsideMonthLabel |
disabled | input | Disables the whole calendar (no focus movement, no selection). Reflected as data-disabled.Default: — |
readonly | input | Read-only: dates stay focusable, selection is blocked. Reflected as data-readonly.Default: — |
firstDayOfWeek | input | First column's weekday, 0-6 (0 = Sunday).Default: null → the adapter's value (or provideForCalendarDefaults) |
locale | input | BCP 47 locale for the heading, weekday headers, month-picker options and cell aria-label names. The calendar system stays Gregorian.Default: null → the adapter's locale(), then the runtime locale |
dir | input | Writing direction. Default: null resolves the ambient direction; reflected to the host dir and mirrors horizontal arrows |
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forCalendar] | data-disabled | present | absent | |
[forCalendar] | data-readonly | present | absent | |
[forCalendarPrevButton] | data-disabled | present | absent | |
[forCalendarNextButton] | data-disabled | present | absent | |
[forCalendarCell] | data-selected | present | absent | |
[forCalendarCell] | data-today | present | absent | |
[forCalendarCell] | data-highlighted | present | absent | |
[forCalendarCell] | data-disabled | present | absent | |
[forCalendarCell] | data-outside-month | present | absent |
Range selection
Set selectionMode="range" and bind [(range)] to get date-range selection. In range mode the single-date value is unused and stays null.
<div forCalendar selectionMode="range" [(range)]="dateRange">
<!-- …header + grid… -->
</div>
Interaction model. Click (or Enter / Space) a first cell to set the anchor; the grid enters selecting state. Click (or Enter / Space) a second cell in either direction to commit the range. Clicking before the anchor commits the inverted band [click, anchor] (matching the hover preview) rather than starting over. There is no separate "start over" gesture and no explicit Escape-to-cancel: once a range is committed, the next click begins a fresh anchor, and range keeps the committed range until the second click replaces it.
Keyboard in range mode. Enter / Space on the focused cell sets the anchor on the first press and commits on the second (same key as single mode). While selecting, arrow / Home / End / PageUp / PageDown move the keyboard focus and update the preview cursor (the keyboard equivalent of pointer hover); moving before the anchor previews (and commits) the inverted band.
min / max / isDateUnavailable still gate both endpoints. An unavailable or out-of-bounds date cannot become an anchor or an end.
minRangeLength / maxRangeLength. Constrain the inclusive day count of the committed range. A click that would violate either limit is a no-op (the anchor is preserved). Both default to null (unconstrained).
Cell facets (range mode). Four boolean data-* attributes are added to [forCalendarCell]:
| Attribute | Present when | |
|---|---|---|
data-range-start | Cell is the start of the effective range (committed idle, or preview selecting) | |
data-range-end | Cell is the end of the effective range | |
data-in-range | Cell is within the committed range, inclusive (idle only) | |
data-range-preview | Cell is within the preview band, inclusive (selecting only) |
data-in-range and data-range-preview are mutually exclusive. Endpoints carry both the endpoint attribute and the band attribute (inclusive). All four are absent in single mode.
[data-in-range] {
background: var(--accent-light);
}
[data-range-start],
[data-range-end] {
background: var(--accent);
color: white;
}
[data-range-preview] {
background: var(--accent-faint);
}
[data-range-start]:not([data-range-end]) {
border-radius: 50% 0 0 50%;
}
[data-range-end]:not([data-range-start]) {
border-radius: 0 50% 50% 0;
}
aria-selected in range mode is "true" across every committed-range cell (inclusive). During selecting (range null), it is "false" everywhere.
Scope. Range mode is day-granular only: granularity / time is orthogonal and not supported alongside it.
View switching
ForCalendar supports two additional views (month grid and year grid) so users can jump quickly to a different month or year without paging one at a time. All three views share a single [(view)] model and the same focusedDate cursor.
Pieces
| Class | Selector | Role | |
|---|---|---|---|
ForCalendarViewTrigger | [forCalendarViewTrigger] | Button that cycles the view: day → month → year. Auto-disabled when the calendar is disabled. | |
ForCalendarMonthGrid | [forCalendarMonthGrid] | 4×3 month grid (role="grid"). Exposes rows(), an array of CalendarMonthRow, each with three CalendarYearOption. | |
ForCalendarMonthCell | [forCalendarMonthCell] | One month (role="gridcell"). Requires [month] (1–12). Click drills down to day view for that month. | |
ForCalendarYearGrid | [forCalendarYearGrid] | 4×3 year grid (role="grid"). Exposes rows(), an array of CalendarYearRow, each with three CalendarYearOption. | |
ForCalendarYearCell | [forCalendarYearCell] | One year (role="gridcell"). Requires [year]. Click drills down to month view for that year. |
view model — ForCalendar
| API | Type | Description |
|---|---|---|
view | model | Active view. Default 'day'. [(view)] for two-way binding or read cal.view(). |
yearBlockSize | input | Years per year-grid page (must be a multiple of 3). Default 12. |
Usage
<div forCalendar [(value)]="date" #cal="forCalendar">
<header>
<button forCalendarPrevButton [ariaLabel]="'Previous'">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
d="m15.75 19.5-7.5-7.5 7.5-7.5"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button forCalendarViewTrigger #vt="forCalendarViewTrigger">{{ vt.label() }}</button>
<button forCalendarNextButton [ariaLabel]="'Next'">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
d="m8.25 4.5 7.5 7.5-7.5 7.5"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<!-- keep a visually-hidden heading so the grid stays labelled -->
<h2 forCalendarHeading #h="forCalendarHeading" class="sr-only">{{ h.label() }}</h2>
</header>
@switch (cal.view()) { @case ('day') {
<table forCalendarGrid #grid="forCalendarGrid">
<thead forCalendarGridHeader>
<tr>
@for (day of grid.weekDays(); track day.key) {
<th scope="col" [attr.aria-label]="day.long">{{ day.narrow }}</th>
}
</tr>
</thead>
<tbody>
@for (week of grid.weeks(); track week.key) {
<tr>
@for (cell of week.days; track cell.key) {
<td forCalendarCell [date]="cell.date">{{ cell.label }}</td>
}
</tr>
}
</tbody>
</table>
} @case ('month') {
<table forCalendarMonthGrid #mg="forCalendarMonthGrid">
<tbody>
@for (row of mg.rows(); track row.key) {
<tr>
@for (m of row.months; track m.value) {
<td forCalendarMonthCell [month]="m.value">{{ m.label }}</td>
}
</tr>
}
</tbody>
</table>
} @case ('year') {
<table forCalendarYearGrid #yg="forCalendarYearGrid">
<tbody>
@for (row of yg.rows(); track row.key) {
<tr>
@for (y of row.years; track y.value) {
<td forCalendarYearCell [year]="y.value">{{ y.value }}</td>
}
</tr>
}
</tbody>
</table>
} }
</div>
Keyboard (month / year grids)
| Key | Behavior | |
|---|---|---|
| ArrowLeft / ArrowRight | Previous / next cell (RTL-mirrored). | |
| ArrowUp / ArrowDown | Previous / next row (three cells per row). | |
| Home / End | First / last cell in the current row. | |
| PageUp / PageDown | Previous / next year (month grid) or previous / next block (year grid). | |
| Enter / Space | Select the focused month or year and drill down. | |
| Escape | (handled by consumer via [(view)]). |
Prev / next behavior per view
The prev and next buttons ([forCalendarPrevButton] / [forCalendarNextButton]) are view-aware:
| View | Prev / Next pages by | |
|---|---|---|
day | One month (as before). | |
month | One year. | |
year | One yearBlockSize block (default 12 years). |
Auto-disabled when the entire previous / next page would be outside [min, max].
Data attributes (view switching)
| Piece | Attribute | Values | |
|---|---|---|---|
[forCalendar] | data-view | "day" | "month" | "year" | |
[forCalendarMonthGrid] | data-view | "month" (static) | |
[forCalendarYearGrid] | data-view | "year" (static) | |
[forCalendarMonthCell] | data-selected | present | absent | |
[forCalendarMonthCell] | data-today | present | absent | |
[forCalendarMonthCell] | data-highlighted | present | absent | |
[forCalendarMonthCell] | data-disabled | present | absent | |
[forCalendarYearCell] | data-selected | present | absent | |
[forCalendarYearCell] | data-today | present | absent | |
[forCalendarYearCell] | data-highlighted | present | absent | |
[forCalendarYearCell] | data-disabled | present | absent |
Scoped defaults
import { provideForCalendarDefaults } from 'forty-cdk/defaults';
// app config or a component's providers — Monday-first weeks for this scope
providers: [
provideForCalendarDefaults({
firstDayOfWeek: 1,
outsideMonthLabel: (formattedDate) => `${formattedDate} (fuera del mes)`,
}),
];
outsideMonthLabel builds the aria-label of a padding day outside the visible month from its formatted full date (default "<date> (outside month)"), so assistive tech can tell it apart from the month on screen. A calendar bound to its own [dateLabel] formatter ignores it.
Keyboard
LTR (horizontal arrows mirror under dir="rtl"):
| Key | Behavior | |
|---|---|---|
| ArrowLeft / ArrowRight | Previous / next day. | |
| ArrowUp / ArrowDown | Same weekday, previous / next week. | |
| Home / End | First / last day of the focused week. | |
| PageUp / PageDown | Same day-of-month, previous / next month (re-pages the grid). | |
| Shift+PageUp / Shift+PageDown | Same month, previous / next year. | |
| Enter / Space | Select the focused date. |
Focus that crosses a month boundary re-pages the visible grid and keeps the focused cell in view. Day-of-month is constrained when paging (e.g. Jan 31 → Feb 28). Paging (the prev / next buttons and PageUp / PageDown) also clamps the focused date into the [min, max] range, so it never lands on a cell outside the selectable bounds; day / week arrows still move freely across unavailable dates so keyboard navigation is never trapped.
Accessibility
Implements the WAI-ARIA Grid pattern.
role="grid"on the table,columnheaderweekday headers andgridcelldays follow the APG Date Picker Dialog technique over a real<table>.aria-labelledbywires the grid to the heading so it names the visible period. Paging the month is announced through a dedicated off-screenaria-live="polite"region (owned by[forCalendar]), so the period is read on navigation without the heading double-announcing as both a live region and the grid's label.aria-labelon every cell carries the full localized date (e.g."Monday, June 15, 2026") so screen readers announce the whole date, not the bare day number that stays the cell's visible content. Outside-month padding days are suffixed (" (outside month)") so they are distinguishable. Override the format viaForCalendar'sdateLabelinput.aria-selectedis always emitted ("true"/"false");aria-current="date"marks today;aria-disabledmarks unavailable dates (truthy-only).- Roving tabindex: exactly one cell (the focused date) is tabbable.
Tabenters and leaves the grid in one stop. - Boolean
data-*on the cell, present when true and absent when false:data-selected,data-today,data-highlighted(the focused/roving cell),data-disabledanddata-outside-month.
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 forCalendar* selectors (Styling forty-cdk explains why).
.calendar-cell {
cursor: pointer;
}
.calendar-cell[data-selected] {
background: var(--accent);
color: white;
}
.calendar-cell[data-today] {
outline: 1px solid var(--accent);
}
.calendar-cell[data-outside-month] {
opacity: 0.4;
}
.calendar-cell[data-disabled] {
cursor: default;
opacity: 0.3;
}
SSR
The active adapter's today() and format() resolve against the runtime time zone and default locale. Rendered on the server they reflect the server's environment, so a render near midnight (or under a different server locale) can disagree with the browser by up to a day. ForCalendar reads today() once to mark the data-today / aria-current="date" cell, so the mismatch surfaces there as a hydration error and a flicker. For SSR, pin a fixed "today" / time zone / locale for both environments, or defer the today-highlight so it only computes client-side:
import { ChangeDetectionStrategy, Component, afterNextRender, signal } from '@angular/core';
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
@Component({
selector: 'app-date',
changeDetection: ChangeDetectionStrategy.OnPush,
/* ... */
})
export class DatePage {
readonly date = signal<CalendarDate | null>(null);
constructor() {
afterNextRender(() => this.date.set(today(getLocalTimeZone())));
}
}
Wrapping in a design system
Subclass the root and re-provide FOR_CALENDAR_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.