Components / forms

Calendar

Week start comes from Intl.Locale.getWeekInfo where it exists. It is not in every engine yet, so the call is guarded and the fallback is Monday. This is the one place the component asks the platform something it may not know.

Rendered from the package · open on its own

Summary

A month of days, navigable in two dimensions — one tab stop for the whole grid.

APG patternGrid
Built onnative elements
Statusstable
Since0.2.0

Anatomy

Root: data-se="calendar". Explicit roles: grid, gridcell.

PartElementRequired
day<td>yes
grid<table>yes
header<div>yes
icon<caret>yes
month<div>yes
next<button>yes
previous<button>yes
sr<span>yes
weekday<th>yes

Properties

Open — see Calendar.d.ts.

States

StateExpressed as
Focused:focus-visible
Hover:hover
Disabledaria-disabled

Behavior

The view and the focus are two different things. Arrowing past the end of a month moves the view, because the focus has to land on a real day and that day is in the next month. month controls the view; the focused day is the grid's own business.

Focus is only restored after a key moved it. A calendar that grabs focus when it appears takes the reader somewhere they did not ask to go — so the effect that re-applies focus after a re-render checks that an arrow, not a render, caused the move.

Days outside the month are empty cells, not the neighbours' numbers. A 29 that belongs to the previous month invites a click that changes the selection and the view at once.

A blocked day stays in the grid. min, max and isDisabled mark a day aria-disabled rather than removing it: a day that vanishes changes the shape of the month under the reader, and the grid stops agreeing with the calendar it is drawing.

Accessibility

The month names the grid. aria-labelledby points at the month heading, so moving between months changes the grid's own accessible name and a screen reader reports it on entry — no live region needed for the common case. The heading is aria-live="polite" as well, for readers already inside the grid when the month moves.

Every day is named in full. The cell draws 7; its accessible name is the whole date in the reader's locale, because "7" tells someone arrowing through a month nothing about where they are.

The column headers are read long and drawn short. Mo is not a word. The short form is aria-hidden, the long one is visually hidden, and the abbr attribute carries it for anything reading the table natively.

Keyboard

KeyWhat it does
Arrow left / rightThe day before or after, crossing months
Arrow up / downThe same weekday, a week back or forward
Home / EndThe first or last day of the focused week
Page Up / Page DownThe same day, a month back or forward
Shift + Page Up / Page DownA year back or forward
Enter / SpaceSelect the focused day

A month is one tab stop. Reaching the 28th by Tab would cost twenty-seven presses, which is the reason APG makes a calendar a grid rather than a list of buttons.

Roles, states and properties

AttributeSet by
aria-disabledset at render
aria-hiddenalways true
aria-labelset at render
aria-labelledbyset at render
aria-livealways polite
aria-selectedset at render
rolealways grid, always gridcell

Focus

One roving tab stop: the focused day carries tabindex="0" and every other day -1. Leaving and re-entering the grid returns to the day the reader left, not to the first of the month.

Screen reader

Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.

Contrast and target size

npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.

It sets no target size of its own; anything interactive inside it brings its own.

Tested

  • test/apg/calendar.test.jsx

Known gaps

_No upstream requirement is recorded as not applicable._

Appearance

TokenUsed for
--se-accentbackground
--se-borderborder
--se-border-strongbox-shadow
--se-border-widthborder, box-shadow
--se-control-hblock-size, inline-size
--se-control-h-smblock-size, inline-size
--se-focus-coloroutline
--se-focus-offsetoutline-offset
--se-focus-widthoutline, outline-offset
--se-icon-smblock-size, font-size, inline-size
--se-radius-lgborder-radius
--se-radius-mdborder-radius
--se-space-2gap, padding-block-end
--se-space-3gap
--se-space-4padding
--se-surfacebackground
--se-surface-sunkbackground
--se-textcolor
--se-text-disabledcolor
--se-text-mutedcolor
--se-text-on-intentcolor

Motion

None. A month that slides when it changes makes the reader wait to read it, and the heading has already said which month they are in.

Content

The month heading is the month and the year, written by Intl in the reader's locale. Never an abbreviation: March 2026, not Mar 26.

The weekday headers are the locale's short names, drawn, with the long name read.

Today is marked with a ring, not a fill. A filled today is indistinguishable from a selected day, and the two mean different things.

Usage

Pair it with a DateField when the date is known, and use it alone when it is being chosen. Typing a birthday beats twelve months of clicking; picking next Tuesday beats typing it.

weekStartsOn when you know. It is read from the locale where the browser can say and falls back to Monday, and guessing wrong shifts every date in the grid by a column.

When not to use it. For a month or a year on their own — a card expiry, a birth year. Those are two selects and no grid.

Notes

Every calculation is UTC, and that is not an implementation detail. A month built from local midnights straddles a daylight-saving boundary twice a year: one day is 23 hours long, and adding 24 hours to it lands on the same date twice. Nothing here reads a local component of a Date — only getUTC*, and only as arithmetic on a calendar date.

addMonths clamps the day. 31 January plus a month is 28 or 29 February, not 3 March. The naive version is what produces a Page Down that skips a month every spring.

Week start comes from Intl.Locale.getWeekInfo where it exists. It is not in every engine yet, so the call is guarded and the fallback is Monday. This is the one place the component asks the platform something it may not know.