カレンダー
stimeo--calendar
キーボードで動かせる月のカレンダー。日付の並びと月またぎを引き受けます。
日付を選ぶ月のカレンダーです。WAI-ARIA の Date Picker Dialog パターンに沿っています。矢印キーで日を移り、ページ送りのキーで月を、Home と End で週の両端へ動きます。月の端を越えたら自動で前後の月に切り替わります。31 日から 30 日しかない月へ移ったときのように、その月に無い日付は正しく丸めます。曜日や月の名前は表示中の言語に合わせて作られるので、日本語でも英語でもそのまま読めます。
実行中
| 日 | 月 | 火 | 水 | 木 | 金 | 土 |
|---|---|---|---|---|---|---|
キーボード操作
| キー | 動作 |
|---|---|
| ← | フォーカスを前日に移動する。月境界をまたぐ場合は自動的に前月に遷移する。RTL では → の動作。 |
| → | フォーカスを翌日に移動する。月境界をまたぐ場合は自動的に翌月に遷移する。RTL では ← の動作。 |
| ↑ | フォーカスを1週間前の同じ曜日に移動する。月境界をまたぐ場合は自動的に前月に遷移する。 |
| ↓ | フォーカスを1週間後の同じ曜日に移動する。月境界をまたぐ場合は自動的に翌月に遷移する。 |
| PageUp | フォーカスを1ヶ月前の同じ日(存在しない場合は月末日に丸め)に移動し、必要に応じて前月に遷移する。 |
| PageDown | フォーカスを1ヶ月後の同じ日(存在しない場合は月末日に丸め)に移動し、必要に応じて翌月に遷移する。 |
| Shift + PageUp | フォーカスを1年前の同じ日(存在しない場合は月末日に丸め)に移動し、必要に応じて前年に遷移する。 |
| Shift + PageDown | フォーカスを1年後の同じ日(存在しない場合は月末日に丸め)に移動し、必要に応じて翌年に遷移する。 |
| Home | フォーカスを現在週の開始曜日のセルに移動する。 |
| End | フォーカスを現在週の終了曜日のセルに移動する。 |
| T | フォーカスを今日(本日)の日付セルに瞬時に移動する(必要に応じて現在の表示月へ遷移する)。 |
| Enter / Space | 現在フォーカスされている日を選択し、stimeo--calendar:select イベントを発火する(無効化日は除く)。 |
<%# 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();
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--calendar"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
label
|
ローカライズされた現在の月・年を表示する span。 | data-stimeo--calendar-target="label" |
grid
必須
|
keydown と click を受け取る日付グリッドのコンテナ。 | data-stimeo--calendar-target="grid" |
day
|
事前確保された 42 個の gridcell の 1 つ。日付・選択・本日・roving tabindex の状態が各セルに反映される。 |
data-stimeo--calendar-target="day" |
field
|
選択日(YYYY-MM-DD)を写す任意の hidden input。選択日が min/max の外にある間は空になる。 |
data-stimeo--calendar-target="field" |
monthField
|
描画中の月(YYYY-MM)を写す任意の hidden input。 | data-stimeo--calendar-target="monthField" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
month
|
表示中の月(YYYY-MM)。空の場合は選択日の月、それも無ければ現在の月を表示し、利用者が月を送る(そのときはここへ書く)かページが書くまでその月に留まる。開くときには書かない。 |
data-stimeo--calendar-month-value |
selected
|
選択したい日付(YYYY-MM-DD)。未選択時は空。min/max の内側にある間だけ選択として表示・送信し、範囲が戻れば選択も戻る。コントローラは書き換えない。 |
data-stimeo--calendar-selected-value |
min
|
選択可能な最小日付(YYYY-MM-DD)。それ以前の日は aria-disabled になり、それ以前の選択日は選択として扱わない。 |
data-stimeo--calendar-min-value |
max
|
選択可能な最大日付(YYYY-MM-DD)。それ以降の日は aria-disabled になり、それ以降の選択日は選択として扱わない。 |
data-stimeo--calendar-max-value |
weekStart
|
週の開始曜日(0 = 日曜、1 = 月曜…、既定 0)。 | data-stimeo--calendar-week-start-value |
locale
|
月ラベルの整形に用いるロケール。空なら自要素を含む最も近い祖先の lang、それも無ければランタイム既定。不正なタグは英語へフォールバック(既定は空)。 |
data-stimeo--calendar-locale-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
next
|
翌月へ移動する。 | stimeo--calendar#next |
onKeydown
|
グリッドのキーボード操作(矢印、PageUp/Down、Shift+PageUp/Down、Home/End、T で本日)と Enter/Space による選択を処理する。 | stimeo--calendar#onKeydown |
prev
|
前月へ移動する。 | stimeo--calendar#prev |
select
|
イベントまたは要素で指定した自部品の日付セルを選択する。 | stimeo--calendar#select |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
monthchange
|
表示月が変わったときに発火。detail は { month }(YYYY-MM)。 |
stimeo--calendar:monthchange |
reconcile
|
ページ側の変化(selected の書き換え、min/max による選択日の取り下げと再公開)で選択として扱う日付が動いたとき、1回の描画につき1回発火。detail は { date }(いま選択として扱う日付。無ければ空)。利用者が選んだ日は select で知らせる。接続時は発火しない。 |
stimeo--calendar:reconcile |
select
|
選択を報告し、detail に { date, reason } を運ぶ。reason で操作と要素指定の API 呼び出しを区別する。detail.reason は user / focus / pointer / api を報告する。 |
stimeo--calendar:select |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-selected |
日セル要素 (day) | 選択中の日付が min/max の内側にある間はその日に "true"、ほかの日付には "false" が入る。コントローラが毎回すべてのセルへ書き直すので、サーバー側で書いておく必要はない。 |
aria-disabled |
日セル要素 (day) | min/max 値の範囲外で選択不可能な日付セルで "true"。 |
data-outside |
日セル要素 (day) | カレンダーの表示月(monthValue)の範囲外の日付(前月末や翌月初)で "true"、範囲内で "false"。 |
data-today |
日セル要素 (day) | 当日(今日)の日付に合致するセルで "true"、それ以外は "false"。 |
tabindex |
日セル要素 (day) | Roving focus 用。グリッド内で現在フォーカス可能な唯一の日セルのみ "0"、それ以外は "-1"。 |