> For the complete documentation index, see [llms.txt](https://docs.rowsncolumns.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.rowsncolumns.app/configuration/features/outline-grouping.md).

# Outline & Grouping

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

```ts
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`:

```ts
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:

```ts
sheet.rowMetadata[3] = { outlineLevel: 1 };
sheet.rowMetadata[4] = { outlineLevel: 1 };
sheet.rowMetadata[5] = { outlineLevel: 1 };
// row 6 is the summary — left at outlineLevel 0
```

Collapsing that group then sets:

```ts
sheet.rowMetadata[3].hiddenByGroup = true;
sheet.rowMetadata[4].hiddenByGroup = true;
sheet.rowMetadata[5].hiddenByGroup = true;
sheet.rowMetadata[6].collapsed = true; // summary shows the "+" button
```

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

```tsx
import { useSpreadsheetState } from "@rowsncolumns/spreadsheet-state";

const {
  onGroupRows,
  onUngroupRows,
  onCollapseRowGroup,
  onExpandRowGroup,
  onGroupColumns,
  onUngroupColumns,
  onCollapseColumnGroup,
  onExpandColumnGroup,
  onSetOutlineDepth,
} = useSpreadsheetState({ sheets, sheetData, ... });
```

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

```tsx
import {
  CanvasGrid,
  ColumnOutlineGutter,
  RowOutlineGutter,
} from "@rowsncolumns/spreadsheet";

<div className="flex flex-col h-full">
  <ColumnOutlineGutter
    sheetId={activeSheetId}
    columnMetadata={columnMetadata}
    rowMetadata={rowMetadata}
    onCollapseColumnGroup={onCollapseColumnGroup}
    onExpandColumnGroup={onExpandColumnGroup}
    onSetOutlineDepth={onSetOutlineDepth}
  />
  <div className="flex flex-1 min-h-0 min-w-0">
    <RowOutlineGutter
      sheetId={activeSheetId}
      rowMetadata={rowMetadata}
      onCollapseRowGroup={onCollapseRowGroup}
      onExpandRowGroup={onExpandRowGroup}
      onSetOutlineDepth={onSetOutlineDepth}
    />
    <CanvasGrid /* ...usual props */ />
  </div>
</div>
```

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:

```tsx
<RowOutlineGutter
  ...
  summaryBelow={sheet.outlinePr?.summaryBelow !== false}
/>
<ColumnOutlineGutter
  ...
  summaryRight={sheet.outlinePr?.summaryRight !== false}
/>
```

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.
