Calendar
A date picker: one date, several, or a range.
src/components/rahti_ui/calendar.rs
Basic
shadcn's Calendar is a `classNames` map for `react-day-picker`: the strings are shadcn's and the markup, the month arithmetic and the keyboard are that library's. There is no `react-day-picker` here, so this port is both halves — the same class lists, over a grid it builds itself.
Click a day to take it and click it again to drop it. The two arrows move a month at a time, and every date is `yyyy-mm-dd` read off the browser's local clock — never `toISOString`, which is the off-by-one shadcn's own docs warn about under "timeZone".
Modes
`single` moves the selection, `multiple` collects any number, and `range` takes two dates and everything between them. The mode reaches the browser as a seeded constant and decides what a click does — nothing about the grid changes.
A range takes two clicks and swaps them when the second is earlier; a third starts over. The ends are `data-range-start` and `data-range-end` on the button, and everything between is `data-range-middle` — shadcn's three attributes, which is why the run reads as one shape rather than as a line of separate days.
Caption layout
shadcn's `captionLayout`. `label` is the plain month name; `dropdown` swaps it for a month select and a year select, and the two half-layouts keep one of each. The year range comes from `min`/`max` when they are given, and is a century back and a decade on when they are not.
The select is a real native `<select>` stretched over the styled label and made `opacity-0` — shadcn's trick, kept whole. The reader sees the label and operates the browser's own control, which is why the year list works on a phone without a popup being written for it. `has-focus:` on the box is how the invisible select still shows a focus ring.
Two months
shadcn's `numberOfMonths`, which is what a range picker usually wants. The months stack on a narrow screen and sit side by side from `md` up — `flex-col md:flex-row`, shadcn's own.
One nav for the pair, and it moves both — `months` is `relative` so the arrows can sit over the first caption, which is also why every caption is padded by a whole cell at each end. Only the first month draws the dropdowns; the rest draw their name.
Blocked dates
shadcn takes a `disabled` matcher, which in `react-day-picker` is a function, a date, a range, or a list of any of those. Two shapes are ported because two shapes cover the examples: named dates, and whole weekdays. `min` and `max` are the third, and they stop the nav as well as the days.
A blocked day is dimmed, skipped by a click and carries the real `disabled` attribute — which is a genuine HTML boolean, so the binding on it drops the attribute when the day is free rather than writing `disabled="false"`.
Weeks
`weekStartsOn` moves the first column and the weekday names follow it, both read from the browser's own locale through `toLocaleString`. The week numbers are ISO: the Thursday decides which year a week belongs to, which is the whole of that rule.
A week-number column takes the first cell of every row, so the rule that rounds the left end of a selected run moves from `:first-child` to `:nth-child(2)` — shadcn branches on `showWeekNumber` for exactly that, and so does this. Outside days turned off become `invisible` rather than absent, so the grid keeps its shape.
States
`disabled` reaches every day and both arrows, so there is no way in from the mouse or the keyboard. A disabled nav arrow is `aria-disabled` rather than `disabled`: it stays in the tab order and says why it does nothing, which is what shadcn's `aria-disabled:opacity-50` is painting.
`buttonVariant` is shadcn's prop for the two arrows and it takes any Button variant — `ghost` unless said otherwise. It is reached through the builder here because a variant is an enum in Rust and a string in the tag.
As a form posts it
The selection is a run of real `<input type="hidden">` fields, one per date, all under one name — `?due=2026-08-20` and `?stay=2026-08-20&stay=2026-08-24`. A range posts exactly two, `from` then `to`, which is the one thing worth knowing before parsing it. Submit and read the query string.
Keyboard
A roving `tabindex` over a real `<button>` per day, and the ring is drawn from the *cell* — `group-data-[focused=true]/day:` with `relative` and `z-10` in the same run, so a focused day's ring is not clipped by its neighbours.
← / → a day at a time ↑ / ↓ a week at a time Home / End the ends of the week PageUp / PageDown a month at a time Enter / Space take the focused day Tab one stop for the whole grid
Only the focused day is tabbable and the rest are `tabindex="-1"`, so Tab crosses the grid in one step rather than thirty-five. Moving past the edge of the month turns the page and keeps the focus on the day it landed on, which is why the focus is state here and not just a DOM property.
The port
Every one of shadcn's slots keeps its class list and its `data-slot`, so a page's own CSS still finds them. What could not come across is `react-day-picker`'s own class names — `rdp-root`, `rdp-day` and the rest, which mean something only with that library's stylesheet. Two are kept anyway: the root's `rtl:**:[.rdp-button\_next>svg]:rotate-180` reads `rdp-button_next`, so the nav buttons still carry it. Locale and `formatters` are `toLocaleString` here, and `timeZone` is not ported — everything is local time.
// shadcn // rahti-ui
<Calendar mode="range" /> <Calendar mode="range" />
selected={date} onSelect={setDate} selected=@{vec![…]} + a change event
disabled={(d) => isBooked(d)} disabled_dates=@{vec![…]}
captionLayout="dropdown" caption_layout="dropdown"
numberOfMonths={2} number_of_months=@{2u32}
// the builder, when the dates are computed
calendar()
.range()
.name("stay")
.disabled_dates(booked.iter().map(|b| b.day.clone()))
.nav_variant(ButtonVariant::Outline)
.render()The boundary
The second Calendar-shaped component in this library with no child tags, and for the reason the Combobox has none: a day has to know whether it is selected, outside the month, disabled or today, and PulsePoint state does not cross a component boundary. So the root owns everything and the grid is a nested `pp-for` over weeks and days. The cost is the line at the bottom — `pp-for` appends rather than adopting, so there is no arrangement where the first paint is the server's and the second is the browser's. **A Calendar is blank until PulsePoint mounts**, which for a component whose whole job is moving between months is the honest trade.
a plain element in the root's html! reads the root's state a <template pp-for> row reads the root's state a nested #[component] tag does not — pp-component is a scope a <>…</> fragment does not — the comment pair is a scope pp-for appends its rows; it does not adopt server-rendered ones.