# Resource trees, columns and selection

> Group resources in a tree, add sortable, resizable and reorderable row header columns, filter rows and events, and select events, rows or time ranges in Pro.

Source: https://superscheduler.org/en/docs/trees-columns-selection/
Reviewed: 2026-10-07

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.

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`.

```tsx
// src/TeamPlanner.tsx
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:

| Option | Default | Effect |
|---|---|---|
| `treeEnabled` | `false` | Builds rows from `children`. |
| `treeIndent` | `20` | Indentation per level, in pixels. |
| `treePreventParentUsage` | `false` | Parent rows accept no events and no time selection. |
| `treeAutoExpand` | `true` | A collapsed parent opens when an event is dragged over it for half a second. |
| `rowFilterParentsAlwaysVisible` | `true` | Keeps 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.

> **Behavior:**
> When a row opens or closes, the control writes the new state back into that resource object's `expanded` field. If your application passes a new `resources` array later, the `expanded` values in that array win.

### 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.

> **Limitation:**
> These two handlers are notifications: their args type includes `preventDefault()`, but calling it does not keep the row from toggling. Loading children when a parent opens (`onLoadNode`) and dragging rows to reorder them (`rowMoveHandling`) are reserved and not implemented. `treeAnimation` is accepted and has no effect; rows open without animation.

## 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.

```tsx
// src/TechnicianColumns.tsx
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](https://superscheduler.org/en/docs/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.

> **Tip:**
> Fill reorderable columns through `display`. A value read from a field follows its column; positional `resource.columns[i]` entries stay at their position. To persist widths and order between sessions, save them with [saved views](https://superscheduler.org/en/docs/panes-saved-views/).

## 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.

```tsx
// src/FilteredJobs.tsx
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.

> **Behavior:**
> The control evaluates the active filter again when the `onRowFilter` or `onEventFilter` function changes. Define them at module level or memoize them. Shift+click range selection skips hidden rows and filtered events.

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.

```tsx
// src/SelectableJobs.tsx
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:

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

> **Limitation:**
> In 0.1.0 a selected event gets no visual mark of its own: the stylesheet's selected style targets a `data-selected` attribute that events do not receive. Add a class in `onBeforeEventRender`, as the snippet does; the hook runs again for each event whose selection changes. With the optional `theme` prefix, selected events also get a `{theme}_selected` class.

> **Behavior:**
> Clicks report through `onEventSelected`. Rectangle selections and calls to `multiselect` (`selectAll`, `clear`, `add`, `remove`) publish the `selection` topic of `super-scheduler/hooks`. The snippet listens to both so the count is always current.

With `allowMultiMove`, dragging one selected event moves the whole selection; each event goes through the same move rules (see [drag, resize and business rules](https://superscheduler.org/en/docs/drag-resize-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"`.

```tsx
// src/RosterRows.tsx
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))`.

```tsx
// src/RangeBlocker.tsx
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](https://superscheduler.org/en/docs/panes-saved-views/)).

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

## Related
→ https://superscheduler.org/en/examples/hotel-rooms/
→ https://superscheduler.org/en/examples/training-rooms/
→ https://superscheduler.org/en/examples/field-service-dispatch/
- [Keyboard, accessibility and touch](https://superscheduler.org/en/docs/keyboard-accessibility-touch/) lists the selection keys.
- [Controlled events and callbacks](https://superscheduler.org/en/docs/controlled-state/) explains how selected events reach your state.
- [Coordinated panes and saved views](https://superscheduler.org/en/docs/panes-saved-views/) persists collapsed rows and column widths.
