Outline & Grouping

Group rows or columns into collapsible outlines with Excel-style +/- gutter buttons and a 1/2/3 level selector

Outlining lets users collapse and expand contiguous ranges of rows or columns into nested levels — the same Shift + Alt + → mechanic from Excel. The state lives on DimensionProperties inside each sheet, the actions are exposed as hooks on useSpreadsheetState, and a pair of optional render components (RowOutlineGutter, ColumnOutlineGutter) paint the +/- buttons and bracket lines next to the canvas.

Outline state round-trips through both the XLSX and ODS importer/exporter, so a sheet grouped in your app opens grouped in Excel / LibreOffice and vice versa.

Data model

Group state lives on the sheet's rowMetadata / columnMetadata arrays. Both arrays are 1-indexed (index 0 is reserved) and entries are DimensionProperties:

type DimensionProperties = {
  // Depth of the group this row/column belongs to (1..7).
  outlineLevel?: number;
  // True on a summary cell whose group is currently collapsed —
  // controls whether the gutter shows + or -.
  collapsed?: boolean;
  // True on a child cell when its group is collapsed. Distinct from
  // `hiddenByUser` so an outline expand doesn't unhide a manually
  // hidden row.
  hiddenByGroup?: boolean;
  // ...sizing/visibility flags
};

Sheet-level direction settings live in sheet.outlinePr:

type OutlinePr = {
  // Where the row summary sits relative to its group.
  //   true  (Excel default): summary is BELOW the group
  //   false:                  summary is ABOVE the group
  summaryBelow?: boolean;
  // Where the column summary sits.
  //   true  (Excel default): summary is to the RIGHT of the group
  //   false:                  summary is to the LEFT
  summaryRight?: boolean;
};

A typical level-1 row group with the level-1 summary sitting at row 6 looks like:

Collapsing that group then sets:

Nested groups stack the level — e.g. cols 3–4 inside a level-1 group spanning 2–5 carry outlineLevel: 2. The maximum depth is 7 (matches Excel).

State hooks

useSpreadsheetState exposes nine callbacks. All of them undo/redo via the same patch history the rest of the spreadsheet uses, and emit a matching onCommand event so you can mirror the action to a backend.

Callback
Signature
Behavior

onGroupRows

(sheetId, rowIndexes: number[])

Increments outlineLevel on the selected rows. Non-contiguous selections produce one group per contiguous span.

onGroupColumns

(sheetId, columnIndexes: number[])

Same, for columns.

onUngroupRows

(sheetId, rowIndexes: number[])

Decrements outlineLevel. Once a row's level reaches 0 the collapsed / hiddenByGroup flags are cleared too.

onUngroupColumns

(sheetId, columnIndexes: number[])

Same, for columns.

onCollapseRowGroup

(sheetId, summaryRowIndex)

Hides every row in the group that summary belongs to (respects outlinePr.summaryBelow).

onCollapseColumnGroup

(sheetId, summaryColumnIndex)

Hides every column in the group that summary belongs to.

onExpandRowGroup

(sheetId, summaryRowIndex)

Reverses a collapse. Manual hiddenByUser flags on individual rows survive the expand.

onExpandColumnGroup

(sheetId, summaryColumnIndex)

Reverses a column collapse.

onSetOutlineDepth

(sheetId, axis: "row" | "column", depth)

Excel-style 1 / 2 / 3 selector. depth=1 collapses every group, depth=maxLevel+1 expands every group, intermediate values keep outer levels open and collapse inner ones.

Rendering the gutter

@rowsncolumns/spreadsheet ships two memoised render components — RowOutlineGutter and ColumnOutlineGutter — that paint the +/- buttons, bracket lines, and 1/2/3 level selector. They are siblings of the CanvasGrid (not children); each gutter is a no-op when the sheet has no groups on its axis, so it's safe to always render them.

Why siblings, not children? CanvasGrid relies on its wrapper (.rnc-canvas-wrapper) being the position: relative ancestor for active-cell and selection overlays. Wrapping or nesting the canvas inside the gutter would break that anchor.

Layout details

  • The column gutter is a horizontal strip above the canvas; its height = (maxColumnOutlineLevel + 1) * 20 + 4 px.

  • The row gutter is a vertical strip to the left of the canvas; its width = (maxRowOutlineLevel + 1) * 20 + 4 px.

  • computeOutlineGutterSize(metadata) returns the same value if you need to inset something else.

  • The column gutter scrolls horizontally in lockstep with the canvas (via the scrollSubscriber signal); the row gutter scrolls vertically.

Direction (summaryBelow / summaryRight)

Pass the sheet's direction through to flip where the +/- button sits:

Both default to true (Excel's default).

Keyboard shortcuts

Outline shortcuts are wired through the same keyboard-handler the rest of the canvas uses:

Shortcut
Action

Shift + Alt + →

Group selected rows / columns

Shift + Alt + ←

Ungroup selected rows / columns

Import / Export

Format
Import
Export
Round-trip tests

XLSX

libs/toolkit/excel-parser/__tests__/outline-roundtrip.spec.ts

ODS

libs/toolkit/excel-parser/__tests__/ods-outline-roundtrip.spec.ts

XLSX details

  • Children: <row outlineLevel="N" hidden="1" collapsed="…">, <col outlineLevel="N" …>.

  • Sheet direction: <sheetPr><outlinePr summaryBelow="…" summaryRight="…"/></sheetPr> — only emitted when the sheet diverges from Excel's defaults.

  • hiddenByGroup is encoded as hidden="1" + outlineLevel > 0; the parser routes it back into hiddenByGroup rather than hiddenByUser so a user-driven hide isn't conflated with a group hide.

ODS details

  • Groups are wrapped in <table:table-row-group> / <table:table-column-group> elements, nested for level > 1.

  • A collapsed group carries table:display="false"; rows/columns inside such a wrapper get hiddenByGroup: true, and the summary cell (per outlinePr.summaryBelow / summaryRight) gets collapsed: true.

  • ODS has no native equivalent of <outlinePr>. Non-default summaryBelow / summaryRight are not persisted through an ODS round-trip — the sheet reopens with Excel defaults.

Notes & gotchas

  • Maximum depth is 7. onGroupRows / onGroupColumns clamp at level 7; further Group actions on already-deep rows are no-ops.

  • Manual hide survives an expand. useOnExpandRowGroup / useOnExpandColumnGroup only clear hiddenByGroup; hiddenByUser from onHideRow / onHideColumn is preserved. This is better than Excel, which conflates the two.

  • Depth selector and nested groups. onSetOutlineDepth decides hiding from each cell's own outlineLevel, so a level-2 child stays hidden when the surrounding level-1 group expands. (Earlier versions wrote hiddenByGroup on whole spans during the scan, which caused the outer-group expand to clobber the inner-group collapse.)

  • Overlapping groups are not supported. The data model uses outlineLevel as a per-cell depth, not a group ID, so groups must be nested or disjoint — same constraint Excel has. Re-grouping a range that overlaps an existing group nests it inside the larger group.

Last updated