InteractionS’applique àSuperScheduler Pro
Glisser, redimensionner et règles métier
Pilotez chaque frame du glisser dans onEventMoving et onEventResizing : définissez args.allowed = false et args.message pour refuser une position en donnant une explication. Empêchez les chevauchements avec allowEventOverlap={false} (ou frame par frame avec args.allowOverlap), bloquez du temps avec des cellules désactivées, verrouillez des événements isolés avec moveDisabled et resizeDisabled, et prenez la décision finale dans onEventMove ou onEventResize, en asynchrone si nécessaire avec args.async = true et args.loaded().
C’est au glisser qu’un planning prouve son utilité, et c’est là que vivent la plupart des règles métier : cette intervention nécessite un pont élévateur, ce séjour ne peut pas être déplacé dans le passé, le bloc opératoire ferme à midi. SuperScheduler Pro consulte votre code à deux moments. Pendant le glisser, à chaque changement de l’ombre, vous pouvez accepter, refuser ou ajuster la position, et dire pourquoi. Au dépôt, une seule fois, vous pouvez annuler, modifier ou confirmer le changement, y compris après un aller-retour avec votre serveur.
Ce guide construit ces règles sur le planning d’un atelier avec des postes de travail, puis traite des chevauchements, du temps fermé, des verrous, de la confirmation asynchrone, de la carte de glisser et de la création d’événements par sélection d’une plage. Tout ce qui suit nécessite Pro ; Lite est en lecture seule.
Comment se décide un glisser
- L’utilisateur saisit un événement. Les événements verrouillés (
moveDisabled) ne déclenchent pas de glisser. - À chaque mouvement du pointeur qui change l’heure ou la ligne cible,
onEventMovings’exécute (onEventResizingpour un redimensionnement). Votre règle définitargs.allowed, peut ajusterargs.startetargs.end, et définitargs.message. - La bibliothèque applique ensuite ses propres contrôles : chevauchement avec d’autres événements quand
allowEventOverlapvautfalse, et cellules désactivées. Une ombre refusée est dessinée comme interdite et la carte de glisser affiche le motif. - Au relâchement sur une position refusée, il ne se passe rien : l’événement revient à sa place et aucun autre callback ne s’exécute.
- Au relâchement sur une position acceptée,
onEventMove(onEventResize) s’exécute une fois, avant la modification du stockage. Il peut annuler, modifier ou différer la validation. - Le stockage est mis à jour,
onEventMoved(onEventResized) s’exécute, etonEventsChangesuit à la microtâche suivante, comme décrit dans Événements contrôlés et callbacks.
La même séquence de validation s’applique aux déplacements et redimensionnements effectués au clavier avec keyboardMode: 'Full'.
Valider pendant le glisser
onEventMoving reçoit la position candidate et inscrit la décision dans ses arguments :
| Modifiable | Effet |
|---|---|
allowed | false dessine l’ombre comme interdite ; un dépôt à cet endroit ne fait rien |
message | Texte affiché par la carte de glisser tant que allowed vaut false |
start, end | Ajustent l’ombre, par exemple pour conserver les horaires d’origine quand seule la ligne change |
allowOverlap | Remplace allowEventOverlap pour cette frame uniquement |
cssClass, html | Classe et contenu de l’ombre |
Il lit aussi le contexte : args.e (l’événement déplacé, avec vos données dans args.e.data), args.resource et args.row (la ligne cible), args.duration, args.conflicts, args.external (glissé depuis l’extérieur du planificateur) et les touches de modification. onEventResizing fonctionne de la même façon sur start, end, allowed, message et allowOverlap, et ajoute args.what, le bord en cours de déplacement ('start' ou '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}
/>
)
}Vous devriez voir un planning de cinq jours en cellules d’une demi-heure, de 08:00 à 18:00. Faites glisser une intervention qui nécessite un pont élévateur sur Bay 3 : l’ombre devient interdite et la carte indique « This job needs a bay with a lift. ». Faites glisser n’importe quelle intervention sur une autre dans un poste : elle est refusée pour chevauchement. Déposez-la sur la liste d’attente : les deux interventions s’y empilent. Raccourcissez une intervention en dessous de 30 minutes : le redimensionnement est refusé.
Empêcher les chevauchements
allowEventOverlap={false} refuse tout déplacement, redimensionnement ou sélection de plage qui chevaucherait un autre événement de la même ligne. Les intervalles sont semi-ouverts : des événements bout à bout (l’un se termine à 11:00, le suivant commence à 11:00) ne comptent jamais comme un chevauchement.
Deux outils affinent la règle selon la situation :
args.allowOverlapdansonEventMovingetonEventResizingremplace l’option pour la frame en cours, comme le fait la liste d’attente ci-dessus. Il est réinitialisé à chaque appel.args.conflictsliste les événements existants avec lesquels l’ombre entre en collision dans la ligne cible (huit au maximum), sous forme d’objets enveloppesSuperScheduler.Event. Utilisez-le pour distinguer les conflits tolérables des conflits bloquants :
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 entoure d’un contour les événements en collision pendant le glisser (ils reçoivent un attribut data-conflict et un contour de couleur d’alerte) : l’utilisateur voit ce qui gêne, et pas seulement que quelque chose gêne.
Bloquer du temps avec des cellules désactivées
Une cellule désactivée est dessinée hachurée et refuse les déplacements, redimensionnements et sélections de plage qui la touchent. Il y a deux façons de désactiver des cellules :
- Une ligne entière :
cellsDisabled: truesur la ressource, pour un poste en réparation ou une chambre hors service. - N’importe quelle cellule : définissez
args.cell.properties.disabled = truedansonBeforeCellRender, pour les pauses déjeuner, les jours fériés ou les horaires d’ouverture propres à chaque ressource.
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}
/>
)
}Vous devriez voir Bay 3 hachuré sur toute sa ligne et tous les postes hachurés de 13:00 à 14:00. Les interventions ne peuvent pas être déposées ni étirées à travers la pause déjeuner.
Autres façons d’exprimer le temps fermé : le masquer de l’axe avec showNonBusiness={false} ou onIncludeTimeCell (voir Heures, minutes, jours et zoom), ou le représenter par des événements qui ne peuvent être ni déplacés ni redimensionnés (moveDisabled, resizeDisabled), comme un blocage de maintenance, qui comptent aussi comme chevauchements quand allowEventOverlap vaut false.
Verrouiller des événements isolés
| Champ de l’événement | Effet |
|---|---|
moveDisabled | L’événement ne peut pas être déplacé |
resizeDisabled | L’événement ne peut pas être redimensionné |
moveHDisabled | Il peut changer de ligne mais pas d’horaire |
moveVDisabled | Il peut changer d’horaire mais pas de ligne |
clickDisabled, deleteDisabled | Il ignore les clics, ou n’a pas de bouton de suppression |
Pour l’ensemble du planificateur, eventMoveHandling="Disabled" et eventResizeHandling="Disabled" désactivent les gestes. Les règles qui dépendent de la personne qui regarde (un réceptionniste peut déplacer, un client non) sont des permissions : calculez ces champs à partir du rôle de l’utilisateur avant de passer les événements.
Confirmer au dépôt, y compris en asynchrone
onEventMove et onEventResize s’exécutent une fois par dépôt, avant toute modification. Ils peuvent :
- Annuler avec
args.preventDefault(). - Modifier le résultat en affectant
args.newStart,args.newEndouargs.newResource. - Différer avec
args.async = true, puis appelerargs.loaded()une fois la réponse obtenue. Appelerargs.preventDefault()avantloaded()annule le dépôt.
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())
}Branchez-les avec onEventMove={confirmMove} et onEventResize={confirmResize}. Tant que la décision est en attente, l’événement reste à sa position d’origine ; il se déplace quand loaded() termine le dépôt, ou reste en place si le dépôt a été annulé.
La carte de glisser
Pendant le glisser, une carte à côté du pointeur affiche les dates cibles, la durée (en nuits pour les plages en jours entiers, en heures et minutes sinon), la ligne cible et la raison d’un refus : votre args.message, ou l’événement chevauché. Un repère de date apparaît aussi dans l’en-tête de temps (headerMarker). Les deux sont activés par défaut.
Configurez la carte avec un objet stable :
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}`
},
}Passez dragCard={DRAG_CARD}, ou dragCard={false} pour la supprimer. Les libellés par défaut sont traduits en anglais, espagnol, catalan, basque, galicien, allemand, français, italien et portugais, selon la locale du planificateur ; labels les remplace. L’option html reçoit les dates, le nom de la ligne, le premier conflit, le message de refus et la position de l’événement avant le glisser.
Créer des événements en sélectionnant du temps
Faire glisser sur des cellules vides sélectionne une plage de temps ; onTimeRangeSelected la signale avec start, end (exclusif), resource et origin. Y créer un événement est une décision de votre application :
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}
/>
)
}Vous devriez voir une ombre de sélection suivre le pointeur, refuser de dépasser quatre heures ou de recouvrir une autre intervention, puis devenir un événement « New job » au relâchement.
Trois comportements à connaître :
- Un simple clic est aussi une sélection. Cliquer sur une cellule vide déclenche
onTimeRangeSelectedavecorigin: 'click'et une seule cellule. Vérifiezorigin === 'drag'si un clic ne doit rien créer. - La sélection reste visible après le relâchement, jusqu’à la sélection suivante ou jusqu’à
args.control.clearSelection(). - Les sélections suivent les mêmes règles que les déplacements : elles ne peuvent traverser ni des cellules désactivées, ni du temps occupé quand
allowEventOverlapvautfalse.onTimeRangeSelectingles pilote frame par frame avecargs.allowed.
Ce qui reste dans votre application
La bibliothèque fait respecter ce que vous configurez et signale ce qui se passe. Votre application possède les règles elles-mêmes (quelle ressource accepte quel travail, qui peut modifier quoi), la validation côté serveur de chaque modification, ainsi que tout placement automatique ou toute optimisation. Déplacer une réservation ne replanifie jamais les autres : si votre métier exige des modifications en cascade, calculez-les et mettez à jour les événements vous-même.
Rendez-vous d’un cabinet de kinésithérapieUn patient ne peut pas venir à 10 h. Trouvez le prochain créneau qui respecte pauses et nettoyages. Ordonnancement des ordres de fabricationLa maintenance a été avancée. Déplacez l’ordre et gardez ses opérations dans le bon ordre. Réservation de courts dans un club sportifEn pleine matinée, le filet d’un court de padel lâche. Déplacez le stage, fermez le court et gardez chaque coach dans son service.
Étapes suivantes
- Enregistrez les modifications acceptées par ces règles : Événements contrôlés et callbacks.
- Permettez aux utilisateurs d’annuler un déplacement : Annuler et rétablir.
- Appliquez les mêmes règles depuis le clavier : Clavier, accessibilité et tactile.
Exemples liés
- FormaRendez-vous d’un cabinet de kinésithérapieUn patient ne peut pas venir à 10 h. Trouvez le prochain créneau qui respecte pauses et nettoyages.
- ForgeOrdonnancement des ordres de fabricationLa maintenance a été avancée. Déplacez l’ordre et gardez ses opérations dans le bon ordre.
- Court ClubRéservation de courts dans un club sportifEn pleine matinée, le filet d’un court de padel lâche. Déplacez le stage, fermez le court et gardez chaque coach dans son service.
- FieldworkRépartition des interventions techniquesUne intervention urgente arrive. Trouvez l’équipe qui peut la prendre avant l’échéance.