Data
Pivot table
Records summed, counted or averaged into a rows by columns table with totals, for reports and breakdowns.
- Built on
<table>scope="row"scope="colgroup"position: sticky- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 3.1 kB
- Dependencies
- React
| Region | Q1 | Q2 | Total |
|---|---|---|---|
| North | 42 | 51 | 93 |
| South | 37 | 44 | 81 |
| West | 29 | 33 | 62 |
| Total | 108 | 128 | 236 |
Code
import { PivotTable } from "@/components/equip/pivot-table";
const sales = [
{ region: "North", quarter: "Q1", amount: 42 },
{ region: "North", quarter: "Q2", amount: 51 },
{ region: "South", quarter: "Q1", amount: 37 },
{ region: "South", quarter: "Q2", amount: 44 },
{ region: "West", quarter: "Q1", amount: 29 },
{ region: "West", quarter: "Q2", amount: 33 },
];
export default function Demo() {
return (
<div style={{ width: 224 }}>
<PivotTable
label="Sales in $k"
hideLabel
dense
rows={sales}
rowField="region"
columnField="quarter"
value="amount"
rowHeader="Region"
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="width:224px">
<div class="eq-pivot-table-scroll" tabindex="0" role="region" aria-label="Sales in $k" data-dense="true">
<table class="eq-pivot-table">
<caption class="eq-pivot-table-caption" data-hidden="true">Sales in $k</caption>
<thead class="eq-pivot-table-head">
<tr>
<th scope="col" class="eq-pivot-table-corner">Region</th>
<th scope="col" class="eq-pivot-table-header">Q1</th>
<th scope="col" class="eq-pivot-table-header">Q2</th>
<th scope="col" class="eq-pivot-table-header" data-total="">Total</th>
</tr>
</thead>
<tbody class="eq-pivot-table-body">
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">North</th>
<td class="eq-pivot-table-cell">42</td>
<td class="eq-pivot-table-cell">51</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">93</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">South</th>
<td class="eq-pivot-table-cell">37</td>
<td class="eq-pivot-table-cell">44</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">81</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">West</th>
<td class="eq-pivot-table-cell">29</td>
<td class="eq-pivot-table-cell">33</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">62</strong>
</td>
</tr>
</tbody>
<tfoot class="eq-pivot-table-foot">
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Total</th>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">108</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">128</strong>
</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">236</strong>
</td>
</tr>
</tfoot>
</table>
</div>
</div>Examples
Revenue by region and quarter
Invoices are summed per cell, with a total column, a total row in tfoot and the grand total in the corner.
| Region | Quarter | Total | |||
|---|---|---|---|---|---|
| Q1 | Q2 | Q3 | Q4 | ||
| Asia Pacific | $95,832 | $103,092 | $100,672 | $107,932 | $407,528 |
| Europe | $145,488 | $141,957 | $152,551 | $163,144 | $603,140 |
| Latin America | $40,845 | $39,873 | $42,790 | $41,818 | $165,326 |
| North America | $176,928 | $190,608 | $204,288 | $199,728 | $771,552 |
| Total | $459,093 | $475,530 | $500,301 | $512,622 | $1,947,546 |
Code
import { PivotTable } from "@/components/equip/pivot-table";
type Invoice = { region: string; quarter: string; amount: number };
const regions = ["North America", "Europe", "Asia Pacific", "Latin America"];
const quarters = ["Q1", "Q2", "Q3", "Q4"];
const base = [182400, 141250, 96800, 38900];
// Two invoices per region and quarter, so each cell is a real sum.
const invoices: Invoice[] = regions.flatMap((region, r) =>
quarters.flatMap((quarter, q) => [
{ region, quarter, amount: Math.round(base[r] * (0.42 + q * 0.035)) },
{ region, quarter, amount: Math.round(base[r] * (0.55 + ((q * 7 + r * 3) % 5) * 0.02)) },
]),
);
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
export default function Example() {
return (
<PivotTable
label="Revenue by region, 2025"
rows={invoices}
rowField="region"
columnField="quarter"
value="amount"
format={usd.format}
rowHeader="Region"
columnHeader="Quarter"
columnOrder={quarters}
/>
);
}HTMLworks in any framework, with the part's CSS
<div class="eq-pivot-table-scroll" tabindex="0" role="region" aria-label="Revenue by region, 2025">
<table class="eq-pivot-table">
<caption class="eq-pivot-table-caption">Revenue by region, 2025</caption>
<thead class="eq-pivot-table-head">
<tr>
<th scope="col" rowSpan="2" class="eq-pivot-table-corner">Region</th>
<th scope="colgroup" colSpan="4" class="eq-pivot-table-group">Quarter</th>
<th scope="col" rowSpan="2" class="eq-pivot-table-header" data-total="">Total</th>
</tr>
<tr>
<th scope="col" class="eq-pivot-table-header">Q1</th>
<th scope="col" class="eq-pivot-table-header">Q2</th>
<th scope="col" class="eq-pivot-table-header">Q3</th>
<th scope="col" class="eq-pivot-table-header">Q4</th>
</tr>
</thead>
<tbody class="eq-pivot-table-body">
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Asia Pacific</th>
<td class="eq-pivot-table-cell">$95,832</td>
<td class="eq-pivot-table-cell">$103,092</td>
<td class="eq-pivot-table-cell">$100,672</td>
<td class="eq-pivot-table-cell">$107,932</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$407,528</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Europe</th>
<td class="eq-pivot-table-cell">$145,488</td>
<td class="eq-pivot-table-cell">$141,957</td>
<td class="eq-pivot-table-cell">$152,551</td>
<td class="eq-pivot-table-cell">$163,144</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$603,140</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Latin America</th>
<td class="eq-pivot-table-cell">$40,845</td>
<td class="eq-pivot-table-cell">$39,873</td>
<td class="eq-pivot-table-cell">$42,790</td>
<td class="eq-pivot-table-cell">$41,818</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$165,326</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">North America</th>
<td class="eq-pivot-table-cell">$176,928</td>
<td class="eq-pivot-table-cell">$190,608</td>
<td class="eq-pivot-table-cell">$204,288</td>
<td class="eq-pivot-table-cell">$199,728</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$771,552</strong>
</td>
</tr>
</tbody>
<tfoot class="eq-pivot-table-foot">
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Total</th>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">$459,093</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">$475,530</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">$500,301</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">$512,622</strong>
</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$1,947,546</strong>
</td>
</tr>
</tfoot>
</table>
</div>Ticket count by team and priority
With aggregate count no measure is needed. columnOrder fixes the priorities in their natural order; empty cells read as No data.
| Team | Urgent | High | Normal | Low | Total |
|---|---|---|---|---|---|
| Billing | 4 | 11 | 26 | 9 | 50 |
| Integrations | 7 | 15 | 18 | No data | 40 |
| Mobile | 2 | No data | 12 | 5 | 19 |
| Onboarding | No data | 6 | 31 | 14 | 51 |
| Total | 13 | 32 | 87 | 28 | 160 |
Code
import { PivotTable } from "@/components/equip/pivot-table";
type Ticket = { team: string; priority: string };
const counts: [string, string, number][] = [
["Billing", "Urgent", 4],
["Billing", "High", 11],
["Billing", "Normal", 26],
["Billing", "Low", 9],
["Onboarding", "High", 6],
["Onboarding", "Normal", 31],
["Onboarding", "Low", 14],
["Integrations", "Urgent", 7],
["Integrations", "High", 15],
["Integrations", "Normal", 18],
["Mobile", "Urgent", 2],
["Mobile", "Normal", 12],
["Mobile", "Low", 5],
];
const tickets: Ticket[] = counts.flatMap(([team, priority, n]) => Array.from({ length: n }, () => ({ team, priority })));
export default function Example() {
return (
<PivotTable
label="Open tickets"
dense
rows={tickets}
rowField="team"
columnField="priority"
aggregate="count"
rowHeader="Team"
columnOrder={["Urgent", "High", "Normal", "Low"]}
/>
);
}HTMLworks in any framework, with the part's CSS
<div class="eq-pivot-table-scroll" tabindex="0" role="region" aria-label="Open tickets" data-dense="true">
<table class="eq-pivot-table">
<caption class="eq-pivot-table-caption">Open tickets</caption>
<thead class="eq-pivot-table-head">
<tr>
<th scope="col" class="eq-pivot-table-corner">Team</th>
<th scope="col" class="eq-pivot-table-header">Urgent</th>
<th scope="col" class="eq-pivot-table-header">High</th>
<th scope="col" class="eq-pivot-table-header">Normal</th>
<th scope="col" class="eq-pivot-table-header">Low</th>
<th scope="col" class="eq-pivot-table-header" data-total="">Total</th>
</tr>
</thead>
<tbody class="eq-pivot-table-body">
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Billing</th>
<td class="eq-pivot-table-cell">4</td>
<td class="eq-pivot-table-cell">11</td>
<td class="eq-pivot-table-cell">26</td>
<td class="eq-pivot-table-cell">9</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">50</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Integrations</th>
<td class="eq-pivot-table-cell">7</td>
<td class="eq-pivot-table-cell">15</td>
<td class="eq-pivot-table-cell">18</td>
<td class="eq-pivot-table-cell"><span aria-hidden="true">–</span><span class="eq-pivot-table-sr">No data</span></td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">40</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Mobile</th>
<td class="eq-pivot-table-cell">2</td>
<td class="eq-pivot-table-cell"><span aria-hidden="true">–</span><span class="eq-pivot-table-sr">No data</span></td>
<td class="eq-pivot-table-cell">12</td>
<td class="eq-pivot-table-cell">5</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">19</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Onboarding</th>
<td class="eq-pivot-table-cell"><span aria-hidden="true">–</span><span class="eq-pivot-table-sr">No data</span></td>
<td class="eq-pivot-table-cell">6</td>
<td class="eq-pivot-table-cell">31</td>
<td class="eq-pivot-table-cell">14</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">51</strong>
</td>
</tr>
</tbody>
<tfoot class="eq-pivot-table-foot">
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Total</th>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">13</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">32</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">87</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">28</strong>
</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">160</strong>
</td>
</tr>
</tfoot>
</table>
</div>Average order value
Overall figures are the mean of every order underneath, not the mean of the cells, so small cells do not skew them.
| Channel | Jan | Feb | Mar | Overall |
|---|---|---|---|---|
| App | $58.10 | $68.03 | $78.95 | $70.41 |
| Retail | $142.60 | No data | $155.20 | $148.90 |
| Web | $102.85 | $86.70 | $132.90 | $102.40 |
| Overall | $101.60 | $77.36 | $111.50 | $96.82 |
Code
import { PivotTable } from "@/components/equip/pivot-table";
type Order = { channel: string; month: string; total: number };
const orders: Order[] = [
{ channel: "Web", month: "Jan", total: 84.2 },
{ channel: "Web", month: "Jan", total: 121.5 },
{ channel: "Web", month: "Feb", total: 96.0 },
{ channel: "Web", month: "Feb", total: 77.4 },
{ channel: "Web", month: "Mar", total: 132.9 },
{ channel: "App", month: "Jan", total: 58.1 },
{ channel: "App", month: "Feb", total: 64.75 },
{ channel: "App", month: "Feb", total: 71.3 },
{ channel: "App", month: "Mar", total: 69.9 },
{ channel: "App", month: "Mar", total: 88.0 },
{ channel: "Retail", month: "Jan", total: 142.6 },
{ channel: "Retail", month: "Mar", total: 155.2 },
];
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
export default function Example() {
return (
<PivotTable
label="Average order value by channel"
rows={orders}
rowField="channel"
columnField="month"
value={(o) => o.total}
aggregate="average"
format={usd.format}
rowHeader="Channel"
columnOrder={["Jan", "Feb", "Mar"]}
/>
);
}HTMLworks in any framework, with the part's CSS
<div class="eq-pivot-table-scroll" tabindex="0" role="region" aria-label="Average order value by channel">
<table class="eq-pivot-table">
<caption class="eq-pivot-table-caption">Average order value by channel</caption>
<thead class="eq-pivot-table-head">
<tr>
<th scope="col" class="eq-pivot-table-corner">Channel</th>
<th scope="col" class="eq-pivot-table-header">Jan</th>
<th scope="col" class="eq-pivot-table-header">Feb</th>
<th scope="col" class="eq-pivot-table-header">Mar</th>
<th scope="col" class="eq-pivot-table-header" data-total="">Overall</th>
</tr>
</thead>
<tbody class="eq-pivot-table-body">
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">App</th>
<td class="eq-pivot-table-cell">$58.10</td>
<td class="eq-pivot-table-cell">$68.03</td>
<td class="eq-pivot-table-cell">$78.95</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$70.41</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Retail</th>
<td class="eq-pivot-table-cell">$142.60</td>
<td class="eq-pivot-table-cell"><span aria-hidden="true">–</span><span class="eq-pivot-table-sr">No data</span></td>
<td class="eq-pivot-table-cell">$155.20</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$148.90</strong>
</td>
</tr>
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Web</th>
<td class="eq-pivot-table-cell">$102.85</td>
<td class="eq-pivot-table-cell">$86.70</td>
<td class="eq-pivot-table-cell">$132.90</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$102.40</strong>
</td>
</tr>
</tbody>
<tfoot class="eq-pivot-table-foot">
<tr class="eq-pivot-table-row">
<th scope="row" class="eq-pivot-table-label">Overall</th>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">$101.60</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">$77.36</strong>
</td>
<td class="eq-pivot-table-cell">
<strong class="eq-pivot-table-figure">$111.50</strong>
</td>
<td class="eq-pivot-table-cell" data-total="">
<strong class="eq-pivot-table-figure">$96.82</strong>
</td>
</tr>
</tfoot>
</table>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | The caption, and the name of the scroll region. |
hideLabel | boolean | false | Hides the caption visually; it still names the table. |
rows | T[] | none | The records to summarise, one per fact, such as one per invoice. |
rowField | keyof T | none | Field whose values become the row headers. |
columnField | keyof T | none | Field whose values become the column headers. |
value | keyof T | (row: T) => number | none | The measure: a numeric field or a function that reads one. Not needed when aggregate is count. |
aggregate | "sum" | "count" | "average" | "sum" | How the records in a cell become one figure. |
format | (n: number) => string | en-US number, up to 2 decimals | Formats every figure, totals included. Pass an Intl.NumberFormat's format for currency. |
rowOrder | string[] | none | Fixes the order of the row headers. Values not listed follow, sorted with a numeric collator. |
columnOrder | string[] | none | Fixes the order of the column headers, such as months or priorities. Listed values with no records still get a column. |
totals | boolean | true | Adds a total column, a total row and the grand total. |
rowHeader | ReactNode | none | Header over the row labels, such as Region. |
columnHeader | ReactNode | none | A header spanning the column labels, such as Quarter, in its own header row. |
totalLabel | string | "Total", or "Overall" for average | Label of the total row and column. |
emptyCell | ReactNode | "–" | Shown in a cell with no records. Screen readers hear No data instead. |
dense | boolean | false | 28px rows and tighter padding. |
...rest | ComponentProps<"table"> | none | Every native table attribute, on the table inside the scroll region. |
Anatomy
| scroll | .eq-pivot-table-scroll | Bordered region that scrolls sideways and takes keyboard focus. |
|---|---|---|
| root | .eq-pivot-table | The table. |
| caption | .eq-pivot-table-caption | The label, as the frame's title bar. |
| head | .eq-pivot-table-head | |
| corner | .eq-pivot-table-corner | Header over the row labels; sticks to the inline start. |
| group | .eq-pivot-table-group | Spans the column headers when columnHeader is set. |
| header | .eq-pivot-table-header | One column value, or the total column with data-total. |
| body | .eq-pivot-table-body | |
| row | .eq-pivot-table-row | |
| label | .eq-pivot-table-label | Row header with scope="row"; sticks while the figures scroll. |
| cell | .eq-pivot-table-cell | One figure, end aligned with tabular figures. |
| figure | .eq-pivot-table-figure | A total, set in the strong weight. |
| foot | .eq-pivot-table-foot | The total row, in tfoot. |
| empty | .eq-pivot-table-empty | Shown when there are no records. |
| screen reader text | .eq-pivot-table-sr | Says No data for an empty cell. |
Keyboard
| Tab | Moves focus to the scroll region. |
|---|---|
| ArrowLeftArrowRight | Scrolls a wide pivot sideways while the region has focus. Native browser behaviour. |
Notes
- A server component: the pivot is worked out while rendering and ships no JavaScript.
- It is a real table with column, column group and row headers, so screen readers announce both headers for every figure.
- Totals are worked out from the records, so an average total is the mean of every record, not the mean of the cells.
- The row labels stick to the inline start edge, so wide pivots keep their labels in view as they scroll.
- Pass an explicit locale to Intl in format, so the server and the browser print the same figures.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/pivot-table.tsx212 lines
import type { ComponentProps, ReactNode } from "react";
import "./pivot-table.css";
export type PivotAggregate = "sum" | "count" | "average";
export type PivotTableProps<T> = Omit<ComponentProps<"table">, "children"> & {
/** The caption, and the name of the scroll region. */
label: string;
/** Hides the caption visually; it still names the table. */
hideLabel?: boolean;
/** The records to summarise, one per fact. */
rows: T[];
/** Field whose values become the row headers. */
rowField: keyof T & string;
/** Field whose values become the column headers. */
columnField: keyof T & string;
/** The measure: a numeric field, or a function that reads one. Not needed for count. */
value?: (keyof T & string) | ((row: T) => number);
aggregate?: PivotAggregate;
/** Formats every figure, totals included. */
format?: (n: number) => string;
/** Fixes the order of the row headers. Values not listed follow, sorted. */
rowOrder?: string[];
/** Fixes the order of the column headers. Values not listed follow, sorted. */
columnOrder?: string[];
/** Adds a total column, a total row and the grand total. */
totals?: boolean;
/** Header over the row labels, such as "Region". */
rowHeader?: ReactNode;
/** Header spanning the column labels, such as "Quarter". */
columnHeader?: ReactNode;
/** Label of the total row and column. */
totalLabel?: string;
/** Shown in a cell with no records, and read as "No data". */
emptyCell?: ReactNode;
dense?: boolean;
};
type Acc = { sum: number; count: number };
const collator = new Intl.Collator("en-US", { numeric: true, sensitivity: "base" });
const plain = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
function order(found: Set<string>, fixed: string[] | undefined) {
const head = fixed ?? [];
const rest = [...found].filter((k) => !head.includes(k)).sort(collator.compare);
return [...head, ...rest];
}
function add(map: Map<string, Acc>, key: string, n: number) {
const acc = map.get(key);
if (acc) {
acc.sum += n;
acc.count += 1;
} else {
map.set(key, { sum: n, count: 1 });
}
}
export function PivotTable<T extends object>({
label,
hideLabel = false,
rows,
rowField,
columnField,
value,
aggregate = "sum",
format = plain.format,
rowOrder,
columnOrder,
totals = true,
rowHeader,
columnHeader,
totalLabel = aggregate === "average" ? "Overall" : "Total",
emptyCell = "–",
dense = false,
className,
...rest
}: PivotTableProps<T>) {
const read = (row: T): number => {
if (aggregate === "count" && value === undefined) return 1;
const v = typeof value === "function" ? value(row) : value === undefined ? 0 : Number(row[value]);
return Number.isFinite(v) ? v : 0;
};
// Sums and counts are kept apart, so an average total is the mean of the records, not of the cells.
const cells = new Map<string, Acc>();
const byRow = new Map<string, Acc>();
const byCol = new Map<string, Acc>();
const all: Acc = { sum: 0, count: 0 };
const rowKeys = new Set<string>();
const colKeys = new Set<string>();
for (const row of rows) {
const r = String(row[rowField] ?? "");
const c = String(row[columnField] ?? "");
const n = read(row);
rowKeys.add(r);
colKeys.add(c);
add(cells, `${r}\u0000${c}`, n);
add(byRow, r, n);
add(byCol, c, n);
all.sum += n;
all.count += 1;
}
const rowList = order(rowKeys, rowOrder);
const colList = order(colKeys, columnOrder);
function show(acc: Acc | undefined, kind: "cell" | "total") {
if (!acc || acc.count === 0) {
return (
<>
<span aria-hidden="true">{emptyCell}</span>
<span className="eq-pivot-table-sr">No data</span>
</>
);
}
const n = aggregate === "sum" ? acc.sum : aggregate === "count" ? acc.count : acc.sum / acc.count;
return kind === "total" ? <strong className="eq-pivot-table-figure">{format(n)}</strong> : format(n);
}
const span = colList.length + (totals ? 1 : 0) + 1;
return (
<div className="eq-pivot-table-scroll" tabIndex={0} role="region" aria-label={label} data-dense={dense || undefined}>
<table {...rest} className={["eq-pivot-table", className].filter(Boolean).join(" ")}>
<caption className="eq-pivot-table-caption" data-hidden={hideLabel || undefined}>
{label}
</caption>
<thead className="eq-pivot-table-head">
{columnHeader != null && (
<tr>
<th scope="col" rowSpan={2} className="eq-pivot-table-corner">
{rowHeader}
</th>
<th scope="colgroup" colSpan={colList.length} className="eq-pivot-table-group">
{columnHeader}
</th>
{totals && (
<th scope="col" rowSpan={2} className="eq-pivot-table-header" data-total="">
{totalLabel}
</th>
)}
</tr>
)}
<tr>
{columnHeader == null && (
<th scope="col" className="eq-pivot-table-corner">
{rowHeader}
</th>
)}
{colList.map((c) => (
<th key={c} scope="col" className="eq-pivot-table-header">
{c}
</th>
))}
{totals && columnHeader == null && (
<th scope="col" className="eq-pivot-table-header" data-total="">
{totalLabel}
</th>
)}
</tr>
</thead>
<tbody className="eq-pivot-table-body">
{rowList.length === 0 ? (
<tr>
<td className="eq-pivot-table-empty" colSpan={span}>
No data
</td>
</tr>
) : (
rowList.map((r) => (
<tr key={r} className="eq-pivot-table-row">
<th scope="row" className="eq-pivot-table-label">
{r}
</th>
{colList.map((c) => (
<td key={c} className="eq-pivot-table-cell">
{show(cells.get(`${r}\u0000${c}`), "cell")}
</td>
))}
{totals && (
<td className="eq-pivot-table-cell" data-total="">
{show(byRow.get(r), "total")}
</td>
)}
</tr>
))
)}
</tbody>
{totals && rowList.length > 0 && (
<tfoot className="eq-pivot-table-foot">
<tr className="eq-pivot-table-row">
<th scope="row" className="eq-pivot-table-label">
{totalLabel}
</th>
{colList.map((c) => (
<td key={c} className="eq-pivot-table-cell">
{show(byCol.get(c), "total")}
</td>
))}
<td className="eq-pivot-table-cell" data-total="">
{show(all, "total")}
</td>
</tr>
</tfoot>
)}
</table>
</div>
);
}components/equip/pivot-table.css150 lines
@layer equip {
.eq-pivot-table-scroll {
--_row: var(--eq-h-m);
--_pad: var(--eq-space-3);
position: relative;
box-sizing: border-box;
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
}
.eq-pivot-table-scroll[data-dense] {
--_row: var(--eq-h-s);
--_pad: var(--eq-space-2);
}
.eq-pivot-table-scroll:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-pivot-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
font-variant-numeric: tabular-nums;
}
/* The caption sits inside the frame as the table's title bar. */
.eq-pivot-table-caption {
padding: var(--eq-space-2) var(--_pad);
border-bottom: 1px solid var(--eq-line);
font-weight: var(--eq-weight-strong);
text-align: start;
}
.eq-pivot-table-caption[data-hidden],
.eq-pivot-table-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-pivot-table-corner,
.eq-pivot-table-group,
.eq-pivot-table-header,
.eq-pivot-table-label,
.eq-pivot-table-cell,
.eq-pivot-table-empty {
box-sizing: border-box;
height: var(--_row);
padding: 0 var(--_pad);
border-bottom: 1px solid var(--eq-line);
vertical-align: middle;
white-space: nowrap;
}
.eq-pivot-table-corner,
.eq-pivot-table-group,
.eq-pivot-table-header {
border-bottom-color: var(--eq-line-strong);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
letter-spacing: 0.01em;
}
.eq-pivot-table-corner {
text-align: start;
vertical-align: bottom;
padding-block-end: calc((var(--_row) - 1lh) / 2);
}
.eq-pivot-table-group {
border-bottom-color: var(--eq-line);
color: var(--eq-ink);
text-align: center;
}
.eq-pivot-table-header,
.eq-pivot-table-cell {
text-align: end;
}
/* Row labels stay in view while wide pivots scroll sideways. */
.eq-pivot-table-corner,
.eq-pivot-table-label {
position: sticky;
inset-inline-start: 0;
z-index: 1;
background: var(--eq-raised);
border-inline-end: 1px solid var(--eq-line);
}
.eq-pivot-table-label {
font-weight: var(--eq-weight);
text-align: start;
}
.eq-pivot-table-cell:has(> .eq-pivot-table-sr) {
color: var(--eq-ink-3);
}
:is(.eq-pivot-table-header, .eq-pivot-table-cell)[data-total] {
border-inline-start: 1px solid var(--eq-line);
}
.eq-pivot-table-figure {
font-weight: var(--eq-weight-strong);
}
.eq-pivot-table-body > .eq-pivot-table-row:last-child > * {
border-bottom-color: var(--eq-line-strong);
}
.eq-pivot-table-foot > .eq-pivot-table-row > * {
border-bottom: 0;
background: var(--eq-tint);
}
.eq-pivot-table-foot .eq-pivot-table-label {
/* A tint over the raised fill, so the sticky label still covers what scrolls under it. */
background: linear-gradient(var(--eq-tint), var(--eq-tint)) var(--eq-raised);
font-weight: var(--eq-weight-strong);
}
@media (hover: hover) {
.eq-pivot-table-body > .eq-pivot-table-row:hover > .eq-pivot-table-cell {
background: var(--eq-tint);
}
}
.eq-pivot-table-empty {
padding-block: var(--eq-space-5);
color: var(--eq-ink-2);
text-align: center;
}
}