期間選択カレンダー
stimeo--date-range-picker
開始と終了の 2 回で期間を選ぶカレンダー。選んでいる途中の範囲も見えます。
カレンダーの上で、開始と終了の 2 回のクリックで期間を選びます。1 回目で開始が決まり、そこから指している日までの範囲が仮に色づきます。2 回目で終了が決まります。開始より前の日を選んだときは、自動で前後を入れ替えます。期間の両端は読み上げにも伝わり、間の日は見た目のためだけに印がつきます。「今日」「直近 7 日」「今月」のような近道も置けます。選んでいる途中で Esc を押せば、仮の範囲を捨ててやり直せます。決まった期間は読み上げに届き、フォームにも渡ります。キーボードでの移動はカレンダーと同じです。
このデモでは、休業日の例として 3 日後と 4 日後を選択不可にしています。範囲がその日をまたぐことはできますが、開始日 / 終了日にはできません。
キーボード操作
| キー | 動作 |
|---|---|
| ← | フォーカスを前日に移動する。月境界をまたぐ場合は自動的に前月に遷移する。RTL では → の動作。 |
| → | フォーカスを翌日に移動する。月境界をまたぐ場合は自動的に翌月に遷移する。RTL では ← の動作。 |
| ↑ | フォーカスを1週間前の同じ曜日に移動する。月境界をまたぐ場合は自動的に前月に遷移する。 |
| ↓ | フォーカスを1週間後の同じ曜日に移動する。月境界をまたぐ場合は自動的に翌月に遷移する。 |
| PageUp | フォーカスを前月の同じ日に移動する。 |
| PageDown | フォーカスを翌月の同じ日に移動する。 |
| Shift+PageUp / Shift+PageDown | フォーカスを前年 / 翌年の同じ日に移動し、存在しない日は月末へ丸める。 |
| Home | フォーカスを現在週の開始曜日のセルに移動する。 |
| End | フォーカスを現在週の終了曜日のセルに移動する。 |
| Enter / Space | 現在フォーカスされている日を、開始 → 終了の順で確定する。 |
| Escape | 選択途中の仮範囲を破棄する(確定済みの範囲は保持する)。 |
<%# Markup for the date-range-picker (range-selection calendar) demo.
stimeo--date-range-picker derives from calendar and handles two-point start/end
selection, a provisional range preview mid-selection, marking in-range cells,
auto-swapping when start > end, applying presets, and grid keyboard movement
(roving). Range ends are shown with aria-selected and the inside with data-in-range;
placement and styling live in demo.css.
Two unavailable days are declared as data rather than written onto cells. The
42 cells are reused for a different date every month, so availability has to
be keyed by the date; the controller derives each cell's aria-disabled from
this array on every paint. A range may still span such a day — it just cannot
end on one. %>
<% blackout = [3.days.from_now, 4.days.from_now].map { |day| day.strftime("%Y-%m-%d") } %>
<div class="drp demo-card" data-controller="stimeo--date-range-picker"
data-stimeo--date-range-picker-disabled-dates-value="<%= blackout.to_json %>">
<div class="drp__header">
<button class="drp__nav" type="button"
data-action="click->stimeo--date-range-picker#prev"
aria-label="<%= t("components.date_range_picker.demo.prev_month") %>">‹</button>
<span class="drp__label" id="drp-month" aria-live="polite"
data-stimeo--date-range-picker-target="monthLabel"></span>
<button class="drp__nav" type="button"
data-action="click->stimeo--date-range-picker#next"
aria-label="<%= t("components.date_range_picker.demo.next_month") %>">›</button>
</div>
<div class="drp__weekdays" aria-hidden="true">
<% t("components.date_range_picker.demo.weekdays").each do |day| %>
<span class="drp__weekday"><%= day %></span>
<% end %>
</div>
<div class="drp__grid" role="grid" aria-labelledby="drp-month"
data-stimeo--date-range-picker-target="grid">
<% 6.times do %>
<div class="drp__row" role="row">
<% 7.times do %>
<button class="drp__cell" type="button" role="gridcell" tabindex="-1"
data-stimeo--date-range-picker-target="cell"
data-action="click->stimeo--date-range-picker#selectDate
mouseenter->stimeo--date-range-picker#previewTo
focus->stimeo--date-range-picker#previewTo
keydown->stimeo--date-range-picker#onKeydown"></button>
<% end %>
</div>
<% end %>
</div>
<div class="drp__presets" role="group"
aria-label="<%= t("components.date_range_picker.demo.presets_label") %>">
<button class="drp__preset" type="button" data-range="today"
data-action="click->stimeo--date-range-picker#applyPreset">
<%= t("components.date_range_picker.demo.preset_today") %>
</button>
<button class="drp__preset" type="button" data-range="last7"
data-action="click->stimeo--date-range-picker#applyPreset">
<%= t("components.date_range_picker.demo.preset_last7") %>
</button>
<button class="drp__preset" type="button" data-range="thisMonth"
data-action="click->stimeo--date-range-picker#applyPreset">
<%= t("components.date_range_picker.demo.preset_this_month") %>
</button>
</div>
<span class="drp__status" role="status" aria-live="polite"
data-stimeo--date-range-picker-target="status"></span>
<%# Says on screen why two days are struck out. The dates move with today, so
without this the exclusion reads as a glitch rather than a demonstration. %>
<p class="drp__note"><%= t("components.date_range_picker.demo.blackout_note") %></p>
<input type="hidden" name="start_date" data-stimeo--date-range-picker-target="startField" />
<input type="hidden" name="end_date" data-stimeo--date-range-picker-target="endField" />
</div>
/*
* Presentation-only styles for the date-range-picker demo.
* Range shading reacts to the state hooks the library sets on each cell:
* - [data-range-start] / [data-range-end] … the range ends
* - [data-in-range] … inside start–end (incl. the preview range)
* - [aria-selected="true"] … the confirmed ends (AT only; not styled here)
* - [aria-disabled="true"] … outside min/max or in disabled-dates
*/
/* Card surface comes from the shared .demo-card primitive (base/demo-primitives.css),
the same one the calendar demo uses; only layout-specific bits stay here. */
.drp {
width: min(20rem, 100%);
font-variant-numeric: tabular-nums;
}
.drp__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.drp__label {
font-weight: 600;
}
.drp__nav {
width: 2rem;
height: 2rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.drp__weekdays,
.drp__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.drp__weekday {
padding: 0.25rem 0;
text-align: center;
font-size: 0.75rem;
color: var(--text-muted, var(--color-text-muted));
}
.drp__cell {
aspect-ratio: 1;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
border-radius: 0;
}
.drp__cell[data-outside="true"] {
color: var(--color-text-muted);
}
.drp__cell[data-today="true"] {
font-weight: 700;
text-decoration: underline;
}
.drp__cell[data-in-range] {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
/* Ends are styled from the data-* trio only. `aria-selected` marks the last
confirmed range, which stays put until the second click of the next one — so
reading it here would keep painting the previous ends through a new
selection. */
.drp__cell[data-range-start],
.drp__cell[data-range-end] {
background: var(--accent);
color: var(--white);
}
.drp__cell[data-range-start] {
border-top-left-radius: 999px;
border-bottom-left-radius: 999px;
}
.drp__cell[data-range-end] {
border-top-right-radius: 999px;
border-bottom-right-radius: 999px;
}
/* The on-screen explanation of the two declared unavailable days. */
.drp__note {
margin: 0;
color: var(--color-text-muted);
font-size: 0.8125rem;
line-height: 1.5;
}
.drp__cell[aria-disabled="true"] {
color: var(--color-text-muted);
cursor: not-allowed;
}
.drp__cell:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.drp__presets {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.drp__preset {
padding: 0.375rem 0.625rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
cursor: pointer;
font-size: 0.85rem;
}
.drp__status {
display: block;
margin-top: 0.625rem;
min-height: 1.25rem;
color: var(--text-muted, var(--color-text-muted));
}
// Demo that subscribes to the date-range-picker change event to inspect the confirmed
// range (ISO dates). The library also announces the confirmed range in a status live
// region, but the consumer can use the detail too.
document.querySelectorAll('[data-controller~="stimeo--date-range-picker"]').forEach((root) => {
root.addEventListener('stimeo--date-range-picker:change', (e) => {
// In real use, e.detail.start / e.detail.end (ISO date strings) feed submission or filtering.
console.log('range:', e.detail.start, '→', e.detail.end);
});
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--date-range-picker"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
grid
必須
|
6 週分の月グリッドのコンテナ。 | data-stimeo--date-range-picker-target="grid" |
monthLabel
|
ローカライズされた表示月・年を示す任意の live な span。 | data-stimeo--date-range-picker-target="monthLabel" |
cell
必須
|
42 個の gridcell の 1 つ。日付・範囲・roving tabindex・無効状態が各セルに反映される。 |
data-stimeo--date-range-picker-target="cell" |
status
|
確定した範囲を読み上げる任意の live な status 領域。 | data-stimeo--date-range-picker-target="status" |
startField
|
確定した範囲開始日を反映する任意の隠し input。 | data-stimeo--date-range-picker-target="startField" |
endField
|
確定した範囲終了日を反映する任意の隠し input。 | data-stimeo--date-range-picker-target="endField" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
min
|
選択可能な最小日付(YYYY-MM-DD)。それ以前のセルは aria-disabled(既定は空)。 |
data-stimeo--date-range-picker-min-value |
max
|
選択可能な最大日付(YYYY-MM-DD)。それ以降のセルは aria-disabled(既定は空)。 |
data-stimeo--date-range-picker-max-value |
disabledDates
|
範囲の内側で個別に除外する ISO 日付の JSON 配列(既定は [])。区間ではなく点の除外で、その日は端点にできないが範囲が跨ぐことはできる。実行時に差し替えると再描画する。 |
data-stimeo--date-range-picker-disabled-dates-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
applyPreset
|
クリックされたボタンの data-range から名前付きプリセット範囲(today/last7/last30/thisMonth)を適用する。 |
stimeo--date-range-picker#applyPreset |
next
|
翌月へ移動する。 | stimeo--date-range-picker#next |
onKeydown
|
グリッドのキーボード操作(矢印、Home/End、PageUp/Down、Shift+PageUp/Down)、Enter/Space による選択、Escape による選択中断を処理する。 | stimeo--date-range-picker#onKeydown |
prev
|
前月へ移動する。 | stimeo--date-range-picker#prev |
previewTo
|
選択中、ホバー/フォーカスしたセルまでの範囲をプレビュー表示する。 | stimeo--date-range-picker#previewTo |
selectDate
|
クリックされたセルで範囲の端点を確定する(1 回目は開始、2 回目は終了)。 | stimeo--date-range-picker#selectDate |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
範囲が確定したときに発火。detail は { start, end }(ISO 日付、start ≤ end)。 |
stimeo--date-range-picker:change |
monthchange
|
別の月を描画し終えた後に発火。detail は { month }(YYYY-MM)。描画が待つフックではなく通知で、取得した結果は disabled-dates へ戻す。 |
stimeo--date-range-picker:monthchange |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-selected |
セル | 確定した範囲の開始日と終了日に "true"。選択途中のプレビュー(ポインタを乗せている日)には付かない —— そちらは data-in-range などで表す。 |
data-range-start / data-range-end |
セル | 範囲端の視覚表現用フック。 |
data-in-range |
セル | 開始〜終了の内側セル(仮範囲を含む)。 |
aria-disabled |
セル | 選択不可(min / max の範囲外、または disabled-dates に含まれる日)。controller が所有し毎描画で導出するので、クリックしたときの挙動と常に一致する。 |
tabindex |
セル | ロービング。フォーカス可能なセルは常に 1 つ。 |
テキスト |
status リージョン | 確定した期間をライブリージョンで通知。 |