Calendar
A month grid for selecting single dates or ranges.
Installation
Included in poetry-ui — available as
poetry_calendar
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add calendar
Default
<%= poetry_calendar(month: "2026-06-01", selected: "2026-06-12", today: "2026-06-15",
class: "rounded-md border") %>
Bounded
<%= poetry_calendar(month: "2026-06-01", today: "2026-06-15",
min: "2026-06-08", max: "2026-06-22",
class: "rounded-md border") %>
Custom cell size
<%= poetry_calendar(month: "2026-06-01", selected: "2026-06-12", today: "2026-06-15",
class: "rounded-md border [--cell-size:--spacing(11)]") %>
Date and time
<%# Date AND time (upstream calendar#date-and-time): the calendar goes
transparent + unpadded so the Card provides the one frame; the footer
carries the divider; the native picker indicator is hidden so the
leading clock is the only icon. Three named values post together. %>
<%= poetry_card(class: "mx-auto w-fit") do |card| %>
<%= poetry_calendar(name: "date", selected: Date.new(Date.today.year, Date.today.month, 12),
class: "bg-transparent p-0") %>
<% card.with_footer(class: "border-t") do %>
<div class="grid w-full gap-4">
<div class="grid gap-1.5">
<%= poetry_label(for_id: "time-from") { "Start Time" } %>
<%= poetry_input_group do %>
<%= poetry_input_group_addon do %>
<%= poetry_icon(name: :"clock-2") %>
<% end %>
<%= poetry_input_group_input(id: "time-from", name: "time_from", type: "time", step: "1",
value: "10:30:00",
class: "appearance-none [&::-webkit-calendar-picker-indicator]:hidden") %>
<% end %>
</div>
<div class="grid gap-1.5">
<%= poetry_label(for_id: "time-to") { "End Time" } %>
<%= poetry_input_group do %>
<%= poetry_input_group_addon do %>
<%= poetry_icon(name: :"clock-2") %>
<% end %>
<%= poetry_input_group_input(id: "time-to", name: "time_to", type: "time", step: "1",
value: "12:30:00",
class: "appearance-none [&::-webkit-calendar-picker-indicator]:hidden") %>
<% end %>
</div>
</div>
<% end %>
<% end %>
Month and year selector
<%# caption_layout: :dropdown swaps the month label for a NativeSelect
pair - jump navigation for far-away dates. min:/max: pin the year
list (without them it spans ten years around the initial month). %>
<%= poetry_calendar(caption_layout: :dropdown, min: "2020-01-01", max: "2030-12-31",
class: "rounded-lg border") %>
Range
<%= poetry_calendar(mode: :range, month: "2026-06-01", today: "2026-06-15",
selected: Date.new(2026, 6, 9)..Date.new(2026, 6, 18),
class: "rounded-md border") %>
Week numbers
<%# week_numbers: true adds the ISO week column - each row's Thursday
decides the number (stable under any week_start:). %>
<%= poetry_calendar(week_numbers: true, class: "rounded-lg border") %>
API
Poetry::Ui::Calendar::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| caption_layout: | Symbol | defaults to :label |
:label shows the month text; :dropdown swaps it for month + year selects (jump navigation). |
| mode: | Symbol | defaults to :single |
Picks one date (:single) or a span (:range). Range selection completes on the second click; a click before the start swaps, a re-click clears. |
| name: | String | Makes the calendar a form control: the pick posts as an ISO string in a hidden input; range mode posts name[start] + name[end]. | |
| week_numbers: | Boolean | defaults to false |
Adds the ISO week-number column (each row's Thursday decides the number). |
| week_start: | Integer | defaults to 0 |
The first weekday column (0 = Sunday .. 6 = Saturday). |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=calendar] | Root wrapper - the calendar controller (navigation, selection, roving arrow keys) rides here |
| [data-slot=calendar-nav] | The header row - previous/next month Buttons around the caption |
| [data-slot=calendar-caption] | The month label ('July 2026') - the controller rewrites it on navigation from the localized month names; under caption_layout: :dropdown it holds the month/year NativeSelect pair instead (the controller reflects navigation into them) |
| [data-slot=calendar-week-number] | The ISO week column (week_numbers:) - a columnheader stub plus one muted rowheader number per week; non-interactive |
| [data-slot=calendar-dropdown] | One caption dropdown unit (caption_layout: :dropdown) - the visible label with the real <select> stretched invisibly over it (the invisible-overlay pattern) |
| [data-slot=calendar-caption-label] | The visible text + chevron of a caption dropdown (aria-hidden - the overlaid select carries the value) |
| [data-slot=calendar-dropdown-value] | The label's text span - the controller rewrites it on navigation (month name or year) |
| [data-slot=calendar-grid] | The role=grid - the weekday header row plus six week rows (42 cells, always full weeks) |
| [data-slot=calendar-weekdays] | The role=row of weekday column headers |
| [data-slot=calendar-weekday] | One role=columnheader two-letter day label |
| [data-slot=calendar-week] | One role=row of seven day cells |
| [data-slot=calendar-day-cell] | The role=gridcell wrapper - aria-selected lives HERE (the ARIA grid contract; it is not valid on the button) |
| [data-slot=calendar-day] | One day <button> - the selection vocabulary and the roving tab stop ride here |
| [data-slot=calendar-day-label] | The day-number span inside the button |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| calendar-dropdown | data-calendar-unit | always - which unit this select drives | month · year |
| calendar-day | data-date | always - the day's ISO date (the controller's selection key) | — |
| calendar-day | data-selected | the day is the single-mode pick, or a start-only range pick (bare; a complete range wears the range-* trio instead) | — |
| calendar-day | data-range-start | the day starts a COMPLETE range | — |
| calendar-day | data-range-end | the day ends a COMPLETE range | — |
| calendar-day | data-range-middle | the day sits strictly inside a complete range | — |
| calendar-day | data-today | the day is today (aria-current=date rides along) | — |
| calendar-day | data-outside | the day belongs to a neighbouring month (leading/trailing fill) | — |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--calendar | registers · value month · value selected · value mode (if range?) · value range_start (if range?) · value range_end (if range?) · value week_start · value min · value max · value month_names |
| day | poetry--core--calendar | select on click · target day |
| nav_previous | poetry--core--calendar | previousMonth on click |
| nav_next | poetry--core--calendar | nextMonth on click |
| dropdown | poetry--core--calendar | jump on change |
| grid | poetry--core--calendar | keydown on keydown |
| caption | poetry--core--calendar | target caption |
| start_input | poetry--core--calendar | target startInput |
| end_input | poetry--core--calendar | target endInput |
| hidden_input | poetry--core--calendar | target input |