From 7d6eda099d5454df5fda545e11b7a236fc22b5cb Mon Sep 17 00:00:00 2001 From: Peter Young <20213436+petera2c@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:48:38 -0500 Subject: [PATCH 01/11] feat: add in-table Pivot Panel with flat multi-dim pivot rows Ship enablePivotPanel for composing Rows/Columns/Values in the column editor, emit one flat row per dimension combination (no synthetic rowGrouping tree), and align panel UI with shared overlay/column-editor styles. Co-authored-by: Cursor --- packages/core/src/core/SimpleTableVanilla.ts | 29 +- .../src/core/rendering/RenderOrchestrator.ts | 5 + .../core/src/core/rendering/TableRenderer.ts | 16 +- packages/core/src/hooks/expandedDepths.ts | 14 +- packages/core/src/icons/CloseIcon.ts | 11 + packages/core/src/icons/index.ts | 1 + packages/core/src/managers/PivotManager.ts | 5 - packages/core/src/styles/base.css | 101 ++++ .../types/ColumnEditorCustomRendererProps.ts | 5 + packages/core/src/types/PivotTypes.ts | 12 +- packages/core/src/types/SimpleTableConfig.ts | 2 + packages/core/src/types/SimpleTableProps.ts | 10 +- packages/core/src/types/TableAPI.ts | 4 +- .../utils/columnEditor/createColumnEditor.ts | 25 + .../columnEditor/createColumnEditorPopout.ts | 101 +++- .../utils/columnEditor/createPivotPanel.ts | 332 +++++++++++++ .../src/utils/filters/createCustomSelect.ts | 11 +- packages/core/src/utils/pivot/pivotRows.ts | 89 +--- .../core/stories/docs/Features.stories.ts | 17 + .../stories/examples/PivotPanelExample.ts | 78 +++ .../examples/PivotPanelLargeExample.ts | 144 ++++++ .../tests/54-PivotPanelTests.stories.ts | 198 ++++++++ .../55-PivotPanelLargeDomTests.stories.ts | 450 ++++++++++++++++++ packages/core/stories/vanillaStoryConfig.ts | 8 + .../react/src/__tests__/pivotRows.test.ts | 23 +- packages/react/src/types.ts | 3 +- .../react/src/utils/wrapReactRenderer.tsx | 5 +- 27 files changed, 1565 insertions(+), 134 deletions(-) create mode 100644 packages/core/src/icons/CloseIcon.ts create mode 100644 packages/core/src/utils/columnEditor/createPivotPanel.ts create mode 100644 packages/core/stories/examples/PivotPanelExample.ts create mode 100644 packages/core/stories/examples/PivotPanelLargeExample.ts create mode 100644 packages/core/stories/tests/54-PivotPanelTests.stories.ts create mode 100644 packages/core/stories/tests/55-PivotPanelLargeDomTests.stories.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/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..c550a41df 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -1194,6 +1194,107 @@ input { padding: var(--st-spacing-small) var(--st-spacing-medium) 0; } +/* Pivot panel — align type/controls with column editor + filter overlays */ +.st-pivot-panel { + flex-shrink: 0; + flex-grow: 1; + overflow: auto; + 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..aedca271f 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,7 +25,10 @@ export type PivotConfig = { showGrandTotal?: boolean; }; -/** Synthetic child-array key used for multi-level pivot row trees. */ +/** + * @deprecated Pivot rows are flat; this key is no longer written. + * Kept for API compatibility with older consumers. + */ export const PIVOT_CHILDREN_KEY = "__pivotChildren"; /** Marker on total rows for styling / identification. */ @@ -40,9 +43,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..a342f8f04 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. */ 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..f2bcb5958 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 scaffold: enablePivotPanel adds a Pivot section (Rows / Columns / Values) to the column editor popout. Grow the field UI here incrementally.", + }, + }, + }, +}; + export const PinnedColumns: StoryObj = { ...storyArgs(pinnedColumnsExampleDefaults), render: (args) => renderPinnedColumnsExample(args), diff --git a/packages/core/stories/examples/PivotPanelExample.ts b/packages/core/stories/examples/PivotPanelExample.ts new file mode 100644 index 000000000..e34207cda --- /dev/null +++ b/packages/core/stories/examples/PivotPanelExample.ts @@ -0,0 +1,78 @@ +/** + * 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 = { + 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..c4c0c0dda --- /dev/null +++ b/packages/core/stories/examples/PivotPanelLargeExample.ts @@ -0,0 +1,144 @@ +/** + * 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 = { + 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