feat(calendar): month grid and date picker on plain Elixir Date - #642
feat(calendar): month grid and date picker on plain Elixir Date#642mplatts wants to merge 2 commits into
Conversation
<.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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
ScreenshotsDate picker open — two-month range panel, 12-16 Aug selected Keyboard — focus ring on Sept 2025 after a Shift+PageUp year jump Calendar, light / dark Date picker, light / dark Verified independently
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 readingClicking the nav arrow as rendered only ever moves one month, so year jumps landed eleven months short. The hook now retargets the arrow ( 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 Deferred deliberately, not silently
The deviations are all additive and each has a concrete reason — Worth noting the picker does not literally wrap Images live on the |
…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.






Closes #610
Summary
Two components, shipped together as the issue asks:
<.calendar>, a server-rendered month grid on Elixir's ownDate, 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/2andDate.day_of_week/1plus modular arithmetic for the week rotation. Display formatting isCalendar.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) andmultiplemin/maxinclusive windows,disabled_datesas a list or a 1-arity function. Disabled days render and stay focusable per the APG, they just lose their click wiringstarts_onfor 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 selectedon_select/on_month_changepushphx-clickwith ISOphx-value-date/phx-value-month; with them nil the nav renders<.link patch>onmonth_param, which is a plain query-string anchor in a dead view<td>so it runs edge to edge between days; the day button keeps its own rounding<.date_picker>formatsays. Range postsname[from]/name[to]%B/%bmonth names and discards%A/%a), and reverts to the last valid value on failure rather than silently clearingtwo_months,clearable,disabled, and the<.field>surface for label, errors, help text and the required marker, includingPhoenix.HTML.FormFieldintegrationScope
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:
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 (acaptionslot, probably, so callers can put anything in the header).One more follow-up worth naming: in
two_monthsmode 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.
todayattr on both. Defaults toDate.utc_today/0. The issue is right that aDatehas 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_longattr.day_namesis 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.nameattr 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. Withnameset (and noon_select) days rendertype="submit" name=... value={iso}, so the no-JS story is real rather than notional.on_selectwins when both are set, and a disabled day never carries the name.navattr 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_changeon the picker.targetis for LiveComponents; the labels exist because otherwise the only localisable strings in the component would be hardcoded English;range_separatorhas 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:triggerslot 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.hidewith the same transitions,phx-click-away,phx-keydownEscape plusJS.focusback to the input) and its ownpc-date-picker__panelsurface.Hook decisions
Two hooks, both justified by the bar the issue sets.
PetalCalendardoes 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 (respectingstarts_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 inupdated(), 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-monthin event mode, the month param in thehrefin link mode) before clicking it, which keepsphx-targetand the patch URL as LiveView's own rather than forging an event. There are three JS specs pinning that.PetalDatePickerdoes open-focus (move into the grid on open, back to the input on Escape), parse-on-blur, and client-side selection when noon_selectis 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
mix format --check-formattedmix compile --force --warnings-as-errorsmix credoorigin/main. Zero newmix testnpm testDate edge cases have real tests: December to January in both directions, February 2024 vs 2026,
fixed_weeksalways six rows, the first and last week of the grid carrying the adjacent months,min/maxinclusivity at both boundaries, all sevenstarts_onvalues, 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.