Pro modulesApplies toSuperScheduler Pro
Coordinated panes and saved views
Replace SuperSchedulerComponent with SchedulerPanes from super-scheduler/panes and describe each pane with an id plus resources or a rowFilter; the panes share horizontal scroll, zoom and row header width, scroll vertically on their own, and events can be dragged between them. For saved views, getViewState(control) returns a JSON-safe object with zoom, scroll position, density, collapsed rows and columns, and applyViewState restores it; where it is stored is up to your application.
Two needs come up in every large planning screen. The first is to keep part of the rows in view while the rest scrolls: an "unassigned" tray under the rooms, a team above its machines. The second is to come back to the same view later: the zoom, the date and the rows the user was looking at. super-scheduler/panes and super-scheduler/views cover them, and both need SuperScheduler Pro.
Split one timeline into panes
SchedulerPanes renders several schedulers stacked over one timeline. They share the horizontal scroll position, the zoom and the row header width; each pane scrolls vertically on its own and has its own height. Splitters between panes resize them.
It replaces SuperSchedulerComponent: you pass the same scheduler props once, plus a panes array and a total height.
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}
/>
</>
)
}You should see the rooms on top and a 140 px "unassigned" tray below, with one time header at the top. Scroll either pane sideways and the other follows. Drag a booking from the tray into a room and it moves there; drag one from a room into the tray and the application asks first.
Pane options
| Field | Default | Effect |
|---|---|---|
id | required | Identifies the pane in handlers (args.pane), panesRef and the DOM (data-pane) |
resources | The rows of this pane | |
rowFilter | Picks this pane's rows from the shared resources; use either this or resources | |
size | 'auto' | Pixels, a percentage of the free height ('30%'), or 'auto' for a share of what is left |
minSize | 48 | Smallest height in pixels; minimums win when the total is too small |
hidden | false | Hides the pane but keeps it mounted, so showing it again costs nothing |
props | Props for this pane only; handlers here replace the shared ones |
Rows are assigned by top-level resource: a parent takes its children into its pane. The first pane with neither resources nor rowFilter receives every top-level resource the other panes did not take.
Layout and splitter
| Prop | Default | Effect |
|---|---|---|
height | required | Total height in pixels: every pane, the splitters and the shared header |
timeHeader | 'first' | 'first' shows the time header on the first visible pane only; 'all' on every pane |
scrollbar | 'last' | Horizontal scrollbar on the last pane only, or 'all' |
splitter | true | { size, step } sets its thickness (6 px) and keyboard step (8 px); false removes it |
onPaneResize | { sizes } after a resize is committed, by pane id |
The splitter is focusable with role="separator"; its value is the height of the pane below it. Up and Down move it by step, Shift+Up and Shift+Down by 40 px, Home and End go to the limits, and Enter or a double click restores the declared sizes. While dragging, the panes are previewed; their heights change on release. In 0.1.0 its accessible name is the English "Pane size", with no option to translate it.
Events in panes
Pass all events once. Each pane shows the events whose resource is one of its rows, and an event moves to another pane when its resource does.
- Controlled:
eventsplusonEventsChange. The handler receives the complete, merged list inargs.events, plusargs.panefor the pane where the change happened. Adopt it as in controlled state. - Uncontrolled:
defaultEvents, and the panes keep the list themselves.
Changing a pane's control.events.list directly is not shared with the other panes; go through state or the control.events API.
Moves between panes
Dragging between panes is on by default (crossPaneMove: true); false keeps every event in its pane. With the default eventMoveHandling: 'Update', a move between panes is reported once, as a 'move' change in onEventsChange.
Every shared handler receives args.pane. On a move between panes, onEventMove and onEventMoved also receive args.sourcePane, so a rule can depend on the direction: the snippet confirms only moves from the rooms into the tray, using args.async and args.loaded(). A cancelled or refused move leaves the data unchanged.
Reach each pane's control
SchedulerPanes creates the schedulers, so it gives you their controls through panesRef:
controls: a map from pane id to control, andcontrol(id)for one of them;forEach(run)to call something on every pane;scrollTo(date, position)to scroll them together;update(options)to apply options to every pane.
To use the React render slots inside panes, pass that entry's component: component={SuperSchedulerComponent} imported from super-scheduler/react-render. The panes module does not import it unless you do.
Link schedulers you place yourself
When the schedulers are not stacked (a staff plan at the top of the page and a room plan further down), keep your own components and link their controls with linkPanes:
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}
/>
</>
)
}Horizontal scrolling is always shared. Zoom and row header width are shared unless you pass zoom: false or rowHeaderWidth: false. Call dispose() to unlink.
Save and restore a view
A view is how the user is looking at the data, not the data itself. getViewState(control, include?) captures it as a small JSON-safe object; applyViewState(control, state, options?) restores it.
Key in include | Saved fields | Notes |
|---|---|---|
'zoom' | cellWidth, zoomLevel | zoomLevel is the index of the active level in zoomLevels, so keep their order stable |
'scroll' | anchorDate, topRowId, topOffset | The date at the left edge and the top row, by id, with the offset inside it |
'density' | density | Only when you set the density prop |
'collapsed' | collapsed | Ids of tree parents that are collapsed |
'columns' | columnWidths, columnOrder | Row header column widths and order |
Every state has v: 1. Without include, all five keys are captured.
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 })
}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}
/>
</>
)
}Scroll to a date, collapse a floor, press "Save this view" and reload: the planner returns to the same date and row with the floor collapsed.
How restoring works:
when: 'rows'(default) waits until the rows, and the saved top row, exist; that covers data that arrives after mount. If they do not appear withintimeout(5,000 ms by default), the promise resolvesfalse.when: 'now'applies at once; if the saved top row is gone, the saved offset is used as an absolute scroll position.animate: trueanimates the zoom change.- Parents listed in
collapsedare collapsed and every other parent is expanded. - If the saved columns no longer match
rowHeaderColumns(another number of columns), nothing is applied and the promise resolvesfalse. A state with a version other than 1 also resolvesfalse. - Restoring does not move keyboard focus.
With panes, save and restore through one pane's control (panesRef.current?.control('rooms')): zoom and horizontal scroll are shared, while vertical scroll and collapsed rows belong to that pane.
What your application owns
- Storage.
localStoragefor one browser, or your backend to follow the user across devices. The library never stores anything. - Naming and sharing. Named views, defaults per team, links that open a view.
- Validation. Stored views are untrusted input: check the shape and
vbefore applying, and drop views that fail. - Stable ids. Row ids must mean the same rows from one session to the next for
topRowIdandcollapsedto work. - Pane sizes and selections. Neither is part of a view; store pane sizes from
onPaneResizeif you want them back.
Related
- Resource trees, columns and selection for the collapsed rows and columns a view saves.
- Time scales and zoom for the zoom levels a view restores.