Selecting dates and times
Let users pick time ranges and days with the keyboard, a click or a drag, with rules that say why a place is refused.
Selection is an opt-in item: a read-only calendar ships none of its code. Add it and wrap the calendar:
npx shadcn@latest add @hapio/calendar-selection'use client'
import type { CalendarEventInput, DateSpan, SelectRules } from '@hapio/calendar-react'
import { CalendarSelection } from '@/components/hapio-calendar/calendar-selection'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
const OPENING_HOURS = { days: [1, 2, 3, 4, 5], start: '09:00', end: '17:00' } as const
const FROM_TODAY = { start: 'today' } as const
const RULES: { readonly select: SelectRules } = {
select: {
constraint: 'businessHours', // inside the opening hours
overlap: false, // not over another booking
// Any check of your own; a string is the reason users hear.
allow: (span) => (span.allDay ? 'Choose a time, not a whole day' : true),
},
}
export function BookingPicker({
bookings,
onPick,
}: {
bookings: readonly CalendarEventInput[]
onPick: (span: DateSpan) => void
}) {
return (
<CalendarSelection>
<HapioCalendar
events={bookings}
timeZone="Europe/Stockholm"
businessHours={OPENING_HOURS}
validRange={FROM_TODAY}
selectable
rules={RULES}
onSelect={({ span }) => onPick(span)}
className="h-[36rem]"
/>
</CalendarSelection>
)
}<CalendarSelection> gives the calendars inside it the selection engine and the parts that draw
a selection (the slot cursor of the time grids and the selection layer). They still need
selectable (and onSelect), or onDateClick for clicks on time slots. Try it in the
selection example.
How users select
- Keyboard: in a week or day view, Tab reaches the slot cursor, one focusable element that stands on one slot at a time. Its name says the slot and whether it can be selected: "Monday, October 19, 2026, 09:00 to 09:30, available", or "…, unavailable, overlaps another event". The arrow keys move it (↑ ↓ a slot, ← → a day, Home and End the day's first and last slot, PageUp and PageDown the period). Space starts a selection, the arrow keys extend it (Shift with an arrow starts and extends in one step), Enter selects and Escape cancels. In a month the day cells work the same way, a day at a time.
- Pointer: a click or tap selects one slot or day. A mouse or pen drag past 5 px selects a
range; on touch, a long-press (400 ms,
longPressMs) then a drag. - Screen readers hear each step through the live region: "Selecting from Monday, October 19, 2026, 09:00 to 09:30. Use the arrow keys to extend it, Enter to select, Escape to cancel.", then each new range, then "Selected …", "Selection cancelled", or "Can't select …" with the reason.
A selection is one contiguous range in the visible range: across days for times, across days for whole days, never mixing the all-day row with times.
What you get
onSelectionChange(span) reports the new selection, then onSelect({ span, via, view, nativeEvent }) says a user completed it (via is 'pointer', 'touch' or 'keyboard'). span
is exact: timed spans are instants in the calendar's zone (start, end, startIso,
endIso, each end with its own offset on a DST day), all-day spans dates with an exclusive end
(24 to 26 October is '2026-10-24' to '2026-10-27').
The selection stays drawn until it is cleared: Escape on it, a press outside the calendar (unless
clearSelectionOnOutsidePointer={false}), a navigation to a range it does not touch, or a zone
change; each calls onSelectionChange(null).
Rules
rules.select decides which selections may complete. It is checked on every step, so the slot
cursor's name and the drawn selection show the verdict before anything is confirmed:
constraint: where a selection must stay.'businessHours';{ group: 'open' }(inside the background events of that group); an object ({ start, end }dates,daysweekdays,startTime/endTimea daily window,resourceIds); a function of the span; or a list, all of which must hold.overlap:falserefuses selections over events; a function(other) => booleandecides per event. Background events never count.allow(span): anything else. Returntrue,false, or a string: the reason users hear.
validRange is checked first, then the constraint, the overlap and allow; the first failure
names the reason ("outside the dates you can choose", "outside the available times", "overlaps
another event", "not allowed", or your string).
Controlled selection
selection (and defaultSelection) make the selection yours. commands.select(span) sets it
from code, without rules and without onSelect, which reports user selections only:
'use client'
import type { CalendarEventInput, DateSpan } from '@hapio/calendar-react'
import { useState } from 'react'
import { CalendarSelection } from '@/components/hapio-calendar/calendar-selection'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import { Button } from '@/components/ui/button'
export function RangePicker({ events }: { events: readonly CalendarEventInput[] }) {
const [selection, setSelection] = useState<DateSpan | null>(null)
return (
<>
<CalendarSelection>
<HapioCalendar
events={events}
timeZone="Europe/Stockholm"
selectable
selection={selection}
onSelectionChange={setSelection}
selectionPreview="event" // drawn like an event, with its times
className="h-[36rem]"
/>
</CalendarSelection>
{selection === null ? null : (
<p>
{selection.startIso} – {selection.endIso}{' '}
<Button type="button" variant="outline" size="sm" onClick={() => setSelection(null)}>
Clear
</Button>
</p>
)}
</>
)
}selectionPreview="event" draws the selection like an event with its times, instead of a tint.
Date clicks
onDateClick({ span, nativeEvent, view }) hears a click or tap on an empty part of a day cell or
a time slot, and Enter on a day cell or the slot cursor. Day cells call it without
<CalendarSelection>; time slots need it, because the selection engine gives them the slot
cursor (so the keyboard reaches every slot a pointer can click) and resolves the slot under the
pointer to the right instant on DST days. Without it, a calendar with a week or day view reports
a development error. With navLinks, Enter on a day cell follows its link instead.
Without the wrapper
HapioCalendar ignores an interaction prop outside <CalendarSelection> (and
<CalendarEditing>), and development builds say so: the grids could not draw what the engine
does. To use the engines with markup of your own, see Customization.
Time zones and daylight saving time
Every calendar has its own zone; changing it re-renders without remounting, and DST days are drawn and edited to the minute.
Moving and resizing events
Drag, long-press or grab events from the keyboard, judge each place with rules, and save changes with an optimistic overlay that reverts on failure.