# Volets coordonnés et vues enregistrées

> Découpez une frise en volets synchronisés avec super-scheduler/panes, déplacez des événements entre eux, et sauvegardez la vue avec super-scheduler/views.

Source: https://superscheduler.org/fr/docs/panes-saved-views/
Reviewed: 2026-10-07

Remplacez SuperSchedulerComponent par SchedulerPanes, importé de super-scheduler/panes, et décrivez chaque volet avec un id plus resources ou un rowFilter ; les volets partagent le défilement horizontal, le zoom et la largeur de l’en-tête de ligne, défilent verticalement chacun de leur côté, et les événements peuvent être glissés de l’un à l’autre. Pour les vues enregistrées, getViewState(control) renvoie un objet sérialisable en JSON avec le zoom, la position de défilement, la densité, les lignes repliées et les colonnes, et applyViewState le restaure ; l’endroit où il est stocké relève de votre application.

Deux besoins reviennent dans tout grand écran de planification. Le premier : garder une partie des lignes visible pendant que le reste défile, comme une zone « à affecter » sous les chambres ou une équipe au-dessus de ses machines. Le second : retrouver plus tard la même vue, c’est-à-dire le zoom, la date et les lignes que l’utilisateur regardait. `super-scheduler/panes` et `super-scheduler/views` y répondent, et tous deux nécessitent SuperScheduler Pro.

## Découper une frise en volets
`SchedulerPanes` affiche plusieurs planificateurs empilés sur une même frise. Ils partagent la position de défilement horizontal, le zoom et la largeur de l’en-tête de ligne ; chaque volet défile verticalement de son côté et a sa propre hauteur. Des séparateurs entre les volets permettent de les redimensionner.

Il remplace `SuperSchedulerComponent` : vous passez une seule fois les mêmes props de planificateur, plus un tableau `panes` et une hauteur totale `height`.

```tsx
// src/RoomsWithTray.tsx
import { useCallback, useMemo, useRef, useState } from 'react'
import type { SchedulerEventsChangeArgs, SchedulerProps, SuperScheduler } from 'super-scheduler'
import { SchedulerPanes } from 'super-scheduler/panes'
import type { SchedulerPane, SchedulerPanesHandle } from 'super-scheduler/panes'
import 'super-scheduler/styles.css'

const isTray = (resource: SuperScheduler.ResourceData) => resource.kind === 'tray'

// Module-level (or memoized): a new `panes` array resets the sizes the user dragged.
const PANES: SchedulerPane[] = [
  { id: 'rooms', rowFilter: (resource) => !isTray(resource), minSize: 200 },
  {
    id: 'tray',
    rowFilter: isTray,
    size: 140,
    minSize: 96,
    // Per-pane overrides: smaller events in the unassigned tray.
    props: { eventHeight: 28 },
  },
]

export function RoomsWithTray(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  const panesRef = useRef<SchedulerPanesHandle>(null)

  // One list for every pane: each event appears in the pane that holds its resource.
  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => setEvents([...args.events]),
    [],
  )

  const shared = useMemo<Partial<SchedulerProps>>(
    () => ({
      onEventMove: (args) => {
        // `pane` is where the event lands; `sourcePane` is set only for a move between panes.
        if (args.sourcePane !== 'rooms' || args.pane !== 'tray') return
        // Unassigning a booking asks first; the drop waits for the answer.
        args.async = true
        void confirmWithUser(`Unassign ${args.e.text()}?`).then((ok) => {
          if (!ok) args.preventDefault()
          args.loaded()
        })
      },
    }),
    [],
  )

  return (
    <>
      <button type="button" onClick={() => panesRef.current?.scrollTo('2026-10-01', 'left')}>
        Go to 1 October
      </button>
      <SchedulerPanes
        {...shared}
        panesRef={panesRef}
        panes={PANES}
        // Total height of every pane, the splitter and the shared header.
        height={640}
        resources={props.resources}
        events={events}
        onEventsChange={onEventsChange}
        splitter={{ size: 6, step: 8 }}
        startDate="2026-10-01"
        days={60}
        scale="Day"
        cellWidth={44}
      />
    </>
  )
}
```
Vous devriez voir les chambres en haut et, en dessous, une zone « à affecter » de 140 px, avec un seul en-tête de temps tout en haut. Faites défiler l’un des volets latéralement : l’autre suit. Glissez une réservation de la zone vers une chambre : elle s’y déplace ; glissez-en une d’une chambre vers la zone : l’application demande d’abord confirmation.

## Options des volets
| Champ | Défaut | Effet |
|---|---|---|
| `id` | obligatoire | Identifie le volet dans les handlers (`args.pane`), dans `panesRef` et dans le DOM (`data-pane`) |
| `resources` | | Les lignes de ce volet |
| `rowFilter` | | Choisit les lignes de ce volet parmi les `resources` partagées ; utilisez soit cette option, soit `resources` |
| `size` | `'auto'` | Des pixels, un pourcentage de la hauteur libre (`'30%'`), ou `'auto'` pour une part de ce qui reste |
| `minSize` | `48` | Hauteur minimale en pixels ; les minimums l’emportent quand le total est trop petit |
| `hidden` | `false` | Masque le volet mais le garde monté : le réafficher ne coûte rien |
| `props` | | Props propres à ce volet ; les handlers définis ici remplacent les handlers partagés |

Les lignes sont réparties par ressource de premier niveau : un parent emmène ses enfants dans son volet. Le premier volet qui n’a ni `resources` ni `rowFilter` reçoit toutes les ressources de premier niveau que les autres volets n’ont pas prises.

## Disposition et séparateur
| Prop | Défaut | Effet |
|---|---|---|
| `height` | obligatoire | Hauteur totale en pixels : tous les volets, les séparateurs et l’en-tête partagé |
| `timeHeader` | `'first'` | `'first'` n’affiche l’en-tête de temps que sur le premier volet visible ; `'all'` sur chaque volet |
| `scrollbar` | `'last'` | Barre de défilement horizontale sur le dernier volet seulement, ou `'all'` |
| `splitter` | `true` | `{ size, step }` définit son épaisseur (6 px) et son pas au clavier (8 px) ; `false` le supprime |
| `onPaneResize` | | `{ sizes }` par id de volet, après validation d’un redimensionnement |

Le séparateur est focalisable, avec `role="separator"` ; sa valeur est la hauteur du volet situé en dessous. Haut et Bas le déplacent de `step`, Maj+Haut et Maj+Bas de 40 px, Début et Fin l’amènent aux limites, et Entrée ou un double-clic rétablit les tailles déclarées. Pendant le glissement, les volets sont prévisualisés ; leurs hauteurs changent au relâchement. Dans la 0.1.0, son nom accessible est l’anglais « Pane size », sans option pour le traduire.

> **Behavior:**
> Les hauteurs redimensionnées par l’utilisateur durent jusqu’à ce que `panes` ou `height` change. Définissez `panes` au niveau du module ou mémoïsez-le, comme le fait l’extrait ; un nouveau tableau à chaque rendu réinitialiserait le séparateur. Pour mémoriser les tailles d’une session à l’autre, stockez-les depuis `onPaneResize` et repassez-les dans le `size` de chaque volet.

## Les événements dans les volets
Passez tous les événements une seule fois. Chaque volet affiche les événements dont la `resource` est l’une de ses lignes, et un événement passe dans un autre volet quand sa ressource y passe.

- **Contrôlé :** `events` plus `onEventsChange`. Le handler reçoit la liste complète et fusionnée dans `args.events`, ainsi que `args.pane` pour le volet où la modification a eu lieu. Adoptez-la comme dans l’[état contrôlé](https://superscheduler.org/fr/docs/controlled-state/).
- **Non contrôlé :** `defaultEvents`, et les volets gèrent la liste eux-mêmes.

Modifier directement le `control.events.list` d’un volet n’est pas répercuté sur les autres volets ; passez par l’état ou par l’API `control.events`.

### Déplacements entre volets
Le glissement entre volets est activé par défaut (`crossPaneMove: true`) ; `false` garde chaque événement dans son volet. Avec la valeur par défaut `eventMoveHandling: 'Update'`, un déplacement entre volets est signalé une seule fois, comme une modification `'move'` dans `onEventsChange`.

Chaque handler partagé reçoit `args.pane`. Lors d’un déplacement entre volets, `onEventMove` et `onEventMoved` reçoivent aussi `args.sourcePane`, si bien qu’une règle peut dépendre du sens : l’extrait ne demande confirmation que pour les déplacements des chambres vers la zone, avec `args.async` et `args.loaded()`. Un déplacement annulé ou refusé laisse les données inchangées.

## Accéder au contrôle de chaque volet
`SchedulerPanes` crée les planificateurs : il vous donne donc leurs contrôles via `panesRef` :

- `controls` : une map de l’id du volet vers son contrôle, et `control(id)` pour l’un d’eux ;
- `forEach(run)` pour appeler quelque chose sur chaque volet ;
- `scrollTo(date, position)` pour les faire défiler ensemble ;
- `update(options)` pour appliquer des options à chaque volet.

Pour utiliser les [slots de rendu React](https://superscheduler.org/fr/docs/react-render-slots/) dans les volets, passez le composant de ce point d’entrée : `component={SuperSchedulerComponent}`, importé de `super-scheduler/react-render`. Le module des volets ne l’importe que si vous le faites.

### Relier des planificateurs que vous placez vous-même
Quand les planificateurs ne sont pas empilés (un planning du personnel en haut de la page et un planning des salles plus bas), gardez vos propres composants et reliez leurs contrôles avec `linkPanes` :

```tsx
// src/LinkedBoards.tsx
import { useEffect, useMemo } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { linkPanes } from 'super-scheduler/panes'

// Two schedulers placed by your own layout (here, a page section apart) that move together.
export function LinkedBoards(props: {
  staff: SuperScheduler.ResourceData[]
  rooms: SuperScheduler.ResourceData[]
  shifts: SuperScheduler.EventData[]
  bookings: SuperScheduler.EventData[]
}) {
  const staff = useSchedulerControl()
  const rooms = useSchedulerControl()
  const shifts = useMemo(() => props.shifts.slice(), [props.shifts])
  const bookings = useMemo(() => props.bookings.slice(), [props.bookings])

  useEffect(() => {
    if (staff.control === null || rooms.control === null) return
    // Horizontal scroll always; zoom and row header width too unless turned off.
    const link = linkPanes([staff.control, rooms.control], { zoom: true, rowHeaderWidth: true })
    return () => link.dispose()
  }, [staff.control, rooms.control])

  return (
    <>
      <h2>Staff</h2>
      <SuperSchedulerComponent
        controlRef={staff.controlRef}
        startDate="2026-10-01"
        days={30}
        scale="Day"
        resources={props.staff}
        events={shifts}
      />
      <h2>Rooms</h2>
      <SuperSchedulerComponent
        controlRef={rooms.controlRef}
        startDate="2026-10-01"
        days={30}
        scale="Day"
        resources={props.rooms}
        events={bookings}
      />
    </>
  )
}
```
Le défilement horizontal est toujours partagé. Le zoom et la largeur de l’en-tête de ligne le sont aussi, sauf si vous passez `zoom: false` ou `rowHeaderWidth: false`. Appelez `dispose()` pour défaire le lien.

## Enregistrer et restaurer une vue
Une vue, c’est la façon dont l’utilisateur regarde les données, pas les données elles-mêmes. `getViewState(control, include?)` la capture sous forme d’un petit objet sérialisable en JSON ; `applyViewState(control, state, options?)` la restaure.

| Clé de `include` | Champs enregistrés | Remarques |
|---|---|---|
| `'zoom'` | `cellWidth`, `zoomLevel` | `zoomLevel` est l’index du niveau actif dans `zoomLevels` : gardez leur ordre stable |
| `'scroll'` | `anchorDate`, `topRowId`, `topOffset` | La date au bord gauche et la ligne du haut, par id, avec le décalage à l’intérieur de celle-ci |
| `'density'` | `density` | Seulement si vous définissez la prop `density` |
| `'collapsed'` | `collapsed` | Id des parents d’arborescence repliés |
| `'columns'` | `columnWidths`, `columnOrder` | Largeur et ordre des colonnes de l’en-tête de ligne |

Chaque état comporte `v: 1`. Sans `include`, les cinq clés sont capturées.

```ts
// src/savedView.ts
import type { SuperScheduler } from 'super-scheduler'
import { applyViewState, getViewState } from 'super-scheduler/views'
import type { SchedulerViewState, ViewStateKey } from 'super-scheduler/views'

// What this application restores from the view. Density and columns stay in React state here.
const KEYS: readonly ViewStateKey[] = ['zoom', 'scroll', 'collapsed']

const storageKey = (user: string, view: string) => `planning-view:${user}:${view}`

/** Saves the current view. The application owns storage: here localStorage, per user. */
export function saveView(control: SuperScheduler.Scheduler, user: string, view: string): void {
  const state = getViewState(control, KEYS)
  try {
    localStorage.setItem(storageKey(user, view), JSON.stringify(state))
  } catch {
    // Storage can be full or disabled; a view is a convenience, not data.
  }
}

/** Stored values are untrusted input: check the shape before using them. */
function isViewState(value: unknown): value is SchedulerViewState {
  return typeof value === 'object' && value !== null && (value as { v?: unknown }).v === 1
}

/** Restores a saved view. Resolves false when nothing was saved or the rows never appeared. */
export async function restoreView(
  control: SuperScheduler.Scheduler,
  user: string,
  view: string,
): Promise<boolean> {
  let saved: unknown = null
  try {
    saved = JSON.parse(localStorage.getItem(storageKey(user, view)) ?? 'null')
  } catch {
    return false
  }
  if (!isViewState(saved)) return false
  // Waits (up to 5 s) for the rows and the saved top row, for data that loads after mount.
  return applyViewState(control, saved, { when: 'rows', timeout: 5000 })
}
```
```tsx
// src/PlannerWithViews.tsx
import { useEffect, useMemo } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { restoreView, saveView } from './saved-view'

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  { id: 'weeks', properties: { scale: 'Week', cellWidth: 120 } },
  { id: 'days', properties: { scale: 'Day', cellWidth: 44 } },
]

export function PlannerWithViews(props: {
  user: string
  resources: SuperScheduler.ResourceData[]
  events: SuperScheduler.EventData[]
}) {
  const { controlRef, control } = useSchedulerControl()
  const events = useMemo(() => props.events.slice(), [props.events])

  // Restore once the control exists; keep row ids stable so the top row can be found again.
  useEffect(() => {
    if (control !== null) void restoreView(control, props.user, 'default')
  }, [control, props.user])

  return (
    <>
      <button
        type="button"
        disabled={control === null}
        onClick={() => control && saveView(control, props.user, 'default')}
      >
        Save this view
      </button>
      <SuperSchedulerComponent
        controlRef={controlRef}
        startDate="2026-01-01"
        days={365}
        zoomLevels={ZOOM_LEVELS}
        zoom="days"
        treeEnabled
        resources={props.resources}
        events={events}
      />
    </>
  )
}
```
Faites défiler jusqu’à une date, repliez un étage, appuyez sur « Save this view » et rechargez la page : le planning revient à la même date et à la même ligne, avec l’étage replié.

Comment se passe la restauration :

- `when: 'rows'` (par défaut) attend que les lignes, et la ligne du haut enregistrée, existent ; cela couvre les données qui arrivent après le montage. Si elles n’apparaissent pas dans le délai `timeout` (5 000 ms par défaut), la promesse se résout avec `false`.
- `when: 'now'` applique immédiatement ; si la ligne du haut enregistrée a disparu, le décalage enregistré sert de position de défilement absolue.
- `animate: true` anime le changement de zoom.
- Les parents listés dans `collapsed` sont repliés et tous les autres parents sont dépliés.
- Si les colonnes enregistrées ne correspondent plus à `rowHeaderColumns` (un nombre de colonnes différent), rien n’est appliqué et la promesse se résout avec `false`. Un état d’une version autre que 1 se résout aussi avec `false`.
- La restauration ne déplace pas le focus clavier.

> **Tip:**
> Si votre application conserve `density` ou `rowHeaderColumns` dans un état React, restaurez-les via votre état et laissez-les hors de `include`, comme le fait l’extrait. `applyViewState` les modifie sur le contrôle, et un changement de prop ultérieur venu de React l’écraserait.

Avec des volets, enregistrez et restaurez via le contrôle d’un seul volet (`panesRef.current?.control('rooms')`) : le zoom et le défilement horizontal sont partagés, tandis que le défilement vertical et les lignes repliées appartiennent à ce volet.

## Ce qui revient à votre application
- **Le stockage.** `localStorage` pour un seul navigateur, ou votre backend pour suivre l’utilisateur d’un appareil à l’autre. La bibliothèque ne stocke jamais rien.
- **Le nommage et le partage.** Vues nommées, vues par défaut par équipe, liens qui ouvrent une vue.
- **La validation.** Les vues stockées sont des entrées non fiables : vérifiez leur forme et `v` avant de les appliquer, et écartez celles qui échouent.
- **Des id stables.** Les id de lignes doivent désigner les mêmes lignes d’une session à l’autre pour que `topRowId` et `collapsed` fonctionnent.
- **Tailles des volets et sélections.** Ni les unes ni les autres ne font partie d’une vue ; stockez les tailles des volets depuis `onPaneResize` si vous voulez les retrouver.

## Voir aussi
→ https://superscheduler.org/fr/examples/training-rooms/
- [Arbres de ressources, colonnes et sélection](https://superscheduler.org/fr/docs/trees-columns-selection/) pour les lignes repliées et les colonnes qu’une vue enregistre.
- [Échelles de temps et zoom](https://superscheduler.org/fr/docs/time-scales-zoom/) pour les niveaux de zoom qu’une vue restaure.
