-
- {PRESETS.map((preset) => {
- const selected = preset.id === activeId;
- return (
- setActiveId(preset.id)}
- className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
- selected
- ? "bg-blue-600 text-white hover:bg-blue-700 hover:text-white"
- : "bg-gray-200 text-gray-700 hover:bg-gray-300 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
- }`}
- >
- {preset.label}
-
- );
- })}
-
-
- {rows.length} fact rows · Active:{" "}
-
- rows: [{active.pivot.rows.map((r: string) => `"${r}"`).join(", ")}]
- {" "}
-
- columns: [{active.pivot.columns.map((c: string) => `"${c}"`).join(", ")}]
-
-
+
+
+
+ Pivot mode
+
(row?.id == null ? undefined : String(row.id))}
/>
);
diff --git a/apps/marketing/src/components/pages/docs-pages/PivotContent.tsx b/apps/marketing/src/components/pages/docs-pages/PivotContent.tsx
index 77a4adacd..7e097fc9a 100644
--- a/apps/marketing/src/components/pages/docs-pages/PivotContent.tsx
+++ b/apps/marketing/src/components/pages/docs-pages/PivotContent.tsx
@@ -64,12 +64,12 @@ const PIVOT_STEPS: DocsStep[] = [
const PIVOT_PATTERNS: PivotPattern[] = [
{
- title: "Nested row dimensions",
+ title: "Multiple row dimensions",
body: (
<>
Multiple{" "}
pivot.rows fields
- become an expandable tree (e.g. region → product).
+ produce one flat row per combination (e.g. region × product), not an expand/collapse tree.
>
),
codeByFramework: forAllFrameworks(`{
@@ -79,6 +79,23 @@ const PIVOT_PATTERNS: PivotPattern[] = [
}`),
language: "typescript",
},
+ {
+ title: "Pivot Panel (column editor)",
+ body: (
+ <>
+ Set{" "}
+
enablePivotPanel{" "}
+ (with{" "}
+
enableColumnEditor
+ ) to compose Available / Rows / Columns / Values in the side panel. The panel drives{" "}
+
setPivot — pivot
+ is active when Values has at least one measure.
+ >
+ ),
+ codeByFramework: forAllFrameworks(`enableColumnEditor: true,
+enablePivotPanel: true,`),
+ language: "typescript",
+ },
{
title: "Multiple measures",
body: (
@@ -169,7 +186,7 @@ const PIVOT_PROPS: PropInfo[] = [
name: "pivot",
required: false,
description:
- "Matrix pivot config. When set, flat rows are reshaped into dynamic columns. Pass null to disable.",
+ "Matrix pivot config. When set, flat rows are reshaped into dynamic columns (one row per row-dimension combination). Pass null to disable. Consumer rowGrouping is off while active.",
type: "PivotConfig | null",
example: `pivot={{ rows: ["region"], columns: ["quarter"], values: [{ accessor: "sales", aggregation: { type: "sum" } }] }}`,
},
@@ -181,6 +198,15 @@ const PIVOT_PROPS: PropInfo[] = [
type: "(pivot: PivotConfig | null) => void",
example: `onPivotChange={(pivot) => { /* ... */ }}`,
},
+ {
+ key: "enablePivotPanel",
+ name: "enablePivotPanel",
+ required: false,
+ description:
+ "Adds a Pivot Panel to the column editor for placing fields into Rows / Columns / Values. Requires enableColumnEditor. Pivot activates when Values has ≥ 1 measure.",
+ type: "boolean",
+ example: `enableColumnEditor={true} enablePivotPanel={true}`,
+ },
];
const PIVOT_CONFIG_PROPS: PropInfo[] = [
@@ -188,7 +214,7 @@ const PIVOT_CONFIG_PROPS: PropInfo[] = [
key: "rows",
name: "PivotConfig.rows",
required: true,
- description: "Row dimension accessors. Multiple fields → expandable tree.",
+ description: "Row dimension accessors. Multiple fields → one flat row per combination.",
type: "Accessor[]",
example: `rows: ["region", "product"]`,
},
@@ -260,7 +286,7 @@ const TABLE_API_PROPS: PropInfo[] = [
key: "getPivotedRows",
name: "getPivotedRows()",
required: false,
- description: "Post-pivot rows (before flatten/expand).",
+ description: "Post-pivot rows while pivot is active (flat combination rows plus optional totals).",
type: "() => Row[]",
},
];
@@ -342,7 +368,7 @@ const PivotContent = () => {
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
-
+
( Example Props PivotConfig.rows PivotConfig.columns PivotConfig.values PivotConfig.showRowTotals PivotConfig.showColumnTotals PivotConfig.showGrandTotal rows getPivotHeaders getPivotedRows Matrix pivot config. When set, flat rows are reshaped into dynamic columns. Pass null to disable. Fires when pivot changes via TableAPI.setPivot (not every prop sync from your app). Row dimension accessors. Multiple fields → expandable tree. Column dimension accessors. Distinct values become headers. Empty = values only. Measures to aggregate (at least one). Optional label overrides the header. Total column across column dims. Default true. Only when columns is non-empty. Total row across row dims. Default true. Grand-total cells at the totals intersection. Default true. Enable, update, or clear pivot. Pass null for the source grid. Active pivot config, or null when off. Generated headers while pivot is active. Post-pivot rows (before flatten/expand). pivot={{ rows: [\"region\"], columns: [\"quarter\"], values: [{ accessor: \"sales\", aggregation: { type: \"sum\" } }] }} onPivotChange={(pivot) => { /* ... */ }} rows: [\"region\", \"product\"] columns: [\"quarter\"] values: [{ accessor: \"sales\", aggregation: { type: \"sum\" } }] showRowTotals: false showColumnTotals: false showGrandTotal: false Sales Units",
+ "content": "Use a flat fact table — one object per measure row, not a pre-nested tree. Turn flat rows into a matrix — row fields on the left, column fields as dynamic headers, values aggregated in each cell. columns pivot rowGrouping pivot.rows enablePivotPanel enableColumnEditor setPivot values label columns: [] showRowTotals showColumnTotals showGrandTotal getPivot null onPivotChange Pass source fields in — labels, types, and formatters for the fact data. While pivot is active, the grid shows generated headers instead of this catalog as columns. Configure with row dims, column dims, and at least one value. Consumer is ignored while pivot is on. Multiple fields produce one flat row per combination (e.g. region × product), not an expand/collapse tree. ) to compose Available / Rows / Columns / Values in the side panel. The panel drives — pivot is active when Values has at least one measure. Add more entries to . Optional overrides the header. Aggregation types match aggregate functions to group and aggregate without a matrix of dynamic headers. , and Call to enable or update, to read the active config, or pass to return to the source grid. Pivot Tables Patterns {PIVOT_PATTERNS.map((pattern) => ( Example Props PivotConfig.rows PivotConfig.columns PivotConfig.values PivotConfig.showRowTotals PivotConfig.showColumnTotals PivotConfig.showGrandTotal rows getPivotHeaders getPivotedRows Matrix pivot config. When set, flat rows are reshaped into dynamic columns (one row per row-dimension combination). Pass null to disable. Consumer rowGrouping is off while active. Fires when pivot changes via TableAPI.setPivot (not every prop sync from your app). Adds a Pivot Panel to the column editor for placing fields into Rows / Columns / Values. Requires enableColumnEditor. Pivot activates when Values has ≥ 1 measure. Row dimension accessors. Multiple fields → one flat row per combination. Column dimension accessors. Distinct values become headers. Empty = values only. Measures to aggregate (at least one). Optional label overrides the header. Total column across column dims. Default true. Only when columns is non-empty. Total row across row dims. Default true. Grand-total cells at the totals intersection. Default true. Enable, update, or clear pivot. Pass null for the source grid. Active pivot config, or null when off. Generated headers while pivot is active. Post-pivot rows while pivot is active (flat combination rows plus optional totals). pivot={{ rows: [\"region\"], columns: [\"quarter\"], values: [{ accessor: \"sales\", aggregation: { type: \"sum\" } }] }} onPivotChange={(pivot) => { /* ... */ }} enableColumnEditor={true} enablePivotPanel={true} rows: [\"region\", \"product\"] columns: [\"quarter\"] values: [{ accessor: \"sales\", aggregation: { type: \"sum\" } }] showRowTotals: false showColumnTotals: false showGrandTotal: false Sales Units",
"section": "Row Features",
"headings": [
"Pivot Tables",
"Define the field catalog",
"Provide flat rows",
"Set pivot",
- "Nested row dimensions",
+ "Multiple row dimensions",
+ "Pivot Panel (column editor)",
"Multiple measures",
"Values only (no column dims)",
"Totals",
diff --git a/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts b/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts
index 79c3691bf..b99c629cf 100644
--- a/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts
+++ b/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts
@@ -127,6 +127,16 @@ animations={{ enabled: false }}`,
type: "boolean",
example: `enableColumnEditorInitOpen={true}`,
},
+ {
+ key: "enablePivotPanel",
+ name: "enablePivotPanel",
+ required: false,
+ description:
+ "Adds a Pivot Panel to the column editor (Available / Rows / Columns / Values). Requires enableColumnEditor. Pivot activates when Values has at least one measure.",
+ type: "boolean",
+ link: "/docs/pivot",
+ example: `enableColumnEditor={true} enablePivotPanel={true}`,
+ },
{
key: "expandAll",
name: "expandAll",
diff --git a/apps/marketing/src/constants/strings/seo.ts b/apps/marketing/src/constants/strings/seo.ts
index 4a4a310a0..a9a72ce6d 100644
--- a/apps/marketing/src/constants/strings/seo.ts
+++ b/apps/marketing/src/constants/strings/seo.ts
@@ -464,7 +464,7 @@ export const SEO_STRINGS = {
reactPivotTable: {
title: "React Pivot Table Tutorial: Matrix Aggregation Without AG Grid Enterprise (2026)",
description:
- "Build a React pivot table with row/column dimensions, aggregations, and totals—without AG Grid Enterprise. Code examples, pivot vs row grouping, and Pivot Panel roadmap.",
+ "Build a React pivot table with row/column dimensions, aggregations, totals, and an in-table Pivot Panel—without AG Grid Enterprise. Code examples and pivot vs row grouping.",
keywords: [
"react pivot table",
"react pivot table tutorial",
@@ -1167,9 +1167,9 @@ export const SEO_STRINGS = {
pivot: {
title: "Pivot Tables in Simple Table: Matrix Aggregation Data Grid",
description:
- "Build matrix pivot tables with Simple Table. Declarative rows, columns, values, aggregations, and totals for React, Vue, Angular, Svelte, Solid, or vanilla TypeScript—AG Grid Enterprise pivot alternative.",
+ "Build matrix pivot tables with Simple Table. Declarative rows, columns, values, aggregations, totals, and an in-table Pivot Panel (enablePivotPanel) for React, Vue, Angular, Svelte, Solid, or vanilla TypeScript—AG Grid Enterprise pivot alternative.",
keywords:
- "simple-table, data-grid, datagrid, pivot table, react pivot table, matrix pivot, pivot mode, pivot aggregation, typescript table, javascript data grid, ag grid pivot alternative, declarative pivot, cross tab table, vue pivot table, angular pivot table",
+ "simple-table, data-grid, datagrid, pivot table, react pivot table, matrix pivot, pivot mode, pivot aggregation, pivot panel, typescript table, javascript data grid, ag grid pivot alternative, declarative pivot, cross tab table, vue pivot table, angular pivot table",
},
rowGrouping: {
title: "Row Grouping in Simple Table Data Grid",
diff --git a/apps/marketing/src/examples/analytics/AnalyticsExample.tsx b/apps/marketing/src/examples/analytics/AnalyticsExample.tsx
index a4925f223..f53adf719 100644
--- a/apps/marketing/src/examples/analytics/AnalyticsExample.tsx
+++ b/apps/marketing/src/examples/analytics/AnalyticsExample.tsx
@@ -28,7 +28,6 @@ export default function AnalyticsExample({
const [activeId, setActiveId] = useState(analyticsPresets[0].id);
const active = analyticsPresets.find((p) => p.id === activeId) ?? analyticsPresets[0];
const isPivoted = active.pivot != null;
- const nestedRows = (active.pivot?.rows.length ?? 0) > 1;
const isDark = theme === "dark" || theme === "modern-dark";
const tableHostRef = useRef(null);
const tableRef = useRef(null);
@@ -155,8 +154,6 @@ export default function AnalyticsExample({
copyHeadersToClipboard
columns={analyticsHeaders}
enableColumnEditor
- enableStickyParents={nestedRows}
- expandAll={nestedRows}
getRowId={({ row }) => {
const id = row.id;
return id == null ? undefined : String(id);
diff --git a/apps/marketing/src/examples/analytics/analytics-data.ts b/apps/marketing/src/examples/analytics/analytics-data.ts
index 0b374b91d..af873cb8e 100644
--- a/apps/marketing/src/examples/analytics/analytics-data.ts
+++ b/apps/marketing/src/examples/analytics/analytics-data.ts
@@ -184,8 +184,8 @@ export const analyticsPresets: AnalyticsPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
description: "Drill into products within each region",
pivot: {
rows: ["region", "product"],
diff --git a/packages/angular/package.json b/packages/angular/package.json
index e4675d03d..07095aca5 100644
--- a/packages/angular/package.json
+++ b/packages/angular/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/angular",
- "version": "4.1.5",
+ "version": "4.1.6",
"type": "module",
"main": "./dist/fesm2022/simple-table-angular.mjs",
"module": "./dist/fesm2022/simple-table-angular.mjs",
diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts
index 5affd5a43..3607bc6b1 100644
--- a/packages/angular/src/index.ts
+++ b/packages/angular/src/index.ts
@@ -125,7 +125,6 @@ export {
pivotRows,
buildPivotAccessor,
buildPivotRowTotalAccessor,
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
PIVOT_ACCESSOR_PREFIX,
PIVOT_BLANK_LABEL,
diff --git a/packages/angular/src/lib/SimpleTableComponent.ts b/packages/angular/src/lib/SimpleTableComponent.ts
index 99ad7cadd..ad8a50637 100644
--- a/packages/angular/src/lib/SimpleTableComponent.ts
+++ b/packages/angular/src/lib/SimpleTableComponent.ts
@@ -87,6 +87,7 @@ export class SimpleTableComponent<
@Input() columnReordering?: SimpleTableAngularProps["columnReordering"];
@Input() enableColumnEditor?: SimpleTableAngularProps["enableColumnEditor"];
@Input() enableColumnEditorInitOpen?: SimpleTableAngularProps["enableColumnEditorInitOpen"];
+ @Input() enablePivotPanel?: SimpleTableAngularProps["enablePivotPanel"];
@Input() selectableCells?: SimpleTableAngularProps["selectableCells"];
@Input() selectableColumns?: SimpleTableAngularProps["selectableColumns"];
@Input() enableHeaderEditing?: SimpleTableAngularProps["enableHeaderEditing"];
@@ -258,8 +259,7 @@ export class SimpleTableComponent<
if (this.enableColumnEditor !== undefined) props.enableColumnEditor = this.enableColumnEditor;
if (this.enableColumnEditorInitOpen !== undefined)
props.enableColumnEditorInitOpen = this.enableColumnEditorInitOpen;
- if (this.enableColumnEditorInitOpen !== undefined)
- props.enableColumnEditorInitOpen = this.enableColumnEditorInitOpen;
+ if (this.enablePivotPanel !== undefined) props.enablePivotPanel = this.enablePivotPanel;
if (this.selectableCells !== undefined) props.selectableCells = this.selectableCells;
if (this.selectableColumns !== undefined) props.selectableColumns = this.selectableColumns;
if (this.enableHeaderEditing !== undefined)
diff --git a/packages/core/package.json b/packages/core/package.json
index 263fb1752..951d2b19c 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "simple-table-core",
- "version": "4.1.5",
+ "version": "4.1.6",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/index.d.ts",
diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts
index 398593874..27e1f4522 100644
--- a/packages/core/src/core/SimpleTableVanilla.ts
+++ b/packages/core/src/core/SimpleTableVanilla.ts
@@ -585,8 +585,6 @@ export class SimpleTableVanilla {
const pivotState = this.pivotManager?.getState();
const initialSortRows =
pivotState?.active ? pivotState.pivotedRows : this.localRows;
- const initialSortGrouping =
- pivotState?.active ? pivotState.rowGrouping : this.config.rowGrouping;
this.sortManager = new SortManager({
headers: this.headers,
@@ -595,7 +593,7 @@ export class SimpleTableVanilla {
// Read from live config at invocation time so callback props updated via
// update() (e.g. a React re-render with a fresh closure) aren't stale.
onSortChange: (sort) => this.config.onSortChange?.(sort),
- rowGrouping: initialSortGrouping,
+ rowGrouping: this.getEffectiveRowGrouping(),
initialSortColumn: this.config.initialSortColumn,
initialSortDirection: this.config.initialSortDirection,
announce,
@@ -1218,11 +1216,14 @@ export class SimpleTableVanilla {
}
}
- /** Row grouping used for flatten/expand while pivot is active (overrides consumer). */
+ /**
+ * Pivot emits a flat matrix — disable consumer rowGrouping while active so
+ * expand/collapse hierarchy does not wrap pivoted rows.
+ */
private getEffectiveRowGrouping(): Accessor[] | undefined {
const pivotState = this.pivotManager?.getState();
if (pivotState?.active) {
- return pivotState.rowGrouping;
+ return undefined;
}
return this.config.rowGrouping;
}
@@ -1252,14 +1253,13 @@ export class SimpleTableVanilla {
this.sortManager?.updateConfig({
tableRows: state.pivotedRows,
headers: state.headers,
- rowGrouping: state.rowGrouping,
+ rowGrouping: undefined,
});
this.selectionManager?.updateConfig({ headers: state.headers });
+ // Flat pivot — clear any expand depths from consumer rowGrouping.
+ this.expandedDepthsManager?.updateRowGrouping(undefined);
if (!wasActive) {
- this.expandedDepthsManager?.updateRowGrouping(state.rowGrouping);
this.collapsedHeaders = TableInitializer.getInitialCollapsedHeaders(state.headers);
- } else {
- this.expandedDepthsManager?.updateRowGrouping(state.rowGrouping);
}
if (this.dimensionManager) {
const effectiveHeaders = this.renderOrchestrator.computeEffectiveHeaders(
@@ -1298,7 +1298,7 @@ export class SimpleTableVanilla {
const pivotState = this.pivotManager?.getState();
const effectiveConfig =
pivotState?.active
- ? { ...this.config, rowGrouping: pivotState.rowGrouping }
+ ? { ...this.config, rowGrouping: undefined }
: this.config;
const effectiveLocalRows =
pivotState?.active ? pivotState.pivotedRows : this.localRows;
@@ -1428,6 +1428,15 @@ export class SimpleTableVanilla {
getExpandedRows: () => this.expandedRows,
getHeaders: () => this.headers,
getPristineDefaultHeaders: () => this.pristineDefaultHeaders,
+ getPivot: () => this.pivotManager?.getPivot() ?? this.config.pivot ?? null,
+ setPivot: (pivotConfig) => {
+ this.config = { ...this.config, pivot: pivotConfig };
+ this.syncPivotPipeline(this.filterManager?.getFilteredRows() ?? this.localRows);
+ this.config.onPivotChange?.(pivotConfig);
+ this.renderOrchestrator.invalidateCache("header");
+ this.renderOrchestrator.invalidateCache("body");
+ this.render("setPivot");
+ },
getRowStateMap: () => this.rowStateMap,
setColumnEditorOpen: (open: boolean) => {
this.columnEditorOpen = open;
diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts
index 6cdf15fed..ff236d4a3 100644
--- a/packages/core/src/core/rendering/RenderOrchestrator.ts
+++ b/packages/core/src/core/rendering/RenderOrchestrator.ts
@@ -1,6 +1,7 @@
import { SimpleTableConfig } from "../../types/SimpleTableConfig";
import { CustomTheme } from "../../types/CustomTheme";
import ColumnDef, { Accessor } from "../../types/ColumnDef";
+import type { PivotConfig } from "../../types/PivotTypes";
import Row from "../../types/Row";
import RowState from "../../types/RowState";
import { DimensionManager, type DimensionManagerState } from "../../managers/DimensionManager";
@@ -66,6 +67,8 @@ export interface RenderContext {
getHeaders: () => ColumnDef[];
/** Pristine snapshot of the configured column definitions — the reset target for the column editor's reset button. */
getPristineDefaultHeaders: () => ColumnDef[];
+ getPivot: () => PivotConfig | null;
+ setPivot: (pivot: PivotConfig | null) => void;
getRowStateMap: () => Map;
headerRegistry: Map;
headers: ColumnDef[];
@@ -891,6 +894,8 @@ export class RenderOrchestrator {
getExpandedRows: context.getExpandedRows,
getHeaders: context.getHeaders,
getPristineDefaultHeaders: context.getPristineDefaultHeaders,
+ getPivot: context.getPivot,
+ setPivot: context.setPivot,
getRowStateMap: context.getRowStateMap,
positionOnlyBody: context.positionOnlyBody,
essentialAccessors: context.essentialAccessors,
diff --git a/packages/core/src/core/rendering/TableRenderer.ts b/packages/core/src/core/rendering/TableRenderer.ts
index bd40aacb6..e5783856d 100644
--- a/packages/core/src/core/rendering/TableRenderer.ts
+++ b/packages/core/src/core/rendering/TableRenderer.ts
@@ -1,4 +1,5 @@
import ColumnDef, { Accessor } from "../../types/ColumnDef";
+import type { PivotConfig } from "../../types/PivotTypes";
import { SimpleTableConfig } from "../../types/SimpleTableConfig";
import { CustomTheme } from "../../types/CustomTheme";
import { FilterCondition } from "../../types/FilterTypes";
@@ -66,6 +67,8 @@ export interface TableRendererDeps {
getHeaders: () => ColumnDef[];
/** Pristine snapshot of the configured column definitions — the reset target for the column editor's reset button. */
getPristineDefaultHeaders: () => ColumnDef[];
+ getPivot: () => PivotConfig | null;
+ setPivot: (pivot: PivotConfig | null) => void;
getRowStateMap: () => Map;
headerRegistry: Map;
headers: ColumnDef[];
@@ -1043,11 +1046,18 @@ export class TableRenderer {
}
};
+ // Always the source field catalog — never pivoted live headers.
+ const pivotFields = deps.getPristineDefaultHeaders();
+
if (this.columnEditorInstance) {
this.columnEditorInstance.update({
columnEditorText: mergedColumnEditorConfig.text,
enableColumnEditor: deps.config.enableColumnEditor,
+ enablePivotPanel: deps.config.enablePivotPanel,
headers: deps.headers,
+ pivotFields,
+ pivot: deps.getPivot(),
+ setPivot: deps.setPivot,
open: columnEditorOpen,
searchEnabled: mergedColumnEditorConfig.searchEnabled,
searchPlaceholder: mergedColumnEditorConfig.searchPlaceholder,
@@ -1072,8 +1082,12 @@ export class TableRenderer {
} else {
const columnEditor = createColumnEditor({
columnEditorText: mergedColumnEditorConfig.text,
- enableColumnEditor: deps.config.enableColumnEditor,
+ enableColumnEditor: deps.config.enableColumnEditor ?? false,
+ enablePivotPanel: deps.config.enablePivotPanel,
headers: deps.headers,
+ pivotFields,
+ pivot: deps.getPivot(),
+ setPivot: deps.setPivot,
open: columnEditorOpen,
searchEnabled: mergedColumnEditorConfig.searchEnabled,
searchPlaceholder: mergedColumnEditorConfig.searchPlaceholder,
diff --git a/packages/core/src/hooks/expandedDepths.ts b/packages/core/src/hooks/expandedDepths.ts
index 6946983da..7b5b05707 100644
--- a/packages/core/src/hooks/expandedDepths.ts
+++ b/packages/core/src/hooks/expandedDepths.ts
@@ -21,18 +21,20 @@ export const initializeExpandedDepths = (
*/
export class ExpandedDepthsManager {
private expandedDepths: Set;
+ private shouldExpandAll: boolean;
private observers: Set<(depths: Set) => void> = new Set();
/** Coalesce sync collapseAll→expandDepth into a single observer notification. */
private notifyMicrotaskScheduled = false;
constructor(expandAll: boolean, rowGrouping?: Accessor[]) {
+ this.shouldExpandAll = expandAll;
this.expandedDepths = initializeExpandedDepths(expandAll, rowGrouping);
}
/**
- * Updates the expanded depths when rowGrouping changes
- * Filters out depths that are now out of range
- * @param rowGrouping - The current row grouping configuration
+ * Updates the expanded depths when rowGrouping changes.
+ * When `expandAll` is on (default), re-expand so grouping that appears after
+ * mount is not stuck collapsed. Otherwise keep in-range depths only.
*/
updateRowGrouping(rowGrouping?: Accessor[]): void {
if (!rowGrouping || rowGrouping.length === 0) {
@@ -40,8 +42,12 @@ export class ExpandedDepthsManager {
return;
}
+ if (this.shouldExpandAll) {
+ this.setExpandedDepths(initializeExpandedDepths(true, rowGrouping));
+ return;
+ }
+
const maxDepth = rowGrouping.length;
- // Filter out depths that are now out of range
const filtered = Array.from(this.expandedDepths).filter((d) => d < maxDepth);
this.setExpandedDepths(new Set(filtered));
}
diff --git a/packages/core/src/icons/CloseIcon.ts b/packages/core/src/icons/CloseIcon.ts
new file mode 100644
index 000000000..7c69f24eb
--- /dev/null
+++ b/packages/core/src/icons/CloseIcon.ts
@@ -0,0 +1,11 @@
+import { createStrokeIcon } from "./createStrokeIcon";
+
+/** Stroke X — dismiss / remove control. */
+export const createCloseIcon = (className?: string): SVGSVGElement =>
+ createStrokeIcon({
+ className,
+ width: 14,
+ height: 14,
+ strokeWidth: 2,
+ paths: ["M18 6L6 18", "M6 6l12 12"],
+ });
diff --git a/packages/core/src/icons/index.ts b/packages/core/src/icons/index.ts
index 158395385..852bed301 100644
--- a/packages/core/src/icons/index.ts
+++ b/packages/core/src/icons/index.ts
@@ -9,6 +9,7 @@ export { createAngleRightIcon } from "./AngleRightIcon";
export { createAngleUpIcon } from "./AngleUpIcon";
export { createAscIcon } from "./AscIcon";
export { createCheckIcon } from "./CheckIcon";
+export { createCloseIcon } from "./CloseIcon";
export { createDescIcon } from "./DescIcon";
export { createMinusIcon } from "./MinusIcon";
diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts
index e4b695aa4..3f283321c 100644
--- a/packages/core/src/index.d.ts
+++ b/packages/core/src/index.d.ts
@@ -9,7 +9,7 @@ import type ColumnDef from "./types/ColumnDef";
import type { Accessor, ChartOptions, ColumnType, Comparator, ComparatorProps, ExportValueGetter, ExportValueProps, ShowWhen, ValueFormatter, ValueFormatterProps, ValueGetter, ValueGetterProps } from "./types/ColumnDef";
import type { AggregationConfig, AggregationType } from "./types/AggregationTypes";
import type { PivotConfig, PivotValueConfig, PivotResult } from "./types/PivotTypes";
-import { PIVOT_CHILDREN_KEY, PIVOT_IS_TOTAL_KEY, PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL } from "./types/PivotTypes";
+import { PIVOT_IS_TOTAL_KEY, PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL } from "./types/PivotTypes";
import { pivotRows, buildPivotAccessor, buildPivotRowTotalAccessor } from "./utils/pivot/pivotRows";
import type OnSortProps from "./types/OnSortProps";
import type OnRowGroupExpandProps from "./types/OnRowGroupExpandProps";
@@ -52,7 +52,7 @@ import type { RowId } from "./types/RowId";
import type { PinnedSectionsState } from "./types/PinnedSectionsState";
export { SimpleTableVanilla };
export { asRows } from "./utils/asRows";
-export { pivotRows, buildPivotAccessor, buildPivotRowTotalAccessor, PIVOT_CHILDREN_KEY, PIVOT_IS_TOTAL_KEY, PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL, };
+export { pivotRows, buildPivotAccessor, buildPivotRowTotalAccessor, PIVOT_IS_TOTAL_KEY, PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL, };
export { headersStructurallyEqual, collectHeaderAccessors, rowsShallowUnchanged, shallowEqualRow, SHALLOW_ROW_COMPARE_MAX, } from "./utils/propSyncEqual";
export type { HeaderStructureLike, GetRowIdLike } from "./utils/propSyncEqual";
export type { Accessor, AggregationConfig, AggregationType, AnimationsConfig, BoundingBox, Cell, CellChangeProps, CellClickProps, CellRenderer, CellRendererProps, CellValue, ChartOptions, ColumnEditorConfig, ColumnEditorCustomRenderer, ColumnEditorCustomRendererProps, ColumnEditorRowRenderer, ColumnEditorRowRendererComponents, ColumnEditorRowRendererProps, ColumnEditorSearchFunction, ColumnType, ColumnVisibilityState, Comparator, ComparatorProps, CustomTheme, CustomThemeProps, DragHandlerProps, EmptyStateRenderer, EmptyStateRendererProps, EnumOption, ErrorStateRenderer, ErrorStateRendererProps, ExportToCSVProps, ExportValueGetter, ExportValueProps, FilterCondition, FilterOperator, StringFilterOperator, NumberFilterOperator, BooleanFilterOperator, DateFilterOperator, EnumFilterOperator, FooterRendererProps, FooterPosition, GetRowId, GetRowIdParams, IconsConfig, LoadingStateRenderer, LoadingStateRendererProps, HeaderDropdown, HeaderDropdownProps, ColumnDef, HeaderRenderer, HeaderRendererProps, HeaderRendererComponents, OnRowGroupExpandProps, OnSortProps, PivotConfig, PivotValueConfig, PivotResult, QuickFilterConfig, QuickFilterGetter, QuickFilterGetterProps, QuickFilterMode, Row, RowButtonProps, RowId, RowSelectionChangeProps, RowSelectionMode, RowState, SetHeaderRenameProps, SharedTableProps, ShowWhen, SimpleTableConfig, SimpleTableProps, SortColumn, TableAPI, TableFilterState, TableHeaderProps, TableRowProps, Theme, PinnedSectionsState, UpdateDataProps, ValueFormatter, ValueFormatterProps, ValueGetter, ValueGetterProps, };
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8890fa4ce..332687fbf 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -25,7 +25,6 @@ import type { SimpleTableConfigInput } from "./utils/normalizeConfig";
import type { AggregationConfig, AggregationType } from "./types/AggregationTypes";
import type { PivotConfig, PivotValueConfig, PivotResult } from "./types/PivotTypes";
import {
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
PIVOT_ACCESSOR_PREFIX,
PIVOT_BLANK_LABEL,
@@ -110,7 +109,6 @@ export {
pivotRows,
buildPivotAccessor,
buildPivotRowTotalAccessor,
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
PIVOT_ACCESSOR_PREFIX,
PIVOT_BLANK_LABEL,
diff --git a/packages/core/src/managers/PivotManager.ts b/packages/core/src/managers/PivotManager.ts
index ee60c73d9..4469a84f6 100644
--- a/packages/core/src/managers/PivotManager.ts
+++ b/packages/core/src/managers/PivotManager.ts
@@ -1,5 +1,4 @@
import type ColumnDef from "../types/ColumnDef";
-import type { Accessor } from "../types/ColumnDef";
import type Row from "../types/Row";
import type { PivotConfig, PivotResult } from "../types/PivotTypes";
import { pivotRows } from "../utils/pivot/pivotRows";
@@ -14,7 +13,6 @@ export interface PivotManagerState {
active: boolean;
pivotedRows: Row[];
headers: ColumnDef[];
- rowGrouping?: Accessor[];
pivot: PivotConfig | null;
}
@@ -61,7 +59,6 @@ export class PivotManager {
active: false,
pivotedRows: config.sourceRows,
headers: config.fieldHeaders,
- rowGrouping: undefined,
pivot: null,
};
}
@@ -78,7 +75,6 @@ export class PivotManager {
active: false,
pivotedRows: config.sourceRows,
headers: config.fieldHeaders,
- rowGrouping: undefined,
pivot: null,
};
}
@@ -87,7 +83,6 @@ export class PivotManager {
active: true,
pivotedRows: result.rows,
headers: result.headers,
- rowGrouping: result.rowGrouping,
pivot,
};
}
diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css
index a53b3230f..17f6ce64d 100644
--- a/packages/core/src/styles/base.css
+++ b/packages/core/src/styles/base.css
@@ -1153,6 +1153,8 @@ input {
touch-action: auto;
border-left: var(--st-border-width) solid var(--st-border-color);
height: 100%;
+ min-height: 0;
+ overflow: hidden;
}
/* Column Editor Search Wrapper */
@@ -1194,6 +1196,108 @@ input {
padding: var(--st-spacing-small) var(--st-spacing-medium) 0;
}
+/* Pivot panel — fill popout height and scroll when zones overflow */
+.st-pivot-panel {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+ color: var(--st-column-editor-text-color);
+ font-size: var(--st-overlay-font-size);
+}
+
+.st-pivot-panel-zone + .st-pivot-panel-zone {
+ border-top: var(--st-border-width) solid var(--st-border-color);
+}
+
+.st-pivot-panel-zone-title {
+ font-size: var(--st-overlay-font-size);
+ font-weight: 600;
+ color: var(--st-column-editor-text-color);
+ padding: var(--st-spacing-medium) var(--st-spacing-medium) var(--st-spacing-small);
+}
+
+.st-pivot-panel-zone-list {
+ padding-top: 0;
+ padding-bottom: var(--st-spacing-medium);
+}
+
+.st-pivot-panel-zone-empty {
+ padding-top: var(--st-spacing-small);
+ font-size: var(--st-overlay-font-size);
+ color: var(--st-column-editor-text-color);
+ opacity: 0.55;
+}
+
+.st-pivot-panel-field,
+.st-pivot-panel-chip {
+ display: flex;
+ align-items: center;
+ gap: var(--st-spacing-small);
+ padding-top: var(--st-spacing-small);
+ padding-bottom: var(--st-spacing-small);
+ color: var(--st-column-editor-text-color);
+}
+
+.st-pivot-panel-field-label,
+.st-pivot-panel-chip-label {
+ flex: 1;
+ min-width: 0;
+ font-size: var(--st-overlay-font-size);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.st-pivot-panel-field-actions {
+ display: flex;
+ gap: var(--st-spacing-small);
+ flex-shrink: 0;
+}
+
+.st-pivot-panel-action {
+ padding: var(--st-spacing-small) var(--st-spacing-medium);
+ font-size: var(--st-overlay-font-size);
+ font-weight: 500;
+ line-height: 1.2;
+ color: var(--st-column-editor-text-color);
+ background: transparent;
+ border: var(--st-border-width) solid var(--st-border-color);
+ border-radius: var(--st-border-radius);
+ cursor: pointer;
+ transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+}
+
+.st-pivot-panel-action:hover {
+ background-color: var(--st-button-hover-background-color);
+}
+
+.st-pivot-panel-remove {
+ flex-shrink: 0;
+ color: var(--st-column-editor-text-color);
+}
+
+/* Aggregation select — same size tokens as filter CustomSelect */
+.st-pivot-panel-agg {
+ width: auto;
+ min-width: 6.5rem;
+ max-width: 9rem;
+ flex-shrink: 0;
+}
+
+.st-pivot-panel-agg .st-custom-select-trigger {
+ background-color: transparent;
+ color: var(--st-column-editor-text-color);
+}
+
+.st-pivot-panel-agg .st-custom-select-value {
+ color: var(--st-column-editor-text-color);
+}
+
+.st-pivot-panel-agg .st-custom-select-arrow {
+ color: var(--st-column-editor-text-color);
+}
+
.st-column-editor-list-section {
padding-top: 0;
padding-bottom: var(--st-spacing-small);
diff --git a/packages/core/src/types/ColumnEditorCustomRendererProps.ts b/packages/core/src/types/ColumnEditorCustomRendererProps.ts
index a64a6d79e..93a4b9866 100644
--- a/packages/core/src/types/ColumnEditorCustomRendererProps.ts
+++ b/packages/core/src/types/ColumnEditorCustomRendererProps.ts
@@ -5,6 +5,11 @@ export interface ColumnEditorCustomRendererProps {
headers: ColumnDef[];
/** Pre-built search input section, or null if search is disabled */
searchSection: HTMLElement | null;
+ /**
+ * Pre-built Pivot section (Rows / Columns / Values), or null when
+ * `enablePivotPanel` is false.
+ */
+ pivotSection: HTMLElement | null;
/** Pre-built column list section with drag-and-drop, checkboxes, etc. */
listSection: HTMLElement;
/** Pre-built reset button section, or null if no default headers are configured */
diff --git a/packages/core/src/types/PivotTypes.ts b/packages/core/src/types/PivotTypes.ts
index 9858ec7a2..5c64b84d4 100644
--- a/packages/core/src/types/PivotTypes.ts
+++ b/packages/core/src/types/PivotTypes.ts
@@ -11,7 +11,7 @@ export type PivotValueConfig = {
};
export type PivotConfig = {
- /** Row dimension accessors (0+). Multi-level dims become an expandable tree. */
+ /** Row dimension accessors (0+). Multi-level dims → one flat row per combination. */
rows: Accessor[];
/** Column dimension accessors (0+). Distinct values become dynamic header groups. */
columns: Accessor[];
@@ -25,9 +25,6 @@ export type PivotConfig = {
showGrandTotal?: boolean;
};
-/** Synthetic child-array key used for multi-level pivot row trees. */
-export const PIVOT_CHILDREN_KEY = "__pivotChildren";
-
/** Marker on total rows for styling / identification. */
export const PIVOT_IS_TOTAL_KEY = "__pivotIsTotal";
@@ -40,9 +37,4 @@ export const PIVOT_BLANK_LABEL = "(blank)";
export type PivotResult = {
rows: Row[];
headers: ColumnDef[];
- /**
- * Internal rowGrouping while pivot is active (undefined when flat).
- * Kept as open accessors — pivot injects synthetic keys.
- */
- rowGrouping?: Accessor[];
};
diff --git a/packages/core/src/types/SimpleTableConfig.ts b/packages/core/src/types/SimpleTableConfig.ts
index 387c33abc..45f4978ae 100644
--- a/packages/core/src/types/SimpleTableConfig.ts
+++ b/packages/core/src/types/SimpleTableConfig.ts
@@ -59,6 +59,8 @@ export interface SimpleTableConfig {
enableColumnEditor?: boolean;
/** Open the column editor when the table loads. */
enableColumnEditorInitOpen?: boolean;
+ /** @see SimpleTableProps.enablePivotPanel */
+ enablePivotPanel?: boolean;
emptyStateRenderer?: VanillaEmptyStateRenderer;
enableHeaderEditing?: boolean;
/** Enable client-side pagination. */
@@ -139,7 +141,7 @@ export interface SimpleTableConfig {
/**
* Property names that define the row grouping hierarchy.
* `Accessor` keeps keyof autocomplete; the `string & {}` arm still
- * allows dynamic / heterogeneous nesting keys and pivot-injected keys.
+ * allows dynamic / heterogeneous nesting keys.
*/
rowGrouping?: Accessor[];
getRowId?: GetRowId;
diff --git a/packages/core/src/types/SimpleTableProps.ts b/packages/core/src/types/SimpleTableProps.ts
index 8be0514b0..1328a9538 100644
--- a/packages/core/src/types/SimpleTableProps.ts
+++ b/packages/core/src/types/SimpleTableProps.ts
@@ -48,6 +48,11 @@ export interface SimpleTableProps {
enableColumnEditor?: boolean;
/** Open the column editor when the table loads. */
enableColumnEditorInitOpen?: boolean;
+ /**
+ * Show a Pivot section in the column editor popout (Rows / Columns / Values).
+ * Requires `enableColumnEditor`. Does not apply a pivot by itself — pair with `pivot`.
+ */
+ enablePivotPanel?: boolean;
emptyStateRenderer?: EmptyStateRenderer; // Custom renderer for empty states (for nested row states)
enableHeaderEditing?: boolean; // Flag for enabling header label editing when clicking already active headers
/** Enable client-side pagination. */
@@ -129,7 +134,8 @@ export interface SimpleTableProps {
onSortChange?: (sort: SortColumn | null) => void; // Callback when sort is applied
/**
* Declarative matrix pivot. When set, flat `rows` are reshaped into a
- * pivoted grid with dynamic columns. Ignores consumer `rowGrouping` while active.
+ * pivoted grid with dynamic columns (one row per row-dimension combination).
+ * Consumer `rowGrouping` is disabled while pivot is active.
*/
pivot?: PivotConfig | null;
/** Fired when pivot config changes via TableAPI.setPivot. */
@@ -139,7 +145,7 @@ export interface SimpleTableProps {
/**
* Property names that define the row grouping hierarchy.
* `Accessor` keeps keyof autocomplete; the `string & {}` arm still
- * allows dynamic / heterogeneous nesting keys and pivot-injected keys.
+ * allows dynamic / heterogeneous nesting keys.
*/
rowGrouping?: Accessor[];
getRowId?: GetRowId; // Stable business id for a row. Return null/undefined when the row has no id (pivot aggregates, loading) to use reference-based identity.
diff --git a/packages/core/src/types/TableAPI.ts b/packages/core/src/types/TableAPI.ts
index fbb19cd56..93960f379 100644
--- a/packages/core/src/types/TableAPI.ts
+++ b/packages/core/src/types/TableAPI.ts
@@ -85,8 +85,8 @@ export type TableAPI = {
/** Generated headers while pivot is active; otherwise current headers. */
getPivotHeaders: () => ColumnDef[];
/**
- * Post-pivot rows (pre-flatten) while pivot is active; otherwise source rows.
- * Always {@link Row} — pivot injects synthetic keys (e.g. `__pivotChildren`)
+ * Post-pivot rows while pivot is active; otherwise source rows.
+ * Always {@link Row} — pivot may inject synthetic measure accessors / total markers
* that are not part of consumer `TData`.
*/
getPivotedRows: () => Row[];
diff --git a/packages/core/src/utils/columnEditor/createColumnEditor.ts b/packages/core/src/utils/columnEditor/createColumnEditor.ts
index d37b51b96..35777d280 100644
--- a/packages/core/src/utils/columnEditor/createColumnEditor.ts
+++ b/packages/core/src/utils/columnEditor/createColumnEditor.ts
@@ -1,5 +1,6 @@
import ColumnDef from "../../types/ColumnDef";
import { ColumnEditorSearchFunction, ColumnEditorConfig } from "../../types/ColumnEditorConfig";
+import type { PivotConfig } from "../../types/PivotTypes";
import { createColumnEditorPopout } from "./createColumnEditorPopout";
import { ColumnVisibilityState } from "../../types/ColumnVisibilityTypes";
import { IconsConfig } from "../../types/IconsConfig";
@@ -8,7 +9,12 @@ import { COLUMN_EDIT_WIDTH } from "../../consts/general-consts";
export interface CreateColumnEditorOptions {
columnEditorText: string;
enableColumnEditor: boolean;
+ enablePivotPanel?: boolean;
headers: ColumnDef[];
+ /** Source field catalog for the pivot panel (pristine columns). */
+ pivotFields?: ColumnDef[];
+ pivot?: PivotConfig | null;
+ setPivot?: (pivot: PivotConfig | null) => void;
open: boolean;
searchEnabled: boolean;
searchPlaceholder: string;
@@ -27,7 +33,11 @@ export const createColumnEditor = (options: CreateColumnEditorOptions) => {
let {
columnEditorText,
enableColumnEditor,
+ enablePivotPanel = false,
headers,
+ pivotFields,
+ pivot = null,
+ setPivot,
open,
searchEnabled,
searchPlaceholder,
@@ -75,6 +85,10 @@ export const createColumnEditor = (options: CreateColumnEditorOptions) => {
const popout = createColumnEditorPopout({
headers,
open,
+ enablePivotPanel,
+ pivotFields,
+ pivot,
+ setPivot,
searchEnabled,
searchPlaceholder,
searchFunction,
@@ -117,9 +131,20 @@ export const createColumnEditor = (options: CreateColumnEditorOptions) => {
if (newOptions.resetColumns !== undefined) {
resetColumns = newOptions.resetColumns;
}
+ if (newOptions.enablePivotPanel !== undefined) {
+ enablePivotPanel = newOptions.enablePivotPanel;
+ }
+ if (newOptions.pivotFields !== undefined) pivotFields = newOptions.pivotFields;
+ if (newOptions.pivot !== undefined) pivot = newOptions.pivot;
+ if (newOptions.setPivot !== undefined) setPivot = newOptions.setPivot;
popout.update({
headers: newOptions.headers,
open: newOptions.open,
+ enablePivotPanel:
+ newOptions.enablePivotPanel !== undefined ? enablePivotPanel : undefined,
+ pivotFields: newOptions.pivotFields,
+ pivot: newOptions.pivot,
+ setPivot: newOptions.setPivot,
searchEnabled: newOptions.searchEnabled,
searchPlaceholder: newOptions.searchPlaceholder,
searchFunction: newOptions.searchFunction,
diff --git a/packages/core/src/utils/columnEditor/createColumnEditorPopout.ts b/packages/core/src/utils/columnEditor/createColumnEditorPopout.ts
index 1327d4afa..23241e70a 100644
--- a/packages/core/src/utils/columnEditor/createColumnEditorPopout.ts
+++ b/packages/core/src/utils/columnEditor/createColumnEditorPopout.ts
@@ -2,6 +2,7 @@ import ColumnDef from "../../types/ColumnDef";
import { ColumnEditorSearchFunction, ColumnEditorConfig } from "../../types/ColumnEditorConfig";
import { ColumnEditorCustomRenderer } from "../../types/ColumnEditorCustomRendererProps";
import { FlattenedHeader } from "../../types/FlattenedHeader";
+import type { PivotConfig } from "../../types/PivotTypes";
import { createColumnEditorRow } from "./createColumnEditorRow";
import {
getColumnEditorCheckboxState,
@@ -11,10 +12,16 @@ import { ColumnVisibilityState } from "../../types/ColumnVisibilityTypes";
import { IconsConfig } from "../../types/IconsConfig";
import { partitionRootHeadersByPin, PanelSection } from "../../utils/pinnedColumnUtils";
import { updateCheckboxElement } from "./createCheckbox";
+import { createPivotPanel, type PivotPanelInstance } from "./createPivotPanel";
export interface CreateColumnEditorPopoutOptions {
headers: ColumnDef[];
open: boolean;
+ /** When true, show the Pivot section (Rows / Columns / Values) above the column list. */
+ enablePivotPanel?: boolean;
+ pivotFields?: ColumnDef[];
+ pivot?: PivotConfig | null;
+ setPivot?: (pivot: PivotConfig | null) => void;
searchEnabled: boolean;
searchPlaceholder: string;
searchFunction?: ColumnEditorSearchFunction;
@@ -106,9 +113,15 @@ function buildResetSection(onReset: () => void): HTMLElement {
function assembleDefaultLayout(
content: HTMLElement,
searchWrapper: HTMLElement | null,
+ pivotSection: HTMLElement | null,
listsContainer: HTMLElement,
resetFooter: HTMLElement | null,
): void {
+ if (pivotSection) {
+ content.appendChild(pivotSection);
+ return;
+ }
+ // Search belongs with column visibility — hidden in pivot mode.
if (searchWrapper) content.appendChild(searchWrapper);
content.appendChild(listsContainer);
if (resetFooter) content.appendChild(resetFooter);
@@ -119,6 +132,7 @@ function assembleCustomLayout(
customRenderer: ColumnEditorCustomRenderer,
headers: ColumnDef[],
searchWrapper: HTMLElement | null,
+ pivotSection: HTMLElement | null,
listsContainer: HTMLElement,
resetFooter: HTMLElement | null,
resetColumns?: () => void,
@@ -126,6 +140,7 @@ function assembleCustomLayout(
const rendered = customRenderer({
headers,
searchSection: searchWrapper,
+ pivotSection,
listSection: listsContainer,
resetSection: resetFooter,
resetColumns,
@@ -145,6 +160,10 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou
let {
headers,
open,
+ enablePivotPanel = false,
+ pivotFields,
+ pivot = null,
+ setPivot,
searchEnabled,
searchPlaceholder,
searchFunction,
@@ -202,15 +221,33 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou
resetFooter = buildResetSection(resetColumns);
}
+ let pivotPanelInstance: PivotPanelInstance | null = null;
+ let pivotSection: HTMLElement | null = null;
+ // Field catalog must be pristine source columns — never live/pivoted `headers`.
+ if (enablePivotPanel && setPivot && pivotFields) {
+ pivotPanelInstance = createPivotPanel({
+ fields: pivotFields,
+ pivot,
+ setPivot,
+ });
+ pivotSection = pivotPanelInstance.element;
+ }
+
let activeCustomRenderer = columnEditorConfig.customRenderer;
if (activeCustomRenderer) {
assembleCustomLayout(
- content, activeCustomRenderer, headers,
- searchWrapper, listsContainer, resetFooter, resetColumns,
+ content,
+ activeCustomRenderer,
+ headers,
+ searchWrapper,
+ pivotSection,
+ listsContainer,
+ resetFooter,
+ resetColumns,
);
} else {
- assembleDefaultLayout(content, searchWrapper, listsContainer, resetFooter);
+ assembleDefaultLayout(content, searchWrapper, pivotSection, listsContainer, resetFooter);
}
container.appendChild(content);
@@ -461,17 +498,65 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou
if (activeCustomRenderer) {
assembleCustomLayout(
- content, activeCustomRenderer, headers,
- searchWrapper, listsContainer, resetFooter, resetColumns,
+ content,
+ activeCustomRenderer,
+ headers,
+ searchWrapper,
+ pivotSection,
+ listsContainer,
+ resetFooter,
+ resetColumns,
);
} else {
- assembleDefaultLayout(content, searchWrapper, listsContainer, resetFooter);
+ assembleDefaultLayout(content, searchWrapper, pivotSection, listsContainer, resetFooter);
}
};
const update = (newOptions: Partial) => {
let structureDirty = false;
let headersUpdated = false;
+ let needsLayoutRebuild = false;
+
+ if (newOptions.pivotFields !== undefined) pivotFields = newOptions.pivotFields;
+ if (newOptions.pivot !== undefined) pivot = newOptions.pivot;
+ if (newOptions.setPivot !== undefined) setPivot = newOptions.setPivot;
+
+ if (
+ newOptions.enablePivotPanel !== undefined &&
+ newOptions.enablePivotPanel !== enablePivotPanel
+ ) {
+ enablePivotPanel = newOptions.enablePivotPanel;
+ pivotPanelInstance?.destroy();
+ pivotPanelInstance = null;
+ pivotSection = null;
+ if (enablePivotPanel && setPivot && pivotFields) {
+ pivotPanelInstance = createPivotPanel({
+ fields: pivotFields,
+ pivot,
+ setPivot,
+ });
+ pivotSection = pivotPanelInstance.element;
+ }
+ structureDirty = true;
+ needsLayoutRebuild = true;
+ } else if (enablePivotPanel && !pivotPanelInstance && setPivot && pivotFields) {
+ // Late catalog/setPivot arrival (first paint before pristine headers were ready).
+ pivotPanelInstance = createPivotPanel({
+ fields: pivotFields,
+ pivot,
+ setPivot,
+ });
+ pivotSection = pivotPanelInstance.element;
+ structureDirty = true;
+ needsLayoutRebuild = true;
+ } else if (pivotPanelInstance) {
+ // Config wins after setPivot — sync from stored pristine fields + getPivot().
+ pivotPanelInstance.update({
+ fields: pivotFields,
+ pivot,
+ setPivot,
+ });
+ }
if (newOptions.searchEnabled !== undefined && newOptions.searchEnabled !== searchEnabled) {
searchEnabled = newOptions.searchEnabled;
@@ -492,8 +577,6 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou
onColumnOrderChange = newOptions.onColumnOrderChange;
if (newOptions.resetColumns !== undefined) resetColumns = newOptions.resetColumns;
- let needsLayoutRebuild = false;
-
if (newOptions.columnEditorConfig !== undefined) {
const newCustomRenderer = newOptions.columnEditorConfig.customRenderer;
if (newCustomRenderer !== activeCustomRenderer) {
@@ -536,6 +619,8 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou
};
const destroy = () => {
+ pivotPanelInstance?.destroy();
+ pivotPanelInstance = null;
if (searchInput) {
searchInput.removeEventListener("input", () => {});
searchInput.removeEventListener("click", () => {});
diff --git a/packages/core/src/utils/columnEditor/createPivotPanel.ts b/packages/core/src/utils/columnEditor/createPivotPanel.ts
new file mode 100644
index 000000000..77ecc9900
--- /dev/null
+++ b/packages/core/src/utils/columnEditor/createPivotPanel.ts
@@ -0,0 +1,332 @@
+import type { AggregationType } from "../../types/AggregationTypes";
+import type ColumnDef from "../../types/ColumnDef";
+import type { Accessor } from "../../types/ColumnDef";
+import type { PivotConfig, PivotValueConfig } from "../../types/PivotTypes";
+import { createCloseIcon } from "../../icons/CloseIcon";
+import { createCustomSelect } from "../filters/createCustomSelect";
+
+const AGG_TYPES: AggregationType[] = ["sum", "average", "count", "min", "max"];
+
+const AGG_OPTIONS = AGG_TYPES.map((type) => ({
+ value: type,
+ label: type.charAt(0).toUpperCase() + type.slice(1),
+}));
+
+type PivotZone = "rows" | "columns" | "values";
+
+type PanelPivotState = {
+ rows: Accessor[];
+ columns: Accessor[];
+ values: { accessor: Accessor; aggregation: AggregationType }[];
+};
+
+const EMPTY_STATE: PanelPivotState = { rows: [], columns: [], values: [] };
+
+function flattenLeafHeaders(headers: ColumnDef[]): ColumnDef[] {
+ const leaves: ColumnDef[] = [];
+ const walk = (list: ColumnDef[]) => {
+ for (const header of list) {
+ if (header.isSelectionColumn || header.excludeFromRender) continue;
+ if (header.children && header.children.length > 0) {
+ walk(header.children);
+ } else {
+ leaves.push(header);
+ }
+ }
+ };
+ walk(headers);
+ return leaves;
+}
+
+function isMeasure(header: ColumnDef): boolean {
+ return header.type === "number";
+}
+
+function toPanelState(pivot: PivotConfig | null | undefined): PanelPivotState {
+ if (!pivot) return structuredClone(EMPTY_STATE);
+ return {
+ rows: [...pivot.rows],
+ columns: [...pivot.columns],
+ values: pivot.values.map((v) => ({
+ accessor: v.accessor,
+ aggregation: (v.aggregation?.type ?? "sum") as AggregationType,
+ })),
+ };
+}
+
+function toPivotConfig(state: PanelPivotState): PivotConfig | null {
+ if (state.values.length === 0) return null;
+ const values: PivotValueConfig[] = state.values.map((v) => ({
+ accessor: v.accessor,
+ aggregation: { type: v.aggregation },
+ }));
+ return {
+ rows: [...state.rows],
+ columns: [...state.columns],
+ values,
+ };
+}
+
+function fieldLabel(fields: ColumnDef[], accessor: Accessor): string {
+ return fields.find((f) => f.accessor === accessor)?.label ?? String(accessor);
+}
+
+export type CreatePivotPanelOptions = {
+ fields: ColumnDef[];
+ pivot: PivotConfig | null;
+ setPivot: (pivot: PivotConfig | null) => void;
+};
+
+export type PivotPanelInstance = {
+ element: HTMLElement;
+ update: (options: Partial) => void;
+ destroy: () => void;
+};
+
+/**
+ * Interactive pivot field composer: Available fields + Rows / Columns / Values.
+ */
+export function createPivotPanel(options: CreatePivotPanelOptions): PivotPanelInstance {
+ let fields = flattenLeafHeaders(options.fields);
+ let state = toPanelState(options.pivot);
+ let setPivot = options.setPivot;
+ const selectInstances: Array> = [];
+
+ const root = document.createElement("div");
+ root.className = "st-pivot-panel";
+
+ const availableHost = document.createElement("div");
+ availableHost.className = "st-pivot-panel-zone";
+ availableHost.dataset.pivotZone = "available";
+
+ const rowsHost = document.createElement("div");
+ rowsHost.className = "st-pivot-panel-zone";
+ rowsHost.dataset.pivotZone = "rows";
+
+ const colsHost = document.createElement("div");
+ colsHost.className = "st-pivot-panel-zone";
+ colsHost.dataset.pivotZone = "columns";
+
+ const valsHost = document.createElement("div");
+ valsHost.className = "st-pivot-panel-zone";
+ valsHost.dataset.pivotZone = "values";
+
+ root.append(availableHost, rowsHost, colsHost, valsHost);
+
+ const commit = () => {
+ // Local paint first; setPivot re-renders the table and syncs panel state back.
+ render();
+ setPivot(toPivotConfig(state));
+ };
+
+ const removeFromAll = (accessor: Accessor) => {
+ state.rows = state.rows.filter((a) => a !== accessor);
+ state.columns = state.columns.filter((a) => a !== accessor);
+ state.values = state.values.filter((v) => v.accessor !== accessor);
+ };
+
+ const place = (accessor: Accessor, zone: PivotZone) => {
+ const header = fields.find((f) => f.accessor === accessor);
+ if (!header) return;
+
+ if (zone === "values") {
+ if (!isMeasure(header)) return;
+ removeFromAll(accessor);
+ state.values = [...state.values, { accessor, aggregation: "sum" }];
+ } else {
+ if (isMeasure(header)) return;
+ removeFromAll(accessor);
+ if (zone === "rows") state.rows = [...state.rows, accessor];
+ else state.columns = [...state.columns, accessor];
+ }
+ commit();
+ };
+
+ const makeSectionLabel = (text: string) => {
+ const label = document.createElement("div");
+ label.className = "st-pivot-panel-zone-title";
+ label.textContent = text;
+ return label;
+ };
+
+ const makeList = () => {
+ const list = document.createElement("div");
+ list.className = "st-column-editor-list st-pivot-panel-zone-list";
+ return list;
+ };
+
+ const makeEmpty = (text: string) => {
+ const empty = document.createElement("div");
+ empty.className = "st-pivot-panel-zone-empty";
+ empty.textContent = text;
+ return empty;
+ };
+
+ const makeActionBtn = (text: string, onClick: () => void) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "st-pivot-panel-action";
+ btn.textContent = text;
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ onClick();
+ });
+ return btn;
+ };
+
+ const makeChip = (
+ accessor: Accessor,
+ zone: PivotZone,
+ aggregation?: AggregationType
+ ): HTMLElement => {
+ const chip = document.createElement("div");
+ chip.className = "st-pivot-panel-chip";
+
+ const label = document.createElement("span");
+ label.className = "st-pivot-panel-chip-label";
+ label.textContent = fieldLabel(fields, accessor);
+ chip.appendChild(label);
+
+ if (zone === "values" && aggregation) {
+ const select = createCustomSelect({
+ value: aggregation,
+ className: "st-pivot-panel-agg",
+ options: AGG_OPTIONS,
+ onChange: (next) => {
+ const target = state.values.find((v) => v.accessor === accessor);
+ if (!target || target.aggregation === next) return;
+ target.aggregation = next as AggregationType;
+ commit();
+ },
+ });
+ select.element.dataset.agg = aggregation;
+ select.element.addEventListener("click", (e) => e.stopPropagation());
+ selectInstances.push(select);
+ chip.appendChild(select.element);
+ }
+
+ const remove = document.createElement("button");
+ remove.type = "button";
+ remove.className = "st-column-pin-btn st-pivot-panel-remove";
+ remove.setAttribute("aria-label", `Remove ${fieldLabel(fields, accessor)}`);
+ remove.appendChild(createCloseIcon("st-column-pin-svg"));
+ remove.addEventListener("click", (e) => {
+ e.stopPropagation();
+ removeFromAll(accessor);
+ commit();
+ });
+ chip.appendChild(remove);
+ return chip;
+ };
+
+ const renderZone = (
+ host: HTMLElement,
+ title: string,
+ zone: PivotZone,
+ accessors: Accessor[],
+ emptyText: string,
+ valueAggs?: { accessor: Accessor; aggregation: AggregationType }[]
+ ) => {
+ host.replaceChildren();
+ host.appendChild(makeSectionLabel(title));
+ const list = makeList();
+ if (accessors.length === 0) {
+ list.appendChild(makeEmpty(emptyText));
+ } else if (zone === "values" && valueAggs) {
+ for (const v of valueAggs) {
+ list.appendChild(makeChip(v.accessor, "values", v.aggregation));
+ }
+ } else {
+ for (const accessor of accessors) {
+ list.appendChild(makeChip(accessor, zone));
+ }
+ }
+ host.appendChild(list);
+ };
+
+ const renderAvailable = () => {
+ availableHost.replaceChildren();
+ availableHost.appendChild(makeSectionLabel("Available"));
+ const list = makeList();
+
+ const used = new Set([
+ ...state.rows.map(String),
+ ...state.columns.map(String),
+ ...state.values.map((v) => String(v.accessor)),
+ ]);
+ const available = fields.filter((f) => !used.has(String(f.accessor)));
+
+ if (available.length === 0) {
+ list.appendChild(makeEmpty("All fields placed"));
+ } else {
+ for (const header of available) {
+ const row = document.createElement("div");
+ row.className = "st-pivot-panel-field";
+
+ const label = document.createElement("span");
+ label.className = "st-pivot-panel-field-label";
+ label.textContent = header.label;
+ row.appendChild(label);
+
+ const actions = document.createElement("div");
+ actions.className = "st-pivot-panel-field-actions";
+
+ if (isMeasure(header)) {
+ actions.appendChild(makeActionBtn("Values", () => place(header.accessor, "values")));
+ } else {
+ actions.appendChild(makeActionBtn("Rows", () => place(header.accessor, "rows")));
+ actions.appendChild(makeActionBtn("Columns", () => place(header.accessor, "columns")));
+ }
+ row.appendChild(actions);
+ list.appendChild(row);
+ }
+ }
+ availableHost.appendChild(list);
+ };
+
+ const destroySelects = () => {
+ while (selectInstances.length > 0) {
+ selectInstances.pop()?.destroy();
+ }
+ };
+
+ const render = () => {
+ destroySelects();
+ renderAvailable();
+ renderZone(rowsHost, "Rows", "rows", state.rows, "Add dimensions");
+ renderZone(colsHost, "Columns", "columns", state.columns, "Add dimensions");
+ renderZone(
+ valsHost,
+ "Values",
+ "values",
+ state.values.map((v) => v.accessor),
+ "Add measures",
+ state.values
+ );
+ };
+
+ render();
+
+ return {
+ element: root,
+ update: (next) => {
+ if (next.fields !== undefined) fields = flattenLeafHeaders(next.fields);
+ if (next.setPivot !== undefined) setPivot = next.setPivot;
+ if (next.pivot !== undefined) {
+ // Config wins when pivot is active (has values) or when an active
+ // pivot is cleared externally. While values are empty, rows/columns
+ // may be staged in the panel with setPivot(null) — keep that draft.
+ if (next.pivot !== null) {
+ state = toPanelState(next.pivot);
+ } else if (state.values.length > 0) {
+ state = toPanelState(null);
+ }
+ }
+ render();
+ },
+ destroy: () => {
+ destroySelects();
+ root.remove();
+ },
+ };
+}
diff --git a/packages/core/src/utils/filters/createCustomSelect.ts b/packages/core/src/utils/filters/createCustomSelect.ts
index 5db70ba6a..0365c7fbf 100644
--- a/packages/core/src/utils/filters/createCustomSelect.ts
+++ b/packages/core/src/utils/filters/createCustomSelect.ts
@@ -87,6 +87,8 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => {
renderOptions();
+ // Fixed + portaled to the table root (same as filter shells) so overflow:auto
+ // ancestors never clip the menu.
const dropdown = createDropdown({
children: optionsContainer,
containerRef,
@@ -97,11 +99,9 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => {
},
open: isOpen,
overflow: "auto",
- positioning: "absolute",
+ positioning: "fixed",
});
- container.appendChild(dropdown.element);
-
const syncValueFromSelection = (optionValue: string) => {
value = optionValue;
const opt = selectOptions.find((o) => o.value === value);
@@ -110,10 +110,11 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => {
const handleOptionClick = (optionValue: string) => {
syncValueFromSelection(optionValue);
- onChange(optionValue);
setOpen(false);
focusedIndex = -1;
renderOptions();
+ // Fire after close so consumers can safely rebuild DOM (e.g. pivot panel).
+ onChange(optionValue);
};
const handleToggle = () => {
@@ -147,10 +148,10 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => {
if (focusedIndex >= 0) {
const v = selectOptions[focusedIndex].value;
syncValueFromSelection(v);
- onChange(v);
setOpen(false);
focusedIndex = -1;
renderOptions();
+ onChange(v);
}
break;
case "Escape":
diff --git a/packages/core/src/utils/pivot/pivotRows.ts b/packages/core/src/utils/pivot/pivotRows.ts
index 953826f3e..998d8b862 100644
--- a/packages/core/src/utils/pivot/pivotRows.ts
+++ b/packages/core/src/utils/pivot/pivotRows.ts
@@ -10,7 +10,6 @@ import type {
import {
PIVOT_ACCESSOR_PREFIX,
PIVOT_BLANK_LABEL,
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
} from "../../types/PivotTypes";
import { aggregateValues } from "../aggregationUtils";
@@ -224,10 +223,9 @@ const buildColumnHeaders = ({
const buildRowDimensionHeaders = (
rowDims: Accessor[],
- catalog: Map,
- expandable: boolean
+ catalog: Map
): ColumnDef[] => {
- return rowDims.map((accessor, index) => {
+ return rowDims.map((accessor) => {
const field = findFieldHeader(catalog, accessor);
return {
accessor,
@@ -238,7 +236,8 @@ const buildRowDimensionHeaders = (
pinned: "left" as const,
sortable: field?.sortable ?? true,
filterable: field?.filterable,
- expandable: expandable && index === 0 ? true : field?.expandable,
+ // Flat pivot layout — never wire expand/collapse onto row dims.
+ expandable: false,
valueFormatter: field?.valueFormatter,
valueGetter: field?.valueGetter,
minWidth: field?.minWidth,
@@ -332,9 +331,9 @@ const fillMeasureCells = ({
}
};
-const buildRowTree = ({
+/** One table row per distinct combination of row-dimension values (tabular / flat). */
+const buildFlatRows = ({
rowDims,
- prefixParts,
distinctRowKeys,
buckets,
colKeys,
@@ -342,82 +341,27 @@ const buildRowTree = ({
showRowTotals,
}: {
rowDims: Accessor[];
- prefixParts: DimValue[];
distinctRowKeys: string[];
buckets: Map;
colKeys: string[];
values: PivotValueConfig[];
showRowTotals: boolean;
}): Row[] => {
- const depth = prefixParts.length;
-
- if (depth >= rowDims.length) {
- return [];
- }
-
- const prefixKey = encodeKey(prefixParts);
- const nextDimIndex = depth;
- const childPartsByLabel = new Map();
-
- for (const rowKey of distinctRowKeys) {
+ return distinctRowKeys.map((rowKey) => {
const parts = decodeKey(rowKey);
- if (prefixKey !== "") {
- if (rowKey !== prefixKey && !rowKey.startsWith(prefixKey + KEY_SEP)) continue;
- }
- if (parts.length <= nextDimIndex) continue;
- const part = parts[nextDimIndex];
- childPartsByLabel.set(dimLabel(part), part);
- }
-
- const sortedLabels = Array.from(childPartsByLabel.keys()).sort(compareDimLabels);
- const isLeafLevel = depth === rowDims.length - 1;
-
- return sortedLabels.map((label) => {
- const part = childPartsByLabel.get(label)!;
- const nextPrefix = [...prefixParts, part];
- const nextPrefixKey = encodeKey(nextPrefix);
const row: Row = {};
-
rowDims.forEach((accessor, i) => {
- if (i <= depth) {
- row[accessor] = nextPrefix[i];
- }
+ row[accessor] = parts[i] ?? PIVOT_BLANK_LABEL;
});
-
- if (isLeafLevel) {
- fillMeasureCells({
- row,
- buckets,
- rowKeyPrefix: nextPrefixKey,
- colKeys,
- values,
- showRowTotals,
- exactRowKey: true,
- });
- return row;
- }
-
- const children = buildRowTree({
- rowDims,
- prefixParts: nextPrefix,
- distinctRowKeys,
- buckets,
- colKeys,
- values,
- showRowTotals,
- });
- row[PIVOT_CHILDREN_KEY] = children;
-
fillMeasureCells({
row,
buckets,
- rowKeyPrefix: nextPrefixKey,
+ rowKeyPrefix: rowKey,
colKeys,
values,
showRowTotals,
- exactRowKey: false,
+ exactRowKey: true,
});
-
return row;
});
};
@@ -503,8 +447,7 @@ export const pivotRows = ({ rows, pivot, fieldHeaders }: PivotRowsProps): PivotR
showRowTotals: effectiveShowRowTotals,
});
- const expandable = rowDims.length > 1;
- const rowHeaders = buildRowDimensionHeaders(rowDims, catalog, expandable);
+ const rowHeaders = buildRowDimensionHeaders(rowDims, catalog);
const headers: ColumnDef[] = [...rowHeaders, ...columnHeaders];
let pivotedRows: Row[];
@@ -522,9 +465,8 @@ export const pivotRows = ({ rows, pivot, fieldHeaders }: PivotRowsProps): PivotR
});
pivotedRows = [row];
} else {
- pivotedRows = buildRowTree({
+ pivotedRows = buildFlatRows({
rowDims,
- prefixParts: [],
distinctRowKeys,
buckets,
colKeys,
@@ -548,10 +490,5 @@ export const pivotRows = ({ rows, pivot, fieldHeaders }: PivotRowsProps): PivotR
pivotedRows = [...pivotedRows, totalRow];
}
- const rowGrouping: Accessor[] | undefined =
- rowDims.length > 1
- ? Array.from({ length: rowDims.length - 1 }, () => PIVOT_CHILDREN_KEY)
- : undefined;
-
- return { rows: pivotedRows, headers, rowGrouping };
+ return { rows: pivotedRows, headers };
};
diff --git a/packages/core/stories/docs/Features.stories.ts b/packages/core/stories/docs/Features.stories.ts
index 3024ca720..1265e78b9 100644
--- a/packages/core/stories/docs/Features.stories.ts
+++ b/packages/core/stories/docs/Features.stories.ts
@@ -106,6 +106,10 @@ import {
paginationAPIExampleDefaults,
} from "../examples/PaginationAPIExample";
import { renderPivotExample, pivotExampleDefaults } from "../examples/PivotExample";
+import {
+ renderPivotPanelExample,
+ pivotPanelExampleDefaults,
+} from "../examples/PivotPanelExample";
import {
renderPinnedColumnsExample,
pinnedColumnsExampleDefaults,
@@ -464,6 +468,19 @@ export const Pivot: StoryObj = {
},
};
+export const PivotPanel: StoryObj = {
+ ...storyArgs(pivotPanelExampleDefaults),
+ render: (args) => renderPivotPanelExample(args),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "In-table Pivot Panel: enablePivotPanel adds Available / Rows / Columns / Values to the column editor. Place fields, set aggregations, and the grid updates via setPivot (flat multi-dimension rows).",
+ },
+ },
+ },
+};
+
export const PinnedColumns: StoryObj = {
...storyArgs(pinnedColumnsExampleDefaults),
render: (args) => renderPinnedColumnsExample(args),
diff --git a/packages/core/stories/examples/PivotExample.ts b/packages/core/stories/examples/PivotExample.ts
index fde64a293..1c036cb2e 100644
--- a/packages/core/stories/examples/PivotExample.ts
+++ b/packages/core/stories/examples/PivotExample.ts
@@ -273,9 +273,9 @@ function createChipRow(): HTMLDivElement {
}
export const pivotExampleDefaults = {
+ autoExpandColumns: true,
columnResizing: true,
height: "480px",
- expandAll: true,
columnBorders: true,
pivot: stateToPivot(DEFAULT_STATE),
};
@@ -485,14 +485,6 @@ export function renderPivotExample(args?: Partial): HTMLEl
}
actionsRow.replaceChildren();
- const expandBtn = document.createElement("button");
- expandBtn.textContent = "Expand all";
- styleButton(expandBtn);
- expandBtn.addEventListener("click", () => table.getAPI().expandAll());
- const collapseBtn = document.createElement("button");
- collapseBtn.textContent = "Collapse all";
- styleButton(collapseBtn);
- collapseBtn.addEventListener("click", () => table.getAPI().collapseAll());
const csvBtn = document.createElement("button");
csvBtn.textContent = "Export CSV";
styleButton(csvBtn);
@@ -517,7 +509,7 @@ export function renderPivotExample(args?: Partial): HTMLEl
renderControls();
applyPivot();
});
- actionsRow.append(expandBtn, collapseBtn, csvBtn, clearBtn, resetBtn);
+ actionsRow.append(csvBtn, clearBtn, resetBtn);
};
renderControls();
diff --git a/packages/core/stories/examples/PivotPanelExample.ts b/packages/core/stories/examples/PivotPanelExample.ts
new file mode 100644
index 000000000..534611e54
--- /dev/null
+++ b/packages/core/stories/examples/PivotPanelExample.ts
@@ -0,0 +1,79 @@
+/**
+ * PivotPanelExample – scaffold for the in-table Pivot Panel feature.
+ */
+import type { ColumnDef, Row } from "../../src/index";
+import { renderVanillaTable } from "../utils";
+import { defaultVanillaArgs, type UniversalVanillaArgs } from "../vanillaStoryConfig";
+
+const HEADERS: ColumnDef[] = [
+ { accessor: "region", label: "Region", width: 120, type: "string", sortable: true },
+ { accessor: "product", label: "Product", width: 120, type: "string", sortable: true },
+ { accessor: "quarter", label: "Quarter", width: 100, type: "string", sortable: true },
+ {
+ accessor: "sales",
+ label: "Sales",
+ width: 100,
+ type: "number",
+ align: "right",
+ sortable: true,
+ valueFormatter: ({ value }) => (typeof value === "number" ? `$${value.toLocaleString()}` : ""),
+ },
+ {
+ accessor: "units",
+ label: "Units",
+ width: 90,
+ type: "number",
+ align: "right",
+ sortable: true,
+ },
+];
+
+const REGIONS = ["West", "East", "North", "South"] as const;
+const PRODUCTS = ["Widget", "Gadget", "License"] as const;
+const QUARTERS = ["Q1", "Q2", "Q3", "Q4"] as const;
+
+const ROWS: Row[] = (() => {
+ const rows: Row[] = [];
+ let id = 1;
+ for (const region of REGIONS) {
+ for (const product of PRODUCTS) {
+ for (const quarter of QUARTERS) {
+ const base = 40 + ((id * 17) % 90);
+ rows.push({
+ id,
+ region,
+ product,
+ quarter,
+ sales: base * 100,
+ units: base,
+ });
+ id++;
+ }
+ }
+ }
+ return rows;
+})();
+
+export const pivotPanelExampleDefaults = {
+ autoExpandColumns: true,
+ columnResizing: true,
+ columnReordering: true,
+ selectableCells: true,
+ enableColumnEditor: true,
+ enableColumnEditorInitOpen: true,
+ enablePivotPanel: true,
+ columnBorders: true,
+ height: "480px",
+};
+
+export function renderPivotPanelExample(args?: Partial): HTMLElement {
+ const options = { ...defaultVanillaArgs, ...pivotPanelExampleDefaults, ...args };
+ const { wrapper, h2 } = renderVanillaTable(HEADERS, ROWS, {
+ ...options,
+ // Pivot aggregate rows have no `id` — return undefined so the table uses
+ // reference identity (String(row.id) would collapse every pivot row to "undefined").
+ getRowId: ({ row }) => (row?.id == null ? undefined : String(row.id)),
+ });
+ h2.textContent = "Pivot Panel";
+ return wrapper;
+}
diff --git a/packages/core/stories/examples/PivotPanelLargeExample.ts b/packages/core/stories/examples/PivotPanelLargeExample.ts
new file mode 100644
index 000000000..6be4991ce
--- /dev/null
+++ b/packages/core/stories/examples/PivotPanelLargeExample.ts
@@ -0,0 +1,145 @@
+/**
+ * Large deterministic dataset for Pivot Panel DOM interaction tests.
+ * Full cartesian: 4×5×2×4×3 → 480 source rows × 10 columns.
+ */
+import type { ColumnDef, Row } from "../../src/index";
+import { renderVanillaTable } from "../utils";
+import { defaultVanillaArgs, type UniversalVanillaArgs } from "../vanillaStoryConfig";
+
+export const LARGE_REGIONS = ["East", "North", "South", "West"] as const;
+export const LARGE_PRODUCTS = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"] as const;
+export const LARGE_YEARS = ["2024", "2025"] as const;
+export const LARGE_QUARTERS = ["Q1", "Q2", "Q3", "Q4"] as const;
+export const LARGE_CHANNELS = ["Direct", "Online", "Partner"] as const;
+
+export const LARGE_PIVOT_HEADERS: ColumnDef[] = [
+ { accessor: "region", label: "Region", width: 110, type: "string", sortable: true },
+ { accessor: "product", label: "Product", width: 110, type: "string", sortable: true },
+ { accessor: "year", label: "Year", width: 90, type: "string", sortable: true },
+ { accessor: "quarter", label: "Quarter", width: 90, type: "string", sortable: true },
+ { accessor: "channel", label: "Channel", width: 100, type: "string", sortable: true },
+ {
+ accessor: "sales",
+ label: "Sales",
+ width: 100,
+ type: "number",
+ align: "right",
+ sortable: true,
+ valueFormatter: ({ value }) => (typeof value === "number" ? `$${value.toLocaleString()}` : ""),
+ },
+ {
+ accessor: "units",
+ label: "Units",
+ width: 90,
+ type: "number",
+ align: "right",
+ sortable: true,
+ },
+ {
+ accessor: "cost",
+ label: "Cost",
+ width: 100,
+ type: "number",
+ align: "right",
+ sortable: true,
+ valueFormatter: ({ value }) => (typeof value === "number" ? `$${value.toLocaleString()}` : ""),
+ },
+ {
+ accessor: "margin",
+ label: "Margin",
+ width: 90,
+ type: "number",
+ align: "right",
+ sortable: true,
+ },
+ {
+ accessor: "returns",
+ label: "Returns",
+ width: 90,
+ type: "number",
+ align: "right",
+ sortable: true,
+ },
+];
+
+/** Constant measures keep expected pivot aggregates easy to verify in DOM. */
+export function buildLargePivotPanelRows(): Row[] {
+ const rows: Row[] = [];
+ let id = 1;
+ for (const region of LARGE_REGIONS) {
+ for (const product of LARGE_PRODUCTS) {
+ for (const year of LARGE_YEARS) {
+ for (const quarter of LARGE_QUARTERS) {
+ for (const channel of LARGE_CHANNELS) {
+ rows.push({
+ id,
+ region,
+ product,
+ year,
+ quarter,
+ channel,
+ sales: 10,
+ units: 2,
+ cost: 5,
+ margin: 3,
+ returns: 1,
+ });
+ id++;
+ }
+ }
+ }
+ }
+ }
+ return rows;
+}
+
+export const LARGE_PIVOT_ROWS = buildLargePivotPanelRows();
+
+export const pivotPanelLargeDefaults = {
+ autoExpandColumns: true,
+ columnResizing: true,
+ enableColumnEditor: true,
+ enableColumnEditorInitOpen: true,
+ enablePivotPanel: true,
+ // Render every row/column so DOM assertions can inspect the full matrix.
+ enableVirtualization: false,
+ columnBorders: true,
+ height: "640px",
+};
+
+export function formatSalesDom(value: number): string {
+ return `$${value.toLocaleString()}`;
+}
+
+export function sumMeasure(
+ rows: Row[],
+ measure: string,
+ filters: Record
+): number {
+ return rows.reduce((sum, row) => {
+ for (const [key, expected] of Object.entries(filters)) {
+ if (String(row[key as keyof Row]) !== expected) return sum;
+ }
+ const value = row[measure as keyof Row];
+ return sum + (typeof value === "number" ? value : 0);
+ }, 0);
+}
+
+export function renderPivotPanelLargeExample(
+ args?: Partial
+): HTMLElement {
+ const options = {
+ ...defaultVanillaArgs,
+ ...pivotPanelLargeDefaults,
+ ...args,
+ enableVirtualization: args?.enableVirtualization ?? pivotPanelLargeDefaults.enableVirtualization,
+ };
+ const { wrapper, h2 } = renderVanillaTable(LARGE_PIVOT_HEADERS, LARGE_PIVOT_ROWS, {
+ ...options,
+ // Pivot aggregate rows have no `id` — return undefined so the table uses
+ // reference identity (String(row.id) would collapse every pivot row to "undefined").
+ getRowId: ({ row }) => (row?.id == null ? undefined : String(row.id)),
+ });
+ h2.textContent = "Pivot Panel — Large Grid";
+ return wrapper;
+}
diff --git a/packages/core/stories/tests/54-PivotPanelTests.stories.ts b/packages/core/stories/tests/54-PivotPanelTests.stories.ts
new file mode 100644
index 000000000..00d21b132
--- /dev/null
+++ b/packages/core/stories/tests/54-PivotPanelTests.stories.ts
@@ -0,0 +1,198 @@
+/**
+ * Interaction tests for the in-table Pivot Panel (column editor popout).
+ * Covers the behavior plan checklist: field catalog, setPivot cycle, activation rule.
+ */
+import type { Meta, StoryObj } from "@storybook/html";
+import { expect, userEvent } from "@storybook/test";
+import { waitForTable, waitUntil } from "./testUtils";
+import {
+ renderPivotPanelExample,
+ pivotPanelExampleDefaults,
+} from "../examples/PivotPanelExample";
+
+const meta: Meta = {
+ title: "Tests/54 - Pivot Panel",
+ parameters: { layout: "padded" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+const clickZoneAction = async (root: HTMLElement, fieldLabel: string, action: string) => {
+ const fields = Array.from(root.querySelectorAll(".st-pivot-panel-field"));
+ const row = fields.find((el) => {
+ const label = el.querySelector(".st-pivot-panel-field-label");
+ return label?.textContent === fieldLabel;
+ });
+ expect(row, `Available field "${fieldLabel}"`).toBeTruthy();
+ const btn = Array.from(row!.querySelectorAll(".st-pivot-panel-action")).find(
+ (el) => el.textContent === action
+ );
+ expect(btn, `Action "${action}" on "${fieldLabel}"`).toBeTruthy();
+ await userEvent.click(btn!);
+};
+
+const headerLabels = (root: HTMLElement): string[] =>
+ Array.from(root.querySelectorAll(".st-header-label-text")).map((el) => el.textContent ?? "");
+
+export const ShowsSourceFieldCatalog: Story = {
+ render: () => renderPivotPanelExample(pivotPanelExampleDefaults),
+ play: async ({ canvasElement }) => {
+ await waitForTable(canvasElement);
+ const panel = canvasElement.querySelector(".st-pivot-panel");
+ expect(panel).toBeTruthy();
+
+ // Column visibility chrome stays hidden in pivot mode.
+ expect(canvasElement.querySelector(".st-column-editor-search")).toBeFalsy();
+ expect(canvasElement.querySelector(".st-column-editor-lists")).toBeFalsy();
+
+ const availableLabels = Array.from(
+ panel!.querySelectorAll(
+ '[data-pivot-zone="available"] .st-pivot-panel-field-label'
+ )
+ ).map((el) => el.textContent ?? "");
+
+ expect(availableLabels).toEqual(
+ expect.arrayContaining(["Region", "Product", "Quarter", "Sales", "Units"])
+ );
+ // Source catalog — not pivoted synthetic accessors.
+ expect(availableLabels.some((l) => l.startsWith("__pivot:"))).toBe(false);
+ },
+};
+
+export const PlaceRemoveSyncsPivotAndGrid: Story = {
+ render: () => renderPivotPanelExample(pivotPanelExampleDefaults),
+ play: async ({ canvasElement }) => {
+ await waitForTable(canvasElement);
+ const root = canvasElement;
+
+ await clickZoneAction(root, "Sales", "Values");
+ await waitUntil(() =>
+ Boolean(root.querySelector('[data-pivot-zone="values"] .st-pivot-panel-chip-label'))
+ );
+
+ expect(
+ root.querySelector('[data-pivot-zone="values"] .st-pivot-panel-chip-label')?.textContent
+ ).toBe("Sales");
+
+ await clickZoneAction(root, "Region", "Rows");
+ await clickZoneAction(root, "Quarter", "Columns");
+
+ await waitUntil(() => headerLabels(root).includes("Q1"));
+
+ const labels = headerLabels(root);
+ expect(labels).toContain("Region");
+ expect(labels).toContain("Q1");
+ expect(labels).toContain("Q2");
+
+ // Remove Values → pivot off → flat source headers return.
+ const removeBtn = root.querySelector(
+ '[data-pivot-zone="values"] .st-pivot-panel-remove'
+ ) as HTMLButtonElement | null;
+ expect(removeBtn).toBeTruthy();
+ await userEvent.click(removeBtn!);
+
+ await waitUntil(() => headerLabels(root).includes("Sales"));
+ const after = headerLabels(root);
+ expect(after).toContain("Sales");
+ expect(after).toContain("Units");
+ expect(after).toContain("Region");
+
+ // Sales returns to Available.
+ const availableAgain = Array.from(
+ root.querySelectorAll('[data-pivot-zone="available"] .st-pivot-panel-field-label')
+ ).map((el) => el.textContent ?? "");
+ expect(availableAgain).toContain("Sales");
+ },
+};
+
+export const PivotRequiresAtLeastOneValue: Story = {
+ render: () => renderPivotPanelExample(pivotPanelExampleDefaults),
+ play: async ({ canvasElement }) => {
+ await waitForTable(canvasElement);
+ const root = canvasElement;
+
+ // Dimensions alone must not activate pivot (still flat source columns).
+ await clickZoneAction(root, "Region", "Rows");
+ await clickZoneAction(root, "Quarter", "Columns");
+
+ await waitUntil(() =>
+ Boolean(root.querySelector('[data-pivot-zone="rows"] .st-pivot-panel-chip-label'))
+ );
+
+ const labels = headerLabels(root);
+ expect(labels).toContain("Sales");
+ expect(labels).toContain("Units");
+ expect(labels).not.toContain("Q1");
+
+ // Adding a measure activates the matrix.
+ await clickZoneAction(root, "Sales", "Values");
+ await waitUntil(() => headerLabels(root).includes("Q1"));
+ expect(headerLabels(root)).toContain("Q1");
+ },
+};
+
+export const MeasuresAndDimensionsRespectZones: Story = {
+ render: () => renderPivotPanelExample(pivotPanelExampleDefaults),
+ play: async ({ canvasElement }) => {
+ await waitForTable(canvasElement);
+ const root = canvasElement;
+
+ const salesRow = Array.from(root.querySelectorAll(".st-pivot-panel-field")).find((el) =>
+ el.querySelector(".st-pivot-panel-field-label")?.textContent === "Sales"
+ );
+ expect(salesRow).toBeTruthy();
+ const salesActions = Array.from(salesRow!.querySelectorAll(".st-pivot-panel-action")).map(
+ (el) => el.textContent
+ );
+ expect(salesActions).toEqual(["Values"]);
+
+ const regionRow = Array.from(root.querySelectorAll(".st-pivot-panel-field")).find((el) =>
+ el.querySelector(".st-pivot-panel-field-label")?.textContent === "Region"
+ );
+ expect(regionRow).toBeTruthy();
+ const regionActions = Array.from(regionRow!.querySelectorAll(".st-pivot-panel-action")).map(
+ (el) => el.textContent
+ );
+ expect(regionActions).toEqual(["Rows", "Columns"]);
+ },
+};
+
+export const AggregationUsesCustomSelect: Story = {
+ render: () => renderPivotPanelExample(pivotPanelExampleDefaults),
+ play: async ({ canvasElement }) => {
+ await waitForTable(canvasElement);
+ const root = canvasElement;
+
+ await clickZoneAction(root, "Sales", "Values");
+ await waitUntil(() =>
+ Boolean(root.querySelector('[data-pivot-zone="values"] .st-pivot-panel-chip-label'))
+ );
+
+ // Match filter/boolean UI: shared CustomSelect, not a native .
+ expect(root.querySelector('[data-pivot-zone="values"] select')).toBeFalsy();
+ const agg = root.querySelector(
+ '[data-pivot-zone="values"] .st-pivot-panel-agg'
+ ) as HTMLElement | null;
+ expect(agg).toBeTruthy();
+ expect(agg!.classList.contains("st-custom-select")).toBe(true);
+ expect(agg!.querySelector(".st-custom-select-trigger")).toBeTruthy();
+ expect(agg!.querySelector(".st-custom-select-arrow")).toBeTruthy();
+ expect(agg!.querySelector(".st-custom-select-value")?.textContent).toBe("Sum");
+ expect(agg!.getAttribute("data-agg")).toBe("sum");
+ },
+};
+
+export const ColumnVisibilityRestoredWhenPivotPanelOff: Story = {
+ render: () =>
+ renderPivotPanelExample({
+ ...pivotPanelExampleDefaults,
+ enablePivotPanel: false,
+ }),
+ play: async ({ canvasElement }) => {
+ await waitForTable(canvasElement);
+ expect(canvasElement.querySelector(".st-pivot-panel")).toBeFalsy();
+ expect(canvasElement.querySelector(".st-column-editor-search")).toBeTruthy();
+ expect(canvasElement.querySelector(".st-column-editor-lists")).toBeTruthy();
+ },
+};
diff --git a/packages/core/stories/tests/55-PivotPanelLargeDomTests.stories.ts b/packages/core/stories/tests/55-PivotPanelLargeDomTests.stories.ts
new file mode 100644
index 000000000..ef7407ed8
--- /dev/null
+++ b/packages/core/stories/tests/55-PivotPanelLargeDomTests.stories.ts
@@ -0,0 +1,450 @@
+/**
+ * Large-grid Pivot Panel DOM tests.
+ * Clicks panel actions against 480 source rows / 10 columns, then asserts
+ * the rendered header + body HTML matches the expected pivot matrix.
+ */
+import type { Meta, StoryObj } from "@storybook/html";
+import { expect, userEvent } from "@storybook/test";
+import { buildPivotAccessor, buildPivotRowTotalAccessor } from "../../src/index";
+import { waitForTable, waitUntil, getRowCount } from "./testUtils";
+import {
+ LARGE_CHANNELS,
+ LARGE_PIVOT_ROWS,
+ LARGE_PRODUCTS,
+ LARGE_QUARTERS,
+ LARGE_REGIONS,
+ LARGE_YEARS,
+ formatSalesDom,
+ pivotPanelLargeDefaults,
+ renderPivotPanelLargeExample,
+ sumMeasure,
+} from "../examples/PivotPanelLargeExample";
+
+const meta: Meta = {
+ title: "Tests/55 - Pivot Panel Large DOM",
+ parameters: { layout: "padded" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+const KEY_SEP = "\u0001";
+const WAIT = { timeoutMs: 20000, intervalMs: 50 };
+
+const waitFor = (predicate: () => boolean) => waitUntil(predicate, WAIT);
+
+const headerLabels = (root: HTMLElement): string[] =>
+ Array.from(root.querySelectorAll(".st-header-label-text")).map((el) => el.textContent ?? "");
+
+const headerAccessors = (root: HTMLElement): string[] =>
+ Array.from(root.querySelectorAll(".st-header-cell[data-accessor]")).map(
+ (el) => el.getAttribute("data-accessor") ?? ""
+ );
+
+const chipLabels = (root: HTMLElement, zone: string): string[] =>
+ Array.from(
+ root.querySelectorAll(`[data-pivot-zone="${zone}"] .st-pivot-panel-chip-label`)
+ ).map((el) => el.textContent ?? "");
+
+const availableLabels = (root: HTMLElement): string[] =>
+ Array.from(
+ root.querySelectorAll(
+ '[data-pivot-zone="available"] .st-pivot-panel-field-label'
+ )
+ ).map((el) => el.textContent ?? "");
+
+const cellText = (root: HTMLElement, rowIndex: number, accessor: string): string => {
+ // Avoid CSS attribute selectors — pivot accessors contain U+0001 separators.
+ const cell = Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-row-index="${rowIndex}"]`)
+ ).find((el) => el.getAttribute("data-accessor") === accessor) as HTMLElement | undefined;
+ expect(cell, `cell row=${rowIndex} accessor=${JSON.stringify(accessor)}`).toBeTruthy();
+ const content = cell!.querySelector(".st-cell-content");
+ return (content?.textContent ?? cell!.textContent ?? "").trim();
+};
+
+const findRowIndexByDim = (root: HTMLElement, accessor: string, label: string): number => {
+ const cells = Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-accessor="${accessor}"]`)
+ );
+ const match = cells.find((el) => {
+ const content = el.querySelector(".st-cell-content");
+ return (content?.textContent ?? el.textContent ?? "").trim() === label;
+ });
+ expect(match, `row dim cell ${accessor}=${label}`).toBeTruthy();
+ return Number(match!.getAttribute("data-row-index"));
+};
+
+const findFirstProductRow = (root: HTMLElement, product: string): number => {
+ const match = Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-accessor="product"]`)
+ ).find((el) => (el.querySelector(".st-cell-content")?.textContent ?? "").trim() === product);
+ expect(match, `product row ${product}`).toBeTruthy();
+ return Number(match!.getAttribute("data-row-index"));
+};
+
+const clickZoneAction = async (root: HTMLElement, fieldLabel: string, action: string) => {
+ await waitFor(() => availableLabels(root).includes(fieldLabel));
+ const fields = Array.from(root.querySelectorAll(".st-pivot-panel-field"));
+ const row = fields.find((el) => {
+ const label = el.querySelector(".st-pivot-panel-field-label");
+ return label?.textContent === fieldLabel;
+ });
+ expect(row, `Available field "${fieldLabel}"`).toBeTruthy();
+ const btn = Array.from(row!.querySelectorAll(".st-pivot-panel-action")).find(
+ (el) => el.textContent === action
+ );
+ expect(btn, `Action "${action}" on "${fieldLabel}"`).toBeTruthy();
+ await userEvent.click(btn!);
+
+ const zone =
+ action === "Rows" ? "rows" : action === "Columns" ? "columns" : "values";
+ await waitFor(() => chipLabels(root, zone).includes(fieldLabel));
+};
+
+const removeChip = async (
+ root: HTMLElement,
+ zone: "rows" | "columns" | "values",
+ label: string
+) => {
+ await waitFor(() => chipLabels(root, zone).includes(label));
+ const chips = Array.from(
+ root.querySelectorAll(`[data-pivot-zone="${zone}"] .st-pivot-panel-chip`)
+ );
+ const chip = chips.find(
+ (el) => el.querySelector(".st-pivot-panel-chip-label")?.textContent === label
+ );
+ expect(chip, `Chip "${label}" in ${zone}`).toBeTruthy();
+ const remove = chip!.querySelector(".st-pivot-panel-remove") as HTMLButtonElement | null;
+ expect(remove).toBeTruthy();
+ await userEvent.click(remove!);
+ await waitFor(() => !chipLabels(root, zone).includes(label));
+};
+
+const expectHeaderPresent = (root: HTMLElement, label: string) => {
+ expect(headerLabels(root), `header "${label}"`).toContain(label);
+};
+
+const expectHeaderAbsent = (root: HTMLElement, label: string) => {
+ expect(headerLabels(root), `header absent "${label}"`).not.toContain(label);
+};
+
+const expectSourceFlatHeaders = (root: HTMLElement) => {
+ for (const label of [
+ "Region",
+ "Product",
+ "Year",
+ "Quarter",
+ "Channel",
+ "Sales",
+ "Units",
+ "Cost",
+ "Margin",
+ "Returns",
+ ]) {
+ expectHeaderPresent(root, label);
+ }
+};
+
+export const LargeGridPanelClicksMatchRenderedHtml: Story = {
+ render: () => renderPivotPanelLargeExample(pivotPanelLargeDefaults),
+ play: async ({ canvasElement }) => {
+ await waitForTable(canvasElement);
+ const root = canvasElement;
+
+ // --- Flat source table: lots of rows + columns in the DOM ---
+ expectSourceFlatHeaders(root);
+ expect(headerAccessors(root)).toEqual(
+ expect.arrayContaining([
+ "region",
+ "product",
+ "year",
+ "quarter",
+ "channel",
+ "sales",
+ "units",
+ "cost",
+ "margin",
+ "returns",
+ ])
+ );
+
+ const flatRowCount = getRowCount(root);
+ expect(flatRowCount).toBe(LARGE_PIVOT_ROWS.length);
+ expect(LARGE_PIVOT_ROWS.length).toBe(
+ LARGE_REGIONS.length *
+ LARGE_PRODUCTS.length *
+ LARGE_YEARS.length *
+ LARGE_QUARTERS.length *
+ LARGE_CHANNELS.length
+ );
+
+ expect(cellText(root, 0, "region")).toBe("East");
+ expect(cellText(root, 0, "sales")).toBe(formatSalesDom(10));
+ expect(cellText(root, 0, "units")).toBe("2");
+ expect(cellText(root, flatRowCount - 1, "region")).toBe("West");
+ expect(cellText(root, flatRowCount - 1, "channel")).toBe("Partner");
+ expect(cellText(root, flatRowCount - 1, "returns")).toBe("1");
+
+ // --- Values only → one aggregated matrix row ---
+ await clickZoneAction(root, "Sales", "Values");
+ const valuesOnlyAccessor = buildPivotAccessor("", "sales");
+ await waitFor(
+ () =>
+ headerAccessors(root).includes(valuesOnlyAccessor) &&
+ !headerAccessors(root).includes("channel") &&
+ getRowCount(root) === 1
+ );
+
+ expectHeaderPresent(root, "Sales");
+ expectHeaderAbsent(root, "Channel");
+ expect(getRowCount(root)).toBe(1);
+
+ const valuesOnlySales = sumMeasure(LARGE_PIVOT_ROWS, "sales", {});
+ expect(cellText(root, 0, valuesOnlyAccessor)).toBe(formatSalesDom(valuesOnlySales));
+ expect(valuesOnlySales).toBe(LARGE_PIVOT_ROWS.length * 10);
+
+ // --- Region rows + Quarter columns ---
+ await clickZoneAction(root, "Region", "Rows");
+ await waitFor(() =>
+ LARGE_REGIONS.every((region) =>
+ Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-accessor="region"]`)
+ ).some(
+ (el) => (el.querySelector(".st-cell-content")?.textContent ?? "").trim() === region
+ )
+ )
+ );
+
+ await clickZoneAction(root, "Quarter", "Columns");
+ await waitFor(
+ () =>
+ LARGE_QUARTERS.every((q) => headerLabels(root).includes(q)) &&
+ headerLabels(root).includes("Total") &&
+ getRowCount(root) === LARGE_REGIONS.length + 1
+ );
+
+ for (const q of LARGE_QUARTERS) expectHeaderPresent(root, q);
+ expectHeaderPresent(root, "Region");
+ expectHeaderPresent(root, "Total");
+ expectHeaderAbsent(root, "Channel");
+ expectHeaderAbsent(root, "Product");
+ expect(getRowCount(root)).toBe(LARGE_REGIONS.length + 1);
+
+ for (const region of LARGE_REGIONS) {
+ const rowIndex = findRowIndexByDim(root, "region", region);
+ for (const quarter of LARGE_QUARTERS) {
+ const expected = sumMeasure(LARGE_PIVOT_ROWS, "sales", { region, quarter });
+ expect(cellText(root, rowIndex, buildPivotAccessor(quarter, "sales"))).toBe(
+ formatSalesDom(expected)
+ );
+ expect(expected).toBe(
+ LARGE_PRODUCTS.length * LARGE_YEARS.length * LARGE_CHANNELS.length * 10
+ );
+ }
+ const regionTotal = sumMeasure(LARGE_PIVOT_ROWS, "sales", { region });
+ expect(cellText(root, rowIndex, buildPivotRowTotalAccessor("sales"))).toBe(
+ formatSalesDom(regionTotal)
+ );
+ }
+
+ const totalRowIndex = findRowIndexByDim(root, "region", "Total");
+ for (const quarter of LARGE_QUARTERS) {
+ const expected = sumMeasure(LARGE_PIVOT_ROWS, "sales", { quarter });
+ expect(cellText(root, totalRowIndex, buildPivotAccessor(quarter, "sales"))).toBe(
+ formatSalesDom(expected)
+ );
+ }
+
+ // --- Add Product as a second row dimension (flat: one row per combo) ---
+ await clickZoneAction(root, "Product", "Rows");
+ expectHeaderPresent(root, "Product");
+ await waitFor(() =>
+ Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-accessor="product"]`)
+ ).filter((el) => (el.querySelector(".st-cell-content")?.textContent ?? "").trim() === "Alpha")
+ .length === LARGE_REGIONS.length
+ );
+
+ const alphaRows = Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-accessor="product"]`)
+ ).filter((el) => (el.querySelector(".st-cell-content")?.textContent ?? "").trim() === "Alpha");
+ expect(alphaRows.length).toBe(LARGE_REGIONS.length);
+
+ // Sorted flat: East + Alpha is the first Alpha combination.
+ const alphaRowIndex = findFirstProductRow(root, "Alpha");
+ const alphaQ1 = sumMeasure(LARGE_PIVOT_ROWS, "sales", {
+ product: "Alpha",
+ quarter: "Q1",
+ region: "East",
+ });
+ expect(cellText(root, alphaRowIndex, buildPivotAccessor("Q1", "sales"))).toBe(
+ formatSalesDom(alphaQ1)
+ );
+ expect(alphaQ1).toBe(LARGE_YEARS.length * LARGE_CHANNELS.length * 10);
+
+ // --- Year column dimension (nested quarter → year headers) ---
+ // Column key order follows panel placement order: Quarter then Year.
+ await clickZoneAction(root, "Year", "Columns");
+ const nestedAccessor = buildPivotAccessor(`Q2${KEY_SEP}2024`, "sales");
+ await waitFor(
+ () =>
+ headerLabels(root).includes("2024") && headerAccessors(root).includes(nestedAccessor)
+ );
+
+ for (const year of LARGE_YEARS) expectHeaderPresent(root, year);
+ for (const q of LARGE_QUARTERS) expectHeaderPresent(root, q);
+
+ const nestedExpected = sumMeasure(LARGE_PIVOT_ROWS, "sales", {
+ product: "Alpha",
+ region: "East",
+ year: "2024",
+ quarter: "Q2",
+ });
+ const nestedRow = findFirstProductRow(root, "Alpha");
+ expect(cellText(root, nestedRow, nestedAccessor)).toBe(formatSalesDom(nestedExpected));
+ expect(nestedExpected).toBe(LARGE_CHANNELS.length * 10);
+
+ // --- Second measure: Units ---
+ await clickZoneAction(root, "Units", "Values");
+ const unitsQ2 = buildPivotAccessor(`Q2${KEY_SEP}2024`, "units");
+ await waitFor(() => headerAccessors(root).includes(unitsQ2));
+
+ expect(headerAccessors(root)).toContain(nestedAccessor);
+ const unitsExpected = sumMeasure(LARGE_PIVOT_ROWS, "units", {
+ product: "Alpha",
+ region: "East",
+ year: "2024",
+ quarter: "Q2",
+ });
+ const unitsRow = findFirstProductRow(root, "Alpha");
+ expect(cellText(root, unitsRow, unitsQ2)).toBe(String(unitsExpected));
+ expect(unitsExpected).toBe(LARGE_CHANNELS.length * 2);
+
+ // --- Aggregation change updates numbers, keeps column accessors ---
+ const salesChip = Array.from(
+ root.querySelectorAll('[data-pivot-zone="values"] .st-pivot-panel-chip')
+ ).find((el) => el.querySelector(".st-pivot-panel-chip-label")?.textContent === "Sales");
+ const salesAgg = salesChip?.querySelector(".st-pivot-panel-agg") as HTMLElement | null;
+ expect(salesAgg).toBeTruthy();
+ const salesTrigger = salesAgg!.querySelector(
+ ".st-custom-select-trigger"
+ ) as HTMLButtonElement;
+ salesTrigger.click();
+ await waitFor(() => Boolean(root.querySelector(".st-pivot-panel-agg.st-custom-select-open")));
+ const countOption = Array.from(root.querySelectorAll(".st-custom-select-option")).find(
+ (el) => el.textContent === "Count"
+ );
+ expect(countOption).toBeTruthy();
+ (countOption as HTMLElement).click();
+ await waitFor(
+ () =>
+ root.querySelector('[data-pivot-zone="values"] .st-pivot-panel-agg')?.getAttribute(
+ "data-agg"
+ ) === "count"
+ );
+
+ const countExpected = LARGE_CHANNELS.length;
+ await waitFor(() => {
+ const match = Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-accessor="product"]`)
+ ).find((el) => (el.querySelector(".st-cell-content")?.textContent ?? "").trim() === "Alpha");
+ if (!match) return false;
+ const rowIndex = Number(match.getAttribute("data-row-index"));
+ const cell = Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-row-index="${rowIndex}"]`)
+ ).find((el) => el.getAttribute("data-accessor") === nestedAccessor);
+ const text = (cell?.querySelector(".st-cell-content")?.textContent ?? "").trim();
+ // Sales keeps its $ formatter even for count aggregates.
+ return text === String(countExpected) || text === formatSalesDom(countExpected);
+ });
+
+ expect(headerAccessors(root)).toContain(nestedAccessor);
+ expect(headerAccessors(root)).toContain(unitsQ2);
+
+ // --- Remove Values → flat source HTML restored ---
+ await removeChip(root, "values", "Sales");
+ await removeChip(root, "values", "Units");
+ await waitFor(
+ () =>
+ headerAccessors(root).includes("channel") &&
+ headerAccessors(root).includes("returns") &&
+ getRowCount(root) === LARGE_PIVOT_ROWS.length &&
+ !headerAccessors(root).some((a) => a.startsWith("__pivot:"))
+ );
+
+ expectSourceFlatHeaders(root);
+ expect(cellText(root, 0, "sales")).toBe(formatSalesDom(10));
+ expect(chipLabels(root, "rows").length).toBeGreaterThan(0);
+ },
+};
+
+export const LargeGridChannelColumnsDom: Story = {
+ render: () => renderPivotPanelLargeExample(pivotPanelLargeDefaults),
+ play: async ({ canvasElement }) => {
+ await waitForTable(canvasElement);
+ const root = canvasElement;
+
+ await clickZoneAction(root, "Cost", "Values");
+ await clickZoneAction(root, "Region", "Rows");
+ await waitFor(() =>
+ LARGE_REGIONS.every((region) =>
+ Array.from(
+ root.querySelectorAll(`.st-body-container .st-cell[data-accessor="region"]`)
+ ).some(
+ (el) => (el.querySelector(".st-cell-content")?.textContent ?? "").trim() === region
+ )
+ )
+ );
+
+ await clickZoneAction(root, "Channel", "Columns");
+ await waitFor(
+ () =>
+ LARGE_CHANNELS.every((c) => headerLabels(root).includes(c)) &&
+ headerAccessors(root).includes(buildPivotAccessor("Direct", "cost")) &&
+ getRowCount(root) === LARGE_REGIONS.length + 1
+ );
+
+ for (const channel of LARGE_CHANNELS) {
+ expectHeaderPresent(root, channel);
+ expect(headerAccessors(root)).toContain(buildPivotAccessor(channel, "cost"));
+ }
+ expectHeaderPresent(root, "Total");
+ expect(headerAccessors(root)).toContain(buildPivotRowTotalAccessor("cost"));
+
+ for (const region of LARGE_REGIONS) {
+ const rowIndex = findRowIndexByDim(root, "region", region);
+ for (const channel of LARGE_CHANNELS) {
+ const expected = sumMeasure(LARGE_PIVOT_ROWS, "cost", { region, channel });
+ expect(cellText(root, rowIndex, buildPivotAccessor(channel, "cost"))).toBe(
+ formatSalesDom(expected)
+ );
+ expect(expected).toBe(
+ LARGE_PRODUCTS.length * LARGE_YEARS.length * LARGE_QUARTERS.length * 5
+ );
+ }
+ }
+
+ // Swap column dim to Year — channel headers leave, year headers appear.
+ await removeChip(root, "columns", "Channel");
+ await clickZoneAction(root, "Year", "Columns");
+ await waitFor(
+ () =>
+ headerLabels(root).includes("2024") &&
+ headerAccessors(root).includes(buildPivotAccessor("2025", "cost")) &&
+ !headerLabels(root).includes("Direct")
+ );
+
+ for (const year of LARGE_YEARS) {
+ expectHeaderPresent(root, year);
+ expect(headerAccessors(root)).toContain(buildPivotAccessor(year, "cost"));
+ }
+ expect(headerAccessors(root).some((a) => a.includes("Direct"))).toBe(false);
+
+ const west = findRowIndexByDim(root, "region", "West");
+ const west2025 = sumMeasure(LARGE_PIVOT_ROWS, "cost", { region: "West", year: "2025" });
+ expect(cellText(root, west, buildPivotAccessor("2025", "cost"))).toBe(
+ formatSalesDom(west2025)
+ );
+ },
+};
diff --git a/packages/core/stories/vanillaStoryConfig.ts b/packages/core/stories/vanillaStoryConfig.ts
index 9356ba6b8..4be8b1a6a 100644
--- a/packages/core/stories/vanillaStoryConfig.ts
+++ b/packages/core/stories/vanillaStoryConfig.ts
@@ -12,6 +12,9 @@ export interface UniversalVanillaArgs {
customTheme?: CustomThemeProps;
enableColumnEditor?: boolean;
enableColumnEditorInitOpen?: boolean;
+ enablePivotPanel?: boolean;
+ /** When false, every row/column is in the DOM (useful for DOM assertion tests). */
+ enableVirtualization?: boolean;
expandAll?: boolean;
externalFilterHandling?: boolean;
externalSortHandling?: boolean;
@@ -36,6 +39,7 @@ export const defaultVanillaArgs: UniversalVanillaArgs = {
customTheme: undefined,
enableColumnEditor: false,
enableColumnEditorInitOpen: false,
+ enablePivotPanel: false,
expandAll: true,
externalFilterHandling: false,
externalSortHandling: false,
@@ -106,6 +110,10 @@ export const vanillaArgTypes = {
control: { type: "boolean" as const },
description: "Open column editor on initial load",
},
+ enablePivotPanel: {
+ control: { type: "boolean" as const },
+ description: "Show Pivot section (Rows / Columns / Values) in the column editor",
+ },
selectableCells: {
control: { type: "boolean" as const },
description: "Enable cell selection",
diff --git a/packages/examples/angular/src/demos/analytics/analytics-demo.component.ts b/packages/examples/angular/src/demos/analytics/analytics-demo.component.ts
index 718ba100f..0d2e742db 100644
--- a/packages/examples/angular/src/demos/analytics/analytics-demo.component.ts
+++ b/packages/examples/angular/src/demos/analytics/analytics-demo.component.ts
@@ -90,7 +90,6 @@ import type { AnalyticsFactRow } from "./analytics.demo-data";
[rows]="rows"
[columns]="headers"
[enableColumnEditor]="true"
- [expandAll]="nestedRows"
[getRowId]="getRowId"
height="100%"
[initialSortColumn]="isPivoted ? undefined : 'sales'"
@@ -116,7 +115,6 @@ export class AnalyticsDemoComponent {
activeId = analyticsPresets[0].id;
pivot: PivotConfig | null = analyticsPresets[0].pivot;
- nestedRows = (analyticsPresets[0].pivot?.rows.length ?? 0) > 1;
isPivoted = analyticsPresets[0].pivot != null;
get formatHeight(): string {
@@ -156,7 +154,6 @@ export class AnalyticsDemoComponent {
selectPreset(preset: AnalyticsPreset): void {
this.activeId = preset.id;
this.pivot = preset.pivot;
- this.nestedRows = (preset.pivot?.rows.length ?? 0) > 1;
this.isPivoted = preset.pivot != null;
}
diff --git a/packages/examples/angular/src/demos/analytics/analytics.demo-data.ts b/packages/examples/angular/src/demos/analytics/analytics.demo-data.ts
index b1b46ff97..18bafaa42 100644
--- a/packages/examples/angular/src/demos/analytics/analytics.demo-data.ts
+++ b/packages/examples/angular/src/demos/analytics/analytics.demo-data.ts
@@ -196,8 +196,8 @@ export const analyticsPresets: AnalyticsPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
description: "Drill into products within each region",
pivot: {
rows: ["region", "product"],
diff --git a/packages/examples/angular/src/demos/pivot/pivot-demo.component.ts b/packages/examples/angular/src/demos/pivot/pivot-demo.component.ts
index 8e800bc44..d63fee259 100644
--- a/packages/examples/angular/src/demos/pivot/pivot-demo.component.ts
+++ b/packages/examples/angular/src/demos/pivot/pivot-demo.component.ts
@@ -35,7 +35,6 @@ import type { PivotFact } from "./pivot.demo-data";
[columns]="headers"
[pivot]="pivot"
[columnResizing]="true"
- [expandAll]="nestedRows"
[height]="height"
[selectableCells]="true"
[theme]="theme"
@@ -53,12 +52,10 @@ export class PivotDemoComponent {
activeId = pivotPresets[0].id;
pivot: PivotConfig = pivotPresets[0].pivot;
- nestedRows = pivotPresets[0].pivot.rows.length > 1;
selectPreset(preset: PivotPreset): void {
this.activeId = preset.id;
this.pivot = preset.pivot;
- this.nestedRows = preset.pivot.rows.length > 1;
}
getRowId = ({ row }: GetRowIdParams) => row.id;
diff --git a/packages/examples/angular/src/demos/pivot/pivot.demo-data.ts b/packages/examples/angular/src/demos/pivot/pivot.demo-data.ts
index 8326d4663..78486b2c4 100644
--- a/packages/examples/angular/src/demos/pivot/pivot.demo-data.ts
+++ b/packages/examples/angular/src/demos/pivot/pivot.demo-data.ts
@@ -123,8 +123,8 @@ export const pivotPresets: PivotPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
pivot: {
rows: ["region", "product"],
columns: ["quarter"],
diff --git a/packages/examples/react/src/demos/analytics/AnalyticsDemo.tsx b/packages/examples/react/src/demos/analytics/AnalyticsDemo.tsx
index d14e64660..6fc629ee7 100644
--- a/packages/examples/react/src/demos/analytics/AnalyticsDemo.tsx
+++ b/packages/examples/react/src/demos/analytics/AnalyticsDemo.tsx
@@ -24,7 +24,6 @@ const AnalyticsDemo = ({
const [activeId, setActiveId] = useState(analyticsPresets[0].id);
const active = analyticsPresets.find((p) => p.id === activeId) ?? analyticsPresets[0];
const isPivoted = active.pivot != null;
- const nestedRows = (active.pivot?.rows.length ?? 0) > 1;
const isDark = theme === "dark" || theme === "modern-dark";
const tableHostRef = useRef(null);
const tableRef = useRef>(null);
@@ -161,8 +160,6 @@ const AnalyticsDemo = ({
copyHeadersToClipboard
columns={analyticsDemoConfig.headers}
enableColumnEditor
- enableStickyParents={nestedRows}
- expandAll={nestedRows}
getRowId={({ row }) => row.id}
height={tableHeightPx}
includeHeadersInCSVExport
diff --git a/packages/examples/react/src/demos/analytics/analytics.demo-data.ts b/packages/examples/react/src/demos/analytics/analytics.demo-data.ts
index a1b483217..9b2bbb025 100644
--- a/packages/examples/react/src/demos/analytics/analytics.demo-data.ts
+++ b/packages/examples/react/src/demos/analytics/analytics.demo-data.ts
@@ -196,8 +196,8 @@ export const analyticsPresets: AnalyticsPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
description: "Drill into products within each region",
pivot: {
rows: ["region", "product"],
diff --git a/packages/examples/react/src/demos/pivot/PivotDemo.tsx b/packages/examples/react/src/demos/pivot/PivotDemo.tsx
index 506c0b38f..7ec704e55 100644
--- a/packages/examples/react/src/demos/pivot/PivotDemo.tsx
+++ b/packages/examples/react/src/demos/pivot/PivotDemo.tsx
@@ -13,7 +13,6 @@ const PivotDemo = ({
}) => {
const [activeId, setActiveId] = useState(pivotPresets[0].id);
const active = pivotPresets.find((p) => p.id === activeId) ?? pivotPresets[0];
- const nestedRows = active.pivot.rows.length > 1;
return (
@@ -46,7 +45,6 @@ const PivotDemo = ({
rows={pivotDemoConfig.rows}
pivot={active.pivot}
columnResizing
- expandAll={nestedRows}
height={height}
selectableCells
theme={theme}
diff --git a/packages/examples/react/src/demos/pivot/pivot.demo-data.ts b/packages/examples/react/src/demos/pivot/pivot.demo-data.ts
index 64a6c90b7..f5f61c1fe 100644
--- a/packages/examples/react/src/demos/pivot/pivot.demo-data.ts
+++ b/packages/examples/react/src/demos/pivot/pivot.demo-data.ts
@@ -123,8 +123,8 @@ export const pivotPresets: PivotPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
pivot: {
rows: ["region", "product"],
columns: ["quarter"],
diff --git a/packages/examples/solid/src/demos/analytics/AnalyticsDemo.tsx b/packages/examples/solid/src/demos/analytics/AnalyticsDemo.tsx
index 6fcfb55c9..ef9abc32d 100644
--- a/packages/examples/solid/src/demos/analytics/AnalyticsDemo.tsx
+++ b/packages/examples/solid/src/demos/analytics/AnalyticsDemo.tsx
@@ -23,7 +23,6 @@ export default function AnalyticsDemo(props: {
() => analyticsPresets.find((p) => p.id === activeId()) ?? analyticsPresets[0]
);
const isPivoted = createMemo(() => active().pivot != null);
- const nestedRows = createMemo(() => (active().pivot?.rows.length ?? 0) > 1);
const isDark = () => props.theme === "dark" || props.theme === "modern-dark";
const chromeBg = () => (isDark() ? "#0f172a" : "#f8fafc");
const chromeBorder = () => (isDark() ? "#1e293b" : "#e2e8f0");
@@ -157,8 +156,6 @@ export default function AnalyticsDemo(props: {
copyHeadersToClipboard
columns={analyticsDemoConfig.headers}
enableColumnEditor
- enableStickyParents={nestedRows()}
- expandAll={nestedRows()}
getRowId={({ row }) => {
const id = row.id;
return id == null ? undefined : String(id);
diff --git a/packages/examples/solid/src/demos/analytics/analytics.demo-data.ts b/packages/examples/solid/src/demos/analytics/analytics.demo-data.ts
index 0754b78ea..51d3a2f9d 100644
--- a/packages/examples/solid/src/demos/analytics/analytics.demo-data.ts
+++ b/packages/examples/solid/src/demos/analytics/analytics.demo-data.ts
@@ -196,8 +196,8 @@ export const analyticsPresets: AnalyticsPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
description: "Drill into products within each region",
pivot: {
rows: ["region", "product"],
diff --git a/packages/examples/solid/src/demos/pivot/PivotDemo.tsx b/packages/examples/solid/src/demos/pivot/PivotDemo.tsx
index a709a0eda..0da4921c7 100644
--- a/packages/examples/solid/src/demos/pivot/PivotDemo.tsx
+++ b/packages/examples/solid/src/demos/pivot/PivotDemo.tsx
@@ -12,7 +12,6 @@ export default function PivotDemo(props: {
const active = createMemo(
() => pivotPresets.find((p) => p.id === activeId()) ?? pivotPresets[0]
);
- const nestedRows = createMemo(() => active().pivot.rows.length > 1);
return (
@@ -47,7 +46,6 @@ export default function PivotDemo(props: {
rows={pivotDemoConfig.rows}
pivot={active().pivot}
columnResizing
- expandAll={nestedRows()}
height={props.height ?? "400px"}
selectableCells
theme={props.theme}
diff --git a/packages/examples/solid/src/demos/pivot/pivot.demo-data.ts b/packages/examples/solid/src/demos/pivot/pivot.demo-data.ts
index 027ee5269..470871714 100644
--- a/packages/examples/solid/src/demos/pivot/pivot.demo-data.ts
+++ b/packages/examples/solid/src/demos/pivot/pivot.demo-data.ts
@@ -123,8 +123,8 @@ export const pivotPresets: PivotPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
pivot: {
rows: ["region", "product"],
columns: ["quarter"],
diff --git a/packages/examples/svelte/src/demos/analytics/AnalyticsDemo.svelte b/packages/examples/svelte/src/demos/analytics/AnalyticsDemo.svelte
index 7f8553d1e..083762f47 100644
--- a/packages/examples/svelte/src/demos/analytics/AnalyticsDemo.svelte
+++ b/packages/examples/svelte/src/demos/analytics/AnalyticsDemo.svelte
@@ -11,7 +11,6 @@
let tableRef = $state<{ getAPI: () => TableAPI
| null } | null>(null);
const active = $derived(analyticsPresets.find((p) => p.id === activeId) ?? analyticsPresets[0]);
const isPivoted = $derived(active.pivot != null);
- const nestedRows = $derived((active.pivot?.rows.length ?? 0) > 1);
const isDark = $derived(theme === "dark" || theme === "modern-dark");
const chromeBg = $derived(isDark ? "#0f172a" : "#f8fafc");
const chromeBorder = $derived(isDark ? "#1e293b" : "#e2e8f0");
@@ -77,8 +76,6 @@
copyHeadersToClipboard={true}
columns={analyticsDemoConfig.headers}
enableColumnEditor={true}
- enableStickyParents={nestedRows}
- expandAll={nestedRows}
{getRowId}
height="100%"
includeHeadersInCSVExport={true}
diff --git a/packages/examples/svelte/src/demos/analytics/analytics.demo-data.ts b/packages/examples/svelte/src/demos/analytics/analytics.demo-data.ts
index a8b173b4c..f36693c2d 100644
--- a/packages/examples/svelte/src/demos/analytics/analytics.demo-data.ts
+++ b/packages/examples/svelte/src/demos/analytics/analytics.demo-data.ts
@@ -196,8 +196,8 @@ export const analyticsPresets: AnalyticsPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
description: "Drill into products within each region",
pivot: {
rows: ["region", "product"],
diff --git a/packages/examples/svelte/src/demos/pivot/PivotDemo.svelte b/packages/examples/svelte/src/demos/pivot/PivotDemo.svelte
index 97359159c..0e51df5cc 100644
--- a/packages/examples/svelte/src/demos/pivot/PivotDemo.svelte
+++ b/packages/examples/svelte/src/demos/pivot/PivotDemo.svelte
@@ -9,7 +9,6 @@
let activeId = $state(pivotPresets[0].id);
const active = $derived(pivotPresets.find((p) => p.id === activeId) ?? pivotPresets[0]);
- const nestedRows = $derived(active.pivot.rows.length > 1);
const getRowId = ({ row }: GetRowIdParams) => row.id;
@@ -34,7 +33,6 @@
pivot={active.pivot}
{getRowId}
columnResizing={true}
- expandAll={nestedRows}
selectableCells={true}
{height}
{theme}
diff --git a/packages/examples/svelte/src/demos/pivot/pivot.demo-data.ts b/packages/examples/svelte/src/demos/pivot/pivot.demo-data.ts
index e842b1d25..87db0257a 100644
--- a/packages/examples/svelte/src/demos/pivot/pivot.demo-data.ts
+++ b/packages/examples/svelte/src/demos/pivot/pivot.demo-data.ts
@@ -123,8 +123,8 @@ export const pivotPresets: PivotPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
pivot: {
rows: ["region", "product"],
columns: ["quarter"],
diff --git a/packages/examples/vanilla/src/demos/analytics/AnalyticsDemo.ts b/packages/examples/vanilla/src/demos/analytics/AnalyticsDemo.ts
index b93958b9e..eab830417 100644
--- a/packages/examples/vanilla/src/demos/analytics/AnalyticsDemo.ts
+++ b/packages/examples/vanilla/src/demos/analytics/AnalyticsDemo.ts
@@ -63,7 +63,6 @@ export function renderAnalyticsDemo(
const remountTable = () => {
tableHost.replaceChildren();
const active = analyticsPresets.find((p) => p.id === activeId) ?? analyticsPresets[0];
- const nested = (active.pivot?.rows.length ?? 0) > 1;
const pivoted = active.pivot != null;
table = new SimpleTableVanilla(tableHost, {
autoExpandColumns: true,
@@ -73,8 +72,6 @@ export function renderAnalyticsDemo(
copyHeadersToClipboard: true,
columns: analyticsDemoConfig.headers,
enableColumnEditor: true,
- enableStickyParents: nested,
- expandAll: nested,
getRowId: ({ row }) => {
const id = row.id;
return id == null ? undefined : String(id);
diff --git a/packages/examples/vanilla/src/demos/analytics/analytics.demo-data.ts b/packages/examples/vanilla/src/demos/analytics/analytics.demo-data.ts
index a2e5487c1..513b74859 100644
--- a/packages/examples/vanilla/src/demos/analytics/analytics.demo-data.ts
+++ b/packages/examples/vanilla/src/demos/analytics/analytics.demo-data.ts
@@ -196,8 +196,8 @@ export const analyticsPresets: AnalyticsPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
description: "Drill into products within each region",
pivot: {
rows: ["region", "product"],
diff --git a/packages/examples/vanilla/src/demos/pivot/PivotDemo.ts b/packages/examples/vanilla/src/demos/pivot/PivotDemo.ts
index 4dafaaafc..9328f4d77 100644
--- a/packages/examples/vanilla/src/demos/pivot/PivotDemo.ts
+++ b/packages/examples/vanilla/src/demos/pivot/PivotDemo.ts
@@ -4,7 +4,6 @@ import type { Theme, GetRowIdParams } from "simple-table-core";
import { pivotDemoConfig, pivotPresets } from "./pivot.demo-data";
import "simple-table-core/styles.css";
-
const getRowId = ({ row }: GetRowIdParams) => row.id;
export function renderPivotDemo(
container: HTMLElement,
@@ -38,7 +37,6 @@ export function renderPivotDemo(
const active = pivotPresets.find((p) => p.id === activeId) ?? pivotPresets[0];
table?.updateConfig({
pivot: active.pivot,
- expandAll: active.pivot.rows.length > 1,
});
});
buttons.appendChild(btn);
@@ -56,7 +54,6 @@ export function renderPivotDemo(
rows: pivotDemoConfig.rows,
pivot: active.pivot,
columnResizing: true,
- expandAll: active.pivot.rows.length > 1,
height: options?.height ?? "400px",
selectableCells: true,
theme: options?.theme,
diff --git a/packages/examples/vanilla/src/demos/pivot/pivot.demo-data.ts b/packages/examples/vanilla/src/demos/pivot/pivot.demo-data.ts
index a16cecf0a..4b7481dab 100644
--- a/packages/examples/vanilla/src/demos/pivot/pivot.demo-data.ts
+++ b/packages/examples/vanilla/src/demos/pivot/pivot.demo-data.ts
@@ -123,8 +123,8 @@ export const pivotPresets: PivotPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
pivot: {
rows: ["region", "product"],
columns: ["quarter"],
diff --git a/packages/examples/vue/src/demos/analytics/AnalyticsDemo.vue b/packages/examples/vue/src/demos/analytics/AnalyticsDemo.vue
index 09654afa1..c134cb0d4 100644
--- a/packages/examples/vue/src/demos/analytics/AnalyticsDemo.vue
+++ b/packages/examples/vue/src/demos/analytics/AnalyticsDemo.vue
@@ -86,8 +86,6 @@
:copy-headers-to-clipboard="true"
:columns="analyticsDemoConfig.headers"
:enable-column-editor="true"
- :enable-sticky-parents="nestedRows"
- :expand-all="nestedRows"
:get-row-id="getRowId"
height="100%"
:include-headers-in-csv-export="true"
@@ -123,7 +121,6 @@ const active = computed(
() => analyticsPresets.find((p) => p.id === activeId.value) ?? analyticsPresets[0]
);
const isPivoted = computed(() => active.value.pivot != null);
-const nestedRows = computed(() => (active.value.pivot?.rows.length ?? 0) > 1);
const isDark = computed(() => props.theme === "dark" || props.theme === "modern-dark");
const chromeBg = computed(() => (isDark.value ? "#0f172a" : "#f8fafc"));
const chromeBorder = computed(() => (isDark.value ? "#1e293b" : "#e2e8f0"));
diff --git a/packages/examples/vue/src/demos/analytics/analytics.demo-data.ts b/packages/examples/vue/src/demos/analytics/analytics.demo-data.ts
index abb292b31..bbe85a11b 100644
--- a/packages/examples/vue/src/demos/analytics/analytics.demo-data.ts
+++ b/packages/examples/vue/src/demos/analytics/analytics.demo-data.ts
@@ -196,8 +196,8 @@ export const analyticsPresets: AnalyticsPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
description: "Drill into products within each region",
pivot: {
rows: ["region", "product"],
diff --git a/packages/examples/vue/src/demos/pivot/PivotDemo.vue b/packages/examples/vue/src/demos/pivot/PivotDemo.vue
index c5880de70..09e6a6cd1 100644
--- a/packages/examples/vue/src/demos/pivot/PivotDemo.vue
+++ b/packages/examples/vue/src/demos/pivot/PivotDemo.vue
@@ -26,7 +26,6 @@
:get-row-id="getRowId"
:pivot="active.pivot"
:column-resizing="true"
- :expand-all="nestedRows"
:height="height"
:selectable-cells="true"
:theme="theme"
@@ -50,7 +49,6 @@ const activeId = ref(pivotPresets[0].id);
const active = computed(
() => pivotPresets.find((p) => p.id === activeId.value) ?? pivotPresets[0]
);
-const nestedRows = computed(() => active.value.pivot.rows.length > 1);
const getRowId = ({ row }: GetRowIdParams) => row.id;
diff --git a/packages/examples/vue/src/demos/pivot/pivot.demo-data.ts b/packages/examples/vue/src/demos/pivot/pivot.demo-data.ts
index b8bf7a1c5..91840ae79 100644
--- a/packages/examples/vue/src/demos/pivot/pivot.demo-data.ts
+++ b/packages/examples/vue/src/demos/pivot/pivot.demo-data.ts
@@ -123,8 +123,8 @@ export const pivotPresets: PivotPreset[] = [
},
},
{
- id: "nested-rows",
- label: "Region → Product",
+ id: "multi-rows",
+ label: "Region × Product",
pivot: {
rows: ["region", "product"],
columns: ["quarter"],
diff --git a/packages/react/package.json b/packages/react/package.json
index 5193f1e58..2354474f7 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/react",
- "version": "4.1.5",
+ "version": "4.1.6",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/index.d.ts",
diff --git a/packages/react/src/__tests__/pivotRows.test.ts b/packages/react/src/__tests__/pivotRows.test.ts
index 7e7318b0a..7ac5dfd7e 100644
--- a/packages/react/src/__tests__/pivotRows.test.ts
+++ b/packages/react/src/__tests__/pivotRows.test.ts
@@ -3,7 +3,6 @@ import {
pivotRows,
buildPivotAccessor,
buildPivotRowTotalAccessor,
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
PIVOT_BLANK_LABEL,
type ColumnDef,
@@ -41,7 +40,6 @@ describe("pivotRows", () => {
},
});
- expect(result.rowGrouping).toBeUndefined();
expect(result.rows).toHaveLength(2);
const west = result.rows.find((r) => r.region === "West")!;
@@ -74,7 +72,7 @@ describe("pivotRows", () => {
expect(totalRow[buildPivotRowTotalAccessor("sales")]).toBe(440);
});
- it("builds a tree for multi-level row dimensions", () => {
+ it("emits flat rows for multi-level row dimensions (one per combination)", () => {
const result = pivotRows({
rows: sampleRows,
fieldHeaders,
@@ -87,15 +85,18 @@ describe("pivotRows", () => {
},
});
- expect(result.rowGrouping).toEqual([PIVOT_CHILDREN_KEY]);
- const west = result.rows.find((r) => r.region === "West")!;
- const children = west[PIVOT_CHILDREN_KEY] as Row[];
- expect(children).toHaveLength(2);
- expect(west[buildPivotAccessor("Q1", "sales")]).toBe(150);
+ // East/A, East (none for B), West/A, West/B — sample has no East/B
+ expect(result.rows).toHaveLength(3);
+ expect(result.headers.some((h) => h.expandable)).toBe(false);
+
+ const westA = result.rows.find((r) => r.region === "West" && r.product === "A")!;
+ const westB = result.rows.find((r) => r.region === "West" && r.product === "B")!;
+ const eastA = result.rows.find((r) => r.region === "East" && r.product === "A")!;
- const productA = children.find((c) => c.product === "A")!;
- expect(productA[buildPivotAccessor("Q1", "sales")]).toBe(100);
- expect(productA[buildPivotAccessor("Q2", "sales")]).toBe(120);
+ expect(westA[buildPivotAccessor("Q1", "sales")]).toBe(100);
+ expect(westA[buildPivotAccessor("Q2", "sales")]).toBe(120);
+ expect(westB[buildPivotAccessor("Q1", "sales")]).toBe(50);
+ expect(eastA[buildPivotAccessor("Q1", "sales")]).toBe(80);
});
it("supports multiple value measures", () => {
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 7f3e871e8..d4fd90b09 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -122,7 +122,6 @@ export {
pivotRows,
buildPivotAccessor,
buildPivotRowTotalAccessor,
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
PIVOT_ACCESSOR_PREFIX,
PIVOT_BLANK_LABEL,
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index 52bd0c23d..b5ff974a6 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -63,9 +63,10 @@ export type ColumnEditorRowRendererProps = Omit<
/** Column editor custom-renderer slots as React nodes (core uses `HTMLElement`). */
export type ColumnEditorCustomRendererProps = Omit<
VanillaColumnEditorCustomRendererProps,
- "searchSection" | "listSection" | "resetSection"
+ "searchSection" | "pivotSection" | "listSection" | "resetSection"
> & {
searchSection?: React.ReactNode;
+ pivotSection?: React.ReactNode;
listSection?: React.ReactNode;
resetSection?: React.ReactNode;
};
diff --git a/packages/react/src/utils/wrapReactRenderer.tsx b/packages/react/src/utils/wrapReactRenderer.tsx
index bd53ad19c..c0399fb59 100644
--- a/packages/react/src/utils/wrapReactRenderer.tsx
+++ b/packages/react/src/utils/wrapReactRenderer.tsx
@@ -296,8 +296,8 @@ export function wrapReactColumnEditorRowRenderer(
}
/**
- * Maps `searchSection` / `listSection` / `resetSection` HTMLElement slots for
- * `columnEditorConfig.customRenderer` the same way as row slots.
+ * Maps `searchSection` / `pivotSection` / `listSection` / `resetSection`
+ * HTMLElement slots for `columnEditorConfig.customRenderer` the same way as row slots.
*/
export function wrapReactColumnEditorCustomRenderer(
bridge: PortalBridge,
@@ -311,6 +311,7 @@ export function wrapReactColumnEditorCustomRenderer(
const reactProps = {
...props,
searchSection: props.searchSection ? domSlotToReactNode(props.searchSection) : null,
+ pivotSection: props.pivotSection ? domSlotToReactNode(props.pivotSection) : null,
listSection: domSlotToReactNode(props.listSection),
resetSection: props.resetSection ? domSlotToReactNode(props.resetSection) : null,
};
diff --git a/packages/solid/package.json b/packages/solid/package.json
index c3e549645..72969bbd8 100644
--- a/packages/solid/package.json
+++ b/packages/solid/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/solid",
- "version": "4.1.5",
+ "version": "4.1.6",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/index.d.ts",
diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts
index 72f969f15..bc02250a1 100644
--- a/packages/solid/src/index.ts
+++ b/packages/solid/src/index.ts
@@ -120,7 +120,6 @@ export {
pivotRows,
buildPivotAccessor,
buildPivotRowTotalAccessor,
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
PIVOT_ACCESSOR_PREFIX,
PIVOT_BLANK_LABEL,
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 5b2072c16..edc629d91 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/svelte",
- "version": "4.1.5",
+ "version": "4.1.6",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/index.d.ts",
diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts
index 7009beba6..e14d94ece 100644
--- a/packages/svelte/src/index.ts
+++ b/packages/svelte/src/index.ts
@@ -123,7 +123,6 @@ export {
pivotRows,
buildPivotAccessor,
buildPivotRowTotalAccessor,
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
PIVOT_ACCESSOR_PREFIX,
PIVOT_BLANK_LABEL,
diff --git a/packages/vue/package.json b/packages/vue/package.json
index 2096c9eac..a8300f9c4 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/vue",
- "version": "4.1.5",
+ "version": "4.1.6",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/index.d.ts",
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
index 402c5e7a9..bf58a420c 100644
--- a/packages/vue/src/index.ts
+++ b/packages/vue/src/index.ts
@@ -121,7 +121,6 @@ export {
pivotRows,
buildPivotAccessor,
buildPivotRowTotalAccessor,
- PIVOT_CHILDREN_KEY,
PIVOT_IS_TOTAL_KEY,
PIVOT_ACCESSOR_PREFIX,
PIVOT_BLANK_LABEL,