# Schedule (/docs/schedule)





`Schedule` is a fully hand-built agenda component — Ark UI ships nothing scheduler-shaped, so
there's no headless machine underneath it. Calendar math goes through
[`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/index.html)
(the same boundary [`Calendar`](/docs/calendar)/[`DatePicker`](/docs/date-picker) use); drag and
resize are hand-rolled pointer gestures modeled on the notifier's own swipe-to-dismiss.

Seven real pieces under one compound namespace: `Schedule` (the root, which owns view-switching
and a shared toolbar), `Schedule.DaysView` (the timed grid — a `visibleDays`-sized window,
drag-to-move, edge-resize, a now-cursor, side-by-side concurrent events), `Schedule.MonthView`
(a read-only month grid), `Schedule.YearView` (a dot/count overview), `Schedule.AgendaView`
(a span of days as a list of events), `Schedule.Toolbar` (Prev/Next/Today/view-switcher, composable on
its own), and `Schedule.NextUp` (the same agenda list over an unbounded from-now window, with no
chrome of its own). Every view is independently mountable — nothing requires `Schedule.Root`.

```tsx
import { Schedule } from "@42-central/ui-react/schedule";

<Schedule
  data={events}
  editable={["create", "drag", "resize", "remove"]}
  onEventChange={(event, next, reason) => updateEvent(event.id, next)}
  onEventRemove={(event) => removeEvent(event.id)}
  onEventCreate={(ranges) => addEvents(ranges)}
/>;
```

<Preview align="block" code="&#x22;<Schedule\n  data={events}\n  staticEvents={staticEvents}\n  startHour={7}\n  endHour={20}\n  slotDisabled={anyOf(disableOutsideHours(9, 17), disableWeekends())}\n  editable={['create', 'drag', 'resize', 'remove']}\n  onEventChange={(event, next, reason) => updateEvent(event.id, next)}\n  onEventRemove={(event) => removeEvent(event.id)}\n  onEventCreate={(ranges) => addEvents(ranges)}\n  onSelect={(event) => showDetails(event)}\n/>&#x22;">
  <ScheduleRootDemo />
</Preview>

Try switching views (top right), dragging an event, resizing from its top/bottom edge, removing
one with its `×`, and press-dragging empty space to draft a new one — every gesture above is
live. The faintly tinted hours are outside 9am–5pm and weekends, blocked via
[`slotDisabled`](#disabled-slots) — the cursor also turns to `not-allowed` if you try to drop
something there. Drag a draft sideways into the next day and it turns red before you release:
the range would span the night between the two columns, which that same `slotDisabled` forbids
(see [Disabled slots](#disabled-slots)).

For a realistic screen putting `Schedule` and `Schedule.NextUp` together inside
[`AppShell`](/docs/app-shell), with a custom event-details/edit panel, jump to the
[dashboard example](#dashboard-example), or view the full-page, unbounded version:

<Button endSlot="<ArrowUpRight />">
  <Link href="/examples/schedule" target="_blank" rel="noopener noreferrer">
    View full-page example
  </Link>
</Button>

## Value model [#value-model]

`data: ScheduleEvent[]` (required, draggable/resizable/removable) and
`staticEvents?: ScheduleStaticEvent[]` (default `[]`, read-only but still clickable) are two
separate arrays, not one `data` array with a flag — the two kinds have categorically different
capability sets. Both are fully consumer-controlled: `Schedule` never mutates them, only reports
proposed changes through callbacks.

```tsx
type ScheduleEventBase<P = unknown> = {
  id: string; // stable identity — never derived from array index
  start: Date; // wall-clock instant, native Date
  end: Date; // exclusive — an event ending when another starts doesn't overlap it
  title: string;
  description?: ReactNode; // secondary content row in the details popover, same role as Field's helper text
  color?: ScheduleEventColor; // "red" | "orange" | "yellow" | "green" | "teal" | "cyan" | "blue" | "purple" | "pink"
  icon?: ReactNode; // icon={null} explicitly suppresses it; undefined = none set
  payload?: P; // arbitrary typed data of your own, surfaced to renderEventDetails/renderOverflowDetails
  allDay?: boolean; // Days view only — see All-day events below
};
type ScheduleEvent<P = unknown> = ScheduleEventBase<P> & {
  kind: "event";
  // Narrows the schedule-level `editable` grant further for this one event —
  // never wider than it. undefined inherits the schedule-level grant as-is.
  editable?: boolean | ("drag" | "resize" | "remove")[];
};
type ScheduleStaticEvent<P = unknown> = ScheduleEventBase<P> & {
  kind: "static";
}; // read-only, still clickable
```

`kind` is required and literal-pinned on every event — a compile-time guard against passing a
static event into `data`, or vice versa. Whether the remove button (or any other affordance)
renders at all is decided by `editable` (see [Edit permissions](#edit-permissions)) — `onEventRemove`
just reports when it's actually clicked. `onEventChange` fires with `(event, { start, end }, reason)`
where `reason` is `"drag"` or `"resize"`. `P` is inferred from `data`, so `event.payload` in every
callback and render prop below comes back precisely typed — see
[Event & day details](#event--day-details).

## Event & day details [#event--day-details]

Clicking an event **selects** it and opens a built-in details popover — title, time range,
description, icon and color, no wiring required. `description` is `ReactNode`, not just plain text,
so it can carry rich content (a link, a short list) the same way `icon` already does.

The popover and the selection are one piece of state, not two: it opens for whichever event is
`selected`. Set `withEventDetails={false}` to keep the selection and render the detail yourself —
a side panel, a drawer — or use [`renderEventDetails`](#event--day-details) to keep the popover and
replace only what is inside it.

Month view has a second popover, listing that day's full, uncapped agenda with the same
list→single-event swap — and it hangs off the `+N` chip, the affordance that announces there is
something hidden in the first place. It is therefore only present on a day that actually overflows
`maxEventsPerDay`; on any other day the visible pills already show everything the list would. A row
inside it follows the identical rule as a pill — it selects that event, and `withEventDetails`
decides whether the built-in detail is what you get, no matter where the click originated.

`onDayClick` no longer gates that popover. The two used to be mutually exclusive, so wiring a day
click (to navigate somewhere of your own, say) silently removed the only route to the hidden
events — the exact dead end the popover exists to close. A day click and the `+N` disclosure are
now independent gestures.

`renderEventDetails`/`renderOverflowDetails` override the popovers' content without giving up the
built-in shell — handy for reaching into a typed `payload` the default content has no field for.
`renderOverflowDetails` still receives an `onSelectEvent` callback so custom row markup can opt
back into the built-in list→detail swap, and it works on every "+N" — the timed grid's and the
all-day row's as well as Month's.

<Preview align="block" code="&#x22;<Schedule.DaysView\n  data={events} // description on \&#x22;Defense: Born2beroot\&#x22; and \&#x22;Evaluation: libft\&#x22;\n/>&#x22;">
  <ScheduleDescriptionDemo />
</Preview>

Both events above use the *default* popover — no `renderEventDetails` needed for rich content,
since `description` already accepts anything renderable: a link, a checklist, whatever the event
calls for.

<Preview align="block" code="&#x22;<Schedule.MonthView\n  data={events} // ScheduleEvent<{ room: string }>[]\n  staticEvents={staticEvents}\n  renderEventDetails={(event) => (\n    <div>\n      <p>{event.title}</p>\n      <p>Room: {event.payload?.room}</p>\n    </div>\n  )}\n  renderOverflowDetails={(hidden, { onSelectEvent }) => (\n    <ul>\n      {hidden.map((event) => (\n        <li key={event.id}>\n          <button onClick={() => onSelectEvent(event)}>{event.title}</button>\n        </li>\n      ))}\n    </ul>\n  )}\n/>&#x22;">
  <ScheduleDetailsDemo />
</Preview>

Click a day, then an event inside it, to see the list→detail swap; click an event directly for the
same popover anchored to its own chip. Both come from the same `selected` id, so they can never
disagree about which event is current — and both stay off entirely under
`withEventDetails={false}`.

### Where the popover opens [#where-the-popover-opens]

Every popover renders inside the view's own scroll area, not over the page: it scrolls with the grid
and is clipped by it. In the Days view an event's popover opens *beside* the chip, not above or
below it, since an event can be as tall as the grid itself. It then slides along the chip to stay in
view while you scroll, flips to the other side when this one is full, and overlaps the chip when
neither side fits (a single, full-width day). It never tucks under the sticky day headers or the
hour gutter.

<Preview align="block" code="&#x22;<Schedule.DaysView\n  data={events} // \&#x22;Hackathon: 24h\&#x22; and \&#x22;Exam day\&#x22; run 00:00 → 24:00\n  visibleDays={4}\n  align=\&#x22;relative\&#x22;\n/>&#x22;">
  <ScheduleTallEventDemo />
</Preview>

## Configuring the views [#configuring-the-views]

`views` decides which entries the switcher offers, and in what order. `"day"` and `"week"` are
shorthands for the days grid sized to 1 and 7; `"days"` is the same grid at whatever `visibleDays`
you set; `"month"`, `"year"` and `"agenda"` are themselves. Anything else is an ordinary entry
with its own id, window and label.

The default entries are still `["days", "month", "year"]`, so a `<Schedule />` with no `views`
shows exactly the three tabs it always has — [the agenda](#agenda-view) is opt-in.

<Preview align="block" code="&#x22;const VIEWS = [\n  'day',\n  'week',\n  { id: 'fortnight', visibleDays: 14, label: '2 weeks' },\n  'month',\n  'year',\n] as const\n\n<Schedule data={events} views={VIEWS} defaultView=\&#x22;week\&#x22; />&#x22;">
  <ScheduleViewsDemo />
</Preview>

Two things are worth knowing.

**An entry's `id` is its identity; its `view` is its renderer.** They coincide for plain
`"days"`/`"month"`/`"year"`, which is why one name sufficed before — but they must not be conflated
once one schedule offers two differently-sized day windows. `view`/`defaultView`/`onViewChange`
carry the **id**, so under the entries above `onViewChange` reports `"day"`, never `"days"`.

**`Schedule` is generic over the array**, so those three props are typed to your exact ids and a
typo is a compile error. An array written **inline** narrows on its own — `as const` is only
needed when you hoist it into a variable first:

```tsx
<Schedule views={["day", "week", "month"]} defaultView="week" />        // narrows
const VIEWS = ["day", "week", "month"] as const                         // narrows
const VIEWS: ScheduleViewOption[] = ["day", "week", "month"]            // does NOT narrow
```

That last line is worth knowing rather than avoiding: annotating the array as
`ScheduleViewOption[]` is what you do when the entries are genuinely decided at runtime, and it
deliberately widens everything back open (see below).

### Props follow the views [#props-follow-the-views]

The array does not only decide which tabs appear — it decides **which props exist**. A prop only
one renderer reads is not accepted by a schedule that never renders it:

```tsx
<Schedule
  views={["month", "year"]}
  data={events}
  slotDuration={30}
//^^^^^^^^^^^^ Type 'number' is not assignable to type
//             'Unavailable<"requires a days renderer in `views`">'
  dayDisabled={disableWeekends()}   // fine: Month and Year both read it
/>
```

Before this, that `slotDuration` was accepted and silently dropped. The error names the renderer
it wanted, and the same applies to `maxEventsPerDay` (Month), `onMonthClick`
(Year), `renderEvent` (Agenda), and the whole `editable` family (Days). Even a *value* is checked:
`align="relative"` is a rolling day window, so it is rejected where no days view can use it.

Two consequences worth stating plainly:

* **Omitting `views` changes nothing.** The default is `["days", "month", "year"]`, which
  configures three of the four renderers, so every prop those read stays available. Only
  `renderEvent` is out of reach, because the default list has no agenda entry.
* **A `views` array built at runtime keeps every prop.** There is nothing for the type to read, so
  it widens to every renderer rather than rejecting code that works. You get a dev-mode console
  warning naming the ignored props instead — the same two-layer scheme `editable` uses.

The switcher does not render at all below two entries: a one-tab tablist is dead chrome. And
`defaultView` falls back to the first entry rather than a hard-coded `"days"`, so
`views={["month", "year"]}` starts on Month instead of rendering a view with no tab selected.

## Selection & navigating between views [#selection--navigating-between-views]

Clicking an event selects it. `selected`/`defaultSelected`/`onSelect` is an ordinary
controlled/uncontrolled triple, and one id drives everything that follows from "this is the current
event" — the chip's styling, which details popover is open, which row of a `+N` list is expanded.

Navigation is deliberately **not** a Schedule prop. What a click means is yours to decide — open a
panel, route to a URL, go somewhere, nothing at all — and `view`/`date`/`selected` are already full
controlled pairs, so everything you need is there. `useSchedule` is sugar over exactly those props,
never a second authority:

<Preview align="block" code="&#x22;const schedule = useSchedule({ views: VIEWS, defaultView: 'month' })\n\n<Schedule\n  {...schedule.props}\n  data={events}\n  onSelect={schedule.goTo('day')}       // an event chip\n  onDayClick={schedule.goTo('day')}     // a day cell, or a Days day-header\n  onMonthClick={schedule.goTo('month')} // a Year month header\n/>&#x22;">
  <ScheduleNavigationDemo />
</Preview>

One `goTo` covers all four click sources because its target is structural — a `Date` from a day or
month click, an event from `onSelect`, or `null` to deselect. Handed an event it selects it as well
as navigating, which is what makes it safe to pass as `onSelect`.

Selecting an event **reveals** it, but only when it isn't already on screen: if any part of its
range overlaps the visible window the anchor date stays put, and in Days view the grid scrolls to
centre the chip only when the chip is outside the scroll viewport. Testing the whole range rather
than just `start` is what makes a long multi-day event behave — it is visible on every day of the
window, so anchoring to its start would page *away* from what you asked to see. Focus is never moved
by a `selected` change; a chip click focuses on its own.

**Navigating to a day reveals it the same way.** Day columns have a `9rem` floor, so a week is
already wider than most containers and a fortnight is far wider — the day you drilled into can
easily be scrolled past. Moving the anchor to a day scrolls its column into view, again only when
it is outside the scroll viewport. This is the inline axis only: whatever time of day you were
looking at stays exactly where it was, and focus is not moved here either.

You don't wire any of it. The anchor date already *is* the day that was clicked, so the built-in
drill-down and your own `onDayClick` — `useSchedule().goTo('days')` included — both get it. Paging
is excluded, since Prev/Next, the Now button and a drag held at the grid's edge are all going
somewhere on purpose rather than asking to see a particular day.

Neither reveal animates. A reveal is a side effect of going somewhere, not a gesture of its own,
and animating it reads as the grid drifting rather than as feedback — so `revealBehavior` is
`"auto"` by default and the jump is instant. Set it to `"smooth"` if you want the motion:

```tsx
<Schedule data={events} revealBehavior="smooth" />
```

`"auto"` defers to the platform, so a CSS `scroll-behavior: smooth` on your own container is the
way to turn this on globally; `"instant"` refuses to animate whatever the CSS says. `"smooth"`
still yields to [reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
— the prop can ask for motion, it can't override someone asking for none. The Now button is a
different case and keeps its animation: it answers a press, rather than following you somewhere.

Don't want any of it? `withSelection={false}` turns the whole concept off, and
`withEventDetails={false}` keeps the selection but leaves the detail rendering to you.

The selected chip carries `data-selected`, alongside the `data-today`/`data-disabled` stamps
already documented in [testing](/docs/testing). One gotcha if you query it: Ark's `Tabs` marks the
**active view-switcher tab** `data-selected` too, so a bare `[data-selected]` inside a schedule
matches two very different things — scope it, e.g.
`[data-schedule-part="event-chip"][data-selected]`.

### Several chips, one selection [#several-chips-one-selection]

Some things are drawn as more than one chip: an offer split around the booking that cuts into it,
or an event interrupted by a break. `selected` also takes a list of ids for these. Every listed chip
is painted selected, and **the first id is the anchor**. It is the only chip whose details popover
opens, and the one a selection change reveals.

`onSelect` still reports the single chip that was pressed. Map it to the group yourself, and put the
pressed id first so the popover opens where the click was. Press either green piece below:

<Preview align="block" code="&#x22;const [pressed, setPressed] = useState<ScheduleAnyEvent<Offer> | null>(null)\n// The pressed piece first, so the popover opens where the click was.\nconst piecesOf = (event) => [\n  event.id,\n  ...events\n    .filter((e) => e.id !== event.id && e.payload?.offer != null)\n    .filter((e) => e.payload?.offer === event.payload?.offer)\n    .map((e) => e.id),\n]\n\n<Schedule.DaysView\n  data={events}\n  selected={pressed ? piecesOf(pressed) : null}\n  onSelect={setPressed}\n/>&#x22;">
  <ScheduleGroupSelectionDemo />
</Preview>

Derive the list on every render rather than storing it, so it stays right when the pieces are
re-cut. Passing a fresh array each time is fine, because the reveal keys on the anchor id and not
on the list. Uncontrolled selection (`defaultSelected` and a plain click) still selects one chip,
since only you know which chips belong together.

## Static events, color & icon [#static-events-color--icon]

A static event (school schedule, an existing commitment) renders identically to a regular one —
and, critically, has no grip strips and no remove button, because the drag/resize hooks are never
invoked for it at all. Color and icon stay fully overridable per event regardless of kind, so a
static "class" event can still carry its own icon and tint.

## Days view window [#days-view-window]

`Schedule.DaysView` renders `visibleDays` day-columns (default `7`) sharing one hour axis —
`visibleDays={1}` is a single-day view, `visibleDays={7}` (the default) is a full week, and
anything else is a real, first-class window size, not just a workaround.

```tsx
<Schedule.DaysView data={events} visibleDays={3} />
```

`align` decides where that window starts — named `align`, not `startOfWeek`, since `DaysView`'s
window isn't necessarily a week (`visibleDays` can be anything):

* **A day-of-week value, or omitted** (locale default) — the window snaps to that calendar
  week's start, the same way the old week view always did. A `visibleDays` shorter than 7 then
  slides forward in whole windows until it contains the anchor, so the window always shows the
  day it is anchored on: with `visibleDays={5}` and a Monday start, a Saturday anchor gives
  Sat–Wed rather than the Mon–Fri that would leave the anchor off screen entirely. A
  `visibleDays` of 7 or more never needs to slide, since the anchor is always inside the first
  window. The one exception is `visibleDays={1}`, which always shows the anchor date itself
  regardless of `align` — a single day has no "start of week" to snap to, so this always matches
  the old standalone `DayView`.
* **`"relative"`** — the window's first visible day is always the anchor date itself, with no
  calendar-week snap at all: a genuinely rolling window.

```tsx
<Schedule.DaysView data={events} visibleDays={5} align="relative" />
```

<Preview align="block" code="`<Schedule.DaysView data={events} staticEvents={staticEvents} visibleDays={5} align=&#x22;relative&#x22; />`">
  <ScheduleRelativeWindowDemo />
</Preview>

Page with Prev/Next below and notice the window keeps rolling forward/back by exactly 5 days
rather than re-snapping to a calendar week — press Now to bring today back into view.

Prev/Next always page the *anchor* by exactly `visibleDays`, in either mode — pagination never
re-anchors to "today." In `"relative"` mode specifically, that means once you page away with
Prev/Next, the window stops containing today until the Now button brings it back — a deliberate
property of a rolling window, not a bug.

<Callout type="info">
  With a calendar-week `align` and a `visibleDays` that doesn't divide 7, the *window* can't step as
  evenly as the anchor does: a week holds one and a bit windows, so at `visibleDays={5}` successive
  Next clicks move it by 5, then 2, then 7 days, and a full week's Saturday and Sunday are only
  reachable by clicking into them from Month or Year. Use `align="relative"` for a window that
  always steps by exactly its own length.
</Callout>

`align` is scoped per component: `Schedule`/`DaysView` accept
`"relative"`, but `Schedule.MonthView`'s own `startOfWeek` stays day-of-week-only — a month grid
genuinely is composed of calendar weeks, so it keeps the accurate `startOfWeek` name, and
`"relative"` there is a compile-time type error, not just an undocumented restriction.

### Row density — `hourHeight` and `slotDuration` [#row-density--hourheight-and-slotduration]

`slotDuration` is `15` (the default), `30` or `60` — any other value is a compile error.
`hourHeight` (px per hour) defaults to `defaultHourHeight(slotDuration)` — a finer `slotDuration`
gets a taller default hour, so each slot's row stays a roughly-legible height regardless of how
many slots make up an hour, clamped to a sane range at either extreme. The default `slotDuration`
of `15` resolves to `48px`, unchanged from before this scaling existed. Pass `hourHeight` explicitly
to override the derived default outright, regardless of `slotDuration`.

```tsx
<Schedule.DaysView data={events} slotDuration={60} />
<Schedule.DaysView data={events} hourHeight={90} />
```

<Preview align="block" code="`<Schedule.DaysView data={events} slotDuration={60} />`">
  <ScheduleHourHeightDemo />
</Preview>

An event chip never renders shorter than one line of its own content, so at the default
`48px` hour a sub-22-minute event is drawn slightly taller than its true duration and
crosses into the following slot — the same compromise every calendar app makes once a slot
is too short to hold text. Only the bottom edge inflates: the chip's top edge stays exact
against the gutter, and drag/resize/create still commit to exact minutes, since those
measure the day column rather than the chip. Raise `hourHeight` if you want short events
to render at their true span.

The time-range subtext appears only when the chip is genuinely tall enough for a second
line, measured from its real rendered height rather than its duration — so it also shows
up correctly in a stretched, fill-height column, where a purely duration-based guess used
to withhold it. The full range is always in the chip's accessible name and its details
popover either way.

### `DayView` / `WeekView` [#dayview--weekview]

`Schedule.DayView` and `Schedule.WeekView` are thin, preconfigured `DaysView`s for the two most
common window sizes — not separate implementations, just `visibleDays` fixed to `1` and `7`
respectively:

```tsx
<Schedule.DayView data={events} />
<Schedule.WeekView data={events} align="mon" />
```

`DayView` drops both `visibleDays` and `align` from its props — a single day always anchors
on the date itself (see above), so `align` has nothing to do there. `WeekView` drops only
`visibleDays` and keeps `align`, since a week genuinely has a start-of-week to configure.
Reach for `Schedule.DaysView` directly instead when you need a window size other than 1 or 7.

## Disabled slots [#disabled-slots]

Two mechanisms, deliberately not merged into one "unavailable" state: a slot with an event on it
needs no predicate at all (the chip explains itself), while a programmatic restriction goes
through a range-shaped predicate:

```tsx
import {
  anyOf,
  disableOutsideHours,
  disablePast,
  disableWeekends,
} from "@42-central/ui-react/schedule";

<Schedule
  data={events}
  slotDisabled={anyOf(
    disablePast(),
    disableOutsideHours(9, 17),
    disableWeekends(),
  )}
/>;
```

```tsx
type ScheduleSlotPredicate = (
  range: { start: Date; end: Date },
  timeZone?: string,
) => boolean;

declare function disablePast(now?: Date): ScheduleSlotPredicate;
declare function disableOutsideHours(
  startHour: number,
  endHour: number,
): ScheduleSlotPredicate;
declare function disableWeekends(): ScheduleSlotPredicate;
declare function disableDateRanges(
  ranges: { start: Date; end: Date }[],
): ScheduleSlotPredicate;
declare function anyOf(...ps: ScheduleSlotPredicate[]): ScheduleSlotPredicate;
declare function allOf(...ps: ScheduleSlotPredicate[]): ScheduleSlotPredicate;
```

`disablePast()` reads the live clock on every call rather than once when the predicate is built, so
a slot that was still in the future when the component mounted correctly becomes disabled once its
start time actually elapses — pass a fixed `now` to pin it for tests/Storybook.

The factories stay zero-arg (or value-only) so they read naturally at the call site; the zone every
calendar reading is interpreted in arrives as the predicate's **second argument**, which `Schedule`
fills in with the very `timeZone` it was given. A predicate therefore can't disagree with the grid
restricting by it — `<Schedule timeZone="UTC" slotDisabled={disableOutsideHours(9, 17)} />` disables
9am–5pm **UTC** whatever the host zone is, instead of whichever hours happened to be 9–17 where the
code ran. `disablePast` and `disableDateRanges` ignore the argument on purpose: both compare
instants, never calendar fields, so they already give the same answer in every zone.

**A multi-day range is judged one calendar day at a time.** Click-drag-to-create and cross-day drag
both produce ranges that span more than one day, so a range is disabled when *any* day it touches is
disabled, each day measured against that day's own portion of the range. The end stays exclusive, so
a range ending exactly at midnight does not touch the following day. Two consequences worth knowing:

* A cross-day draft can be refused for time you cannot see. With `startHour={9} endHour={17}`,
  dragging from Thursday into Friday's column spans the unpainted 5pm–9am night between them, and
  `disableOutsideHours(9, 17)` rejects it — correctly, since committing it would put an event
  through hours you declared off-limits.
* Overnight events are unaffected: `disableOutsideHours(0, 24)` disables nothing, and a range from
  22:00 to 02:00 is allowed. A *wrapping* window (a 8pm–6am night shift) still isn't expressible
  here — reach for `disableDateRanges`, or write the predicate by hand.

Enforcement, not just decoration: a drag/resize commit whose proposed range intersects a disabled
slot is rejected — the chip snaps back instead of firing `onEventChange`. A disabled time slot in
Days view carries a flat, low-contrast background tint plus `cursor-not-allowed` on hover — the
tint alone (not just the cursor) matters on touch/mobile, where nothing ever hovers. That tint is
probed one `slotDuration` step at a time through the same predicate, in the same `timeZone`, so the
shading and the commit-time rejection can never disagree about which time is off-limits.

<Preview align="block" code="&#x22;const OPENS_AT = 8 * 60 + 45;\n\n// The shipped factories are a convenience, not the contract — a predicate is\n// just a function, and `disableOutsideHours` only takes whole hours anyway.\nconst beforeOpening = ({ start }, timeZone) => {\n  const [h, m] = new Intl.DateTimeFormat('en-GB', {\n    timeZone, hour: '2-digit', minute: '2-digit', hour12: false,\n  }).format(start).split(':');\n  return Number(h) * 60 + Number(m) < OPENS_AT;\n};\n\n<Schedule.DaysView\n  data={events}\n  visibleDays={3}\n  startHour={7}\n  endHour={13}\n  slotDuration={15}\n  slotDisabled={beforeOpening}\n  withNowIndicator={false}\n  editable={['create', 'drag']}\n  onEventCreate={(ranges) => addEvents(ranges)}\n/>&#x22;">
  <ScheduleDisabledSlotsDemo />
</Preview>

The boundary above is deliberately **08:45**, not 09:00, because that is where tint and enforcement
have to agree. The overlay probes one `slotDuration` step at a time, so the tint stops at 08:45 with
the first bookable slot flush against it — a draft starting at 08:30 is refused and one starting at
08:45 commits, and the picture says exactly that. `slotDuration` is the right unit for this and not
just a finer one: it is what every gesture snaps to, so a boundary the shading could still round
away is a boundary no drag could land on anyway.

`Schedule.MonthView` / `Schedule.YearView` take a day-granularity `dayDisabled?: DatePredicate`
instead — literally the same type
[`Calendar`](/docs/calendar)/[`DatePicker`](/docs/date-picker) use (`disablePast()`,
`disableWeekends()`, … from `@42-central/ui-react/date-picker`), and the view fills in its own `timeZone` as
the second argument exactly as they do. Same names, different module and shape: that one takes a
whole day, `@42-central/ui-react/schedule`'s own is range-shaped for a specific time slot. Granularity is
the distinction, not the argument list — a month cell has no time axis to restrict within.

## Click-drag-to-create & overlap prevention [#click-drag-to-create--overlap-prevention]

Press-drag on empty Days-view grid space to draft a new event; releasing hands the new ranges to
`onEventCreate` — always an array, so one state update adds everything a gesture made — once `editable` includes `"create"` (see [Edit permissions](#edit-permissions)
below). Schedule never invents an `id`/`title` — you build the real event and add it to `data`
yourself, the same fully-consumer-controlled contract as `onEventChange`/`onEventRemove`. A plain
click with no discernible drag clears the selection instead — `onSelect(null)` — and separately
fires `onGridClick` with the instant you pressed on, snapped to the grid. Both fire, and they are
not synonyms: `onGridClick` reports the gesture whether or not anything was selected. Neither
depends on `editable`'s `"create"` grant; it can never fire alongside a committed
`onEventCreate`, since they're mutually exclusive outcomes of the same click-vs-drag check. A drag
shorter than [`minDuration`](#minimum-and-maximum-duration) — one `slotDuration` unless you raise
it — grows to it. On touch, a short (\~450ms) stationary hold
arms the gesture — a quick swipe still scrolls the grid natively.

```tsx
<Schedule.DaysView data={events} selected={selectedId} onSelect={(event) => setSelectedId(event?.id ?? null)} />
```

```tsx
<Schedule.DaysView
  data={events}
  editable={["create"]}
  onEventCreate={(ranges) => addEvents(ranges)}
/>
```

```tsx
onEventCreate={(ranges) =>
  setEvents((events) => [...events, ...ranges.map((range) => ({ id: uid(), title: "New", ...range }))])
}
```

**Drag sideways to span days.** The range runs between the two instants you pressed on and
released on, so pressing Sunday at 22:00 and releasing on Monday at 02:00 drafts one four-hour
event, not two. Dragging *down* past the bottom of a column does the same thing — it carries the
draft into the next day rather than stopping at the edge.

A draft that spans days is validated one calendar day at a time, including the hours between two
columns that `startHour`/`endHour` never paint — so a [`slotDisabled`](#disabled-slots) rejection
can come from time you can't see on screen.

Either way the draft draws the way a real event does: one piece per day, each clipped to that
day's own hours, squared off on the edge where it continues. A piece that runs past the last day
in view is squared off too — the event is still perfectly valid, you just can't see all of it yet.

Because the day it ends on may not be on screen at all, the draft carries a live readout of the
range it would commit. It picks up the weekday as soon as the range crosses a date boundary:
`10:00 – 11:00` stays as it is, while a crossing one reads `Tue 10:00 PM – Wed 02:00 AM`.

**Right-drag to repeat across days.** With the right mouse button, the same gesture drafts one
event *per day* instead, each over the same time of day: pressing Monday at 08:00 and releasing
Wednesday at 16:00 creates three 08:00–16:00 events. Days and times normalise independently, so
dragging backwards from Wednesday 16:00 to Monday 08:00 is the same gesture, and each event stays
within its own day's hours rather than rolling into the next. A day that would be rejected (a
[`slotDisabled`](#disabled-slots) slot, or an overlap under `preventOverlap`) is tinted red and
skipped — right-drag Monday → Sunday with the weekend disabled and you get five events; only a
gesture where every day is rejected fails. The browser's context menu is suppressed for the
gesture, and left alone entirely when `"create"` isn't granted. Touch has no secondary button, so
it always spans.

### Minimum and maximum duration [#minimum-and-maximum-duration]

**`minDuration` and `maxDuration` bound what a create or resize can produce.** Both are minutes,
and both must be a whole number of `slotDuration`s — that is checked when you compile, against the
`slotDuration` written beside them:

```tsx
<Schedule slotDuration={30} minDuration={60} maxDuration={240} />  // fine
<Schedule slotDuration={30} minDuration={45} />
//                          ^^^^^^^^^^^ Type '45' is not assignable to type 'SlotMultiple<30>'
```

`minDuration` defaults to one slot. A create drag shorter than it grows to it from the point you
pressed on, and a resize grip stops that far from the event's other edge. `maxDuration` is the
mirror image: the draft stops growing at the cap — in a right-drag, each day's event is capped —
and a resize grip stops at it too. An existing event already longer than the cap can still be
resized shorter, never longer; moving an event never changes its length, so drag ignores both.

Pass the minimum here rather than enforcing it in `onEventCreate`. Stretching a range after the
fact works, but the draft still shows the shorter one while you drag, so what you see is not what
gets created.

The accepted values run up to 24 hours in slot steps, then whole days up to a week. Values read at
runtime — from an API, a settings form — are plain `number`s, which the type does not accept.
`isScheduleSlotProps` checks the three together, against the `slotDuration` given with them, and
narrows them:

```tsx
const slots = { slotDuration: rules.granularity, minDuration: rules.minimum }

<Schedule data={events} {...(isScheduleSlotProps(slots) ? slots : {})} />
```

If an off-grid value reaches the schedule anyway — through a cast, or from plain JavaScript — it is
rounded onto the grid with a console warning in development: `minDuration` up, `maxDuration` down
and never below `minDuration`.

```tsx
<Schedule.DaysView
  data={events}
  editable={["create", "resize"]}
  slotDuration={30}
  minDuration={60}
  maxDuration={120}
  onEventCreate={(ranges) => addEvents(ranges)}
  onEventChange={(event, next) => updateEvent(event.id, next)}
/>
```

While the drag is in progress, the draft box turns red the instant its current position would be
rejected on release (a disabled slot, or an overlap once `preventOverlap` is on) — not just after
you let go.

`preventOverlap` keeps a created, dragged, or resized event from landing on another one. A plain
boolean covers the common case — `true` enforces it for all three gestures, against both regular
and static events; `false`/`undefined` (the default) leaves existing side-by-side concurrent-event
layout ([`layoutEventColumns`](#value-model)) completely unaffected. Pass an object instead to opt
in to just a subset of gestures (`gestures`, e.g. `["create"]`) and/or scope which event kind counts
as a blocker (`against`, `"event" | "static" | "both"`, default `"both"`):

```tsx
preventOverlap={{ gestures: ["create"], against: "static" }}
```

<Preview align="block" code="&#x22;<Schedule.DaysView\n  data={events}\n  editable={['create', 'drag', 'resize', 'remove']}\n  preventOverlap\n  maxDuration={capped ? 120 : undefined}\n  onEventCreate={(ranges) => addEvents(ranges)}\n  onEventChange={(event, next) => updateEvent(event.id, next)}\n  onEventRemove={(event) => removeEvent(event.id)}\n/>&#x22;">
  <ScheduleCreateDemo />
</Preview>

Right-drag across a few days to create one event on each; turn "Cap at 2 hours" on and any
draft stops at two hours. Try dragging in the empty space above with "Prevent overlap" on — the draft turns red as soon as
it would land on an existing event, then snaps back with the same rejection pulse a disabled slot
gets on release, since both are checked at the same commit-time gate. Turn it off and the same
drag lands and lays out side by side with whatever it overlapped.

Only block against static events, leaving room to double-book real events with each other:

```tsx
<Schedule.DaysView
  data={events}
  staticEvents={staticEvents}
  editable={["create", "drag", "resize"]}
  preventOverlap={{ against: "static" }}
  onEventCreate={(ranges) => addEvents(ranges)}
  onEventChange={(event, next) => updateEvent(event.id, next)}
/>
```

<Preview align="block" code="&#x22;<Schedule.DaysView\n  data={events}\n  staticEvents={staticEvents}\n  editable={['create', 'drag', 'resize']}\n  preventOverlap={{ against: 'static' }}\n  onEventCreate={(ranges) => addEvents(ranges)}\n  onEventChange={(event, next) => updateEvent(event.id, next)}\n/>&#x22;">
  <ScheduleOverlapScopeDemo />
</Preview>

Try dragging or creating over "Exam — Rank 02" (a static event) — blocked, same rejection pulse as
before. Now try the same over "Evaluation: libft" (a regular event) — allowed, since `against`
scopes the check to statics only.

## Edit permissions [#edit-permissions]

Nothing is editable by default. `editable` grants which gestures are possible at all —
`"create"`, `"drag"`, `"resize"`, `"remove"` — either `true` for all four or an array for a subset.
It's the sole authority on whether an affordance renders; passing `onEventChange`/`onEventRemove`/
`onEventCreate` only decides what happens once a *granted* gesture actually commits, it no longer
enables the gesture by itself.

```tsx
// Movable and resizable, but not removable or drag-to-create.
<Schedule.DaysView
  data={events}
  editable={["drag", "resize"]}
  onEventChange={(event, next) => updateEvent(event.id, next)}
/>
```

An individual event can narrow this further with its own `editable` field — never wider than the
schedule-level grant, only tighter, the same direction every other per-item `disabled` in the kit
composes in:

```tsx
const events = [
  {
    id: "1",
    kind: "event",
    title: "Confirmed booking",
    editable: false /* … */,
  },
  { id: "2", kind: "event", title: "Draft slot" /* … */ }, // inherits the schedule-level grant
];
```

<Preview align="block" code="&#x22;<Schedule.DaysView\n  data={events}\n  editable={['drag', 'resize', 'remove']}\n  onEventChange={(event, next) => updateEvent(event.id, next)}\n  onEventRemove={(event) => removeEvent(event.id)}\n/>&#x22;">
  <ScheduleEditableDemo />
</Preview>

"Defense: Born2beroot" above always carries its own `editable: false`, so it stays locked — solid
fill, no grips, no remove button — no matter which schedule-level switches are on; it's still your
own event, not the read-only `kind:"static"` look, just not editable right now. In development, a
console warning fires once if a granted gesture's matching callback is missing (the affordance
would render but do nothing), or if an event's `editable` tries to grant something the
schedule-level `editable` doesn't already allow.

## Now-cursor [#now-cursor]

A themeable line marks the current instant, drawn only across today's column, with a small clock
label (e.g. "09:30") next to its dot — a fainter, label-less guide line also spans the other
visible days so the hour it lines up with stays easy to trace even when today isn't one of them.
It ticks once a minute (never per-frame), and passing `now` freezes it — useful for tests/
Storybook, or for pinning a demo to a fixed instant. `withNowIndicator={false}` turns it off
entirely. The line's position and its label read the schedule's own `timeZone`, the same clock
the hour axis and the chips use (see [Time zones](#time-zones)).

The cursor is scoped to the window the grid actually paints: it only renders while the current
instant falls inside `[startHour, endHour)` — `endHour` exclusive, the same convention the rest of
the component uses. Outside it, the line, its clock label and the gutter's own decluttering all
drop together, rather than the line pinning itself to an edge and asserting "now is here" next to
an hour it isn't.

Dropping it silently would leave the grid unable to say *why* there is no line, though — a window
that simply has no cursor and one whose cursor is just off the top look identical. So the gutter
keeps a small chevron at the edge `now` lies past, pointing that way: up above `startHour`, down
below `endHour`. It carries no text, so there is nothing to translate and nothing for a screen
reader to announce or a font fallback to substitute.

<Preview align="block" code="&#x22;// One window, three pinned instants — only the middle one is inside it.\n<DaysView data={events} visibleDays={1} startHour={7} endHour={13} now={at(5, 20)} />\n<DaysView data={events} visibleDays={1} startHour={7} endHour={13} now={at(9, 30)} />\n<DaysView data={events} visibleDays={1} startHour={7} endHour={13} now={at(18, 50)} />&#x22;">
  <ScheduleNowWindowDemo />
</Preview>

## Concurrent events & overflow [#concurrent-events--overflow]

Overlapping events in Days view lay out side by side via `layoutEventColumns` — an interval-graph
greedy column-packer that also widens an event past any column it doesn't genuinely conflict with,
so two events that only briefly overlap don't both stay squeezed to a flat fraction for their whole
span. `maxOverlapColumns` (default `4`) caps the **total** rendered column count for an overlapping
cluster — `maxOverlapColumns={3}` always means at most 3 columns, never 3 real columns plus a 4th
for "+N". Once a cluster's real concurrency would exceed the cap, it's laid out with one fewer real
column instead, reserving the freed-up one for a shared "+N" chip spanning that cluster's hidden
events. That one chip covers the whole cluster in a single click-through rather than fragmenting
into several time-sliced indicators, which would otherwise crowd a run of short, staggered events
into illegibly small, overlapping pieces. Clicking "+N" opens the same kind of popover Month
view's own day click does — a compact row per hidden event, click one to see its *full* details
(time, icon, color, description) with a "Back" button to return.

`onOverflowClick` reports the activation — the hidden events, plus a `ctx` naming the day and
which surface fired (`"timed"`, `"allDay"` or `"month"`). It **suppresses nothing**: pass
`withOverflowDetails={false}` to take the disclosure over yourself, or `renderOverflowDetails` to
keep the popover and replace only what is inside it. Those used to be one prop — passing
`onOverflowClick` silently removed the built-in popover — which is the same two-jobs-in-one shape
`onEventClick` had before `selected`/`withEventDetails` replaced it.

<Preview align="block" code="`<Schedule.DaysView data={events} visibleDays={1} maxOverlapColumns={3} />`">
  <ScheduleOverlapDemo />
</Preview>

Overlapping columns get a thin gap, not a divider, via `--sch-column-gap` (default `3px`) — it's set
inline on the grid root, so overriding it on an ancestor needs `!important`:
`[data-schedule-part="timed-grid"] { --sch-column-gap: 6px !important; }`.

Columns also cascade — like most other calendar apps, a later, more-overlapping event is drawn as a
card slightly overlapping the one(s) behind it (with its own shadow and stacking order), rather than
each getting a flat, fully-separate sliver of space. Control how far each step cascades with
`--sch-column-overlap` (same override pattern as `--sch-column-gap` above); set it to `0px` to go
back to a flat, non-overlapping side-by-side layout. `visibleDays={1}` (a single full-width
day-column, room to spare already) defaults to `10px`; any wider window (`visibleDays > 1`)
defaults to `20px` — narrower day-columns benefit from a deeper cascade to keep a busy cluster's
frontmost title/time text legible.

Each overlapping event also only ever claims `--sch-column-fraction` (default `0.7`, same override
pattern as `--sch-column-gap`/`--sch-column-overlap` above) of whatever space remains after its own
cascade indent — never the full remainder — so there's always real room for the next, more-
overlapping card to intrude into and read as the front of the stack. The one exception: a cluster's
actual last (frontmost) card takes all of what's left, since nothing is stacked in front of it to
make room for. A solo, non-overlapping event is simultaneously its own first and last "column," so
it always gets the full-remainder treatment too. Set `--sch-column-fraction: 1` to go back to a
flat, edge-to-edge layout for every non-last column.

## Moving an event to another day [#moving-an-event-to-another-day]

Drag an event sideways onto a different day column. Up/down still changes the time, and you can do
both in the same gesture — a drag is one movement, not two.

<Preview align="block" code="`<Schedule.DaysView data={events} editable={['drag']} onEventChange={handleChange} />`">
  <ScheduleCrossDayDemo />
</Preview>

While you drag, a preview box shows where the event will land and the day column underneath it is
highlighted. **Moving never changes how long an event is** — grab a piece of a multi-day event and
the whole thing moves, keeping its duration exactly.

`onEventChange` reports the result, exactly as it already did for a same-day move; `next.start` and
`next.end` are absolute dates, so a day change needs nothing new from you:

```tsx
<Schedule.DaysView
  data={events}
  editable={["drag"]}
  onEventChange={(event, next) =>
    setEvents((prev) => prev.map((e) => (e.id === event.id ? { ...e, ...next } : e)))
  }
/>
```

### Turning the page [#turning-the-page]

Prev and Next slide the day band in the direction you're travelling — day
headers, all-day bars and day columns together — while the hour gutter, the
gridlines and the now-cursor stay where they are. It's the days that moved, not
the view.

Only a real page turn slides. Pressing **Now**, or setting `date` yourself to
somewhere else entirely, is a jump rather than a step, so it lands directly
instead of claiming a distance it didn't travel. A page turn you reach by
*holding a drag at the edge* doesn't slide either — the view moves under a
gesture that is still in flight, and animating it there would fight the drag.

Nothing to configure, and nothing animates for anyone who has asked for
[reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion).

### Reaching a day that isn't on screen [#reaching-a-day-that-isnt-on-screen]

Drag to the left or right edge of the grid and it scrolls. Keep holding there once it can't scroll
any further and the view turns the page, so you can carry an event into the next week without
letting go. The same works vertically for the time axis.

This works the same way for all three gestures — moving an event, resizing one by its grip, and
drafting a new one on empty space. Whatever you are dragging survives the page turn.

Holding is deliberate — it takes about a second and a half, so brushing the edge on the way past
doesn't move you anywhere.

<Callout type="info">
  Paging mid-drag moves the view's own anchor date. If you control `date` yourself, it arrives as an
  `onDateChange` like any other navigation — ignore it and the drag simply won't page, while
  everything else keeps working.
</Callout>

### From the keyboard [#from-the-keyboard]

Focus an event and press <kbd>←</kbd> / <kbd>→</kbd> to move it a day at a time, <kbd>↑</kbd> /
<kbd>↓</kbd> to move it by one slot (hold <kbd>Shift</kbd> for four). Every gesture here has a
keyboard equivalent.

## Reaching the other edge — `resizeCollision` [#reaching-the-other-edge--resizecollision]

A resize grip eventually runs into the event's own other edge. By default it stops
[`minDuration`](#minimum-and-maximum-duration) short of it — one `slotDuration` unless you raise
it — so an event can never be shrunk below that. `resizeCollision`
offers the two other answers a two-thumb range control normally gives you:

| Value                          | What a grip does at the other edge                                                                                                           |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `"none"&#x60; &#x2A;(default)* | Stops `minDuration` short. Extra movement is ignored.                                                                                        |
| `"push"`                       | Keeps going, carrying the other edge with it — the event holds its minimum length and slides.                                                |
| `"swap"`                       | Once the grip travels *past* the other edge, the two exchange roles: the old edge becomes the near one and your grip now drives the far one. |

<Preview align="block" code="`<Schedule.DaysView data={events} resizeCollision=&#x22;swap&#x22; editable={['resize']} />`">
  <ScheduleCollisionDemo />
</Preview>

The names are [Slider](/docs/slider)'s own, one layer down — both sit on the same underlying
`thumbCollisionBehavior`, because this is the same problem on a time axis rather than a new one.

`resizeCollision` applies to resizing only. A **move** shifts both edges by the identical displacement,
so there is nothing for it to collide with; a **create** drag has no second stop either, since
both of its endpoints come from the one gesture and already swap simply by being read as
`[earlier, later]`.

<Callout type="info">
  Whatever the mode, a commit can never invert a range or take it below the minimum duration —
  `"push"` and `"swap"` change *which* edges move, never that invariant.
</Callout>

## Events that cross midnight [#events-that-cross-midnight]

An event whose `end` lands on a later calendar day than its `start` is still **one event**. The
grid just has day boundaries, so it is drawn in **pieces** — one per day it touches, each clipped
to that day's own hour window.

<Preview align="block" code="`<Schedule.DaysView data={events} visibleDays={4} editable={['drag', 'resize']} />`">
  <ScheduleOvernightDemo />
</Preview>

Six rules cover every case, and the first two are simply what single-day events already do:

1. **Drag any piece to move the whole event.** Grab the 00:00–02:00 tail of last night's shift,
   drag it down 30 minutes, and the whole thing moves — its 22:00 start becomes 22:30. Moving
   never changes how long an event is.
2. **Drag a grip to move just that edge.** Resizing never touches the other end.
3. **Grips only appear where the event really starts and really ends.** A piece that continues
   past midnight has no grip on that side, and a middle piece of a three-day event has none at
   all — there is no edge there to grab. The same is true of a piece clipped by `startHour` /
   `endHour`: the grip is always on the edge you can actually see.
4. **Dragging a grip through midnight grows the event into the next day**, where it simply
   re-draws as one more piece. There is no separate "make this multi-day" action.
5. **Drag a grip sideways to land the edge on a particular day.** While the edge is on its own
   day the chip stretches under your grip as usual; the moment it lands on another one the chip
   steps back and the draft pieces take over, since a stretch can't show a day change at all.
6. **Draft one directly** by press-dragging empty space across days, or down past the bottom of a
   column — see [Click-drag-to-create](#click-drag-to-create--overlap-prevention).

A grip can also run into the event's *own* other edge rather than a day boundary — see
[`resizeCollision`](#reaching-the-other-edge--resizecollision) for the three answers to that.

A piece that continues is **squared off** on the cut edge, so it visibly runs off the end of its
day rather than reading as a self-contained event that happens to sit at 22:00. Every piece shows
the whole event's real range, not its own clipped one — two pieces both reading "22:00 – 02:00"
read as one event seen twice, which is what they are.

<Callout type="info">
  An event that falls **entirely** outside `[startHour, endHour)` renders nothing at all, rather
  than being drawn off-grid. With a partial window this can hide data — Schedule logs a
  development-mode warning when it happens.
</Callout>

Timed events are never auto-promoted into the all-day row, in either direction: crossing midnight
does not make something an all-day event. If you want a day-granularity event, say so explicitly
with `allDay` — see below.

## All-day events [#all-day-events]

Set `allDay: true` on an event to pin it above the hour grid instead of positioning it by
time-of-day — a sticky row at the top of Days view, right under the day headers. Build its
`start`/`end` as day *boundaries*, the same exclusive-end convention every timed event already
uses: `start` is midnight of the first day, `end` is midnight of the day *after* the last day. A
multi-day all-day event draws one continuous bar spanning every day column it covers, rather than
an independent pill per day. The **longest** event always claims the top lane, regardless of
which one started first — the demo below's 3-day event stays visible in lane 0 even though a
same-day 1-day event was seeded first in the array.

<Preview align="block" code="`<Schedule.DaysView data={events} maxAllDayRows={2} />`">
  <ScheduleAllDayDemo />
</Preview>

`maxAllDayRows` (default `2`) caps the row's **total** row count — `maxAllDayRows={2}` always
means at most 2 rows, never 2 visible lanes plus a 3rd for "+N". Once more events are concurrent
than fit, the shortest one(s) collapse first (the same priority order that decides a bar's lane
also decides who overflows) into a shared per-day "+N", using up one of those `maxAllDayRows`
rows rather than an extra one on top. A lone overflowing event renders as a real bar instead of
a "+1" (it costs the same row space either way, so there's nothing worth hiding it behind) —
only 2+ collapses into a genuine "+N", opening the identical list↔detail popover — and taking the
same `onOverflowClick`/`withOverflowDetails`/`renderOverflowDetails` trio — the timed grid's own
"+N" uses. All-day events are display +
click only: no
drag-to-move, resize, or
click-drag-to-create, matching the same "no gesture without a real time axis to drag against"
rule the rest of Days view already follows. Month and Year need no special handling — an `allDay`
event there is just an ordinary member of `data`/`staticEvents`.

## Month view [#month-view]

Fully read-only: a chip list per day, capped at `maxEventsPerDay` (default `3`) with a `+N` chip
for the rest. That chip spans the cell and carries an event chip's hit target — same box, none of
its paint — and it discloses exactly the events it counts, the same rule both Days "+N" chips
follow. The visible chips stay on screen and each opens its own details popover, so nothing about
the day is out of reach; the list simply stops repeating what is already in front of you. It takes
the same `onOverflowClick`/`withOverflowDetails`/`renderOverflowDetails` trio Days does. Adjacent-month days render dimmed but still show
their real events. Tune `maxEventsPerDay` to how much
vertical room each cell actually has — a cap that's too high for a short container clips the
overflow pill along with everything else, rather than growing the row (each day's cell content is
clipped to its own grid row so the whole month stays visually uniform). It takes the same prop on
`Schedule` itself, where it reaches the Month view and nothing else — and where it usually wants to
be one lower than a standalone `MonthView` in the same box, since `Schedule` spends a row of that
height on its own toolbar. A disabled day (weekends in
the demo below, via `dayDisabled`) gets a muted background tint and a muted day-number —
existing events on it still render normally and stay clickable.

<Preview align="block" code="&#x22;<Schedule.MonthView\n  data={events}\n  staticEvents={staticEvents}\n  maxEventsPerDay={2}\n  dayDisabled={disableWeekends()}\n  onDayClick={(date) => openDay(date)}\n/>&#x22;">
  <ScheduleMonthDemo />
</Preview>

Clicking a day — its number, or anywhere in the cell outside a chip — is the "go to that day"
gesture, and under `Schedule` it is wired for you: it switches to the days view anchored on that
date. Your own `onDayClick` replaces that rather than firing alongside it, and a standalone
`Schedule.MonthView` has no view to drill into, so there the callback is the only behavior.

Drilling in moves the anchor date, never the window size: a `visibleDays={3}` schedule stays a
three-day schedule with the clicked day inside it — and the grid scrolls that day's column into
view if the band is wider than its container, so you land on the day you picked rather than
wherever the scroll happened to be. See
[Selection & navigating between views](#selection--navigating-between-views).

<Preview align="block" code="&#x22;<Schedule\n  data={events}\n  staticEvents={staticEvents}\n  defaultView=\&#x22;month\&#x22;\n  maxEventsPerDay={2}\n/>&#x22;">
  <ScheduleDrilldownDemo view="month" />
</Preview>

## Year view [#year-view]

A pure overview — no per-event rendering, ever. A neutral dot marks exactly one event, a capped
numeral ("9+") marks more; disabled days get a muted day-number only — no background tint here
unlike Month, since a flat fill would read as a smudge at Year's much smaller cell size.

Both granularities drill down, and both are gated by presence: a day cell becomes a button when
`onDayClick` is set, a mini-month header when `onMonthClick` is — otherwise each stays inert text,
since a focusable control whose activation does nothing is worse for a keyboard user than no
control.

The standalone view below has no view to drill *into*, so its handlers just report the click —
that is the whole API surface `Schedule.YearView` has on its own:

<Preview align="block" code="&#x22;<Schedule.YearView\n  data={events}\n  dayDisabled={disableWeekends()}\n  onDayClick={(date) => openDay(date)}\n  onMonthClick={(date) => openMonth(date)}\n/>&#x22;">
  <ScheduleYearDemo />
</Preview>

Under `Schedule` both are wired for you: a day goes to the days view on that date, a month header
to Month view for that month. Clicking a day also brings its column into view, exactly as
[drilling in from Month](#month-view) does — pick a day late in the week and watch the band land
on it rather than at the start of the week:

<Preview align="block" code="`<Schedule data={events} defaultView=&#x22;year&#x22; visibleDays={14} />`">
  <ScheduleDrilldownDemo view="year" />
</Preview>

## Agenda view [#agenda-view]

The same events as a chronological list rather than a grid, grouped by day then by exact start
time. Add `"agenda"` to `views` and it becomes an ordinary tab.

A list is read from its top, so the window **starts at the anchor date itself** and runs
`visibleDays` days (default `7`), like a Days window with `align="relative"`. Now puts today's
events first, Prev/Next step by the same span, and the title names the range on screen
("15 – 21 Sept 2026"). An entry can pin its own span — below, the agenda covers two weeks while
the week view beside it keeps seven days:

<Preview align="block" code="&#x22;<Schedule\n  data={events}\n  staticEvents={staticEvents}\n  views={['week', 'month', { id: 'agenda', visibleDays: 14 }] as const}\n  defaultView=\&#x22;agenda\&#x22;\n/>&#x22;">
  <ScheduleAgendaDemo />
</Preview>

Rows are real buttons: clicking one selects it, driven by the same `selected`/`onSelect` triple
the grid views use — so [Selection & navigating between views](#selection--navigating-between-views)
applies here unchanged, including the anchor moving to reveal a selection outside the current
window. A click opens **no details popover** here by default: a row already prints the title,
time and description, which is all the popover would repeat. Pass `withEventDetails` (or
`renderEventDetails`, for different content) to bring it back; `withSelection={false}` turns
selection off as usual.

**`renderEvent` makes a row inert.** Supply it and the row stops being a button, and
`withEventDetails` stops applying with it. That isn't an oversight: an override is free to render
its own buttons and links, and interactive content inside a `<button>` is invalid HTML that
browsers silently reparse. Selection is yours to wire from inside the override — the row still
carries `data-selected`, so it can at least be styled.

An **all-day** event reads `All day` in place of a clock time and shows no duration, since an
all-day event's `start`/`end` are day boundaries rather than meaningful clock readings. A
multi-day event is listed once, under its own real start date — which can head a group dated just
before the range in the title.

`Schedule.AgendaView` also mounts on its own, with its own optional Prev/Next
(`withPagination`, default `true` standalone) and Now button (`withNowButton`, default `false`):

<Preview align="block" code="&#x22;<Schedule.AgendaView\n  data={events}\n  staticEvents={staticEvents}\n  onSelect={(event) => setCurrent(event)}\n/>&#x22;">
  <ScheduleAgendaDemo />
</Preview>

## Time zones [#time-zones]

`timeZone` (or an enclosing [`IntlProvider`](/docs/intl)) sets the clock the whole
schedule reads, not just its day boundaries. Below, the events are 09:00, 11:00 and 14:30 in New
York: whatever zone you are reading from, the hour axis places them there, the chips and agenda
rows print those times, and the details popover — opened from a chip or from an agenda row —
agrees with the surface it came from.

<Preview align="block" code="&#x22;<Schedule\n  data={events}\n  timeZone=\&#x22;America/New_York\&#x22;\n  views={['week', 'agenda'] as const}\n  withEventDetails\n/>&#x22;">
  <ScheduleTimeZoneDemo />
</Preview>

## Next up [#next-up]

`Schedule.NextUp` renders exactly the same list as [the agenda view](#agenda-view) over a
different window: everything from now on, unbounded, rather than a span of days from a date. It is
grouped the same way — by day (Today/Tomorrow, then a weekday+date for anything further out, all
`Intl`-derived, no translation key needed for any of it) then by exact start time.

It is deliberately **not** a switchable view, and the reason is the window: with no anchor date
there is nothing for Prev/Next to page and nothing for the title to name. That makes it the
standalone preset — a sidebar or dashboard "what's next" panel — rather than a fourth tab. Its
rows are plain text, not buttons, so a `renderEvent` override is free to put its own controls in
them; reach for `Schedule.AgendaView` when you want selection.

An **all-day** event reads `All day` in place of a clock time and shows no duration, the same as
in the agenda view — its `start`/`end` are day boundaries, not clock readings.

An event stays listed until `pastEventsOffset` minutes after its own `end` (default `30`), and the
whole list is live-ticking — which events show, and their day grouping, re-derive automatically as
real time passes, the same clock primitive the now-cursor uses.

Events sharing an identical start time stack under one shared time label, each keeping its own
duration underneath it (dimmed, one per stacked event, row-aligned to that event's own content) so
they can still be told apart — below, "Evaluation: libft" and "Peer correction: push\_swap" both
start at the same instant and share one time slot. `Schedule.NextUp` itself has no built-in
card/border — it's a bare list meant to sit inside whatever surface the consumer already has,
`Card` here.

<Preview align="block" code="&#x22;<Card>\n  <Schedule.NextUp data={events} />\n</Card>&#x22;">
  <ScheduleNextUpDemo />
</Preview>

`renderEvent` fully replaces one event's own row content for a typesafe custom payload — narrowed
against whatever generic payload type the `data` array itself carries, the same bare-generic
pattern `renderEventDetails` uses. The time label and each event's own duration both stay owned by
`Schedule.NextUp` itself and always render, the same as the built-in Day/Week grid's own event
time never depends on `renderEventDetails` — only what renders *inside* one event's row (next to
its now-untouched duration) is up to you. Its `state` argument's `isNext` flag marks the single
soonest event in the whole list.

<Preview align="block" code="&#x22;<Card>\n  <Schedule.NextUp\n    data={events}\n    renderEvent={(event, { isNext }) => (\n      <div className=\&#x22;flex min-w-0 flex-1 items-center justify-between gap-2\&#x22;>\n        <div className=\&#x22;min-w-0\&#x22;>\n          <p className=\&#x22;truncate font-semibold text-gray-light-900 text-sm dark:text-gray-dark-25\&#x22;>\n            {event.title}\n          </p>\n          <p className=\&#x22;truncate text-gray-light-500 text-xs dark:text-gray-dark-400\&#x22;>\n            {event.payload?.room}\n          </p>\n        </div>\n        {isNext ? <Badge size=\&#x22;sm\&#x22;>Up next</Badge> : null}\n        <Button size=\&#x22;xs\&#x22;>Action</Button>\n      </div>\n    )}\n  />\n</Card>&#x22;">
  <ScheduleNextUpRenderEventDemo />
</Preview>

<TypeTable
  type="{
  data: {
    type: &#x22;ScheduleAnyEvent[]&#x22;,
    description:
      &#x22;Both interactive and read-only events, merged — NextUp never drags, resizes, or removes anything.&#x22;,
  },
  now: {
    type: &#x22;Date&#x22;,
    description: &#x22;Override for tests — freezes the live tick entirely.&#x22;,
  },
  pastEventsOffset: {
    type: &#x22;number&#x22;,
    default: &#x22;30&#x22;,
    description: &#x22;Minutes an event stays listed past its own end.&#x22;,
  },
  locale: { type: &#x22;string&#x22;, description: &#x22;Overrides the IntlProvider locale (default \&#x22;en-US\&#x22;).&#x22; },
  timeZone: { type: &#x22;string&#x22;, description: &#x22;Overrides the IntlProvider time zone (default: the browser's own).&#x22; },
  translations: {
    type: &#x22;{ empty?: string; allDay?: string }&#x22;,
    description:
      &#x22;Today/Tomorrow/date-group labels are fully Intl-derived, so only these two apply here.&#x22;,
  },
  empty: {
    type: &#x22;ReactNode&#x22;,
    description: 'Full override for the &#x22;nothing upcoming&#x22; state.',
  },
  renderEvent: {
    type: &#x22;(event: ScheduleAnyEvent, state: { isNext: boolean }) => ReactNode&#x22;,
    description:
      &#x22;Overrides one event's own row content — the time label and each event's own duration always render regardless. Absent: icon + bold title + muted description.&#x22;,
  },
  classNames: {
    type: &#x22;{ dayGroup?, dayLabel?, timeGroup?, time?, event?, icon?, title?, description?, duration? }&#x22;,
  },
}"
/>

## Toolbar, composed by hand [#toolbar-composed-by-hand]

`Schedule.Toolbar` is a separate, independently composable part — not baked into each view, so
an embedded `DaysView` with no chrome needs no `withToolbar={false}` escape hatch. Each part
(`Title`, `PrevTrigger`, `NextTrigger`, `NowTrigger`, `ViewSwitcher`) is fully prop-driven, no
shared context — the same explicit wiring `Schedule` itself does internally to build its own
chrome.

<Preview align="block" code="&#x22;function CustomSchedule() {\n  const [date, setDate] = useState(new Date());\n  return (\n    <>\n      <Schedule.Toolbar>\n        <Schedule.Toolbar.Title view=\&#x22;days\&#x22; date={date} />\n        <div className=\&#x22;flex items-center gap-1\&#x22;>\n          <Schedule.Toolbar.NowTrigger onDateChange={setDate} />\n          <Schedule.Toolbar.PrevTrigger view=\&#x22;days\&#x22; date={date} onDateChange={setDate} />\n          <Schedule.Toolbar.NextTrigger view=\&#x22;days\&#x22; date={date} onDateChange={setDate} />\n        </div>\n      </Schedule.Toolbar>\n      <Schedule.DaysView data={events} date={date} onDateChange={setDate} withPagination={false} />\n    </>\n  );\n}&#x22;">
  <ScheduleToolbarCompoundDemo />
</Preview>

Every standalone view (`DaysView`/`MonthView`/`YearView`) also ships its own built-in
Prev/Next/Now chrome (`withPagination`/`withNowButton`) for the common case — reach for the
composed `Schedule.Toolbar` parts only when the default layout doesn't fit.

In Days view specifically, clicking Now also scrolls its timed grid so the now-line lands centered
in the viewport (not flush under the sticky header) — `Schedule.DaysView`'s own `gridRef` prop
exposes that same scrollable root for a fully custom toolbar composed by hand, like the one above.

## Translations [#translations]

One `translations` prop, prop-only — no context, no bundled locale packs. Weekday/month names
and AM/PM are Intl-derivable from `locale`/`timeZone` and need no key at all.

<Preview align="block" code="&#x22;<Schedule\n  data={events}\n  locale=\&#x22;fr-FR\&#x22;\n  translations={{ now: 'Maintenant', previous: 'Précédent', next: 'Suivant', days: 'Jours', month: 'Mois', year: 'Année', agenda: 'Agenda' }}\n/>&#x22;">
  <ScheduleTranslationsDemo />
</Preview>

## Touch & mobile [#touch--mobile]

Mobile keeps the same grid and the same gestures as desktop — no separate read-only fallback.
Resize grips get a bigger *invisible* hit area under `pointer: coarse` (44×44px, per WCAG 2.5.5)
without becoming clumsily oversized for a mouse in a dense week grid — a CSS media query is the
only thing that ever changes hit-area size, never a JS pointer-type check.

The remove button works differently, because it has nowhere to grow into: it sits *inside* the
chip, and a chip is only as tall as its event. Its hit area is a constant 24px wide and grows to
24×24 once the chip itself is tall enough to contain that (roughly a 33-minute event at the default
density), but never further — the strips immediately above and below a chip already belong to its
resize grips, and a remove target overhanging into them would take a destructive action's hit box
across a neighbouring event. On a shorter chip the inline button stays under the 24×24 minimum, and
the full-size control in the [details popover](#event--day-details) is the accessible path instead
(WCAG 2.2 SC 2.5.8's "bypass" exception: the same function, reachable from a control that does meet
the minimum).

## Dashboard example [#dashboard-example]

A more realistic screen: `Schedule` as the main content of an [`AppShell`](/docs/app-shell), with
`editable` and `preventOverlap` fully on, and an `Aside` that swaps between `Schedule.NextUp`
(nothing selected — mixing in `staticEvents`, a companion agenda for both interactive and
read-only events at once) and a full edit form (an event selected) — title, description,
start/end time, room, a color swatch, and per-event permission toggles, all actually mutating the
grid live. `visibleDays={5} align="relative"` shows the rolling window covered in
[Days view window](#days-view-window) above.

<Preview align="block" code="&#x22;<AppShell>\n  <AppShell.Main>\n    <Schedule\n      data={events}\n      staticEvents={staticEvents}\n      startHour={8}\n      endHour={18}\n      visibleDays={5}\n      align=\&#x22;relative\&#x22;\n      editable={['create', 'drag', 'resize', 'remove']}\n      preventOverlap\n      selected={selected?.id ?? null}\n      onSelect={setSelected}\n      withEventDetails={false}\n      onEventChange={(event, next) => updateEvent(event.id, next)}\n      onEventRemove={(event) => removeEvent(event.id)}\n      onEventCreate={(ranges) => createEvents(ranges)}\n    />\n  </AppShell.Main>\n  <AppShell.Aside>\n    {selected ? (\n      <EventEditForm event={selected} />\n    ) : (\n      <Schedule.NextUp data={[...events, ...staticEvents]} />\n    )}\n  </AppShell.Aside>\n</AppShell>&#x22;">
  <ScheduleDashboardExample />
</Preview>

Click an event to open its edit form in the Aside — change its title, description, time, room, or
color, and the grid updates live; toggle its drag/resize/remove permissions individually; remove
it entirely. Click empty grid space and drag to create a new event, which opens straight into the
same form. Deselect (the back arrow) to return to the `Schedule.NextUp` agenda.

<Button endSlot="<ArrowUpRight />">
  <Link href="/examples/schedule" target="_blank" rel="noopener noreferrer">
    View full-page example
  </Link>
</Button>

## API [#api]

<TypeTable
  type="{
  data: {
    type: &#x22;ScheduleEvent[]&#x22;,
    description:
      &#x22;Draggable, resizable, closable events. Never mutated internally.&#x22;,
  },
  staticEvents: {
    type: &#x22;ScheduleStaticEvent[]&#x22;,
    default: &#x22;[]&#x22;,
    description: &#x22;Read-only, still-clickable events.&#x22;,
  },
  view: {
    type: &#x22;ScheduleViewId<V>&#x22;,
    description:
      'Controlled active entry id. Under the default views that is &#x22;days&#x22; | &#x22;month&#x22; | &#x22;year&#x22;; with your own `views` it is your exact ids.',
  },
  defaultView: {
    type: &#x22;ScheduleViewId<V>&#x22;,
    default: &#x22;views[0]&#x22;,
    description: &#x22;Uncontrolled initial entry id.&#x22;,
  },
  onViewChange: { type: &#x22;(view: ScheduleViewId<V>) => void&#x22; },
  date: { type: &#x22;Date&#x22;, description: &#x22;Controlled anchor date.&#x22; },
  defaultDate: { type: &#x22;Date&#x22;, default: &#x22;new Date()&#x22; },
  onDateChange: { type: &#x22;(date: Date) => void&#x22; },
  visibleDays: {
    type: &#x22;number&#x22;,
    default: &#x22;7&#x22;,
    description:
      &#x22;**Days · Agenda only.** The window size, in days. In Days, the number of day columns (1 reproduces the old DayView, 7 the old WeekView); in Agenda, how many days the list covers from the anchor date.&#x22;,
  },
  startHour: { type: &#x22;number&#x22;, default: &#x22;0&#x22;, description: &#x22;**Days only.**&#x22; },
  endHour: { type: &#x22;number&#x22;, default: &#x22;24&#x22;, description: &#x22;**Days only.**&#x22; },
  slotDuration: {
    type: &#x22;15 | 30 | 60&#x22;,
    default: &#x22;15&#x22;,
    description:
      &#x22;**Days only.** Minutes — grid spacing AND the drag/resize/create snap increment.&#x22;,
  },
  minDuration: {
    type: &#x22;SlotMultiple<slotDuration>&#x22;,
    default: &#x22;slotDuration&#x22;,
    description:
      &#x22;**Days only.** Minutes — the shortest event a create or resize may produce. A whole number of slots, checked at compile time.&#x22;,
  },
  maxDuration: {
    type: &#x22;SlotMultiple<slotDuration>&#x22;,
    description:
      &#x22;**Days only.** Minutes — the longest event a create or resize may produce. A whole number of slots, checked at compile time, and never below `minDuration`; an event already longer can shrink but not grow. Default: no cap.&#x22;,
  },
  resizeCollision: {
    type: '&#x22;none&#x22; | &#x22;push&#x22; | &#x22;swap&#x22;',
    default: '&#x22;none&#x22;',
    description:
      &#x22;**Days only.** What a resize grip does on reaching the event's other edge: stop `minDuration` short, push that edge along, or swap the two. Resize only; a move and a create have no second stop to collide with.&#x22;,
  },
  hourHeight: {
    type: 'number',
    description: 'Days view only — row density in px per hour. Default scales with slotDuration (a finer slot gets a taller default hour, clamped to a legible range); an explicit value always overrides.',
  },
  slotDisabled: {
    type: &#x22;(range: { start: Date; end: Date }, timeZone?: string) => boolean&#x22;,
    description:
      &#x22;**Days only.** See disableOutsideHours/disableWeekends/disableDateRanges. Receives the schedule's own timeZone; a multi-day range is judged per calendar day.&#x22;,
  },
  dayDisabled: {
    type: &#x22;(date: Date, timeZone?: string) => boolean&#x22;,
    description:
      &#x22;**Month · Year only.** Day-granularity, pair with @42-central/ui-react/date-picker predicates. Receives the view's own timeZone.&#x22;,
  },
  maxEventsPerDay: {
    type: &#x22;number&#x22;,
    default: &#x22;3&#x22;,
    description:
      &#x22;**Month only.** Chips per day cell before the rest collapse into a +N chip. Also sets each row's height floor, so tune it to the height the cells actually get: too high for the container and the cell clips the last pill along with the +N that was accounting for it.&#x22;,
  },
  now: {
    type: &#x22;Date&#x22;,
    description: &#x22;Override for tests — freezes the now-cursor.&#x22;,
  },
  withNowIndicator: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true&#x22;,
    description: &#x22;**Days only.** The ticking now-line across the timed grid.&#x22;,
  },
  withNowButton: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true on Schedule, false standalone&#x22;,
  },
  align: {
    type: '&#x22;sun&#x22; | &#x22;mon&#x22; | ... | &#x22;relative&#x22;',
    description:
      'Locale default when omitted. &#x22;relative&#x22; (Schedule/DaysView only) starts the window at the anchor date itself, no calendar-week snap — forwarded into MonthView\'s own startOfWeek (FirstDayOfWeek-only, no &#x22;relative&#x22;) when narrowed.',
  },
  views: {
    type: &#x22;readonly ScheduleViewOption[]&#x22;,
    default: '[&#x22;days&#x22;, &#x22;month&#x22;, &#x22;year&#x22;]',
    description:
      'Which entries the view switcher offers, in order. Shorthands &#x22;day&#x22; (1 day) / &#x22;week&#x22; (7) / &#x22;days&#x22; (your visibleDays) / &#x22;month&#x22; / &#x22;year&#x22; / &#x22;agenda&#x22; (your visibleDays from the anchor date, as a list), or { id, view, visibleDays, label } objects. Schedule is generic over this array, so view/defaultView/onViewChange carry your exact ids — hoist it with `as const` to keep that narrowing. No switcher renders below two entries.',
  },
  selected: {
    type: &#x22;string | readonly string[] | null&#x22;,
    description:
      &#x22;**Days · Month · Agenda only.** The selected event's id, or several ids for one thing drawn as several chips. Every listed chip is painted selected; the first id is the anchor, the one whose details popover opens, whose +N row is expanded, and that a selection change reveals. Also accepts defaultSelected.&#x22;,
  },
  defaultSelected: {
    type: &#x22;string | readonly string[] | null&#x22;,
    description:
      &#x22;**Days · Month · Agenda only.** Uncontrolled initial selection. `null` (not `undefined`) is the nothing-selected sentinel.&#x22;,
  },
  onSelect: {
    type: &#x22;(event: ScheduleAnyEvent | null) => void&#x22;,
    description:
      &#x22;**Days · Month · Agenda only.** Fires with the whole event when one is selected, and with null when the selection is cleared (a plain click on empty grid space, or dismissing the details popover). Replaces the former onEventClick.&#x22;,
  },
  withSelection: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true&#x22;,
    description:
      &#x22;**Days · Month · Agenda only.** False turns selection off entirely: nothing is styled selected, no details popover opens, and onSelect never fires.&#x22;,
  },
  withEventDetails: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true&#x22;,
    description:
      &#x22;**Days · Month · Agenda only.** Whether the built-in event-details popover mounts for the selected event. Defaults to false in Agenda, whose rows already show the details, unless renderEventDetails is passed. Set false when you render the detail yourself; use renderEventDetails to keep the popover and replace its contents.&#x22;,
  },
  onGridClick: {
    type: &#x22;(date: Date) => void&#x22;,
    description:
      &#x22;**Days only.** A plain click (no discernible drag) on empty day-column space, reporting the instant pressed, snapped to the grid. Fires alongside onSelect(null) but is not a synonym for it — this fires whether or not anything was selected. Formerly onEventDismiss.&#x22;,
  },
  renderEventDetails: {
    type: &#x22;(event: ScheduleAnyEvent) => ReactNode&#x22;,
    description:
      &#x22;**Days · Month · Agenda only.** Overrides the built-in event-details popover's content. Not on YearView, which has neither.&#x22;,
  },
  renderOverflowDetails: {
    type: &#x22;(events: ScheduleAnyEvent[], ctx: { date: Date; source: \&#x22;timed\&#x22; | \&#x22;allDay\&#x22; | \&#x22;month\&#x22;; onSelectEvent: (event: ScheduleAnyEvent) => void }) => ReactNode&#x22;,
    description:
      &#x22;**Days · Month only.** Overrides the \&#x22;+N\&#x22; disclosure's list, keeping the popover shell; ctx.onSelectEvent still drives the built-in list→detail swap. Replaces the former renderDayDetails, which was Month-only and took (date, events, ctx).&#x22;,
  },
  onEventChange: {
    type: '(event: ScheduleEvent, next: { start: Date; end: Date }, reason: &#x22;drag&#x22; | &#x22;resize&#x22;) => void',
    description:
      &#x22;**Days only.** Fires once a drag/resize permitted by editable actually commits.&#x22;,
  },
  onEventRemove: {
    type: &#x22;(event: ScheduleEvent) => void&#x22;,
    description:
      &#x22;**Days only.** Fires once a remove permitted by editable is actually clicked.&#x22;,
  },
  onEventCreate: {
    type: &#x22;(ranges: { start: Date; end: Date }[]) => void&#x22;,
    description:
      &#x22;**Days only.** Fires once a create-drag permitted by editable actually commits, with every range it created: one for a left drag, one per day for a right drag (rejected days skipped).&#x22;,
  },
  editable: {
    type: 'boolean | (&#x22;create&#x22; | &#x22;drag&#x22; | &#x22;resize&#x22; | &#x22;remove&#x22;)[]',
    description:
      'Days view only — which gestures are possible at all. Default: none (fully read-only). A kind:&#x22;event&#x22; item can further narrow this per-event via its own editable field — see Edit permissions.',
  },
  preventOverlap: {
    type: 'boolean | { gestures?: boolean | (&#x22;create&#x22; | &#x22;drag&#x22; | &#x22;resize&#x22;)[]; against?: &#x22;event&#x22; | &#x22;static&#x22; | &#x22;both&#x22; }',
    description:
      'Days view only — keeps a created/dragged/resized event from landing on another one. true (or {}) enforces it for all gestures against both event kinds; pass an object to scope gestures and/or against (default &#x22;both&#x22;). Default: none (today\'s side-by-side layout is unaffected).',
  },
  maxOverlapColumns: {
    type: &#x22;number&#x22;,
    default: &#x22;4&#x22;,
    description:
      'Days view only — the TOTAL column count for an overlapping cluster, &#x22;+N&#x22; chip included (never +1 beyond it). Pass Infinity to restore unbounded columns.',
  },
  maxAllDayRows: {
    type: &#x22;number&#x22;,
    default: &#x22;2&#x22;,
    description:
      'Days view only — all-day lanes before further concurrent all-day events collapse into a per-day &#x22;+N&#x22;.',
  },
  empty: {
    type: &#x22;ReactNode&#x22;,
    description:
      &#x22;**Days · Agenda only.** Replaces the whole nothing-here placeholder; `translations.empty` is its default copy. Month and Year always draw a calendar, so they have no empty state. Pass `null` to suppress it.&#x22;,
  },
  onOverflowClick: {
    type: '(events: ScheduleAnyEvent[], ctx: { date: Date; source: &#x22;timed&#x22; | &#x22;allDay&#x22; | &#x22;month&#x22; }) => void',
    description:
      &#x22;**Days · Month only.** Fires when a \&#x22;+N\&#x22; is activated, with the events it hides and which surface fired. Reports only — it no longer suppresses the built-in popover by being present; use withOverflowDetails for that.&#x22;,
  },
  withOverflowDetails: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true&#x22;,
    description:
      &#x22;**Days · Month only.** Whether the built-in \&#x22;+N\&#x22; disclosure popover mounts. The twin of withEventDetails one level up: false leaves the \&#x22;+N\&#x22; a plain button that only fires onOverflowClick.&#x22;,
  },
  revealBehavior: {
    type: '&#x22;auto&#x22; | &#x22;instant&#x22; | &#x22;smooth&#x22;',
    default: '&#x22;auto&#x22;',
    description:
      &#x22;**Days only.** How a reveal scrolls the days grid — the day brought into view by navigating to it, and the chip brought into view by selecting it. Defaults to no animation; a reveal is a side effect of going somewhere, not a gesture, so an animated one reads as drift. The Now button is unaffected and keeps animating. \&#x22;smooth\&#x22; still yields to prefers-reduced-motion.&#x22;,
  },
  onDayClick: {
    type: &#x22;(date: Date) => void&#x22;,
    description:
      &#x22;**Days · Month · Year only.** Day-cell / day-header click, all three views. On Schedule/Schedule.Root it defaults to drilling into the days view on that date; passing your own replaces that.&#x22;,
  },
  onMonthClick: {
    type: &#x22;(date: Date) => void&#x22;,
    description:
      &#x22;**Year only.** Mini-month header click, with that month's start. On Schedule/Schedule.Root it defaults to drilling into Month view for that month; passing your own replaces that. Absent, the header is inert text.&#x22;,
  },
  locale: { type: &#x22;string&#x22;, description: &#x22;Overrides the IntlProvider locale (default \&#x22;en-US\&#x22;).&#x22; },
  timeZone: { type: &#x22;string&#x22;, description: &#x22;Overrides the IntlProvider time zone (default: the browser's own).&#x22; },
  renderEvent: {
    type: &#x22;(event: ScheduleAnyEvent, state: { isNext: boolean }) => ReactNode&#x22;,
    description:
      &#x22;**Agenda only.** Overrides one row's content. Supplying it makes the row inert (no button, no details popover), since an override may contain its own controls. `isNext` is always false here; it is a Schedule.NextUp concept.&#x22;,
  },
  translations: { type: &#x22;ScheduleTranslations&#x22; },
  classNames: {
    type: &#x22;{ toolbar?, grid?, event?, disabledOverlay?, nowIndicator?, createDraft?, eventDetailsPopover?, overflow?, agenda? }&#x22;,
  },
}"
/>

## Accessibility [#accessibility]

* Each resize handle is `role="separator"` with `aria-orientation="horizontal"`,
  `aria-valuenow`/`min`/`max` in minutes-since-midnight, and `aria-valuetext` as a formatted clock
  time.
* `ArrowUp`/`ArrowDown` on a focused resize handle nudge by `slotDuration` (`Shift` ×4, `Alt` = 1
  minute); the same keys on the chip body itself move the whole event.
* `ArrowLeft`/`ArrowRight` on the chip body move the event one calendar day — the keyboard
  equivalent of dragging it sideways. No modifier variants: one press is one day. This is the only
  way to change an event's day without a pointer, so it is not optional polish.
* Note that screen readers in browse mode (NVDA, JAWS) intercept plain arrow keys before they reach
  a focused element, so the arrow-key move and resize are unreachable there. This is a documented
  limitation rather than an oversight — the alternative, `role="application"`, would suppress browse
  mode across the whole grid and cost far more than it buys.
* Removing an event is reachable two ways: the inline `(x)` on the chip, and a full-size **Remove
  event** button in the event's details popover, which every chip opens from its whole body. The
  second exists because the inline button cannot meet WCAG 2.2 SC 2.5.8's 24×24 target minimum on a
  short chip — see [Touch & mobile](#touch--mobile). Both appear only when `editable` grants
  `"remove"`, and both go through `onEventRemove`.
* A focused resize handle shows a visible focus indicator — its (otherwise invisible) hit area
  itself stays unringed, and the small dash mark inside it recolors and thickens instead, the same
  "ring the visible mark, not the oversized hit box" treatment `DataTable`'s column-resize handle
  uses.
* Month's disabled day cells carry `data-disabled`. The disabled-slot overlay itself is
  `aria-hidden` — its background tint and `cursor-not-allowed` are both visual only, the actual
  enforcement happens on commit, not from this element.
* `Schedule.Toolbar`'s `ViewSwitcher` is built on [`Tabs`](/docs/tabs)' machine — arrow keys move
  between whichever entries `views` offers, and the active one carries `aria-selected`.
* An agenda row's accessible name carries the time as well as the title ("Standup, 09:00 – 10:00",
  or the date for an all-day event), because the visible time sits in a sibling column and a
  same-start stack prints it only once for the whole slot. A row is a button only when it is
  actually selectable and its content isn't overridden — a focusable control whose activation does
  nothing is worse for a keyboard user than plain text, and an override may carry controls of its
  own that must not nest inside a button.
* Click-drag-to-create has no keyboard equivalent — a documented limitation, not an oversight. A
  keyboard-only user creates events through their own "Add event" UI instead, calling the same
  code a successful `onEventCreate` would.
