InteractionApplies toSuperScheduler Pro
Drag, resize and business rules
Steer every drag frame in onEventMoving and onEventResizing: set args.allowed = false and args.message to refuse a position with an explanation. Prevent overlaps with allowEventOverlap={false} (or per frame with args.allowOverlap), block time with disabled cells, lock single events with moveDisabled and resizeDisabled, and take the final decision in onEventMove or onEventResize, asynchronously if needed with args.async = true and args.loaded().
Dragging is where a planning board earns its keep, and where most business rules live: this job needs a lift, that stay cannot move into the past, the theatre is closed at lunch. SuperScheduler Pro asks your code at two moments. While the user drags, on every change of the shadow, you can accept, refuse or adjust the position and say why. On drop, once, you can cancel, alter or confirm the change, also after a round trip to your server.
This guide builds those rules on a workshop planning with service bays, then covers overlap, closed time, locks, asynchronous confirmation, the drag card and creating events by selecting a range. Everything here requires Pro; Lite is read-only.
How a drag is decided
- The user grabs an event. Locked events (
moveDisabled) do not start a drag. - On every pointer move that changes the target time or row,
onEventMovingruns (onEventResizingfor a resize). Your rule setsargs.allowed, may adjustargs.startandargs.end, and setsargs.message. - The library then applies its own checks: overlap with other events when
allowEventOverlapisfalse, and disabled cells. A refused shadow is drawn as forbidden and the drag card shows the reason. - On release over a refused position, nothing happens: the event goes back and no further callback runs.
- On release over an accepted position,
onEventMove(onEventResize) runs once, before the store changes. It can cancel, alter or defer the commit. - The store is updated,
onEventMoved(onEventResized) runs, andonEventsChangefollows on the next microtask, as described in Controlled events and callbacks.
The same commit sequence runs for moves and resizes made with the keyboard in keyboardMode: 'Full'.
Validate while dragging
onEventMoving receives the candidate position and writes the decision back into its arguments:
| Writable | Effect |
|---|---|
allowed | false draws the shadow as forbidden; dropping there does nothing |
message | Text the drag card shows while allowed is false |
start, end | Adjust the shadow, for example to keep the original times when only the row changes |
allowOverlap | Overrides allowEventOverlap for this frame only |
cssClass, html | Class and content of the shadow |
It also reads the context: args.e (the dragged event, with your data in args.e.data), args.resource and args.row (the target row), args.duration, args.conflicts, args.external (dragged from outside the scheduler) and the modifier keys. onEventResizing works the same way on start, end, allowed, message and allowOverlap, and adds args.what, the edge being dragged ('start' or 'end').
import { useMemo } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
const BAYS: SuperScheduler.ResourceData[] = [
{ id: 'bay-1', name: 'Bay 1 (lift)' },
{ id: 'bay-2', name: 'Bay 2 (lift)' },
{ id: 'bay-3', name: 'Bay 3' },
{ id: 'waiting', name: 'Waiting list' },
]
const BAYS_WITH_LIFT: ReadonlySet<SuperScheduler.ResourceId> = new Set(['bay-1', 'bay-2'])
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
{ groupBy: 'Day', format: 'dddd d MMMM' },
{ groupBy: 'Hour', format: 'HH:mm' },
]
/** A custom field of the job data (see "Custom fields" in the data model guide). */
function needsLift(data: SuperScheduler.EventData): boolean {
return 'needsLift' in data && data.needsLift === true
}
interface Props {
readonly jobs: SuperScheduler.EventData[]
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
}
export function WorkshopPlanning({ jobs, onEventsChange }: Props) {
const config = useMemo<SchedulerProps>(
() => ({
startDate: '2026-10-12',
days: 5,
scale: 'CellDuration',
cellDuration: 30,
cellWidth: 48,
timeHeaders: TIME_HEADERS,
businessBeginsHour: 8,
businessEndsHour: 18,
showNonBusiness: false,
useEventBoxes: 'Never',
allowEventOverlap: false,
conflictHighlight: true,
// Runs on every shadow change: keep it synchronous and cheap.
onEventMoving: (args) => {
if (args.start.getTime() < SuperScheduler.Date.now().getTime()) {
args.allowed = false
args.message = 'Jobs cannot be moved into the past.'
return
}
if (needsLift(args.e.data) && !BAYS_WITH_LIFT.has(args.resource)) {
args.allowed = false
args.message = 'This job needs a bay with a lift.'
return
}
// The waiting list may hold overlapping jobs; the bays may not (allowEventOverlap above).
args.allowOverlap = args.resource === 'waiting'
},
onEventResizing: (args) => {
const minutes = (args.end.getTime() - args.start.getTime()) / 60_000
if (minutes < 30) {
args.allowed = false
args.message = 'A job takes at least 30 minutes.'
}
},
}),
[],
)
const owned = useMemo(() => jobs.slice(), [jobs])
return (
<SuperSchedulerComponent
{...config}
resources={BAYS}
events={owned}
onEventsChange={onEventsChange}
/>
)
}You should see a five-day planning in half-hour cells from 08:00 to 18:00. Drag a job that needs a lift onto Bay 3: the shadow turns forbidden and the card reads "This job needs a bay with a lift.". Drag any job over another one on a bay: it is refused as an overlap. Drop it on the waiting list: both jobs stack there. Shorten a job below 30 minutes: the resize is refused.
Prevent overlaps
allowEventOverlap={false} refuses any move, resize or range selection that would overlap another event in the same row. Intervals are half-open, so back-to-back events (one ends at 11:00, the next starts at 11:00) never count as an overlap.
Two tools refine the rule per situation:
args.allowOverlapinonEventMovingandonEventResizingoverrides the option for the current frame, as the waiting list above does. It resets on every call.args.conflictslists the existing events the shadow collides with in the target row (up to eight), asSuperScheduler.Eventwrappers. Use it to tell soft conflicts from hard ones:
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'
function isTentative(data: SuperScheduler.EventData): boolean {
return 'status' in data && data.status === 'tentative'
}
/** Tentative jobs may be double-booked; confirmed ones may not. */
export const softConflicts: Pick<
SchedulerProps,
'allowEventOverlap' | 'conflictHighlight' | 'onEventMoving'
> = {
allowEventOverlap: false,
// Outlines the events the shadow collides with (data-conflict) while dragging.
conflictHighlight: true,
onEventMoving: (args) => {
// Up to eight colliding events in the target row: feedback, not exhaustive validation.
const hard = args.conflicts.find((event) => !isTentative(event.data))
if (hard === undefined) {
// For this frame only; the instance option stays false.
args.allowOverlap = true
return
}
args.allowed = false
args.message = `Overlaps ${hard.text()}, which is confirmed.`
},
}conflictHighlight outlines the colliding events while the user drags (they get a data-conflict attribute and a danger-colored outline), so the user sees what is in the way, not only that something is.
Block time with disabled cells
A disabled cell is drawn hatched and rejects moves, resizes and range selections that touch it. There are two ways to disable cells:
- A whole row:
cellsDisabled: trueon the resource, for a bay under repair or a room out of order. - Any cell: set
args.cell.properties.disabled = trueinonBeforeCellRender, for lunch breaks, holidays or per-resource opening hours.
import { useMemo } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerBeforeCellRenderArgs, SuperScheduler } from 'super-scheduler'
const BAYS: SuperScheduler.ResourceData[] = [
{ id: 'bay-1', name: 'Bay 1' },
{ id: 'bay-2', name: 'Bay 2' },
// Closed for refurbishment: every cell of the row is disabled.
{ id: 'bay-3', name: 'Bay 3', cellsDisabled: true },
]
/**
* Module level, so its identity never changes: a new function per render would invalidate the
* per-cell cache. Disabled cells are hatched and reject drops, resizes and range selection.
*/
function closeLunchBreak(args: SchedulerBeforeCellRenderArgs): void {
if (args.cell.start.getHours() === 13) {
args.cell.properties.disabled = true
args.cell.properties.cssClass = 'lunch-break'
}
}
export function ClosedTime({ jobs }: { jobs: SuperScheduler.EventData[] }) {
const owned = useMemo(() => jobs.slice(), [jobs])
return (
<SuperSchedulerComponent
startDate="2026-10-12"
days={5}
scale="CellDuration"
cellDuration={30}
resources={BAYS}
events={owned}
onBeforeCellRender={closeLunchBreak}
/>
)
}You should see Bay 3 hatched along its whole row and every bay hatched from 13:00 to 14:00. Jobs cannot be dropped or stretched across the lunch break.
Other ways to express closed time: hide it from the axis with showNonBusiness={false} or onIncludeTimeCell (see Hours, minutes, days and zoom), or represent it as events that cannot move or resize (moveDisabled, resizeDisabled), such as a maintenance block, which also count as overlaps when allowEventOverlap is false.
Lock individual events
| Event field | Effect |
|---|---|
moveDisabled | The event cannot be moved |
resizeDisabled | The event cannot be resized |
moveHDisabled | It can change row but not time |
moveVDisabled | It can change time but not row |
clickDisabled, deleteDisabled | It ignores clicks, or has no delete button |
For the whole scheduler, eventMoveHandling="Disabled" and eventResizeHandling="Disabled" turn the gestures off. Rules that depend on who is looking (a receptionist may move, a guest may not) are permissions: compute these fields from the user's role before you pass the events.
Confirm on drop, also asynchronously
onEventMove and onEventResize run once per drop, before anything changes. They can:
- Cancel with
args.preventDefault(). - Alter the result by assigning
args.newStart,args.newEndorargs.newResource. - Defer with
args.async = true, then callargs.loaded()when you have an answer. Callingargs.preventDefault()beforeloaded()cancels the drop.
import type {
SchedulerEventMoveArgs,
SchedulerEventResizeArgs,
SuperScheduler,
} from 'super-scheduler'
function inProgress(data: SuperScheduler.EventData): boolean {
return 'status' in data && data.status === 'inProgress'
}
/**
* onEventMove: called once on drop, before the store changes. The library never awaits a
* handler, so an asynchronous decision defers the drop with `async` and finishes it with `loaded()`.
*/
export function confirmMove(args: SchedulerEventMoveArgs): void {
// A synchronous veto needs no async: cancel and return. This final check also covers moves
// made with the keyboard.
if (inProgress(args.e.data) && args.newResource !== args.e.resource()) {
args.preventDefault()
args.control.message('A job in progress stays in its bay.')
return
}
args.async = true
const resource = String(args.newResource)
void (async () => {
try {
const question = `Move ${args.e.text()} to ${resource}, ${args.newStart.toString('ddd d MMM HH:mm')}?`
if (!(await confirmWithUser(question))) {
args.preventDefault()
return
}
await saveBooking({
id: String(args.e.id()),
resource,
start: args.newStart.value,
end: args.newEnd.value,
})
} catch {
args.preventDefault()
args.control.message('The move could not be saved.')
} finally {
// Always: completes the drop, or cancels it when preventDefault() was called first.
args.loaded()
}
})()
}
/** The same protocol for resizing; `what` tells which edge moved. */
export function confirmResize(args: SchedulerEventResizeArgs): void {
args.async = true
void saveBooking({
id: String(args.e.id()),
resource: String(args.e.resource()),
start: args.newStart.value,
end: args.newEnd.value,
})
.catch(() => args.preventDefault())
.finally(() => args.loaded())
}Attach them as onEventMove={confirmMove} and onEventResize={confirmResize}. While the decision is pending, the event stays at its original position; it moves when loaded() completes the drop, or stays put if the drop was cancelled.
The drag card
While dragging, a card next to the pointer shows the target dates, the duration (nights for whole-day ranges, hours and minutes otherwise), the target row, and why a position is refused: your args.message, or the event it overlaps. A date marker also appears in the time header (headerMarker). Both are on by default.
Configure the card with one stable object:
import { SuperScheduler } from 'super-scheduler'
// One stable object at module level: a new object per render would reconfigure the card.
export const DRAG_CARD: SuperScheduler.DragCardOptions = {
// Intraday work: show the time of both edges.
dateFormat: 'ddd d MMM HH:mm',
movingDateFormat: 'ddd d MMM HH:mm',
// Whole-day ranges count nights by default; other ranges show hours and minutes.
duration: 'auto',
row: true,
labels: { overlapping: 'Overlaps', forbidden: 'Not allowed' },
}
// Replace the content when a refusal needs more room. The string is trusted HTML.
export const DRAG_CARD_WITH_REASON: SuperScheduler.DragCardOptions = {
...DRAG_CARD,
html: (info) => {
if (info.refusal === null) return null // null keeps the default content
const reason = SuperScheduler.Util.escapeHtml(info.refusal)
const row = SuperScheduler.Util.escapeHtml(info.rowName ?? '')
return `<strong>${reason}</strong><br>${row}`
},
}Pass dragCard={DRAG_CARD}, or dragCard={false} to remove it. The default labels are translated for English, Spanish, Catalan, Basque, Galician, German, French, Italian and Portuguese, following the scheduler's locale; labels overrides them. The html option receives the dates, the row name, the first conflict, the refusal message and the event's position before the drag.
Create events by selecting time
Dragging across empty cells selects a time range; onTimeRangeSelected reports it with start, end (exclusive), resource and origin. Creating an event there is your application's decision:
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type {
SchedulerEventsChangeArgs,
SchedulerTimeRangeSelectedArgs,
SchedulerTimeRangeSelectingArgs,
SuperScheduler,
} from 'super-scheduler'
const BAYS: SuperScheduler.ResourceData[] = [
{ id: 'bay-1', name: 'Bay 1' },
{ id: 'bay-2', name: 'Bay 2' },
]
/** Steers the selection while it is drawn, as onEventMoving does for moves: four hours at most. */
function limitToFourHours(args: SchedulerTimeRangeSelectingArgs): void {
args.allowed = args.end.getTime() - args.start.getTime() <= 4 * 3_600_000
}
export function CreateOnSelect() {
const [jobs, setJobs] = useState<SuperScheduler.EventData[]>([])
const owned = useMemo(() => jobs.slice(), [jobs])
const onEventsChange = useCallback(
(args: SchedulerEventsChangeArgs) => setJobs([...args.events]),
[],
)
const onTimeRangeSelected = useCallback((args: SchedulerTimeRangeSelectedArgs) => {
// The selection shadow stays until cleared.
args.control.clearSelection()
// A plain click on an empty cell also selects it (origin 'click'): create only on a drag.
if (args.origin !== 'drag') return
setJobs((current) => [
...current,
{
id: crypto.randomUUID(),
resource: args.resource,
// Store strings: start and end arrive as SuperScheduler.Date (end exclusive).
start: args.start.value,
end: args.end.value,
text: 'New job',
},
])
}, [])
return (
<SuperSchedulerComponent
startDate="2026-10-12"
days={5}
scale="CellDuration"
cellDuration={30}
resources={BAYS}
events={owned}
allowEventOverlap={false}
onTimeRangeSelecting={limitToFourHours}
onTimeRangeSelected={onTimeRangeSelected}
onEventsChange={onEventsChange}
/>
)
}You should see a selection shadow follow the pointer, refuse to grow beyond four hours or over another job, and become a "New job" event on release.
Three behaviors to know:
- A plain click is a selection too. Clicking an empty cell fires
onTimeRangeSelectedwithorigin: 'click'and one cell. Checkorigin === 'drag'if a click must not create anything. - The selection stays visible after release until the next selection or
args.control.clearSelection(). - Selections follow the same rules as moves: they cannot cross disabled cells, nor occupied time when
allowEventOverlapisfalse.onTimeRangeSelectingsteers them frame by frame withargs.allowed.
What stays in your application
The library enforces what you configure and reports what happens. Your application owns the rules themselves (which resource accepts which work, who may change what), the server-side validation of every change, and any automatic placement or optimization. Moving a booking never reschedules the others: if your business needs cascading changes, compute them and update the events yourself.
Physiotherapy clinic appointmentsA patient cannot make 10:00 AM. Find the next slot that respects breaks and room cleaning. Manufacturing order schedulingMaintenance was brought forward. Move the order out of the way and keep its operations in sequence. Sports club court bookingA padel net snaps mid-morning. Move the clinic, close the court and keep every coach where the rota says.
Next steps
- Persist the changes these rules accept: Controlled events and callbacks.
- Let users undo a move: Undo and redo.
- Drive the same rules from the keyboard: Keyboard, accessibility and touch.
Related examples
- FormaPhysiotherapy clinic appointmentsA patient cannot make 10:00 AM. Find the next slot that respects breaks and room cleaning.
- ForgeManufacturing order schedulingMaintenance was brought forward. Move the order out of the way and keep its operations in sequence.
- Court ClubSports club court bookingA padel net snaps mid-morning. Move the clinic, close the court and keep every coach where the rota says.
- FieldworkField service dispatchAn urgent job lands in the queue. Find the crew that can take it before it is due.