Grid Tool panel

Tool panel

The rail of section tabs at the grid's right edge: column visibility and pinning, the AND/OR filter builder, and grouping with per-column aggregates.

The tool panel is a rail of section tabs docked at the grid's right edge; selecting a tab opens a full-height pane beside it. It is on by default — every <PretableSurface> and <Pretable> renders the rail with no section open — because column control is table-stakes UI a reader expects to find on the grid itself, not something each application should have to rebuild. The rail consumes width from the surface's own box rather than adding to it, so enabling (or opening) it never reflows the surrounding layout.

Three sections ship today, and all three are documented below: Columns, which hides, pins, and reorders columns; Filters, which builds the query's AND/OR filter tree; and Grouping, which manages the group-by levels, expansion, the hide-grouped-columns switch, and per-column aggregates. The rail is not closed at those three — Custom sections covers adding your own panes, hiding built-ins, and reordering the tabs. In the grid below, defaultActiveSection opens the columns pane on load — uncheck a row to hide its column, drag a grip to reorder, use the row's ⋮ menu to pin:

The columns section

The pane opens on load via defaultActiveSection. Hide, pin, and reorder columns and watch the drawn header follow each commit.

.md

Enabling, disabling, configuring

toolPanel accepts boolean | PretableToolPanelConfig. The default is true: rail visible, no pane open. Pass false to remove rail and pane both:

tsx
<PretableSurface
  ariaLabel="Holdings"
  rows={rows}
  columns={columns}
  toolPanel={false}
/>

The <Pretable> preset forwards toolPanel verbatim, so the same two lines of intent work there — the preset is default-on for the same reason the surface is:

tsx
<Pretable
  ariaLabel="Holdings"
  rows={rows}
  columns={columns}
  toolPanel={false}
/>

Configuration

Passing an object keeps the panel on, chooses which sections the rail carries, and controls which one is open. ToolPanelSectionId is the union of shipped section ids — today "columns", "filters", and "grouping". The three active-section fields take the wider PretableToolPanelSectionId — the built-in literals plus any custom section's id — so a consumer-authored pane is nameable everywhere a built-in is.

OptionTypeRequiredDescription
sectionsreadonly (ToolPanelSectionId | PretableToolPanelSection)[]noThe complete rail, in order: built-ins by id, custom sections as descriptors, freely interleaved. Absent, the rail is the three built-ins; [] turns the panel off.
defaultActiveSectionPretableToolPanelSectionId | nullnoThe section open on mount when the surface owns the state. Defaults to null — rail visible, nothing open.
activeSectionPretableToolPanelSectionId | nullnoPresent (including null, meaning "open nothing") makes the open section fully controlled: tab clicks then only report through onActiveSectionChange.
onActiveSectionChange(section: PretableToolPanelSectionId | null) => voidnoReports every open/close, controlled or not — the same assert-and-report split as state and onSelectionChange.

The rail's accessible name and the built-in section tab labels are messages like any other grid string: override toolPanelLabel, toolPanelColumnsLabel, toolPanelFiltersLabel and toolPanelGroupingLabel on the messages prop to localize them. A custom section's label is not a message — it is the descriptor's own plain string, localized where the rest of your application is.

The columns section

The pane lists every data column in drawn order — the order the engine actually renders, grouped into Pinned left, unpinned, and Pinned right subgroups. Each row carries:

  • A visibility checkbox. Unchecking hides the column from the grid. Hidden columns stay listed, dimmed, exactly where they were — a hidden column keeps its place in the order and its pin, so re-showing it puts it back where it came from rather than appending it somewhere surprising.
  • A drag grip. Dragging a row reorders the column; dragging past a subgroup boundary re-pins it (into Pinned left, Pinned right, or back to unpinned). The commit happens on drop, never mid-drag, and Escape mid-drag cancels without committing.
  • A ⋮ menu with Pin left, Pin right, and Unpin. The menu is also the only way to pin into an empty pinned group: with no rows in a subgroup there is no boundary to drag or arrow across, so the menu is the affordance that creates the first member.
  • Search filters the list by column label; Reset columns restores the order, pinning, and visibility the grid mounted with.

Everything the panel commits writes straight into the engine, so the grid it changes is the same layout header gestures change. That has one consequence worth knowing before you control layout state: a controlled state.columnOrder or state.columnPinned remains the authority, and it re-imposes the prop's layout over the panel's commits whenever the write-back effect re-runs — any state change reaching the surface is enough. Leave those slices uncontrolled when the panel should own them.

The filters section

The Filters pane is a builder over the query's filter tree — the same filters the header funnel writes, and the same nesting a server-side implementation has to translate. Where the funnel edits one column in isolation, the pane shows the whole tree at once, and it is the only chrome that can nest one.

Add a filter, switch its column to Sector and give it a value, and watch the count under the grid drop; open that column's header funnel and find the row you just built already sitting in it; then add a group and flip its and to or — a filter built inside the group stays the pane's, because a funnel only ever addresses a top-level row:

The filters section

The Filters pane opens on load via defaultActiveSection. Add a condition, nest a group, flip a list between and and or, and watch the row count follow — then open a header funnel onto the same tree.

.md

Rows, runs, and the join

The pane renders one row per leaf of the tree: a column picker, an operator picker, and whatever value control the operator's shape calls for — one field, a min/max pair, a checklist, or nothing at all for isEmpty and isNotEmpty. Those are the same operators the funnel offers, resolved the same way from the column's type and its filterOperators. + filter appends a row, + group appends a nested list with its own add pair, and each row's ✕ removes it.

A list of siblings carries exactly one connective. The first row reads Where; every row after it carries the list's join, and pressing it flips the connective for the whole list, not for that one row. That is the tree's shape rather than a simplification of it — a group is its operator (PretableFilterNodeFor's group arm is { op, children }), so there is nowhere for a per-row connective to live. A and B or C is not a tree, and a control that appeared to build one would be writing a query the engine then has to interpret some other way.

The root list is an implicit AND, and its join renders as plain text with no button: the query's filters is an array rather than a group, so there is no op on it to flip. Nesting is therefore the whole grammar — to express an OR, add a group and flip the join inside it.

An empty group matches every row

A group with no children evaluates true, under and and or alike. That is deliberate, and it is what makes the builder usable at all: you add a group before you have anything to put in it, and a group that evaluated false while empty would blank the grid for exactly as long as it took you to fill it.

The same rule covers a row you have started but not finished. Until a row has a value the engine can act on, it holds its position in the tree as an empty group — so a half-typed row constrains nothing, and neither does one you walk away from. That has a visible consequence worth knowing: abandon a row and it is still there next time the pane opens, but as an empty group — a bare rail with an add pair, because the tree kept the position while the unfinished draft did not survive. Remove it with its ✕.

What commits, and when

Everything the pane commits goes straight into the engine's query — there is no apply button, and the grid re-filters as you build.

  • An operand is debounced ~200 ms, the same dwell the funnel menu uses. A value that committed on every keystroke would rebuild the row model once per character, which on a large grid is a cost you can feel. The dwell is keyed on the operand slot, not on how the value is entered, so a date picked from a date field waits exactly as a typed one does.
  • Every discrete change applies immediately: the column, the operator, an enum checkbox, the join button, adding a row or a group, removing one. Nothing about those is mid-thought, so nothing waits.
  • Nesting is bounded at 64 levels below the root. That is the engine's own bound — a deeper tree makes setQuery reject the query rather than filter with it — so at the bound both add buttons refuse, and each renders the reason as text it points at. A disabled button is not focusable, so a tooltip alone would reach nobody.
  • + filter is refused when there is no column to filter on — every column opted out with filterable: false, or no columns at all — in the same way and for the same reason: a control that cannot act says so.

One tree, two chromes

The funnel and the pane edit one model, so a filter written in either turns up in the other. Two asymmetries follow from what a funnel can address:

  • A funnel writes and edits its column's top-level leaf — a row sitting directly in the root list. It has no address for a leaf nested inside a group, so a filter you build inside a group stays the pane's to edit.
  • A funnel lights when its column appears anywhere in the tree, nested or not. The tint answers "is this column constrained", which is true either way; the menu it opens is still the top-level leaf.

Which columns each chrome offers differs too, in the one direction the pane can afford to be more generous. filterable: false removes a column from both. But a hidden column still appears in the pane's picker — marked as hidden in the picker's accessible name, not by dimming alone, which would be a WCAG 1.4.1 failure — and so does a column that grouping has drawn out of the header. Neither has a header left to hang a funnel on, and filtering by a column you have hidden or grouped by is a real thing to want.

A grouped column carries its own marker — the toolPanelColumnGroupedMarker message, default "grouped" — and it appears only while the column is not drawn: grouped, with Hide grouped columns on. The marker's job is to explain why a column you can filter by is absent from the header, and a grouped column that is still drawn needs no explanation. The two markers are distinct — "grouped" is not "hidden" — and where both apply, hidden wins.

DOM hooks and strings

The parts expose stable hooks, the same way the funnel menu does: data-pretable-filter-row on a row, with data-pretable-filter-row-column, -operator, -value, and -remove on its controls; data-pretable-filter-join on the connective (a <button> where the join can change, a <span> where it cannot); data-pretable-filter-add on the two add buttons; data-pretable-filter-rail on a nested group's rail; data-pretable-filter-column-hidden on a row whose column is hidden; data-pretable-filter-column-grouped on a row whose column is grouped away (grouped and not drawn); and data-pretable-filter-empty on the "no filters" message.

Every string the pane renders comes from the messages prop — the toolPanelFilter* labels for a row's controls and its join, plus toolPanelAddFilterLabel, toolPanelAddGroupLabel, toolPanelRemoveFilterLabel, toolPanelNoFiltersMessage, toolPanelNoFilterValuesMessage, and the two refusal sentences (toolPanelFilterDepthRefusal and toolPanelNoFilterColumnsRefusal) — with one exception. The operator names in the operator picker are not messages. They are shared verbatim with the header funnel, which is not on the messages layer yet, and localizing them here alone would show one grid's operators in two languages at once. Until the funnel joins, that list is English.

The grouping section

The Grouping pane manages row grouping end to end: which columns group the rows, whether the groups are open, whether a grouped column keeps its place in the body, and what each column's group rows aggregate. Four blocks, top to bottom — group-by, expansion, the hide-grouped switch, aggregates — ordered by how often each is reached for.

Add Sector as a second level from + Add group, drag a grip to reorder the two, and watch the strip above the header follow; then change Market value's aggregate from Default (Sum) to None and watch its group cells blank:

The grouping section

The Grouping pane opens on load via defaultActiveSection. Add and reorder group-by levels, expand and collapse everything at once, flip hide-grouped-columns, and override a column's aggregate — with the drag-to-group strip reflecting every change.

.md

Group by

The list renders one row per grouping level, in level order — grip, label, ✕ — the columns section's row anatomy on a different model. + Add group opens a menu of every data column not already grouped (there is no opt-in flag; any data column can group), ✕ removes a level, and dragging a grip reorders — the commit happens on drop, never mid-drag, and Escape mid-drag cancels without committing. With nothing grouped the list says so and the rest of the pane waits.

The list is a pure projection of the query's rowGroups — the same model the drag-to-group strip writes, and the strip stays: strip and pane are two projections of one model, so a level added in either appears in both, and neither keeps a copy that could disagree. A grouped column the columns section has hidden still shows in the list, unmarked — grouping by a hidden column is legal and in effect, and the strip does not mark it either.

Expand all, collapse all

Two buttons, calling the row model's own expand-all and collapse-all. Both are disabled while nothing is grouped — they act on groups, and with none they are noise — with the standard disabled treatment rather than disappearing, so the pane's shape does not jump as grouping comes and goes.

Hide grouped columns

A labelled switch over the engine's hideGroupedColumns — on by default, matching the grid: a grouped column's values are already in the group rows, so drawing its body column too usually just repeats them. Flip it off to keep grouped columns in the body.

One thing to know before you also pass the surface's hideGroupedColumns prop: the prop seeds this state at mount and keeps writing changed values back after mount. A consumer who keeps driving the prop declaratively retains ownership — the pane and the prop are a two-writer situation the grid does not arbitrate; a consumer who leaves the prop alone after mount cedes the state to the switch.

Aggregates

One picker per data column, listing:

  • Default (…) — no override: the column follows whatever its prop declares, and the parenthetical shows what that is — Default (Sum) for aggregate: "sum", Default (None) for no declaration, Default (Custom) for a declared custom aggregator. Choosing it clears any override.
  • None — an override meaning "show no aggregate": the row model strips the column's declared aggregate before the query compiles, so the group cell renders empty. This is a value, not a clear — which is why it and Default (…) both exist.
  • The type-valid builtinsSum, Average, Min, Max, Count for number columns; Count alone for every other type, mirroring what the engine accepts. Choosing one writes an engine override.

Default (…) is an explicit option so that "no override" and "overridden to the same value" never look alike: a column showing Default (Sum) follows its prop, and one showing Sum has been pinned there — change the prop and only the first follows. Overrides are per-column configuration, not per-grouping: they persist while nothing is grouped (the block stays visible then, so a configured override stays reachable) and re-apply when grouping returns.

The block renders in rows mode only. In explicit-model mode the caller owns their row model and the surface never re-requests its derivations, so an aggregate write would be recorded in engine state and have no effect on what a group row shows — a visible-but-inert picker being the worst outcome, the block is absent there rather than disabled. State an aggregate on the model's own columns instead.

The grouping section's DOM hooks and strings

The section's container is data-pretable-tool-grouping. A group-by row is data-pretable-tool-group-row (with data-pretable-column-id), its grip data-pretable-tool-row-grip and its remove button data-pretable-tool-group-remove; the add button is data-pretable-add-group and its menu data-pretable-add-group-menu. The expansion pair is data-pretable-expand-all / data-pretable-collapse-all, the switch's input data-pretable-hide-grouped, and an aggregate row data-pretable-aggregate-row (with data-pretable-column-id).

Every string is a message: toolPanelGroupByLabel, toolPanelAddRowGroupLabel, toolPanelRemoveGroupLabel, toolPanelReorderGroupLabel, and toolPanelNoGroupsMessage for the group-by block; toolPanelExpandAllLabel and toolPanelCollapseAllLabel; toolPanelHideGroupedColumnsLabel; and for the pickers toolPanelAggregatesLabel, toolPanelAggregateColumnLabel, toolPanelAggregateDefaultOption, toolPanelAggregateNoneOption, toolPanelAggregateCustomLabel, and the five builtin names (toolPanelAggregateSumLabel through toolPanelAggregateCountLabel). The reorder grip deliberately does not reuse the columns section's "Reorder ": the two grips coexist in one panel, and identical accessible names would leave a screen-reader user unable to tell reordering a column from reordering a grouping level.

Custom sections

The rail is not closed at the three built-ins. toolPanel.sections is the complete roster: present, it states the whole rail in order — built-ins referenced by id, custom sections as PretableToolPanelSection descriptors, freely interleaved. One shape subsumes appending a pane, hiding a built-in, reordering the tabs, and slotting your section between two shipped ones:

tsx
<PretableSurface
  ariaLabel="Trades"
  rows={rows}
  columns={columns}
  toolPanel={{
    // Grouping is dropped, the custom section sits second: a subset,
    // a reorder, and an interleave in one array.
    sections: ["columns", notesSection, "filters"],
  }}
/>

Absent, the rail is the three built-ins exactly as shipped. A built-in referenced by id keeps every behavior it has today — its messages, its config coupling — the roster only selects and orders. And sections: [] is legal and means "no sections": rail hidden, pane closed, the panel effectively off — equivalent to toolPanel={false}, but reachable from a dynamic roster without switching prop shapes.

An invalid roster throws at render, with a [pretable] toolPanel.sections: message naming the id and the rule: a duplicate id, an empty or whitespace id, an unknown built-in reference, or a custom descriptor reusing a built-in id. These are programming errors present from the first render, and a silently dropped tab would be the harder bug to find. The built-in-collision error also states that replacing a built-in section is not supported — reusing "columns" as a custom descriptor's id is a collision, not an override.

An activeSection naming an id the roster does not carry is the one lenient case: the rail renders alone and nothing opens, without throwing — a controlled consumer may set the id a frame before the roster carries it.

In the grid below, the rail carries Columns, a custom Actions section, then Filters — press its buttons to scroll the grid or export it through the handle onGridReady delivers:

A custom section

toolPanel.sections states the complete rail: Columns, a consumer-authored Actions section, then Filters, with grouping left off. The custom pane's buttons reach the grid through the handle onGridReady delivers — scroll to a row, or export the grid as CSV.

.md

The descriptor

FieldTypeRequiredDescription
idstringyesNon-empty, no whitespace — it is interpolated into DOM ids (the tab's id, the pane's aria-labelledby), where whitespace is forbidden. Must not collide with "columns", "filters", or "grouping". Carried verbatim on data-pretable-section.
iconComponentType<{ className?: string }>yesThe rail tab's icon component.
labelstringyesThe rail tooltip and the tab's accessible name.
render() => ReactNodeyesThe pane's content. Takes no arguments — see below for how a section reaches the grid.

label is a plain string, not a message key — deliberately. A custom section is consumer-owned UI, and you localize it where you localize the rest of your application; routing one label through the grid's messages prop would split your strings across two localization systems for no gain. The messages layer stays the built-ins'.

render takes no arguments; everything the pane needs, it closes over. A section that needs the grid itself holds the handle via the surface's existing onGridReady prop:

tsx
const grid = useRef<PretableSurfaceGrid<
  Trade,
  string,
  readonly PretableColumn<Trade>[]
> | null>(null);
 
const notesSection: PretableToolPanelSection = {
  id: "notes",
  icon: NotesIcon,
  label: "Notes",
  // The render closure reads the ref when the button is pressed —
  // by then `onGridReady` has long since filled it.
  render: () => (
    <button onClick={() => grid.current?.exportCsv()} type="button">
      Download CSV
    </button>
  ),
};
 
<PretableSurface
  ariaLabel="Trades"
  rows={rows}
  columns={columns}
  onGridReady={(ready) => {
    grid.current = ready;
  }}
  toolPanel={{ sections: ["columns", notesSection, "filters"] }}
/>;

A sections array built inline re-creates itself every render, which rebuilds the internal descriptor array — a little work and nothing else: React reconciles the pane's child by position and type, so a re-rendered section is not a remounted one, and its own component state survives. Hold the roster stable (a module constant, or a memo) to skip even that.

What the shell gives, and what a section owes

A custom pane inherits the shell's accessibility contract with no work on your part: the pane is a role="tabpanel" labelled by its tab, Escape inside it returns focus to the rail tab, the pane unmounts when closed, and the rail stays one Tab stop however many sections you add.

What the shell cannot enforce is the conduct of the content you render into it. Two rules are not optional, and this grid's own history is the argument — the project once shipped a grid that was keyboard-unreachable and a focus trap at once, a WCAG level-A failure on both counts, and it was found by a hard gate rather than a person. Do not rebuild that bug inside a pane:

Ordinary form controls in ordinary DOM order satisfy both rules with no further effort — the shell's own e2e suite walks a consumer-authored pane exactly this way to prove the contract holds.

On a server-rendered grid, a custom section's tab is painted but inert until React hydrates, like every other grid control: gate on data-pretable-hydrated="true" before driving it from a test.

Keyboard

The rail is one Tab stop, however many sections it grows.

KeyAction
TabReach the rail (one stop). From the open rail tab, Shift+Tab enters the pane at its last control — the pane precedes the rail in tab order.
/ (rail)Move between section tabs. Focus only — nothing opens.
Enter / Space (rail)Open the focused section; on the already-open section, close it.
Escape (anywhere in the pane)Dismiss the innermost open thing first — a mid-drag reorder is cancelled (see above), an open ⋮ menu closes back to its kebab; with nothing open, focus returns to the rail tab.
Shift+↑ / Shift+↓ (on a grip)Move the column — or, in the grouping pane, the grouping level — one position. Crossing a columns-section subgroup boundary re-pins; at the list's ends nothing moves. Focus follows the row, so the chord repeats.

Each row's grip is its own Tab stop, so the chord is reachable without a pointer. The two panes' grips carry different accessible names — "Reorder " in the columns section, "Reorder grouping by " in the grouping section — because both can be in the panel at once. Pinning into an empty pinned group is the one operation the chord cannot express — that is the columns row's ⋮ menu.

The grouping pane's remaining controls are ordinary form controls — the expansion buttons, the switch, one <select> per aggregate row — and its + Add group menu is the same menu keyboard the filters pane's pickers use: arrows move, Enter selects, Escape closes back to the button.

The filters pane needs no chords of its own: its controls are ordinary form controls, walked in the order the tree reads — column, operator, value, remove, then the run's add pair, and the same again inside each group. Two things are not stops there. The join is focusable only where it can actually change anything: on the second and later rows of a nested run it is a button, and everywhere else — every run's first row, and every row of the root run, whose implicit AND has nothing to flip — it is plain text. And the plain buttons may be skipped by the browser: WebKit leaves a <button> out of the Tab order unless macOS's "Tab moves between all controls" is on, which is a platform preference rather than something a grid can set.

The rail tab is a stop in every browser, and it is the last one inside the panel. Whichever section is open, one more forward Tab from there leaves the grid entirely — the panel is not a trap.

What's ahead

The rail is built to hold more sections, and ToolPanelSectionId names exactly the ones that ship — custom sections are how you add your own today, and a new built-in would join that closed union. Replacing a built-in section by reusing its id is deliberately not supported; if it ever is, it will be designed, not implied.

Where to go next

  • Column layout — the header-gesture and controlled-state side of order, pinning, and widths.
  • Filtering — the header funnel, the operator families, and the controlled query the filters section writes into.
  • Grouping — the row-grouping model itself: the query's rowGroups, aggregates, expansion, and the drag-to-group strip.
  • Keyboard — the grid's own navigation model the panel sits beside.
  • <PretableSurface> — every configuration surface in one place.