Calendar
stimeo--calendar
Keyboard-navigable calendar grid compliant with WAI-ARIA APG Date Picker Dialog pattern.
The stimeo--calendar controller provides the complete behavior for an accessible date selection grid. Binds 42 pre-allocated grid cells to local-aware monthly navigation, supports roving focus keyboard navigation (arrows, PageUp/Down, Home/End, Shift+PageUp/Down), implements automatic month transition across limits, and clamps out-of-range days gracefully (e.g. 31st to 30th on month steps).
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
Keyboard
| Key | Action |
|---|---|
| ← | Moves focus to the previous day, transitioning to the previous month automatically if crossing boundaries. |
| → | Moves focus to the next day, transitioning to the next month automatically if crossing boundaries. |
| ↑ | Moves focus to the same day of the previous week, transitioning to the previous month if necessary. |
| ↓ | Moves focus to the same day of the next week, transitioning to the next month if necessary. |
| PageUp | Moves focus to the same day of the previous month (clamped to month-end if needed) and transitions month. |
| PageDown | Moves focus to the same day of the next month (clamped to month-end if needed) and transitions month. |
| Shift + PageUp | Moves focus to the same day of the previous year (clamped if needed) and transitions year. |
| Shift + PageDown | Moves focus to the same day of the next year (clamped if needed) and transitions year. |
| Home | Moves focus to the first day of the current week. |
| End | Moves focus to the last day of the current week. |
| T | Instantly moves focus to today's date cell, transitioning to the current month if necessary. |
| Enter / Space | Selects the currently focused day and dispatches stimeo--calendar:select (except disabled days). |
<%# Markup for the calendar (APG Date Picker Dialog) demo.
stimeo--calendar provides the displayed month label via Intl.DateTimeFormat,
date navigation via roving focus, month transitions, 42-cell sync, and min/max
boundary limits. %>
<div
class="calendar-demo demo-card"
data-controller="stimeo--calendar"
data-stimeo--calendar-month-value="<%= Time.current.strftime("%Y-%m") %>"
data-stimeo--calendar-selected-value="<%= Time.current.strftime("%Y-%m-%d") %>"
data-stimeo--calendar-min-value="<%= (Time.current - 1.month).beginning_of_month.strftime(
"%Y-%m-%d"
) %>"
data-stimeo--calendar-max-value="<%= (Time.current + 2.months).end_of_month.strftime(
"%Y-%m-%d"
) %>"
data-stimeo--calendar-week-start-value="0"
>
<div class="calendar-demo__header">
<button
class="calendar-demo__btn"
type="button"
data-action="click->stimeo--calendar#prev"
aria-label="<%= t("components.calendar.demo.prev_month") %>"
>
<svg
class="calendar-demo__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<span
class="calendar-demo__label"
data-stimeo--calendar-target="label"
></span>
<button
class="calendar-demo__btn"
type="button"
data-action="click->stimeo--calendar#next"
aria-label="<%= t("components.calendar.demo.next_month") %>"
>
<svg
class="calendar-demo__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<table class="calendar-demo__table" role="grid">
<thead>
<tr role="row">
<th
scope="col"
aria-label="<%= t("components.calendar.demo.sunday") %>"
>
<%= t("components.calendar.demo.sunday")[0] %>
</th>
<th
scope="col"
aria-label="<%= t("components.calendar.demo.monday") %>"
>
<%= t("components.calendar.demo.monday")[0] %>
</th>
<th
scope="col"
aria-label="<%= t("components.calendar.demo.tuesday") %>"
>
<%= t("components.calendar.demo.tuesday")[0] %>
</th>
<th
scope="col"
aria-label="<%= t("components.calendar.demo.wednesday") %>"
>
<%= t("components.calendar.demo.wednesday")[0] %>
</th>
<th
scope="col"
aria-label="<%= t("components.calendar.demo.thursday") %>"
>
<%= t("components.calendar.demo.thursday")[0] %>
</th>
<th
scope="col"
aria-label="<%= t("components.calendar.demo.friday") %>"
>
<%= t("components.calendar.demo.friday")[0] %>
</th>
<th
scope="col"
aria-label="<%= t("components.calendar.demo.saturday") %>"
>
<%= t("components.calendar.demo.saturday")[0] %>
</th>
</tr>
</thead>
<tbody
data-stimeo--calendar-target="grid"
data-action="
keydown->stimeo--calendar#onKeydown
click->stimeo--calendar#selectByClick
"
>
<% 6.times do %>
<tr role="row">
<% 7.times do %>
<td
role="gridcell"
class="calendar-demo__cell"
data-stimeo--calendar-target="day"
tabindex="-1"
></td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
/*
* Presentation-only styles for the calendar demo.
* Selected = aria-selected, disabled = aria-disabled, outside the month =
* data-outside, today = data-today (all set by the library).
*/
/* Card surface (background / border / radius / padding / shadow) comes from the shared
.demo-card primitive (base/demo-primitives.css); only layout-specific bits stay here. */
.calendar-demo {
display: inline-block;
font-family: inherit;
user-select: none;
}
.calendar-demo__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.calendar-demo__label {
font-weight: 600;
font-size: 1rem;
color: var(--fg);
}
.calendar-demo__btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--border-default);
border-radius: 6px;
background: transparent;
color: var(--fg);
font-size: 1.15rem;
cursor: pointer;
transition: all 0.15s ease;
}
.calendar-demo__btn:hover {
background: var(--surface-subtle);
border-color: var(--border-strong);
}
.calendar-demo__table {
border-collapse: collapse;
width: 100%;
}
.calendar-demo__table th {
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-muted);
padding: 0.5rem 0;
text-align: center;
width: 2.5rem;
}
.calendar-demo__cell {
text-align: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
font-size: 0.875rem;
border-radius: 8px;
color: var(--fg);
cursor: pointer;
transition: all 0.15s ease;
}
/* Background for roving focus (tabindex="0") or on hover. */
.calendar-demo__cell[tabindex="0"] {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.calendar-demo__cell:hover:not([aria-disabled="true"]) {
background: var(--surface-subtle);
}
/* Outside the displayed month. */
.calendar-demo__cell[data-outside="true"] {
color: var(--color-text-subtle);
}
/* Today. */
.calendar-demo__cell[data-today="true"] {
font-weight: bold;
border: 1px solid var(--accent);
}
/* Selected state. */
.calendar-demo__cell[aria-selected="true"] {
background: var(--accent) !important;
color: var(--white) !important;
font-weight: bold;
}
/* Out of range (disabled). */
.calendar-demo__cell[aria-disabled="true"] {
color: var(--slate-300) !important;
cursor: not-allowed;
background: transparent !important;
}
/* Styles for the navigation icons. */
.calendar-demo__icon {
width: 1.15rem;
height: 1.15rem;
stroke-width: 2.25;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--calendar"
Targets
| Name | Description | Attribute |
|---|---|---|
label
|
Span showing the localized current month and year. | data-stimeo--calendar-target="label" |
grid
required
|
The day-grid container that receives keydown and click events. | data-stimeo--calendar-target="grid" |
day
|
One of the 42 pre-allocated gridcells; the controller binds date, selection, today, and roving-tabindex state to each. |
data-stimeo--calendar-target="day" |
Values
| Name | Description | Attribute |
|---|---|---|
month
|
Displayed month as YYYY-MM (defaults to the current month when empty). |
data-stimeo--calendar-month-value |
selected
|
Currently selected date as YYYY-MM-DD, or empty when none. |
data-stimeo--calendar-selected-value |
min
|
Earliest selectable date (YYYY-MM-DD); earlier days get aria-disabled. |
data-stimeo--calendar-min-value |
max
|
Latest selectable date (YYYY-MM-DD); later days get aria-disabled. |
data-stimeo--calendar-max-value |
weekStart
|
First day of the week (0 = Sunday, 1 = Monday, …; default 0). | data-stimeo--calendar-week-start-value |
Actions
| Name | Description | Action |
|---|---|---|
next
|
Navigates to the next month. | stimeo--calendar#next |
onKeydown
|
Handles grid keyboard navigation (arrows, PageUp/Down, Shift+PageUp/Down, Home/End, T for today) and selection via Enter/Space. | stimeo--calendar#onKeydown |
prev
|
Navigates to the previous month. | stimeo--calendar#prev |
selectByClick
|
Selects the day whose gridcell was clicked. | stimeo--calendar#selectByClick |
Events
| Name | Description | Event |
|---|---|---|
monthchange
|
Fires when the displayed month changes; detail { month } (YYYY-MM). |
stimeo--calendar:monthchange |
select
|
Fires when a date is selected; detail { date } (YYYY-MM-DD). |
stimeo--calendar:select |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-selected |
day target cell | "true" for the currently selected date cell, "false" otherwise. |
aria-disabled |
day target cell | "true" for dates outside the allowed min/max range. |
data-outside |
day target cell | "true" for dates belonging to the previous or next month on the 42-day grid. |
data-today |
day target cell | "true" for the cell matching today's actual date. |
tabindex |
day target cell | Roving focus tracking: "0" for the single focusable date in the grid, "-1" for others. |