Skip to content
SuperScheduler

InteractionApplies toSuperScheduler Pro

Resource trees, columns and selection

Set treeEnabled and nest resources in children; a parent stays collapsed unless it has expanded: true. Add row header columns with rowHeaderColumns, filling cells from resource fields through display. Filter with control.rows.filter() and control.events.filter() plus onRowFilter and onEventFilter, and select with eventClickHandling: 'Select', rectangle selection, rowClickHandling: 'Select' and the multiselect and multirange APIs.

Verified against v0.1.0 · reviewed October 7, 2026.md

Real plans rarely have a flat list of resources. Rooms belong to floors, technicians to regions, machines to lines. This guide covers the Pro features that organize rows and work on several items at once: resource trees, row header columns, filters, and the three kinds of selection (events, rows and time ranges).

Everything here needs SuperScheduler Pro (super-scheduler). The Lite edition renders a flat list and rejects resource children and columns.

Group resources in a tree

A tree is plain data: a resource with a children array becomes a parent row. Two things turn it on:

  • treeEnabled: true on the scheduler. Without it, children are ignored and the row header has no toggles.
  • expanded: true on every parent that should start open. Parents are collapsed unless expanded is exactly true.
src/TeamPlanner.tsxtsx
import { useMemo } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import 'super-scheduler/styles.css'

// Regions are parent rows and technicians their children. A parent stays collapsed
// unless its resource says `expanded: true`.
const RESOURCES: SuperScheduler.ResourceData[] = [
  {
    id: 'north',
    name: 'North region',
    expanded: true,
    children: [
      { id: 'ana', name: 'Ana Ruiz' },
      { id: 'ben', name: 'Ben Ortiz' },
    ],
  },
  {
    id: 'south',
    name: 'South region',
    children: [
      { id: 'cai', name: 'Cai Moreno' },
      { id: 'dee', name: 'Dee Patel' },
    ],
  },
]

const JOBS: SuperScheduler.EventData[] = [
  {
    id: 'job-1',
    resource: 'ana',
    start: '2026-10-05T00:00:00',
    end: '2026-10-07T00:00:00',
    text: 'Panel upgrade',
  },
  {
    id: 'job-2',
    resource: 'cai',
    start: '2026-10-06T00:00:00',
    end: '2026-10-09T00:00:00',
    text: 'Boiler service',
  },
]

export function TeamPlanner() {
  const { controlRef, control } = useSchedulerControl()
  // The control adopts the events array and mutates it: hand it a copy.
  const events = useMemo(() => JOBS.slice(), [])

  return (
    <section aria-labelledby="team-plan-title">
      <h2 id="team-plan-title">Field teams</h2>
      <div role="toolbar" aria-label="Rows">
        <button type="button" disabled={control === null} onClick={() => control?.rows.expandAll()}>
          Expand all
        </button>
        <button
          type="button"
          disabled={control === null}
          onClick={() => control?.rows.collapseAll()}
        >
          Collapse all
        </button>
      </div>
      <SuperSchedulerComponent
        controlRef={controlRef}
        startDate="2026-10-05"
        days={14}
        scale="Day"
        cellWidth={56}
        treeEnabled
        treeIndent={16}
        // Region rows group technicians: no jobs and no time selection on them.
        treePreventParentUsage
        resources={RESOURCES}
        events={events}
      />
    </section>
  )
}

You should see "North region" open with its two technicians, "South region" closed, and both buttons working once the scheduler has mounted (control is null until then).

Related options:

OptionDefaultEffect
treeEnabledfalseBuilds rows from children.
treeIndent20Indentation per level, in pixels.
treePreventParentUsagefalseParent rows accept no events and no time selection.
treeAutoExpandtrueA collapsed parent opens when an event is dragged over it for half a second.
rowFilterParentsAlwaysVisibletrueKeeps the ancestors of a row that matches a filter.

Parents can also hold events (a "whole team" booking) unless you set treePreventParentUsage. A single resource refuses dropped events with preventUsage: true, and top-level resources can be pinned above or below the scrolling rows with frozen: 'top' or frozen: 'bottom', which suits summary rows.

Expand and collapse from code

The control exposes the tree through control.rows:

  • control.rows.expandAll() and control.rows.collapseAll();
  • control.rows.expand(level) opens the parents above a level (1 by default, -1 for every level);
  • control.rows.find(id) returns a row; row.expand(), row.collapse() and row.toggle() change it.

onResourceExpand and onResourceCollapse report a toggle after it happened, with the row as args.resource. Use them to remember what the user opened or to keep other parts of your interface in step.

Add row header columns

Without rowHeaderColumns, the row header shows each resource's name. With it, the header becomes a small table:

  • text is the column title (name and title are accepted too);
  • display names a resource field whose value fills the cell (a field inside tags with that name is read first);
  • the first column shows the resource name when it has no display;
  • width sets the width in pixels (default rowHeaderColumnDefaultWidth, 80);
  • sort makes the title clickable: it sorts the rows by that field, ascending then descending, and sets aria-sort on the title.

A resource can also carry its own columns: [{ text }, { html }]. Those entries are matched by position, one per column.

src/TechnicianColumns.tsxtsx
import { useState } from 'react'
import { type SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import 'super-scheduler/styles.css'

type Status = 'available' | 'off'

const TECHNICIANS: SuperScheduler.ResourceData[] = [
  { id: 'ana', name: 'Ana Ruiz', skill: 'Electrical', shift: 'Early', status: 'available' },
  { id: 'ben', name: 'Ben Ortiz', skill: 'HVAC', shift: 'Late', status: 'off' },
  { id: 'cai', name: 'Cai Moreno', skill: 'Plumbing', shift: 'Early', status: 'available' },
]

// `display` reads a resource field, so a column keeps its values when it is reordered.
// `sort` makes the title clickable: it sorts the rows by that field, ascending then descending.
const INITIAL_COLUMNS: SuperScheduler.RowHeaderColumnData[] = [
  { text: 'Technician', width: 150, sort: 'name' },
  { text: 'Skill', display: 'skill', width: 110, sort: 'skill' },
  { text: 'Shift', display: 'shift', width: 70, nonresizable: true },
  { text: 'Status', display: 'status', width: 100 },
]

const STATUS_LABEL: Record<Status, string> = { available: 'Available', off: 'Day off' }

// Runs with `this` = the control, whose rowHeaderColumns are always in the current order.
function decorateStatus(
  this: SuperScheduler.SchedulerApi,
  args: SuperScheduler.SchedulerBeforeRowHeaderRenderArgs,
): void {
  const index = this.rowHeaderColumns?.findIndex((column) => column.display === 'status') ?? -1
  const cell = args.row.columns[index]
  const status = args.row.data.status
  if (cell === undefined || (status !== 'available' && status !== 'off')) return
  // With rowHeaderColumns, row.html is ignored: write the column's own html (trusted markup).
  cell.html = `<span class="tech-status tech-status--${status}">${STATUS_LABEL[status]}</span>`
}

export function TechnicianColumns({ events }: { events: SuperScheduler.EventData[] }) {
  // Kept in state so a parent re-render never hands the control the original order again.
  const [columns, setColumns] = useState(INITIAL_COLUMNS)

  return (
    <SuperSchedulerComponent
      startDate="2026-10-05"
      days={14}
      scale="Day"
      cellWidth={56}
      resources={TECHNICIANS}
      events={events}
      rowHeaderColumns={columns}
      rowHeaderColumnsReorderable
      onRowHeaderColumnsChange={(args) => setColumns([...args.columns])}
      onBeforeRowHeaderRender={decorateStatus}
    />
  )
}

To decorate a cell, use onBeforeRowHeaderRender. With columns, the row's own html is ignored: write args.row.columns[i].html instead. That markup is trusted HTML, so escape user text with SuperScheduler.Util.escapeHtml. For React content in the row header, see React render slots.

Resize and reorder columns

Columns are resizable by default (rowHeaderColumnsResizable: true); nonresizable: true turns it off for one column. After a resize, the control writes the new width into that column object of your rowHeaderColumns array and calls onRowHeaderColumnResized({ column }).

Reordering is off by default. With rowHeaderColumnsReorderable, users drag column titles; resize edges keep priority over the drag. In keyboardMode: 'Full', Alt+Shift+Left and Alt+Shift+Right move the focused title and announce the change. From code, control.moveRowHeaderColumn(from, to) moves a column by its index in the current order.

Every resize and reorder calls onRowHeaderColumnsChange with:

  • columns: the columns in their new order, with their widths;
  • order: order[i] is the original index (in the prop you passed) of the column now at position i;
  • widths and reason ('resize' or 'reorder').

Keep the columns in React state, as the snippet does, and adopt args.columns. A parent that re-renders with a fresh literal array would otherwise hand the original order back to the control.

Filter rows and events

Filters are a parameter plus a callback. control.rows.filter(param) stores the parameter and calls onRowFilter for every row; set args.visible = false to hide it. control.events.filter(param) and onEventFilter work the same way for events. A falsy parameter ('', null, 0) clears the filter.

src/FilteredJobs.tsxtsx
import { useEffect, useMemo, useState } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'

type JobStatus = 'scheduled' | 'done' | 'cancelled'
type Job = { status: JobStatus }

// Module-level handlers keep their identity: a new onRowFilter or onEventFilter
// function makes the control evaluate the active filter again.
const FILTERS: Pick<SchedulerProps, 'onRowFilter' | 'onEventFilter'> = {
  onRowFilter: (args) => {
    const query = String(args.filterParam).toLowerCase()
    args.visible = args.row.name.toLowerCase().includes(query)
  },
  onEventFilter: (args) => {
    const job = args.e.data as SuperScheduler.EventData<Job>
    args.visible = job.status === args.filterParam
  },
}

export function FilteredJobs(props: {
  resources: SuperScheduler.ResourceData[]
  jobs: SuperScheduler.EventData<Job>[]
}) {
  const { controlRef, control } = useSchedulerControl()
  const [query, setQuery] = useState('')
  const [status, setStatus] = useState<JobStatus | ''>('')
  const events = useMemo(() => props.jobs.slice(), [props.jobs])

  // A falsy parameter ('' or null) clears the filter.
  useEffect(() => control?.rows.filter(query.trim() || null), [control, query])
  useEffect(() => control?.events.filter(status || null), [control, status])

  return (
    <>
      <label>
        Technician <input type="search" value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      <label>
        Status
        <select value={status} onChange={(e) => setStatus(e.target.value as JobStatus | '')}>
          <option value="">All</option>
          <option value="scheduled">Scheduled</option>
          <option value="done">Done</option>
          <option value="cancelled">Cancelled</option>
        </select>
      </label>
      <SuperSchedulerComponent
        {...FILTERS}
        controlRef={controlRef}
        startDate="2026-10-05"
        days={14}
        scale="Day"
        treeEnabled
        // Keeps the region row of every matching technician (default true).
        rowFilterParentsAlwaysVisible
        resources={props.resources}
        events={events}
      />
    </>
  )
}

Typing "ben" leaves "North region" and "Ben Ortiz": rowFilterParentsAlwaysVisible keeps the ancestors of every matching row. Choosing a status hides the other events without touching rows.

Filters run in the browser over the data the control holds. A search across data that is not loaded (another month, another site) is a query to your backend, followed by new resources or events.

Select events

By default a click on an event only runs onEventClick. Selection needs eventClickHandling: 'Select':

  • a click toggles the event and clears the others;
  • Ctrl+click (Cmd+click on macOS) toggles it and keeps the others, while allowMultiSelect is true (the default);
  • with eventMultiSelectRange, Shift+click selects the events between the last clicked event and this one, across the visible rows; Ctrl/Cmd+Shift adds them.

onEventSelect runs before the change and can cancel it with args.preventDefault(); onEventSelected runs after it.

The control.multiselect API reads and changes the selection: get(), add(e), remove(e), clear(), isSelected(e) and selectAll({ scope, filter }). selectAll selects the events in view by default; scope: 'all' scans every loaded event, at a cost proportional to the store.

src/SelectableJobs.tsxtsx
import { useEffect, useMemo, useState } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'
import { subscribeScheduler } from 'super-scheduler/hooks'

type Job = { status: 'scheduled' | 'done' }

export function SelectableJobs(props: {
  resources: SuperScheduler.ResourceData[]
  jobs: SuperScheduler.EventData<Job>[]
  onReschedule: (ids: SuperScheduler.EventId[]) => void
}) {
  const { controlRef, control } = useSchedulerControl()
  const [count, setCount] = useState(0)
  const events = useMemo(() => props.jobs.slice(), [props.jobs])

  // Rectangle selections and multiselect calls (selectAll, clear) publish the
  // 'selection' topic; clicks are reported by onEventSelected below.
  useEffect(() => {
    if (control === null) return
    return subscribeScheduler(control, 'selection', (selected) => setCount(selected.length))
  }, [control])

  const config = useMemo<SchedulerProps>(
    () => ({
      // A click toggles the event; Ctrl+click (Cmd+click on macOS) keeps the others.
      eventClickHandling: 'Select',
      // Shift+click selects every event between the last clicked one and this one.
      eventMultiSelectRange: true,
      // Shift+drag over the grid selects the events inside the rectangle.
      rectangleSelectHandling: 'EventSelect',
      // With the rectangle: Shift adds to the selection, Alt removes from it.
      rectangleSelectModifiers: true,
      onEventSelected() {
        setCount(this.multiselect.get().length)
      },
      // Marks selected events for CSS. The hook runs again for every event whose
      // selection changes.
      onBeforeEventRender: (args) => {
        const e = args.control.events.find(args.data.id)
        if (e !== null && args.control.multiselect.isSelected(e)) {
          args.data.cssClass = [args.data.cssClass, 'is-selected'].filter(Boolean).join(' ')
        }
      },
    }),
    [],
  )

  const selectScheduled = () =>
    control?.multiselect.selectAll({
      scope: 'view',
      filter: (e) => (e.data as SuperScheduler.EventData<Job>).status === 'scheduled',
    })

  const reschedule = () => {
    const ids = control?.multiselect.get().map((e) => e.id()) ?? []
    if (ids.length > 0) props.onReschedule(ids)
  }

  return (
    <>
      <div role="toolbar" aria-label="Selection">
        <button type="button" onClick={selectScheduled}>
          Select scheduled in view
        </button>
        <button type="button" onClick={() => control?.multiselect.clear()}>
          Clear
        </button>
        <button type="button" disabled={count === 0} onClick={reschedule}>
          Reschedule {count} {count === 1 ? 'job' : 'jobs'}
        </button>
      </div>
      <SuperSchedulerComponent
        {...config}
        controlRef={controlRef}
        startDate="2026-10-05"
        days={14}
        scale="Day"
        resources={props.resources}
        events={events}
      />
    </>
  )
}

You should see the button count follow clicks, rectangles and the toolbar actions, and selected jobs outlined by this CSS:

csscss
.super-scheduler__event.is-selected {
  z-index: 2;
  box-shadow: 0 0 0 2px var(--super-scheduler-accent), var(--super-scheduler-shadow-2);
}

With allowMultiMove, dragging one selected event moves the whole selection; each event goes through the same move rules (see drag, resize and business rules). In Full keyboard mode, Ctrl/Cmd+A selects every event in view.

Rectangle selection

rectangleSelectHandling decides what a dragged rectangle does:

  • 'EventSelect' selects the events inside it (and runs the callbacks);
  • 'Enabled' only runs the callbacks, for applications that act on the rectangle themselves;
  • 'Disabled' (default) turns it off.

Users start a rectangle with Shift+drag over the grid. To start one without Shift, set args.action = 'RectangleSelect' in onGridMouseDown, or call control.multiselect.startRectangle() before the next press. rectangleSelectMode: 'Row' keeps the rectangle in the row where the press started. With rectangleSelectModifiers, Shift adds to the current selection and Alt removes from it; with neither key, the rectangle replaces the selection.

The callbacks are onRectangleSelecting (each frame; args.visible = false hides the rectangle), onRectangleSelect (cancelable, with events, start, end and resources) and onRectangleSelected.

Select rows

rowClickHandling: 'Select' makes a row header click select the row. Ctrl/Cmd toggles a row, and Shift selects a range from the last clicked row. Selected rows carry aria-selected="true".

src/RosterRows.tsxtsx
import { useMemo, useState } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'

export function RosterRows(props: {
  resources: SuperScheduler.ResourceData[]
  events: SuperScheduler.EventData[]
  onNotify: (resourceIds: SuperScheduler.ResourceId[]) => void
}) {
  const { controlRef, control } = useSchedulerControl()
  const [selected, setSelected] = useState<readonly SuperScheduler.ResourceId[]>([])
  const events = useMemo(() => props.events.slice(), [props.events])

  const config = useMemo<SchedulerProps>(
    () => ({
      // A click on a row header selects the row; Ctrl/Cmd toggles, Shift selects a range.
      rowClickHandling: 'Select',
      onRowSelected() {
        setSelected(this.rows.selection.get().map((row) => row.id))
      },
    }),
    [],
  )

  return (
    <>
      <button
        type="button"
        disabled={selected.length === 0}
        onClick={() => props.onNotify([...selected])}
      >
        Notify {selected.length} technicians
      </button>
      <button
        type="button"
        onClick={() => {
          control?.rows.selection.clear()
          setSelected([])
        }}
      >
        Clear rows
      </button>
      <SuperSchedulerComponent
        {...config}
        controlRef={controlRef}
        startDate="2026-10-05"
        days={14}
        scale="Day"
        resources={props.resources}
        events={events}
      />
    </>
  )
}

control.rows.selection offers get(), add(row), remove(row), isSelected(row) and clear(); these calls highlight the rows at once and fire no callback, which is why the snippet resets its own state after clear(). onRowSelect (cancelable) and onRowSelected run around each selection made by a click.

The selectedRows prop holds the selected resource ids. Pass it to start with a selection; the control updates that same array as the selection changes.

Select time ranges

Dragging over empty cells selects a time range and calls onTimeRangeSelected once, on release, with start, end (exclusive), resource and origin: 'drag'. Other origins:

  • 'click': a plain click on an empty cell selects that cell;
  • 'keyboard': with the keyboard enabled, Enter on a focused cell, or Shift+Left/Right to extend a range;
  • 'api': control.selectTimeRange(start, end, resource).

Check args.origin when only a drag should create something. The selection stays drawn after release, until the next selection or control.clearSelection(). A click elsewhere also clears it, except with timeRangeSelectedHandling: 'HoldForever'; 'Disabled' turns range selection off.

onTimeRangeSelecting runs on every change while dragging. Set args.allowed = false to refuse a range (the shadow is marked and the release selects nothing), or rewrite args.start and args.end to clamp it. Disabled cells and parents under treePreventParentUsage refuse selections without any code.

Several ranges at once

With allowMultiRange, a new drag can add a range instead of replacing the current one: while Ctrl/Cmd is held (multiRangeMode: 'CtrlOrMeta', the default) or always ('Always'). args.multirange lists every selected range, and control.multirange reads or changes them: get(), clear() and add(new SuperScheduler.Selection(start, end, resource)).

src/RangeBlocker.tsxtsx
import { useMemo, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SchedulerProps } from 'super-scheduler'

export interface BlockRequest {
  readonly resource: SuperScheduler.ResourceId
  /** ISO wall-clock values; `end` is exclusive. */
  readonly start: string
  readonly end: string
}

export function RangeBlocker(props: {
  resources: SuperScheduler.ResourceData[]
  events: SuperScheduler.EventData[]
  onBlock: (requests: BlockRequest[]) => void
}) {
  const { controlRef, control } = useSchedulerControl()
  const [ranges, setRanges] = useState<readonly SuperScheduler.SchedulerRange[]>([])
  const events = useMemo(() => props.events.slice(), [props.events])

  const config = useMemo<SchedulerProps>(
    () => ({
      // Ctrl/Cmd + drag adds a range instead of replacing the current one.
      allowMultiRange: true,
      multiRangeMode: 'CtrlOrMeta',
      onTimeRangeSelecting: (args) => {
        // Past days cannot be blocked: the shadow turns red and the drop is refused.
        args.allowed = args.start.getTime() >= SuperScheduler.Date.today().getTime()
      },
      onTimeRangeSelected: (args) => {
        // A plain click on an empty cell also selects it (origin 'click').
        setRanges([...args.multirange])
      },
    }),
    [],
  )

  const block = () => {
    props.onBlock(
      ranges.map((range) => ({
        resource: range.resource,
        start: range.start.value,
        end: range.end.value,
      })),
    )
    control?.clearSelection()
    setRanges([])
  }

  return (
    <>
      <button type="button" disabled={ranges.length === 0} onClick={block}>
        Block {ranges.length} selected {ranges.length === 1 ? 'range' : 'ranges'}
      </button>
      <SuperSchedulerComponent
        {...config}
        controlRef={controlRef}
        startDate="2026-10-05"
        days={14}
        scale="Day"
        resources={props.resources}
        events={events}
      />
    </>
  )
}

Drag over free days of one room, then of another with Ctrl (Cmd on macOS) held: the button reads "Block 2 selected ranges". The ranges reach onBlock as ISO wall-clock values with exclusive ends, ready to send to your backend.

What your application owns

The library keeps trees, filters and selections in the browser, for the current session. Your application decides:

  • where the tree comes from: rows are the resources you pass, and the control never asks for children itself (onLoadNode is reserved);
  • what a filter means when data is not loaded, and how search reaches the backend;
  • what a selection does: bulk actions, permissions to perform them, and their persistence;
  • whether expanded rows and column layouts survive a reload (see saved views).

Selections are not saved anywhere. Expanded state lives in the resource objects you passed until you replace them.

Hotel room planningA shower leaks in Room 104. Rehouse the next guest, block the room for the plumber and find the nights that are already full. Training room schedulingEnrolment outgrew the room. Select both sittings, see what is free for both, move them together and keep the view. Field service dispatchAn urgent job lands in the queue. Find the crew that can take it before it is due.