Scheduler

Scheduler

A themeable, accessible day/week/month calendar for events and appointments — one headless core, many views, client- or server-driven, in React (@bpdm/scheduler) and Angular (@bpdm/ng-scheduler).

The Scheduler lays events onto a time grid or month grid. Overlapping events cascade automatically, the current time is marked, and every view is driven by one framework-agnostic core (@bpdm/scheduler-core). It reads events from a data source — an in-memory array on the client, or your own source that fetches a visible range from a server. Zero runtime dependencies in the core; each binding is a few kilobytes.

Ships for React (@bpdm/scheduler) and Angular (@bpdm/ng-scheduler) — the same headless core, so the model, views, options and responsive behaviour match. The tabs below show each. Still pre-1.0: minor versions may adjust the API.

What you can configure

Everything below is built in — you opt into the interactive bits with a prop:

  • Data — a client array (events) or a server source (dataSource, range-based). → Quick start · Data source
  • Views — defaultView, which views the toolbar offers (views), the visible hours (dayStartHour / dayEndHour / scrollToHour), first day of week (weekStartsOn), viewport height (maxHeight), row density (hourHeight), month chips per day (monthMaxChips). → Views
  • Creating events — click an empty slot to open your own form (renderCreateForm), persist with onCreate; tune the proposed slot (createDuration, snapMinutes, createDefaultHour) or just get the raw slot (onSelectSlot). → Creating events
  • Opening events — a built-in detail dialog by default, or take over with onEventClick. The month “+N more” day peek and its ‹ Back navigation are automatic. → Event details · Month
  • Editing events — set onEventChange to enable drag-to-move / drag-to-resize (keyboard-operable + announced); editable toggles it. → Editing events
  • Internationalization — every string via messages, dates/times via locale, RTL automatically. → Internationalization
  • Theming — the four @bpdm/tokens themes via data-theme; event colours via category. → Theming

See the full prop table for defaults and types.

Install

pnpm add @bpdm/scheduler

Import the stylesheet once (it reads the @bpdm/tokens CSS variables, so the four themes and RTL come for free):

// e.g. in your root layout or main entry
import '@bpdm/scheduler/styles.css';
pnpm add @bpdm/ng-scheduler

Add the stylesheet once in angular.json (or import it in your global styles.css):

{
  "styles": ["@bpdm/ng-scheduler/styles.css", "src/styles.css"]
}

Quick start — client-side events

Pass an array of events. Each event needs id, title, start and end; an optional category picks a themeable colour, and location / description show in the built-in detail dialog.

import { Scheduler, type CalendarEvent } from '@bpdm/scheduler';
import '@bpdm/scheduler/styles.css';

const events: CalendarEvent[] = [
  {
    id: '1',
    title: 'Sprint planning',
    start: new Date(2026, 6, 20, 10, 0),
    end: new Date(2026, 6, 20, 11, 30),
    category: 'amber',
    location: 'Room Aster',
    description: 'Plan the sprint backlog and lock the two-week goal.',
  },
  {
    id: '2',
    title: 'Design review',
    start: new Date(2026, 6, 21, 11, 0),
    end: new Date(2026, 6, 21, 12, 30),
    category: 'teal',
  },
  {
    id: '3',
    title: 'Standup',
    start: new Date(2026, 6, 22, 9, 30),
    end: new Date(2026, 6, 22, 9, 45),
    category: 'blue',
  },
];

export function TeamCalendar() {
  return (
    <Scheduler
      events={events}
      defaultView="week"
      onEventClick={(event) => console.log('clicked', event.id)}
    />
  );
}
import { Component } from '@angular/core';
import { BpdmScheduler, type CalendarEvent } from '@bpdm/ng-scheduler';

@Component({
  selector: 'app-team-calendar',
  standalone: true,
  imports: [BpdmScheduler],
  template: `
    <bpdm-scheduler
      [events]="events"
      defaultView="week"
      (eventClick)="onEventClick($event)"
    />
  `,
})
export class TeamCalendarComponent {
  events: CalendarEvent[] = [
    {
      id: '1',
      title: 'Sprint planning',
      start: new Date(2026, 6, 20, 10, 0),
      end: new Date(2026, 6, 20, 11, 30),
      category: 'amber',
      location: 'Room Aster',
      description: 'Plan the sprint backlog and lock the two-week goal.',
    },
    {
      id: '2',
      title: 'Design review',
      start: new Date(2026, 6, 21, 11, 0),
      end: new Date(2026, 6, 21, 12, 30),
      category: 'teal',
    },
    {
      id: '3',
      title: 'Standup',
      start: new Date(2026, 6, 22, 9, 30),
      end: new Date(2026, 6, 22, 9, 45),
      category: 'blue',
    },
  ];

  onEventClick(event: CalendarEvent) {
    console.log('clicked', event.id);
  }
}

Views

Set defaultView and choose which the toolbar offers via views. Navigate with Today / ‹ › — each step moves by the view's period (a day, a week, a month).

Day and Week

The time grid holds the whole day (00:00–24:00); it opens scrolled to scrollToHour (7 AM by default) and scrolls to reach early-morning or evening events. Overlapping events cascade — wider cards, offset and stacked, with the hovered one on top.

Month

The month grid spans only the weeks the month actually covers (5 or 6), with event chips per day and a “+N more” overflow. Scroll the grid up/down to move between months.

When a day has more events than fit, “+N more” opens a day peek — a scrollable popup listing every event on that day (its background is locked, so wheeling scrolls the list, not the page). Clicking an event there opens its detail dialog with a ‹ Back control that returns you to the list. All of this is built in and keyboard-accessible — nothing to wire up.

Responsive

The scheduler adapts to its own container's width — not just the browser window — entirely in CSS (container queries, zero JavaScript, SSR-safe), so it behaves the same on a phone as it does embedded in a narrow panel. Nothing to configure:

  • Day / week keep a usable column width and scroll horizontally rather than squishing to slivers; the time gutter and day headers stay pinned.
  • Month cells shorten and event chips collapse to dots on small widths — "+N more" still opens the day peek. With collapseToDayBelow set, a phone-width month also caps the dots (≤2) so cells stay compact.
  • Toolbar wraps the view switcher onto its own row, and touch pointers get larger hit areas.

A seven-column week is inherently cramped on a phone, so — like most calendar apps — you can have a week become a single day with a week strip below a width you choose with collapseToDayBelow. The strip shows the week's seven days (two-letter name + date, with a dot for days that have events); tap one to switch, or step a day at a time with ‹ ›. It is opt-in (off by default, zero cost), keeps the store on week (so the toolbar still reads Week and widening is seamless), and measures the scheduler's own container, not the window. The demos on this page use collapseToDayBelow={640}; narrow the page (or view it on a phone) to watch the compact week.

import { Scheduler } from '@bpdm/scheduler';
import '@bpdm/scheduler/styles.css';

// On a container narrower than 640px, "week" renders as a single day.
<Scheduler events={events} defaultView="week" collapseToDayBelow={640} />;
// <bpdm-scheduler [events]="events" defaultView="week" [collapseToDayBelow]="640" />

Data source — server-side events

events builds an in-memory source for you. For server data, pass a dataSource whose fetch returns the events for a visible range — it may return an array or a promise, so a real API call drops straight in. The scheduler re-fetches whenever the visible range changes, so the server only sends what's on screen. The same component handles both; nothing else changes.

import { Scheduler, type CalendarEvent, type DataSource } from '@bpdm/scheduler';
import '@bpdm/scheduler/styles.css';

// The API sends dates as ISO strings; revive them into Date objects on the way in.
interface EventRow {
  id: string;
  title: string;
  start: string;
  end: string;
  category?: string;
}

const remote: DataSource = {
  async fetch(range) {
    const params = new URLSearchParams({
      from: range.start.toISOString(),
      to: range.end.toISOString(),
    });
    const res = await fetch(`/api/events?${params}`);
    const rows: EventRow[] = await res.json();

    return rows.map(
      (row): CalendarEvent => ({
        ...row,
        start: new Date(row.start),
        end: new Date(row.end),
      }),
    );
  },
};

export function ServerCalendar() {
  return <Scheduler dataSource={remote} defaultView="week" />;
}
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { BpdmScheduler, type CalendarEvent, type DataSource } from '@bpdm/ng-scheduler';

interface EventRow {
  id: string;
  title: string;
  start: string;
  end: string;
  category?: string;
}

@Component({
  selector: 'app-server-calendar',
  standalone: true,
  imports: [BpdmScheduler],
  template: `<bpdm-scheduler [dataSource]="remote" defaultView="week" />`,
})
export class ServerCalendarComponent {
  private http = inject(HttpClient);

  remote: DataSource = {
    fetch: async (range) => {
      const rows = await firstValueFrom(
        this.http.get<EventRow[]>('/api/events', {
          params: {
            from: range.start.toISOString(),
            to: range.end.toISOString(),
          },
        }),
      );

      return rows.map(
        (row): CalendarEvent => ({
          ...row,
          start: new Date(row.start),
          end: new Date(row.end),
        }),
      );
    },
  };
}

Event details

By default, clicking an event opens a built-in detail dialog (title, time, location, description). Supply onEventClick to handle clicks yourself — the built-in dialog is then suppressed, so you can open your own editor or navigate.

import { useState } from 'react';
import { Scheduler, type CalendarEvent } from '@bpdm/scheduler';
import '@bpdm/scheduler/styles.css';

export function EditableCalendar({ events }: { events: CalendarEvent[] }) {
  const [selected, setSelected] = useState<CalendarEvent | null>(null);

  return (
    <>
      <Scheduler events={events} onEventClick={setSelected} />

      {selected && (
        <div role="dialog" aria-label={selected.title}>
          <h2>{selected.title}</h2>
          {/* your own editor, navigation, delete button, … */}
          <button onClick={() => setSelected(null)}>Close</button>
        </div>
      )}
    </>
  );
}
import { Component, signal } from '@angular/core';
import { BpdmScheduler, type CalendarEvent } from '@bpdm/ng-scheduler';

@Component({
  selector: 'app-editable-calendar',
  standalone: true,
  imports: [BpdmScheduler],
  template: `
    <bpdm-scheduler [events]="events" (eventClick)="selected.set($event)" />

    @if (selected(); as event) {
      <div role="dialog" [attr.aria-label]="event.title">
        <h2>{{ event.title }}</h2>
        <!-- your own editor, navigation, delete button, … -->
        <button (click)="selected.set(null)">Close</button>
      </div>
    }
  `,
})
export class EditableCalendarComponent {
  events: CalendarEvent[] = [];
  selected = signal<CalendarEvent | null>(null);
}

Creating events

The scheduler ships no form of its own — it stays dependency-free. Clicking an empty slot (a time in day/week, a day in month) hands you the clicked range; you render your own fields via renderCreateForm — any inputs, any component library — and onCreate receives the finished event, which appears immediately. The exact same wiring works client- or server-side.

Click an empty slot above to open the create popup. That form is built with @bpdm/ui controls — but the scheduler itself needs nothing; you could use plain inputs, as below.

Where does the time come from? A day/week click carries the exact clicked time (snapped to snapMinutes). A month cell has no time axis, so a click there proposes createDefaultHour (9 AM by default — set it to whatever you like). Either way, slot.start/slot.end are just defaults — add a time field to your form (a <select> below, the two dropdowns in the live demo above) and pass explicit start/end to submit to override them.

import { useState } from 'react';
import { Scheduler, type CalendarEvent, type CreateFormArgs } from '@bpdm/scheduler';
import '@bpdm/scheduler/styles.css';

// Your own form — plain inputs here; swap in any component library you like.
function CreateForm({ slot, submit, cancel }: CreateFormArgs) {
  const [title, setTitle] = useState('');
  // seed the time picker from the clicked slot (HH:MM)
  const [time, setTime] = useState(
    () => slot.start.toTimeString().slice(0, 5),
  );

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        if (!title.trim()) return;
        // override the slot's default time with the chosen one (1-hour event)
        const [h, m] = time.split(':').map(Number);
        const start = new Date(slot.start);
        start.setHours(h, m, 0, 0);
        const end = new Date(start.getTime() + 60 * 60 * 1000);
        submit({ title: title.trim(), start, end, category: 'violet' });
      }}
    >
      <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
      <input type="time" value={time} onChange={(e) => setTime(e.target.value)} />
      <button type="button" onClick={cancel}>Cancel</button>
      <button type="submit">Create</button>
    </form>
  );
}

export function EditableCalendar() {
  const [events, setEvents] = useState<CalendarEvent[]>([]);

  return (
    <Scheduler
      events={events}
      renderCreateForm={(args) => <CreateForm {...args} />}
      onCreate={(event) => setEvents((prev) => [...prev, event])}
      createDefaultHour={9} // month clicks propose 9 AM (day/week use the clicked time)
    />
  );
}

For server-side persistence, do the write in onCreate (e.g. via your dataSource.create); once it resolves the scheduler refetches the visible range, so the server's authoritative record (assigned id, normalized times) is what shows:

import { Scheduler, type CalendarEvent, type DataSource } from '@bpdm/scheduler';
import '@bpdm/scheduler/styles.css';

const remote: DataSource = {
  async fetch(range) {
    const params = new URLSearchParams({
      from: range.start.toISOString(),
      to: range.end.toISOString(),
    });
    const res = await fetch(`/api/events?${params}`);
    const rows: CalendarEvent[] = await res.json();
    return rows.map((r) => ({ ...r, start: new Date(r.start), end: new Date(r.end) }));
  },
  async create(event) {
    const res = await fetch('/api/events', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(event),
    });
    return res.json();
  },
};

export function ServerCalendar() {
  return (
    <Scheduler
      dataSource={remote}
      renderCreateForm={(args) => <CreateForm {...args} />}
      onCreate={(event) => remote.create?.(event)}
    />
  );
}

Prefer to own the whole popup? Skip renderCreateForm and use onSelectSlot(slot) to open your own dialog anywhere in your app.

import { Component, signal } from '@angular/core';
import { BpdmScheduler, type CalendarEvent } from '@bpdm/ng-scheduler';

@Component({
  selector: 'app-editable-calendar',
  standalone: true,
  imports: [BpdmScheduler],
  template: `
    <bpdm-scheduler [events]="events()" (create)="onCreate($event)">
      <ng-template #createForm let-slot="slot" let-submit="submit" let-cancel="cancel">
        <!-- your own fields — call submit({ title, location, … }) or cancel() -->
      </ng-template>
    </bpdm-scheduler>
  `,
})
export class EditableCalendarComponent {
  events = signal<CalendarEvent[]>([]);

  onCreate(event: CalendarEvent) {
    this.events.update((prev) => [...prev, event]);
  }
}

Editing events

Pass onEventChange and events become draggable everywhere: in day/week drag the body to move (snapped to snapMinutes; in week view, drag across days too) and the bottom edge to resize; in month view drag a chip to another day. On drop you get the updated event to persist; the grid refetches after, so a client array or a server dataSource.update both work.

import { useState } from 'react';
import { Scheduler, type CalendarEvent } from '@bpdm/scheduler';
import '@bpdm/scheduler/styles.css';

export function EditableCalendar({ initial }: { initial: CalendarEvent[] }) {
  const [events, setEvents] = useState(initial);

  return (
    <Scheduler
      events={events}
      onEventChange={(changed) =>
        setEvents((prev) => prev.map((e) => (e.id === changed.id ? changed : e)))
      }
    />
  );
}
import { Component, signal } from '@angular/core';
import { BpdmScheduler, type CalendarEvent } from '@bpdm/ng-scheduler';

@Component({
  selector: 'app-editable-calendar',
  standalone: true,
  imports: [BpdmScheduler],
  template: `<bpdm-scheduler [events]="events()" (eventChange)="onChange($event)" />`,
})
export class EditableCalendarComponent {
  events = signal<CalendarEvent[]>([]);
  onChange(changed: CalendarEvent) {
    this.events.update((prev) => prev.map((e) => (e.id === changed.id ? changed : e)));
  }
}

Fully keyboard-operable (no mouse required) via a grab mode, so the arrow keys never fight grid navigation. Focus an event and press Space to pick it up (announced), then:

  • day/week — ↑ / ↓ move its time, ← / → move it to another day, Shift + ↑ / ↓ resize it
  • month — ← / → move by a day, ↑ / ↓ by a week

Press Space (or Enter) to drop, or Escape to cancel. Enter on an event that isn't grabbed opens it. Each step snaps and is announced to screen readers — satisfying WCAG 2.1.1 (Keyboard) and 2.5.7 (Dragging Movements).

Theming

The scheduler is styled entirely with @bpdm/tokens CSS variables, so it adopts whichever of the four themes (paper, mist, charcoal, slate) is active on an ancestor via data-theme — no configuration. Event categories (amber, violet, teal, blue, rose) mix with the surface colour, so they read on light and dark. Try the theme switcher at the top of this page.

Internationalization

Every user-facing string is overridable, dates and times follow a locale, and the layout is fully RTL via logical CSS.

import { Scheduler, type CalendarEvent } from '@bpdm/scheduler';
import '@bpdm/scheduler/styles.css';

const events: CalendarEvent[] = [
  {
    id: '1',
    title: 'Sprintplanung',
    start: new Date(2026, 6, 20, 10, 0),
    end: new Date(2026, 6, 20, 11, 30),
    category: 'amber',
  },
];

export function GermanCalendar() {
  return (
    <Scheduler
      events={events}
      locale="de-DE"
      weekStartsOn={1}
      messages={{
        today: 'Heute',
        previous: 'Zurück',
        next: 'Weiter',
        day: 'Tag',
        week: 'Woche',
        month: 'Monat',
        close: 'Schließen',
      }}
    />
  );
}

locale drives day names, dates and the 12/24-hour clock; messages overrides the labels. In an RTL document (dir="rtl") the grid, columns and dialog mirror automatically.

Every string is translatable — not only the visible ones but also the assistive-tech labels and announcements, so a localized calendar is fully localized for screen-reader users too. Notable keys beyond the obvious today/day/week/month:

KeyUsed for
viewLabelaria-label of the view switcher
gridLabel / monthLabelaria-label of the time grid / month grid
allDayan all-day event's time slot
more / showAllthe "+N more" chip text / its aria-label (showAll interpolates {count})
grabbed / droppedannounced when a keyboard drag picks up / releases an event
movedTo / resizedToannounced (with the new time) after a keyboard move / resize
eventAdjustablearia-roledescription of an editable event

messages is a partial override — anything you omit falls back to the built-in English default, so you only supply the strings you translate.

import { Component } from '@angular/core';
import { BpdmScheduler, type CalendarEvent } from '@bpdm/ng-scheduler';

@Component({
  selector: 'app-german-calendar',
  standalone: true,
  imports: [BpdmScheduler],
  template: `
    <bpdm-scheduler
      [events]="events"
      locale="de-DE"
      [weekStartsOn]="1"
      [messages]="messages"
    />
  `,
})
export class GermanCalendarComponent {
  events: CalendarEvent[] = [
    {
      id: '1',
      title: 'Sprintplanung',
      start: new Date(2026, 6, 20, 10, 0),
      end: new Date(2026, 6, 20, 11, 30),
      category: 'amber',
    },
  ];

  messages = {
    today: 'Heute',
    previous: 'Zurück',
    next: 'Weiter',
    day: 'Tag',
    week: 'Woche',
    month: 'Monat',
    close: 'Schließen',
  };
}

Accessibility

Built to WCAG 2.1 AA. What that means concretely:

  • Structure (1.3.1 Info & Relationships). The grid uses ARIA grid / row / gridcell roles; the view switcher is a tablist; the detail dialog is a labelled role="dialog" with aria-modal. Every interactive label is driven by messages, so it localizes with the rest of the UI.
  • Name, role, value (4.1.2). Every event and chip is a real <button>; editable events carry aria-roledescription and aria-keyshortcuts.
  • Keyboard (2.1.1) — no mouse required, and no trap (2.1.2). Full editing runs through a grab mode: focus an event, Space to pick it up, arrow keys to move (↑/↓ time, ←/→ day; month: ←/→ day, ↑/↓ week), Shift+arrows to resize, Space/Enter to drop, Escape to cancel. Enter on an ungrabbed event opens it. Dialogs close on Escape and take/restore focus. Arrow keys are inert until an event is grabbed, so they never fight grid navigation.
  • Dragging movements (2.5.7). The keyboard grab mode is a complete, single-pointer-free alternative to drag-and-drop.
  • Status messages (4.1.3). Moves, resizes and pick-up/drop are announced through a polite role="status" live region — nothing relies on seeing the drag.
  • Focus visible (2.4.7). Every control has a visible focus ring; after a keyboard move the moved event keeps focus so you can keep going.
  • Contrast (1.4.3 / 1.4.11). Colors come from @bpdm/tokens, which ship AA-contrast pairs across all four themes.
  • Reflow & motion (1.4.10 / 2.3.3). Layout is responsive, and all motion respects prefers-reduced-motion.

Performance & scale

  • Range-based data. Only the visible window is ever fetched and laid out. A dataSource receives just { from, to }, so a calendar backed by millions of stored rows stays light — the server returns only what's on screen, and the grid refetches when you navigate.
  • Overlap engine. Dense days are laid out by a cluster-detecting, lane-packing algorithm in the core (packEvents / layoutDay) — overlapping events cascade automatically; you never position anything by hand.
  • Small and tree-shakeable. The core (@bpdm/scheduler-core) has zero runtime dependencies; the React binding is a few kilobytes brotli, ESM + CJS, and the budget is enforced in CI with size-limit.
  • SSR-safe. Renders on the server and hydrates cleanly. Read "now"/live dates on the client (e.g. in an effect) to avoid a server/client mismatch; the component itself does no non-deterministic work during render.

Testing

The scheduler is tested at two levels, and you can test your own integration the same way.

How it's tested internally

  • @bpdm/scheduler-core — pure unit tests for the date/time math, the overlap → lane-packing layout engine, and the observable store. No DOM, fully deterministic.
  • @bpdm/scheduler — component tests with Vitest + Testing Library in a jsdom environment. Coverage includes view rendering, the create/detail dialogs, the "+N more" day peek, the full keyboard grab-mode (pick up, move, resize, cross-cell move, Escape release, and that arrows are inert until grabbed), focus restoration after a move remounts the event, and messages overrides (i18n).
  • Every change is gated in CI: pnpm test (unit + component), pnpm lint, pnpm build, and a size-limit budget so the bundle can't silently grow.

Testing your own usage

Because events and chips are real buttons with accessible names, you query and drive the scheduler the way a user would — by role and name, then keyboard:

import { render, screen, fireEvent } from '@testing-library/react';
import { useState } from 'react';
import { Scheduler, type CalendarEvent } from '@bpdm/scheduler';

function Harness() {
  const [events, setEvents] = useState<CalendarEvent[]>([
    { id: 'a', title: 'Sprint planning', start: new Date(2026, 6, 20, 9, 0), end: new Date(2026, 6, 20, 10, 0) },
  ]);
  return (
    <Scheduler
      events={events}
      defaultDate={new Date(2026, 6, 20)}
      views={['day']}
      onEventChange={(changed) =>
        setEvents((prev) => prev.map((e) => (e.id === changed.id ? changed : e)))
      }
    />
  );
}

test('an event can be moved later with the keyboard', () => {
  render(<Harness />);
  const event = screen.getByRole('button', { name: /Sprint planning/ });
  event.focus();
  fireEvent.keyDown(event, { key: ' ' });         // pick up (grab mode)
  fireEvent.keyDown(event, { key: 'ArrowDown' }); // move to a later time
  // assert against your own persisted state / onEventChange spy
});

Note the Space before the arrow — nothing moves until an event is grabbed, exactly as a keyboard user experiences it.

API

<Scheduler> props

PropTypeDefaultDescription
eventsCalendarEvent[][]Client-side events (ignored if dataSource is set).
dataSourceDataSource—Custom source, e.g. server-backed.
defaultView'day' | 'week' | 'month''week'Initial view.
defaultDateDatetodayInitial focused date.
viewsViewType[]['day','week','month']Views the toolbar offers.
dayStartHournumber0First hour rendered (day/week).
dayEndHournumber24Last hour rendered (day/week).
scrollToHournumber7Hour the viewport opens at.
maxHeightnumber600Day/week viewport height (px) before it scrolls.
weekStartsOn0–61First day of the week (Monday).
nowDatenew Date()Reference time (today marker + now line).
localestringruntimeBCP-47 locale for dates/times.
messagesPartial<SchedulerMessages>EnglishOverride UI strings.
onEventClick(event) => void—Handle clicks yourself; suppresses the built-in dialog.
editableboolean!!onEventChangeEnable drag-to-move / drag-to-resize in day/week.
onEventChange(event) => void | Promise<void>—Persist a moved/resized event (update state, or dataSource.update); the grid refetches after.
onSelectSlot(slot: SlotSelection) => void—Fires when an empty slot is clicked (its start/end).
renderCreateForm(args: CreateFormArgs) => ReactNode—Render your own create form in the popup on slot click.
onCreate(event) => void | Promise<void>—Persist a created event (update state, or POST); the grid refetches after.
createDurationnumber60Minutes a click proposes for the new slot.
snapMinutesnumber30Snap a clicked day/week time to this many minutes.
createDefaultHournumber9Month cells have no time axis — a click there proposes this hour (0–23).
hourHeightnumber52Pixel height of one hour row in day/week (row density).
monthMaxChipsnumber3Event chips shown per day cell in month view before “+N more”.
collapseToDayBelownumber—Opt-in phone layout: below this container width (px), a week becomes a single day with a week strip, and the month caps its event dots lower (≤2). Measures the scheduler's own width, not the window. Off (and free) when unset.
classNamestring—Extra class on the root.

CalendarEvent

interface CalendarEvent {
  id: string;
  title: string;
  start: Date;
  end: Date;
  allDay?: boolean;
  resourceId?: string;   // multi-resource views (roadmap)
  category?: string;     // 'amber' | 'violet' | 'teal' | 'blue' | 'rose'
  location?: string;     // shown in the dialog
  description?: string;  // shown in the dialog
  data?: unknown;        // your own payload, untouched
}

DataSource

interface DataSource<E = CalendarEvent> {
  fetch(range: { start: Date; end: Date }, options?: { resourceIds?: string[] }): E[] | Promise<E[]>;
  create?(event: E): E | Promise<E>;
  update?(event: E): E | Promise<E>;
  remove?(id: string): void | Promise<void>;
}

InMemoryDataSource implements this for client-side arrays and is what events uses under the hood.

Create types

interface SlotSelection {
  start: Date;
  end: Date;
}

// what a renderCreateForm submits — id is generated if omitted; start/end
// default to the clicked slot; the rest map onto CalendarEvent.
interface CreateEventInput {
  id?: string;
  title: string;
  start?: Date;
  end?: Date;
  category?: string;
  location?: string;
  description?: string;
  data?: unknown;
}

interface CreateFormArgs {
  slot: SlotSelection;
  submit: (input: CreateEventInput) => void;
  cancel: () => void;
}

Roadmap: drag to create / move / resize, resources (multi-person columns), Timeline / Agenda / Year views, recurrence (RRULE) and time zones — all on the same core, plus the Angular binding and a pnpm create / @bpdm/cli add scheduler installer.

On this page