Pivot Table
ProCross-tab summarization with a field configurator reachable by drag OR by keyboard — dimensions, six aggregators, expand/collapse subtotals, sort-by-measure, top-N filter, a cardinality guard, Excel/PDF export that matches the grid cell for cell, and server-side aggregation.
Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.
import { SvPivotTableComponent } from '@styloviz-pro/pivot-table';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Nested rows, sorted, top-N, with export
ProServer-side aggregation
ProGuarding a high-cardinality field
ProThe headless engine, outside Angular
ProWhen to use
- Summarizing a flat dataset by one or more dimensions, with grand totals and subtotals.
- You want the end user to reconfigure what's being summarized (drag fields, not a form).
- For row grouping without cross-tab columns or aggregators, the Data Table's grouping is lighter weight.
Async states
- loading
- empty
- error
- retry
Item identity & owned state
- Restorable state
- collapsedIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Saved state
- Stored kinds
- sv-pivot-table/view
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| fieldsrequired | readonly SvPivotField[] | — | Every field available to drag into Rows / Columns / Values. |
| data | readonly T[] | [] | Source rows. Ignored (and may be left unbound) in `serverSide` mode. |
| config | SvPivotConfig (two-way) | EMPTY_PIVOT_CONFIG | Rows/columns/measures/sort/topN — two-way, so a host can persist the layout. |
| result | SvPivotResult | null | null | Pre-aggregated result from the server. Required (and used verbatim) when `serverSide` is true. |
| serverSide | boolean | false | When true, the component renders `[result]` instead of computing one from `[data]` — aggregation happened on the server. `config` still updates from drag-and-drop / the toolbar, so its `(configChange)` is what to key a refetch off. |
| maxAxisGroups | number | 1000 | How many distinct groups one axis may render before the pivot stops and warns instead of building. `0` disables the guard. THE COST OF A PIVOT IS PER GROUP, NOT PER ROW. Dropping a field like `orderId` or `email` into Rows asks for one group — one `<tr>`, one aggregated array per column leaf — per source row, so a 100,000-row dataset becomes 100,000 rendered rows and the tab stops responding before anything reaches the screen. The limit is per-axis rather than a cell budget precisely so the warning can name the field responsible; "too many cells" leaves a user with five dimensions guessing. Client-side only: in `serverSide` mode the component never sees `data`, so the host is the only party that can count anything. |
| loading | boolean | false | Show a skeleton placeholder in place of the pivot while data loads. |
| error | SvDataError | string | null | null | Load failure, shown as an error banner with a Retry button above the pivot (a stale `result` stays visible below it). |
| emptyMessage | string | 'Drag a field into Rows or Columns to b… | Text shown in place of the pivot when there is no result to display. |
| title | string | '' | Title shown in the toolbar. |
| exportable | boolean | false | Show export buttons in the toolbar, one per format in `exportFormats`. |
| exportFormats | readonly PivotExportFormat[] | ['xlsx'] | Formats offered as export buttons when `exportable`: 'xlsx' (Excel) and/or 'pdf'. |
| exportFileName | string | '' | Base filename, without extension. |
| collapsedIds | ReadonlySet<string> (two-way) | new Set() | Row-node ids that are collapsed (two-way). Absent from this set = expanded — a fresh pivot starts fully open. A `model` because it is the same kind of fact as a Kanban's collapsed columns or a Gantt's folded rows: something the host has reason to restore after a route change, deep-link to, or persist per user. Held in a private signal it could be neither set nor observed. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| retry | void | The host should re-run the failed load (server-side mode). |
| exportFailed | { format: PivotExportFormat; error: Error } | An export failed — PDF loads `jspdf`/`html2canvas` as a lazy chunk at click time, which can fail (offline, or a peer skipped at install). |
| configChange | SvPivotConfig | Emits the new `config` whenever it changes — the output half of `[(config)]`. |
| collapsedIdsChange | ReadonlySet<string> | Emits the new `collapsedIds` whenever it changes — the output half of `[(collapsedIds)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.
Template
ProBound to the names this component actually exposes.
Host class
ProDrive the three async inputs from your own fetch.
- This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `data`, `loading` and `error`.
- `serverSide` switches **off** the built-in filtering, sorting and paging. Leave it false and the component works entirely from the array you pass — which is what you want for a few hundred rows.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Six aggregators (sum, average, count, min, max, distinct count) and five number formats (general, integer, decimal, currency, percent). Every subtotal is the aggregator applied to that group's own source rows, so an average is a true average and a distinct count is a true distinct count — never a roll-up of the numbers on the rows beneath.
- Sort any row level by a measure, ascending or descending.
- Top-N filters the outermost row groups by a measure's total.
- Excel/PDF export serializes exactly what's on screen — collapsed groups stay collapsed, the label column and Grand Total row are always present because the grid always draws them, and each measure's format carries across. XLSX keeps true numeric cells (with the format as a real Excel number format, so host formulas still work) while PDF takes the formatted strings, because a picture of the grid that reads `0.42` where the screen reads `42.0%` is the wrong picture.
- `maxAxisGroups` (default 1000) caps how many groups either axis may render. Past it the grid is replaced by a notice naming the axis, its group count and the widest field responsible, with a "Render it anyway" waiver that is re-armed as soon as the axes change again. `0` switches the guard off; `serverSide` skips it, since the host is already deciding what to aggregate.
Best practices
- Do: Mark numeric fields with `numeric: true` in `SvPivotField` — it drives the drag-drop default aggregator guess (sum vs. count).
- Do: Pair `serverSide` with a real aggregation endpoint once the source dataset is too large to pivot client-side — the same `buildPivot()` engine runs unchanged on the server.
- Tip: Bind `[(config)]` (two-way) to persist a user's layout, same as `data-table`'s saved column state.
- Do: Lower `maxAxisGroups` (default 1000) for embedded or mobile hosts, and raise it only when you know the dimensions are bounded. The guard counts groups without building the pivot, so it costs nothing on a config that stays under the limit.
- Don't: Don't offer a per-record identifier (`orderId`, `email`) as a dimension field at all if you can help it — grouping by it produces one row per record, which is the source table, not a summary. As a measure with `countDistinct` it answers the question the user actually had.
Accessibility
- The grid IS a `<table>`, so it gets no second off-screen copy — a companion summary here would read every figure twice. What it gets instead is its own semantics: an `sr-only` `<caption>` naming the fields in play rather than counting them, `scope="col"`/`scope="row"` on both header axes, and a Grand Total row that is a row header, not a stray cell.
- The row hierarchy is in the accessible name, not the indent. Nesting reached the page as `padding-left` alone, so "West" and "West → Seattle" announced identically; each row header now carries its branch.
- The scroll container is a labelled `role="region"` with `tabindex="0"`. A scrollable box is a keyboard tab stop whether or not you name it, and an unnamed stop announces as nothing at all.
- Expand/collapse buttons expose `aria-expanded` and a computed label ("Expand West" / "Collapse West").
- Drag is one path, not the path. Native HTML5 drag is reachable by pointer only — not keyboard, not switch access, not voice control — so every field carries a zone menu naming where it sits (Unused / Rows / Columns / Values) and every placed field carries move buttons for its position. Order is not decoration here: `rows` order IS the nesting order, so a configurator without reorder could only express the arrangement the fields happened to be dropped in. Focus follows the field across the re-render, because a zone change destroys the control that triggered it.
- Every value cell carries a `title` naming the measure, the row path and the column it came from, and marks a subtotal as one — the sighted answer to "is this a subtotal, and of what", which a grid of bare numbers cannot give. It is deliberately not an `aria-label`: on a `<td>` that REPLACES the cell's own content, and the `scope` headers already carry the association.
- Loading, error and empty states follow the kit-wide async contract (`data-sv-state`). A pivot blocked by `maxAxisGroups` is not one of them — it has too much data, not none, so it gets a `role="status"` notice that keeps the configurator on screen rather than an empty shell that would hide the only way out.
Feedback on Pivot Table
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.