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.
Oct 19 – 25, 2026
<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
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
| Name | What it does |
|---|---|
create | Drag across empty slots, or click one. Creates, or opens createPanel, or reports the slot |
move | Drag an event. attachEvent on every event |
resize | Drag the edge of an event. attachEvent on every event |
keyboard | The one tab stop: arrows, Home, End, Page keys, Space, Enter, Delete, Escape, and the focus announcements |
external | Accepts drops from dragSource elements and the DragSourceList |
Oct 19 – 25, 2026
<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.
| Prop | Type | Default |
|---|---|---|
interactions | InteractionPlugin<T>[] | [] |
editable | boolean | true |
creatable | boolean | true |
onMutate | (m: Mutation<T>) => MutationResult<T> | Promise<...> | - |