Pinned Rows (Status Bar) Plugin
The Pinned Rows plugin creates a fixed status bar at the top or bottom of the grid for displaying aggregations, row counts, or custom content. Think of it as the “totals row” you’d see in a spreadsheet—always visible regardless of scroll position.
Installation
Section titled “Installation”import '@toolbox-web/grid/features/pinned-rows';Basic Usage
Section titled “Basic Usage”Pinned rows are configured with a unified slots[] array. Each slot becomes
its own DOM row at position: 'top' or position: 'bottom' (default
'bottom'), in declared order. A slot is either an aggregation row
(sum/avg/min/max/count/first/last or a custom function per column) or a
panel row (a render function — built-in or custom).
import { queryGrid } from '@toolbox-web/grid';import { rowCountPanel } from '@toolbox-web/grid/plugins/pinned-rows';
const grid = queryGrid('tbw-grid');grid.gridConfig = { columns: [ { field: 'product', header: 'Product' }, { field: 'quantity', header: 'Qty', type: 'number' }, { field: 'price', header: 'Price', type: 'currency' }, ], features: { pinnedRows: { slots: [ { id: 'totals', position: 'bottom', aggregators: { quantity: 'sum', price: { aggFunc: 'sum', formatter: (v) => `$${v.toFixed(2)}` }, }, cells: { product: 'Totals:' }, }, { id: 'count', position: 'bottom', render: rowCountPanel() }, ], }, },};import '@toolbox-web/grid-react/features/pinned-rows';import { DataGrid } from '@toolbox-web/grid-react';import { rowCountPanel } from '@toolbox-web/grid/plugins/pinned-rows';
function OrderGrid({ orders }) { return ( <DataGrid rows={orders} columns={[ { field: 'product', header: 'Product' }, { field: 'quantity', header: 'Qty', type: 'number' }, { field: 'price', header: 'Price', type: 'currency' }, ]} pinnedRows={{ slots: [ { id: 'totals', position: 'bottom', aggregators: { quantity: 'sum', price: 'sum' }, cells: { product: 'Totals:' }, }, { id: 'count', position: 'bottom', render: rowCountPanel() }, ], }} /> );}<script setup>import '@toolbox-web/grid-vue/features/pinned-rows';import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';import { rowCountPanel } from '@toolbox-web/grid/plugins/pinned-rows';
const orders = [ { product: 'Widget', quantity: 10, price: 99.99 }, { product: 'Gadget', quantity: 5, price: 149.99 },];
const pinnedRowsConfig = { slots: [ { id: 'totals', position: 'bottom', aggregators: { quantity: 'sum', price: 'sum' }, cells: { product: 'Totals:' }, }, { id: 'count', position: 'bottom', render: rowCountPanel() }, ],};</script>
<template> <TbwGrid :rows="orders" :pinned-rows="pinnedRowsConfig"> <TbwGridColumn field="product" header="Product" /> <TbwGridColumn field="quantity" header="Qty" type="number" /> <TbwGridColumn field="price" header="Price" type="currency" /> </TbwGrid></template>// Feature import - enables the [pinnedRows] inputimport { GridPinnedRowsDirective } from '@toolbox-web/grid-angular/features/pinned-rows';import { Component } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { ColumnConfig } from '@toolbox-web/grid';import { rowCountPanel } from '@toolbox-web/grid/plugins/pinned-rows';
@Component({ selector: 'app-order-grid', imports: [Grid, GridPinnedRowsDirective], template: ` <tbw-grid [rows]="rows" [columns]="columns" [pinnedRows]="pinnedRowsConfig" style="height: 400px; display: block;"> </tbw-grid> `,})export class OrderGridComponent { rows = [];
columns: ColumnConfig[] = [ { field: 'product', header: 'Product' }, { field: 'quantity', header: 'Qty', type: 'number' }, { field: 'price', header: 'Price', type: 'currency' }, ];
pinnedRowsConfig = { slots: [ { id: 'totals', position: 'bottom' as const, aggregators: { quantity: 'sum', price: 'sum' }, cells: { product: 'Totals:' }, }, { id: 'count', position: 'bottom' as const, render: rowCountPanel() }, ], };}Use the controls to explore aggregation rows (top or bottom, single or
stacked, full-width or per-column), the built-in row-count status panel
and a custom right-zone panel. Every behaviour below is driven by the
same slots[] array.
<tbw-grid style="height: 400px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/pinned-rows';import {rowCountPanel,type PinnedRowSlot,type ZonedPanelRender,} from '@toolbox-web/grid/plugins/pinned-rows';
const names = ['Widget', 'Gadget', 'Gizmo', 'Doohickey', 'Thingamabob'];const data = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: names[i % names.length], quantity: Math.floor(Math.random() * 100) + 10, price: Math.round((Math.random() * 50 + 5) * 100) / 100,}));
const columns = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name' }, { field: 'quantity', header: 'Qty', type: 'number' }, { field: 'price', header: 'Price', type: 'number' },];
const formatCurrency = (value: unknown) => `$${(value).toFixed(2)}`;const grid = queryGrid('tbw-grid');
function build(opts: { aggPosition: 'top' | 'bottom'; multipleRows: boolean; fullWidth: boolean; showRowCount: boolean; showCustom: boolean;}) { const aggregationSlots: PinnedRowSlot[] = opts.multipleRows ? [ { id: 'sum', position: opts.aggPosition, label: 'Sum', aggregators: { quantity: 'sum', price: { aggFunc: 'sum', formatter: formatCurrency } }, cells: { id: 'Sum:', name: '' }, }, { id: 'avg', position: opts.aggPosition, label: 'Average', aggregators: { quantity: { aggFunc: 'avg', formatter: (v: unknown) => (v).toFixed(1) }, price: { aggFunc: 'avg', formatter: formatCurrency }, }, cells: { id: 'Avg:', name: '' }, }, { id: 'minmax', position: opts.aggPosition, label: 'Min/Max', aggregators: { quantity: 'min', price: { aggFunc: 'max', formatter: formatCurrency } }, cells: { id: 'Min/Max:', name: '' }, }, ] : [ { id: 'totals', position: opts.aggPosition, label: 'Totals', aggregators: { name: (rows: unknown[]) => `${new Set((rows).map((r) => r.name)).size} unique`, quantity: 'sum', price: { aggFunc: 'sum', formatter: formatCurrency }, }, cells: { id: 'Totals:' }, }, ];
// All status panels share a SINGLE slot so they appear on one DOM row. // Each entry in `render: [...]` is a zone contribution within that row; // adding more slots would stack additional rows below. const statusContribs: ZonedPanelRender[] = []; if (opts.showRowCount) statusContribs.push({ zone: 'left', render: rowCountPanel() }); if (opts.showCustom) { statusContribs.push({ zone: 'right', render: (ctx) => { const rows = ctx.rows; const total = rows.reduce( (sum, row) => sum + (row.quantity || 0) * (row.price || 0), 0, ); const el = document.createElement('strong'); el.className = 'tbw-status-panel'; el.textContent = `Inventory value: ${formatCurrency(total)}`; return el; }, }); }
const slots: PinnedRowSlot[] = [...aggregationSlots]; if (statusContribs.length > 0) { slots.push({ id: 'status', position: 'bottom', render: statusContribs }); }
grid.gridConfig = { columns, features: { pinnedRows: { fullWidth: opts.fullWidth, slots }, }, }; grid.rows = data;}
build({ aggPosition: 'bottom', multipleRows: false, fullWidth: false, showRowCount: true, showCustom: true,});Configuration Options
Section titled “Configuration Options”| Option | Type | Default | Description |
|---|---|---|---|
slots | PinnedRowSlot[] | [] | Unified ordered list of pinned-row slots (see Slots API) |
fullWidth | boolean | false | Default fullWidth mode applied to every aggregation slot |
Legacy fields —
position,showRowCount,showSelectedCount,showFilteredCount,aggregationRows,customPanels— are still accepted for backwards compatibility but are deprecated and ignored whenslotsis set. They will be removed in a future major release. New code should useslots. See the migration notes below.
Slots API
Section titled “Slots API”The slots[] array is a unified, ordered list of pinned-row entries. Each
slot becomes its own DOM row inside its position area ('top' or
'bottom', default 'bottom'), in declared order. Two slot shapes coexist:
- Aggregation slot — anything without a
renderfield is treated as an aggregation row (same shape asAggregationRowConfig:aggregators,cells,label,fullWidth). - Panel slot — anything with a
renderfield.renderis either a(ctx) => HTMLElement | nullfunction (rendered into the left zone), or an array of{ zone?: 'left' | 'center' | 'right', render }entries.
A panel render that returns null skips that contribution; a slot whose
renderers all return null is dropped from the DOM. This is how the built-in
selectedCountPanel and filteredCountPanel self-hide when their count is
zero / unfiltered.
Built-in panel renderers
Section titled “Built-in panel renderers”import { filteredCountPanel, rowCountPanel, selectedCountPanel,} from '@toolbox-web/grid/plugins/pinned-rows';| Renderer | Always renders? |
|---|---|
rowCountPanel() | Yes |
selectedCountPanel() | Only when selectedRows > 0 |
filteredCountPanel() | Only when filteredRows !== totalRows |
Example
Section titled “Example”import { filteredCountPanel, rowCountPanel, selectedCountPanel,} from '@toolbox-web/grid/plugins/pinned-rows';
features: { pinnedRows: { slots: [ // Aggregation row at the top { position: 'top', aggregators: { price: 'sum' }, cells: { id: 'Totals:' } },
// Three stacked status-panel rows at the bottom (in this order) { position: 'bottom', render: rowCountPanel() }, { position: 'bottom', render: filteredCountPanel() }, { position: 'bottom', render: selectedCountPanel() },
// Mixed-zone panel: left + right content in one row { position: 'bottom', render: [ { zone: 'left', render: (ctx) => myLeftBadge(ctx) }, { zone: 'right', render: (ctx) => myRightBadge(ctx) }, ], }, ], },},Framework adapters
Section titled “Framework adapters”All three adapters bridge slots[].render automatically — return your
framework’s native node and the adapter wraps it in a vanilla DOM element
via the existing portal/teleport/component-rendering machinery:
| Adapter | render may return |
|---|---|
| React | ReactNode | null (e.g. <span>...</span>) |
| Vue | VNode | null (e.g. h('span', ...)) |
| Angular | Type<unknown> | null (a component class) |
Aggregation Rows
Section titled “Aggregation Rows”An aggregation slot (any slot without a render field) computes values
from the current rows on a per-column basis:
features: { pinnedRows: { slots: [ { id: 'totals', position: 'bottom', aggregators: { // Simple string aggregator quantity: 'sum', // Object syntax with formatter price: { aggFunc: 'sum', formatter: (value) => `$${value.toFixed(2)}`, }, }, cells: { id: 'Totals:', name: '' }, }, ], },},Aggregator Syntax
Section titled “Aggregator Syntax”| Syntax | Example | Description |
|---|---|---|
| String | 'sum' | Built-in aggregator |
| Function | (rows, field) => rows.length | Custom aggregator function |
| Object | { aggFunc: 'sum', formatter: (v) => v.toFixed(2) } | Aggregator with formatter |
Built-in Aggregators
Section titled “Built-in Aggregators”sum, avg, count, min, max, first, last
Full-Width Mode
Section titled “Full-Width Mode”When fullWidth is true, an aggregation slot renders as a single spanning
cell with the label and aggregated values displayed inline—similar to the
row grouping plugin’s full-width mode. When false (the default), each
column gets its own cell aligned to the grid template.
The label property works in both modes. In per-column mode it renders as
an overlay at the left edge of the row, independent of column width—so the
text won’t truncate even if the first column is narrow.
Set fullWidth globally on the plugin config or per-slot on the aggregation
slot. Per-slot settings override the global default:
features: { pinnedRows: { fullWidth: true, // All aggregation slots span full width by default slots: [ { id: 'totals', label: 'Totals', aggregators: { quantity: 'sum', price: 'sum' }, }, { id: 'detail', fullWidth: false, // Override: render per-column aggregators: { quantity: 'avg', price: 'avg' }, cells: { product: 'Averages:' }, }, ], },},Custom Panel Slots
Section titled “Custom Panel Slots”Use a panel slot — any slot with a render field — to inject custom
content. render is either a single function (rendered into the left zone)
or an array of { zone, render } entries to populate 'left', 'center',
and/or 'right' zones in one row:
features: { pinnedRows: { slots: [ { id: 'info', position: 'bottom', render: [ { zone: 'right', render: (ctx) => { const el = document.createElement('span'); el.textContent = `Rows: ${ctx.totalRows}`; return el; }, }, ], }, ], },},Return null from a render function to skip that contribution; if every
contribution in a slot returns null the whole row is dropped. The built-in
selectedCountPanel() and filteredCountPanel() use this to self-hide.
Stateful counter — combine totals, filter and selection
Section titled “Stateful counter — combine totals, filter and selection”Because everything you need is on the context object, a single render function can adapt its message to grid state without reading from any plugin directly. The example below shows one combined counter that:
- shows
Total: N rowswhen nothing is filtered or selected, - switches to
Filtered: M / Nwhen a filter is active, - switches to
Selected: S of Nwhen rows are selected, - and combines into
Selected: S of M / Nwhen both apply.
features: { pinnedRows: { slots: [ { id: 'counter', position: 'bottom', render: (ctx) => { const { totalRows, filteredRows, selectedRows } = ctx; const isFiltered = filteredRows !== totalRows; const visible = isFiltered ? `${filteredRows} / ${totalRows}` : `${totalRows}`;
let text: string; if (selectedRows > 0) { text = `Selected: ${selectedRows} of ${visible}`; } else if (isFiltered) { text = `Filtered: ${visible}`; } else { text = `Total: ${totalRows} rows`; }
const el = document.createElement('span'); el.className = 'tbw-status-panel'; el.textContent = text; return el; }, }, ], },},Migrating from Legacy Fields
Section titled “Migrating from Legacy Fields”The legacy top-level fields are deprecated. Map them to slots as follows:
| Legacy field | Slots equivalent |
|---|---|
position: 'top' | 'bottom' | Per-slot position field |
showRowCount: true | { render: rowCountPanel() } |
showSelectedCount: true | { render: selectedCountPanel() } |
showFilteredCount: true | { render: filteredCountPanel() } |
aggregationRows: [{ ... }] | Same shape — drop into slots[] (no render field) |
customPanels: [{ render }] | Wrap as { render: [{ zone: panel.position, render: panel.render }] } |
See Also
Section titled “See Also”- Pinned Columns — Sticky columns
- Core Configuration — Grid configuration overview