On this page
Choices

Calendar released

One month of days as a grid, standing on the page rather than folded into a panel.

  • Import@pacit/components/date
  • Selectorpct-calendar

Implements the grid of the W3C ARIA APG Date Picker Dialog pattern ↗

October 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday
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
1 2 3 4 5 6 7

calendar.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctCalendar } from '@pacit/components/date';
ExportedWhat it is
PctCalendarCellinterfaceOne cell of the grid, with everything the template and the reader need already decided.
PctDayPredicatetype(day: PctDay) => booleanA day the consumer refuses — weekends, holidays, a taken slot.
PctCalendarcomponentpct-calendarCalendar — one month of days as a role="grid", walked by a roving tabindex.
PctDatePanelAligntype'start' | 'end'Which edge of the field the panel lines its own up with.
PctDatecomponentpct-dateDate field — a text control the library formats and parses per locale, with a calendar in a panel beside it.
PctDayBoundtypeunreleasedPctDay | undefined | LogicFn<TValue, PctDay | undefined, TPathKind>A bound pctDayBounds takes: a day, or a function of the field's context that returns one — the two shapes Angular's own min() takes. One that is not a day, undefined among them, is no bound, as it is on the field's input.
PctDayBoundErrortypeunreleased| { readonly kind: 'minDay'What pctDayBounds reports: minDay for a day before min, maxDay for one after max. Each carries the bound it broke, as the bound was written.
PctDayBoundsConfiginterfaceunreleasedThe bounds of pctDayBounds, and the sentence its errors carry — one for both, or one written from the error, which holds the bound.
pctDayBoundsfunctionunreleasedBounds a date field in a signal form: what Angular's minDate() and maxDate() do for a Date, for a PctDay — which they cannot take (their path is a Date's, and their validator reads the value with getTime()), and which the compiler refuses as an attribute beside [formField] (NG8022). The bounds reach the field's min / max, so the calendar's walk stops at them, and the form grades the value: a day outside is minDay or maxDay.
PctDaytypestringA calendar day, written the way the platform already writes one: YYYY-MM-DD.
PctDayPartsinterfaceThe three fields of a day, as numbers. month is 1–12, the way a person says it.
pctDaysInMonthfunctionHow many days February has — the Gregorian rule, written once.
isPctDayfunctionWhether a string is a day this library will work with: the right shape and a day the calendar has. 2026-02-30 has the shape and is not a day, and the difference matters — a value read from a server, a query string or a localStorage entry is a string somebody else wrote.
pctDayfunctionBuilds a day from its three fields, normalising an overflow the way a person expects — month 13 is January of the next year, day 0 is the last day of the previous month. That is what makes pctAddMonths and pctAddDays one line each instead of three branches.
pctDayAsUtcfunctionThe day as the one Date anything outside this file is allowed to see: midnight UTC, which is what Intl.DateTimeFormat with timeZone: 'UTC' reads back as the same three fields it was built from. Handing out a local-time Date is the defect this whole module exists to refuse.
pctDayPartsfunctionThe three fields of a day. The caller has already established that it is one.
pctTodayfunctionToday, read from the LOCAL clock — the one read of local time in this file, and the only one that belongs: "what day is it" is a question about where the user is standing, and every other question here is arithmetic on a day that already has an answer.
pctAddDaysfunctionn days on (or back).
pctAddMonthsfunctionn months on (or back), clamped to the length of the month it lands in: 31 January plus one month is 28 February and not 3 March. The clamp is what a calendar's "next month" button means — a walk that changed the day of the month would move the cursor twice.
pctCompareDaysfunction-1, 0 or 1 — read from the three fields, though the shape invites a string compare and carried one for months.
pctWeekdayfunctionThe ISO weekday: Monday is 1, Sunday is 7 — the numbering Intl.Locale.prototype.getWeekInfo() answers in, so the two never need converting between them.
pctClampDayfunctionHeld inside the bounds, either of which may be absent — and undefined is what absent is, because that is how the FormUiControl contract's min / max spell an absent bound.
pctMonthGridfunctionThe grid of one month: whole weeks, starting on firstDayOfWeek, with the days of the neighbouring months that share those weeks. Six weeks always, and that is a decision rather than an oversight — a month spans four to six weeks depending on where it starts, and a panel that changed height between March and August would move the page under the pointer mid-walk.
PctDayFieldtype'day' | 'month' | 'year'Which of the three fields a position in the format holds.
PctDayLettersinterfaceThe letters a format hint is written with — one per field, from PCT_TEXTS.
pctFirstDayOfWeekfunctionWhich day the week starts on, 1 (Monday) … 7 (Sunday).
PctDayFormatinterfaceEverything about writing and reading a day in one language, built once per locale.
pctDayFormatfunctionThe day, weekday and month names of a locale, built once and shared.
PctWeekdayNameinterfaceThe two names a column header needs: the one the eye reads and the one a reader says.
pctWeekdayNamesfunctionThe seven column headings, rotated so that the first is firstDayOfWeek.
pctMonthCaptionfunctionThe month and year over the grid ("December 2026"), in the value's own calendar.
<pct-calendar [(value)]="day" ariaLabel="Pick a day" />
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

Another language, another first day

locale names the month and the weekdays through the platform's own Intl; firstDayOfWeek starts the row where that culture starts it — Monday is 1.

październik 2026
poniedziałekwtorekśrodaczwartekpiąteksobotaniedziela
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
2 3 4 5 6 7 8
API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

unreleased marks 5 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.

Calendar — one month of days as a role="grid", walked by a roving tabindex.

Inputs

NameTypeWhat it does
valuetwo-waydefault nullPctDay | nullThe chosen day, or null.
mindefault undefinedunknownThe earliest and latest day the walk may reach; absent means no bound on that side.
maxdefault undefinedunknownThe latest day the walk may reach; absent means no bound on that side — the other half of min.
dateDisableddefault nullPctDayPredicate | nullDays inside the bounds that still cannot be picked. The split is deliberate: min/max are the range and this is the holes in it — the bounds clamp where the keyboard can go, a refused day is reached, announced and simply not takeable.
startAtunreleaseddefault undefinedunknownThe day the grid opens on while there is no value — absent, today. It is where the walk STARTS and nothing more: no day is marked chosen by it, and the bounds still hold it. The datetime field hands it the month a half that is not a date names, so 31.02.2026 opens on February (0090 §2).
disableddefault falsebooleanFreezes the grid: no day can be picked, the walk and the month buttons stop, and aria-disabled says so.
localedefault ''stringOverrides the application's LOCALE_ID, as on [pctNumber] and <pct-slider>.
firstDayOfWeekdefault 0numberWhich day the week starts on, 1 (Monday) … 7 (Sunday). Absent, the locale decides — through the platform where it answers and through a table of regions where it does not (locale.ts — firefox 151 is one engine that does not, and Angular still supports it).
sizedefault config.defaultSizePctSizeScales the grid with the field sizes, so a panel matches the field it opens from; from providePctConfig by default (req-api-config).
ariaLabeldefault ''stringThe accessible name of the grid; the panel that owns one passes its own.
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere on the page.

Outputs

NamePayloadWhen it fires
dayPickedPctDayA day was chosen by the user — which is not the same event as value changing, and the difference is what closes a panel: a value written from outside must not.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-calendar
data-pct-sizesize()
data-pct-disableddisabled() ? "" : null
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctCalendarHarness from @pacit/components/testing reads a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
caption[data-pct-part="caption"]the month and the year over the grid
day[data-pct-part="day"]one day cell
grid[data-pct-part="grid"]the grid of days
nav[data-pct-part="nav"]the previous-month and next-month buttons
week[data-pct-part="week"]one row of the grid
weekday[data-pct-part="weekday"]a heading over a column of days
control[data-pct-part="control"]the typed input
error[data-pct-part="error"]the message when the value is invalid
hint[data-pct-part="hint"]the hint under the input
label[data-pct-part="label"]the label of the typed input
panel[data-pct-part="panel"]the calendar, floating or inline
toggle[data-pct-part="toggle"]the button that opens the calendar
warning[data-pct-part="warning"]the message when the value is allowed and suspect — after the error, before the hint (0087)

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-date-bg{pct.surface}#ffffff · #0f172aBackground of the date field.
--pct-date-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293bBackground of the date field while disabled.
--pct-date-border{pct.border-strong}#64748bEdge colour of the date field.
--pct-date-border-disabled{pct.border}#e2e8f0 · #334155Edge colour of the date field while disabled.
--pct-date-border-focus{pct.primary}#2563eb · #60a5faEdge colour of the date field when focused.
--pct-date-border-hover{pct.text-muted}#334155 · #e2e8f0Edge colour of the date field on hover.
--pct-date-border-invalid{pct.danger}#b91c1c · #f87171Edge colour of the date field when invalid.
--pct-date-border-warning{pct.warning}#b45309 · #fbbf24Edge colour of the date field under a warning (0087).
--pct-date-caption-font-size{pct.font.size.md}14pxFont size of the month caption.
--pct-date-caption-font-weight{pct.font.weight.medium}500Font weight of the month caption.
--pct-date-day-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of a day cell on hover.
--pct-date-day-bg-selected{pct.primary}#2563eb · #60a5faBackground of a day cell when selected.
--pct-date-day-border-today{pct.primary}#2563eb · #60a5faEdge colour of a day cell for today.
--pct-date-day-fg-disabled{pct.text-disabled}#64748bText colour of a day cell while disabled.
--pct-date-day-fg-muted{pct.text-muted}#334155 · #e2e8f0Text colour of a day outside the month shown.
--pct-date-day-fg-selected{pct.on-primary}#ffffff · #0f172aText colour of a day cell when selected.
--pct-date-day-font-size{pct.font.size.md}14pxFont size of a day cell.
--pct-date-day-font-size-lg{pct.font.size.lg}16pxFont size of a day cell at size lg.
--pct-date-day-font-size-sm{pct.font.size.sm}13pxFont size of a day cell at size sm.
--pct-date-day-font-weight-selected{pct.font.weight.medium}500Font weight of a day cell when selected.
--pct-date-day-radius{pct.radius.md}8pxCorner radius of a day cell.
--pct-date-day-size32pxSize of a day cell.
--pct-date-day-size-lg36pxSize of a day cell at size lg.
--pct-date-day-size-sm28pxSize of a day cell at size sm.
--pct-date-fg{pct.text}#0f172a · #ffffffText colour of the date field.
--pct-date-fg-disabled{pct.text-disabled}#64748bText colour of the date field while disabled.
--pct-date-fg-invalid{pct.danger}#b91c1c · #f87171Text colour of the date field when invalid.
--pct-date-fg-warning{pct.warning}#b45309 · #fbbf24Text colour of the warning line (0087).
--pct-date-font-size{pct.font.size.md}14pxFont size of the date field.
--pct-date-font-size-lg{pct.font.size.lg}16pxFont size of the date field at size lg.
--pct-date-font-size-sm{pct.font.size.sm}13pxFont size of the date field at size sm.
--pct-date-gap{pct.space.3}8pxGap between the label, the input and the hint.
--pct-date-hint-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the hint.
--pct-date-icon-size16pxSize of the icon.
--pct-date-label-fg{pct.text}#0f172a · #ffffffText colour of the label.
--pct-date-nav-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of the month navigation on hover.
--pct-date-nav-radius{pct.radius.md}8pxCorner radius of the month navigation.
--pct-date-nav-size28pxSize of the month navigation.
--pct-date-padding-x{pct.space.4}12pxHorizontal padding of the date field.
--pct-date-padding-x-lg{pct.space.4}12pxHorizontal padding of the date field at size lg.
--pct-date-padding-x-sm{pct.space.3}8pxHorizontal padding of the date field at size sm.
--pct-date-padding-y{pct.space.3}8pxVertical padding of the date field.
--pct-date-padding-y-lg{pct.space.4}12pxVertical padding of the date field at size lg.
--pct-date-padding-y-sm{pct.space.2}4pxVertical padding of the date field at size sm.
--pct-date-panel-bg{pct.surface}#ffffff · #0f172aBackground of the panel.
--pct-date-panel-border{pct.border-strong}#64748bEdge colour of the panel.
--pct-date-panel-padding-x{pct.space.3}8pxHorizontal padding of the panel.
--pct-date-panel-padding-y{pct.space.3}8pxVertical padding of the panel.
--pct-date-panel-radius{pct.radius.md}8pxCorner radius of the panel.
--pct-date-panel-shadow{pct.shadow-panel}0 8px 24px rgba(15, 23, 42, 0.14) · 0 8px 24px rgba(0, 0, 0, 0.55)Shadow of the panel.
--pct-date-placeholder-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the placeholder.
--pct-date-radius{pct.radius.md}8pxCorner radius of the date field.
--pct-date-target-min{pct.target.min}24pxTouch-target floor of the date field.
--pct-date-toggle-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of the calendar toggle on hover.
--pct-date-toggle-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the calendar toggle.
--pct-date-toggle-radius{pct.radius.md}8pxCorner radius of the calendar toggle.
--pct-date-toggle-size20pxSize of the calendar toggle.
--pct-date-weekday-fg{pct.text-muted}#334155 · #e2e8f0Text colour of a weekday heading.
--pct-date-weekday-font-size{pct.font.size.sm}13pxFont size of a weekday heading.
--pct-date-weekday-font-weight{pct.font.weight.medium}500Font weight of a weekday heading.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-date-day-bg-selected: #0f766e;
  --pct-date-day-fg-selected: #ffffff;
  --pct-date-day-border-today: #0f766e;
}
October 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday
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
1 2 3 4 5 6 7
Accessibility

Measured, not assumed

Pattern: the grid of Date Picker Dialog — a <table role="grid"> walked by a roving tabindex

Keyboard

keyeffecttest
ArrowRight / ArrowLeftone day along the inline axislibs/components/date/src/calendar.spec.ts
ArrowDown / ArrowUpone weeklibs/components/date/src/calendar.spec.ts
Home / Endthe first / last day of the week it stands inlibs/components/date/src/calendar.spec.ts
PageDown / PageUpone monthlibs/components/date/src/calendar.spec.ts
Shift+PageDown / Shift+PageUpone yearapps/sandbox-e2e/src/date.spec.ts
Enter / Spacetakes the day the cursor is onlibs/components/date/src/calendar.spec.ts

ArrowRight moves to the next day in an LTR grid and to the previous one in an RTL grid, because a grid's arrows are about the cell to the side and which side that is depends on the direction it is written in — req-token-logical read on the keyboard rather than in a stylesheet. Measured as geometry and as behaviour in apps/sandbox-e2e/src/date.spec.ts.

A walk is not a choice. Moving the cursor never writes the value; only Enter, the space bar or a press does.

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

MeasuredARIA APG pattern in the JSDoclibs/components/date/src/calendar.ts
MeasuredKeyboard maplibs/components/date/src/calendar.spec.ts and apps/sandbox-e2e/src/date.spec.ts
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — the /date view holds an inline calendar, so the grid is audited attached and not only inside a panel
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — date-calendar and date-calendar-rtl
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the chosen day, today and a refused day stay three different things after the palette swap
Measuredprefers-reduced-motiontools/check-styles.mjs point 9 — the day cell's transition takes --pct-motion-transition-duration
MeasuredTouch target ≥ 24×24 pxlibs/components/date/src/calendar.scss — the day cell is --pct-date-day-size (32 px at md, 28 at sm), above the threshold at every size
MeasuredSize axis--pct-date-day-size-sm / -lg — every box in the grid is derived from the day cell, so one number moves the whole month
GapDensity axisnone — gap (req-token-density)
MeasuredRTLapps/sandbox-e2e/src/date.spec.ts — geometry and arrows; the date-calendar-rtl baseline
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts — the /date view is in SBX_ROUTES, and the inline calendar renders on the server
n/aFormsnot applicable — the calendar is not a form control. It is <pct-date> that implements FormValueControl
n/aMessage announcednot applicable — the calendar draws no message. The month caption is aria-live="polite", because it is the one thing that changes without the user moving anything they can see
MeasuredDay arithmetic as lawslibs/components/date/src/day.property.spec.ts — the grid's own laws among them: six rows of seven, 42 consecutive days, the first column always firstDayOfWeek, and the month's 1st inside the first row. The bound test this panel reads a cell's disabling from went through < on two day strings until that sweep (lesson-185)
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json — the day, its states and the weekday headings
MeasuredStrings through PCT_TEXTStools/check-texts.mjs — two keys; everything else is Intl
MeasuredSize budgetlibs/components/size.snapshot.md — measured together with the field, one entrypoint
MeasuredScreen-reader logRead 2026-09-28 from docs/acr/at/ — three readers, whole sandbox, and the act on /date that opens the month grid — the only way this component enters the logs. Focus lands on the chosen day and what a reader makes of the cell differs: Orca Choisir une date · dialog · août 2026. · table with 7 rows 7 columns · jeudi column header 27 · row 6 column 4 — the caption, the table's shape, the column heading's long weekday and the cell's TEXT, not the whole date the cell carries as its name; NVDA Choisir une date, dialog, the dialog alone in the act's window; VoiceOver silent in the window and 27 on the row after. So the long weekday reaches a reader through the column heading, as designed, and the cell's date name reached none of the three inside the act's two seconds. The moving focus and the month change are arrows the act does not press, and stay unread
Measureddocs page/components/calendar on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

75.98%mutation score136 of 179 mutants killed
20 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
0colour pairs measuredlight and dark, at every token build
0visual baselinesChromium, the sandbox's own pictures
84elements in the preview46 listeners · 1 render to settle · 17345 µs in jsdom, held exactly but for the clock

Why it is built this way

It ships in the date entrypoint and is a component of its own rather than a private half of PctDate, for one reason: a calendar standing on the page, always visible, is a real control and not a degenerate date field. <pct-date> puts this one in a panel; a booking screen puts it in a column.

Decisions behind it

  • 0043A day is not an instant, and the field that takes one is text the application formats
  • 0032A menu moves focus, a listbox points at it
  • 0026One channel per politeness, and a message with a home announces from it
  • lesson-185A day's string is not fixed-width, and two functions had assumed it was

Known limitations

  • One month at a time. Two months side by side is what a range picker wants, and a range is a question about the tag (0034).
  • No month or year view. The nav steps a month at a time and Shift+PageUp/PageDown steps a year; a distant date is reached by typing it in the field, which is the whole reason the field is typable. A three-view picker is a feature waiting for the consumer who needs one without a field beside it.
  • The Gregorian calendar only — see PctDate.
  • The first day of the week is a table where the platform is silent. Intl.Locale.prototype.getWeekInfo() was absent from firefox 151, which Angular still supports, so 80 regions are written down in locale.ts and checked against the platform's own CLDR for every two-letter region code in locale.spec.ts; date.spec.ts deletes the method in every engine and draws the grid from the table (0084). A consumer can override it outright with firstDayOfWeek.