# Calendar

**When to use:** Showing and editing your own events over time, in month, week, day and agenda views: bookings, shifts, appointments. To let a user pick one date, use [DatePicker](DatePicker.md); for a sequence of past steps, use [Timeline](Timeline.md).

**Keywords:** schedule, scheduler, planner, appointments, booking, meetings, week view, month view, drag and drop, events, business hours, opening hours, calendar week, KW

**Import:** `import { Calendar, CalendarView } from '@ahrowe/ui'`  
**Types:** `import type { CalendarProps, CalendarEvent, CalendarRange, CalendarEventChange, CalendarEventContext, CalendarSlot, CalendarLabels, CalendarWeekday, CalendarWeekStart, CalendarSlots, CalendarDefaults } from '@ahrowe/ui'`

```tsx
import { useState } from 'react';
import { Calendar, CalendarView, Popover, PopoverPlacement, Presentation } from '@ahrowe/ui';
import type { CalendarEvent, CalendarSlot } from '@ahrowe/ui';

// Your own event type: the calendar hands the same objects back in every callback.
interface Booking extends CalendarEvent {
  room: string;
}

const [bookings, setBookings] = useState<Booking[]>(initial);
// A clicked event, or a new slot that has no id yet.
const [editing, setEditing] = useState<{ booking: Booking | CalendarSlot; anchor: HTMLElement } | null>(null);

<Calendar<Booking>
  events={bookings}
  defaultView={CalendarView.Week}
  // Setting a callback is what switches the interaction on.
  onEventClick={(booking, anchor) => setEditing({ booking, anchor })}
  onSlotCreate={(slot, anchor) => setEditing({ booking: slot, anchor })}
  onEventChange={({ event, start, end }) =>
    setBookings((all) => all.map((b) => (b.id === event.id ? { ...b, start, end } : b)))
  }
  // One line in a short event or a month row; a second line where there is room for it.
  renderEvent={(booking, { singleLine }) =>
    singleLine ? booking.title : <>{booking.title}<small className='room'>{booking.room}</small></>
  }
/>

// The editor is yours: next to the event on a desktop, a bottom sheet on a phone.
<Popover
  anchor={editing?.anchor ?? null}
  isOpen={editing !== null}
  onOpenChange={(isOpen) => !isOpen && setEditing(null)}
  placement={PopoverPlacement.Right}
  presentation={Presentation.Auto}
  content={editing && <BookingEditor booking={editing.booking} onDone={() => setEditing(null)} />}
/>

// Read-only month and agenda
<Calendar events={events} views={[CalendarView.Month, CalendarView.Agenda]} />

// A working week, 08:00 to 17:00, with the rest of the day shaded
<Calendar events={events} defaultView={CalendarView.Week} hiddenDays={[0, 6]} workingHours={[8, 17]} />
```

**Enums:** `CalendarView`: `Month`, `Week`, `Day`, `Agenda`

**Days and moments:** the selected day (`date`) is a `'YYYY-MM-DD'` string, like DatePicker's, and is the same day wherever the calendar is opened. Events are moments, so their `start` and `end` are `Date`s, shown in the browser's time zone. `dayTimeToDate` and `dateToTime` turn one into the other; see "When a day stands for a moment" in [DatePicker.md](DatePicker.md), also for a zone other than the browser's.

**Events** are plain objects: `{ id, title, start, end, allDay?, color?, readOnly? }`.

- `id` is **unique among `events`**. Each occurrence of a recurring series needs its own, such as `` `${seriesId}-${day}` ``: two events with one id are drawn with one React key, and a drag picks up the first of them.
- `end` is **exclusive**, for timed and all-day events alike. A one-day all-day event on the 26th runs from midnight on the 26th to midnight on the 27th. A timed event ending at midnight does not show on the next day.
- Timed events of 24 hours or more are drawn in the all-day row, not stretched down the time grid.
- A timed event is drawn exactly as long as it lasts. One under 15 minutes has no room for its title, so it is a bar of its real length with the title on a line beneath it, and the next event in its column leaves that line free. Zoomed in far enough, the bar holds its title like any other event.
- `color` is a `ThemeColor` (`color: ThemeColor.Success`) or any CSS colour, see [ThemeColor.md](ThemeColor.md). The event is tinted from it against the page background, so any colour reads in both light and dark themes.
- `readOnly` events can still be clicked, but not moved or resized.

**Loading events and recurring series.** `onRangeChange` fires on mount and whenever the shown period changes, with that period **and one either side of it**, because swiping reveals the neighbours before it pages. Fetch events for that range, and expand recurring series into it yourself: the calendar has no recurrence rules of its own.

```tsx
const [events, setEvents] = useState<CalendarEvent[]>([]);
const latest = useRef<CalendarRange | null>(null);

<Calendar
  events={events}
  onRangeChange={async (range) => {
    latest.current = range;
    const loaded = await api.events(range.start, range.end);
    // Paged on while this loaded: a later range's answer is the one to show.
    if (latest.current === range) setEvents(loaded);
  }}
/>
```

**Editing:**

- With a **mouse**, drag on empty time to create, drag an event to move it, and drag its bottom edge to resize it. A plain click on empty time creates an hour (or `slotMinutes`, if longer).
- **Whole days** are created and moved the same way in the month view and in the all-day row of the week and day views: a click creates an all-day slot on that day, a drag across days creates one over all of them, and dragging an event moves it by days.
- With **touch**, press and hold for 400ms to pick up, then drag. A tap does what a click does. On a coarse pointer the resize grip is always visible.
- Moving and resizing snap to `slotMinutes`. A live time label follows the drag, and `Escape` cancels it.
- The header's **New event** button (shown when `onSlotCreate` is set) creates on the selected day: in the week and day views the hour from the next full hour of the clock (23:00 at the latest, so it stays on that day), elsewhere the whole day. It is the keyboard's way to create.
- A click on empty time that closes a `Popover` of this library, such as your editor, only closes it; the next click creates. A click on another event still opens that one.
- Update `events` in the same render as `onEventChange`, or **return a promise**: the event then stays where it was dropped until the promise settles, and is drawn from `events` again after that. So update `events` before it resolves, and reject to put the event back:

```tsx
onEventChange={async ({ event, start, end }) => {
  await api.save(event.id, { start, end }); // rejects when saving fails: the event goes back
  setBookings((all) => all.map((b) => (b.id === event.id ? { ...b, start, end } : b)));
}}
```

**An event editor.** The calendar has none of its own, because what an event holds and how it is saved are yours. The Editing example on the [demo page](https://ahrowe.com/packages/ui/demo/#calendar) is one to copy, built from `Input`, `DatePicker`, `TimeInput`, `Checkbox` and `ActionButtons`; its **Code** tab has all of it. The part that is easy to get wrong is the conversion, because the fields hold days and times and the event holds moments:

```tsx
import { dateToDay, dateToTime, dayTimeToDate, dayToDate } from '@ahrowe/ui';
import type { CalendarEvent, CalendarSlot } from '@ahrowe/ui';

// What the editor's DatePickers and TimeInputs hold.
interface Draft { startDay: string; startTime: string; endDay: string; endTime: string; allDay: boolean }

function toDraft({ start, end, allDay = false }: Pick<CalendarEvent, 'start' | 'end' | 'allDay'>): Draft {
  // `end` is exclusive, so an all-day event ends at midnight after its last day. Show the last day.
  const lastDay = allDay ? new Date(end.getTime() - 1) : end;
  return { allDay, startDay: dateToDay(start), startTime: dateToTime(start), endDay: dateToDay(lastDay), endTime: dateToTime(end) };
}

function fromDraft({ startDay, startTime, endDay, endTime, allDay }: Draft): CalendarSlot {
  if (!allDay) return { allDay, start: dayTimeToDate(startDay, startTime), end: dayTimeToDate(endDay, endTime) };
  const end = dayToDate(endDay);
  end.setDate(end.getDate() + 1);
  return { allDay, start: dayToDate(startDay), end };
}
```

The example also:

- **keeps the length when the start changes**, as calendar apps do, counting whole days for an all-day event, since a day the clocks change on is 23 or 25 hours long;
- **shows a placeholder while a new event is written**: it adds the draft to `events` as a `readOnly` event, and Cancel drops it;
- **disables Save** until the title is filled in and the end is after the start.

**Hiding days:** `hiddenDays` leaves weekdays out of every view, `[0, 6]` being the weekend. The week and month grids close up without them, the day view and its arrows step over them, and a selected day that is hidden shows as the next one after it. An event on hidden days only is not drawn; one that runs into them is drawn on its shown days.

**Navigating:** the header's arrows, **Today** and the view switcher. In the month, week and day views a horizontal swipe on touch pages to the next or previous period, with its events already in view while you swipe; the agenda is a list and pages with the arrows only. Pinch the week or day view, or hold Ctrl and use the wheel or trackpad, to zoom the hours; the zoom is kept when switching views. Clicking a day number, or a weekday in the week view, opens that day.

**On a narrow calendar** (under 600px of its own width, not the viewport's) the month view becomes a grid with coloured dots above the selected day's events. Tapping a day selects it rather than opening it; the header's **New event** button creates an all-day slot on it. Scrolling the list folds the month up into the selected week; swiping then pages by week instead of by month. Under `prefers-reduced-motion: reduce` the month folds in one step, once the list has scrolled the whole way, instead of following the scroll.

The week view of a narrow calendar shows **three days** from the selected one and pages by three, as seven columns at phone width leave no room for a title. The switcher still calls it Week, and `onRangeChange` reports those three days and their neighbours.

**Working hours and week numbers:** `workingHours={[8, 17]}` shades the hours before 08:00 and from 17:00 in the week and day views, and makes 08:00 the hour they open at when today is not shown. `[22, 6]` is an overnight shift, shaded from 06:00 to 22:00. `showWeekNumbers` labels each row of the month view and the corner of the week and day views with its **ISO 8601** week, the numbering used across most of Europe; a week that starts on another day than Monday shows the ISO week holding most of its days. The month of a narrow calendar has no room for them and leaves them out. `labels.weekNumber` words it: ``(week) => `KW ${week}` `` in German.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `events` | `E[]` | The events to show, `E extends CalendarEvent` |
| `date` / `defaultDate` / `onDateChange` | `string` | Selected day, `'YYYY-MM-DD'`, controlled or uncontrolled: the same value a [DatePicker](DatePicker.md) holds, so the two can share state |
| `view` / `defaultView` / `onViewChange` | `CalendarView` | Shown view, controlled or uncontrolled (default the first of `views`) |
| `views` | `CalendarView[]` | Views in the switcher, in order; hidden when there is one (default all four) |
| `locale` | `string` | BCP 47 tag for dates, times and the week start (default the app's `locale`, else the browser's) |
| `weekStartsOn` | `0` to `6` | First day of the week, `0` being Sunday (default the locale's, else Monday) |
| `slotMinutes` | `number` | What creating, moving and resizing snap to (default `15`) |
| `hiddenDays` | `CalendarWeekday[]` | Weekdays left out of every view, `0` being Sunday: `[0, 6]` hides the weekend |
| `scrollToHour` | `number` | Hour the week and day views open at, such as `6` (default an hour before now while today is shown, otherwise the start of `workingHours`, or `7`) |
| `workingHours` | `[start, end]` | Hours of working time in the week and day views, `[8, 17]`, or `[22, 6]` overnight; the rest is shaded |
| `showWeekNumbers` | `boolean` | ISO 8601 week numbers on the month's rows and in the week and day views' corner (default `false`) |
| `onRangeChange` | `(range: CalendarRange) => void` | The shown period plus one on either side |
| `onEventClick` | `(event: E, anchor: HTMLElement) => void` | An event was clicked; `anchor` is its element, ready for `Popover`'s `anchor` |
| `onEventChange` | `(change: CalendarEventChange<E>) => void \| Promise<unknown>` | Moved or resized. Setting it makes events draggable. A returned promise keeps the event in place until it settles |
| `onSlotCreate` | `(slot: CalendarSlot, anchor: HTMLElement) => void` | Empty time clicked or dragged over, or the header's **New event** pressed. Setting it enables creating. `anchor` is ready for `Popover`'s `anchor`: the first day's cell in the month view, the first day's header for the all-day row, an invisible box over the new slot in the time grid, and the button itself from the header |
| `renderEvent` | `(event: E, context: CalendarEventContext) => ReactNode` | Content of an event in place of its title. No interactive elements. `context.singleLine` is set where it has one line: an event under 45 minutes, an all-day bar, a month row. Keep to the title there |
| `eventClassName` | `(event: E) => string \| undefined` | A class for one event's element in every view, next to `classNames.event`: a tentative, cancelled or selected event |
| `ref` | `Ref<HTMLDivElement>` | The root element |

Give the calendar a height. It is `40em` tall by default and scrolls inside itself; `style={{ height: '100dvh' }}` or a class sets another.

**Keyboard:** in the month view the days are a grid with one tab stop on the selected day: `←`/`→` move a day, `↑`/`↓` a week, `Home`/`End` go to the start and end of the week, `PageUp`/`PageDown` a month. `Enter` or `Space` opens a list of that day's events, with a **New event** button when `onSlotCreate` is set. In the week and day views every event is a button, in time order. The header's **New event** button creates in every view; opened from it with the keyboard, a `Popover` editor takes the focus.

**Accessibility:**

- The period title is a polite live region, so paging is announced.
- Days carry their full date and event count as their name, and today has `aria-current="date"`.
- Each day column of the time grid is a group named by its date.
- **Dragging needs a single-pointer alternative (WCAG 2.5.7).** Creating has one, a click, and the header's button. For moving and resizing it has to be your `onEventClick` dialog, so let it edit the start and end.

**Theming:** these are theme variables (typed on `ThemeVariables`). Set them theme-wide via `ThemeProvider`'s `variables`, or per instance via `style`:

| Variable | Falls back to |
|----------|---------------|
| `--calendar-hour-height` | `3em` (the height of one hour; pinching zooms from it) |
| `--calendar-today-color` | `var(--primary-color)` |
| `--calendar-now-color` | `var(--error-color)` |
| `--calendar-event-radius` | `6px` |
| `--calendar-off-hours-color` | `var(--background-accent)` (the hours outside `workingHours`) |

**Global defaults:** `ConfigProvider` sets `labels`, `weekStartsOn`, `slotMinutes`, `views`, `hiddenDays`, `scrollToHour`, `workingHours` and `showWeekNumbers` for every calendar, e.g. `defaultProps={{ Calendar: { weekStartsOn: 1, showWeekNumbers: true } }}`. The other props depend on your event type, so they are set per instance. The locale is not one of them: it is set once, as `locale` on `AhroweProvider` or a `ConfigProvider`. See [ConfigProvider.md](ConfigProvider.md).

**Slots:** `root` `header` `dayHeader` `cell` `event`

**Labels:** `previous` `next` `today` `viewSwitcher` `month` `week` `day` `agenda` `allDay` `more` `eventCount` `noEvents` `newEvent` `weekNumber`: the text this component renders of its own. `more` and `eventCount` are functions of a count, `weekNumber` of the week's number. Dates and times come from `locale`, not from here. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; keys merge individually. Type: `CalendarLabels`.

**Limits:**

- **One time zone, the browser's.** Every day boundary and clock time is worked out from the local `Date` fields. Showing another zone would mean routing each of them through `Intl` offset lookups, which the calendar does not do.
- **No recurrence rules.** Expand series for the range `onRangeChange` reports.
- **Moving stays within the shown period**, and between days or times of the same kind: an event cannot be dragged from the time grid into the all-day row.
- **No resource columns.** A column per person or room, as a team schedule has, is not a view of its own.
- **No right-to-left layout.**
- **Safari on macOS** sends a trackpad pinch as a gesture event rather than a Ctrl+wheel, so there it zooms the page as usual; pinching on a touch screen zooms the hours in every browser.
- **A controlled `date` must update in the same render as `onDateChange`.** After a swipe, the calendar shows the new period's panes straight away; a parent that updates later makes it jump back and forth.

HTML attributes such as `id`, `aria-label` and `data-*` are passed to the root element.
