Date Range Picker
stimeo--date-range-picker
A two-point date range picker with preview and presets, derived from Calendar.
The stimeo--date-range-picker controller builds on the Calendar grid model to add two-point range selection. The first click sets the start and enters selecting mode (the range previews to the hovered/focused day); the second confirms the end, auto-swapping when it precedes the start. Range endpoints are exposed to assistive tech via aria-selected, while inner cells use data-in-range for visual painting only. Grid keyboard navigation uses roving tabindex, presets (today / last 7 days / this month) jump the view, and Escape abandons an in-progress selection. The confirmed range is mirrored to a live status region and to hidden start/end fields. Behavior only.
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. |
| PageDown | Moves focus to the same day of the next month. |
| Home | Moves focus to the first day of the current week. |
| End | Moves focus to the last day of the current week. |
| Enter / Space | Confirms the focused day as the start, then the end of the range. |
| Escape | Discards an in-progress selection (the confirmed range is kept). |
<%# 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. %>
<div class="drp demo-card" data-controller="stimeo--date-range-picker">
<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>
<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 two end cells (for AT announcement)
* - [aria-disabled="true"] … outside min/max, not selectable
*/
/* 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);
}
.drp__cell[data-range-start],
.drp__cell[data-range-end],
.drp__cell[aria-selected="true"] {
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;
}
.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);
});
});
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--date-range-picker"
Targets
| Name | Description | Attribute |
|---|---|---|
grid
required
|
The six-week month-grid container. | data-stimeo--date-range-picker-target="grid" |
monthLabel
|
Live span showing the localized displayed month and year. | data-stimeo--date-range-picker-target="monthLabel" |
cell
required
|
One of the 42 gridcells; the controller binds date, range, roving-tabindex, and disabled state to each. |
data-stimeo--date-range-picker-target="cell" |
status
|
Live status region that announces the confirmed range. | data-stimeo--date-range-picker-target="status" |
startField
|
Hidden input mirroring the confirmed range start date. | data-stimeo--date-range-picker-target="startField" |
endField
|
Hidden input mirroring the confirmed range end date. | data-stimeo--date-range-picker-target="endField" |
Values
| Name | Description | Attribute |
|---|---|---|
min
|
Earliest selectable date (YYYY-MM-DD); earlier cells get aria-disabled (default empty). |
data-stimeo--date-range-picker-min-value |
max
|
Latest selectable date (YYYY-MM-DD); later cells get aria-disabled (default empty). |
data-stimeo--date-range-picker-max-value |
Actions
| Name | Description | Action |
|---|---|---|
applyPreset
|
Applies a named preset range (today/last7/last30/thisMonth) from the clicked button's data-range. |
stimeo--date-range-picker#applyPreset |
next
|
Navigates to the next month. | stimeo--date-range-picker#next |
onKeydown
|
Handles grid keyboard navigation (arrows, Home/End, PageUp/Down), selection via Enter/Space, and Escape to cancel an in-progress range. | stimeo--date-range-picker#onKeydown |
prev
|
Navigates to the previous month. | stimeo--date-range-picker#prev |
previewTo
|
Previews the range up to the hovered/focused cell while a selection is in progress. | stimeo--date-range-picker#previewTo |
selectDate
|
Confirms a range endpoint from the clicked cell (first sets the start, second the end). | stimeo--date-range-picker#selectDate |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when a range is confirmed; detail { start, end } (ISO dates, start ≤ end). |
stimeo--date-range-picker:change |
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 |
Cell | The start / end endpoints of the range. |
data-range-start / data-range-end |
Cell | Visual hooks for the range ends. |
data-in-range |
Cell | Inner cells between start and end (including the preview). |
aria-disabled |
Cell | Out of the min / max bounds and not selectable. |
tabindex |
Cell | Roving focus — exactly one cell is focusable. |
text |
Status region | The confirmed range, announced via a live region. |