Calendar
stimeo--calendar
A month calendar you drive from the keyboard, with the day grid and month steps handled.
A month calendar for picking a date, following the WAI-ARIA Date Picker Dialog pattern. The arrow keys move by day, the page keys move by month, and Home and End jump to the ends of a week. Crossing the edge of a month steps into the next or previous one. A date the target month does not have, such as the 31st moving into a 30-day month, is rounded properly. Weekday and month names are built for the language on display, so they read correctly in English and Japanese alike.
| 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. Under RTL this is ArrowRight. |
| → | Moves focus to the next day, transitioning to the next month automatically if crossing boundaries. Under RTL this is ArrowLeft. |
| ↑ | 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 to month-end if needed) and transitions year. |
| Shift + PageDown | Moves focus to the same day of the next year (clamped to month-end 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"
>
<%# The hidden fields carry the selected day and the painted month into the
form, so a server can serve the days it is showing. Both are optional. %>
<input type="hidden" name="on" data-stimeo--calendar-target="field" />
<input type="hidden" name="month" data-stimeo--calendar-target="monthField" />
<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#select
"
>
<% 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>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* 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__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.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:focus-visible {
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;
}
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// a selection the page moved — `selected` rewritten, or a bound that withholds
// the selected day or gives it back — is reported as `stimeo--calendar:reconcile`.
// This demo reads both events, so the readout follows the user's moves and the
// page's.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSelector = [
"[data-stimeo--calendar-target='field']",
"[data-stimeo--calendar-target='monthField']",
].join(", ");
const fieldSources = demoStage.querySelectorAll(fieldSelector);
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll(fieldSelector))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--calendar:reconcile", showSubmitted);
showSubmitted();
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" |
field
|
Optional hidden input the selected day (YYYY-MM-DD) is mirrored into; empty while that day lies outside min/max. |
data-stimeo--calendar-target="field" |
monthField
|
Optional hidden input the painted month (YYYY-MM) is mirrored into. |
data-stimeo--calendar-target="monthField" |
Values
| Name | Description | Attribute |
|---|---|---|
month
|
Displayed month as YYYY-MM. When empty, the grid opens on the month of the selected date, else on the current month, and stays there until the user steps the month (which writes it here) or the page writes it; opening writes nothing. |
data-stimeo--calendar-month-value |
selected
|
Requested date as YYYY-MM-DD, or empty when none. It is shown and submitted as the selection only while it lies inside min/max, and comes back when they allow it again; the controller never rewrites it. |
data-stimeo--calendar-selected-value |
min
|
Earliest selectable date (YYYY-MM-DD); earlier days get aria-disabled, and a selection before it is withheld. |
data-stimeo--calendar-min-value |
max
|
Latest selectable date (YYYY-MM-DD); later days get aria-disabled, and a selection after it is withheld. |
data-stimeo--calendar-max-value |
weekStart
|
First day of the week (0 = Sunday, 1 = Monday, …; default 0). | data-stimeo--calendar-week-start-value |
locale
|
Locale the month label is formatted in; falls back to the nearest lang up the ancestor chain, then to the runtime default; malformed tags fall back to English (empty by default). |
data-stimeo--calendar-locale-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 |
select
|
Selects the owned day from an event or element. | stimeo--calendar#select |
Events
| Name | Description | Event |
|---|---|---|
monthchange
|
Fires when the displayed month changes; detail { month } (YYYY-MM). |
stimeo--calendar:monthchange |
reconcile
|
Fires once per paint when a change on the page side — selected rewritten, or min/max withholding the selected date or giving it back — moves the selection shown; detail { date } (the selection now shown, empty when none). A date the user picks fires select instead. Not fired on connect. |
stimeo--calendar:reconcile |
select
|
Reports selection with detail { date, reason }; reason distinguishes the interaction from an element API call. detail.reason reports user / focus / pointer / api. |
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" on the selected date while it lies inside min/max, "false" on every other cell. The controller repaints all of them, so there is nothing to render server-side. |
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. |