Hapio Calendar

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
components/booking-picker.tsx
'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, days weekdays, startTime/endTime a daily window, resourceIds); a function of the span; or a list, all of which must hold.
  • overlap: false refuses selections over events; a function (other) => boolean decides per event. Background events never count.
  • allow(span): anything else. Return true, 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:

components/range-picker.tsx
'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.