diff --git a/apps/marketing/public/txt-demos/angular/analytics.txt b/apps/marketing/public/txt-demos/angular/analytics.txt index 7b1176f8a..f3671c564 100644 --- a/apps/marketing/public/txt-demos/angular/analytics.txt +++ b/apps/marketing/public/txt-demos/angular/analytics.txt @@ -110,7 +110,6 @@ import "@simple-table/angular/styles.css"; [rows]="rows" [columns]="headers" [enableColumnEditor]="true" - [expandAll]="nestedRows" [getRowId]="getRowId" height="100%" [initialSortColumn]="isPivoted ? undefined : 'sales'" @@ -138,7 +137,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; activeDescription = analyticsPresets[0].description; searchText = ""; @@ -192,7 +190,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; this.activeDescription = preset.description; } @@ -202,7 +199,6 @@ export class AnalyticsDemoComponent { } } - // analytics.demo-data.ts import type { PivotConfig, AngularColumnDef, Row } from "@simple-table/angular"; @@ -390,8 +386,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/apps/marketing/public/txt-demos/angular/pivot.txt b/apps/marketing/public/txt-demos/angular/pivot.txt index de1d4534c..fec6dbee2 100644 --- a/apps/marketing/public/txt-demos/angular/pivot.txt +++ b/apps/marketing/public/txt-demos/angular/pivot.txt @@ -1,8 +1,14 @@ // pivot-demo.component.ts import { Component, Input } from "@angular/core"; import { SimpleTableComponent } from "@simple-table/angular"; -import type { AngularColumnDef, PivotConfig, Row, Theme } from "@simple-table/angular"; -import { pivotDemoConfig, pivotPresets, type PivotPreset } from "./pivot.demo-data"; +import type { + AngularColumnDef, + GetRowIdParams, + PivotConfig, + Theme, +} from "@simple-table/angular"; +import { pivotDemoConfig } from "./pivot.demo-data"; +import type { PivotFact } from "./pivot.demo-data"; import "@simple-table/angular/styles.css"; @Component({ @@ -10,229 +16,40 @@ import "@simple-table/angular/styles.css"; standalone: true, imports: [SimpleTableComponent], template: ` -
-
- @for (preset of presets; track preset.id) { - - } -
- -
+ `, }) export class PivotDemoComponent { - @Input() height: string | number = "400px"; + @Input() height: string | number = "500px"; @Input() theme?: Theme; - readonly rows: Row[] = pivotDemoConfig.rows; - readonly headers: AngularColumnDef[] = pivotDemoConfig.headers; - readonly presets = pivotPresets; + readonly rows: PivotFact[] = pivotDemoConfig.rows; + readonly headers: AngularColumnDef[] = pivotDemoConfig.headers; - activeId = pivotPresets[0].id; - pivot: PivotConfig = pivotPresets[0].pivot; - nestedRows = pivotPresets[0].pivot.rows.length > 1; + pivot: PivotConfig | null = { + rows: ["region", "product"], + columns: ["quarter"], + values: [{ accessor: "sales", aggregation: { type: "sum" } }], + }; - selectPreset(preset: PivotPreset): void { - this.activeId = preset.id; - this.pivot = preset.pivot; - this.nestedRows = preset.pivot.rows.length > 1; - } -} - - -// pivot.demo-data.ts -import type { PivotConfig, AngularColumnDef, Row } from "@simple-table/angular"; - -export const pivotHeaders: AngularColumnDef[] = [ - { accessor: "region", label: "Region", width: 110, type: "string" }, - { accessor: "country", label: "Country", width: 100, type: "string" }, - { accessor: "category", label: "Category", width: 110, type: "string" }, - { accessor: "product", label: "Product", width: 120, type: "string" }, - { accessor: "channel", label: "Channel", width: 100, type: "string" }, - { accessor: "year", label: "Year", width: 80, type: "number" }, - { accessor: "quarter", label: "Quarter", width: 80, type: "string" }, - { - accessor: "sales", - label: "Sales", - width: 100, - type: "number", - align: "right", - valueFormatter: ({ value }) => - typeof value === "number" ? `$${value.toLocaleString()}` : "", - }, - { - accessor: "units", - label: "Units", - width: 80, - type: "number", - align: "right", - }, - { - accessor: "cost", - label: "Cost", - width: 100, - type: "number", - align: "right", - valueFormatter: ({ value }) => - typeof value === "number" ? `$${value.toLocaleString()}` : "", - }, -]; - -const REGIONS = ["West", "East", "North", "South"] as const; -const COUNTRIES: Record<(typeof REGIONS)[number], string[]> = { - West: ["USA", "Canada"], - East: ["USA", "UK"], - North: ["Canada", "Sweden"], - South: ["Brazil", "Australia"], -}; -const CATEGORIES = ["Hardware", "Software"] as const; -const PRODUCTS: Record<(typeof CATEGORIES)[number], string[]> = { - Hardware: ["Widget", "Gadget", "Sensor"], - Software: ["License", "Subscription"], -}; -const CHANNELS = ["Direct", "Partner", "Online"] as const; -const YEARS = [2024, 2025] as const; -const QUARTERS = ["Q1", "Q2", "Q3", "Q4"] as const; + onPivotChange = (next: PivotConfig | null) => { + this.pivot = next; + }; -/** Sparse multi-dimension fact cube (~150–250 rows). */ -export function generatePivotRows(): Row[] { - const rows: Row[] = []; - let id = 1; - for (const region of REGIONS) { - for (const country of COUNTRIES[region]) { - for (const category of CATEGORIES) { - for (const product of PRODUCTS[category]) { - for (const channel of CHANNELS) { - for (const year of YEARS) { - for (const quarter of QUARTERS) { - if ((id + year + quarter.charCodeAt(1) + channel.length) % 5 !== 0) { - id++; - continue; - } - const base = 40 + ((id * 17) % 90); - rows.push({ - id: `r${id}`, - region, - country, - category, - product, - channel, - year, - quarter, - sales: base * 100, - units: base, - cost: Math.round(base * 55), - }); - id++; - } - } - } - } - } - } - } - return rows; + getRowId = ({ row }: GetRowIdParams) => + row?.id == null ? undefined : String(row.id); } - -export const pivotRows: Row[] = generatePivotRows(); - -export type PivotPreset = { - id: string; - label: string; - pivot: PivotConfig; -}; - -export const pivotPresets: PivotPreset[] = [ - { - id: "region-quarter", - label: "Region × Quarter", - pivot: { - rows: ["region"], - columns: ["quarter"], - values: [{ accessor: "sales", aggregation: { type: "sum" } }], - }, - }, - { - id: "nested-rows", - label: "Region → Product", - pivot: { - rows: ["region", "product"], - columns: ["quarter"], - values: [{ accessor: "sales", aggregation: { type: "sum" } }], - }, - }, - { - id: "category-year-quarter", - label: "Category × Year → Quarter", - pivot: { - rows: ["category"], - columns: ["year", "quarter"], - values: [{ accessor: "sales", aggregation: { type: "sum" } }], - }, - }, - { - id: "channel-quarter", - label: "Channel × Quarter", - pivot: { - rows: ["channel"], - columns: ["quarter"], - values: [ - { accessor: "sales", aggregation: { type: "sum" }, label: "Sales" }, - { accessor: "units", aggregation: { type: "sum" }, label: "Units" }, - ], - }, - }, - { - id: "country-category", - label: "Country × Category", - pivot: { - rows: ["country"], - columns: ["category"], - values: [{ accessor: "sales", aggregation: { type: "average" } }], - showColumnTotals: false, - }, - }, - { - id: "values-only", - label: "Values only", - pivot: { - rows: ["region", "category"], - columns: [], - values: [ - { accessor: "sales", aggregation: { type: "sum" } }, - { accessor: "cost", aggregation: { type: "sum" } }, - ], - }, - }, -]; - -export const pivotConfig: PivotConfig = pivotPresets[0].pivot; - -export const pivotDemoConfig = { - headers: pivotHeaders, - rows: pivotRows, - tableProps: { pivot: pivotConfig }, - presets: pivotPresets, -}; diff --git a/apps/marketing/public/txt-demos/react/analytics.txt b/apps/marketing/public/txt-demos/react/analytics.txt index 00ff7f429..6a71d3d9e 100644 --- a/apps/marketing/public/txt-demos/react/analytics.txt +++ b/apps/marketing/public/txt-demos/react/analytics.txt @@ -25,7 +25,6 @@ const AnalyticsDemo = ({ const [searchText, setSearchText] = useState(""); 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); @@ -178,8 +177,6 @@ const AnalyticsDemo = ({ copyHeadersToClipboard columns={analyticsDemoConfig.headers} enableColumnEditor - enableStickyParents={nestedRows} - expandAll={nestedRows} getRowId={({ row }) => String(row.id)} height={tableHeightPx} includeHeadersInCSVExport diff --git a/apps/marketing/public/txt-demos/react/pivot.txt b/apps/marketing/public/txt-demos/react/pivot.txt index cc1f3833f..6f9c2885d 100644 --- a/apps/marketing/public/txt-demos/react/pivot.txt +++ b/apps/marketing/public/txt-demos/react/pivot.txt @@ -1,57 +1,40 @@ import { useState } from "react"; import { SimpleTable } from "@simple-table/react"; -import type { Theme } from "@simple-table/react"; -import { pivotDemoConfig, pivotPresets } from "./pivot.demo-data"; +import type { PivotConfig, Theme } from "@simple-table/react"; +import { pivotDemoConfig } from "./pivot.demo-data"; import "@simple-table/react/styles.css"; +const INITIAL_PIVOT: PivotConfig = { + rows: ["region", "product"], + columns: ["quarter"], + values: [{ accessor: "sales", aggregation: { type: "sum" } }], +}; + const PivotDemo = ({ - height = "400px", + height = "500px", theme, }: { height?: string | number; theme?: Theme; }) => { - const [activeId, setActiveId] = useState(pivotPresets[0].id); - const active = pivotPresets.find((p) => p.id === activeId) ?? pivotPresets[0]; - const nestedRows = active.pivot.rows.length > 1; + const [pivot, setPivot] = useState(INITIAL_PIVOT); return ( -
-
- {pivotPresets.map((preset) => { - const selected = preset.id === activeId; - return ( - - ); - })} -
- -
+ (row?.id == null ? undefined : String(row.id))} + /> ); }; diff --git a/apps/marketing/public/txt-demos/solid/analytics.txt b/apps/marketing/public/txt-demos/solid/analytics.txt index 84fca8158..4264b8921 100644 --- a/apps/marketing/public/txt-demos/solid/analytics.txt +++ b/apps/marketing/public/txt-demos/solid/analytics.txt @@ -28,7 +28,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"); @@ -178,8 +177,6 @@ export default function AnalyticsDemo(props: { copyHeadersToClipboard columns={analyticsDemoConfig.headers} enableColumnEditor - enableStickyParents={nestedRows()} - expandAll={nestedRows()} getRowId={({ row }) => String(row.id)} height={tableHeightPx()!} includeHeadersInCSVExport diff --git a/apps/marketing/public/txt-demos/solid/pivot.txt b/apps/marketing/public/txt-demos/solid/pivot.txt index fa5dc138c..9477355c9 100644 --- a/apps/marketing/public/txt-demos/solid/pivot.txt +++ b/apps/marketing/public/txt-demos/solid/pivot.txt @@ -1,56 +1,36 @@ -import { createMemo, createSignal, For } from "solid-js"; +import { createSignal } from "solid-js"; import { SimpleTable } from "@simple-table/solid"; -import type { Theme } from "@simple-table/solid"; -import { pivotDemoConfig, pivotPresets } from "./pivot.demo-data"; +import type { PivotConfig, Theme } from "@simple-table/solid"; +import { pivotDemoConfig } from "./pivot.demo-data"; import "@simple-table/solid/styles.css"; +const INITIAL_PIVOT: PivotConfig = { + rows: ["region", "product"], + columns: ["quarter"], + values: [{ accessor: "sales", aggregation: { type: "sum" } }], +}; + export default function PivotDemo(props: { height?: string | number; theme?: Theme; }) { - const [activeId, setActiveId] = createSignal(pivotPresets[0].id); - const active = createMemo( - () => pivotPresets.find((p) => p.id === activeId()) ?? pivotPresets[0] - ); - const nestedRows = createMemo(() => active().pivot.rows.length > 1); + const [pivot, setPivot] = createSignal(INITIAL_PIVOT); return ( -
-
- - {(preset) => { - const selected = () => preset.id === activeId(); - return ( - - ); - }} - -
- -
+ (row?.id == null ? undefined : String(row.id))} + /> ); } diff --git a/apps/marketing/public/txt-demos/svelte/analytics.txt b/apps/marketing/public/txt-demos/svelte/analytics.txt index be704c42d..038ee88c7 100644 --- a/apps/marketing/public/txt-demos/svelte/analytics.txt +++ b/apps/marketing/public/txt-demos/svelte/analytics.txt @@ -15,7 +15,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"); @@ -96,8 +95,6 @@ copyHeadersToClipboard={true} columns={analyticsDemoConfig.headers} enableColumnEditor={true} - enableStickyParents={nestedRows} - expandAll={nestedRows} {getRowId} height="100%" includeHeadersInCSVExport={true} diff --git a/apps/marketing/public/txt-demos/svelte/pivot.txt b/apps/marketing/public/txt-demos/svelte/pivot.txt index e6d97c31a..ed9404869 100644 --- a/apps/marketing/public/txt-demos/svelte/pivot.txt +++ b/apps/marketing/public/txt-demos/svelte/pivot.txt @@ -1,39 +1,34 @@ -
-
- {#each pivotPresets as preset} - - {/each} -
- -
+ (pivot = next)} + autoExpandColumns={true} + columnResizing={true} + enableColumnEditor={true} + enableColumnEditorInitOpen={true} + enablePivotPanel={true} + selectableCells={true} + {getRowId} + {height} + {theme} +/> diff --git a/apps/marketing/public/txt-demos/vanilla/analytics.txt b/apps/marketing/public/txt-demos/vanilla/analytics.txt index a648267c9..1b5eed763 100644 --- a/apps/marketing/public/txt-demos/vanilla/analytics.txt +++ b/apps/marketing/public/txt-demos/vanilla/analytics.txt @@ -87,7 +87,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, @@ -97,8 +96,6 @@ export function renderAnalyticsDemo( copyHeadersToClipboard: true, columns: analyticsDemoConfig.headers, enableColumnEditor: true, - enableStickyParents: nested, - expandAll: nested, getRowId: ({ row }) => String(row.id), height: "100%", includeHeadersInCSVExport: true, @@ -147,7 +144,6 @@ export function renderAnalyticsDemo( return table!; } - // analytics.demo-data.ts import type { ColumnDef, PivotConfig, Row } from "simple-table-core"; @@ -335,8 +331,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/apps/marketing/public/txt-demos/vanilla/pivot.txt b/apps/marketing/public/txt-demos/vanilla/pivot.txt index b6004d5d4..2429a2a26 100644 --- a/apps/marketing/public/txt-demos/vanilla/pivot.txt +++ b/apps/marketing/public/txt-demos/vanilla/pivot.txt @@ -1,238 +1,32 @@ // PivotDemo.ts import { SimpleTableVanilla } from "simple-table-core"; -import type { Theme } from "simple-table-core"; -import { pivotDemoConfig, pivotPresets } from "./pivot.demo-data"; +import type { PivotConfig, Theme } from "simple-table-core"; +import { pivotDemoConfig } from "./pivot.demo-data"; import "simple-table-core/styles.css"; +const INITIAL_PIVOT: PivotConfig = { + rows: ["region", "product"], + columns: ["quarter"], + values: [{ accessor: "sales", aggregation: { type: "sum" } }], +}; + export function renderPivotDemo( container: HTMLElement, options?: { height?: string | number; theme?: Theme } ): SimpleTableVanilla { - let activeId = pivotPresets[0].id; - let table: SimpleTableVanilla | null = null; - - const root = document.createElement("div"); - root.style.cssText = "display:flex;flex-direction:column;gap:12px;width:100%"; - - const buttons = document.createElement("div"); - buttons.style.cssText = "display:flex;flex-wrap:wrap;gap:8px"; - - const tableHost = document.createElement("div"); - tableHost.style.cssText = "width:100%"; - - const paintButtons = () => { - buttons.replaceChildren(); - for (const preset of pivotPresets) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = preset.label; - const selected = preset.id === activeId; - btn.style.cssText = `padding:6px 12px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:500;background:${ - selected ? "#2563eb" : "#e5e7eb" - };color:${selected ? "#fff" : "#374151"}`; - btn.addEventListener("click", () => { - activeId = preset.id; - paintButtons(); - const active = pivotPresets.find((p) => p.id === activeId) ?? pivotPresets[0]; - table?.updateConfig({ - pivot: active.pivot, - expandAll: active.pivot.rows.length > 1, - }); - }); - buttons.appendChild(btn); - } - }; - - paintButtons(); - root.append(buttons, tableHost); - container.replaceChildren(root); - - const active = pivotPresets.find((p) => p.id === activeId) ?? pivotPresets[0]; - table = new SimpleTableVanilla(tableHost, { + container.replaceChildren(); + return new SimpleTableVanilla(container, { columns: pivotDemoConfig.headers, rows: pivotDemoConfig.rows, - pivot: active.pivot, + pivot: INITIAL_PIVOT, + autoExpandColumns: true, columnResizing: true, - expandAll: active.pivot.rows.length > 1, - height: options?.height ?? "400px", + enableColumnEditor: true, + enableColumnEditorInitOpen: true, + enablePivotPanel: true, + height: options?.height ?? "500px", selectableCells: true, theme: options?.theme, + getRowId: ({ row }) => (row?.id == null ? undefined : String(row.id)), }); - return table; } - - -// pivot.demo-data.ts -import type { ColumnDef, PivotConfig, Row } from "simple-table-core"; - -export const pivotHeaders: ColumnDef[] = [ - { accessor: "region", label: "Region", width: 110, type: "string" }, - { accessor: "country", label: "Country", width: 100, type: "string" }, - { accessor: "category", label: "Category", width: 110, type: "string" }, - { accessor: "product", label: "Product", width: 120, type: "string" }, - { accessor: "channel", label: "Channel", width: 100, type: "string" }, - { accessor: "year", label: "Year", width: 80, type: "number" }, - { accessor: "quarter", label: "Quarter", width: 80, type: "string" }, - { - accessor: "sales", - label: "Sales", - width: 100, - type: "number", - align: "right", - valueFormatter: ({ value }) => - typeof value === "number" ? `$${value.toLocaleString()}` : "", - }, - { - accessor: "units", - label: "Units", - width: 80, - type: "number", - align: "right", - }, - { - accessor: "cost", - label: "Cost", - width: 100, - type: "number", - align: "right", - valueFormatter: ({ value }) => - typeof value === "number" ? `$${value.toLocaleString()}` : "", - }, -]; - -const REGIONS = ["West", "East", "North", "South"] as const; -const COUNTRIES: Record<(typeof REGIONS)[number], string[]> = { - West: ["USA", "Canada"], - East: ["USA", "UK"], - North: ["Canada", "Sweden"], - South: ["Brazil", "Australia"], -}; -const CATEGORIES = ["Hardware", "Software"] as const; -const PRODUCTS: Record<(typeof CATEGORIES)[number], string[]> = { - Hardware: ["Widget", "Gadget", "Sensor"], - Software: ["License", "Subscription"], -}; -const CHANNELS = ["Direct", "Partner", "Online"] as const; -const YEARS = [2024, 2025] as const; -const QUARTERS = ["Q1", "Q2", "Q3", "Q4"] as const; - -/** Sparse multi-dimension fact cube (~150–250 rows). */ -export function generatePivotRows(): Row[] { - const rows: Row[] = []; - let id = 1; - for (const region of REGIONS) { - for (const country of COUNTRIES[region]) { - for (const category of CATEGORIES) { - for (const product of PRODUCTS[category]) { - for (const channel of CHANNELS) { - for (const year of YEARS) { - for (const quarter of QUARTERS) { - if ((id + year + quarter.charCodeAt(1) + channel.length) % 5 !== 0) { - id++; - continue; - } - const base = 40 + ((id * 17) % 90); - rows.push({ - id: `r${id}`, - region, - country, - category, - product, - channel, - year, - quarter, - sales: base * 100, - units: base, - cost: Math.round(base * 55), - }); - id++; - } - } - } - } - } - } - } - return rows; -} - -export const pivotRows: Row[] = generatePivotRows(); - -export type PivotPreset = { - id: string; - label: string; - pivot: PivotConfig; -}; - -export const pivotPresets: PivotPreset[] = [ - { - id: "region-quarter", - label: "Region × Quarter", - pivot: { - rows: ["region"], - columns: ["quarter"], - values: [{ accessor: "sales", aggregation: { type: "sum" } }], - }, - }, - { - id: "nested-rows", - label: "Region → Product", - pivot: { - rows: ["region", "product"], - columns: ["quarter"], - values: [{ accessor: "sales", aggregation: { type: "sum" } }], - }, - }, - { - id: "category-year-quarter", - label: "Category × Year → Quarter", - pivot: { - rows: ["category"], - columns: ["year", "quarter"], - values: [{ accessor: "sales", aggregation: { type: "sum" } }], - }, - }, - { - id: "channel-quarter", - label: "Channel × Quarter", - pivot: { - rows: ["channel"], - columns: ["quarter"], - values: [ - { accessor: "sales", aggregation: { type: "sum" }, label: "Sales" }, - { accessor: "units", aggregation: { type: "sum" }, label: "Units" }, - ], - }, - }, - { - id: "country-category", - label: "Country × Category", - pivot: { - rows: ["country"], - columns: ["category"], - values: [{ accessor: "sales", aggregation: { type: "average" } }], - showColumnTotals: false, - }, - }, - { - id: "values-only", - label: "Values only", - pivot: { - rows: ["region", "category"], - columns: [], - values: [ - { accessor: "sales", aggregation: { type: "sum" } }, - { accessor: "cost", aggregation: { type: "sum" } }, - ], - }, - }, -]; - -export const pivotConfig: PivotConfig = pivotPresets[0].pivot; - -export const pivotDemoConfig = { - headers: pivotHeaders, - rows: pivotRows, - tableProps: { pivot: pivotConfig }, - presets: pivotPresets, -}; diff --git a/apps/marketing/public/txt-demos/vue/analytics.txt b/apps/marketing/public/txt-demos/vue/analytics.txt index b46593b68..c089c9d7b 100644 --- a/apps/marketing/public/txt-demos/vue/analytics.txt +++ b/apps/marketing/public/txt-demos/vue/analytics.txt @@ -106,8 +106,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" @@ -148,7 +146,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/apps/marketing/public/txt-demos/vue/pivot.txt b/apps/marketing/public/txt-demos/vue/pivot.txt index ab691ba24..ea708541b 100644 --- a/apps/marketing/public/txt-demos/vue/pivot.txt +++ b/apps/marketing/public/txt-demos/vue/pivot.txt @@ -1,52 +1,39 @@ diff --git a/apps/marketing/src/app/blog/ag-grid-alternatives-free-angular-data-grids-2026/page.tsx b/apps/marketing/src/app/blog/ag-grid-alternatives-free-angular-data-grids-2026/page.tsx index 0ec24ad52..a39f95414 100644 --- a/apps/marketing/src/app/blog/ag-grid-alternatives-free-angular-data-grids-2026/page.tsx +++ b/apps/marketing/src/app/blog/ag-grid-alternatives-free-angular-data-grids-2026/page.tsx @@ -93,7 +93,7 @@ export default function Page() { title: "Existing AG Grid Enterprise with pivoting", body: "Pivoting and master/detail are core; renewal is committed.", recommendation: "competitor", - recommendationLabel: "Need an interactive Pivot Panel today? AG Grid Enterprise has it now. Simple Table ships declarative matrix pivoting today, with a drag-and-drop Pivot Panel on the Enterprise roadmap.", + recommendationLabel: "Need a Pivot Panel today? AG Grid Enterprise has a mature DnD panel. Simple Table ships declarative matrix pivoting plus enablePivotPanel in the column editor.", }, { emoji: "📈", diff --git a/apps/marketing/src/app/blog/ag-grid-alternatives-free-vue-data-grids-2026/page.tsx b/apps/marketing/src/app/blog/ag-grid-alternatives-free-vue-data-grids-2026/page.tsx index 67f6a1958..dae433e3d 100644 --- a/apps/marketing/src/app/blog/ag-grid-alternatives-free-vue-data-grids-2026/page.tsx +++ b/apps/marketing/src/app/blog/ag-grid-alternatives-free-vue-data-grids-2026/page.tsx @@ -107,7 +107,7 @@ export default function Page() { title: "Existing AG Grid Enterprise with pivoting", body: "Pivoting is core; renewal committed.", recommendation: "competitor", - recommendationLabel: "Need an interactive Pivot Panel today? AG Grid Enterprise has it now. Simple Table ships declarative matrix pivoting today, with a drag-and-drop Pivot Panel on the Enterprise roadmap.", + recommendationLabel: "Need a Pivot Panel today? AG Grid Enterprise has a mature DnD panel. Simple Table ships declarative matrix pivoting plus enablePivotPanel in the column editor.", }, ]} faqs={[ diff --git a/apps/marketing/src/app/blog/best-solidjs-data-grid-2026/page.tsx b/apps/marketing/src/app/blog/best-solidjs-data-grid-2026/page.tsx index f55aba53e..a1536e0e2 100644 --- a/apps/marketing/src/app/blog/best-solidjs-data-grid-2026/page.tsx +++ b/apps/marketing/src/app/blog/best-solidjs-data-grid-2026/page.tsx @@ -91,7 +91,7 @@ export default function Page() { title: "Existing AG Grid Enterprise + Solid", body: "Pivoting and master/detail are core; renewal committed.", recommendation: "competitor", - recommendationLabel: "Need an interactive Pivot Panel today? AG Grid Enterprise has it now. Simple Table ships declarative matrix pivoting today, with a drag-and-drop Pivot Panel on the Enterprise roadmap.", + recommendationLabel: "Need a Pivot Panel today? AG Grid Enterprise has a mature DnD panel. Simple Table ships declarative matrix pivoting plus enablePivotPanel in the column editor on the same config API.", }, { emoji: "📊", diff --git a/apps/marketing/src/app/blog/react-pivot-table/page.tsx b/apps/marketing/src/app/blog/react-pivot-table/page.tsx index 808304975..56e52317f 100644 --- a/apps/marketing/src/app/blog/react-pivot-table/page.tsx +++ b/apps/marketing/src/app/blog/react-pivot-table/page.tsx @@ -32,7 +32,7 @@ const FAQS = [ { question: "Is there a free alternative to AG Grid pivot mode?", answer: - "Yes. Simple Table provides declarative matrix pivot without AG Grid Enterprise. You configure rows, columns, and values via props or TableAPI. An interactive drag-and-drop Pivot Panel is on the Simple Table Enterprise roadmap.", + "Yes. Simple Table provides declarative matrix pivot without AG Grid Enterprise. You configure rows, columns, and values via props or TableAPI, or use enablePivotPanel in the column editor for an in-table Pivot Panel.", }, { question: "When should I use pivot vs row grouping in a React data grid?", @@ -40,14 +40,14 @@ const FAQS = [ "Use matrix pivot when you need cross-tab columns generated from data and aggregated cells. Use row grouping when you still need individual fact rows in an expand/collapse hierarchy with fixed columns.", }, { - question: "Does Simple Table include a drag-and-drop Pivot Panel?", + question: "Does Simple Table include a Pivot Panel?", answer: - "Declarative pivot is available today. A first-party drag-and-drop Pivot Panel is coming for Simple Table Enterprise and will drive the same pivot config API.", + "Yes. Set enablePivotPanel (with enableColumnEditor) to place fields into Rows, Columns, and Values from the column editor. The panel drives the same pivot config API as props and setPivot.", }, { question: "Can I update a React pivot table at runtime?", answer: - "Yes. The analytics and docs demos swap a controlled pivot prop (and expandAll for nested row fields). You can also call TableAPI.setPivot to enable, change, or clear pivot imperatively. getPivot, getPivotHeaders, and getPivotedRows inspect the active matrix.", + "Yes. Swap a controlled pivot prop, use the Pivot Panel, or call TableAPI.setPivot to enable, change, or clear pivot. getPivot, getPivotHeaders, and getPivotedRows inspect the active matrix.", }, ]; @@ -140,8 +140,12 @@ export default function ReactPivotTablePage() { AG Grid's {" "} interactive Pivot Panel is powerful—and priced accordingly. Simple Table ships{" "} - declarative matrix pivot today so you can build analytics views in - code or your own UI. A drag-and-drop Pivot Panel is on the Enterprise roadmap. + declarative matrix pivot plus an in-table{" "} + Pivot Panel ( + + enablePivotPanel + + ) that drives the same config API.

@@ -279,7 +283,7 @@ export default function ReactPivotTablePage() { pivot.rows {" "} - builds its own expandable tree (e.g. region → product). + stay flat — one grid row per combination (e.g. region × product).

@@ -302,8 +306,11 @@ export default function ReactPivotTablePage() { pivot {" "} - config. Until the Enterprise Pivot Panel ships, your app owns the UI for choosing - dimensions—presets, selects, analytics chrome, or{" "} + config. End users can also arrange fields with{" "} + + enablePivotPanel + + , or your app can drive{" "} TableAPI.setPivot @@ -312,9 +319,8 @@ export default function ReactPivotTablePage() {

That gives teams a practical{" "} - AG Grid pivot alternative: matrix pivoting without Enterprise - pricing today, with a first-party drag-and-drop panel coming for end-user field - arrangement. + AG Grid pivot alternative: matrix pivoting and an in-table Pivot + Panel without AG Grid Enterprise pricing.

- Nested row fields + Multiple row fields

- One row field renders a flat pivot. Multiple row fields build an expandable tree - (docs/analytics "Region → Product" preset). Pass{" "} - - expandAll - {" "} - when you want nested levels open by default: + One or many row fields stay tabular: each distinct combination is its own grid row + (docs/analytics "Region × Product" style presets). No expand/collapse + hierarchy:

1 height="400px" />`} /> @@ -530,11 +532,7 @@ pivot={{ setPivot - ). Nested row presets also pass{" "} - - expandAll - - : + ):

p.id === activeId) ?? presets[0]; - const nestedRows = (active.pivot?.rows.length ?? 0) > 1; return ( <> @@ -581,7 +578,6 @@ export default function AnalyticsPivot() { columns={headers} rows={rows} pivot={active.pivot} - expandAll={nestedRows} height="480px" /> @@ -679,13 +675,16 @@ tableRef.current?.setPivot(null); // back to source rows`}

- What's next: An interactive drag-and-drop Pivot Panel is - coming for Simple Table Enterprise—end users will arrange row, column, and value - fields without custom UI. Declarative{" "} + Pivot Panel: Enable{" "} + + enablePivotPanel + {" "} + with the column editor so end users place fields into Rows, Columns, and Values. + Declarative{" "} pivot {" "} - stays the foundation; the panel will drive the same config. + stays the foundation; the panel drives the same config.

@@ -825,8 +824,11 @@ tableRef.current?.setPivot(null); // back to source rows`} className="text-green-500 mt-1 shrink-0" /> - A clear path to an Enterprise Pivot Panel on the same config - model + An in-table Pivot Panel ( + + enablePivotPanel + + ) on the same config model @@ -837,7 +839,7 @@ tableRef.current?.setPivot(null); // back to source rows`} { - const [activeId, setActiveId] = useState(PRESETS[0].id); - const active = PRESETS.find((p) => p.id === activeId) ?? PRESETS[0]; - const nestedRows = active.pivot.rows.length > 1; + const [pivotEnabled, setPivotEnabled] = useState(true); + const [pivot, setPivot] = useState(INITIAL_PIVOT); + + const handlePivotEnabledChange = (enabled: boolean) => { + setPivotEnabled(enabled); + if (enabled && pivot === null) { + setPivot(INITIAL_PIVOT); + } + }; return ( -
-
- {PRESETS.map((preset) => { - const selected = preset.id === activeId; - return ( - - ); - })} -
-

- {rows.length} fact rows · Active:{" "} - - rows: [{active.pivot.rows.map((r: string) => `"${r}"`).join(", ")}] - {" "} - - columns: [{active.pivot.columns.map((c: string) => `"${c}"`).join(", ")}] - -

+
+ (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