Skip to content

feat(calendar): month grid and date picker on plain Elixir Date - #642

Draft
mplatts wants to merge 2 commits into
mainfrom
feat/calendar
Draft

feat(calendar): month grid and date picker on plain Elixir Date#642
mplatts wants to merge 2 commits into
mainfrom
feat/calendar

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #610

Summary

Two components, shipped together as the issue asks: <.calendar>, a server-rendered month grid on Elixir's own Date, and <.date_picker>, that grid in a panel under a text input.

No new dependencies. Month maths is Date.beginning_of_month/1, Date.end_of_month/1, Date.add/2 and Date.day_of_week/1 plus modular arithmetic for the week rotation. Display formatting is Calendar.strftime/2. Nothing converts a timezone, because a %Date{} does not have one.

<.calendar>

  • single, range (including the half-open {from, nil} mid-selection state) and multiple
  • min/max inclusive windows, disabled_dates as a list or a 1-arity function. Disabled days render and stay focusable per the APG, they just lose their click wiring
  • starts_on for all seven ISO week starts, show_outside_days, fixed_weeks, and a today marker (a dot under the number) that still reads when the day is also selected
  • two wirings: on_select/on_month_change push phx-click with ISO phx-value-date/phx-value-month; with them nil the nav renders <.link patch> on month_param, which is a plain query-string anchor in a dead view
  • the range band lives on the <td> so it runs edge to edge between days; the day button keeps its own rounding

<.date_picker>

  • display input is strftime and unnamed, so it never posts; a hidden input carries ISO 8601 whatever format says. Range posts name[from] / name[to]
  • parse on blur or Enter: ISO first, then a lenient parse of the configured format (padding-insensitive, case-insensitive, accepts %B/%b month names and discards %A/%a), and reverts to the last valid value on failure rather than silently clearing
  • two_months, clearable, disabled, and the <.field> surface for label, errors, help text and the required marker, including Phoenix.HTML.FormField integration

Scope

Everything in the issue's "Variants & states to build", the playground spec and the test checklist is in this PR. Deliberately not in it, per the issue's own Non-goals:

  • time selection, natural-language parsing, a presets rail, cldr
  • the birthday year-select header. The issue says to note it rather than half-ship it, so: the birthday scenario is on the playground page and it works, but it gets there via parse-on-blur (type 12 Jun 1987, the grid follows) plus Shift+PageUp/PageDown for year jumps, not a decade dropdown. A year/month select header is a good follow-up and wants its own API decision (a caption slot, probably, so callers can put anything in the header).

One more follow-up worth naming: in two_months mode both arrows sit on the left pane and the right pane gets none, so paging always moves the pair by one month. Splitting them (prev on the left header, next on the right) needs the right pane's arrow to target base+1 rather than its own next month, which is a second nav-target attr I did not think earned its place yet.

Deviations from the issue's API sketch

All additive; nothing in the sketch was dropped or renamed.

  • today attr on both. Defaults to Date.utc_today/0. The issue is right that a Date has no timezone, which is exactly why the component must not pick one for you: a user in Melbourne gets "today" wrong for ten hours a day if UTC is hardcoded. Passing a date is the honest fix, and it makes every test deterministic.
  • day_names_long attr. day_names is 7 short labels, which is what a column header shows. The screen-reader name for the column needs the full word, and deriving "Monday" from "Mo" is not possible once the attr is localised. English defaults, same as the others.
  • name attr on <.calendar>. The sketch says days "render as submit-capable buttons inside whatever form wraps the calendar" but a submit button needs a name to post anything. With name set (and no on_select) days render type="submit" name=... value={iso}, so the no-JS story is real rather than notional. on_select wins when both are set, and a disabled day never carries the name.
  • nav attr on <.calendar> (both / prev / next / none). Two side-by-side panes rendering two independent sets of arrows was confusing in the playground. A suppressed arrow leaves a spacer so the caption stays optically centred.
  • target, on_clear, range_separator, prev_label/next_label/open_label/clear_label, panel_class, month/on_month_change on the picker. target is for LiveComponents; the labels exist because otherwise the only localisable strings in the component would be hardcoded English; range_separator has to be an attr because the hook parses the display value back apart with it.
  • <.date_picker> does not literally wrap <.popover>. The popover's :trigger slot renders inside a <button>, and a date picker's trigger is an <input> plus a toggle button. It reuses the popover's mechanics instead (JS.show/JS.hide with the same transitions, phx-click-away, phx-keydown Escape plus JS.focus back to the input) and its own pc-date-picker__panel surface.

Hook decisions

Two hooks, both justified by the bar the issue sets.

PetalCalendar does roving tabindex and the arrow map. CSS cannot move focus and a server round trip per arrow key is not navigation, it is latency. Keyboard map: arrows by day and week, Home/End to the week bounds (respecting starts_on), PageUp/PageDown by month, Shift+PageUp/PageDown by year, Enter/Space to select (swallowed on a disabled day). Arrowing or paging out of the displayed month clicks the nav element and restores focus in updated(), so it works identically in event mode and link mode.

One thing worth flagging in review: clicking the arrow as rendered only ever moves one month, so a year jump landed eleven months short. I caught this driving the real playground, not in a unit test. The hook now retargets the arrow (phx-value-month in event mode, the month param in the href in link mode) before clicking it, which keeps phx-target and the patch URL as LiveView's own rather than forging an event. There are three JS specs pinning that.

PetalDatePicker does open-focus (move into the grid on open, back to the input on Escape), parse-on-blur, and client-side selection when no on_select is wired so the picker works in a dead view. When the server owns selection the hook stays out of the value entirely.

Both degrade: every day is a real <button>, so Tab plus Enter works with the hooks deleted. Only roving focus and the arrow map are lost.

Verification

check result
mix format --check-formatted pass
mix compile --force --warnings-as-errors pass
mix credo 45 issues, same 45 as origin/main. Zero new
mix test 913 → 972, 0 failures, 1 skipped (+59)
npm test 157 → 195 (+38)

Date edge cases have real tests: December to January in both directions, February 2024 vs 2026, fixed_weeks always six rows, the first and last week of the grid carrying the adjacent months, min/max inclusivity at both boundaries, all seven starts_on values, a backwards range pair getting ordered, and clamping 31 January to 28 February on a month page.

Verified by hand in the playground, keyboard only: ArrowRight then ArrowDown moved 12 Aug to 20 Aug, Home snapped to 17 Aug and End to 23 Aug, PageDown moved to September 2026 keeping the 23rd focused, and Shift+PageUp landed on September 2025 with focus intact. Light and dark both checked, and the two-month range panel was opened and screenshotted.

<.calendar> is a server-rendered month grid built on Date/Calendar from
the standard library. No date dependency, no DateTime, no timezone
conversion anywhere.

- modes: single, range (including the half-open mid-selection state) and
  multiple; range paints the band on the cells so it runs edge to edge
- min/max windows and disabled_dates as a list or a 1-arity function;
  disabled days render and stay focusable per the APG
- starts_on for all seven ISO week starts, show_outside_days,
  fixed_weeks, and a today marker that coexists with selection
- two wirings: phx-click events carrying ISO strings, or patch links on
  a month query param for dead views. A `name` turns days into submit
  buttons so a plain form posts the clicked date with no JS at all
- day and month names are plain attrs; gettext is a documented recipe,
  not a dependency

<.date_picker> composes that grid into a panel under a text input:
strftime display, ISO 8601 posting through a hidden input, range mode
posting from/to sub-fields, two-month pane, clearable, and the <.field>
surface for label, errors, help text and the required marker.

Accessibility: role="grid" with column headers, aria-selected on the
selected cells, aria-disabled on blacked-out days, a polite live region
for month changes, and a roving tabindex so exactly one day is tabbable.

Hooks: PetalCalendar owns roving focus and the arrow map (day, week,
month on PageUp/PageDown, year with Shift, week bounds on Home/End);
PetalDatePicker owns open-focus, parse-on-blur and client-side selection
when no event is wired. Both degrade to real buttons plus Tab and Enter.

Playground: two pages with dials, plus booking-range, birthday and
in-a-form deadline scenarios and a keyboard walkthrough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.50867% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.53%. Comparing base (871b2cf) to head (3bf70e5).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
lib/petal_components/calendar.ex 94.44% 10 Missing ⚠️
lib/petal_components/date_picker.ex 93.61% 9 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #642      +/-   ##
==========================================
+ Coverage   92.40%   92.53%   +0.13%     
==========================================
  Files         119      123       +4     
  Lines        5066     5412     +346     
==========================================
+ Hits         4681     5008     +327     
- Misses        385      404      +19     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mplatts added a commit that referenced this pull request Aug 12, 2026
@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Date picker open — two-month range panel, 12-16 Aug selected

Date picker open

Keyboard — focus ring on Sept 2025 after a Shift+PageUp year jump

Calendar keyboard navigation

Calendar, light / dark

Calendar, light
Calendar, dark

Date picker, light / dark

Date picker, light
Date picker, dark

Verified independently

Check Result
mix test 972 tests, 0 failures, 1 skipped (+59)
npm test 195 passing (+38)
mix format / compile -Werror clean
mix credo zero new entries vs main
new dependencies none — stdlib Date/Calendar only
slugs : clauses 62 : 62, two components, two pages

The largest issue in the set and it came in clean. Both components carry moduledocs, both are registered, and the 1:1 playground mapping holds at 62.

The bug worth reading

Clicking the nav arrow as rendered only ever moves one month, so year jumps landed eleven months short. The hook now retargets the arrow (phx-value-month, or the month param in the href) before clicking, keeping phx-target and the patch URL as LiveView's own. Three JS specs pin it.

The author's own note: "I'd have shipped that broken on unit tests alone." That's the second time this run that driving the real browser caught something no unit test could — the other was File Upload's :accept string-vs-list. Both argue for the CONTRIBUTING rule that the playground page is part of the deliverable.

Deferred deliberately, not silently

  • The birthday year-select header. The issue says note it rather than half-ship it, and that's what happened — the birthday scenario works via parse-on-blur and Shift+PageUp/Down instead of a decade dropdown. A caption slot is the suggested follow-up API.
  • Split arrows in two_months. Both arrows sit on the left pane and paging moves the pair by one month; splitting them needs a second nav-target attr the author judged hadn't earned its place. Your call.

The deviations are all additive and each has a concrete reason — today for timezone honesty (UTC is wrong for 10 hours a day in Melbourne), day_names_long because you can't derive "Monday" from a localised "Lu", name because a submit button needs one to post anything.

Worth noting the picker does not literally wrap &lt;.popover&gt;: popover's :trigger renders inside a &lt;button&gt; and a picker's trigger is an &lt;input&gt;, so it reuses the mechanics rather than the component. Reasonable, and stated rather than glossed.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

…etry, window clamp

Audit round. (1) The parse-on-blur path parsed a typed date and then did
nothing whenever on_select was wired: no commit, because the server owns
the value, and no event either, so the value never moved. The playground
birthday scenario's own copy ("type 12 Jun 1987 and the grid follows you
there") was broken by it. The hook now pushes the wired select event with
the parsed ISO, reading phx-target off a day button so LiveComponents get
the same routing a click gets. Range mode sends one event per end in
reading order, which is exactly the sequence two clicks produce, so an
existing on_select handler needs no new branch. Emptying the box pushes
on_clear when one is wired and otherwise reverts the display, because a
blank input over a hidden field that still posts a date is the one state
worse than doing nothing. Moduledoc states the whole contract.

(2) Two latent bugs the tests for (1) uncovered. The range splitter used
the separator's *trimmed* form, so with the default ISO format and the
default " - " separator, "2026-03-09 - 2026-03-17" was torn apart on the
hyphens inside the dates and both ends dropped - typing a range in the
default configuration silently cleared the field. It now splits on the
separator as configured, at its first occurrence, with a whitespace-
anchored fallback that an ISO date cannot match. And the client-side
strftime had no %a/%A, so a client-owned picker with a day name in its
format rendered a literal "%a" into the input the moment you clicked a
day. Day names now ride the same data attr contract the month names use.

(3) pc-calendar__nav-icon and pc-date-picker__icon sized bare hero-*
spans with plain w-/h- utilities. Consumer heroicon CSS lands in the
utilities layer (Tailwind v4 @plugin) or unlayered, both of which beat a
components-layer rule regardless of specificity, so those chevrons and
the calendar glyph were consumer-sized. Doubled selector plus the bang
form, per the contract documented at the pagination chevron.

(4) Keyboard paging ignored min/max, so PageUp out of a bounded window
retargeted the nav into a month where every day is disabled - a dead end
you could only leave by paging back. The window now reaches the hook as
data-min/data-max and movement stops at its edges. Days disabled by
disabled_dates are deliberately not clamped: the APG wants those
traversable.

(5) @day_flags emitted pc-calendar__day--range-start/--range-end and
paint() toggled them, but only the cell-level modifiers have rules - the
band and its rounded ends belong on the cell so they run edge to edge,
and the two ends already read as selected. Dropped from both sides so no
dead class ships.

(6) The toggle button carried aria-haspopup and aria-controls but its
aria-expanded was never rendered or updated - only the input's was. It is
the primary pointer target, so open/1 and close/1 now set it on both,
matching how popover.ex treats its trigger.

(7) pc-date-picker__panel is absolute left-0 w-max with no bound, so a
two-month pane walked off a narrow viewport. Clamped the way
pc-popover__panel is, sized to fit two panes.

(8) attr :target's doc read "(a LiveComponent myself)" - it ships to
HexDocs and the MCP schema, so it now says what it means. Day and month
name lists are padded once up front, so the hook's data contract can
never be truncated by a caller passing six labels.

(9) The playground deadline scenario hand-built a name and an errors
list; the brief asked for the FormField path the tests cover, so it now
goes through to_form/1 and field={f[:due_on]}.

Two panes keeping one tab stop each is now test-pinned as the deliberate
reading of roving tabindex rather than an accident.

mix test 978, 0 failures. npm test 212, 0 failures. credo unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Component: Calendar & Date Picker

2 participants