PetalComponents.Calendar (petal_components v4.15.1)

Copy Markdown View Source

A server-rendered month grid built on Elixir's Date. No time, no timezone, no DateTime: values in and out are %Date{} and anything posted is ISO 8601.

The grid follows the WAI-ARIA grid pattern. Every day is a real <button>, so Tab plus Enter works with JavaScript disabled; the PetalCalendar hook layers roving tabindex and the arrow-key map on top.

Two wirings

With on_select and on_month_change set, days and nav buttons push events carrying ISO strings:

<.calendar
  id="due-date"
  value={@due_on}
  month={@month}
  on_select="pick_date"
  on_month_change="change_month"
/>

def handle_event("pick_date", %{"date" => iso}, socket) do
  {:ok, date} = Date.from_iso8601(iso)
  {:noreply, assign(socket, due_on: date)}
end

def handle_event("change_month", %{"month" => iso}, socket) do
  {:ok, month} = Date.from_iso8601(iso)
  {:noreply, assign(socket, month: month)}
end

With them left nil, month navigation renders as patch links carrying month_param, which works in a dead view as a plain query-string link:

<.calendar month={@month} value={@value} month_param="month" name="booking[date]" />

Give it a name and each day becomes a submit button, so a form wrapping the calendar posts the clicked ISO date with no JavaScript at all.

Selection modes

<.calendar mode="single" value={~D[2026-03-14]} />
<.calendar mode="range" value={{~D[2026-03-10], ~D[2026-03-17]}} />
<.calendar mode="multiple" value={[~D[2026-03-03], ~D[2026-03-09]]} />

In range mode either side may be nil while a selection is in flight, so a half-open {~D[2026-03-10], nil} renders the anchor day and nothing else.

Limiting what can be picked

<.calendar
  min={Date.utc_today()}
  max={Date.add(Date.utc_today(), 30)}
  disabled_dates={&(Date.day_of_week(&1) in [6, 7])}
/>

disabled_dates takes a list of dates or a 1-arity function. Disabled days still render and stay focusable (per the APG), they just cannot be selected.

Today, and timezones

today defaults to Date.utc_today/0. A %Date{} has no timezone, so the component never converts one for you. If your users are not on UTC, pass the date you consider today:

<.calendar today={DateTime.to_date(DateTime.shift_zone!(DateTime.utc_now(), @tz))} />

Localisation

There is no date-localisation dependency here. Day and month names are plain attrs, so wire them to gettext yourself:

<.calendar
  day_names={Enum.map(~w(Mon Tue Wed Thu Fri Sat Sun), &gettext("day_short_%{d}", d: &1))}
  month_names={for m <- 1..12, do: Gettext.gettext(MyApp.Gettext, month_key(m))}
/>

day_names is always ordered Monday-first; the component rotates it to match starts_on.

Cell size

Day cells are square, and one CSS custom property sizes them: --pc-calendar-cell-size, default 2.25rem. Set it on the calendar or on anything above it and the days, the weekday headers and the month-nav arrows all move together, so the grid stays in register:

<.calendar class="[--pc-calendar-cell-size:3rem]" />

<div style="--pc-calendar-cell-size: 4rem">
  <.calendar />
</div>

There is deliberately no size attr. The token is the API, the way --pc-radius is for corners: one dial, set wherever you already set classes, with no new attr values to learn or version. Being a class, it takes breakpoint variants like any other - big cells cannot fit seven columns on a phone, so a booking grid steps its size:

<.calendar class="[--pc-calendar-cell-size:2.75rem] sm:[--pc-calendar-cell-size:3.5rem]" />

Custom day content

The :day slot replaces the content of every day button. The classic case is a booking grid with a price under each date, which is a cell size and a slot together:

<.calendar mode="range" value={@stay} class="[--pc-calendar-cell-size:3.5rem]">
  <:day :let={day}>
    <span class="flex flex-col items-center gap-0.5 leading-none">
      <span>{day.date.day}</span>
      <span :if={!day.outside} class={["text-xs", !day.selected && "text-gray-500"]}>
        {price_for(day.date)}
      </span>
    </span>
  </:day>
</.calendar>

The slot owns the whole content of the button, so you render the number. It receives the day the component already built:

  • :date - the %Date{}
  • :iso - that date as an ISO 8601 string
  • :today, :outside, :disabled, :selected - booleans
  • :range_start, :range_middle, :range_end - booleans, range mode

The button itself is untouched: the selection chip, the range band, the today dot and the disabled treatment are still the component's job, and the aria-label is still the full date. The slot is what a sighted user sees inside the button, not what a screen reader is told it is. Use :selected to keep a muted second line readable once the inverse chip lands under it.

Summary

Functions

Renders a month grid. See the module docs above for the two wiring modes, the selection shapes each mode accepts, and the gettext recipe for day and month names.

Functions

calendar(assigns)

Renders a month grid. See the module docs above for the two wiring modes, the selection shapes each mode accepts, and the gettext recipe for day and month names.

<.calendar value={@due_on} month={@month} on_select="pick" on_month_change="page" />

Attributes

  • id (:string) - the calendar id; autogenerated if not set.
  • mode (:string) - selection behaviour. Defaults to "single". Must be one of "single", "range", or "multiple".
  • value (:any) - the selection: a Date (single), a {from, to} tuple or a map with :from/:to (range, either side may be nil mid-selection), or a list of Dates (multiple). Defaults to nil.
  • month (:any) - the displayed month as any Date within it; defaults to the selected value's month, else today. Defaults to nil.
  • min (:any) - earliest selectable Date (inclusive). Defaults to nil.
  • max (:any) - latest selectable Date (inclusive). Defaults to nil.
  • disabled_dates (:any) - a list of Dates, or a 1-arity function Date -> boolean; disabled days render but are not selectable. Defaults to nil.
  • today (:any) - the date treated as today; defaults to Date.utc_today/0. Pass the user's local date if UTC is not good enough. Defaults to nil.
  • starts_on (:integer) - first day of the week, ISO day numbers (1 = Monday, 7 = Sunday). Defaults to 1. Must be one of 1, 2, 3, 4, 5, 6, or 7.
  • show_outside_days (:boolean) - render leading/trailing days from adjacent months. Defaults to true.
  • fixed_weeks (:boolean) - always render 6 week rows so the grid height never jumps. Defaults to false.
  • day_names (:list) - 7 short day labels ordered Monday-first; default English. Defaults to ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].
  • month_names (:list) - 12 month labels; default English. Defaults to ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"].
  • day_names_long (:list) - 7 full day labels ordered Monday-first, used for screen readers; default English. Defaults to ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"].
  • on_select (:any) - event name pushed when a day is clicked; the payload carries the ISO date. Defaults to nil.
  • on_month_change (:any) - event name for prev/next month; when nil, nav renders as patch links using the month_param query param. Defaults to nil.
  • target (:any) - phx-target for the select and month-change events; set it to @myself when the calendar lives inside a LiveComponent. Defaults to nil.
  • month_param (:string) - query param used for link-based month navigation. Defaults to "month".
  • name (:any) - when set (and no on_select), day buttons submit this form field with the ISO date as value. Defaults to nil.
  • nav (:string) - which month-nav arrows to render; two side-by-side panes want prev on the left pane and next on the right. Defaults to "both". Must be one of "both", "prev", "next", or "none".
  • prev_label (:string) - accessible label for the prev button. Defaults to "Previous month".
  • next_label (:string) - accessible label for the next button. Defaults to "Next month".
  • class (:any) - extra classes for the calendar wrapper. Defaults to nil.
  • Global attributes are accepted.

Slots

  • day - content for every day button, in place of the bare number - render the number yourself. Receives the day: :date (a Date), :iso, and the :today, :outside, :disabled, :selected, :range_start, :range_middle and :range_end flags. Pair it with a bigger --pc-calendar-cell-size when the content needs a second line.