Columns

Header Groups

A column with children spans them in a header row of its own. From 1.3.0 that group can also fold: down to the one column it is worth reading at a glance, or away entirely behind a drawer with its name down the side.

Grouping Columns

children makes a column a group header spanning its leaves. Only id, header and the three folding fields apply to the group itself; everything else belongs to the leaves. Resizing a group distributes the change across the columns underneath it.

Identity
Employment
Name
Email
Team
Role
Salary
Hoang Kowalski
hoang.kowalski1@example.com
Design
Manager
$127,691.00
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Support
$105,146.00
Bruno Dubois
bruno.dubois3@example.com
Design
Manager
$86,538.00
Farid Haddad
farid.haddad4@example.com
Growth
Analyst
$76,443.00
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Support
$129,812.00
Quyen Tanaka
quyen.tanaka6@example.com
Growth
Analyst
$72,011.00
6 rows
// children makes a column a header group. Only id, header, collapsed,
// collapseMode and headerGroupCell apply to the group itself; everything
// else belongs to the leaves. Nesting is unlimited.
const columns: ColumnDef<Person>[] = [
  {
    id: 'identity',
    header: 'Identity',
    children: [
      { id: 'name', header: 'Name', flex: 1, minWidth: 140 },
      { id: 'email', header: 'Email', flex: 1.4, minWidth: 200 }
    ]
  },
  {
    id: 'employment',
    header: 'Employment',
    children: [
      { id: 'team', header: 'Team', width: 120 },
      { id: 'role', header: 'Role', width: 120 },
      { id: 'salary', header: 'Salary', width: 130, align: 'right', type: 'currency' }
    ]
  }
];

// A group id shares the columns' namespace, so it needs one of its own: a
// group and a column that answer to the same id cannot both be addressed.

// Resizing a group distributes the change across the columns underneath it.

A group id shares the columns' namespace, so it needs one of its own: a group and a column that answer to the same id cannot both be addressed, by the snapshot or by anything else.

Folding to a Summary Column

A group folds when one of its children says what it is for. columnGroupShow: 'open' marks the detail a closed group puts away, 'closed' the summary it folds down to, and a child that says neither is drawn either way. Declaring it on any child is what gives that group a toggle at the trailing edge of its header cell.

Pay folds down to Total and opens up to Base and Bonus. Ccy declares nothing, so it stays either way. Fold it from the header, from the column menu of any column in it, or from the buttons.

Pay is open
Pay
Name
Base
Bonus
Ccy
Team
Hoang Kowalski
$102,153.00
$25,538.00
USD
Design
Bruno Nguyen
$84,117.00
$21,029.00
USD
Growth
Bruno Dubois
$69,230.00
$17,308.00
USD
Design
Farid Haddad
$61,154.00
$15,289.00
USD
Growth
Jonas Yilmaz
$103,850.00
$25,962.00
USD
Core
Quyen Tanaka
$57,609.00
$14,402.00
USD
Growth
6 rows

Columns on screen: Name, Base, Bonus, Ccy, Team. The toggle is the chevron at the trailing edge of the Pay header cell, tinted here through the groupToggle slot so it is easy to find; by default it is the same ghost button the rest of the header controls use.

// A group folds when one of its children says what it is for.
// 'open' is the detail a closed group puts away, 'closed' the summary it
// folds down to, and a child that says neither is drawn either way.
const columns: ColumnDef<Person>[] = [
  { id: 'name', header: 'Name', flex: 1, pinned: 'left' },
  {
    id: 'pay',
    header: 'Pay',
    collapsed: false,              // its starting state
    children: [
      { id: 'salary', header: 'Total', columnGroupShow: 'closed', type: 'currency' },
      { id: 'base', header: 'Base', columnGroupShow: 'open', type: 'currency' },
      { id: 'bonus', header: 'Bonus', columnGroupShow: 'open', type: 'currency' },
      { id: 'currency', header: 'Ccy', width: 80 }   // neither: always drawn
    ]
  }
];

// The toggle sits at the trailing edge of the group's own header cell, and
// the column menu of every column in the group carries the same action.

// A toggle is only offered when the state it would switch to leaves a
// column of the group on screen: a group whose children are all 'open'
// would fold its own header away, and nothing would be left to click.

Folding to a Rail

collapseMode: 'rail' folds the other way: the whole group goes, header and cells alike, and a narrow drawer stands in its place with the group's name down its length. It runs the full height of the grid, header included, and clicking anywhere down it opens the group again, which is why no toggle sits in the header over it.

Nothing has to declare columnGroupShow for this, because the drawer is what unfolds the group. Planning below starts folded, Money starts open, and both fold this way.

planning: folded money: open
Money
Name
Planning
Salary
Completion
Team
Hoang Kowalski
$127,691.00
2%
Design
Bruno Nguyen
$105,146.00
45%
Growth
Bruno Dubois
$86,538.00
35%
Design
Farid Haddad
$76,443.00
99%
Growth
Jonas Yilmaz
$129,812.00
2%
Core
Quyen Tanaka
$72,011.00
72%
Growth
6 rows
// The other way to fold: the whole group goes, header and cells alike,
// and a narrow drawer stands in its place with the group's name down its
// length. Nothing needs columnGroupShow, because the drawer is what
// unfolds the group again.
const columns: ColumnDef<Person>[] = [
  { id: 'name', header: 'Name', flex: 1 },
  {
    id: 'planning',
    header: 'Planning',
    collapseMode: 'rail',
    collapsed: true,               // opens folded
    children: [joined, rating]
  }
];

// The drawer runs the full height of the grid, header included, and the
// name starts at the top and stays there as the rows scroll. Clicking
// anywhere down it opens the group again, which is why no toggle sits in
// the header over it.

// The rail is a column the grid draws for itself, like the selection
// checkbox: it holds no data, so it is never exported, copied or filtered
// on. A pinned group folds to a pinned drawer, which stays at its edge
// while the rest scrolls.

The drawer is a column the grid draws for itself, the way the selection checkbox is: it holds no data, so it is never exported, copied or filtered on, and the group's own header cell is still underneath it, named and marked collapsed, which is how the keyboard reaches it.

A Pinned Group Folds to a Pinned Drawer

A group whose leaves share a pin side keeps that side when it folds, so the drawer stays at the edge while the rest of the table scrolls under it. Fold Who below and scroll the grid sideways: the strip stays where the pinned columns were.

Who is open
Who
Detail
Name
Team
Email
Role
Country
Salary
Hoang Kowalski
Design
hoang.kowalski1@example.com
Manager
Poland
$127,691.00
Bruno Nguyen
Growth
bruno.nguyen2@example.com
Support
Brazil
$105,146.00
Bruno Dubois
Design
bruno.dubois3@example.com
Manager
Nigeria
$86,538.00
Farid Haddad
Growth
farid.haddad4@example.com
Analyst
Brazil
$76,443.00
Jonas Yilmaz
Core
jonas.yilmaz5@example.com
Support
Vietnam
$129,812.00
Quyen Tanaka
Growth
quyen.tanaka6@example.com
Analyst
France
$72,011.00
6 rows
// The other way to fold: the whole group goes, header and cells alike,
// and a narrow drawer stands in its place with the group's name down its
// length. Nothing needs columnGroupShow, because the drawer is what
// unfolds the group again.
const columns: ColumnDef<Person>[] = [
  { id: 'name', header: 'Name', flex: 1 },
  {
    id: 'planning',
    header: 'Planning',
    collapseMode: 'rail',
    collapsed: true,               // opens folded
    children: [joined, rating]
  }
];

// The drawer runs the full height of the grid, header included, and the
// name starts at the top and stays there as the rows scroll. Clicking
// anywhere down it opens the group again, which is why no toggle sits in
// the header over it.

// The rail is a column the grid draws for itself, like the selection
// checkbox: it holds no data, so it is never exported, copied or filtered
// on. A pinned group folds to a pinned drawer, which stays at its edge
// while the rest scrolls.

A Group Inside a Group

columnGroupShow reads against the nearest group above the column, so a nested group's own children answer to it rather than to the outer one. Q1 folds down to Total, taking the whole By month group with it; By month folds down to Jan, which declares nothing against that group.

Q1
By month
Name
Jan
Feb
Mar
Team
Hoang Kowalski
$42,564.00
$42,564.00
$42,564.00
Design
Bruno Nguyen
$35,049.00
$35,049.00
$35,049.00
Growth
Bruno Dubois
$28,846.00
$28,846.00
$28,846.00
Design
Farid Haddad
$25,481.00
$25,481.00
$25,481.00
Growth
Jonas Yilmaz
$43,271.00
$43,271.00
$43,271.00
Core
Quyen Tanaka
$24,004.00
$24,004.00
$24,004.00
Growth
6 rows

Columns on screen: Name, Jan, Feb, Mar, Team

// columnGroupShow reads against the nearest group above the column, so a
// nested group's own children answer to it rather than to the outer one.
const columns: ColumnDef<Row>[] = [
  { id: 'name', header: 'Name', flex: 1 },
  {
    id: 'quarter',
    header: 'Q1',
    children: [
      { id: 'q1Total', header: 'Total', columnGroupShow: 'closed' },
      {
        id: 'months',
        header: 'By month',
        columnGroupShow: 'open',       // the whole nested group folds with Q1
        children: [
          { id: 'jan', header: 'Jan' },
          { id: 'feb', header: 'Feb', columnGroupShow: 'open' },
          { id: 'mar', header: 'Mar', columnGroupShow: 'open' }
        ]
      }
    ]
  }
];

// Folding Q1 puts the nested group away with everything under it. Folding
// By month keeps Jan, which declares nothing against that group, and puts
// Feb and Mar away.

Drawing the Header Yourself

headerGroupCell draws a group header the way headerCell draws a leaf one. It is handed the group cell and the same action the built-in control performs, and the control stays beside whatever the snippet draws. header stays the label everything non-visual reads: the column menu, the announcer and the name of the fold action.

Pay
Name
Base
Team
Hoang Kowalski
$102,153.00
Design
Bruno Nguyen
$84,117.00
Growth
Bruno Dubois
$69,230.00
Design
Farid Haddad
$61,154.00
Growth
Jonas Yilmaz
$103,850.00
Core
5 rows
<script lang="ts">
  import type { HeaderGroupContext } from '@sv5ui/datagrid';
</script>

<!-- headerGroupCell draws a group header the way headerCell draws a leaf
     one. The grid's own fold control stays beside whatever it draws, and
     \`header\` stays the label everything non-visual reads: the column menu,
     the announcer, and the name of the fold action. -->
{#snippet payHeader({ cell, toggle }: HeaderGroupContext)}
  <button onclick={toggle} class="flex items-center gap-1.5">
    <Icon name={cell.collapsed ? 'lucide:chevrons-right' : 'lucide:chevrons-left'} />
    {cell.header}
    <Badge label={String(cell.span)} size="xs" />
  </button>
{/snippet}

<!-- cell carries the group's id, header, span, the leaves under it, and
     whether it is collapsible and collapsed. toggle does nothing on a
     group that cannot fold. -->
{ id: 'pay', header: 'Pay', headerGroupCell: payHeader, children: [...] }

Header Group Context

What a headerGroupCell snippet receives.

FieldDescription
cell.idThe group's id
cell.headerIts label, and its accessible name
cell.startIndex of the first column it spans, which is what names the cell in the DOM
cell.spanHow many columns it covers right now
cell.isPlaceholderTrue for the filler drawn above a column that has no group at this level. A placeholder never folds
cell.leafIdsThe leaves under it
cell.pinnedThe pin side its leaves share, if any
cell.collapsibleWhether this group is offered a toggle at all
cell.collapsedWhether it is folded right now
toggleThe same action the built-in control performs; a no-op on a group that cannot fold

Driving It From Code

Every surface that folds a group comes through the same door, so the header toggle, the column menu, the keyboard and your own call all announce it and all emit columnGroupToggled.

// Both are on grid.api and on getColumnOps(grid). The model settles
// whether a group may take that state at all, so a group with no toggle
// stays as it is rather than folding itself off the screen.
grid.api.toggleGroup?.('pay');
grid.api.setGroupCollapsed?.('pay', true);

// Read it back:
grid.columns.isCollapsed('pay');        // boolean
grid.columns.isRail('planning');        // folds to a drawer rather than a summary
grid.columns.groupDef('pay');           // the group's own definition
grid.columns.foldableGroupOf('salary'); // the group this column folds with, if any

// Every fold announces and emits, the same way a column move does:
grid.events.on('columnGroupToggled', ({ groupId, collapsed }) => save(groupId, collapsed));

// The keyboard reaches the levels above the leaf header row:
//   ArrowUp from a leaf header   walks up into the groups
//   ArrowLeft / ArrowRight       step between the groups of that level
//   Enter or Space               folds the group under the caret
//   ArrowDown                    comes back down

The keyboard reaches the header levels themselves: ArrowUp from a leaf header walks up into the groups, ArrowLeft and ArrowRight step between the groups of a level, Enter or Space folds the one under the caret, and ArrowDown comes back. A column with no group above it has nowhere to go up to. The accessibility page has the rest of the map.

Folding Is Not Hiding

What the column chooser put away stays away when a group opens, and what a group folded comes back when it opens. The model keeps them apart for that reason, and a snapshot keeps both: columns by their own id, groups by theirs.

// Folding is not hiding, and the two are kept apart in the model:
grid.columns.hiddenOverrides;   // what the column chooser put away, by column
grid.columns.collapsedGroups;   // what a fold put away, by group

// So ticking a column in the chooser never opens a single column in the
// middle of a closed group, and a column put away stays away when its
// group opens.

// Both travel in a snapshot, keyed the way they are held:
{
  version: 1,
  columns: {
    hidden: { country: true },
    collapsed: { pay: true }
  }
}

// A group that has since disappeared is dropped on the way back in, the
// same as a column id that no longer exists.

A group that has since disappeared is dropped on the way back in, the same as a column id that no longer exists, so a saved view survives a schema that moved on. The persistence page covers the rest of the snapshot.

What a Group Declares

The fields that mean something on a group, or on a child of one.

FieldDefault
children-
columnGroupShow-
collapsedfalse
collapseMode'summary'
headerGroupCell-

Folding From Code

What grid.api, getColumnOps(grid) and the column model offer.

MemberDescription
toggleGroup(id)On grid.api and on getColumnOps(grid). Announces and emits columnGroupToggled
setGroupCollapsed(id, on)The same door, when you know which state you want
columns.isCollapsed(id)The group's own starting state, with the user's answer over it
columns.isRail(id)Whether this group folds to a drawer rather than to a summary column
columns.groupDef(id)The group node itself, for whatever draws or names it
columns.foldableGroupOf(id)The group a column would fold with, nearest first. What the column menu asks before offering the action
columnGroupToggledEmitted on every fold, whichever surface asked for it