# Pinned Rows (Status Bar) Plugin

> Pin summary or custom rows to the top or bottom of the grid.

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

```ts
import '@toolbox-web/grid/features/pinned-rows';
```

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

#### TypeScript

```ts
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() },
      ],
    },
  },
};
```

#### React

```tsx
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() },
        ],
      }}
    />
  );
}
```

#### Vue

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

#### Angular

```typescript
// Feature import - enables the [pinnedRows] input
import { 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() },
    ],
  };
}
```

## Demo

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.

```ts
// PinnedRowsDemo.astro
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 container = document.getElementById('pinned-rows-demo');
if (container) {
  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 as number).toFixed(2)}`;
  const grid = queryGrid('tbw-grid', container)!;

  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 as number).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 as { name: string }[]).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 as Array<{ quantity: number; price: number }>;
          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,
  });

  container.addEventListener('control-change', ((e: CustomEvent) => {
    const v = e.detail.allValues;
    build({
      aggPosition: v.aggPosition as 'top' | 'bottom',
      multipleRows: v.multipleRows as boolean,
      fullWidth: v.fullWidth as boolean,
      showRowCount: v.showRowCount as boolean,
      showCustom: v.showCustom as boolean,
    });
  }) as EventListener);
}
```

## Configuration Options

| Option      | Type                | Default    | Description                                                                  |
| ----------- | ------------------- | ---------- | ---------------------------------------------------------------------------- |
| `slots`     | `PinnedRowSlot[]`   | `[]`       | Unified ordered list of pinned-row slots (see [Slots API](#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 when `slots` is
> set. They will be removed in a future major release. New code should use
> `slots`. See the [migration notes](#migrating-from-legacy-fields) below.

## 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 `render` field is treated as
  an aggregation row (same shape as `AggregationRowConfig`: `aggregators`,
  `cells`, `label`, `fullWidth`).
- **Panel slot** — anything **with** a `render` field. `render` is either a
  `(ctx) => HTMLElement | null` function (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

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

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

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

The adapter feature modules cache the host DOM element per-renderer across
calls, so the plugin's reference-equality short-circuit keeps the pinned
row stable across grid refreshes (no React/Vue unmount-remount loop, no
Angular component re-creation). You can therefore write the renderers as
plain inline functions — no need for manual `useRef` / `ref` host caching:

#### React

```tsx
import '@toolbox-web/grid-react/features/pinned-rows';
import { DataGrid } from '@toolbox-web/grid-react';

function Legend() {
  return <span className="legend">Legend content</span>;
}

<DataGrid
  rows={rows}
  pinnedRows={{
    slots: [
      { id: 'legend', position: 'bottom', render: () => <Legend /> },
    ],
  }}
/>;
```

#### Vue

```html
<script setup lang="ts">
import '@toolbox-web/grid-vue/features/pinned-rows';
import { h, shallowRef } from 'vue';
import { TbwGrid } from '@toolbox-web/grid-vue';
import Legend from './Legend.vue';

const pinnedRows = shallowRef({
  slots: [
    { id: 'legend', position: 'bottom' as const, render: () => h(Legend) },
  ],
});
</script>

<template>
  <TbwGrid :rows="rows" :pinned-rows="pinnedRows" />
</template>
```

#### Angular

```ts
import { Component } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import { GridPinnedRowsDirective } from '@toolbox-web/grid-angular/features/pinned-rows';
import { LegendComponent } from './legend.component';

@Component({
  selector: 'app-grid',
  imports: [Grid, GridPinnedRowsDirective],
  template: `<tbw-grid [rows]="rows" [pinnedRows]="pinnedRows" />`,
})
export class AppGrid {
  rows = [];
  pinnedRows = {
    slots: [
      { id: 'legend', position: 'bottom' as const, render: LegendComponent },
    ],
  };
}
```

## Aggregation Rows

An **aggregation slot** (any slot without a `render` field) computes values
from the current rows on a per-column basis:

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

| 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

`sum`, `avg`, `count`, `min`, `max`, `first`, `last`

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

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

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:

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

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 rows` when nothing is filtered or selected,
- switches to `Filtered: M / N` when a filter is active,
- switches to `Selected: S of N` when rows are selected,
- and combines into `Selected: S of M / N` when both apply.

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

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

- **[Pinned Columns](/grid/plugins/pinned-columns.md)** — Sticky columns
- **[Core Configuration](/grid/core.md)** — Grid configuration overview
