forty-cdk
llms.txt

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.

forty-cdk/calendar WAI-ARIA APG

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
27282930123
45678910
11121314151617
18192021222324
25262728293031

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 no FormValueControl contract, 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):

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()],
});

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

Default

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
Disabled

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
Read-only

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
2829301234
567891011
12131415161718
19202122232425
2627282930311

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
27282930123
45678910
11121314151617
18192021222324
25262728293031

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
27282930123
45678910
11121314151617
18192021222324
25262728293031

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

PropertyTypeDescription
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

PieceAttributeValues
[forCalendar]data-disabledpresent | absent
[forCalendar]data-readonlypresent | absent
[forCalendarPrevButton]data-disabledpresent | absent
[forCalendarNextButton]data-disabledpresent | absent
[forCalendarCell]data-selectedpresent | absent
[forCalendarCell]data-todaypresent | absent
[forCalendarCell]data-highlightedpresent | absent
[forCalendarCell]data-disabledpresent | absent
[forCalendarCell]data-outside-monthpresent | 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]:

AttributePresent when
data-range-startCell is the start of the effective range (committed idle, or preview selecting)
data-range-endCell is the end of the effective range
data-in-rangeCell is within the committed range, inclusive (idle only)
data-range-previewCell 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.

Month / year navigation

ForCalendar exposes absolute-navigation methods so consumers can wire their own month/year <select> dropdowns (or any other UI) without needing a library directive.

MethodDescription
goTo(year, month)Set the visible month to (year, month) without selecting a date. month is 1-12.
goToMonth(month)Set the visible month within the current visible year. month is 1-12.
goToYear(year)Set the visible year, keeping the current visible month.

All three methods re-apply the user's intended day-of-month (clamped to the target month's length), clamp the result into [min, max], and announce the new period politely when the visible month changes. They keep DOM focus on the caller and do not move focus into the grid. They are a no-op while the calendar is disabled.

Read accessors and predicates

APITypeDescription
visibleYear()
Signal
The visible month's full year (e.g. 2026).
visibleMonthNumber()
Signal
The visible month, 1-12.
monthOptions()
Signal
Twelve localized, bounds-aware entries for the visible year. See CalendarMonthOption.
isMonthDisabled(month)
(month: number) => boolean
Whether every day of month (1-12) in the visible year is outside [min, max].
isYearDisabled(year)
(year: number) => boolean
Whether every day of year is outside [min, max].

CalendarMonthOption has three fields: value: number (1-12), label: string (localized month name via the adapter), and disabled: boolean.

A month/year is "disabled" only when its entire span is out of range: its last day falls before min, or its first day falls after max. A non-midnight min/max keeps its boundary month/year enabled, matching the grid's existing day-level availability.

Usage — native <select> dropdowns

The recommended path is [forCalendarMonthSelect] and [forCalendarYearSelect]. Apply them on <select> elements inside [forCalendar] and render the <option> elements yourself from the directive's signals:

<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>
    <select forCalendarMonthSelect #m="forCalendarMonthSelect">
      @for (opt of m.options(); track opt.value) {
      <option [value]="opt.value" [disabled]="opt.disabled">{{ opt.label }}</option>
      }
    </select>
    <select forCalendarYearSelect #y="forCalendarYearSelect" [minYear]="1900" [maxYear]="2100">
      @for (opt of y.years(); track opt.value) {
      <option [value]="opt.value" [disabled]="opt.disabled">{{ opt.value }}</option>
      }
    </select>
    <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>
    <h2 forCalendarHeading class="sr-only" #h="forCalendarHeading">{{ h.label() }}</h2>
  </header>
  <table forCalendarGrid>
    …
  </table>
</div>

ForCalendarMonthSelect API

APIDescription
options()Signal<readonly CalendarMonthOption[]>: twelve localized, bounds-aware month options for the visible year.

ForCalendarYearSelect API

APIDescription
minYearinput<number | null>: the lowest listed year. Defaults to currentYear - 100 when null.
maxYearinput<number | null>: the highest listed year. Defaults to currentYear + 10 when null.
years()Signal<readonly CalendarYearOption[]>: years from minYear to maxYear inclusive, each disabled when the whole year falls outside [min, max].

The default window is anchored to the current year (not the visible year), so navigating far away never drops the current year off the list. Out-of-[min, max] entries have disabled: true, matching the CalendarYearOption shape. Both directives set the native disabled attribute on the <select> itself when the calendar is disabled.

Keep [forCalendarHeading] in the DOM. The grid's aria-labelledby points at the heading's id. When dropdowns replace the visible heading, keep a visually-hidden [forCalendarHeading] so the grid stays named. Removing the heading entirely leaves aria-labelledby pointing at a missing element.

The lower-level hooks (visibleMonthNumber(), visibleYear(), monthOptions(), goToMonth(), goToYear(), isYearDisabled()) in the table above remain available for any other UI.

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

ClassSelectorRole
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

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

KeyBehavior
ArrowLeft / ArrowRightPrevious / next cell (RTL-mirrored).
ArrowUp / ArrowDownPrevious / next row (three cells per row).
Home / EndFirst / last cell in the current row.
PageUp / PageDownPrevious / next year (month grid) or previous / next block (year grid).
Enter / SpaceSelect 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:

ViewPrev / Next pages by
dayOne month (as before).
monthOne year.
yearOne yearBlockSize block (default 12 years).

Auto-disabled when the entire previous / next page would be outside [min, max].

Data attributes (view switching)

PieceAttributeValues
[forCalendar]data-view"day" | "month" | "year"
[forCalendarMonthGrid]data-view"month" (static)
[forCalendarYearGrid]data-view"year" (static)
[forCalendarMonthCell]data-selectedpresent | absent
[forCalendarMonthCell]data-todaypresent | absent
[forCalendarMonthCell]data-highlightedpresent | absent
[forCalendarMonthCell]data-disabledpresent | absent
[forCalendarYearCell]data-selectedpresent | absent
[forCalendarYearCell]data-todaypresent | absent
[forCalendarYearCell]data-highlightedpresent | absent
[forCalendarYearCell]data-disabledpresent | 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"):

KeyBehavior
ArrowLeft / ArrowRightPrevious / next day.
ArrowUp / ArrowDownSame weekday, previous / next week.
Home / EndFirst / last day of the focused week.
PageUp / PageDownSame day-of-month, previous / next month (re-pages the grid).
Shift+PageUp / Shift+PageDownSame month, previous / next year.
Enter / SpaceSelect 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, columnheader weekday headers and gridcell days follow the APG Date Picker Dialog technique over a real <table>.
  • aria-labelledby wires the grid to the heading so it names the visible period. Paging the month is announced through a dedicated off-screen aria-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-label on 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 via ForCalendar's dateLabel input.
  • aria-selected is always emitted ("true" / "false"); aria-current="date" marks today; aria-disabled marks unavailable dates (truthy-only).
  • Roving tabindex: exactly one cell (the focused date) is tabbable. Tab enters 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-disabled and data-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.