Advanced

Interaction Plugins

Drag, resize, create and the keyboard are plugins. Yours use the same context they do: read the grid under a pointer, select, commit a change through the save pipeline, announce it.

What a Plugin Is

An object with a name and one or two attachment factories. attach runs once per grid, on the element that takes the pointer and the keyboard; attachEvent runs once per drawn event. Both run inside the view component, so sv5ui hooks such as useEventListener clean up on their own.

import type { InteractionPlugin } from '@sv5ui/scheduler';

// attach runs once per grid, attachEvent once per drawn event. Both return a Svelte
// attachment: a function that receives the element and may return a cleanup.
// attach runs inside the view component, so sv5ui hooks like useEventListener work.
const plugin: InteractionPlugin = {
  name: 'my-plugin',
  attach: (context) => (node) => {
    // listen on node, read context
    return () => { /* cleanup */ };
  },
  attachEvent: (context, position) => (node) => {
    // node is one event element; position says which event and where
  }
};

<Scheduler bind:events interactions={[plugin]} />

Pointer Readout

hitTest turns a screen point into a date, a day index and whether the point is in an all-day cell. It reads the same DOM contract the built-in gestures use, so it works in every view, including yours. Move over the grid, then switch to Month.

Under the pointer: move over the grid

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
All day
Lunch break
<script lang="ts">
  import { useEventListener } from 'sv5ui';
  import type { InteractionPlugin } from '@sv5ui/scheduler';

  let pointer = $state<string | null>(null);

  // hitTest turns a screen point into a date, a day index and whether the point
  // is in an all-day cell. It works the same in the week and in the month.
  const pointerTime: InteractionPlugin = {
    name: 'pointer-time',
    attach: (context) => (node) => {
      useEventListener(node, 'pointermove', (event: PointerEvent) => {
        const hit = context.hitTest(event.clientX, event.clientY);
        pointer = hit ? (hit.allDay ? dayFormat.format(hit.date.toDate()) : hourFormat.format(hit.date.toDate())) : null;
      });
      useEventListener(node, 'pointerleave', () => (pointer = null));
    }
  };
</script>

<Badge label={pointer ?? 'Move over the grid'} />
<Scheduler bind:events interactions={[pointerTime]} />

The Context

Everything a plugin can read and do. The state fields are live: reading selectedEventId in an event handler gives the current value.

interface InteractionContext<T> {
  // Reading the grid
  view: string;  viewLabel: string;  range: DateRange;  days: ZonedDateTime[];  columnsPerRow: number;
  scale: TimeScale;                                   // clock to pixels
  scheduler: SchedulerContext;                        // timeZone, locale, labels, editable, now...
  hitTest: (clientX, clientY) => HitTarget | null;    // { date, dayIndex, allDay, keepsTime, eventId }
  snap: (date) => ZonedDateTime;                      // to the slot grid
  getEvent: (id) => SchedulerEvent<T> | undefined;
  events: readonly SchedulerEvent<T>[];               // in range, filtered, series expanded, by start

  // State
  selectedEventId: string | null;  select: (id | null) => void;
  focus: GridFocus | null;         setFocus: (focus | null) => void;
  setPreview: (preview: { kind, event } | null) => void;   // the ghost

  // Actions
  commit: (mutation: Omit<Mutation<T>, 'id'>) => void;     // through the save pipeline
  selectSlot: (point: { date, allDay }) => void;           // fires onSelectSlot
  selectRange: (selection: SlotSelection) => void;         // creates, or opens createPanel
  step: (direction: 1 | -1) => void;  navigate: (date, view?) => void;
  newEventId: () => string;                                // an id the store has not seen
  announce: (message: string) => void;                     // the live region
}

Committing a Change

commit sends a mutation through the same pipeline a drag uses: the store changes at once, onMutate runs, a failure rolls back and the change is announced. Double-click an event below to copy it to the next day; the log shows the create reaching onMutate. Occurrences of the daily standup are refused, since an occurrence cannot be edited.

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
All day
Lunch break

Pipeline

  • Double-click an event to copy it to the next day.
// Double-click an event to copy it to the next day. commit goes through the same
// pipeline as a drag: optimistic, onMutate, rollback on failure, announced.
const duplicate: InteractionPlugin = {
  name: 'duplicate',
  attach: () => () => {},
  attachEvent: (context, position) => (node) => {
    useEventListener(node, 'dblclick', () => {
      const source = position.event;
      if (!context.scheduler.editable || source.seriesId) return;
      const copy = {
        ...source,
        id: context.newEventId(),
        seriesId: undefined,
        start: source.start.add({ days: 1 }),
        end: source.end.add({ days: 1 })
      };
      context.commit({ kind: 'create', eventId: copy.id, before: null, after: copy });
      context.announce(`Copied ${source.title} to the next day`);
    });
  }
};

Per-Event Attachments

attachEvent receives the position of the event it is attached to, so it knows the event, the view and the shape. The built-in move and resize plugins are written this way. Yours run alongside them.

// attachEvent runs for every drawn event, in every view, and again when the
// layout changes. position tells you which event and what shape it has.
attachEvent: (context, position) => (node) => {
  if (position.kind !== 'time') return;              // blocks only
  node.title = `${position.event.title}, ${formatTime(position.segmentStart)}`;   // a native tooltip
}

// The built-in move and resize are attachEvent plugins too. Yours run alongside
// them; a plugin with the same name runs instead of them.

Built-in Plugins and Replacing Them

Five plugins ship with the scheduler. A plugin of yours with the same name runs instead of the built-in one, so an attachment that does nothing turns a gesture off. The switches below replace resize and create; with resize off the edge cursor is gone, with create off a drag on empty slots does nothing.

Built-in plugins

NameWhat it does
createDrag across empty slots, or click one. Creates, or opens createPanel, or reports the slot
moveDrag an event. attachEvent on every event
resizeDrag the edge of an event. attachEvent on every event
keyboardThe one tab stop: arrows, Home, End, Page keys, Space, Enter, Delete, Escape, and the focus announcements
externalAccepts drops from dragSource elements and the DragSourceList

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
All day
Lunch break
<script lang="ts">
  // The built-in plugins: create, move, resize, keyboard, external.
  // A plugin with the same name replaces one. An attachment that does nothing turns it off.
  const noResize = { name: 'resize', attach: () => () => {} };
  const noCreate = { name: 'create', attach: () => () => {} };
</script>

<Scheduler bind:events interactions={[noResize]} />

<!-- Replacing keyboard removes arrow navigation, Space, Enter, Delete and every
     focus announcement. Do that only with a replacement that does the same job. -->

Replacing keyboard removes arrow navigation, Space, Enter, Delete and every focus announcement at once. Do that only with a replacement that does the same job, or the calendar stops being usable without a pointer. To turn off editing, prefer editable={false} and creatable={false}, which keep the keyboard.

Guarding Gestures

There is no hook that runs before a built-in gesture commits. To refuse a drop, throw in onMutate: the event slides back and the refusal is announced. To see or change every patch that reaches the store, use a store middleware.

// There is no "before commit" hook on the built-in gestures. To refuse a drop,
// throw in onMutate: the event slides back and the reader hears why.
onMutate={(mutation) => {
  if (mutation.after && isWeekend(mutation.after.start)) throw new Error('Not on a weekend');
  return api.save(mutation);
}}

// To see or change every patch that reaches the store, gestures and loads alike,
// use a store middleware: the next page.

Previews

A plugin with a gesture of its own draws its ghost through setPreview. The view lays the previewed event out with its normal layout and draws it as the ghost, so the preview looks right in every view without the plugin knowing how the view draws.

// A plugin with a gesture of its own draws its ghost through setPreview: the view
// lays the event out with its normal layout and draws it as the preview. Clear it
// when the gesture ends, before or after commit.
context.setPreview({ kind: 'move', event: { ...source, start, end } });
// ...
context.setPreview(null);
context.commit({ kind: 'move', eventId: source.id, before: source, after: { ...source, start, end } });

Props

The registration prop, and the two that guard what plugins may do.

PropTypeDefault
interactionsInteractionPlugin<T>[][]
editablebooleantrue
creatablebooleantrue
onMutate(m: Mutation<T>) => MutationResult<T> | Promise<...>-