PropertyCalendar

A booking-aware date range picker that extends Calendar with per-day availability states, stay requirements, and booking rules.

Installation

pnpm add @wandercom/design-system-web

Usage

import { PropertyCalendar } from '@wandercom/design-system-web/ui/property-calendar';
import type { AvailabilityDay, DateRange } from '@wandercom/design-system-web/ui/property-calendar';
import { useState } from 'react';

export function Example() {
  const [value, setValue] = useState<DateRange>({ start: null, end: null });

  return (
    <PropertyCalendar
      value={value}
      onChange={setValue}
      availability={availabilityData}
      stayRequirements={{ minNights: 3, checkInDays: ['Saturday'] }}
      onClose={() => setOpen(false)}
      onClearDates={() => setValue({ start: null, end: null })}
    />
  );
}

Examples

Loading example...

Checkout-only days (isBlocked: true, canCheckOut: true, status: "booked")

Days at the end of a booking where the guest checks out have canCheckOut: true and isBlocked: true simultaneously. These should render as restricted with a "Check-out only" tooltip, not as unavailable with a strikethrough. Hover the last day of each booked block below to confirm.

Loading example...

Day states

Each calendar day is evaluated against availability data and the current selection state. Days resolve to one of four visual states:

available -- The day is selectable. Renders as a standard interactive calendar day with text-primary.

restricted -- The day is bookable but constrained for the current selection context. Rendered with text-secondary at full opacity, disabled (not clickable), and shows a tooltip explaining the constraint. Restriction tooltips have two distinct activation contexts:

In browse mode (value.start === null, no dates selected), check-in restriction labels appear immediately on hover:

  • Days where check-in is restricted (canCheckIn: false) -- shows "Check-out only" if the date is reachable as a checkout (both canCheckOut: true and present in the precomputed validCheckoutDates set), otherwise "Check-in unavailable"
  • Days with no reachable checkout from this date (validCheckinDates miss) -- same tooltip logic as above
  • Days that don't match checkInDays in stayRequirements -- shows "Check-out only" if reachable as a checkout, otherwise "Check-in unavailable"

When a complete range is selected (value.start !== null && value.end !== null), no restriction labels are shown — dates render neutral with no tooltips.

In checkout selection mode (value.start !== null && value.end === null), checkout restriction tooltips are deferred: they do not appear immediately when a check-in date is clicked. They only activate after the user moves the cursor into the calendar section for the first time following check-in selection. Once that first mouse movement occurs, these restrictions appear on hover:

  • Days where check-out is restricted (canCheckOut: false) -- shows "Check-out unavailable"
  • Days that don't match checkOutDays in stayRequirements -- shows "Check-out unavailable"
  • Days that violate min/max night constraints -- shows constraint tooltip (e.g. "Min 3 nights", "Max 7 nights")

inactive -- The day is structurally disabled. Rendered with muted text, no strikethrough, not clickable. Used for:

  • Past dates (before today)
  • Dates before a selected check-in (when selecting check-out)
  • Days blocked by booking rules such as same-day-turnover, gap-nights, advance-booking, or quota-exhausted -- these also show a tooltip with the rule message

unavailable -- The day is booked, blocked, or under maintenance. Rendered with reduced opacity and a diagonal strikethrough. No tooltip — the strikethrough is sufficient. Applies to dates with status: "blocked", status: "maintenance", or isBlocked: true (when status !== "booked"). status: "booked" days are exceptions: those with canCheckIn: true render as available in browse mode. Those with canCheckOut: true render as restricted ("Check-out only") in browse mode only if they are reachable — meaning the backward scan from that date finds a valid check-in path within stay constraints, placing the date in the precomputed validCheckoutDates set. Interior booked days that happen to have canCheckOut: true but have no reachable check-in path (surrounded by other booked days) are excluded from that set and continue to render as strikethrough unavailable. When a check-in is selected, any booked+canCheckOut day where the stay interior is unobstructed (no blocked day between check-in and checkout) remains selectable as a checkout endpoint via validateCheckoutDate.

Variants

PropertyCalendar

The base calendar content component, used standalone or composed into wrappers.

PropertyCalendarDesktop

Wraps PropertyCalendar in a Popover with a paginated 2-month view and prev/next navigation arrows. Used by BookingPanel for desktop viewports.

Loading example...

PropertyCalendarMobile

Wraps PropertyCalendar in a Drawer with 12 months rendered vertically in a scrollable view. Navigation arrows are hidden; users scroll through months instead. Used by BookingPanelMobileBar for mobile viewports.

Loading example...

With pricing

When both dates are selected and pricePerNight is provided, the calendar header shows the nightly rate.

Loading example...

Pricing loading

When pricingLoading is true, a skeleton shimmer replaces the price per night in the header.

Loading example...

Error

When booking rules are violated, the calendar displays an alert with the relevant error message.

Loading example...

Types

DateRange

interface DateRange {
  start: Date | null;
  end: Date | null;
}

AvailabilityDay

Per-day availability data keyed by date string in YYYY-MM-DD format.

interface AvailabilityDay {
  date: string;
  canCheckIn: boolean;   // when true and status === "booked", overrides isBlocked for check-in validation
  canCheckOut: boolean;  // when true and status === "booked", overrides isBlocked for checkout validation
  isBlocked?: boolean;
  minNights?: number;
  maxNights?: number;
  nightPrice?: number;
  status: 'available' | 'booked' | 'maintenance' | 'blocked';
}

BookingRule

A booking rule that may restrict date selection. The message field is intended for display to the user.

interface BookingRule {
  type:
    | 'min-nights'
    | 'max-nights'
    | 'check-in-day'
    | 'check-out-day'
    | 'advance-booking'
    | 'gap-nights'
    | 'same-day-turnover'
    | 'quota-exhausted';
  message: string;
}

StayRequirements

Stay requirement constraints displayed as an info banner between the header and calendar.

interface StayRequirements {
  minNights?: number;
  maxNights?: number;
  checkInDays?: string[];
  checkOutDays?: string[];
}

Props

PropertyCalendar

value

DateRange
Current date range selection with nullable start and end.

onChange

(range: DateRange) => void
Called when the date range changes.

availability?:

AvailabilityDay[]
Per-day availability data. Each entry is keyed by YYYY-MM-DD date string.

availabilityLoading?:

boolean
Whether availability data is currently loading.

rules?:

BookingRule[]
Booking rules that may restrict date selection.

stayRequirements?:

StayRequirements
Stay requirement constraints displayed as an info banner (e.g. min nights, check-in days).

numberOfMonths?:

number
Number of months to display. Defaults to 2.

hideNavigation?:

boolean
Hides prev/next month navigation arrows and forces vertical (flex-col) month layout. Used internally by PropertyCalendarMobile for the scrollable experience.

hideFooter?:

boolean
Hides the footer row with Close and Clear dates actions.

hideClose?:

boolean
Hides only the Close button within the footer.

hideWeekdays?:

boolean
Hides the weekday header row rendered per month by react-day-picker.

fullWidth?:

boolean
Removes horizontal padding and makes the calendar stretch full width.

weekStartsOn?:

0 | 1
First day of the week. 0 = Sunday, 1 = Monday. Defaults to 0.

minDate?:

Date
Earliest selectable date. Defaults to today.

maxDate?:

Date
Latest selectable date. Defaults to 18 months from today.

alert?:

ReactNode
Alert content rendered between the header and calendar.

info?:

ReactNode
Info banner content rendered below the alert.

pricePerNight?:

string
Pre-formatted price per night string, e.g. "$787.86/night pre-tax". Shown in the header when both dates are selected.

pricingLoading?:

boolean
When true, shows a skeleton shimmer in place of the price per night.

onDatesSelected?:

(range: DateRange) => void
Called when both check-in and check-out dates are committed. Use this to trigger a pricing API request.

className?:

string
Additional CSS classes for the root element.

onClose?:

() => void
Called when the close button in the footer is clicked.

onClearDates?:

() => void
Called when the clear dates button in the footer is clicked.

PropertyCalendarDesktop

Extends a subset of PropertyCalendar props plus:

open?:

boolean
Controls the popover open state.

onOpenChange?:

(open: boolean) => void
Called when the popover open state changes.

trigger?:

ReactNode
Trigger element that opens the popover.

side?:

'bottom' | 'left' | 'right' | 'top'
Popover placement side. Defaults to "bottom".

sideOffset?:

number
Offset from the trigger in pixels. Defaults to 8.

align?:

'start' | 'center' | 'end'
Alignment along the side. Defaults to "start".

alignOffset?:

number
Offset along the alignment axis in pixels.

numberOfMonths?:

1 | 2
Number of months to display. Defaults to 2.

info?:

ReactNode
Info banner content rendered below the alert.

pricePerNight?:

string
Pre-formatted price per night string, e.g. "$787.86/night pre-tax". Shown in the header when both dates are selected.

pricingLoading?:

boolean
When true, shows a skeleton shimmer in place of the price per night.

onDatesSelected?:

(range: DateRange) => void
Called when both check-in and check-out dates are committed. Use this to trigger a pricing API request.

onClose?:

() => void
Called when the calendar is explicitly closed via the Close button. Runs before the popover closes.

PropertyCalendarMobile

Extends a subset of PropertyCalendar props plus:

open?:

boolean
Controls the drawer open state.

onOpenChange?:

(open: boolean) => void
Called when the drawer open state changes.

trigger?:

ReactNode
Trigger element that opens the drawer.

title?:

string
Heading text shown at the top of the drawer. Defaults to "Select dates".

description?:

string
Description text shown below the heading. Defaults to "Add your check-in & check-out dates.". Pass an empty string to hide.

saveLabel?:

string
Label for the save/submit button. Defaults to "Save".

saveDisabled?:

boolean
Disables the save button. Defaults to false.

info?:

ReactNode
Info banner content rendered below the alert.

onClose?:

() => void
Called when the calendar is explicitly closed via the Save button. Runs before the drawer closes.

Accessibility

PropertyCalendar inherits keyboard navigation from the underlying Calendar component (react-day-picker).

Unavailable days show a diagonal strikethrough with no tooltip. Booked days with canCheckIn: true render as available. Booked days with canCheckOut: true lose the strikethrough and render as restricted ("Check-out only") only when they are reachable — the backward scan must find a valid check-in path within stay constraints, placing the date in validCheckoutDates. Interior booked days with canCheckOut: true that have no such path remain strikethrough unavailable. Restricted days show contextual tooltips explaining the constraint (e.g. "Check-in unavailable", "Min 3 nights") while remaining visible at full opacity. In browse mode (no dates selected), check-in restriction tooltips appear on hover immediately. In checkout selection mode, checkout restriction tooltips are deferred until the user first moves the cursor into the calendar section after selecting a check-in date. When a complete date range is selected, no restriction tooltips are shown. Inactive days (past, maintenance, blocked, and certain booking-rule violations) render muted; those triggered by booking rules also show a tooltip with the rule message. The check-in and check-out input fields include aria-label attributes for screen reader support.

PropertyCalendar