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

18 lines
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-tertiary 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), check-in restriction tooltips continue to display on hover, matching browse mode behavior.

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")

On touch devices, tapping a restricted date opens the same tooltip immediately above that date, including on the first rejected checkout tap.

While choosing a checkout date with a mouse or trackpad, hovering a later date previews the prospective range without committing it.

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. Its header exposes the date inputs when its own container reaches 65rem (1040px), rather than depending on viewport width.

PropertyCalendarDesktop

Wraps PropertyCalendar in a Popover with a paginated 2-month view and prev/next navigation arrows. Used by BookingPanel for its wide container layout.

Loading example...

PropertyCalendarMobile

Wraps PropertyCalendar in a full-screen mobile modal with the full booking horizon (maxDate, or 24 months by default) rendered vertically in a scrollable view. It includes a Clear action, close button, fixed footer, and no navigation arrows. The first two months mount with the modal shell before the rest fill in.

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

Typed-date validation errors appear as toasts without changing the calendar layout. Mount one Toaster in the application root. For unavailable-range callbacks, show the error through toast.error instead of passing an inline alert. Invalid inputs retain their accessible error state.

Loading example...

Types

DateRange

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

AvailabilityDay

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

10 lines
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.

12 lines
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.

6 lines
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 24 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.

onError?:

() => void
Called when availability data reveals the current date range overlaps unavailable days. Fires at most once per unique start/end pair. Use to clear or reset the range.

error?:

boolean
When true, marks both date inputs with destructive border styling.

hideCheckInDaysBanner?:

boolean
Hides the check-in days info banner even when stayRequirements.checkInDays is set.

PropertyCalendarContent

Low-level content renderer exported from @wandercom/design-system-web/ui/property-calendar/shared and used by the inline, desktop, and mobile variants. Accepts all PropertyCalendar props plus:

desktopLayout?:

boolean
When true, shows the desktop header inputs and forces a horizontal multi-month layout without waiting for the container breakpoint.

onSelectComplete?:

() => void
Called after the user commits a complete date range.

hideHeader?:

boolean
When true, hides the calendar header.

PropertyCalendarHeader

Low-level header renderer exported from @wandercom/design-system-web/ui/property-calendar/shared. Its desktopLayout prop is primarily used by PropertyCalendarContent:

desktopLayout?:

boolean
When true, shows the desktop date inputs without waiting for the header container breakpoint.

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.

onError?:

() => void
Called when availability data reveals the current date range overlaps unavailable days. Fires at most once per unique start/end pair.

error?:

boolean
When true, marks both date inputs with destructive border styling.

PropertyCalendarMobile

Extends a subset of PropertyCalendar props plus:

open?:

boolean
Controls the calendar open state.

onOpenChange?:

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

trigger?:

ReactNode
Trigger element that opens the calendar.

title?:

string
Accessible full-screen title. Defaults to "Select dates".

description?:

string
Accessible full-screen description. Defaults to "Add your check-in & check-out dates.".

saveLabel?:

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

saveVariant?:

React.ComponentProps<typeof Button>["variant"]
Button variant for the confirmation action. Defaults to "primary".

saving?:

boolean
Shows progress and disables the confirmation button. Defaults to false.

onConfirm?:

() => void
Replaces the default Save action without closing the calendar or calling onClose. The application controls dismissal or navigation. Other dismiss actions do not invoke this callback.

saveDisabled?:

boolean
Disables the save button. Defaults to false.

info?:

ReactNode
Info banner content rendered below the alert.

onClose?:

() => void
Called by the default Save action before the calendar closes. Not called when onConfirm replaces that action.

onDatesSelected?:

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

onError?:

() => void
Called when availability data reveals the current date range overlaps unavailable days. Fires at most once per unique start/end pair.

error?:

boolean
When true, marks both date inputs with destructive border styling.

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 or touch immediately. In checkout selection mode, mouse tooltips activate after the first pointer movement, while a rejected touch tap opens the relevant tooltip immediately. 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