Calendar & dates
Everything time-shaped: entering a date, entering a date range, rendering a live “time ago”, and laying events out on a month grid. Date entry is the DS <DatePicker> — a thin, token-driven wrapper over a NATIVE <input type='date'>, so it inherits the OS picker, keyboard entry and locale for free (no date-fns, no JS calendar widget). The month grid is the app's own EventsCalendar, shown here on a static fixture. Flip the theme in the top bar to confirm every surface re-skins via the semantic tokens.
- DatePicker
- The date-entry component (@phauna/ds/overlays). A styled native <input type='date'> — takes the usual input attrs (value / onChange / min / max / disabled / required …) plus invalid?: boolean. Controlled: own the value in client state.
- DateRangePicker
- Two coupled native date inputs (@phauna/ds/overlays). Controlled via start / end (strings) + onStartChange / onEndChange; min / max / invalid forward through to both.
- RelativeTime
- A self-updating <time> element (@phauna/ds/overlays) — date (Date|string|number) + optional intervalMs. Re-renders on its own, so it is client-only.
- DateInput (TYPE)
- NOT a component — in @phauna/ds it is the type alias Date | string | number | null | undefined that the fmt* helpers accept. Don't reach for a <DateInput>; reach for <DatePicker>.
- fmt helpers
- fmtDate / fmtDateRange / fmtRelative (from @phauna/ds) turn a DateInput into display text. The native input emits a 'YYYY-MM-DD' string; pass that straight to fmtDate for the human label.
- EventsCalendar
- The app's month grid (src/components/portal/EventsCalendar.tsx). Props: events: CalendarEvent[]. Static & client (holds the month cursor); here it runs on an in-memory fixture.
DatePicker — date entry
The single date field. It is a controlled native <input type='date'>: pass value (a 'YYYY-MM-DD' string) and an onChange that reads e.target.value. Because it is native, the calendar dropdown, keyboard typing and the locale-aware display all come from the browser — the DS only supplies the token-driven chrome (border, radius, focus ring, the invalid state). Always give it an accessible name (a <label htmlFor>, or aria-label as in these isolated demos).
value 2026-07-04 → fmtDate July 3, 2026
- value / onChange
- Controlled like any input: value is a 'YYYY-MM-DD' string; onChange={(e) => setValue(e.target.value)}. An empty string clears it.
- type is fixed
- The component omits & hard-sets type='date' — you can't (and don't) pass type. Everything else from InputHTMLAttributes spreads through.
- display vs value
- The browser shows the date in the user's locale; the value it emits is always ISO 'YYYY-MM-DD'. Format for display elsewhere with fmtDate(value, …).
- accessible name
- Pair with a visible <label htmlFor> in real forms (or the DS FormRow). These standalone demos use aria-label so each field is still named for AT.
DatePicker — states & constraints
The states you'll actually wire: invalid for a failed validation (red ring, token-driven — never an inline color), disabled for an inert field, required for a must-fill, and min / max to clamp the selectable range (the native picker greys out everything outside the window). Each is a plain prop forwarded to the underlying input.
- invalid
- boolean. Switches the field to the error treatment (red focus/border ring) via data-attributes + tokens. Pair with an external error message + aria-describedby.
- disabled
- Standard input disable — greyed, not focusable, not editable. The value is still rendered but locked.
- required
- Native required: the field participates in form validation and the browser blocks submit if empty.
- min / max
- ISO 'YYYY-MM-DD' bounds. The native picker disables dates outside [min, max] and rejects out-of-range typing.
DateRangePicker — a date range
Two coupled DatePickers for a start → end span. Controlled with the explicit start / end strings and onStartChange / onEndChange callbacks (note: distinct from the single picker's e.target.value handler). min / max and invalid apply to both ends. Use it for an event window, a report range, an availability span — anywhere one field would be ambiguous.
Pick a start and an end date.
Jul 3, 2026 — Jul 10, 2026
Jul 10, 2026 — Jul 3, 2026
- start / end
- Two controlled 'YYYY-MM-DD' strings — the component does NOT use value/onChange. Keep both in state.
- onStartChange / onEndChange
- Each receives the new string value (already unwrapped — not an event). Wire your own start ≤ end check; the component won't enforce ordering.
- min / max / invalid
- Apply to both ends. Set invalid yourself when start > end (or your own rule fails) — the demo's invalid case shows the reversed-range treatment.
RelativeTime — live “time ago”
A <time> element that renders a relative phrase (“12 minutes ago”, “in 2 days”) and re-computes itself on an interval, so it stays fresh without a re-fetch. Because it ticks on its own it is client-only. Under the hood it is the same logic as the fmtRelative() helper — use the component when you want it to keep updating, the helper when you just need a one-shot string.
- date
- Date | string | number — the instant to describe. Past → “… ago”, future → “in …”.
- intervalMs
- How often to re-render (default is a sensible cadence). The element re-computes the phrase so it never goes stale on a long-open page.
- renders <time>
- Outputs a semantic <time> element; pass-through <time> attrs are accepted (dateTime is managed for you).
- vs fmtRelative
- fmtRelative(d) (from @phauna/ds) returns the same phrase as a plain string — no ticking. Use it in non-reactive contexts (CSV, a server render, a one-off label).
Month calendar (EventsCalendar)
The app's own month grid — src/components/portal/EventsCalendar.tsx — rendered here on a static in-memory fixture (no fetch, no DB). It takes events: CalendarEvent[], buckets each by its local calendar day from the ISO startsAt, pads the leading blanks so day 1 lands under the right weekday, marks today with an accent pill, and links each event to its detail page. Major events (isMajor) get a full-width red badge; everything else is a compact accent chip, optionally tagged with its EventTypeBadge. The Today / ‹ / › controls shift the month cursor (client state).
- events: CalendarEvent[]
- The only prop. CalendarEvent = { id, slug, title, startsAt (ISO string), isMajor, type (EventType | null) }. Here it's a static fixture; in the portal it's loaded server-side and passed in.
- day bucketing
- Each event lands on the LOCAL calendar day of its startsAt (viewer timezone). Multiple events per day stack within the cell.
- isMajor
- true → a full-width red Badge (a headline action like a rally/protest). false → a compact accent chip. Red is reserved for genuinely major actions, never decoration.
- type → EventTypeBadge
- A non-null EventType renders the matching EventTypeBadge (icon + tone) beneath the title, so the kind of event reads at a glance.
- navigation
- Each event links to /portal/events/{slug ?? id}. The header's ‹ / › step the month and Today snaps back — all local client state, no refetch.
- static demo
- EventsCalendar is a client component; this page mounts it through a colocated 'use client' island on a hardcoded CalendarEvent[]. Do not fetch in the styleguide.
Calendar engine — unified <Calendar>
The STD calendar engine (src/components/ds/calendar/). One <Calendar> composes the CalendarToolbar over four interchangeable views — Month, Week, Day, Agenda — all speaking the SAME CalendarEvent shape. Use the segmented control to switch views; ‹ / Today / › step the cursor by that view's natural period (month / week / day / agenda window) and the period label updates to match. Both view and cursor are controlled-OR-uncontrolled; this demo runs uncontrolled. The Week and Day views lay timed events over a 6am–11pm axis with side-by-side overlap packing; the Agenda view lists the next 30 days grouped by day. Every event here has no href, so activation flows through onSelectEvent (echoed below the calendar) rather than navigation.
- events: CalendarEvent[]
- The shared engine shape: { id, title, startsAt (ISO|Date), endsAt? , href?, type? (free-form tag), isMajor?, tone? ('accent'|'good'|'warn'|'bad'|'neutral') }. Distinct from the portal EventsCalendar's CalendarEvent — the engine's is generic. ISO strings are parsed to viewer-LOCAL Dates via date-fns.
- view / defaultView
- Active layout ('month'|'week'|'day'|'agenda'). Controlled via view + onViewChange, or uncontrolled from defaultView (default 'month'). The toolbar's segmented control switches it.
- cursor / onCursorChange
- The focused date. Controlled, or uncontrolled (seeded at the start of today's month). prev/next step by the active view's period — ±month / ±week / ±day / ±agendaDays — and Today snaps to today.
- onSelectEvent
- Fires when an event is activated (click / Enter) in any view. Events with an href navigate; otherwise this is the only selection hook. The demo echoes the most-recent selection.
- today / agendaDays
- today injects what counts as 'now' (defaults to new Date()) for deterministic 'today' marking. agendaDays sets the agenda window length AND the agenda prev/next step (default 30).
- accessibility
- Month/Week/Day grids are role=grid with per-cell aria-labels, roving tabindex, and arrow-key navigation; today + selected are conveyed via aria-current / aria-selected, not color alone. Agenda is a linear role=list. All views are client components (render in viewer-local tz).
MiniMonth — compact date picker
The engine's compact month date-picker (src/components/ds/calendar/MiniMonth.tsx) — a 7-column month grid for pairing with date fields. Click a day or arrow-key to it and press Enter to pick; ‹ / › page the month. Days in markedDays get a dot ('has something' — here, every day with a sample event). It is a role=grid with per-cell aria-label, roving tabindex, and aria-selected / aria-current, so the picked + current day never rely on color alone. Controlled via selected + onSelect (and optionally month + onMonthChange); this demo owns the selection in state and echoes it.
- selected / onSelect
- selected (Date | null) is the picked day (highlighted + aria-selected); onSelect(d) fires on click or Enter/Space. Own the value in client state.
- month / onMonthChange
- Optional controlled visible month. Omit both and MiniMonth self-manages which month is shown (paging stays internal); supply them to drive it externally.
- markedDays
- Date[] to dot under the day number ('has events' / 'has something'). Here it's every start day in the engine fixture.
- vs DatePicker
- DatePicker is a native <input type='date'> (OS picker, no JS grid). MiniMonth is a custom JS grid — reach for it when you need an always-visible calendar surface, marked days, or full control over the look; reach for DatePicker for plain date entry in a form.