;
+}
+
+// ─── Top-level props ──────────────────────────────────────────────────────────
+// Mirrors SimpleTableProps with Angular-specific overrides.
+// `tableRef` is omitted — consumers use Angular's @ViewChild decorator instead:
+// @ViewChild(SimpleTableComponent) tableRef!: SimpleTableComponent;
+// then: this.tableRef.getAPI()?.sort(...)
+export interface SimpleTableAngularProps
+ extends Omit<
+ SimpleTableProps,
+ | "tableRef"
+ | "allowAnimations"
+ | "expandIcon"
+ | "filterIcon"
+ | "headerCollapseIcon"
+ | "headerExpandIcon"
+ | "nextIcon"
+ | "prevIcon"
+ | "sortDownIcon"
+ | "sortUpIcon"
+ | "columnEditorText"
+ | "defaultHeaders"
+ | "footerRenderer"
+ | "emptyStateRenderer"
+ | "errorStateRenderer"
+ | "loadingStateRenderer"
+ | "tableEmptyStateRenderer"
+ | "headerDropdown"
+ | "columnEditorConfig"
+ > {
+ defaultHeaders: AngularHeaderObject[];
+ footerRenderer?: AngularFooterRenderer;
+ loadingStateRenderer?: AngularLoadingStateRenderer;
+ errorStateRenderer?: AngularErrorStateRenderer;
+ emptyStateRenderer?: AngularEmptyStateRenderer;
+ tableEmptyStateRenderer?: HTMLElement | string | null;
+ headerDropdown?: AngularHeaderDropdown;
+ columnEditorConfig?: AngularColumnEditorConfig;
+ icons?: IconsConfig;
+}
+
+// Re-export vanilla prop types that consumers still need directly
+export type {
+ CellRendererProps,
+ HeaderRendererProps,
+ FooterRendererProps,
+ LoadingStateRendererProps,
+ ErrorStateRendererProps,
+ EmptyStateRendererProps,
+ HeaderDropdownProps,
+ ColumnEditorRowRendererProps,
+};
diff --git a/packages/angular/src/utils/wrapAngularRenderer.ts b/packages/angular/src/utils/wrapAngularRenderer.ts
new file mode 100644
index 000000000..90e2f7923
--- /dev/null
+++ b/packages/angular/src/utils/wrapAngularRenderer.ts
@@ -0,0 +1,46 @@
+import {
+ ApplicationRef,
+ createComponent,
+ EnvironmentInjector,
+ type Type,
+} from "@angular/core";
+
+/**
+ * Wraps an Angular standalone component into a function that returns an
+ * HTMLElement, matching the vanilla renderer contract expected by
+ * simple-table-core.
+ *
+ * Requires references to the running Angular ApplicationRef and
+ * EnvironmentInjector so it can attach the dynamically-created component
+ * to the change detection tree and trigger a synchronous flush before
+ * returning the element to the vanilla rendering pipeline.
+ *
+ * These are injected automatically when the consumer uses
+ * `provideSimpleTable()` in their application providers.
+ */
+export function wrapAngularRenderer(
+ component: Type
,
+ appRef: ApplicationRef,
+ injector: EnvironmentInjector
+): (props: Partial
) => HTMLElement {
+ return (props: Partial
): HTMLElement => {
+ const el = document.createElement("div");
+
+ const componentRef = createComponent(component, {
+ environmentInjector: injector,
+ hostElement: el,
+ });
+
+ // Assign input props to the component instance.
+ Object.assign(componentRef.instance as object, props);
+
+ // Attach to the application's view tree so Angular tracks it.
+ appRef.attachView(componentRef.hostView);
+
+ // Synchronous change detection flush — ensures the rendered output is
+ // in the DOM before we return the element to the vanilla pipeline.
+ componentRef.changeDetectorRef.detectChanges();
+
+ return el;
+ };
+}
diff --git a/packages/angular/tsconfig.build.json b/packages/angular/tsconfig.build.json
new file mode 100644
index 000000000..e5a19f3ea
--- /dev/null
+++ b/packages/angular/tsconfig.build.json
@@ -0,0 +1,6 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "paths": {}
+ }
+}
diff --git a/packages/angular/tsconfig.json b/packages/angular/tsconfig.json
new file mode 100644
index 000000000..8aea34dc0
--- /dev/null
+++ b/packages/angular/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "dom"],
+ "experimentalDecorators": true,
+ "useDefineForClassFields": false,
+ "noEmit": false,
+ "declaration": true,
+ "declarationDir": "dist/types",
+ "outDir": "dist",
+ "baseUrl": ".",
+ "paths": {
+ "simple-table-core": ["../core/src/index.ts"]
+ }
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/core/.storybook/main.ts b/packages/core/.storybook/main.ts
new file mode 100644
index 000000000..41956110b
--- /dev/null
+++ b/packages/core/.storybook/main.ts
@@ -0,0 +1,34 @@
+import type { StorybookConfig } from "@storybook/html-webpack5";
+
+const config: StorybookConfig = {
+ stories: ["../stories/**/*.stories.@(js|ts|mjs)"],
+ addons: [
+ "@storybook/addon-links",
+ "@storybook/addon-essentials",
+ "@storybook/addon-interactions",
+ ],
+ framework: {
+ name: "@storybook/html-webpack5",
+ options: {},
+ },
+ webpackFinal: async (config) => {
+ config.module = config.module || { rules: [] };
+ config.module.rules = config.module.rules || [];
+ config.module.rules.unshift({
+ test: /\.(ts|tsx)$/,
+ use: {
+ loader: "ts-loader",
+ options: { transpileOnly: true },
+ },
+ exclude: /node_modules/,
+ });
+ config.resolve = config.resolve || {};
+ config.resolve.extensions = [
+ ...(config.resolve.extensions || []),
+ ".ts",
+ ".tsx",
+ ].filter((v, i, a) => a.indexOf(v) === i);
+ return config;
+ },
+};
+export default config;
diff --git a/packages/core/.storybook/preview-head.html b/packages/core/.storybook/preview-head.html
new file mode 100644
index 000000000..953746407
--- /dev/null
+++ b/packages/core/.storybook/preview-head.html
@@ -0,0 +1,4 @@
+
diff --git a/packages/core/.storybook/preview.ts b/packages/core/.storybook/preview.ts
new file mode 100644
index 000000000..66b88702b
--- /dev/null
+++ b/packages/core/.storybook/preview.ts
@@ -0,0 +1,23 @@
+import "../src/styles/all-themes.css";
+import type { Preview } from "@storybook/html";
+
+const preview: Preview = {
+ parameters: {
+ layout: "centered",
+ },
+ decorators: [
+ (Story) => {
+ const wrapper = document.createElement("div");
+ wrapper.style.fontFamily = "Nunito, sans-serif";
+ const content = Story();
+ if (content instanceof HTMLElement) {
+ wrapper.appendChild(content);
+ } else if (typeof content === "string") {
+ wrapper.innerHTML = content;
+ }
+ return wrapper;
+ },
+ ],
+};
+
+export default preview;
diff --git a/EULA.txt b/packages/core/EULA.txt
similarity index 100%
rename from EULA.txt
rename to packages/core/EULA.txt
diff --git a/LICENSE b/packages/core/LICENSE
similarity index 100%
rename from LICENSE
rename to packages/core/LICENSE
diff --git a/README.md b/packages/core/README.md
similarity index 100%
rename from README.md
rename to packages/core/README.md
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 000000000..fe2538df5
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,106 @@
+{
+ "name": "simple-table-core",
+ "version": "3.0.0-beta.1",
+ "main": "dist/cjs/index.js",
+ "module": "dist/index.es.js",
+ "types": "dist/src/index.d.ts",
+ "scripts": {
+ "build": "rollup -c",
+ "preview": "rollup -c -w",
+ "start": "storybook dev -p 6006",
+ "build-storybook": "storybook build",
+ "version:patch": "npm version patch && git push && git push --tags",
+ "version:minor": "npm version minor && git push && git push --tags",
+ "version:major": "npm version major && git push && git push --tags",
+ "test-storybook:ci": "test-storybook --url http://localhost:6006"
+ },
+ "sideEffects": [
+ "*.css",
+ "**/*.css"
+ ],
+ "exports": {
+ ".": {
+ "import": "./dist/index.es.js",
+ "require": "./dist/cjs/index.js",
+ "types": "./dist/src/index.d.ts"
+ },
+ "./styles.css": "./dist/styles.css",
+ "./styles/base.css": "./src/styles/base.css",
+ "./styles/themes/*.css": "./src/styles/themes/*.css"
+ },
+ "license": "MIT",
+ "files": [
+ "dist",
+ "src/styles",
+ "LICENSE",
+ "EULA.txt",
+ "README.md"
+ ],
+ "devDependencies": {
+ "@rollup/plugin-node-resolve": "^15.3.0",
+ "@size-limit/preset-small-lib": "^11.2.0",
+ "@storybook/addon-essentials": "^8.6.14",
+ "@storybook/addon-interactions": "^8.6.14",
+ "@storybook/addon-links": "^8.6.14",
+ "@storybook/html": "^8.6.14",
+ "@storybook/html-webpack5": "^8.6.14",
+ "@storybook/test": "^8.6.14",
+ "@storybook/test-runner": "^0.19.1",
+ "@types/node": "^16.18.111",
+ "cssnano": "^7.0.6",
+ "postcss-calc": "^10.1.1",
+ "postcss-custom-properties": "^14.0.4",
+ "postcss-import": "^16.1.0",
+ "postcss-preset-env": "^10.1.5",
+ "rollup": "^2.79.2",
+ "rollup-plugin-delete": "^2.1.0",
+ "rollup-plugin-peer-deps-external": "^2.2.4",
+ "rollup-plugin-postcss": "^4.0.2",
+ "rollup-plugin-terser": "^7.0.2",
+ "rollup-plugin-typescript2": "^0.36.0",
+ "size-limit": "^11.2.0",
+ "storybook": "^8.6.14",
+ "ts-loader": "^9.5.4",
+ "typescript": "^4.9.5"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "size-limit": [
+ {
+ "path": "dist/cjs/index.js"
+ }
+ ],
+ "description": "Simple Table: A lightweight, free framework-agnostic data grid and table component with TypeScript support, sorting, filtering, and virtualization. Works with vanilla JS, React, Vue, Angular, and more.",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/petera2c/simple-table.git"
+ },
+ "bugs": {
+ "url": "https://github.com/petera2c/simple-table/issues"
+ },
+ "homepage": "https://www.simple-table.com",
+ "keywords": [
+ "simple-table",
+ "simple-table-core",
+ "datagrid",
+ "data-grid",
+ "data grid",
+ "datatable",
+ "data-table",
+ "data table",
+ "grid",
+ "table",
+ "spreadsheet",
+ "spreadsheet-table"
+ ]
+}
diff --git a/rollup.config.js b/packages/core/rollup.config.js
similarity index 84%
rename from rollup.config.js
rename to packages/core/rollup.config.js
index f172c4180..ea68cd399 100644
--- a/rollup.config.js
+++ b/packages/core/rollup.config.js
@@ -1,13 +1,11 @@
-import babel from "@rollup/plugin-babel";
import resolve from "@rollup/plugin-node-resolve";
import postcss from "rollup-plugin-postcss";
import typescript from "rollup-plugin-typescript2";
import { terser } from "rollup-plugin-terser";
import del from "rollup-plugin-delete";
-import peerDepsExternal from "rollup-plugin-peer-deps-external";
export default {
- input: "src/index.tsx",
+ input: "src/index.ts",
output: [
{
dir: "dist/cjs",
@@ -28,7 +26,6 @@ export default {
],
plugins: [
del({ targets: "dist/*" }),
- peerDepsExternal(),
postcss({
extract: "styles.css", // All-in-one file for backward compatibility
inject: false,
@@ -65,17 +62,18 @@ export default {
}),
],
}),
- babel({
- exclude: ["node_modules/**", "src/stories/**"],
- presets: ["@babel/preset-react"],
- babelHelpers: "bundled",
- }),
resolve(),
typescript({
- include: ["*.ts", "*.tsx", "**/*.ts", "**/*.tsx"],
- exclude: ["node_modules/**", "src/stories/**"],
+ include: ["*.ts", "**/*.ts"],
+ exclude: ["node_modules/**"],
rollupCommonJSResolveHack: false,
clean: true,
+ tsconfigOverride: {
+ compilerOptions: {
+ declaration: true,
+ declarationDir: "dist",
+ },
+ },
}),
terser({
compress: {
@@ -96,5 +94,5 @@ export default {
},
}),
],
- external: ["react", "react/jsx-runtime"],
+ external: [],
};
diff --git a/src/consts/column-constraints.ts b/packages/core/src/consts/column-constraints.ts
similarity index 100%
rename from src/consts/column-constraints.ts
rename to packages/core/src/consts/column-constraints.ts
diff --git a/src/consts/general-consts.ts b/packages/core/src/consts/general-consts.ts
similarity index 100%
rename from src/consts/general-consts.ts
rename to packages/core/src/consts/general-consts.ts
diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts
new file mode 100644
index 000000000..96b9352e6
--- /dev/null
+++ b/packages/core/src/core/SimpleTableVanilla.ts
@@ -0,0 +1,701 @@
+import { SimpleTableConfig } from "../types/SimpleTableConfig";
+import { TableAPI } from "../types/TableAPI";
+import HeaderObject, { Accessor } from "../types/HeaderObject";
+import Row from "../types/Row";
+import { CustomTheme } from "../types/CustomTheme";
+import RowState from "../types/RowState";
+
+import { AutoScaleManager } from "../managers/AutoScaleManager";
+import { DimensionManager } from "../managers/DimensionManager";
+import { ScrollManager } from "../managers/ScrollManager";
+import { SectionScrollController } from "../managers/SectionScrollController";
+import { SortManager } from "../managers/SortManager";
+import { FilterManager } from "../managers/FilterManager";
+import { SelectionManager } from "../managers/SelectionManager";
+import { RowSelectionManager } from "../managers/RowSelectionManager";
+import WindowResizeManager from "../hooks/windowResize";
+import HandleOutsideClickManager from "../hooks/handleOutsideClick";
+import ScrollbarVisibilityManager from "../hooks/scrollbarVisibility";
+import ExpandedDepthsManager from "../hooks/expandedDepths";
+import AriaAnnouncementManager from "../hooks/ariaAnnouncements";
+
+import { calculateScrollbarWidth } from "../hooks/scrollbarWidth";
+import { generateRowId, rowIdToString } from "../utils/rowUtils";
+import { checkDeprecatedProps } from "../utils/deprecatedPropsWarnings";
+import { deepClone } from "../utils/generalUtils";
+
+import {
+ TableInitializer,
+ ResolvedIcons,
+ MergedColumnEditorConfig,
+} from "./initialization/TableInitializer";
+import { DOMManager } from "./dom/DOMManager";
+import {
+ RenderOrchestrator,
+ RenderContext,
+ RenderState,
+} from "./rendering/RenderOrchestrator";
+import { TableAPIImpl, TableAPIContext } from "./api/TableAPIImpl";
+
+import "../styles/all-themes.css";
+
+export class SimpleTableVanilla {
+ private container: HTMLElement;
+ private config: SimpleTableConfig;
+ private customTheme: CustomTheme;
+ private mergedColumnEditorConfig: MergedColumnEditorConfig;
+ private resolvedIcons: ResolvedIcons;
+
+ private domManager: DOMManager;
+ private renderOrchestrator: RenderOrchestrator;
+
+ private draggedHeaderRef: { current: HeaderObject | null } = {
+ current: null,
+ };
+ private hoveredHeaderRef: { current: HeaderObject | null } = {
+ current: null,
+ };
+
+ private localRows: Row[] = [];
+ private headers: HeaderObject[] = [];
+ private essentialAccessors: Set = new Set();
+ private currentPage: number = 1;
+ private scrollTop: number = 0;
+ private scrollDirection: "up" | "down" | "none" = "none";
+ private isResizing: boolean = false;
+ private isScrolling: boolean = false;
+ /** True when this render is scroll-driven so body can use position-only updates for existing cells. */
+ private _positionOnlyBody: boolean = false;
+ private firstRenderDone: boolean = false;
+ private internalIsLoading: boolean = false;
+ private scrollbarWidth: number = 0;
+ private isMainSectionScrollable: boolean = false;
+ private columnEditorOpen: boolean = false;
+ private collapsedHeaders: Set = new Set();
+ private expandedDepths: Set = new Set();
+ private expandedRows: Map = new Map();
+ private collapsedRows: Map = new Map();
+ private rowStateMap: Map = new Map();
+ private announcement: string = "";
+
+ private cellRegistry: Map = new Map();
+ private headerRegistry: Map = new Map();
+ private rowIndexMap: Map = new Map();
+
+ private autoScaleManager: AutoScaleManager | null = null;
+ private dimensionManager: DimensionManager | null = null;
+ private scrollManager: ScrollManager | null = null;
+ private sectionScrollController: SectionScrollController | null = null;
+ private sortManager: SortManager | null = null;
+ private filterManager: FilterManager | null = null;
+ private selectionManager: SelectionManager | null = null;
+ private rowSelectionManager: RowSelectionManager | null = null;
+ private windowResizeManager: WindowResizeManager | null = null;
+ private handleOutsideClickManager: HandleOutsideClickManager | null = null;
+ private scrollbarVisibilityManager: ScrollbarVisibilityManager | null = null;
+ private expandedDepthsManager: ExpandedDepthsManager | null = null;
+ private ariaAnnouncementManager: AriaAnnouncementManager | null = null;
+
+ private mounted: boolean = false;
+ private scrollRafId: number | null = null;
+ private scrollEndTimeoutId: number | null = null;
+ private lastScrollTop: number = 0;
+ private isUpdating: boolean = false;
+
+ constructor(container: HTMLElement, config: SimpleTableConfig) {
+ this.container = container;
+ this.config = config;
+
+ checkDeprecatedProps(config);
+
+ this.customTheme = TableInitializer.mergeCustomTheme(config);
+ this.mergedColumnEditorConfig =
+ TableInitializer.mergeColumnEditorConfig(config);
+ this.resolvedIcons = TableInitializer.resolveIcons(config);
+
+ this.localRows = [...config.rows];
+ this.headers = [...config.defaultHeaders];
+ this.essentialAccessors = TableInitializer.buildEssentialAccessors(this.headers);
+ this.columnEditorOpen = config.editColumnsInitOpen ?? false;
+ this.internalIsLoading = config.isLoading ?? false;
+
+ this.collapsedHeaders = TableInitializer.getInitialCollapsedHeaders(
+ config.defaultHeaders,
+ );
+ this.expandedDepths = TableInitializer.getInitialExpandedDepths(config);
+
+ this.domManager = new DOMManager();
+ this.renderOrchestrator = new RenderOrchestrator();
+
+ this.rebuildRowIndexMap();
+ this.initializeManagers();
+ }
+
+ private rebuildRowIndexMap(): void {
+ this.rowIndexMap.clear();
+ this.localRows.forEach((row, index) => {
+ const rowIdArray = generateRowId({
+ row,
+ getRowId: this.config.getRowId,
+ depth: 0,
+ index,
+ rowPath: [index],
+ rowIndexPath: [index],
+ });
+ const rowIdKey = rowIdToString(rowIdArray);
+ this.rowIndexMap.set(rowIdKey, index);
+ });
+ }
+
+ private initializeManagers(): void {
+ this.ariaAnnouncementManager = new AriaAnnouncementManager();
+ this.ariaAnnouncementManager.subscribe((message) => {
+ this.announcement = message;
+ this.updateAriaLiveRegion();
+ });
+
+ this.expandedDepthsManager = new ExpandedDepthsManager(
+ this.config.expandAll ?? true,
+ this.config.rowGrouping,
+ );
+ this.expandedDepthsManager.subscribe((depths) => {
+ this.expandedDepths = depths;
+ this.render("expandedDepthsManager");
+ });
+
+ const announce = (message: string) => {
+ if (this.ariaAnnouncementManager) {
+ this.ariaAnnouncementManager.announce(message);
+ }
+ };
+
+ this.sortManager = new SortManager({
+ headers: this.headers,
+ tableRows: this.localRows,
+ externalSortHandling: this.config.externalSortHandling || false,
+ onSortChange: this.config.onSortChange,
+ rowGrouping: this.config.rowGrouping,
+ initialSortColumn: this.config.initialSortColumn,
+ initialSortDirection: this.config.initialSortDirection,
+ announce,
+ });
+
+ this.sortManager.subscribe((state) => {
+ this.render("sortManager");
+ });
+
+ this.filterManager = new FilterManager({
+ rows: this.localRows,
+ headers: this.headers,
+ externalFilterHandling: this.config.externalFilterHandling || false,
+ onFilterChange: this.config.onFilterChange,
+ announce,
+ });
+
+ this.filterManager.subscribe((filterState) => {
+ if (this.sortManager) {
+ this.sortManager.updateConfig({ tableRows: filterState.filteredRows });
+ }
+ this.render("filterManager");
+ });
+
+ // Initialize SelectionManager with empty tableRows (will be updated during render)
+ this.selectionManager = new SelectionManager({
+ selectableCells: this.config.selectableCells ?? false,
+ headers: this.headers,
+ tableRows: [],
+ onCellEdit: this.config.onCellEdit,
+ cellRegistry: this.cellRegistry,
+ collapsedHeaders: this.collapsedHeaders,
+ rowHeight: this.customTheme.rowHeight,
+ enableRowSelection: this.config.enableRowSelection,
+ copyHeadersToClipboard: this.config.copyHeadersToClipboard,
+ customTheme: this.customTheme,
+ tableRoot: this.container,
+ onSelectionDragEnd: () => {
+ this.renderOrchestrator.invalidateCache("context");
+ this.renderOrchestrator.invalidateCache("body");
+ this.render("selectionDragEnd");
+ },
+ });
+ }
+
+ mount(): void {
+ if (this.mounted) {
+ console.warn("SimpleTableVanilla: Table is already mounted");
+ return;
+ }
+
+ this.domManager.createDOMStructure(this.container, this.config);
+ this.mounted = true;
+ this.setupManagers();
+ }
+
+ private setupManagers(): void {
+ const refs = this.domManager.getRefs();
+ const elements = this.domManager.getElements();
+
+ if (!refs.tableBodyContainerRef.current || !elements) return;
+
+ this.scrollbarWidth = calculateScrollbarWidth(
+ refs.tableBodyContainerRef.current,
+ );
+
+ const effectiveHeaders = this.renderOrchestrator.computeEffectiveHeaders(
+ this.headers,
+ this.config,
+ this.customTheme,
+ );
+
+ this.dimensionManager = new DimensionManager({
+ effectiveHeaders,
+ headerHeight: this.customTheme.headerHeight,
+ rowHeight: this.customTheme.rowHeight,
+ height: this.config.height,
+ maxHeight: this.config.maxHeight,
+ totalRowCount: this.localRows.length,
+ footerHeight:
+ (this.config.shouldPaginate || this.config.footerRenderer) && !this.config.hideFooter
+ ? this.customTheme.footerHeight
+ : undefined,
+ containerElement: refs.tableBodyContainerRef.current,
+ });
+
+ this.dimensionManager.subscribe(() => {
+ this.render("dimensionManager");
+ if (!this.firstRenderDone) {
+ this.firstRenderDone = true;
+ if (this.config.onGridReady) {
+ this.config.onGridReady();
+ }
+ }
+ });
+
+ this.scrollManager = new ScrollManager({
+ onLoadMore: this.config.onLoadMore,
+ infiniteScrollThreshold: 200,
+ });
+
+ this.scrollManager.subscribe(() => {
+ this.render("scrollManager");
+ });
+
+ this.sectionScrollController = new SectionScrollController({
+ onMainSectionScrollLeft: (scrollLeft) => {
+ const refs = this.domManager.getRefs();
+ const header = refs.mainHeaderRef.current;
+ const body = refs.mainBodyRef.current;
+ (header as any)?.__renderHeaderCells?.(scrollLeft);
+ (body as any)?.__renderBodyCells?.(scrollLeft);
+ },
+ });
+
+ if (this.config.autoExpandColumns) {
+ this.autoScaleManager = new AutoScaleManager(
+ {
+ autoExpandColumns: this.config.autoExpandColumns,
+ containerWidth: this.dimensionManager.getState().containerWidth,
+ pinnedLeftWidth: 0,
+ pinnedRightWidth: 0,
+ mainBodyRef: refs.mainBodyRef,
+ isResizing: this.isResizing,
+ },
+ () => {
+ this.render("autoScaleManager");
+ },
+ );
+ }
+
+ if (refs.headerContainerRef.current && refs.tableBodyContainerRef.current) {
+ this.scrollbarVisibilityManager = new ScrollbarVisibilityManager({
+ headerContainer: refs.headerContainerRef.current,
+ mainSection: refs.tableBodyContainerRef.current,
+ scrollbarWidth: this.scrollbarWidth,
+ });
+
+ this.scrollbarVisibilityManager.subscribe((isScrollable) => {
+ this.isMainSectionScrollable = isScrollable;
+ this.render("scrollbarVisibilityManager");
+ });
+ }
+
+ this.windowResizeManager = new WindowResizeManager();
+ this.windowResizeManager.addCallback(() => {
+ if (refs.tableBodyContainerRef.current) {
+ const newScrollbarWidth = calculateScrollbarWidth(
+ refs.tableBodyContainerRef.current,
+ );
+ this.scrollbarWidth = newScrollbarWidth;
+ this.scrollbarVisibilityManager?.setScrollbarWidth(newScrollbarWidth);
+ }
+ this.render("scrollbarWidth-change");
+ });
+
+ if (this.config.enableRowSelection) {
+ this.rowSelectionManager = new RowSelectionManager({
+ tableRows: [],
+ onRowSelectionChange: this.config.onRowSelectionChange,
+ enableRowSelection: true,
+ });
+ this.rowSelectionManager.subscribe(() => {
+ this.render("rowSelectionManager");
+ });
+ }
+
+ if (this.selectionManager) {
+ this.handleOutsideClickManager = new HandleOutsideClickManager({
+ selectableColumns: this.config.selectableColumns ?? false,
+ selectedCells: new Set(),
+ selectedColumns: new Set(),
+ setSelectedCells: (cells) =>
+ this.selectionManager!.setSelectedCells(cells),
+ setSelectedColumns: (columns) =>
+ this.selectionManager!.setSelectedColumns(columns),
+ getSelectedCells: () => this.selectionManager!.getSelectedCells(),
+ getSelectedColumns: () => this.selectionManager!.getSelectedColumns(),
+ onClearSelection: () => this.selectionManager!.clearSelection(),
+ });
+ this.handleOutsideClickManager.startListening();
+ }
+
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ const elements = this.domManager.getElements();
+ if (!elements?.bodyContainer) return;
+
+ elements.bodyContainer.addEventListener(
+ "scroll",
+ this.handleScroll.bind(this),
+ );
+ elements.bodyContainer.addEventListener("mouseleave", () => {
+ this.clearHoveredRows();
+ });
+ }
+
+ private handleScroll(e: Event): void {
+ const element = e.currentTarget as HTMLDivElement;
+ const newScrollTop = element.scrollTop;
+
+ // Set scrolling state immediately
+ this.isScrolling = true;
+
+ // Clear previous scroll end timeout
+ if (this.scrollEndTimeoutId !== null) {
+ clearTimeout(this.scrollEndTimeoutId);
+ }
+
+ // Set up timeout to detect when scrolling ends; run one full render so selection/content are correct
+ this.scrollEndTimeoutId = window.setTimeout(() => {
+ this.isScrolling = false;
+ this.scrollEndTimeoutId = null;
+ this.render("scroll-end");
+ }, 150);
+
+ // Cancel any pending RAF
+ if (this.scrollRafId !== null) {
+ cancelAnimationFrame(this.scrollRafId);
+ }
+
+ // Use RAF to throttle scroll updates
+ this.scrollRafId = requestAnimationFrame(() => {
+ // Calculate scroll direction
+ const direction: "up" | "down" | "none" =
+ newScrollTop > this.lastScrollTop
+ ? "down"
+ : newScrollTop < this.lastScrollTop
+ ? "up"
+ : "none";
+
+ // Update state
+ this.scrollTop = newScrollTop;
+ this.scrollDirection = direction;
+ this.lastScrollTop = newScrollTop;
+
+ // Use scroll manager if available
+ if (this.scrollManager) {
+ const containerHeight = element.clientHeight;
+ const contentHeight = element.scrollHeight;
+ this.scrollManager.handleScroll(
+ newScrollTop,
+ element.scrollLeft,
+ containerHeight,
+ contentHeight,
+ );
+ }
+
+ // Trigger re-render for virtualization
+ this.render("scroll-raf");
+
+ this.scrollRafId = null;
+ });
+ }
+
+ private clearHoveredRows(): void {
+ document.querySelectorAll(".st-row.hovered").forEach((el) => {
+ el.classList.remove("hovered");
+ });
+ }
+
+ private updateAriaLiveRegion(): void {
+ const elements = this.domManager.getElements();
+ if (elements?.ariaLiveRegion) {
+ elements.ariaLiveRegion.textContent = this.announcement;
+ }
+ }
+
+ private getRenderContext(): RenderContext {
+ const refs = this.domManager.getRefs();
+ return {
+ config: this.config,
+ customTheme: this.customTheme,
+ resolvedIcons: this.resolvedIcons,
+ effectiveHeaders: [],
+ essentialAccessors: this.essentialAccessors,
+ headers: this.headers,
+ localRows: this.localRows,
+ collapsedHeaders: this.collapsedHeaders,
+ collapsedRows: this.collapsedRows,
+ expandedRows: this.expandedRows,
+ expandedDepths: this.expandedDepths,
+ isResizing: this.isResizing,
+ internalIsLoading: this.internalIsLoading,
+ cellRegistry: this.cellRegistry,
+ headerRegistry: this.headerRegistry,
+ draggedHeaderRef: this.draggedHeaderRef,
+ hoveredHeaderRef: this.hoveredHeaderRef,
+ mainBodyRef: refs.mainBodyRef,
+ pinnedLeftRef: refs.pinnedLeftRef,
+ pinnedRightRef: refs.pinnedRightRef,
+ mainHeaderRef: refs.mainHeaderRef,
+ pinnedLeftHeaderRef: refs.pinnedLeftHeaderRef,
+ pinnedRightHeaderRef: refs.pinnedRightHeaderRef,
+ dimensionManager: this.dimensionManager,
+ scrollManager: this.scrollManager,
+ sectionScrollController: this.sectionScrollController,
+ sortManager: this.sortManager,
+ filterManager: this.filterManager,
+ selectionManager: this.selectionManager,
+ rowSelectionManager: this.rowSelectionManager,
+ rowStateMap: this.rowStateMap,
+ positionOnlyBody: this._positionOnlyBody,
+ onRender: () => this.render("resizeHandler-onRender"),
+ setIsResizing: (value: boolean) => {
+ this.isResizing = value;
+ if (this.autoScaleManager && value === false) {
+ const refs = this.domManager.getRefs();
+ const containerWidth =
+ refs.tableBodyContainerRef?.current?.clientWidth ??
+ refs.mainBodyRef?.current?.clientWidth ??
+ this.dimensionManager?.getState().containerWidth ??
+ 0;
+ this.autoScaleManager.updateConfig({
+ isResizing: false,
+ containerWidth,
+ });
+ }
+ },
+ setHeaders: (headers: HeaderObject[]) => {
+ this.headers = deepClone(headers);
+ this.renderOrchestrator.invalidateCache("header");
+ },
+ setCollapsedHeaders: (headers: Set) => {
+ this.collapsedHeaders = headers;
+ },
+ setCollapsedRows: (rowsOrUpdater: Map | ((prev: Map) => Map)) => {
+ this.collapsedRows = typeof rowsOrUpdater === "function" ? rowsOrUpdater(this.collapsedRows) : rowsOrUpdater;
+ this.render("expansion");
+ },
+ setExpandedRows: (rowsOrUpdater: Map | ((prev: Map) => Map)) => {
+ this.expandedRows = typeof rowsOrUpdater === "function" ? rowsOrUpdater(this.expandedRows) : rowsOrUpdater;
+ this.render("expansion");
+ },
+ setRowStateMap: (mapOrUpdater: Map | ((prev: Map) => Map)) => {
+ this.rowStateMap = typeof mapOrUpdater === "function" ? mapOrUpdater(this.rowStateMap) : mapOrUpdater;
+ this.render("rowStateMap");
+ },
+ getCollapsedRows: () => this.collapsedRows,
+ getCollapsedHeaders: () => this.collapsedHeaders,
+ getExpandedRows: () => this.expandedRows,
+ getRowStateMap: () => this.rowStateMap,
+ setColumnEditorOpen: (open: boolean) => {
+ this.columnEditorOpen = open;
+ },
+ setCurrentPage: (page: number) => {
+ this.currentPage = page;
+ },
+ };
+ }
+
+ private getRenderState(): RenderState {
+ return {
+ currentPage: this.currentPage,
+ scrollTop: this.scrollTop,
+ scrollDirection: this.scrollDirection,
+ scrollbarWidth: this.scrollbarWidth,
+ isMainSectionScrollable: this.isMainSectionScrollable,
+ columnEditorOpen: this.columnEditorOpen,
+ };
+ }
+
+ private render(source?: string): void {
+ if (!this.mounted) return;
+
+ // Skip renders triggered by manager updates during an update() call
+ // The update() method will call render at the end
+ if (this.isUpdating && source !== "update") {
+ return;
+ }
+
+ // During scroll use position-only body updates; full update on scroll-end or other triggers
+ this._positionOnlyBody =
+ source === "scroll-raf" && this.isScrolling === true;
+
+ const elements = this.domManager.getElements();
+ const refs = this.domManager.getRefs();
+
+ if (!elements) return;
+
+ this.renderOrchestrator.render(
+ elements,
+ refs,
+ this.getRenderContext(),
+ this.getRenderState(),
+ this.mergedColumnEditorConfig,
+ );
+ }
+
+ update(config: Partial): void {
+ this.isUpdating = true;
+ this.config = { ...this.config, ...config };
+
+ if (config.rows !== undefined) {
+ this.localRows = [...config.rows];
+ this.rebuildRowIndexMap();
+
+ if (this.filterManager) {
+ this.filterManager.updateConfig({ rows: this.localRows });
+ }
+ if (this.sortManager) {
+ this.sortManager.updateConfig({ tableRows: this.localRows });
+ }
+ // SelectionManager will be updated with processed rows during render
+ }
+
+ if (config.defaultHeaders !== undefined) {
+ this.headers = [...config.defaultHeaders];
+ this.essentialAccessors = TableInitializer.buildEssentialAccessors(this.headers);
+
+ if (this.filterManager) {
+ this.filterManager.updateConfig({ headers: this.headers });
+ }
+ if (this.sortManager) {
+ this.sortManager.updateConfig({ headers: this.headers });
+ }
+ if (this.selectionManager) {
+ this.selectionManager.updateConfig({ headers: this.headers });
+ }
+ }
+
+ if (config.isLoading !== undefined) {
+ this.internalIsLoading = config.isLoading;
+ }
+
+ if (config.theme !== undefined) {
+ this.domManager.updateTheme(config.theme);
+ }
+
+ if (config.customTheme !== undefined) {
+ this.customTheme = TableInitializer.mergeCustomTheme(this.config);
+ if (this.selectionManager) {
+ this.selectionManager.updateConfig({ customTheme: this.customTheme });
+ }
+ }
+
+ this.isUpdating = false;
+ this.render("update");
+ }
+
+ destroy(): void {
+ this.mounted = false;
+ this.firstRenderDone = false;
+
+ // Clean up RAF and timeouts
+ if (this.scrollRafId !== null) {
+ cancelAnimationFrame(this.scrollRafId);
+ this.scrollRafId = null;
+ }
+ if (this.scrollEndTimeoutId !== null) {
+ clearTimeout(this.scrollEndTimeoutId);
+ this.scrollEndTimeoutId = null;
+ }
+
+ this.dimensionManager?.destroy();
+ this.scrollManager?.destroy();
+ this.sectionScrollController?.destroy();
+ this.sortManager?.destroy();
+ this.filterManager?.destroy();
+ this.rowSelectionManager?.destroy();
+ this.selectionManager?.destroy();
+ this.autoScaleManager?.destroy();
+ this.windowResizeManager?.destroy();
+ this.handleOutsideClickManager?.destroy();
+ this.scrollbarVisibilityManager?.destroy();
+ this.expandedDepthsManager?.destroy();
+ this.ariaAnnouncementManager?.destroy();
+
+ this.renderOrchestrator.cleanup();
+ this.domManager.destroy(this.container);
+ }
+
+ getAPI(): TableAPI {
+ const effectiveHeaders = this.renderOrchestrator.computeEffectiveHeaders(
+ this.headers,
+ this.config,
+ this.customTheme,
+ );
+
+ // Use `thiz` so that getter properties can read live instance state rather
+ // than a snapshot captured at getAPI() call time.
+ const thiz = this;
+ const context: TableAPIContext = {
+ config: this.config,
+ localRows: this.localRows,
+ effectiveHeaders,
+ get headers() { return thiz.headers; },
+ essentialAccessors: this.essentialAccessors,
+ customTheme: this.customTheme,
+ currentPage: this.currentPage,
+ getCurrentPage: () => this.currentPage,
+ expandedRows: this.expandedRows,
+ collapsedRows: this.collapsedRows,
+ expandedDepths: this.expandedDepths,
+ rowStateMap: this.rowStateMap,
+ headerRegistry: this.headerRegistry,
+ cellRegistry: this.cellRegistry,
+ columnEditorOpen: this.columnEditorOpen,
+ getCachedFlattenResult: () => this.renderOrchestrator.getCachedFlattenResult(),
+ getCachedProcessedResult: () => this.renderOrchestrator.getLastProcessedResult(),
+ expandedDepthsManager: this.expandedDepthsManager,
+ selectionManager: this.selectionManager,
+ sortManager: this.sortManager,
+ filterManager: this.filterManager,
+ onRender: () => this.render("columnEditor-onRender"),
+ setHeaders: (headers: HeaderObject[]) => {
+ this.headers = deepClone(headers);
+ this.renderOrchestrator.invalidateCache("header");
+ },
+ setCurrentPage: (page: number) => {
+ this.currentPage = page;
+ },
+ setColumnEditorOpen: (open: boolean) => {
+ this.columnEditorOpen = open;
+
+ this.render("columnEditor-toggle");
+ },
+ };
+
+ return TableAPIImpl.createAPI(context);
+ }
+}
diff --git a/packages/core/src/core/api/TableAPIImpl.ts b/packages/core/src/core/api/TableAPIImpl.ts
new file mode 100644
index 000000000..f4b9514f9
--- /dev/null
+++ b/packages/core/src/core/api/TableAPIImpl.ts
@@ -0,0 +1,304 @@
+import { TableAPI } from "../../types/TableAPI";
+import { SimpleTableConfig } from "../../types/SimpleTableConfig";
+import HeaderObject, { Accessor } from "../../types/HeaderObject";
+import Row from "../../types/Row";
+import TableRow from "../../types/TableRow";
+import SortColumn, { SortDirection } from "../../types/SortColumn";
+import { FilterCondition, TableFilterState } from "../../types/FilterTypes";
+import { CustomTheme } from "../../types/CustomTheme";
+import UpdateDataProps from "../../types/UpdateCellProps";
+import { SetHeaderRenameProps, ExportToCSVProps } from "../../types/TableAPI";
+import RowState from "../../types/RowState";
+import Cell from "../../types/Cell";
+import { SelectionManager } from "../../managers/SelectionManager";
+import { SortManager } from "../../managers/SortManager";
+import { FilterManager } from "../../managers/FilterManager";
+import { flattenRows, FlattenRowsResult } from "../../utils/rowFlattening";
+import { ProcessRowsResult } from "../../utils/rowProcessing";
+import { exportTableToCSV } from "../../utils/csvExportUtils";
+import {
+ getPinnedSectionsState,
+ isHeaderEssential,
+ rebuildHeadersFromPinnedState,
+} from "../../utils/pinnedColumnUtils";
+import { PinnedSectionsState } from "../../types/PinnedSectionsState";
+import { deepClone } from "../../utils/generalUtils";
+
+export interface TableAPIContext {
+ config: SimpleTableConfig;
+ localRows: Row[];
+ effectiveHeaders: HeaderObject[];
+ headers: HeaderObject[];
+ essentialAccessors: Set;
+ customTheme: CustomTheme;
+ currentPage: number;
+ /** Returns current page from live state (use this in API getCurrentPage so it stays correct after setPage). */
+ getCurrentPage: () => number;
+ expandedRows: Map;
+ collapsedRows: Map;
+ expandedDepths: Set;
+ rowStateMap: Map;
+ headerRegistry: Map;
+ cellRegistry?: Map void }>;
+ columnEditorOpen: boolean;
+ expandedDepthsManager: any;
+ selectionManager: SelectionManager | null;
+ sortManager: SortManager | null;
+ filterManager: FilterManager | null;
+ getCachedFlattenResult?: () => FlattenRowsResult | null;
+ getCachedProcessedResult?: () => ProcessRowsResult | null;
+ onRender: () => void;
+ setHeaders: (headers: HeaderObject[]) => void;
+ setCurrentPage: (page: number) => void;
+ setColumnEditorOpen: (open: boolean) => void;
+}
+
+export class TableAPIImpl {
+ static createAPI(context: TableAPIContext): TableAPI {
+ const getFlattenResult = (): FlattenRowsResult => {
+ const cached = context.getCachedFlattenResult?.();
+ if (cached) return cached;
+ return flattenRows({
+ rows: context.localRows,
+ rowGrouping: context.config.rowGrouping,
+ getRowId: context.config.getRowId,
+ expandedRows: context.expandedRows,
+ collapsedRows: context.collapsedRows,
+ expandedDepths: context.expandedDepths,
+ rowStateMap: context.rowStateMap,
+ hasLoadingRenderer: Boolean(context.config.loadingStateRenderer),
+ hasErrorRenderer: Boolean(context.config.errorStateRenderer),
+ hasEmptyRenderer: Boolean(context.config.emptyStateRenderer),
+ headers: context.effectiveHeaders,
+ rowHeight: context.customTheme.rowHeight,
+ headerHeight: context.customTheme.headerHeight,
+ customTheme: context.customTheme,
+ });
+ };
+
+ return {
+ updateData: (props: UpdateDataProps) => {
+ const { rowIndex, accessor, newValue } = props;
+ if (rowIndex >= 0 && rowIndex < context.localRows.length) {
+ const row = context.localRows[rowIndex] as any;
+ row[accessor] = newValue;
+ const rowPath = [rowIndex];
+ const rowIdArray: (string | number)[] = context.config.getRowId
+ ? [
+ rowIndex,
+ context.config.getRowId({
+ row: context.localRows[rowIndex],
+ depth: 0,
+ index: rowIndex,
+ rowPath,
+ rowIndexPath: rowPath,
+ }),
+ ]
+ : [rowIndex];
+ const key = `${rowIdArray.join("-")}-${accessor}`;
+ const entry = context.cellRegistry?.get(key);
+ if (entry) {
+ entry.updateContent(newValue);
+ } else {
+ context.onRender();
+ }
+ }
+ },
+
+ setHeaderRename: (props: SetHeaderRenameProps) => {
+ const headerRegistry = context.headerRegistry.get(props.accessor as string);
+ if (headerRegistry) {
+ headerRegistry.setEditing(true);
+ }
+ },
+
+ getVisibleRows: (): TableRow[] => {
+ const processed = context.getCachedProcessedResult?.();
+ if (processed) return processed.rowsToRender;
+ return getFlattenResult().flattenedRows;
+ },
+
+ getAllRows: (): TableRow[] => {
+ return getFlattenResult().flattenedRows;
+ },
+
+ getHeaders: (): HeaderObject[] => {
+ return context.effectiveHeaders;
+ },
+
+ exportToCSV: (props?: ExportToCSVProps) => {
+ exportTableToCSV(
+ getFlattenResult().flattenedRows,
+ context.effectiveHeaders,
+ props?.filename,
+ context.config.includeHeadersInCSVExport ?? true,
+ );
+ },
+
+ getSortState: (): SortColumn | null => {
+ return context.sortManager?.getSortColumn() ?? null;
+ },
+
+ applySortState: async (props?: { accessor: Accessor; direction?: SortDirection }) => {
+ if (context.sortManager) {
+ context.sortManager.updateSort(props);
+ }
+ },
+
+ getPinnedState: (): PinnedSectionsState => {
+ return getPinnedSectionsState(context.headers);
+ },
+
+ applyPinnedState: async (state: PinnedSectionsState) => {
+ const updated = rebuildHeadersFromPinnedState(
+ context.headers,
+ state,
+ context.essentialAccessors,
+ );
+ if (updated) {
+ context.setHeaders(updated);
+ context.onRender();
+ }
+ },
+
+ resetColumns: () => {
+ context.setHeaders(deepClone(context.config.defaultHeaders));
+ context.onRender();
+ },
+
+ getFilterState: (): TableFilterState => {
+ return context.filterManager?.getFilters() ?? {};
+ },
+
+ applyFilter: async (filter: FilterCondition) => {
+ if (context.filterManager) {
+ context.filterManager.updateFilter(filter);
+ }
+ },
+
+ clearFilter: async (accessor: Accessor) => {
+ if (context.filterManager) {
+ context.filterManager.clearFilter(accessor);
+ }
+ },
+
+ clearAllFilters: async () => {
+ if (context.filterManager) {
+ context.filterManager.clearAllFilters();
+ }
+ },
+
+ getCurrentPage: (): number => {
+ return context.getCurrentPage();
+ },
+
+ getTotalPages: (): number => {
+ const totalRows = context.config.totalRowCount ?? getFlattenResult().paginatableRows.length;
+ return Math.ceil(totalRows / (context.config.rowsPerPage ?? 10));
+ },
+
+ setPage: async (page: number) => {
+ const totalRows = context.config.totalRowCount ?? getFlattenResult().paginatableRows.length;
+ const rowsPerPage = context.config.rowsPerPage ?? 10;
+ const totalPages = Math.ceil(totalRows / rowsPerPage);
+ if (page < 1 || page > totalPages) return;
+ context.setCurrentPage(page);
+ context.onRender();
+ if (context.config.onPageChange) {
+ await context.config.onPageChange(page);
+ }
+ },
+
+ expandAll: () => {
+ context.expandedDepthsManager?.expandAll();
+ },
+
+ collapseAll: () => {
+ context.expandedDepthsManager?.collapseAll();
+ },
+
+ expandDepth: (depth: number) => {
+ context.expandedDepthsManager?.expandDepth(depth);
+ },
+
+ collapseDepth: (depth: number) => {
+ context.expandedDepthsManager?.collapseDepth(depth);
+ },
+
+ toggleDepth: (depth: number) => {
+ context.expandedDepthsManager?.toggleDepth(depth);
+ },
+
+ setExpandedDepths: (depths: Set) => {
+ context.expandedDepths = depths;
+ context.onRender();
+ },
+
+ getExpandedDepths: (): Set => {
+ return context.expandedDepthsManager?.getExpandedDepths() ?? context.expandedDepths;
+ },
+
+ getGroupingProperty: (depth: number): Accessor | undefined => {
+ return context.config.rowGrouping?.[depth];
+ },
+
+ getGroupingDepth: (property: Accessor): number => {
+ return context.config.rowGrouping?.indexOf(property) ?? -1;
+ },
+
+ toggleColumnEditor: (open?: boolean) => {
+ if (!context.config.editColumns) return;
+ context.setColumnEditorOpen(open !== undefined ? open : !context.columnEditorOpen);
+ context.onRender();
+ },
+
+ applyColumnVisibility: async (visibility: { [accessor: string]: boolean }) => {
+ const updateHeaderVisibility = (headerList: HeaderObject[]): HeaderObject[] => {
+ return headerList.map((header) => {
+ const acc = String(header.accessor);
+ const shouldUpdate = acc in visibility;
+ let hide = shouldUpdate ? !visibility[acc] : header.hide;
+ if (isHeaderEssential(header, context.essentialAccessors)) {
+ hide = false;
+ }
+ return {
+ ...header,
+ hide,
+ children: header.children
+ ? updateHeaderVisibility(header.children)
+ : header.children,
+ };
+ });
+ };
+
+ context.setHeaders(updateHeaderVisibility(context.headers));
+ context.onRender();
+ if (context.config.onColumnVisibilityChange) {
+ context.config.onColumnVisibilityChange(visibility);
+ }
+ },
+
+ setQuickFilter: (text: string) => {
+ if (context.config.quickFilter?.onChange) {
+ context.config.quickFilter.onChange(text);
+ }
+ },
+
+ getSelectedCells: (): Set => {
+ return context.selectionManager?.getSelectedCells() || new Set();
+ },
+
+ clearSelection: () => {
+ context.selectionManager?.clearSelection();
+ },
+
+ selectCell: (cell: Cell) => {
+ context.selectionManager?.selectSingleCell(cell);
+ },
+
+ selectCellRange: (startCell: Cell, endCell: Cell) => {
+ context.selectionManager?.selectCellRange(startCell, endCell);
+ },
+ };
+ }
+}
diff --git a/packages/core/src/core/dom/DOMManager.ts b/packages/core/src/core/dom/DOMManager.ts
new file mode 100644
index 000000000..b28999e14
--- /dev/null
+++ b/packages/core/src/core/dom/DOMManager.ts
@@ -0,0 +1,128 @@
+import { SimpleTableConfig } from "../../types/SimpleTableConfig";
+
+export interface DOMElements {
+ rootElement: HTMLElement;
+ wrapperContainer: HTMLElement;
+ contentWrapper: HTMLElement;
+ content: HTMLElement;
+ headerContainer: HTMLElement;
+ bodyContainer: HTMLElement;
+ footerContainer: HTMLElement;
+ ariaLiveRegion: HTMLElement;
+}
+
+export interface DOMRefs {
+ mainBodyRef: { current: HTMLDivElement | null };
+ pinnedLeftRef: { current: HTMLDivElement | null };
+ pinnedRightRef: { current: HTMLDivElement | null };
+ mainHeaderRef: { current: HTMLDivElement | null };
+ pinnedLeftHeaderRef: { current: HTMLDivElement | null };
+ pinnedRightHeaderRef: { current: HTMLDivElement | null };
+ headerContainerRef: { current: HTMLDivElement | null };
+ tableBodyContainerRef: { current: HTMLDivElement | null };
+ horizontalScrollbarRef: { current: HTMLElement | null };
+}
+
+export class DOMManager {
+ private elements: DOMElements | null = null;
+ private refs: DOMRefs;
+
+ constructor() {
+ this.refs = {
+ mainBodyRef: { current: null },
+ pinnedLeftRef: { current: null },
+ pinnedRightRef: { current: null },
+ mainHeaderRef: { current: null },
+ pinnedLeftHeaderRef: { current: null },
+ pinnedRightHeaderRef: { current: null },
+ headerContainerRef: { current: null },
+ tableBodyContainerRef: { current: null },
+ horizontalScrollbarRef: { current: null },
+ };
+ }
+
+ createDOMStructure(container: HTMLElement, config: SimpleTableConfig): DOMElements {
+ const theme = config.theme ?? "modern-light";
+ const className = config.className ?? "";
+ const columnBorders = config.columnBorders ?? false;
+
+ const rootElement = document.createElement("div");
+ rootElement.className = `simple-table-root st-wrapper theme-${theme} ${className} ${
+ columnBorders ? "st-column-borders" : ""
+ }`;
+ rootElement.setAttribute("role", "grid");
+
+ const wrapperContainer = document.createElement("div");
+ wrapperContainer.className = "st-wrapper-container";
+
+ const contentWrapper = document.createElement("div");
+ contentWrapper.className = "st-content-wrapper";
+
+ const content = document.createElement("div");
+ content.className = "st-content";
+
+ const headerContainer = document.createElement("div");
+ headerContainer.className = "st-header-container";
+ this.refs.headerContainerRef.current = headerContainer as HTMLDivElement;
+
+ const bodyContainer = document.createElement("div");
+ bodyContainer.className = "st-body-container";
+ this.refs.tableBodyContainerRef.current = bodyContainer as HTMLDivElement;
+
+ const footerContainer = document.createElement("div");
+ footerContainer.id = "st-footer-container";
+
+ const ariaLiveRegion = document.createElement("div");
+ ariaLiveRegion.setAttribute("aria-live", "polite");
+ ariaLiveRegion.setAttribute("aria-atomic", "true");
+ ariaLiveRegion.className = "st-sr-only";
+
+ content.appendChild(headerContainer);
+ content.appendChild(bodyContainer);
+
+ contentWrapper.appendChild(content);
+
+ wrapperContainer.appendChild(contentWrapper);
+ wrapperContainer.appendChild(footerContainer);
+
+ rootElement.appendChild(wrapperContainer);
+ rootElement.appendChild(ariaLiveRegion);
+
+ container.appendChild(rootElement);
+
+ this.elements = {
+ rootElement,
+ wrapperContainer,
+ contentWrapper,
+ content,
+ headerContainer,
+ bodyContainer,
+ footerContainer,
+ ariaLiveRegion,
+ };
+
+ return this.elements;
+ }
+
+ updateTheme(theme: string): void {
+ if (!this.elements) return;
+ const root = this.elements.rootElement;
+ const classes = root.className.replace(/\btheme-\S+/g, "").trim();
+ root.className = `${classes} theme-${theme}`;
+ }
+
+ getElements(): DOMElements | null {
+ return this.elements;
+ }
+
+ getRefs(): DOMRefs {
+ return this.refs;
+ }
+
+ destroy(container: HTMLElement): void {
+ if (this.elements?.rootElement && container.contains(this.elements.rootElement)) {
+ container.removeChild(this.elements.rootElement);
+ }
+ this.elements = null;
+ }
+}
diff --git a/packages/core/src/core/initialization/TableInitializer.ts b/packages/core/src/core/initialization/TableInitializer.ts
new file mode 100644
index 000000000..b4c99c1da
--- /dev/null
+++ b/packages/core/src/core/initialization/TableInitializer.ts
@@ -0,0 +1,113 @@
+import { SimpleTableConfig } from "../../types/SimpleTableConfig";
+import { CustomTheme, DEFAULT_CUSTOM_THEME } from "../../types/CustomTheme";
+import { DEFAULT_COLUMN_EDITOR_CONFIG } from "../../types/ColumnEditorConfig";
+import HeaderObject, { Accessor } from "../../types/HeaderObject";
+import {
+ createAngleLeftIcon,
+ createAngleRightIcon,
+ createDescIcon,
+ createAscIcon,
+ createFilterIcon,
+ createDragIcon,
+} from "../../icons";
+import { initializeExpandedDepths } from "../../hooks/expandedDepths";
+import { collectEssentialAccessors } from "../../utils/pinnedColumnUtils";
+
+export interface ResolvedIcons {
+ drag: string | HTMLElement | SVGSVGElement;
+ expand: string | HTMLElement | SVGSVGElement;
+ filter: string | HTMLElement | SVGSVGElement;
+ headerCollapse: string | HTMLElement | SVGSVGElement;
+ headerExpand: string | HTMLElement | SVGSVGElement;
+ next: string | HTMLElement | SVGSVGElement;
+ prev: string | HTMLElement | SVGSVGElement;
+ sortDown: string | HTMLElement | SVGSVGElement;
+ sortUp: string | HTMLElement | SVGSVGElement;
+}
+
+export interface MergedColumnEditorConfig {
+ text: string;
+ searchEnabled: boolean;
+ searchPlaceholder: string;
+ allowColumnPinning: boolean;
+ searchFunction?: (header: HeaderObject, searchText: string) => boolean;
+ rowRenderer?: any;
+}
+
+export class TableInitializer {
+ static resolveIcons(config: SimpleTableConfig): ResolvedIcons {
+ const defaultIcons = {
+ drag: createDragIcon("st-drag-icon"),
+ expand: createAngleRightIcon("st-expand-icon"),
+ filter: createFilterIcon("st-header-icon"),
+ headerCollapse: createAngleRightIcon("st-header-icon"),
+ headerExpand: createAngleLeftIcon("st-header-icon"),
+ next: createAngleRightIcon("st-next-prev-icon"),
+ prev: createAngleLeftIcon("st-next-prev-icon"),
+ sortDown: createDescIcon("st-header-icon"),
+ sortUp: createAscIcon("st-header-icon"),
+ };
+
+ return {
+ drag: config.icons?.drag ?? defaultIcons.drag,
+ expand: config.icons?.expand ?? defaultIcons.expand,
+ filter: config.icons?.filter ?? defaultIcons.filter,
+ headerCollapse: config.icons?.headerCollapse ?? defaultIcons.headerCollapse,
+ headerExpand: config.icons?.headerExpand ?? defaultIcons.headerExpand,
+ next: config.icons?.next ?? defaultIcons.next,
+ prev: config.icons?.prev ?? defaultIcons.prev,
+ sortDown: config.icons?.sortDown ?? defaultIcons.sortDown,
+ sortUp: config.icons?.sortUp ?? defaultIcons.sortUp,
+ };
+ }
+
+ static mergeCustomTheme(config: SimpleTableConfig): CustomTheme {
+ return {
+ ...DEFAULT_CUSTOM_THEME,
+ ...config.customTheme,
+ };
+ }
+
+ static mergeColumnEditorConfig(config: SimpleTableConfig): MergedColumnEditorConfig {
+ return {
+ text:
+ config.columnEditorConfig?.text ??
+ config.columnEditorText ??
+ DEFAULT_COLUMN_EDITOR_CONFIG.text,
+ searchEnabled:
+ config.columnEditorConfig?.searchEnabled ?? DEFAULT_COLUMN_EDITOR_CONFIG.searchEnabled,
+ searchPlaceholder:
+ config.columnEditorConfig?.searchPlaceholder ??
+ DEFAULT_COLUMN_EDITOR_CONFIG.searchPlaceholder,
+ allowColumnPinning:
+ config.columnEditorConfig?.allowColumnPinning ??
+ DEFAULT_COLUMN_EDITOR_CONFIG.allowColumnPinning,
+ searchFunction: config.columnEditorConfig?.searchFunction,
+ rowRenderer: config.columnEditorConfig?.rowRenderer,
+ };
+ }
+
+ static buildEssentialAccessors(headers: HeaderObject[]): Set {
+ return collectEssentialAccessors(headers);
+ }
+
+ static getInitialCollapsedHeaders(headers: HeaderObject[]): Set {
+ const collapsed = new Set();
+ const processHeaders = (hdrs: HeaderObject[]) => {
+ hdrs.forEach((header) => {
+ if (header.collapseDefault && header.collapsible) {
+ collapsed.add(header.accessor);
+ }
+ if (header.children) {
+ processHeaders(header.children);
+ }
+ });
+ };
+ processHeaders(headers);
+ return collapsed;
+ }
+
+ static getInitialExpandedDepths(config: SimpleTableConfig): Set {
+ return initializeExpandedDepths(config.expandAll ?? true, config.rowGrouping);
+ }
+}
diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts
new file mode 100644
index 000000000..13be18603
--- /dev/null
+++ b/packages/core/src/core/rendering/RenderOrchestrator.ts
@@ -0,0 +1,667 @@
+import { SimpleTableConfig } from "../../types/SimpleTableConfig";
+import { CustomTheme } from "../../types/CustomTheme";
+import HeaderObject, { Accessor } from "../../types/HeaderObject";
+import Row from "../../types/Row";
+import RowState from "../../types/RowState";
+import { DimensionManager } from "../../managers/DimensionManager";
+import { ScrollManager } from "../../managers/ScrollManager";
+import type { SectionScrollController } from "../../managers/SectionScrollController";
+import { SortManager } from "../../managers/SortManager";
+import { FilterManager } from "../../managers/FilterManager";
+import { SelectionManager } from "../../managers/SelectionManager";
+import { RowSelectionManager } from "../../managers/RowSelectionManager";
+import { TableRenderer } from "./TableRenderer";
+import { flattenRows, FlattenRowsResult } from "../../utils/rowFlattening";
+import { processRows, ProcessRowsResult } from "../../utils/rowProcessing";
+import { calculateContentHeight } from "../../hooks/contentHeight";
+import { filterRowsWithQuickFilter } from "../../hooks/useQuickFilter";
+import { calculateAggregatedRows } from "../../hooks/useAggregatedRows";
+import { createSelectionHeader } from "../../utils/rowSelectionUtils";
+import { normalizeHeaderWidths } from "../../utils/headerWidthUtils";
+import { applyAutoScaleToHeaders } from "../../managers/AutoScaleManager";
+import { COLUMN_EDIT_WIDTH } from "../../consts/general-consts";
+import {
+ MergedColumnEditorConfig,
+ ResolvedIcons,
+} from "../initialization/TableInitializer";
+import { recalculateAllSectionWidths } from "../../utils/resizeUtils/sectionWidths";
+
+export interface RenderContext {
+ cellRegistry: Map;
+ collapsedHeaders: Set;
+ collapsedRows: Map;
+ config: SimpleTableConfig;
+ customTheme: CustomTheme;
+ dimensionManager: DimensionManager | null;
+ draggedHeaderRef: { current: HeaderObject | null };
+ effectiveHeaders: HeaderObject[];
+ essentialAccessors: Set;
+ expandedDepths: Set;
+ expandedRows: Map;
+ filterManager: FilterManager | null;
+ getCollapsedRows: () => Map;
+ getCollapsedHeaders?: () => Set;
+ getExpandedRows: () => Map;
+ getRowStateMap: () => Map;
+ headerRegistry: Map;
+ headers: HeaderObject[];
+ hoveredHeaderRef: { current: HeaderObject | null };
+ internalIsLoading: boolean;
+ isResizing: boolean;
+ localRows: Row[];
+ mainBodyRef: { current: HTMLDivElement | null };
+ mainHeaderRef: { current: HTMLDivElement | null };
+ onRender: () => void;
+ pinnedLeftHeaderRef: { current: HTMLDivElement | null };
+ pinnedLeftRef: { current: HTMLDivElement | null };
+ pinnedRightHeaderRef: { current: HTMLDivElement | null };
+ pinnedRightRef: { current: HTMLDivElement | null };
+ resolvedIcons: ResolvedIcons;
+ rowSelectionManager: RowSelectionManager | null;
+ rowStateMap: Map;
+ scrollManager: ScrollManager | null;
+ sectionScrollController: SectionScrollController | null;
+ selectionManager: SelectionManager | null;
+ setCollapsedHeaders: (headers: Set) => void;
+ setCollapsedRows: (rows: Map) => void;
+ setColumnEditorOpen: (open: boolean) => void;
+ setCurrentPage: (page: number) => void;
+ setExpandedRows: (rows: Map) => void;
+ setHeaders: (headers: HeaderObject[]) => void;
+ setIsResizing: (value: boolean) => void;
+ setRowStateMap: (map: Map) => void;
+ sortManager: SortManager | null;
+ /** When true, body cells that stay visible get only position updates (no content/selection recalc). Used during vertical scroll for performance. */
+ positionOnlyBody?: boolean;
+}
+
+export interface RenderState {
+ currentPage: number;
+ scrollTop: number;
+ scrollDirection: "up" | "down" | "none";
+ scrollbarWidth: number;
+ isMainSectionScrollable: boolean;
+ columnEditorOpen: boolean;
+}
+
+interface FlattenedRowsCache {
+ aggregatedRows: Row[];
+ quickFilteredRows: Row[];
+ flattenResult: any;
+ deps: {
+ rowsRef: Row[];
+ /** Value-based key so cache only hits when quickFilter text/mode actually match (avoids stale 8-row cache when typing). */
+ quickFilterKey: string;
+ expandedRowsSize: number;
+ collapsedRowsSize: number;
+ expandedDepthsSize: number;
+ rowStateMapSize: number;
+ sortKey: string;
+ filterKey: string;
+ };
+}
+
+export class RenderOrchestrator {
+ private tableRenderer: TableRenderer;
+ private lastHeadersRef: HeaderObject[] | null = null;
+ private lastRowsRef: Row[] | null = null;
+ private flattenedRowsCache: FlattenedRowsCache | null = null;
+ private lastProcessedResult: ProcessRowsResult | null = null;
+
+ constructor() {
+ this.tableRenderer = new TableRenderer();
+ }
+
+ getCachedFlattenResult(): FlattenRowsResult | null {
+ return this.flattenedRowsCache?.flattenResult ?? null;
+ }
+
+ getLastProcessedResult(): ProcessRowsResult | null {
+ return this.lastProcessedResult;
+ }
+
+ invalidateCache(type?: "body" | "header" | "context" | "all"): void {
+ this.tableRenderer.invalidateCache(type);
+ if (!type || type === "all" || type === "body") {
+ this.flattenedRowsCache = null;
+ this.lastProcessedResult = null;
+ }
+ }
+
+ computeEffectiveHeaders(
+ headers: HeaderObject[],
+ config: SimpleTableConfig,
+ customTheme: CustomTheme,
+ containerWidth?: number,
+ ): HeaderObject[] {
+ let processedHeaders = [...headers];
+
+ if (config.enableRowSelection && !headers?.[0]?.isSelectionColumn) {
+ const selectionHeader = createSelectionHeader(
+ customTheme.selectionColumnWidth,
+ );
+ processedHeaders = [selectionHeader, ...processedHeaders];
+ }
+
+ if (containerWidth != null && containerWidth > 0) {
+ return normalizeHeaderWidths(processedHeaders, { containerWidth });
+ }
+ return normalizeHeaderWidths(processedHeaders);
+ }
+
+ render(
+ elements: {
+ bodyContainer: HTMLElement;
+ content: HTMLElement;
+ contentWrapper: HTMLElement;
+ footerContainer: HTMLElement;
+ headerContainer: HTMLElement;
+ rootElement: HTMLElement;
+ wrapperContainer: HTMLElement;
+ },
+ refs: {
+ mainBodyRef: { current: HTMLDivElement | null };
+ tableBodyContainerRef: { current: HTMLDivElement | null };
+ },
+ context: RenderContext,
+ state: RenderState,
+ mergedColumnEditorConfig: MergedColumnEditorConfig,
+ ): void {
+ // Invalidate caches when headers or rows change (by reference)
+ if (this.lastHeadersRef !== context.headers) {
+ this.invalidateCache("header");
+ this.invalidateCache("context");
+ this.lastHeadersRef = context.headers;
+ }
+
+ if (!context.dimensionManager) return;
+
+ // Capture horizontal scroll at start so we can reapply after header/body render (DOM updates can reset it)
+ const savedScrollLeft =
+ context.mainBodyRef?.current?.scrollLeft ??
+ context.mainHeaderRef?.current?.scrollLeft ??
+ 0;
+
+ const dimensionState = context.dimensionManager.getState();
+
+ const { containerWidth, calculatedHeaderHeight, maxHeaderDepth } =
+ dimensionState;
+
+ let effectiveHeaders = this.computeEffectiveHeaders(
+ context.headers,
+ context.config,
+ context.customTheme,
+ containerWidth,
+ );
+
+ // Calculate pinned section widths from un-scaled headers first so auto-scale
+ // knows exactly how much space is available for the main section.
+ const {
+ leftWidth: pinnedLeftWidth,
+ rightWidth: pinnedRightWidth,
+ } = recalculateAllSectionWidths({
+ headers: effectiveHeaders,
+ containerWidth,
+ collapsedHeaders: context.collapsedHeaders,
+ });
+
+ if (context.config.autoExpandColumns && containerWidth > 0) {
+ effectiveHeaders = applyAutoScaleToHeaders(effectiveHeaders, {
+ autoExpandColumns: true,
+ containerWidth,
+ pinnedLeftWidth,
+ pinnedRightWidth,
+ mainBodyRef: context.mainBodyRef ?? { current: null },
+ isResizing: context.isResizing ?? false,
+ });
+ }
+
+ const {
+ mainWidth,
+ leftWidth,
+ rightWidth,
+ leftContentWidth,
+ rightContentWidth,
+ } = recalculateAllSectionWidths({
+ headers: effectiveHeaders,
+ containerWidth,
+ collapsedHeaders: context.collapsedHeaders,
+ });
+
+ const mainSectionContainerWidth = containerWidth - leftWidth - rightWidth;
+
+ // Match main: maxHeight overrides height for the container; when maxHeight is set, height prop is ignored
+ const normalizeHeight = (v: string | number) =>
+ typeof v === "number" ? `${v}px` : v;
+ let maxHeightStyle = "";
+ let heightStyle = "";
+ if (context.config.maxHeight) {
+ const normalizedMax = normalizeHeight(context.config.maxHeight);
+ maxHeightStyle = `max-height: ${normalizedMax};`;
+ heightStyle =
+ dimensionState.contentHeight === undefined
+ ? "height: auto;"
+ : `height: ${normalizedMax};`;
+ } else if (context.config.height) {
+ heightStyle = `height: ${normalizeHeight(context.config.height)};`;
+ }
+
+ const { customTheme } = context;
+ elements.rootElement.style.cssText = `
+ ${maxHeightStyle}
+ ${heightStyle}
+ --st-main-section-width: ${mainSectionContainerWidth}px;
+ --st-scrollbar-width: ${state.scrollbarWidth}px;
+ --st-editor-width: ${context.config.editColumns ? COLUMN_EDIT_WIDTH : 0}px;
+ --st-border-width: ${customTheme.borderWidth}px;
+ --st-footer-height: ${customTheme.footerHeight}px;
+ `;
+
+ const columnResizing = context.config.columnResizing ?? false;
+ elements.content.className = `st-content ${columnResizing ? "st-resizeable" : "st-not-resizeable"}`;
+ elements.content.style.width = context.config.editColumns
+ ? `calc(100% - ${COLUMN_EDIT_WIDTH}px)`
+ : "100%";
+
+ let effectiveRows = context.localRows;
+
+ // Use sorted rows from SortManager (which already includes filtering)
+ // The FilterManager updates the SortManager's input rows when filters change
+ if (context.sortManager) {
+ effectiveRows = context.sortManager.getSortedRows();
+ } else if (context.filterManager) {
+ // Fallback: if no sort manager but filter manager exists, use filtered rows
+ effectiveRows = context.filterManager.getFilteredRows();
+ }
+
+ // Invalidate body and context cache when effective rows change (includes sorting/filtering)
+ if (this.lastRowsRef !== effectiveRows) {
+ this.invalidateCache("body");
+ this.invalidateCache("context"); // Also invalidate context to update sort indicators
+ this.lastRowsRef = effectiveRows;
+ }
+
+ if (context.internalIsLoading && effectiveRows.length === 0) {
+ let rowsToShow = context.config.shouldPaginate
+ ? (context.config.rowsPerPage ?? 10)
+ : 10;
+ if (state.isMainSectionScrollable) {
+ rowsToShow += 1;
+ }
+ effectiveRows = Array.from({ length: rowsToShow }, () => ({}));
+ }
+
+ // Check if we can use cached flattened rows
+ const sortState = context.sortManager?.getState();
+ const filterState = context.filterManager?.getState();
+
+ // Serialize sort and filter state for cache comparison
+ const sortKey = sortState?.sort
+ ? `${sortState.sort.key.accessor}-${sortState.sort.direction}`
+ : "none";
+ const filterKey = JSON.stringify(filterState?.filters || {});
+
+ const q = context.config.quickFilter;
+ const quickFilterKey = q ? `${q.text ?? ""}|${q.mode ?? "simple"}` : "";
+
+ const canUseCache =
+ this.flattenedRowsCache &&
+ this.flattenedRowsCache.deps.rowsRef === effectiveRows &&
+ this.flattenedRowsCache.deps.quickFilterKey === quickFilterKey &&
+ this.flattenedRowsCache.deps.expandedRowsSize ===
+ context.expandedRows.size &&
+ this.flattenedRowsCache.deps.collapsedRowsSize ===
+ context.collapsedRows.size &&
+ this.flattenedRowsCache.deps.expandedDepthsSize ===
+ context.expandedDepths.size &&
+ this.flattenedRowsCache.deps.rowStateMapSize ===
+ context.rowStateMap.size &&
+ this.flattenedRowsCache.deps.sortKey === sortKey &&
+ this.flattenedRowsCache.deps.filterKey === filterKey;
+
+ let aggregatedRows: Row[];
+ let quickFilteredRows: Row[];
+ let flattenResult: any;
+
+ if (canUseCache && this.flattenedRowsCache) {
+ aggregatedRows = this.flattenedRowsCache.aggregatedRows;
+ quickFilteredRows = this.flattenedRowsCache.quickFilteredRows;
+ flattenResult = this.flattenedRowsCache.flattenResult;
+ } else {
+ // SortManager already returns aggregated rows, so only aggregate if no SortManager
+ aggregatedRows = context.sortManager
+ ? effectiveRows
+ : calculateAggregatedRows({
+ rows: effectiveRows,
+ headers: context.headers,
+ rowGrouping: context.config.rowGrouping,
+ });
+
+ quickFilteredRows = filterRowsWithQuickFilter({
+ rows: aggregatedRows,
+ headers: effectiveHeaders,
+ quickFilter: context.config.quickFilter,
+ });
+
+ flattenResult = flattenRows({
+ rows: quickFilteredRows,
+ rowGrouping: context.config.rowGrouping,
+ getRowId: context.config.getRowId,
+ expandedRows: context.expandedRows,
+ collapsedRows: context.collapsedRows,
+ expandedDepths: context.expandedDepths,
+ rowStateMap: context.rowStateMap,
+ hasLoadingRenderer: Boolean(context.config.loadingStateRenderer),
+ hasErrorRenderer: Boolean(context.config.errorStateRenderer),
+ hasEmptyRenderer: Boolean(context.config.emptyStateRenderer),
+ headers: effectiveHeaders,
+ rowHeight: context.customTheme.rowHeight,
+ headerHeight: context.customTheme.headerHeight,
+ customTheme: context.customTheme,
+ });
+
+ // Cache the result
+ this.flattenedRowsCache = {
+ aggregatedRows,
+ quickFilteredRows,
+ flattenResult,
+ deps: {
+ rowsRef: effectiveRows,
+ quickFilterKey,
+ expandedRowsSize: context.expandedRows.size,
+ collapsedRowsSize: context.collapsedRows.size,
+ expandedDepthsSize: context.expandedDepths.size,
+ rowStateMapSize: context.rowStateMap.size,
+ sortKey,
+ filterKey,
+ },
+ };
+ }
+
+ const contentHeight = calculateContentHeight({
+ height: context.config.height,
+ maxHeight: context.config.maxHeight,
+ rowHeight: context.customTheme.rowHeight,
+ shouldPaginate: context.config.shouldPaginate ?? false,
+ rowsPerPage: context.config.rowsPerPage ?? 10,
+ totalRowCount:
+ context.config.totalRowCount ?? flattenResult.paginatableRows.length,
+ headerHeight: calculatedHeaderHeight,
+ footerHeight:
+ (context.config.shouldPaginate || context.config.footerRenderer) && !context.config.hideFooter
+ ? context.customTheme.footerHeight
+ : undefined,
+ });
+
+ const processedResult = processRows({
+ flattenedRows: flattenResult.flattenedRows,
+ paginatableRows: flattenResult.paginatableRows,
+ parentEndPositions: flattenResult.parentEndPositions,
+ currentPage: state.currentPage,
+ rowsPerPage: context.config.rowsPerPage ?? 10,
+ shouldPaginate: context.config.shouldPaginate ?? false,
+ serverSidePagination: context.config.serverSidePagination ?? false,
+ contentHeight,
+ rowHeight: context.customTheme.rowHeight,
+ scrollTop: state.scrollTop,
+ scrollDirection: state.scrollDirection,
+ heightOffsets: flattenResult.heightOffsets,
+ customTheme: context.customTheme,
+ enableStickyParents: context.config.enableStickyParents ?? false,
+ rowGrouping: context.config.rowGrouping,
+ });
+ this.lastProcessedResult = processedResult;
+
+ context.rowSelectionManager?.updateConfig({
+ tableRows: processedResult.currentTableRows,
+ });
+
+ this.renderHeader(
+ elements.headerContainer,
+ calculatedHeaderHeight,
+ maxHeaderDepth,
+ effectiveHeaders,
+ context,
+ );
+ this.renderBody(
+ elements.bodyContainer,
+ processedResult,
+ effectiveHeaders,
+ context,
+ );
+
+ // Register header and body panes with section scroll controller, seed state from current scroll, then restore
+ this.registerSectionPanes(context);
+ const controller = context.sectionScrollController;
+ if (controller) {
+ controller.setSectionScrollLeft("main", savedScrollLeft);
+ if (context.pinnedLeftRef.current != null) {
+ controller.setSectionScrollLeft(
+ "pinned-left",
+ context.pinnedLeftRef.current.scrollLeft,
+ );
+ }
+ if (context.pinnedRightRef.current != null) {
+ controller.setSectionScrollLeft(
+ "pinned-right",
+ context.pinnedRightRef.current.scrollLeft,
+ );
+ }
+ controller.restoreAll();
+ }
+
+ this.renderFooter(
+ elements.footerContainer,
+ context.config.totalRowCount ?? flattenResult.paginatableRows.length,
+ state.currentPage,
+ effectiveHeaders,
+ context,
+ );
+ this.renderColumnEditor(
+ elements.contentWrapper,
+ state.columnEditorOpen,
+ mergedColumnEditorConfig,
+ effectiveHeaders,
+ context,
+ );
+ this.renderHorizontalScrollbar(
+ elements.wrapperContainer,
+ mainWidth,
+ leftWidth,
+ rightWidth,
+ leftContentWidth,
+ rightContentWidth,
+ refs.tableBodyContainerRef.current,
+ effectiveHeaders,
+ context,
+ );
+ }
+
+ private renderHeader(
+ headerContainer: HTMLElement,
+ calculatedHeaderHeight: number,
+ maxHeaderDepth: number,
+ effectiveHeaders: HeaderObject[],
+ context: RenderContext,
+ ): void {
+ if (context.config.hideHeader) return;
+
+ const deps = this.buildRendererDeps(effectiveHeaders, context);
+ this.tableRenderer.renderHeader(
+ headerContainer,
+ calculatedHeaderHeight,
+ maxHeaderDepth,
+ deps,
+ );
+ }
+
+ private renderBody(
+ bodyContainer: HTMLElement,
+ processedResult: any,
+ effectiveHeaders: HeaderObject[],
+ context: RenderContext,
+ ): void {
+ const deps = this.buildRendererDeps(effectiveHeaders, context);
+ this.tableRenderer.renderBody(bodyContainer, processedResult, deps);
+ }
+
+ private renderFooter(
+ footerContainer: HTMLElement,
+ totalRows: number,
+ currentPage: number,
+ effectiveHeaders: HeaderObject[],
+ context: RenderContext,
+ ): void {
+ const deps = this.buildRendererDeps(effectiveHeaders, context);
+ this.tableRenderer.renderFooter(
+ footerContainer,
+ totalRows,
+ currentPage,
+ (page: number) => {
+ context.setCurrentPage(page);
+ context.onRender();
+ },
+ deps,
+ );
+ }
+
+ private renderColumnEditor(
+ contentWrapper: HTMLElement,
+ columnEditorOpen: boolean,
+ mergedColumnEditorConfig: MergedColumnEditorConfig,
+ effectiveHeaders: HeaderObject[],
+ context: RenderContext,
+ ): void {
+ const deps = this.buildRendererDeps(effectiveHeaders, context);
+ this.tableRenderer.renderColumnEditor(
+ contentWrapper,
+ columnEditorOpen,
+ (open: boolean) => {
+ context.setColumnEditorOpen(open);
+ context.onRender();
+ },
+ mergedColumnEditorConfig,
+ deps,
+ );
+ }
+
+ private renderHorizontalScrollbar(
+ wrapperContainer: HTMLElement,
+ mainBodyWidth: number,
+ pinnedLeftWidth: number,
+ pinnedRightWidth: number,
+ pinnedLeftContentWidth: number,
+ pinnedRightContentWidth: number,
+ tableBodyContainer: HTMLDivElement | null,
+ effectiveHeaders: HeaderObject[],
+ context: RenderContext,
+ ): void {
+ if (!context.mainBodyRef.current || !tableBodyContainer) return;
+
+ const deps = this.buildRendererDeps(effectiveHeaders, context);
+ this.tableRenderer.renderHorizontalScrollbar(
+ wrapperContainer,
+ mainBodyWidth,
+ pinnedLeftWidth,
+ pinnedRightWidth,
+ pinnedLeftContentWidth,
+ pinnedRightContentWidth,
+ tableBodyContainer,
+ deps,
+ );
+ }
+
+ private registerSectionPanes(context: RenderContext): void {
+ const controller = context.sectionScrollController;
+ if (!controller) return;
+
+ if (context.pinnedLeftHeaderRef.current) {
+ controller.registerPane(
+ "pinned-left",
+ context.pinnedLeftHeaderRef.current,
+ "header",
+ );
+ }
+ if (context.pinnedLeftRef.current) {
+ controller.registerPane(
+ "pinned-left",
+ context.pinnedLeftRef.current,
+ "body",
+ );
+ }
+ if (context.mainHeaderRef.current) {
+ controller.registerPane("main", context.mainHeaderRef.current, "header");
+ }
+ if (context.mainBodyRef.current) {
+ controller.registerPane("main", context.mainBodyRef.current, "body");
+ }
+ if (context.pinnedRightHeaderRef.current) {
+ controller.registerPane(
+ "pinned-right",
+ context.pinnedRightHeaderRef.current,
+ "header",
+ );
+ }
+ if (context.pinnedRightRef.current) {
+ controller.registerPane(
+ "pinned-right",
+ context.pinnedRightRef.current,
+ "body",
+ );
+ }
+ }
+
+ private buildRendererDeps(
+ effectiveHeaders: HeaderObject[],
+ context: RenderContext,
+ ) {
+ return {
+ config: context.config,
+ customTheme: context.customTheme,
+ resolvedIcons: context.resolvedIcons,
+ effectiveHeaders,
+ headers: context.headers,
+ localRows: context.localRows,
+ collapsedHeaders: context.collapsedHeaders,
+ collapsedRows: context.collapsedRows,
+ expandedRows: context.expandedRows,
+ expandedDepths: context.expandedDepths,
+ isResizing: context.isResizing,
+ internalIsLoading: context.internalIsLoading,
+ cellRegistry: context.cellRegistry,
+ headerRegistry: context.headerRegistry,
+ draggedHeaderRef: context.draggedHeaderRef,
+ hoveredHeaderRef: context.hoveredHeaderRef,
+ mainBodyRef: context.mainBodyRef,
+ pinnedLeftRef: context.pinnedLeftRef,
+ pinnedRightRef: context.pinnedRightRef,
+ mainHeaderRef: context.mainHeaderRef,
+ pinnedLeftHeaderRef: context.pinnedLeftHeaderRef,
+ pinnedRightHeaderRef: context.pinnedRightHeaderRef,
+ dimensionManager: context.dimensionManager,
+ sectionScrollController: context.sectionScrollController,
+ sortManager: context.sortManager,
+ filterManager: context.filterManager,
+ selectionManager: context.selectionManager,
+ rowSelectionManager: context.rowSelectionManager,
+ rowStateMap: context.rowStateMap,
+ onRender: context.onRender,
+ setIsResizing: context.setIsResizing,
+ setHeaders: context.setHeaders,
+ setCollapsedHeaders: context.setCollapsedHeaders,
+ setCollapsedRows: context.setCollapsedRows,
+ setExpandedRows: context.setExpandedRows,
+ setRowStateMap: context.setRowStateMap,
+ getCollapsedRows: context.getCollapsedRows,
+ getCollapsedHeaders: context.getCollapsedHeaders,
+ getExpandedRows: context.getExpandedRows,
+ getRowStateMap: context.getRowStateMap,
+ positionOnlyBody: context.positionOnlyBody,
+ essentialAccessors: context.essentialAccessors,
+ };
+ }
+
+ cleanup(): void {
+ this.tableRenderer.cleanup();
+ }
+}
diff --git a/packages/core/src/core/rendering/SectionRenderer.ts b/packages/core/src/core/rendering/SectionRenderer.ts
new file mode 100644
index 000000000..964dd0308
--- /dev/null
+++ b/packages/core/src/core/rendering/SectionRenderer.ts
@@ -0,0 +1,959 @@
+import HeaderObject, { Accessor } from "../../types/HeaderObject";
+import {
+ renderHeaderCells,
+ AbsoluteCell,
+ HeaderRenderContext,
+ cleanupHeaderCellRendering,
+} from "../../utils/headerCellRenderer";
+import {
+ renderBodyCells,
+ AbsoluteBodyCell,
+ CellRenderContext,
+ cleanupBodyCellRendering,
+} from "../../utils/bodyCellRenderer";
+import TableRow from "../../types/TableRow";
+import { rowIdToString } from "../../utils/rowUtils";
+import { DEFAULT_CUSTOM_THEME } from "../../types/CustomTheme";
+import {
+ calculateTotalHeight,
+ calculateRowTopPosition,
+} from "../../utils/infiniteScrollUtils";
+import {
+ createNestedGridRow,
+ createNestedGridSpacer,
+ type NestedGridRowRenderContext,
+} from "../../utils/nestedGridRowRenderer";
+import { createStateRow, type StateRowRenderContext } from "../../utils/stateRowRenderer";
+
+export interface HeaderSectionParams {
+ headers: HeaderObject[];
+ collapsedHeaders: Set;
+ pinned?: "left" | "right";
+ maxHeaderDepth: number;
+ headerHeight: number;
+ context: HeaderRenderContext;
+ sectionWidth?: number;
+ startColIndex?: number;
+}
+
+export interface BodySectionParams {
+ headers: HeaderObject[];
+ rows: TableRow[];
+ collapsedHeaders: Set;
+ pinned?: "left" | "right";
+ context: CellRenderContext;
+ sectionWidth?: number;
+ rowHeight: number;
+ heightOffsets?: Array<[number, number]>;
+ totalRowCount?: number;
+ startColIndex?: number;
+ /** When true, only update cell positions for existing cells (scroll performance). */
+ positionOnly?: boolean;
+ /** Full table rows ref + range for range-based body cell cache (avoids cache miss on every scroll). */
+ fullTableRows?: TableRow[];
+ renderedStartIndex?: number;
+ renderedEndIndex?: number;
+}
+
+interface BodyCellsCacheEntry {
+ cells: AbsoluteBodyCell[];
+ deps: {
+ headersHash: string;
+ rowsRef: TableRow[];
+ collapsedHeadersSize: number;
+ rowHeight: number;
+ heightOffsetsHash: string;
+ /** Range-based cache: when set, cache key includes these instead of rowsRef for stable key on scroll. */
+ renderedStartIndex?: number;
+ renderedEndIndex?: number;
+ fullTableRowsRef?: TableRow[];
+ };
+}
+
+interface HeaderCellsCacheEntry {
+ cells: AbsoluteCell[];
+ deps: {
+ headersHash: string;
+ collapsedHeadersSize: number;
+ maxDepth: number;
+ headerHeight: number;
+ };
+}
+
+interface ContextCacheEntry {
+ context: CellRenderContext | HeaderRenderContext;
+ deps: {
+ contextHash: string;
+ };
+}
+
+export class SectionRenderer {
+ private headerSections: Map = new Map();
+ private bodySections: Map = new Map();
+
+ private bodyCellsCache: Map = new Map();
+ private headerCellsCache: Map = new Map();
+ private contextCache: Map = new Map();
+
+ // Track the next colIndex for each section after rendering
+ private nextColIndexMap: Map = new Map();
+
+ // State row elements per section (key: sectionKey, value: Map)
+ private stateRowsMap: Map> = new Map();
+
+ // Nested grid row elements per section (key: sectionKey, value: Map)
+ private nestedGridRowsMap: Map<
+ string,
+ Map void }>
+ > = new Map();
+
+ renderHeaderSection(params: HeaderSectionParams): HTMLElement {
+ const {
+ headers,
+ collapsedHeaders,
+ pinned,
+ maxHeaderDepth,
+ headerHeight,
+ context,
+ sectionWidth,
+ startColIndex = 0,
+ } = params;
+
+ const sectionKey = pinned || "main";
+ let section = this.headerSections.get(sectionKey);
+
+ if (!section) {
+ section = document.createElement("div");
+ section.className =
+ pinned === "left"
+ ? "st-header-pinned-left"
+ : pinned === "right"
+ ? "st-header-pinned-right"
+ : "st-header-main";
+ section.setAttribute("role", "rowgroup");
+ this.headerSections.set(sectionKey, section);
+ }
+
+ const filteredHeaders = headers.filter((h) => {
+ if (pinned === "left") return h.pinned === "left";
+ if (pinned === "right") return h.pinned === "right";
+ return !h.pinned;
+ });
+
+ if (filteredHeaders.length === 0) {
+ section.style.display = "none";
+ return section;
+ }
+
+ section.style.display = "";
+
+ section.style.cssText = `
+ position: relative;
+ ${sectionWidth !== undefined ? `width: ${sectionWidth}px;` : ""}
+ height: ${maxHeaderDepth * headerHeight}px;
+ `;
+
+ const absoluteCells = this.getCachedHeaderCells(
+ sectionKey,
+ filteredHeaders,
+ collapsedHeaders,
+ maxHeaderDepth,
+ headerHeight,
+ startColIndex,
+ );
+
+ // Calculate and store the next colIndex for this section
+ const maxColIndex =
+ absoluteCells.length > 0
+ ? Math.max(...absoluteCells.map((c) => c.colIndex)) + 1
+ : startColIndex;
+ this.nextColIndexMap.set(sectionKey, maxColIndex);
+
+ const cachedContext = this.getCachedContext(
+ `header-${sectionKey}`,
+ context,
+ pinned,
+ );
+
+ // Render with current scrollLeft to preserve scroll position during re-renders
+ const currentScrollLeft = section.scrollLeft;
+ renderHeaderCells(section, absoluteCells, cachedContext, currentScrollLeft);
+ // Restore header scroll after render so the browser doesn't reset it (which would trigger header→body sync and reset body scroll)
+ if (!pinned && currentScrollLeft !== section.scrollLeft) {
+ section.scrollLeft = currentScrollLeft;
+ }
+
+ // For main section (not pinned), attach render function for scroll updates
+ if (!pinned && section) {
+ (section as any).__renderHeaderCells = (scrollLeft: number) => {
+ if (section) {
+ renderHeaderCells(section, absoluteCells, cachedContext, scrollLeft);
+ }
+ };
+ }
+
+ return section;
+ }
+
+ renderBodySection(params: BodySectionParams): HTMLElement {
+ const {
+ headers,
+ rows,
+ collapsedHeaders,
+ pinned,
+ context,
+ sectionWidth,
+ rowHeight,
+ heightOffsets,
+ totalRowCount,
+ startColIndex = 0,
+ positionOnly = false,
+ fullTableRows,
+ renderedStartIndex,
+ renderedEndIndex,
+ } = params;
+
+ const sectionKey = pinned || "main";
+ let section = this.bodySections.get(sectionKey);
+ let isNewSection = false;
+
+ if (!section) {
+ section = document.createElement("div");
+ section.className =
+ pinned === "left"
+ ? "st-body-pinned-left"
+ : pinned === "right"
+ ? "st-body-pinned-right"
+ : "st-body-main";
+ section.setAttribute("role", "rowgroup");
+ this.bodySections.set(sectionKey, section);
+ isNewSection = true;
+ }
+
+ const filteredHeaders = headers.filter((h) => {
+ if (pinned === "left") return h.pinned === "left";
+ if (pinned === "right") return h.pinned === "right";
+ return !h.pinned;
+ });
+
+ if (filteredHeaders.length === 0) {
+ section.style.display = "none";
+ return section;
+ }
+
+ section.style.display = "";
+
+ // Calculate total height properly using calculateTotalHeight with heightOffsets
+ const rowCount = totalRowCount !== undefined ? totalRowCount : rows.length;
+ const totalHeight = calculateTotalHeight(
+ rowCount,
+ rowHeight,
+ heightOffsets,
+ context.customTheme ?? DEFAULT_CUSTOM_THEME,
+ );
+
+ section.style.cssText = `
+ position: relative;
+ ${sectionWidth !== undefined ? `width: ${sectionWidth}px;` : ""}
+ ${!pinned ? "flex-grow: 1;" : ""}
+ height: ${totalHeight}px;
+ `;
+
+ const absoluteCells = this.getCachedBodyCells(
+ sectionKey,
+ filteredHeaders,
+ rows,
+ collapsedHeaders,
+ rowHeight,
+ heightOffsets,
+ context.customTheme ?? DEFAULT_CUSTOM_THEME,
+ startColIndex,
+ fullTableRows,
+ renderedStartIndex,
+ renderedEndIndex,
+ );
+
+ // Calculate and store the next colIndex for this section
+ const maxColIndex =
+ absoluteCells.length > 0
+ ? Math.max(...absoluteCells.map((c) => c.colIndex)) + 1
+ : startColIndex;
+ this.nextColIndexMap.set(sectionKey, maxColIndex);
+
+ const cachedContext = this.getCachedContext(
+ `body-${sectionKey}`,
+ context,
+ pinned,
+ );
+
+ // Render with current scrollLeft to preserve scroll position during re-renders.
+ // Pass full rows so separators and nested grid rows account for every row.
+ const currentScrollLeft = section.scrollLeft;
+ renderBodyCells(
+ section,
+ absoluteCells,
+ cachedContext,
+ currentScrollLeft,
+ rows,
+ positionOnly,
+ );
+
+ // Render nested grid rows (full-width rows that contain a nested SimpleTable) or spacers in pinned sections
+ this.renderNestedGridRows(section, sectionKey, rows, pinned, cachedContext);
+
+ // Render state indicator rows (loading/error/empty) as full-width rows – only in main (non-pinned) section
+ if (!pinned) {
+ this.renderStateRows(section, sectionKey, rows, cachedContext);
+ }
+
+ // For main section (not pinned), attach render function for scroll updates (used by SectionScrollController.onMainSectionScrollLeft)
+ if (!pinned && section) {
+ (section as any).__renderBodyCells = (scrollLeft: number) => {
+ if (section) {
+ renderBodyCells(
+ section,
+ absoluteCells,
+ cachedContext,
+ scrollLeft,
+ rows,
+ true,
+ );
+ }
+ };
+ }
+
+ return section;
+ }
+
+ private renderNestedGridRows(
+ section: HTMLElement,
+ sectionKey: string,
+ rows: TableRow[],
+ pinned: "left" | "right" | undefined,
+ context: CellRenderContext,
+ ): void {
+ const nestedRows = rows.filter((r) => r.nestedTable);
+ const currentPositions = new Set(nestedRows.map((r) => r.position));
+
+ let map = this.nestedGridRowsMap.get(sectionKey);
+ if (!map) {
+ map = new Map();
+ this.nestedGridRowsMap.set(sectionKey, map);
+ }
+
+ // Remove nested row elements that are no longer in the list
+ map.forEach((entry, position) => {
+ if (!currentPositions.has(position)) {
+ entry.cleanup?.();
+ entry.element.remove();
+ map!.delete(position);
+ }
+ });
+
+ const nestedContext: NestedGridRowRenderContext = {
+ rowHeight: context.rowHeight,
+ heightOffsets: context.heightOffsets,
+ customTheme: context.customTheme ?? ({} as any),
+ theme: context.theme,
+ rowGrouping: context.rowGrouping,
+ depth: 0,
+ loadingStateRenderer: context.loadingStateRenderer,
+ errorStateRenderer: context.errorStateRenderer,
+ emptyStateRenderer: context.emptyStateRenderer,
+ icons: context.icons,
+ };
+
+ nestedRows.forEach((tableRow) => {
+ const position = tableRow.position;
+ const existing = map!.get(position);
+
+ if (existing) {
+ // Already rendered for this position; could update if needed (e.g. height/position changed)
+ return;
+ }
+
+ if (pinned) {
+ const spacer = createNestedGridSpacer(tableRow, {
+ rowHeight: context.rowHeight,
+ heightOffsets: context.heightOffsets,
+ customTheme: context.customTheme ?? ({} as any),
+ });
+ section.appendChild(spacer);
+ map!.set(position, { element: spacer });
+ } else {
+ nestedContext.depth = tableRow.depth > 0 ? tableRow.depth - 1 : 0;
+ const { element, cleanup } = createNestedGridRow(
+ tableRow,
+ nestedContext,
+ );
+ section.appendChild(element);
+ map!.set(position, { element, cleanup });
+ }
+ });
+ }
+
+ private renderStateRows(
+ section: HTMLElement,
+ sectionKey: string,
+ rows: TableRow[],
+ context: CellRenderContext,
+ ): void {
+ const stateRows = rows.filter((r) => r.stateIndicator);
+ const currentPositions = new Set(stateRows.map((r) => r.position));
+
+ let map = this.stateRowsMap.get(sectionKey);
+ if (!map) {
+ map = new Map();
+ this.stateRowsMap.set(sectionKey, map);
+ }
+
+ // Remove state row elements that are no longer in the list
+ map.forEach((element, position) => {
+ if (!currentPositions.has(position)) {
+ element.remove();
+ map!.delete(position);
+ }
+ });
+
+ const stateContext: StateRowRenderContext = {
+ index: 0,
+ rowHeight: context.rowHeight,
+ heightOffsets: context.heightOffsets,
+ customTheme: context.customTheme ?? ({} as any),
+ loadingStateRenderer: context.loadingStateRenderer,
+ errorStateRenderer: context.errorStateRenderer,
+ emptyStateRenderer: context.emptyStateRenderer,
+ };
+
+ stateRows.forEach((tableRow, i) => {
+ const position = tableRow.position;
+ const existing = map!.get(position);
+
+ if (existing) {
+ // Update position in case it changed
+ const top = calculateRowTopPosition({
+ position,
+ rowHeight: context.rowHeight,
+ heightOffsets: context.heightOffsets,
+ customTheme: context.customTheme ?? ({} as any),
+ });
+ existing.style.transform = `translate3d(0, ${top}px, 0)`;
+ return;
+ }
+
+ const rowElement = createStateRow(tableRow, { ...stateContext, index: i });
+ // Position the state row correctly
+ const top = calculateRowTopPosition({
+ position,
+ rowHeight: context.rowHeight,
+ heightOffsets: context.heightOffsets,
+ customTheme: context.customTheme ?? ({} as any),
+ });
+ rowElement.style.position = "absolute";
+ rowElement.style.transform = `translate3d(0, ${top}px, 0)`;
+ rowElement.style.width = "100%";
+ section.appendChild(rowElement);
+ map!.set(position, rowElement);
+ });
+ }
+
+ private calculateAbsoluteHeaderCells(
+ headers: HeaderObject[],
+ collapsedHeaders: Set,
+ maxDepth: number,
+ headerHeight: number,
+ startColIndex: number = 0,
+ ): AbsoluteCell[] {
+ const cells: AbsoluteCell[] = [];
+ let colIndex = startColIndex;
+ let currentLeft = 0;
+
+ const processHeader = (
+ header: HeaderObject,
+ depth: number,
+ parentHeader?: HeaderObject,
+ ): number => {
+ if (header.hide || header.excludeFromRender) return 0;
+
+ const isCollapsed = collapsedHeaders.has(header.accessor);
+ const hasChildren = header.children && header.children.length > 0;
+
+ if (hasChildren) {
+ const visibleChildren = header.children!.filter((child) => {
+ const showWhen = child.showWhen || "parentExpanded";
+ if (isCollapsed) {
+ return showWhen === "parentCollapsed" || showWhen === "always";
+ } else {
+ return showWhen === "parentExpanded" || showWhen === "always";
+ }
+ });
+
+ if (header.singleRowChildren) {
+ const width = typeof header.width === "number" ? header.width : 150;
+ cells.push({
+ header,
+ left: currentLeft,
+ top: depth * headerHeight,
+ width,
+ height: (maxDepth - depth) * headerHeight,
+ colIndex,
+ parentHeader,
+ });
+ colIndex++;
+ currentLeft += width;
+
+ let childrenWidth = 0;
+ visibleChildren.forEach((child) => {
+ childrenWidth += processHeader(child, depth, header);
+ });
+
+ return width + childrenWidth;
+ }
+
+ if (visibleChildren.length === 0) {
+ const width = typeof header.width === "number" ? header.width : 150;
+ cells.push({
+ header,
+ left: currentLeft,
+ top: depth * headerHeight,
+ width,
+ height: (maxDepth - depth) * headerHeight,
+ colIndex,
+ parentHeader,
+ });
+ colIndex++;
+ currentLeft += width;
+ return width;
+ }
+
+ // Parent with children - process children first, then add parent cell
+ const parentLeft = currentLeft;
+ let totalChildrenWidth = 0;
+ visibleChildren.forEach((child) => {
+ totalChildrenWidth += processHeader(child, depth + 1, header);
+ });
+
+ // Add parent cell spanning all children
+ cells.push({
+ header,
+ left: parentLeft,
+ top: depth * headerHeight,
+ width: totalChildrenWidth,
+ height: headerHeight,
+ colIndex,
+ parentHeader,
+ });
+ colIndex++;
+
+ return totalChildrenWidth;
+ } else {
+ const width = typeof header.width === "number" ? header.width : 150;
+ cells.push({
+ header,
+ left: currentLeft,
+ top: depth * headerHeight,
+ width,
+ height: (maxDepth - depth) * headerHeight,
+ colIndex,
+ parentHeader,
+ });
+ colIndex++;
+ currentLeft += width;
+ return width;
+ }
+ };
+
+ headers.forEach((header) => processHeader(header, 0));
+
+ return cells;
+ }
+
+ private calculateAbsoluteBodyCells(
+ headers: HeaderObject[],
+ rows: TableRow[],
+ collapsedHeaders: Set,
+ rowHeight: number,
+ heightOffsets?: Array<[number, number]>,
+ customTheme?: any,
+ startColIndex: number = 0,
+ ): AbsoluteBodyCell[] {
+ const cells: AbsoluteBodyCell[] = [];
+
+ // Exclude nested table rows and state indicator rows – both are rendered as full-width rows, not per-column cells
+ const rowsForCells = rows.filter((r) => !r.nestedTable && !r.stateIndicator);
+
+ const leafHeaders = this.getLeafHeaders(headers, collapsedHeaders);
+
+ // Build header positions map with accumulated widths
+ const headerPositions = new Map();
+ let currentLeft = 0;
+ leafHeaders.forEach((header) => {
+ const width = typeof header.width === "number" ? header.width : 150;
+ headerPositions.set(header.accessor, { left: currentLeft, width });
+ currentLeft += width;
+ });
+
+ rowsForCells.forEach((tableRow, rowIndex) => {
+ // Calculate proper top position using calculateRowTopPosition
+ const topPosition = customTheme
+ ? calculateRowTopPosition({
+ position: tableRow.position,
+ rowHeight,
+ heightOffsets,
+ customTheme,
+ })
+ : rowIndex * rowHeight;
+
+ leafHeaders.forEach((header, leafIndex) => {
+ const position = headerPositions.get(header.accessor);
+ const colIndex = startColIndex + leafIndex;
+ cells.push({
+ header,
+ row: tableRow.row,
+ rowIndex,
+ colIndex,
+ rowId: rowIdToString(tableRow.rowId),
+ displayRowNumber: tableRow.displayPosition,
+ depth: tableRow.depth,
+ isOdd: rowIndex % 2 === 1,
+ tableRow,
+ left: position?.left ?? 0,
+ top: topPosition,
+ width: position?.width ?? 150,
+ height: rowHeight,
+ });
+ });
+ });
+
+ return cells;
+ }
+
+ private getLeafHeaders(
+ headers: HeaderObject[],
+ collapsedHeaders: Set,
+ ): HeaderObject[] {
+ const leaves: HeaderObject[] = [];
+
+ const processHeader = (header: HeaderObject): void => {
+ if (header.hide || header.excludeFromRender) return;
+
+ const isCollapsed = collapsedHeaders.has(header.accessor);
+ const hasChildren = header.children && header.children.length > 0;
+
+ if (hasChildren) {
+ const visibleChildren = header.children!.filter((child) => {
+ const showWhen = child.showWhen || "parentExpanded";
+ if (isCollapsed) {
+ return showWhen === "parentCollapsed" || showWhen === "always";
+ } else {
+ return showWhen === "parentExpanded" || showWhen === "always";
+ }
+ });
+
+ if (header.singleRowChildren) {
+ leaves.push(header);
+ }
+
+ if (visibleChildren.length > 0) {
+ visibleChildren.forEach((child) => processHeader(child));
+ } else if (!header.singleRowChildren) {
+ leaves.push(header);
+ }
+ } else {
+ leaves.push(header);
+ }
+ };
+
+ headers.forEach((header) => processHeader(header));
+
+ return leaves;
+ }
+
+ private createHeadersHash(headers: HeaderObject[]): string {
+ const hashHeader = (h: HeaderObject): string => {
+ let hash = `${h.accessor}:${h.width}:${h.pinned || ""}:${h.hide || ""}`;
+ if (h.children && h.children.length > 0) {
+ hash += `:children[${h.children.map(hashHeader).join(",")}]`;
+ }
+ return hash;
+ };
+ return headers.map(hashHeader).join("|");
+ }
+
+ private createHeightOffsetsHash(
+ heightOffsets?: Array<[number, number]>,
+ ): string {
+ if (!heightOffsets || heightOffsets.length === 0) return "";
+ return heightOffsets.map(([pos, height]) => `${pos}:${height}`).join("|");
+ }
+
+ private createContextHash(context: any): string {
+ const keys = [
+ "columnBorders",
+ "enableRowSelection",
+ "cellUpdateFlash",
+ "useOddColumnBackground",
+ "useHoverRowBackground",
+ "useOddEvenRowBackground",
+ "rowHeight",
+ "containerWidth",
+ ];
+ let hash = keys.map((k) => `${k}:${context[k]}`).join("|");
+
+ // Include sort state in hash for header context
+ if (context.sort) {
+ hash += `|sort:${context.sort.key.accessor}-${context.sort.direction}`;
+ } else {
+ hash += `|sort:none`;
+ }
+
+ // Include filter state in hash for header context
+ if (context.filters && Object.keys(context.filters).length > 0) {
+ hash += `|filters:${JSON.stringify(context.filters)}`;
+ } else {
+ hash += `|filters:none`;
+ }
+
+ // Include expansion state in hash for body context
+ if (context.expandedRows) {
+ hash += `|expandedRows:${context.expandedRows.size}`;
+ }
+ if (context.collapsedRows) {
+ hash += `|collapsedRows:${context.collapsedRows.size}`;
+ }
+ if (context.expandedDepths) {
+ hash += `|expandedDepths:${Array.isArray(context.expandedDepths) ? context.expandedDepths.length : context.expandedDepths.size}`;
+ }
+ // Include column collapse state so header/body re-render with correct collapse icons and visibility
+ if (context.collapsedHeaders != null) {
+ const size = context.collapsedHeaders.size;
+ const serialized =
+ size === 0
+ ? ""
+ : Array.from(context.collapsedHeaders as Set)
+ .sort()
+ .join(",");
+ hash += `|collapsedHeaders:${size}:${serialized}`;
+ }
+ // Include row selection so body re-renders with updated isRowSelected when selection changes
+ if (context.selectedRowCount !== undefined) {
+ hash += `|selectedRowCount:${context.selectedRowCount}`;
+ }
+ // Include column selection so header/body re-render with st-header-selected and st-cell-column-selected
+ if (context.selectedColumns && context.selectedColumns.size !== undefined) {
+ hash += `|selectedColumns:${Array.from(
+ context.selectedColumns as Set,
+ )
+ .sort((a, b) => a - b)
+ .join(",")}`;
+ }
+ if (
+ context.columnsWithSelectedCells &&
+ context.columnsWithSelectedCells.size !== undefined
+ ) {
+ hash += `|columnsWithSelectedCells:${Array.from(
+ context.columnsWithSelectedCells as Set,
+ )
+ .sort((a, b) => a - b)
+ .join(",")}`;
+ }
+ if (
+ context.rowsWithSelectedCells &&
+ context.rowsWithSelectedCells.size !== undefined
+ ) {
+ hash += `|rowsWithSelectedCells:${Array.from(
+ context.rowsWithSelectedCells as Set,
+ )
+ .sort()
+ .join(",")}`;
+ }
+
+ return hash;
+ }
+
+ private getCachedBodyCells(
+ sectionKey: string,
+ headers: HeaderObject[],
+ rows: TableRow[],
+ collapsedHeaders: Set,
+ rowHeight: number,
+ heightOffsets?: Array<[number, number]>,
+ customTheme?: any,
+ startColIndex: number = 0,
+ fullTableRows?: TableRow[],
+ renderedStartIndex?: number,
+ renderedEndIndex?: number,
+ ): AbsoluteBodyCell[] {
+ const headersHash = this.createHeadersHash(headers);
+ const heightOffsetsHash = this.createHeightOffsetsHash(heightOffsets);
+ const useRangeCache =
+ fullTableRows != null &&
+ renderedStartIndex != null &&
+ renderedEndIndex != null;
+
+ const cached = this.bodyCellsCache.get(sectionKey);
+
+ const cacheHit =
+ cached &&
+ cached.deps.headersHash === headersHash &&
+ cached.deps.collapsedHeadersSize === collapsedHeaders.size &&
+ cached.deps.rowHeight === rowHeight &&
+ cached.deps.heightOffsetsHash === heightOffsetsHash &&
+ (useRangeCache
+ ? cached.deps.fullTableRowsRef === fullTableRows &&
+ cached.deps.renderedStartIndex === renderedStartIndex &&
+ cached.deps.renderedEndIndex === renderedEndIndex
+ : cached.deps.rowsRef === rows);
+
+ if (cacheHit) {
+ return cached.cells;
+ }
+
+ const rowsToCompute = useRangeCache
+ ? fullTableRows!.slice(renderedStartIndex!, renderedEndIndex!)
+ : rows;
+
+ const cells = this.calculateAbsoluteBodyCells(
+ headers,
+ rowsToCompute,
+ collapsedHeaders,
+ rowHeight,
+ heightOffsets,
+ customTheme,
+ startColIndex,
+ );
+
+ this.bodyCellsCache.set(sectionKey, {
+ cells,
+ deps: {
+ headersHash,
+ rowsRef: rowsToCompute,
+ collapsedHeadersSize: collapsedHeaders.size,
+ rowHeight,
+ heightOffsetsHash,
+ ...(useRangeCache && {
+ fullTableRowsRef: fullTableRows,
+ renderedStartIndex,
+ renderedEndIndex,
+ }),
+ },
+ });
+
+ return cells;
+ }
+
+ private getCachedHeaderCells(
+ sectionKey: string,
+ headers: HeaderObject[],
+ collapsedHeaders: Set,
+ maxDepth: number,
+ headerHeight: number,
+ startColIndex: number = 0,
+ ): AbsoluteCell[] {
+ const cached = this.headerCellsCache.get(sectionKey);
+
+ const headersHash = this.createHeadersHash(headers);
+
+ if (
+ cached &&
+ cached.deps.headersHash === headersHash &&
+ cached.deps.collapsedHeadersSize === collapsedHeaders.size &&
+ cached.deps.maxDepth === maxDepth &&
+ cached.deps.headerHeight === headerHeight
+ ) {
+ return cached.cells;
+ }
+
+ const cells = this.calculateAbsoluteHeaderCells(
+ headers,
+ collapsedHeaders,
+ maxDepth,
+ headerHeight,
+ startColIndex,
+ );
+
+ this.headerCellsCache.set(sectionKey, {
+ cells,
+ deps: {
+ headersHash,
+ collapsedHeadersSize: collapsedHeaders.size,
+ maxDepth,
+ headerHeight,
+ },
+ });
+
+ return cells;
+ }
+
+ private getCachedContext(
+ cacheKey: string,
+ context: T,
+ pinned?: "left" | "right",
+ ): T {
+ const cached = this.contextCache.get(cacheKey);
+ const contextHash = this.createContextHash(context);
+
+ if (cached && cached.deps.contextHash === contextHash) {
+ return cached.context as T;
+ }
+
+ const newContext = { ...context, pinned };
+ this.contextCache.set(cacheKey, {
+ context: newContext,
+ deps: { contextHash },
+ });
+
+ return newContext as T;
+ }
+
+ invalidateCache(type?: "body" | "header" | "context" | "all"): void {
+ if (!type || type === "all") {
+ this.bodyCellsCache.clear();
+ this.headerCellsCache.clear();
+ this.contextCache.clear();
+ // Clear rendered cell elements from all body sections
+ this.bodySections.forEach((section) => {
+ cleanupBodyCellRendering(section);
+ });
+ // Clear rendered cell elements from all header sections
+ this.headerSections.forEach((section) => {
+ cleanupHeaderCellRendering(section);
+ });
+ } else if (type === "body") {
+ // Only clear the calculated cells cache so we recompute the cell list (e.g. after expand/collapse).
+ // Do NOT clear rendered cell elements: renderBodyCells will update existing cells in place
+ // (so expand icon can animate) and remove only cells no longer visible.
+ this.bodyCellsCache.clear();
+ } else if (type === "header") {
+ this.headerCellsCache.clear();
+ // Clear rendered cell elements from all header sections
+ this.headerSections.forEach((section) => {
+ cleanupHeaderCellRendering(section);
+ });
+ } else if (type === "context") {
+ this.contextCache.clear();
+ // Clear header rendered elements so sort indicators etc. update.
+ // Do NOT clear body rendered elements: renderBodyCells will update existing cells
+ // in place (e.g. selection classes, expand icon state) so expand icon can animate.
+ this.headerSections.forEach((section) => {
+ cleanupHeaderCellRendering(section);
+ });
+ }
+ }
+
+ /**
+ * Get the next colIndex after rendering a section
+ */
+ getNextColIndex(sectionKey: string): number {
+ return this.nextColIndexMap.get(sectionKey) ?? 0;
+ }
+
+ cleanup(): void {
+ this.headerSections.clear();
+ this.bodySections.clear();
+ this.bodyCellsCache.clear();
+ this.headerCellsCache.clear();
+ this.contextCache.clear();
+ this.nextColIndexMap.clear();
+ }
+}
diff --git a/packages/core/src/core/rendering/TableRenderer.ts b/packages/core/src/core/rendering/TableRenderer.ts
new file mode 100644
index 000000000..f57c9606c
--- /dev/null
+++ b/packages/core/src/core/rendering/TableRenderer.ts
@@ -0,0 +1,972 @@
+import HeaderObject, { Accessor } from "../../types/HeaderObject";
+import { SimpleTableConfig } from "../../types/SimpleTableConfig";
+import { CustomTheme } from "../../types/CustomTheme";
+import { FilterCondition } from "../../types/FilterTypes";
+import { SectionRenderer } from "./SectionRenderer";
+import { HeaderRenderContext } from "../../utils/headerCellRenderer";
+import { CellRenderContext } from "../../utils/bodyCellRenderer";
+import { createTableFooter } from "../../utils/footer/createTableFooter";
+import { createColumnEditor } from "../../utils/columnEditor/createColumnEditor";
+import {
+ createHorizontalScrollbar,
+ cleanupHorizontalScrollbar,
+} from "../../utils/horizontalScrollbarRenderer";
+import {
+ createStickyParentsContainer,
+ cleanupStickyParentsContainer,
+} from "../../utils/stickyParentsRenderer";
+import { DimensionManager } from "../../managers/DimensionManager";
+import type { SectionScrollController } from "../../managers/SectionScrollController";
+import { SortManager } from "../../managers/SortManager";
+import { FilterManager } from "../../managers/FilterManager";
+import { SelectionManager } from "../../managers/SelectionManager";
+import { RowSelectionManager } from "../../managers/RowSelectionManager";
+import { recalculateAllSectionWidths } from "../../utils/resizeUtils/sectionWidths";
+import { canDisplaySection } from "../../utils/generalUtils";
+
+export interface TableRendererDeps {
+ cellRegistry: Map;
+ collapsedHeaders: Set;
+ collapsedRows: Map;
+ config: SimpleTableConfig;
+ customTheme: CustomTheme;
+ dimensionManager: DimensionManager | null;
+ draggedHeaderRef: { current: HeaderObject | null };
+ effectiveHeaders: HeaderObject[];
+ essentialAccessors: Set;
+ expandedDepths: Set;
+ expandedRows: Map;
+ filterManager: FilterManager | null;
+ getCollapsedHeaders?: () => Set;
+ getCollapsedRows: () => Map;
+ getExpandedRows: () => Map;
+ getRowStateMap: () => Map;
+ headerRegistry: Map;
+ headers: HeaderObject[];
+ hoveredHeaderRef: { current: HeaderObject | null };
+ internalIsLoading: boolean;
+ isResizing: boolean;
+ localRows: any[];
+ mainBodyRef: { current: HTMLDivElement | null };
+ mainHeaderRef: { current: HTMLDivElement | null };
+ onRender: () => void;
+ pinnedLeftHeaderRef: { current: HTMLDivElement | null };
+ pinnedLeftRef: { current: HTMLDivElement | null };
+ pinnedRightHeaderRef: { current: HTMLDivElement | null };
+ pinnedRightRef: { current: HTMLDivElement | null };
+ positionOnlyBody?: boolean; /** When true, body sections use position-only updates for existing cells (scroll performance). */
+ resolvedIcons: any;
+ rowSelectionManager: RowSelectionManager | null;
+ rowStateMap: Map;
+ sectionScrollController: SectionScrollController | null;
+ selectionManager: SelectionManager | null;
+ setCollapsedHeaders: (headers: Set) => void;
+ setCollapsedRows: (rows: Map) => void;
+ setExpandedRows: (rows: Map) => void;
+ setHeaders: (headers: HeaderObject[]) => void;
+ setIsResizing: (value: boolean) => void;
+ setRowStateMap: (map: Map) => void;
+ sortManager: SortManager | null;
+}
+
+export class TableRenderer {
+ private sectionRenderer: SectionRenderer;
+ private footerInstance: ReturnType | null = null;
+ private columnEditorInstance: ReturnType | null =
+ null;
+ private horizontalScrollbarRef: { current: HTMLElement | null } = {
+ current: null,
+ };
+ private scrollbarTimeoutId: number | null = null;
+ private stickyParentsContainer: HTMLElement | null = null;
+ private sectionScrollController: SectionScrollController | null = null;
+ private renderScheduled: boolean = false;
+ private pendingRenderCallback: (() => void) | null = null;
+
+ constructor() {
+ this.sectionRenderer = new SectionRenderer();
+ }
+
+ private scheduleRender(callback: () => void): void {
+ if (!this.renderScheduled) {
+ this.renderScheduled = true;
+ this.pendingRenderCallback = callback;
+ queueMicrotask(() => {
+ this.renderScheduled = false;
+ if (this.pendingRenderCallback) {
+ this.pendingRenderCallback();
+ this.pendingRenderCallback = null;
+ }
+ });
+ }
+ }
+
+ invalidateCache(type?: "body" | "header" | "context" | "all"): void {
+ this.sectionRenderer.invalidateCache(type);
+ }
+
+ renderHeader(
+ container: HTMLElement,
+ calculatedHeaderHeight: number,
+ maxHeaderDepth: number,
+ deps: TableRendererDeps,
+ ): void {
+ if (!container || deps.config.hideHeader) return;
+
+ container.style.height = `${calculatedHeaderHeight}px`;
+
+ // When no section has visible columns, apply minHeight so the header doesn't collapse
+ // and the column editor / reset button remain accessible.
+ const hasAnyVisibleSection =
+ canDisplaySection(deps.effectiveHeaders, "left") ||
+ canDisplaySection(deps.effectiveHeaders, undefined) ||
+ canDisplaySection(deps.effectiveHeaders, "right");
+ container.style.minHeight = hasAnyVisibleSection ? "" : `${calculatedHeaderHeight}px`;
+ container.setAttribute("aria-rowcount", String(1 + deps.localRows.length));
+ container.setAttribute(
+ "aria-colcount",
+ String(deps.effectiveHeaders.length),
+ );
+
+ const dimensionState = deps.dimensionManager?.getState() ?? {
+ containerWidth: 0,
+ calculatedHeaderHeight: deps.customTheme.headerHeight,
+ maxHeaderDepth: 1,
+ };
+
+ const { mainWidth, leftWidth, rightWidth } = recalculateAllSectionWidths({
+ headers: deps.effectiveHeaders,
+ containerWidth: dimensionState.containerWidth,
+ collapsedHeaders: deps.collapsedHeaders,
+ });
+
+ const sortState = deps.sortManager?.getState();
+ const filterState = deps.filterManager?.getState();
+
+ const headerSelectedRowCount =
+ deps.rowSelectionManager?.getSelectedRowCount() ?? 0;
+ const headerContext: HeaderRenderContext = {
+ reverse: false,
+ collapsedHeaders: deps.collapsedHeaders,
+ getCollapsedHeaders: deps.getCollapsedHeaders,
+ columnBorders: deps.config.columnBorders ?? false,
+ columnReordering: deps.config.columnReordering ?? false,
+ columnResizing: deps.config.columnResizing ?? false,
+ containerWidth: dimensionState.containerWidth,
+ mainSectionContainerWidth: mainWidth,
+ enableHeaderEditing: deps.config.enableHeaderEditing,
+ enableRowSelection: deps.config.enableRowSelection,
+ selectedRowCount: headerSelectedRowCount,
+ filters: filterState?.filters ?? {},
+ icons: deps.resolvedIcons,
+ ...(deps.config.selectableColumns && deps.selectionManager
+ ? {
+ selectedColumns: deps.selectionManager.getSelectedColumns(),
+ columnsWithSelectedCells:
+ deps.selectionManager.getColumnsWithSelectedCells(),
+ }
+ : {
+ selectedColumns: new Set(),
+ columnsWithSelectedCells: new Set(),
+ }),
+ sort: sortState?.sort ?? null,
+ autoExpandColumns: deps.config.autoExpandColumns ?? false,
+ essentialAccessors: deps.essentialAccessors,
+ selectableColumns: deps.config.selectableColumns,
+ headers: deps.effectiveHeaders,
+ rows: deps.localRows,
+ headerHeight: deps.customTheme.headerHeight,
+ lastHeaderIndex: deps.effectiveHeaders.length - 1,
+ onSort: (accessor: Accessor) => {
+ if (deps.sortManager) {
+ deps.sortManager.updateSort({ accessor });
+ }
+ },
+ handleApplyFilter: (filter: FilterCondition) => {
+ if (deps.filterManager) {
+ deps.filterManager.updateFilter(filter);
+ }
+ },
+ handleClearFilter: (accessor: Accessor) => {
+ if (deps.filterManager) {
+ deps.filterManager.clearFilter(accessor);
+ }
+ },
+ handleSelectAll: (checked: boolean) => {
+ deps.rowSelectionManager?.handleSelectAll(checked);
+ },
+ setCollapsedHeaders: (value: any) => {
+ if (typeof value === "function") {
+ deps.setCollapsedHeaders(value(deps.collapsedHeaders));
+ } else {
+ deps.setCollapsedHeaders(value);
+ }
+ deps.onRender();
+ },
+ setHeaders: (value: any) => {
+ if (typeof value === "function") {
+ deps.setHeaders(value(deps.headers));
+ } else {
+ deps.setHeaders(value);
+ }
+ deps.onRender();
+ },
+ setIsResizing: (value: any) => {
+ deps.setIsResizing(
+ typeof value === "function" ? value(deps.isResizing) : value,
+ );
+ },
+ onColumnWidthChange: deps.config.onColumnWidthChange,
+ onColumnOrderChange: deps.config.onColumnOrderChange,
+ onTableHeaderDragEnd: (headers: HeaderObject[]) => {
+ deps.setHeaders(headers);
+ deps.onRender();
+ },
+ onHeaderEdit: deps.config.onHeaderEdit,
+ onColumnSelect: deps.config.onColumnSelect,
+ selectColumns:
+ deps.selectionManager && deps.config.selectableColumns
+ ? (columnIndices: number[], isShiftKey?: boolean) => {
+ deps.selectionManager!.selectColumns(columnIndices, isShiftKey);
+ deps.onRender();
+ }
+ : (columnIndices: number[]) => {},
+ setSelectedColumns:
+ deps.selectionManager && deps.config.selectableColumns
+ ? (value: Set | ((prev: Set) => Set)) => {
+ const prev = deps.selectionManager!.getSelectedColumns();
+ const next = typeof value === "function" ? value(prev) : value;
+ deps.selectionManager!.setSelectedColumns(next);
+ deps.onRender();
+ }
+ : (value: any) => {},
+ setSelectedCells: deps.selectionManager
+ ? (value: Set | ((prev: Set) => Set)) => {
+ const prev = deps.selectionManager!.getSelectedCells();
+ const next = typeof value === "function" ? value(prev) : value;
+ deps.selectionManager!.setSelectedCells(
+ next instanceof Set ? next : new Set(),
+ );
+ deps.onRender?.();
+ }
+ : (value: any) => {},
+ setInitialFocusedCell: deps.selectionManager
+ ? (
+ cell: { rowIndex: number; colIndex: number; rowId: string } | null,
+ ) => {
+ deps.selectionManager!.setInitialFocusedCell(cell ?? null);
+ deps.onRender?.();
+ }
+ : (cell: any) => {},
+ areAllRowsSelected: () =>
+ deps.rowSelectionManager?.areAllRowsSelected() ?? false,
+ draggedHeaderRef: deps.draggedHeaderRef,
+ hoveredHeaderRef: deps.hoveredHeaderRef,
+ headerRegistry: deps.headerRegistry,
+ forceUpdate: () => deps.onRender(),
+ mainBodyRef: deps.mainBodyRef,
+ pinnedLeftRef: deps.pinnedLeftRef,
+ pinnedRightRef: deps.pinnedRightRef,
+ };
+
+ const pinnedLeftHeaders = deps.effectiveHeaders.filter(
+ (h) => h.pinned === "left",
+ );
+ const mainHeaders = deps.effectiveHeaders.filter((h) => !h.pinned);
+ const pinnedRightHeaders = deps.effectiveHeaders.filter(
+ (h) => h.pinned === "right",
+ );
+
+ // Calculate startColIndex for each section to ensure global uniqueness
+ let currentColIndex = 0;
+
+ // Track which sections should exist (like React's component list)
+ const sectionsToKeep: HTMLElement[] = [];
+
+ if (pinnedLeftHeaders.length > 0) {
+ const leftSection = this.sectionRenderer.renderHeaderSection({
+ headers: deps.effectiveHeaders,
+ collapsedHeaders: deps.collapsedHeaders,
+ pinned: "left",
+ maxHeaderDepth,
+ headerHeight: deps.customTheme.headerHeight,
+ context: headerContext,
+ sectionWidth: leftWidth,
+ startColIndex: currentColIndex,
+ });
+ deps.pinnedLeftHeaderRef.current = leftSection as HTMLDivElement;
+ sectionsToKeep.push(leftSection);
+ if (!container.contains(leftSection)) {
+ container.appendChild(leftSection);
+ }
+ // Update colIndex for next section
+ currentColIndex = this.sectionRenderer.getNextColIndex("left");
+ }
+
+ if (mainHeaders.length > 0) {
+ const mainSection = this.sectionRenderer.renderHeaderSection({
+ headers: deps.effectiveHeaders,
+ collapsedHeaders: deps.collapsedHeaders,
+ maxHeaderDepth,
+ headerHeight: deps.customTheme.headerHeight,
+ context: headerContext,
+ sectionWidth: mainWidth,
+ startColIndex: currentColIndex,
+ });
+ deps.mainHeaderRef.current = mainSection as HTMLDivElement;
+ sectionsToKeep.push(mainSection);
+ if (!container.contains(mainSection)) {
+ container.appendChild(mainSection);
+ }
+ // Update colIndex for next section
+ currentColIndex = this.sectionRenderer.getNextColIndex("main");
+ }
+
+ if (pinnedRightHeaders.length > 0) {
+ const rightSection = this.sectionRenderer.renderHeaderSection({
+ headers: deps.effectiveHeaders,
+ collapsedHeaders: deps.collapsedHeaders,
+ pinned: "right",
+ maxHeaderDepth,
+ headerHeight: deps.customTheme.headerHeight,
+ context: headerContext,
+ sectionWidth: rightWidth,
+ startColIndex: currentColIndex,
+ });
+ deps.pinnedRightHeaderRef.current = rightSection as HTMLDivElement;
+ sectionsToKeep.push(rightSection);
+ if (!container.contains(rightSection)) {
+ container.appendChild(rightSection);
+ }
+ }
+
+ // Remove any orphaned sections (like React unmounting components)
+ Array.from(container.children).forEach((child) => {
+ if (!sectionsToKeep.includes(child as HTMLElement)) {
+ child.remove();
+ }
+ });
+ }
+
+ renderBody(
+ container: HTMLElement,
+ processedResult: any,
+ deps: TableRendererDeps,
+ ): void {
+ if (!container) return;
+
+ // When no section has visible columns, apply minHeight so the table keeps its height
+ // and the column editor / reset button remain accessible.
+ const hasAnyVisibleBodySection =
+ canDisplaySection(deps.effectiveHeaders, "left") ||
+ canDisplaySection(deps.effectiveHeaders, undefined) ||
+ canDisplaySection(deps.effectiveHeaders, "right");
+ if (!hasAnyVisibleBodySection) {
+ const totalHeight = processedResult?.heightMap?.totalHeight ?? 0;
+ container.style.minHeight = `${totalHeight}px`;
+ } else {
+ container.style.minHeight = "";
+ }
+
+ const rowsToRender =
+ processedResult.rowsToRender || processedResult.currentTableRows;
+ const shouldShowEmptyState =
+ !deps.internalIsLoading && processedResult.currentTableRows.length === 0;
+
+ // Update SelectionManager with processed table rows; use minimal update when scroll-only for performance
+ if (deps.selectionManager && processedResult.currentTableRows) {
+ deps.selectionManager.updateConfig(
+ {
+ tableRows: processedResult.currentTableRows,
+ headers: deps.effectiveHeaders,
+ collapsedHeaders: deps.collapsedHeaders,
+ },
+ { positionOnlyBody: deps.positionOnlyBody },
+ );
+ }
+
+ if (shouldShowEmptyState) {
+ container.innerHTML = "";
+ const emptyWrapper = document.createElement("div");
+ emptyWrapper.className = "st-empty-state-wrapper";
+
+ if (typeof deps.config.tableEmptyStateRenderer === "string") {
+ emptyWrapper.textContent = deps.config.tableEmptyStateRenderer;
+ } else if (deps.config.tableEmptyStateRenderer instanceof HTMLElement) {
+ emptyWrapper.appendChild(
+ deps.config.tableEmptyStateRenderer.cloneNode(true),
+ );
+ } else {
+ emptyWrapper.innerHTML =
+ "No rows to display
";
+ }
+
+ container.appendChild(emptyWrapper);
+ return;
+ }
+
+ const dimensionState = deps.dimensionManager?.getState() ?? {
+ containerWidth: 0,
+ calculatedHeaderHeight: deps.customTheme.headerHeight,
+ maxHeaderDepth: 1,
+ };
+
+ const { mainWidth, leftWidth, rightWidth } = recalculateAllSectionWidths({
+ headers: deps.effectiveHeaders,
+ containerWidth: dimensionState.containerWidth,
+ collapsedHeaders: deps.collapsedHeaders,
+ });
+
+ const selectedRowCount =
+ deps.rowSelectionManager?.getSelectedRowCount() ?? 0;
+ const maxHeaderDepth = dimensionState.maxHeaderDepth ?? 1;
+ const bodyContext: CellRenderContext = {
+ collapsedHeaders: deps.collapsedHeaders,
+ collapsedRows: deps.getCollapsedRows(),
+ expandedRows: deps.getExpandedRows(),
+ expandedDepths: Array.from(deps.expandedDepths),
+ selectedColumns: deps.selectionManager?.getSelectedColumns() ?? new Set(),
+ rowsWithSelectedCells:
+ deps.selectionManager?.getRowsWithSelectedCells() ?? new Set(),
+ columnBorders: deps.config.columnBorders ?? false,
+ enableRowSelection: deps.config.enableRowSelection,
+ selectedRowCount,
+ cellUpdateFlash: deps.config.cellUpdateFlash,
+ useOddColumnBackground: deps.config.useOddColumnBackground,
+ useHoverRowBackground: deps.config.useHoverRowBackground,
+ useOddEvenRowBackground: deps.config.useOddEvenRowBackground,
+ rowGrouping: deps.config.rowGrouping,
+ headers: deps.effectiveHeaders,
+ rowHeight: deps.customTheme.rowHeight,
+ maxHeaderDepth,
+ heightOffsets: processedResult.paginatedHeightOffsets,
+ customTheme: deps.customTheme,
+ containerWidth: dimensionState.containerWidth,
+ mainSectionContainerWidth: mainWidth,
+ onCellEdit: deps.config.onCellEdit,
+ onCellClick: deps.config.onCellClick,
+ onRowGroupExpand: deps.config.onRowGroupExpand,
+ handleRowSelect: (rowId: string, checked: boolean) => {
+ deps.rowSelectionManager?.handleRowSelect(rowId, checked);
+ },
+ cellRegistry: deps.cellRegistry,
+ getCollapsedRows: () => deps.getCollapsedRows(),
+ getExpandedRows: () => deps.getExpandedRows(),
+ setCollapsedRows: (value: any) => {
+ if (typeof value === "function") {
+ deps.setCollapsedRows(value(deps.getCollapsedRows()));
+ } else {
+ deps.setCollapsedRows(value);
+ }
+ // Batch multiple state updates together
+ this.scheduleRender(deps.onRender);
+ },
+ setExpandedRows: (value: any) => {
+ if (typeof value === "function") {
+ deps.setExpandedRows(value(deps.getExpandedRows()));
+ } else {
+ deps.setExpandedRows(value);
+ }
+ // Batch multiple state updates together
+ this.scheduleRender(deps.onRender);
+ },
+ setRowStateMap: (value: any) => {
+ if (typeof value === "function") {
+ deps.setRowStateMap(value(deps.getRowStateMap()));
+ } else {
+ deps.setRowStateMap(value);
+ }
+ // Batch multiple state updates together
+ this.scheduleRender(deps.onRender);
+ },
+ icons: deps.resolvedIcons,
+ theme: deps.config.theme ?? "modern-light",
+ rowButtons: deps.config.rowButtons,
+ loadingStateRenderer: deps.config.loadingStateRenderer,
+ errorStateRenderer: deps.config.errorStateRenderer,
+ emptyStateRenderer: deps.config.emptyStateRenderer,
+ getBorderClass: (cell: any) =>
+ deps.selectionManager?.getBorderClass(cell) || "",
+ isSelected: (cell: any) =>
+ deps.selectionManager?.isSelected(cell) || false,
+ isInitialFocusedCell: (cell: any) =>
+ deps.selectionManager?.isInitialFocusedCell(cell) || false,
+ isCopyFlashing: (cell: any) =>
+ deps.selectionManager?.isCopyFlashing(cell) || false,
+ isWarningFlashing: (cell: any) =>
+ deps.selectionManager?.isWarningFlashing(cell) || false,
+ handleMouseDown: (cell: any) =>
+ deps.selectionManager?.handleMouseDown(cell),
+ handleMouseOver: (cell: any) =>
+ deps.selectionManager?.handleMouseOver(cell),
+ isRowSelected: (rowId: string) =>
+ deps.rowSelectionManager?.isRowSelected(rowId) ?? false,
+ canExpandRowGroup: deps.config.canExpandRowGroup,
+ isLoading: deps.internalIsLoading,
+ };
+
+ const pinnedLeftHeaders = deps.effectiveHeaders.filter(
+ (h) => h.pinned === "left",
+ );
+ const mainHeaders = deps.effectiveHeaders.filter((h) => !h.pinned);
+ const pinnedRightHeaders = deps.effectiveHeaders.filter(
+ (h) => h.pinned === "right",
+ );
+
+ // Calculate startColIndex for each section to ensure global uniqueness
+ let currentColIndex = 0;
+
+ // Track which sections should exist (like React's component list)
+ const sectionsToKeep: HTMLElement[] = [];
+
+ if (pinnedLeftHeaders.length > 0) {
+ const leftSection = this.sectionRenderer.renderBodySection({
+ headers: deps.effectiveHeaders,
+ rows: rowsToRender,
+ collapsedHeaders: deps.collapsedHeaders,
+ pinned: "left",
+ context: bodyContext,
+ sectionWidth: leftWidth,
+ rowHeight: deps.customTheme.rowHeight,
+ heightOffsets: processedResult.paginatedHeightOffsets,
+ totalRowCount: processedResult.currentTableRows.length,
+ startColIndex: currentColIndex,
+ positionOnly: deps.positionOnlyBody,
+ fullTableRows: processedResult.currentTableRows,
+ renderedStartIndex: processedResult.renderedStartIndex,
+ renderedEndIndex: processedResult.renderedEndIndex,
+ });
+ deps.pinnedLeftRef.current = leftSection as HTMLDivElement;
+ sectionsToKeep.push(leftSection);
+ if (!container.contains(leftSection)) {
+ container.appendChild(leftSection);
+ }
+ // Update colIndex for next section
+ currentColIndex = this.sectionRenderer.getNextColIndex("left");
+ }
+
+ if (mainHeaders.length > 0) {
+ const mainSection = this.sectionRenderer.renderBodySection({
+ headers: deps.effectiveHeaders,
+ rows: rowsToRender,
+ collapsedHeaders: deps.collapsedHeaders,
+ context: bodyContext,
+ sectionWidth: mainWidth,
+ rowHeight: deps.customTheme.rowHeight,
+ heightOffsets: processedResult.paginatedHeightOffsets,
+ totalRowCount: processedResult.currentTableRows.length,
+ startColIndex: currentColIndex,
+ positionOnly: deps.positionOnlyBody,
+ fullTableRows: processedResult.currentTableRows,
+ renderedStartIndex: processedResult.renderedStartIndex,
+ renderedEndIndex: processedResult.renderedEndIndex,
+ });
+ deps.mainBodyRef.current = mainSection as HTMLDivElement;
+ sectionsToKeep.push(mainSection);
+ if (!container.contains(mainSection)) {
+ container.appendChild(mainSection);
+ }
+ // Update colIndex for next section
+ currentColIndex = this.sectionRenderer.getNextColIndex("main");
+ }
+
+ if (pinnedRightHeaders.length > 0) {
+ const rightSection = this.sectionRenderer.renderBodySection({
+ headers: deps.effectiveHeaders,
+ rows: rowsToRender,
+ collapsedHeaders: deps.collapsedHeaders,
+ pinned: "right",
+ context: bodyContext,
+ sectionWidth: rightWidth,
+ rowHeight: deps.customTheme.rowHeight,
+ heightOffsets: processedResult.paginatedHeightOffsets,
+ totalRowCount: processedResult.currentTableRows.length,
+ startColIndex: currentColIndex,
+ positionOnly: deps.positionOnlyBody,
+ fullTableRows: processedResult.currentTableRows,
+ renderedStartIndex: processedResult.renderedStartIndex,
+ renderedEndIndex: processedResult.renderedEndIndex,
+ });
+ deps.pinnedRightRef.current = rightSection as HTMLDivElement;
+ sectionsToKeep.push(rightSection);
+ if (!container.contains(rightSection)) {
+ container.appendChild(rightSection);
+ }
+ }
+
+ // Render sticky parents if enabled
+ if (
+ deps.config.enableStickyParents &&
+ processedResult.stickyParents &&
+ processedResult.stickyParents.length > 0
+ ) {
+ // Clean up old sticky parents container
+ if (this.stickyParentsContainer) {
+ cleanupStickyParentsContainer(
+ this.stickyParentsContainer,
+ deps.sectionScrollController ?? null,
+ );
+ this.stickyParentsContainer = null;
+ }
+
+ // Get scroll state
+ const scrollTop = deps.mainBodyRef.current?.scrollTop ?? 0;
+ const scrollbarWidth = deps.mainBodyRef.current
+ ? deps.mainBodyRef.current.offsetWidth -
+ deps.mainBodyRef.current.clientWidth
+ : 0;
+
+ // Create sticky parents container
+ this.stickyParentsContainer = createStickyParentsContainer(
+ {
+ calculatedHeaderHeight: dimensionState.calculatedHeaderHeight,
+ heightMap: processedResult.heightMap,
+ partiallyVisibleRows: processedResult.partiallyVisibleRows || [],
+ pinnedLeftColumns: pinnedLeftHeaders,
+ pinnedLeftWidth: leftWidth,
+ pinnedRightColumns: pinnedRightHeaders,
+ pinnedRightWidth: rightWidth,
+ scrollTop,
+ scrollbarWidth,
+ stickyParents: processedResult.stickyParents,
+ },
+ {
+ collapsedHeaders: deps.collapsedHeaders,
+ customTheme: deps.customTheme,
+ editColumns: deps.config.editColumns ?? false,
+ headers: deps.effectiveHeaders,
+ rowHeight: deps.customTheme.rowHeight,
+ heightOffsets: processedResult.paginatedHeightOffsets,
+ cellRenderContext: bodyContext,
+ sectionScrollController: deps.sectionScrollController ?? null,
+ },
+ );
+
+ if (this.stickyParentsContainer) {
+ sectionsToKeep.push(this.stickyParentsContainer);
+ if (!container.contains(this.stickyParentsContainer)) {
+ container.appendChild(this.stickyParentsContainer);
+ }
+ }
+ } else {
+ // Clean up sticky parents if disabled or no sticky parents
+ if (this.stickyParentsContainer) {
+ cleanupStickyParentsContainer(
+ this.stickyParentsContainer,
+ deps.sectionScrollController ?? null,
+ );
+ this.stickyParentsContainer = null;
+ }
+ }
+
+ // Remove any orphaned sections (like React unmounting components)
+ Array.from(container.children).forEach((child) => {
+ if (!sectionsToKeep.includes(child as HTMLElement)) {
+ child.remove();
+ }
+ });
+ }
+
+ renderFooter(
+ container: HTMLElement,
+ totalRows: number,
+ currentPage: number,
+ onPageChange: (page: number) => void,
+ deps: TableRendererDeps,
+ ): void {
+ if (!container) return;
+
+ const hasCustomFooter = Boolean(deps.config.footerRenderer);
+ const hasPaginationFooter = deps.config.shouldPaginate && !deps.config.hideFooter;
+
+ if (!hasCustomFooter && !hasPaginationFooter) {
+ container.innerHTML = "";
+ return;
+ }
+
+ const rowsPerPage = deps.config.rowsPerPage ?? 10;
+ const totalPages = Math.ceil(totalRows / rowsPerPage);
+
+ if (hasCustomFooter) {
+ const startRow = (currentPage - 1) * rowsPerPage + 1;
+ const endRow = Math.min(currentPage * rowsPerPage, totalRows);
+ const renderedContent = deps.config.footerRenderer!({
+ currentPage,
+ endRow,
+ hasNextPage: currentPage < totalPages,
+ hasPrevPage: currentPage > 1,
+ nextIcon: deps.resolvedIcons?.next,
+ onNextPage: async () => {
+ if (currentPage < totalPages) {
+ onPageChange(currentPage + 1);
+ if (deps.config.onNextPage) await deps.config.onNextPage(currentPage + 1);
+ }
+ },
+ onPageChange,
+ onPrevPage: () => {
+ if (currentPage > 1) onPageChange(currentPage - 1);
+ },
+ prevIcon: deps.resolvedIcons?.prev,
+ rowsPerPage,
+ startRow,
+ totalPages,
+ totalRows,
+ });
+
+ container.innerHTML = "";
+ if (renderedContent instanceof HTMLElement) {
+ container.appendChild(renderedContent);
+ } else if (typeof renderedContent === "string") {
+ container.innerHTML = renderedContent;
+ }
+ this.footerInstance = null;
+ return;
+ }
+
+ if (this.footerInstance) {
+ this.footerInstance.update({
+ currentPage,
+ hideFooter: deps.config.hideFooter ?? false,
+ onPageChange,
+ onNextPage: deps.config.onNextPage,
+ onUserPageChange: deps.config.onPageChange,
+ rowsPerPage,
+ shouldPaginate: deps.config.shouldPaginate ?? false,
+ totalPages,
+ totalRows,
+ prevIcon: deps.resolvedIcons?.prev,
+ nextIcon: deps.resolvedIcons?.next,
+ });
+ } else {
+ container.innerHTML = "";
+ const footer = createTableFooter({
+ currentPage,
+ hideFooter: deps.config.hideFooter ?? false,
+ onPageChange,
+ onNextPage: deps.config.onNextPage,
+ onUserPageChange: deps.config.onPageChange,
+ rowsPerPage,
+ shouldPaginate: deps.config.shouldPaginate ?? false,
+ totalPages,
+ totalRows,
+ prevIcon: deps.resolvedIcons?.prev,
+ nextIcon: deps.resolvedIcons?.next,
+ });
+ this.footerInstance = footer;
+ container.appendChild(footer.element);
+ }
+ }
+
+ renderColumnEditor(
+ contentWrapper: HTMLElement,
+ columnEditorOpen: boolean,
+ setColumnEditorOpen: (open: boolean) => void,
+ mergedColumnEditorConfig: any,
+ deps: TableRendererDeps,
+ ): void {
+ if (!contentWrapper) return;
+
+ if (!deps.config.editColumns) {
+ if (this.columnEditorInstance) {
+ this.columnEditorInstance.destroy();
+ this.columnEditorInstance = null;
+ }
+ return;
+ }
+
+ const resetColumns = () => {
+ const defaultHeaders = deps.config.defaultHeaders;
+ if (defaultHeaders) {
+ const cloned = defaultHeaders.map((h: HeaderObject) => ({ ...h }));
+ deps.setHeaders(cloned);
+ deps.onRender();
+ }
+ };
+
+ if (this.columnEditorInstance) {
+ this.columnEditorInstance.update({
+ columnEditorText: mergedColumnEditorConfig.text,
+ editColumns: deps.config.editColumns,
+ headers: deps.headers,
+ open: columnEditorOpen,
+ searchEnabled: mergedColumnEditorConfig.searchEnabled,
+ searchPlaceholder: mergedColumnEditorConfig.searchPlaceholder,
+ searchFunction: mergedColumnEditorConfig.searchFunction,
+ columnEditorConfig: mergedColumnEditorConfig,
+ contextHeaders: deps.headers,
+ essentialAccessors: deps.essentialAccessors,
+ setHeaders: (newHeaders: HeaderObject[]) => {
+ deps.setHeaders(newHeaders);
+ if (this.columnEditorInstance) {
+ this.columnEditorInstance.update({
+ headers: newHeaders,
+ contextHeaders: newHeaders,
+ });
+ }
+ deps.onRender();
+ },
+ onColumnVisibilityChange: deps.config.onColumnVisibilityChange,
+ onColumnOrderChange: deps.config.onColumnOrderChange,
+ resetColumns,
+ setOpen: setColumnEditorOpen,
+ });
+ } else {
+ const columnEditor = createColumnEditor({
+ columnEditorText: mergedColumnEditorConfig.text,
+ editColumns: deps.config.editColumns,
+ headers: deps.headers,
+ open: columnEditorOpen,
+ searchEnabled: mergedColumnEditorConfig.searchEnabled,
+ searchPlaceholder: mergedColumnEditorConfig.searchPlaceholder,
+ searchFunction: mergedColumnEditorConfig.searchFunction,
+ columnEditorConfig: mergedColumnEditorConfig,
+ contextHeaders: deps.headers,
+ essentialAccessors: deps.essentialAccessors,
+ setHeaders: (newHeaders: HeaderObject[]) => {
+ deps.setHeaders(newHeaders);
+ if (this.columnEditorInstance) {
+ this.columnEditorInstance.update({
+ headers: newHeaders,
+ contextHeaders: newHeaders,
+ });
+ }
+ deps.onRender();
+ },
+ onColumnVisibilityChange: deps.config.onColumnVisibilityChange,
+ onColumnOrderChange: deps.config.onColumnOrderChange,
+ resetColumns,
+ setOpen: setColumnEditorOpen,
+ });
+ this.columnEditorInstance = columnEditor;
+ contentWrapper.appendChild(columnEditor.element);
+ }
+ }
+
+ renderHorizontalScrollbar(
+ wrapperContainer: HTMLElement,
+ mainBodyWidth: number,
+ pinnedLeftWidth: number,
+ pinnedRightWidth: number,
+ pinnedLeftContentWidth: number,
+ pinnedRightContentWidth: number,
+ tableBodyContainerRef: HTMLDivElement,
+ deps: TableRendererDeps,
+ ): void {
+ if (
+ !wrapperContainer ||
+ !deps.mainBodyRef.current ||
+ !tableBodyContainerRef
+ ) {
+ return;
+ }
+
+ // Check if horizontal scrolling is needed
+ const clientWidth = deps.mainBodyRef.current.clientWidth;
+ const scrollWidth = deps.mainBodyRef.current.scrollWidth;
+ const threshold = 1;
+ const isScrollable = scrollWidth - clientWidth > threshold;
+
+ // If not scrollable, remove existing scrollbar if present
+ if (!isScrollable) {
+ if (this.horizontalScrollbarRef.current) {
+ cleanupHorizontalScrollbar(this.horizontalScrollbarRef.current);
+ this.horizontalScrollbarRef.current = null;
+ }
+ if (this.scrollbarTimeoutId !== null) {
+ clearTimeout(this.scrollbarTimeoutId);
+ this.scrollbarTimeoutId = null;
+ }
+ return;
+ }
+
+ // If scrollbar already exists, keep it (like React keeping component mounted)
+ if (
+ this.horizontalScrollbarRef.current &&
+ wrapperContainer.contains(this.horizontalScrollbarRef.current)
+ ) {
+ return;
+ }
+
+ // Cancel any pending scrollbar creation
+ if (this.scrollbarTimeoutId !== null) {
+ clearTimeout(this.scrollbarTimeoutId);
+ this.scrollbarTimeoutId = null;
+ }
+
+ // Create scrollbar only if it doesn't exist
+ this.scrollbarTimeoutId = window.setTimeout(() => {
+ if (
+ !deps.mainBodyRef.current ||
+ !tableBodyContainerRef ||
+ !wrapperContainer
+ ) {
+ return;
+ }
+
+ // Double-check it wasn't created by another render
+ if (
+ this.horizontalScrollbarRef.current &&
+ wrapperContainer.contains(this.horizontalScrollbarRef.current)
+ ) {
+ this.scrollbarTimeoutId = null;
+ return;
+ }
+
+ this.sectionScrollController = deps.sectionScrollController ?? null;
+ const scrollbar = createHorizontalScrollbar({
+ mainBodyRef: deps.mainBodyRef.current,
+ mainBodyWidth,
+ pinnedLeftWidth,
+ pinnedRightWidth,
+ pinnedLeftContentWidth,
+ pinnedRightContentWidth,
+ tableBodyContainerRef,
+ editColumns: deps.config.editColumns ?? false,
+ sectionScrollController: this.sectionScrollController,
+ });
+
+ if (scrollbar) {
+ const contentWrapper = wrapperContainer.querySelector(
+ ".st-content-wrapper",
+ );
+ if (contentWrapper && contentWrapper.nextSibling) {
+ wrapperContainer.insertBefore(scrollbar, contentWrapper.nextSibling);
+ } else {
+ wrapperContainer.appendChild(scrollbar);
+ }
+ this.horizontalScrollbarRef.current = scrollbar;
+ }
+
+ this.scrollbarTimeoutId = null;
+ }, 1);
+ }
+
+ cleanup(): void {
+ this.sectionRenderer.cleanup();
+ this.footerInstance?.destroy();
+ this.columnEditorInstance?.destroy();
+
+ // Cancel any pending scrollbar creation
+ if (this.scrollbarTimeoutId !== null) {
+ clearTimeout(this.scrollbarTimeoutId);
+ this.scrollbarTimeoutId = null;
+ }
+
+ if (this.horizontalScrollbarRef.current) {
+ cleanupHorizontalScrollbar(
+ this.horizontalScrollbarRef.current,
+ this.sectionScrollController,
+ );
+ this.horizontalScrollbarRef.current = null;
+ }
+
+ if (this.stickyParentsContainer) {
+ cleanupStickyParentsContainer(
+ this.stickyParentsContainer,
+ this.sectionScrollController,
+ );
+ this.stickyParentsContainer = null;
+ }
+ this.sectionScrollController = null;
+ }
+}
diff --git a/packages/core/src/hooks/ariaAnnouncements.ts b/packages/core/src/hooks/ariaAnnouncements.ts
new file mode 100644
index 000000000..35f1da429
--- /dev/null
+++ b/packages/core/src/hooks/ariaAnnouncements.ts
@@ -0,0 +1,73 @@
+/**
+ * Manages aria-live announcements for screen readers.
+ * This is a vanilla JS alternative to the useAriaAnnouncements hook.
+ *
+ * Provides a way to announce dynamic content changes to assistive technologies.
+ */
+export class AriaAnnouncementManager {
+ private announcement: string = "";
+ private timeoutId: NodeJS.Timeout | null = null;
+ private observers: Set<(message: string) => void> = new Set();
+
+ /**
+ * Announces a message to screen readers
+ * The message will be cleared after 1 second to allow for new announcements
+ * @param message - The message to announce
+ */
+ announce(message: string): void {
+ this.announcement = message;
+ this.notifyObservers();
+
+ // Clear any existing timeout
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ }
+
+ // Clear the announcement after 1 second to allow for new announcements
+ this.timeoutId = setTimeout(() => {
+ this.announcement = "";
+ this.notifyObservers();
+ }, 1000);
+ }
+
+ /**
+ * Gets the current announcement message
+ * @returns The current announcement string
+ */
+ getAnnouncement(): string {
+ return this.announcement;
+ }
+
+ /**
+ * Subscribes to announcement changes
+ * @param callback - Function to call when announcement changes
+ * @returns Unsubscribe function
+ */
+ subscribe(callback: (message: string) => void): () => void {
+ this.observers.add(callback);
+ return () => {
+ this.observers.delete(callback);
+ };
+ }
+
+ /**
+ * Notifies all observers of announcement changes
+ */
+ private notifyObservers(): void {
+ this.observers.forEach(callback => callback(this.announcement));
+ }
+
+ /**
+ * Cleans up the manager and clears any pending timeouts
+ */
+ destroy(): void {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ }
+ this.observers.clear();
+ this.announcement = "";
+ }
+}
+
+export default AriaAnnouncementManager;
diff --git a/packages/core/src/hooks/contentHeight.ts b/packages/core/src/hooks/contentHeight.ts
new file mode 100644
index 000000000..9c5c35b05
--- /dev/null
+++ b/packages/core/src/hooks/contentHeight.ts
@@ -0,0 +1,101 @@
+import { VIRTUALIZATION_THRESHOLD } from "../consts/general-consts";
+
+export interface ContentHeightConfig {
+ height?: string | number;
+ maxHeight?: string | number;
+ rowHeight: number;
+ shouldPaginate?: boolean;
+ rowsPerPage?: number;
+ totalRowCount: number;
+ headerHeight?: number;
+ footerHeight?: number;
+}
+
+/**
+ * Converts a height value (string or number) to pixels
+ */
+export const convertHeightToPixels = (heightValue: string | number): number => {
+ // Get the container element for measurement
+ const container = document.querySelector(".simple-table-root");
+
+ if (typeof heightValue === "string") {
+ if (heightValue.endsWith("px")) {
+ return parseInt(heightValue, 10);
+ } else if (heightValue.endsWith("vh")) {
+ const vh = parseInt(heightValue, 10);
+ return (window.innerHeight * vh) / 100;
+ } else if (heightValue.endsWith("%")) {
+ const percentage = parseInt(heightValue, 10);
+ const parentHeight = container?.parentElement?.clientHeight;
+ if (!parentHeight || parentHeight < 50) {
+ return 0; // Invalid parent height
+ }
+ return (parentHeight * percentage) / 100;
+ } else {
+ // Fall back to inner height if format is unknown
+ return window.innerHeight;
+ }
+ } else {
+ return heightValue as number;
+ }
+};
+
+/**
+ * Calculates the content height for the table.
+ * This is a pure function alternative to the useContentHeight hook.
+ *
+ * @param config - Configuration for content height calculation
+ * @returns The calculated content height in pixels, or undefined to disable virtualization
+ */
+export const calculateContentHeight = ({
+ height,
+ maxHeight,
+ rowHeight,
+ shouldPaginate,
+ rowsPerPage,
+ totalRowCount,
+ headerHeight,
+ footerHeight,
+}: ContentHeightConfig): number | undefined => {
+ // If maxHeight is provided, it takes precedence over height
+ if (maxHeight) {
+ const maxHeightPx = convertHeightToPixels(maxHeight);
+
+ // If conversion failed (e.g., invalid parent height for %), disable virtualization
+ if (maxHeightPx === 0) {
+ return undefined;
+ }
+
+ // Calculate actual content height needed
+ const actualHeaderHeight = headerHeight || rowHeight;
+ const actualFooterHeight = footerHeight || 0;
+ const actualContentHeight =
+ actualHeaderHeight + totalRowCount * rowHeight + actualFooterHeight;
+
+ // If content fits within maxHeight OR row count is below threshold, disable virtualization
+ if (actualContentHeight <= maxHeightPx || totalRowCount < VIRTUALIZATION_THRESHOLD) {
+ return undefined;
+ }
+
+ // Content exceeds maxHeight and we have enough rows - enable virtualization
+ // Subtract header height to get the scrollable content area height
+ return Math.max(0, maxHeightPx - actualHeaderHeight);
+ }
+
+ // When no height is specified, return undefined to disable virtualization
+ // This allows the table to grow naturally to fit all content (paginated or not)
+ if (!height) return undefined;
+
+ // Convert height to pixels
+ const totalHeightPx = convertHeightToPixels(height);
+
+ // If conversion failed, disable virtualization
+ if (totalHeightPx === 0) {
+ return undefined;
+ }
+
+ // Subtract header height
+ return Math.max(0, totalHeightPx - rowHeight);
+};
+
+export default calculateContentHeight;
diff --git a/packages/core/src/hooks/expandedDepths.ts b/packages/core/src/hooks/expandedDepths.ts
new file mode 100644
index 000000000..1772a42f4
--- /dev/null
+++ b/packages/core/src/hooks/expandedDepths.ts
@@ -0,0 +1,138 @@
+import { Accessor } from "../types/HeaderObject";
+
+/**
+ * Initialize expandedDepths based on expandAll prop and rowGrouping
+ */
+export const initializeExpandedDepths = (
+ expandAll: boolean,
+ rowGrouping?: Accessor[]
+): Set => {
+ if (!rowGrouping || rowGrouping.length === 0) return new Set();
+ if (expandAll) {
+ const depths = Array.from({ length: rowGrouping.length }, (_, i) => i);
+ return new Set(depths);
+ }
+ return new Set();
+};
+
+/**
+ * Manages expanded depths state for row grouping.
+ * This is a vanilla JS alternative to the useExpandedDepths hook.
+ */
+export class ExpandedDepthsManager {
+ private expandedDepths: Set;
+ private observers: Set<(depths: Set) => void> = new Set();
+
+ constructor(expandAll: boolean, rowGrouping?: Accessor[]) {
+ 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
+ */
+ updateRowGrouping(rowGrouping?: Accessor[]): void {
+ if (!rowGrouping || rowGrouping.length === 0) {
+ this.setExpandedDepths(new Set());
+ 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));
+ }
+
+ /**
+ * Gets the current expanded depths
+ * @returns Set of expanded depth numbers
+ */
+ getExpandedDepths(): Set {
+ return this.expandedDepths;
+ }
+
+ /**
+ * Sets the expanded depths
+ * @param depths - New set of expanded depths
+ */
+ setExpandedDepths(depths: Set): void {
+ this.expandedDepths = depths;
+ this.notifyObservers();
+ }
+
+ /**
+ * Subscribes to expanded depths changes
+ * @param callback - Function to call when depths change
+ * @returns Unsubscribe function
+ */
+ subscribe(callback: (depths: Set) => void): () => void {
+ this.observers.add(callback);
+ return () => {
+ this.observers.delete(callback);
+ };
+ }
+
+ /**
+ * Notifies all observers of depth changes
+ */
+ private notifyObservers(): void {
+ this.observers.forEach(callback => callback(this.expandedDepths));
+ }
+
+ /**
+ * Expands all depths
+ */
+ expandAll(): void {
+ const allDepths = new Set();
+ for (let i = 0; i < 10; i++) {
+ allDepths.add(i);
+ }
+ this.setExpandedDepths(allDepths);
+ }
+
+ /**
+ * Collapses all depths
+ */
+ collapseAll(): void {
+ this.setExpandedDepths(new Set());
+ }
+
+ /**
+ * Expands a specific depth
+ */
+ expandDepth(depth: number): void {
+ const newDepths = new Set(this.expandedDepths);
+ newDepths.add(depth);
+ this.setExpandedDepths(newDepths);
+ }
+
+ /**
+ * Collapses a specific depth
+ */
+ collapseDepth(depth: number): void {
+ const newDepths = new Set(this.expandedDepths);
+ newDepths.delete(depth);
+ this.setExpandedDepths(newDepths);
+ }
+
+ /**
+ * Toggles a specific depth
+ */
+ toggleDepth(depth: number): void {
+ if (this.expandedDepths.has(depth)) {
+ this.collapseDepth(depth);
+ } else {
+ this.expandDepth(depth);
+ }
+ }
+
+ /**
+ * Cleans up the manager
+ */
+ destroy(): void {
+ this.observers.clear();
+ }
+}
+
+export default ExpandedDepthsManager;
diff --git a/packages/core/src/hooks/handleOutsideClick.ts b/packages/core/src/hooks/handleOutsideClick.ts
new file mode 100644
index 000000000..965ea6fcf
--- /dev/null
+++ b/packages/core/src/hooks/handleOutsideClick.ts
@@ -0,0 +1,114 @@
+import HeaderObject from "../types/HeaderObject";
+import Cell from "../types/Cell";
+
+export interface HandleOutsideClickConfig {
+ selectableColumns: boolean;
+ selectedCells: Set;
+ selectedColumns: Set;
+ setSelectedCells: (cells: Set) => void;
+ setSelectedColumns: (columns: Set) => void;
+ activeHeaderDropdown?: HeaderObject | null;
+ setActiveHeaderDropdown?: (header: HeaderObject | null) => void;
+ startCell?: { current: Cell | null };
+ /** When provided, used to read current selection (avoids stale refs). */
+ getSelectedCells?: () => Set;
+ getSelectedColumns?: () => Set;
+ /** When provided, called to clear both cell/column selection and startCell in one go. */
+ onClearSelection?: () => void;
+}
+
+/**
+ * Manages outside click detection for cells, columns, and header dropdowns.
+ * This is a vanilla JS alternative to the useHandleOutsideClick hook.
+ */
+export class HandleOutsideClickManager {
+ private config: HandleOutsideClickConfig;
+ private isListening: boolean = false;
+
+ constructor(config: HandleOutsideClickConfig) {
+ this.config = config;
+ }
+
+ /**
+ * Updates the configuration
+ * @param config - New configuration
+ */
+ updateConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
+ }
+
+ /**
+ * Handles the mousedown event
+ */
+ private handleClickOutside = (event: MouseEvent): void => {
+ const target = event.target as HTMLElement;
+
+ // Check if the click is inside an editable header input - if so, don't handle outside click
+ if (target.closest(".editable-cell-input") && target.closest(".st-header-cell")) {
+ return;
+ }
+
+ // Close header dropdown if clicking outside of it
+ if (this.config.activeHeaderDropdown && this.config.setActiveHeaderDropdown) {
+ const insideDropdown =
+ target.closest(".st-dropdown-content") || target.closest(".dropdown-content");
+ if (!target.closest(".st-header-cell") && !insideDropdown) {
+ this.config.setActiveHeaderDropdown(null);
+ }
+ }
+
+ if (
+ !target.closest(".st-cell") &&
+ (this.config.selectableColumns
+ ? !target.classList.contains("st-header-cell") &&
+ !target.classList.contains("st-header-label") &&
+ !target.classList.contains("st-header-label-text")
+ : true)
+ ) {
+ const selectedCells = this.config.getSelectedCells?.() ?? this.config.selectedCells;
+ const selectedColumns = this.config.getSelectedColumns?.() ?? this.config.selectedColumns;
+ const hasSelection = selectedCells.size > 0 || selectedColumns.size > 0;
+
+ if (hasSelection) {
+ if (this.config.onClearSelection) {
+ this.config.onClearSelection();
+ } else {
+ this.config.setSelectedCells(new Set());
+ this.config.setSelectedColumns(new Set());
+ if (this.config.startCell) {
+ this.config.startCell.current = null;
+ }
+ }
+ }
+ }
+ };
+
+ /**
+ * Starts listening to mousedown events
+ */
+ startListening(): void {
+ if (!this.isListening) {
+ document.addEventListener("mousedown", this.handleClickOutside);
+ this.isListening = true;
+ }
+ }
+
+ /**
+ * Stops listening to mousedown events
+ */
+ stopListening(): void {
+ if (this.isListening) {
+ document.removeEventListener("mousedown", this.handleClickOutside);
+ this.isListening = false;
+ }
+ }
+
+ /**
+ * Cleans up the manager and removes all event listeners
+ */
+ destroy(): void {
+ this.stopListening();
+ }
+}
+
+export default HandleOutsideClickManager;
diff --git a/packages/core/src/hooks/previousValue.ts b/packages/core/src/hooks/previousValue.ts
new file mode 100644
index 000000000..973af8694
--- /dev/null
+++ b/packages/core/src/hooks/previousValue.ts
@@ -0,0 +1,50 @@
+/**
+ * A class to track previous values of a variable.
+ * This replaces the usePrevious hook for non-React code.
+ *
+ * @example
+ * const tracker = new PreviousValueTracker(initialValue);
+ * const previous = tracker.get();
+ * tracker.update(newValue);
+ */
+export class PreviousValueTracker {
+ private previousValue: T;
+
+ constructor(initialValue: T) {
+ this.previousValue = initialValue;
+ }
+
+ /**
+ * Updates the tracked value and returns the previous value
+ * @param newValue - The new value to track
+ * @returns The previous value before update
+ */
+ update(newValue: T): T {
+ const prev = this.previousValue;
+
+ // Only update if the value has changed (deep comparison via JSON)
+ if (JSON.stringify(prev) !== JSON.stringify(newValue)) {
+ this.previousValue = newValue;
+ }
+
+ return prev;
+ }
+
+ /**
+ * Gets the current previous value without updating
+ * @returns The currently stored previous value
+ */
+ get(): T {
+ return this.previousValue;
+ }
+
+ /**
+ * Sets the previous value directly (useful for initialization)
+ * @param value - The value to set
+ */
+ set(value: T): void {
+ this.previousValue = value;
+ }
+}
+
+export default PreviousValueTracker;
diff --git a/packages/core/src/hooks/scrollbarVisibility.ts b/packages/core/src/hooks/scrollbarVisibility.ts
new file mode 100644
index 000000000..cb1a2fd26
--- /dev/null
+++ b/packages/core/src/hooks/scrollbarVisibility.ts
@@ -0,0 +1,171 @@
+/**
+ * Manages scrollbar visibility detection and header padding adjustments.
+ * This is a vanilla JS alternative to the useScrollbarVisibility hook.
+ */
+export class ScrollbarVisibilityManager {
+ private isMainSectionScrollable: boolean = false;
+ private headerContainer: HTMLElement | null = null;
+ private mainSection: HTMLElement | null = null;
+ private scrollbarWidth: number = 0;
+ private resizeObserver: ResizeObserver | null = null;
+ private observers: Set<(isScrollable: boolean) => void> = new Set();
+ private rafId: number | null = null;
+
+ constructor(config: {
+ headerContainer?: HTMLElement | null;
+ mainSection?: HTMLElement | null;
+ scrollbarWidth: number;
+ }) {
+ this.headerContainer = config.headerContainer || null;
+ this.mainSection = config.mainSection || null;
+ this.scrollbarWidth = config.scrollbarWidth;
+
+ if (this.mainSection && this.headerContainer) {
+ this.initialize();
+ }
+ }
+
+ /**
+ * Initializes the scrollbar visibility detection
+ */
+ private initialize(): void {
+ if (!this.mainSection || !this.headerContainer) return;
+
+ // Check on initial setup
+ this.checkScrollability();
+
+ // Use requestAnimationFrame to defer scroll checks triggered by ResizeObserver,
+ // preventing ResizeObserver loop errors in Chromium when the callback
+ // synchronously triggers renders that affect the observed element's layout.
+ this.resizeObserver = new ResizeObserver(() => {
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId);
+ }
+ this.rafId = requestAnimationFrame(() => {
+ this.rafId = null;
+ this.checkScrollability();
+ });
+ });
+
+ this.resizeObserver.observe(this.mainSection);
+ }
+
+ /**
+ * Checks if the main section is scrollable
+ */
+ private checkScrollability(): void {
+ if (this.mainSection) {
+ const hasVerticalScroll = this.mainSection.scrollHeight > this.mainSection.clientHeight;
+
+ if (hasVerticalScroll !== this.isMainSectionScrollable) {
+ this.isMainSectionScrollable = hasVerticalScroll;
+ this.updateHeaderPadding();
+ this.notifyObservers();
+ }
+ }
+ }
+
+ /**
+ * Updates the header padding based on scrollbar visibility
+ */
+ private updateHeaderPadding(): void {
+ if (!this.headerContainer) return;
+
+ if (this.isMainSectionScrollable) {
+ this.headerContainer.classList.add("st-header-scroll-padding");
+ // Change width of the ::after div to the scrollbarWidth
+ this.headerContainer.style.setProperty("--st-after-width", `${this.scrollbarWidth}px`);
+ } else {
+ this.headerContainer.classList.remove("st-header-scroll-padding");
+ }
+ }
+
+ /**
+ * Updates the scrollbar width and refreshes padding
+ * @param width - New scrollbar width in pixels
+ */
+ setScrollbarWidth(width: number): void {
+ this.scrollbarWidth = width;
+ this.updateHeaderPadding();
+ }
+
+ /**
+ * Updates the header container element
+ * @param container - New header container element
+ */
+ setHeaderContainer(container: HTMLElement | null): void {
+ // Clean up old header
+ if (this.headerContainer) {
+ this.headerContainer.classList.remove("st-header-scroll-padding");
+ }
+
+ this.headerContainer = container;
+ this.updateHeaderPadding();
+ }
+
+ /**
+ * Updates the main section element
+ * @param section - New main section element
+ */
+ setMainSection(section: HTMLElement | null): void {
+ // Clean up old observer
+ if (this.resizeObserver && this.mainSection) {
+ this.resizeObserver.unobserve(this.mainSection);
+ }
+
+ this.mainSection = section;
+
+ if (this.mainSection && this.headerContainer) {
+ this.initialize();
+ }
+ }
+
+ /**
+ * Gets whether the main section is currently scrollable
+ * @returns True if the main section has vertical scroll
+ */
+ getIsMainSectionScrollable(): boolean {
+ return this.isMainSectionScrollable;
+ }
+
+ /**
+ * Subscribes to scrollability changes
+ * @param callback - Function to call when scrollability changes
+ * @returns Unsubscribe function
+ */
+ subscribe(callback: (isScrollable: boolean) => void): () => void {
+ this.observers.add(callback);
+ return () => {
+ this.observers.delete(callback);
+ };
+ }
+
+ /**
+ * Notifies all observers of scrollability changes
+ */
+ private notifyObservers(): void {
+ this.observers.forEach(callback => callback(this.isMainSectionScrollable));
+ }
+
+ /**
+ * Cleans up the manager and removes all observers
+ */
+ destroy(): void {
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = null;
+ }
+ if (this.resizeObserver && this.mainSection) {
+ this.resizeObserver.unobserve(this.mainSection);
+ this.resizeObserver = null;
+ }
+
+ if (this.headerContainer) {
+ this.headerContainer.classList.remove("st-header-scroll-padding");
+ }
+
+ this.observers.clear();
+ }
+}
+
+export default ScrollbarVisibilityManager;
diff --git a/packages/core/src/hooks/scrollbarWidth.ts b/packages/core/src/hooks/scrollbarWidth.ts
new file mode 100644
index 000000000..8e104af6c
--- /dev/null
+++ b/packages/core/src/hooks/scrollbarWidth.ts
@@ -0,0 +1,97 @@
+/**
+ * Calculates the scrollbar width of an element.
+ * This is a pure function that replaces the useScrollbarWidth hook.
+ *
+ * @param element - The HTML element to measure
+ * @returns The width of the scrollbar in pixels, or 0 if element is null
+ */
+export function calculateScrollbarWidth(element: HTMLElement | null): number {
+ if (!element) return 0;
+
+ const scrollbarWidth = element.offsetWidth - element.clientWidth;
+ return scrollbarWidth;
+}
+
+/**
+ * A class to manage scrollbar width state and updates.
+ * This provides a stateful alternative to the useScrollbarWidth hook.
+ */
+export class ScrollbarWidthManager {
+ private width: number = 0;
+ private element: HTMLElement | null = null;
+ private observers: Set<(width: number) => void> = new Set();
+
+ constructor(element?: HTMLElement | null) {
+ if (element) {
+ this.setElement(element);
+ }
+ }
+
+ /**
+ * Sets the element to measure and calculates its scrollbar width
+ * @param element - The HTML element to measure
+ */
+ setElement(element: HTMLElement | null): void {
+ this.element = element;
+ this.update();
+ }
+
+ /**
+ * Updates the scrollbar width measurement
+ */
+ update(): void {
+ const newWidth = calculateScrollbarWidth(this.element);
+ if (newWidth !== this.width) {
+ this.width = newWidth;
+ this.notifyObservers();
+ }
+ }
+
+ /**
+ * Gets the current scrollbar width
+ * @returns The current scrollbar width in pixels
+ */
+ getWidth(): number {
+ return this.width;
+ }
+
+ /**
+ * Manually sets the scrollbar width
+ * @param width - The width to set
+ */
+ setWidth(width: number): void {
+ if (width !== this.width) {
+ this.width = width;
+ this.notifyObservers();
+ }
+ }
+
+ /**
+ * Subscribes to scrollbar width changes
+ * @param callback - Function to call when width changes
+ * @returns Unsubscribe function
+ */
+ subscribe(callback: (width: number) => void): () => void {
+ this.observers.add(callback);
+ return () => {
+ this.observers.delete(callback);
+ };
+ }
+
+ /**
+ * Notifies all observers of width changes
+ */
+ private notifyObservers(): void {
+ this.observers.forEach(callback => callback(this.width));
+ }
+
+ /**
+ * Cleans up the manager
+ */
+ destroy(): void {
+ this.observers.clear();
+ this.element = null;
+ }
+}
+
+export default calculateScrollbarWidth;
diff --git a/packages/core/src/hooks/useAggregatedRows.ts b/packages/core/src/hooks/useAggregatedRows.ts
new file mode 100644
index 000000000..e42452ab0
--- /dev/null
+++ b/packages/core/src/hooks/useAggregatedRows.ts
@@ -0,0 +1,146 @@
+import HeaderObject, { Accessor } from "../types/HeaderObject";
+import { AggregationConfig } from "../types/AggregationTypes";
+import Row from "../types/Row";
+import { flattenAllHeaders } from "../utils/headerUtils";
+import { isRowArray, getNestedValue, setNestedValue } from "../utils/rowUtils";
+import { RowManager } from "../managers/RowManager";
+
+interface CalculateAggregatedRowsProps {
+ rows?: Row[];
+ headers?: HeaderObject[];
+ rowGrouping?: string[];
+ rowManager?: RowManager;
+}
+
+const getAllAggregationHeaders = (headers: HeaderObject[]): HeaderObject[] => {
+ return flattenAllHeaders(headers).filter((header) => header.aggregation);
+};
+
+const calculateAggregation = (
+ childRows: Row[],
+ accessor: Accessor,
+ config: AggregationConfig,
+ nextGroupKey?: string
+): any => {
+ const allValues: any[] = [];
+
+ const collectValues = (rows: Row[]) => {
+ rows.forEach((row) => {
+ const nextGroupValue = nextGroupKey ? row[nextGroupKey] : undefined;
+ if (nextGroupKey && nextGroupValue && isRowArray(nextGroupValue)) {
+ collectValues(nextGroupValue);
+ } else {
+ const value = getNestedValue(row, accessor);
+ if (value !== undefined && value !== null) {
+ allValues.push(value);
+ }
+ }
+ });
+ };
+
+ collectValues(childRows);
+
+ if (allValues.length === 0) {
+ return undefined;
+ }
+
+ if (config.type === "custom" && config.customFn) {
+ return config.customFn(allValues);
+ }
+
+ const numericValues = config.parseValue
+ ? allValues.map(config.parseValue).filter((val) => !isNaN(val))
+ : allValues
+ .map((val) => {
+ if (typeof val === "number") return val;
+ if (typeof val === "string") return parseFloat(val);
+ return NaN;
+ })
+ .filter((val) => !isNaN(val));
+
+ if (numericValues.length === 0) {
+ return config.type === "count" ? allValues.length : undefined;
+ }
+
+ let result: number;
+
+ switch (config.type) {
+ case "sum":
+ result = numericValues.reduce((sum, val) => sum + val, 0);
+ break;
+ case "average":
+ result = numericValues.reduce((sum, val) => sum + val, 0) / numericValues.length;
+ break;
+ case "count":
+ result = allValues.length;
+ break;
+ case "min":
+ result = Math.min(...numericValues);
+ break;
+ case "max":
+ result = Math.max(...numericValues);
+ break;
+ default:
+ return undefined;
+ }
+
+ return config.formatResult ? config.formatResult(result) : result;
+};
+
+/**
+ * Pure function to calculate aggregated rows based on row grouping and aggregation configuration
+ */
+export const calculateAggregatedRows = (props: CalculateAggregatedRowsProps): Row[] => {
+ const { rows = [], headers = [], rowGrouping, rowManager } = props;
+
+ if (rowManager) {
+ return rowManager.getAggregatedRows();
+ }
+ if (!rowGrouping || rowGrouping.length === 0) {
+ return rows;
+ }
+
+ const aggregationHeaders = getAllAggregationHeaders(headers);
+
+ if (aggregationHeaders.length === 0) {
+ return rows;
+ }
+
+ const aggregatedRows = JSON.parse(JSON.stringify(rows));
+
+ const processRows = (rowsToProcess: Row[], groupingLevel: number = 0): Row[] => {
+ return rowsToProcess.map((row) => {
+ const currentGroupKey = rowGrouping[groupingLevel];
+ const nextGroupKey = rowGrouping[groupingLevel + 1];
+
+ const currentGroupValue = row[currentGroupKey];
+ if (currentGroupValue && isRowArray(currentGroupValue)) {
+ const processedChildren = processRows(currentGroupValue, groupingLevel + 1);
+
+ const aggregatedRow = { ...row };
+ aggregatedRow[currentGroupKey] = processedChildren;
+
+ aggregationHeaders.forEach((header) => {
+ const aggregatedValue = calculateAggregation(
+ processedChildren,
+ header.accessor,
+ header.aggregation!,
+ nextGroupKey
+ );
+
+ if (aggregatedValue !== undefined) {
+ setNestedValue(aggregatedRow, header.accessor, aggregatedValue);
+ }
+ });
+
+ return aggregatedRow;
+ }
+
+ return row;
+ });
+ };
+
+ return processRows(aggregatedRows);
+};
+
+export default calculateAggregatedRows;
diff --git a/src/hooks/useQuickFilter.ts b/packages/core/src/hooks/useQuickFilter.ts
similarity index 93%
rename from src/hooks/useQuickFilter.ts
rename to packages/core/src/hooks/useQuickFilter.ts
index 9d3f142e8..9fd41643c 100644
--- a/src/hooks/useQuickFilter.ts
+++ b/packages/core/src/hooks/useQuickFilter.ts
@@ -1,4 +1,3 @@
-import { useMemo } from "react";
import { QuickFilterConfig, SmartFilterToken } from "../types/QuickFilterTypes";
import Row from "../types/Row";
import HeaderObject, { Accessor } from "../types/HeaderObject";
@@ -6,22 +5,21 @@ import { getNestedValue } from "../utils/rowUtils";
import CellValue from "../types/CellValue";
import { parseSmartFilter, matchesSimpleFilter } from "../utils/quickFilterUtils";
-interface UseQuickFilterProps {
+interface FilterRowsWithQuickFilterProps {
rows: Row[];
headers: HeaderObject[];
quickFilter?: QuickFilterConfig;
}
/**
- * Hook to filter rows based on quick filter configuration
+ * Pure function to filter rows based on quick filter configuration
* Supports both simple (contains) and smart (multi-word, phrases, negation, column-specific) modes
*/
-const useQuickFilter = ({ rows, headers, quickFilter }: UseQuickFilterProps): Row[] => {
- return useMemo(() => {
- // If no quick filter or empty text, return all rows
- if (!quickFilter || !quickFilter.text || quickFilter.text.trim() === "") {
- return rows;
- }
+export const filterRowsWithQuickFilter = ({ rows, headers, quickFilter }: FilterRowsWithQuickFilterProps): Row[] => {
+ // If no quick filter or empty text, return all rows
+ if (!quickFilter || !quickFilter.text || quickFilter.text.trim() === "") {
+ return rows;
+ }
const {
text,
@@ -204,7 +202,6 @@ const useQuickFilter = ({ rows, headers, quickFilter }: UseQuickFilterProps): Ro
});
}
});
- }, [rows, headers, quickFilter]);
};
-export default useQuickFilter;
+export default filterRowsWithQuickFilter;
diff --git a/packages/core/src/hooks/windowResize.ts b/packages/core/src/hooks/windowResize.ts
new file mode 100644
index 000000000..63adeffeb
--- /dev/null
+++ b/packages/core/src/hooks/windowResize.ts
@@ -0,0 +1,68 @@
+/**
+ * Manages window resize event listeners.
+ * This is a vanilla JS alternative to the useWindowResize hook.
+ */
+export class WindowResizeManager {
+ private callbacks: Set<() => void> = new Set();
+ private isListening: boolean = false;
+
+ /**
+ * Handles the window resize event
+ */
+ private handleResize = (): void => {
+ this.callbacks.forEach(callback => callback());
+ };
+
+ /**
+ * Adds a callback to be called on window resize
+ * @param callback - Function to call when window resizes
+ * @returns Unsubscribe function
+ */
+ addCallback(callback: () => void): () => void {
+ this.callbacks.add(callback);
+
+ // Start listening if this is the first callback
+ if (!this.isListening) {
+ this.startListening();
+ }
+
+ return () => {
+ this.callbacks.delete(callback);
+
+ // Stop listening if no more callbacks
+ if (this.callbacks.size === 0) {
+ this.stopListening();
+ }
+ };
+ }
+
+ /**
+ * Starts listening to window resize events
+ */
+ startListening(): void {
+ if (!this.isListening) {
+ window.addEventListener("resize", this.handleResize);
+ this.isListening = true;
+ }
+ }
+
+ /**
+ * Stops listening to window resize events
+ */
+ stopListening(): void {
+ if (this.isListening) {
+ window.removeEventListener("resize", this.handleResize);
+ this.isListening = false;
+ }
+ }
+
+ /**
+ * Cleans up the manager and removes all event listeners
+ */
+ destroy(): void {
+ this.stopListening();
+ this.callbacks.clear();
+ }
+}
+
+export default WindowResizeManager;
diff --git a/packages/core/src/icons/AngleDownIcon.ts b/packages/core/src/icons/AngleDownIcon.ts
new file mode 100644
index 000000000..2500eccc0
--- /dev/null
+++ b/packages/core/src/icons/AngleDownIcon.ts
@@ -0,0 +1,18 @@
+export const createAngleDownIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("width", "24");
+ svg.setAttribute("height", "24");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M5.41 7.59L10 12.17l4.59-4.58L16 9l-6 6-6-6z");
+ path.setAttribute("fill", "inherit");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/AngleLeftIcon.ts b/packages/core/src/icons/AngleLeftIcon.ts
new file mode 100644
index 000000000..14df3d04f
--- /dev/null
+++ b/packages/core/src/icons/AngleLeftIcon.ts
@@ -0,0 +1,18 @@
+export const createAngleLeftIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("width", "24");
+ svg.setAttribute("height", "24");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z");
+ path.setAttribute("fill", "inherit");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/AngleRightIcon.ts b/packages/core/src/icons/AngleRightIcon.ts
new file mode 100644
index 000000000..c22cbf74d
--- /dev/null
+++ b/packages/core/src/icons/AngleRightIcon.ts
@@ -0,0 +1,18 @@
+export const createAngleRightIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("width", "24");
+ svg.setAttribute("height", "24");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z");
+ path.setAttribute("fill", "inherit");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/AngleUpIcon.ts b/packages/core/src/icons/AngleUpIcon.ts
new file mode 100644
index 000000000..3321bc5bb
--- /dev/null
+++ b/packages/core/src/icons/AngleUpIcon.ts
@@ -0,0 +1,18 @@
+export const createAngleUpIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("width", "24");
+ svg.setAttribute("height", "24");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M5.41 11.41L10 6.83l4.59 4.58L16 10l-6-6-6 6z");
+ path.setAttribute("fill", "inherit");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/AscIcon.ts b/packages/core/src/icons/AscIcon.ts
new file mode 100644
index 000000000..8580c4f86
--- /dev/null
+++ b/packages/core/src/icons/AscIcon.ts
@@ -0,0 +1,20 @@
+export const createAscIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("focusable", "false");
+ svg.setAttribute("role", "img");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+ svg.setAttribute("viewBox", "0 0 320 512");
+ svg.setAttribute("height", "1em");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M298 177.5c3.8-8.8 2-19-4.6-26l-116-144C172.9 2.7 166.6 0 160 0s-12.9 2.7-17.4 7.5l-116 144c-6.6 7-8.4 17.2-4.6 26S34.4 192 44 192l72 0 0 288c0 17.7 14.3 32 32 32l24 0c17.7 0 32-14.3 32-32l0-288 72 0c9.6 0 18.2-5.7 22-14.5z");
+ path.setAttribute("fill", "inherit");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/CheckIcon.ts b/packages/core/src/icons/CheckIcon.ts
new file mode 100644
index 000000000..0ad168e87
--- /dev/null
+++ b/packages/core/src/icons/CheckIcon.ts
@@ -0,0 +1,19 @@
+export const createCheckIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("role", "img");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+ svg.setAttribute("viewBox", "0 0 448 512");
+ svg.setAttribute("height", "10px");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z");
+ path.setAttribute("fill", "inherit");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/DescIcon.ts b/packages/core/src/icons/DescIcon.ts
new file mode 100644
index 000000000..38608619c
--- /dev/null
+++ b/packages/core/src/icons/DescIcon.ts
@@ -0,0 +1,23 @@
+export const createDescIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("focusable", "false");
+ svg.setAttribute("role", "img");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+ svg.setAttribute("viewBox", "0 0 320 512");
+ svg.setAttribute("height", "1em");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute(
+ "d",
+ "M22 334.5c-3.8 8.8-2 19 4.6 26l116 144c4.5 4.8 10.8 7.5 17.4 7.5s12.9-2.7 17.4-7.5l116-144c6.6-7 8.4-17.2 4.6-26s-12.5-14.5-22-14.5l-72 0 0-288c0-17.7-14.3-32-32-32L148 0C130.3 0 116 14.3 116 32l0 288-72 0c-9.6 0-18.2 5.7-22 14.5z",
+ );
+ path.setAttribute("fill", "inherit");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/DragIcon.ts b/packages/core/src/icons/DragIcon.ts
new file mode 100644
index 000000000..fce0e2454
--- /dev/null
+++ b/packages/core/src/icons/DragIcon.ts
@@ -0,0 +1,33 @@
+export const createDragIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("role", "img");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+ svg.setAttribute("viewBox", "0 0 16 10");
+ svg.setAttribute("width", "16px");
+ svg.setAttribute("height", "10px");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const circles = [
+ { cx: "3", cy: "3" },
+ { cx: "8", cy: "3" },
+ { cx: "13", cy: "3" },
+ { cx: "3", cy: "7" },
+ { cx: "8", cy: "7" },
+ { cx: "13", cy: "7" },
+ ];
+
+ circles.forEach(({ cx, cy }) => {
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ circle.setAttribute("cx", cx);
+ circle.setAttribute("cy", cy);
+ circle.setAttribute("r", "1.5");
+ circle.setAttribute("fill", "currentColor");
+ svg.appendChild(circle);
+ });
+
+ return svg;
+};
diff --git a/packages/core/src/icons/FilterIcon.ts b/packages/core/src/icons/FilterIcon.ts
new file mode 100644
index 000000000..0aae447b9
--- /dev/null
+++ b/packages/core/src/icons/FilterIcon.ts
@@ -0,0 +1,19 @@
+export const createFilterIcon = (className?: string): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("role", "img");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+ svg.setAttribute("viewBox", "0 0 512 512");
+ svg.setAttribute("height", "1em");
+
+ if (className) {
+ svg.setAttribute("class", className);
+ }
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M3.9 54.9C10.5 40.9 24.5 32 40 32l432 0c15.5 0 29.5 8.9 36.1 22.9s4.6 30.5-5.2 42.5L320 320.9 320 448c0 12.1-6.8 23.2-17.7 28.6s-23.8 4.3-33.5-3l-64-48c-8.1-6-12.8-15.5-12.8-25.6l0-79.1L9 97.3C-.7 85.4-2.8 68.8 3.9 54.9z");
+ path.setAttribute("fill", "inherit");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/SelectIcon.ts b/packages/core/src/icons/SelectIcon.ts
new file mode 100644
index 000000000..1b6a47499
--- /dev/null
+++ b/packages/core/src/icons/SelectIcon.ts
@@ -0,0 +1,19 @@
+export const createSelectIcon = (): SVGSVGElement => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("class", "st-custom-select-arrow");
+ svg.setAttribute("width", "12");
+ svg.setAttribute("height", "12");
+ svg.setAttribute("viewBox", "0 0 12 12");
+ svg.setAttribute("fill", "none");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M3 4.5L6 7.5L9 4.5");
+ path.setAttribute("stroke", "currentColor");
+ path.setAttribute("stroke-width", "1.5");
+ path.setAttribute("stroke-linecap", "round");
+ path.setAttribute("stroke-linejoin", "round");
+ svg.appendChild(path);
+
+ return svg;
+};
diff --git a/packages/core/src/icons/index.ts b/packages/core/src/icons/index.ts
new file mode 100644
index 000000000..407227f23
--- /dev/null
+++ b/packages/core/src/icons/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Internal icon factory functions
+ * These create vanilla JS SVG elements for use within the table component
+ */
+
+export { createAngleDownIcon } from "./AngleDownIcon";
+export { createAngleLeftIcon } from "./AngleLeftIcon";
+export { createAngleRightIcon } from "./AngleRightIcon";
+export { createAngleUpIcon } from "./AngleUpIcon";
+export { createAscIcon } from "./AscIcon";
+export { createCheckIcon } from "./CheckIcon";
+export { createDescIcon } from "./DescIcon";
+export { createDragIcon } from "./DragIcon";
+export { createFilterIcon } from "./FilterIcon";
+export { createSelectIcon } from "./SelectIcon";
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 000000000..0371f68d5
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1,155 @@
+import { SimpleTableVanilla } from "./core/SimpleTableVanilla";
+import type BoundingBox from "./types/BoundingBox";
+import type Cell from "./types/Cell";
+import type CellChangeProps from "./types/CellChangeProps";
+import type CellValue from "./types/CellValue";
+import type DragHandlerProps from "./types/DragHandlerProps";
+import type EnumOption from "./types/EnumOption";
+import type HeaderObject from "./types/HeaderObject";
+import type {
+ Accessor,
+ ChartOptions,
+ ColumnType,
+ Comparator,
+ ComparatorProps,
+ ExportValueGetter,
+ ExportValueProps,
+ ShowWhen,
+ ValueFormatter,
+ ValueFormatterProps,
+ ValueGetter,
+ ValueGetterProps,
+} from "./types/HeaderObject";
+import type { AggregationConfig, AggregationType } from "./types/AggregationTypes";
+import type OnSortProps from "./types/OnSortProps";
+import type OnRowGroupExpandProps from "./types/OnRowGroupExpandProps";
+import type Row from "./types/Row";
+import type RowState from "./types/RowState";
+import type SharedTableProps from "./types/SharedTableProps";
+import type SortColumn from "./types/SortColumn";
+import type TableHeaderProps from "./types/TableHeaderProps";
+import type { TableAPI, SetHeaderRenameProps, ExportToCSVProps } from "./types/TableAPI";
+import type TableRefType from "./types/TableRefType";
+import type TableRowProps from "./types/TableRowProps";
+import type Theme from "./types/Theme";
+import type UpdateDataProps from "./types/UpdateCellProps";
+import type { FilterCondition, TableFilterState } from "./types/FilterTypes";
+import type {
+ QuickFilterConfig,
+ QuickFilterGetter,
+ QuickFilterGetterProps,
+ QuickFilterMode,
+} from "./types/QuickFilterTypes";
+import type { ColumnVisibilityState } from "./types/ColumnVisibilityTypes";
+import type RowSelectionChangeProps from "./types/RowSelectionChangeProps";
+import type CellClickProps from "./types/CellClickProps";
+import type CellRendererProps from "./types/CellRendererProps";
+import type { CellRenderer } from "./types/CellRendererProps";
+import type HeaderRendererProps from "./types/HeaderRendererProps";
+import type {
+ HeaderRenderer,
+ HeaderRendererComponents,
+} from "./types/HeaderRendererProps";
+import type ColumnEditorRowRendererProps from "./types/ColumnEditorRowRendererProps";
+import type {
+ ColumnEditorRowRenderer,
+ ColumnEditorRowRendererComponents,
+} from "./types/ColumnEditorRowRendererProps";
+import type HeaderDropdownProps from "./types/HeaderDropdownProps";
+import type { HeaderDropdown } from "./types/HeaderDropdownProps";
+import type { RowButtonProps } from "./types/RowButton";
+import type FooterRendererProps from "./types/FooterRendererProps";
+import type {
+ LoadingStateRenderer,
+ ErrorStateRenderer,
+ EmptyStateRenderer,
+ LoadingStateRendererProps,
+ ErrorStateRendererProps,
+ EmptyStateRendererProps,
+} from "./types/RowStateRendererProps";
+import type { CustomTheme, CustomThemeProps } from "./types/CustomTheme";
+import type { ColumnEditorConfig, ColumnEditorSearchFunction } from "./types/ColumnEditorConfig";
+import type { IconsConfig } from "./types/IconsConfig";
+import type { GetRowId, GetRowIdParams } from "./types/GetRowId";
+import type { SimpleTableConfig } from "./types/SimpleTableConfig";
+import type { SimpleTableProps } from "./types/SimpleTableProps";
+import type { RowId } from "./types/RowId";
+import type { PinnedSectionsState } from "./types/PinnedSectionsState";
+
+export { SimpleTableVanilla };
+
+export type {
+ Accessor,
+ AggregationConfig,
+ AggregationType,
+ BoundingBox,
+ Cell,
+ CellChangeProps,
+ CellClickProps,
+ CellRenderer,
+ CellRendererProps,
+ CellValue,
+ ChartOptions,
+ ColumnEditorConfig,
+ ColumnEditorRowRenderer,
+ ColumnEditorRowRendererComponents,
+ ColumnEditorRowRendererProps,
+ ColumnEditorSearchFunction,
+ ColumnType,
+ ColumnVisibilityState,
+ Comparator,
+ ComparatorProps,
+ CustomTheme,
+ CustomThemeProps,
+ DragHandlerProps,
+ EmptyStateRenderer,
+ EmptyStateRendererProps,
+ EnumOption,
+ ErrorStateRenderer,
+ ErrorStateRendererProps,
+ ExportToCSVProps,
+ ExportValueGetter,
+ ExportValueProps,
+ FilterCondition,
+ FooterRendererProps,
+ GetRowId,
+ GetRowIdParams,
+ IconsConfig,
+ LoadingStateRenderer,
+ LoadingStateRendererProps,
+ HeaderDropdown,
+ HeaderDropdownProps,
+ HeaderObject,
+ HeaderRenderer,
+ HeaderRendererProps,
+ HeaderRendererComponents,
+ OnRowGroupExpandProps,
+ OnSortProps,
+ QuickFilterConfig,
+ QuickFilterGetter,
+ QuickFilterGetterProps,
+ QuickFilterMode,
+ Row,
+ RowButtonProps,
+ RowId,
+ RowSelectionChangeProps,
+ RowState,
+ SetHeaderRenameProps,
+ SharedTableProps,
+ ShowWhen,
+ SimpleTableConfig,
+ SimpleTableProps,
+ SortColumn,
+ TableAPI,
+ TableFilterState,
+ TableHeaderProps,
+ TableRefType,
+ TableRowProps,
+ Theme,
+ PinnedSectionsState,
+ UpdateDataProps,
+ ValueFormatter,
+ ValueFormatterProps,
+ ValueGetter,
+ ValueGetterProps,
+};
diff --git a/src/hooks/useAutoScaleMainSection.ts b/packages/core/src/managers/AutoScaleManager.ts
similarity index 53%
rename from src/hooks/useAutoScaleMainSection.ts
rename to packages/core/src/managers/AutoScaleManager.ts
index 8acc0206b..9e761ed81 100644
--- a/src/hooks/useAutoScaleMainSection.ts
+++ b/packages/core/src/managers/AutoScaleManager.ts
@@ -1,19 +1,19 @@
-import { useCallback, useEffect, useRef, RefObject } from "react";
import HeaderObject from "../types/HeaderObject";
import { Pinned } from "../types/Pinned";
import { getHeaderMinWidth } from "../utils/headerWidthUtils";
+import { PreviousValueTracker } from "../hooks/previousValue";
-interface AutoScaleOptions {
+interface AutoScaleConfig {
autoExpandColumns: boolean;
containerWidth: number;
pinnedLeftWidth: number;
pinnedRightWidth: number;
- mainBodyRef: RefObject;
+ mainBodyRef: { current: HTMLDivElement | null };
isResizing?: boolean;
}
-// Helper to get all leaf headers (actual columns that render)
-// rootPinned is passed from parent - used to filter leaves by section
+type HeaderUpdateCallback = (headers: HeaderObject[]) => void;
+
const getLeafHeaders = (headers: HeaderObject[], rootPinned?: Pinned): HeaderObject[] => {
const leaves: HeaderObject[] = [];
headers.forEach((header) => {
@@ -28,9 +28,6 @@ const getLeafHeaders = (headers: HeaderObject[], rootPinned?: Pinned): HeaderObj
return leaves;
};
-/**
- * Check if a section can apply autoExpandColumns based on minWidth constraints
- */
const canAutoExpandSection = (
leafHeaders: HeaderObject[],
availableSectionWidth: number,
@@ -39,13 +36,9 @@ const canAutoExpandSection = (
return total + getHeaderMinWidth(header);
}, 0);
- // If minWidths don't fit, we need horizontal scroll
return totalMinWidth <= availableSectionWidth;
};
-/**
- * Scale headers in a specific section to fill available width
- */
const scaleSection = (
leafHeaders: HeaderObject[],
availableSectionWidth: number,
@@ -66,7 +59,6 @@ const scaleSection = (
const scaleFactor = availableSectionWidth / totalCurrentWidth;
- // Only scale if needed (avoid tiny adjustments)
if (Math.abs(scaleFactor - 1) < 0.01) {
return scaledWidths;
}
@@ -83,10 +75,8 @@ const scaleSection = (
let newWidth: number;
if (index === leafHeaders.length - 1) {
- // Last column gets the remaining width to ensure exact total
newWidth = availableSectionWidth - accumulatedWidth;
} else {
- // Round intermediate columns
newWidth = Math.round(currentWidth * scaleFactor);
accumulatedWidth += newWidth;
}
@@ -97,12 +87,9 @@ const scaleSection = (
return scaledWidths;
};
-/**
- * Pure function that scales headers to fill available width if autoExpandColumns is enabled
- */
export const applyAutoScaleToHeaders = (
headers: HeaderObject[],
- options: AutoScaleOptions,
+ options: AutoScaleConfig,
): HeaderObject[] => {
const {
autoExpandColumns,
@@ -113,22 +100,14 @@ export const applyAutoScaleToHeaders = (
isResizing,
} = options;
- // If auto-expand is disabled or currently resizing, return headers unchanged
if (!autoExpandColumns || containerWidth === 0 || isResizing) {
return headers;
}
- // Calculate the available viewport width for the main section
- let availableMainSectionWidth: number;
- if (mainBodyRef.current) {
- // Use the actual measured width from the DOM (most accurate)
- availableMainSectionWidth = mainBodyRef.current.clientWidth;
- } else {
- // Fallback calculation: container minus pinned sections
- availableMainSectionWidth = Math.max(0, containerWidth - pinnedLeftWidth - pinnedRightWidth);
- }
+ // Always derive available main width from calculated pinned widths rather than
+ // reading from the DOM, which may reflect stale CSS from the previous render.
+ const availableMainSectionWidth = Math.max(0, containerWidth - pinnedLeftWidth - pinnedRightWidth);
- // Get leaf headers for each section
const leftSectionHeaders = headers.filter((h) => h.pinned === "left");
const rightSectionHeaders = headers.filter((h) => h.pinned === "right");
const mainSectionHeaders = headers.filter((h) => !h.pinned);
@@ -137,7 +116,6 @@ export const applyAutoScaleToHeaders = (
const rightLeafHeaders = getLeafHeaders(rightSectionHeaders, "right");
const mainLeafHeaders = getLeafHeaders(mainSectionHeaders, undefined);
- // Check each section to see if it can apply autoExpandColumns
const canExpandLeft =
leftLeafHeaders.length > 0 && canAutoExpandSection(leftLeafHeaders, pinnedLeftWidth);
const canExpandRight =
@@ -147,7 +125,6 @@ export const applyAutoScaleToHeaders = (
availableMainSectionWidth > 0 &&
canAutoExpandSection(mainLeafHeaders, availableMainSectionWidth);
- // Calculate scaled widths for each section that can be expanded
const scaledWidths = new Map();
if (canExpandLeft) {
@@ -165,21 +142,17 @@ export const applyAutoScaleToHeaders = (
mainScaledWidths.forEach((width, accessor) => scaledWidths.set(accessor, width));
}
- // If no sections can be expanded, return headers unchanged
if (scaledWidths.size === 0) {
return headers;
}
- // Recursively scale all headers (including nested children)
const scaleHeader = (header: HeaderObject, rootPinned?: Pinned): HeaderObject => {
if (header.hide) return header;
const currentRootPinned = rootPinned ?? header.pinned;
const scaledChildren = header.children?.map((child) => scaleHeader(child, currentRootPinned));
- // Only scale leaf headers (columns without children)
if (!header.children || header.children.length === 0) {
- // Use pre-calculated width from the map if available
const newWidth = scaledWidths.get(header.accessor as string);
if (newWidth !== undefined) {
return {
@@ -189,14 +162,12 @@ export const applyAutoScaleToHeaders = (
};
}
- // No scaling for this header - return as is
return {
...header,
children: scaledChildren,
};
}
- // For parent headers, just update children
return {
...header,
children: scaledChildren,
@@ -208,89 +179,62 @@ export const applyAutoScaleToHeaders = (
return scaledHeaders;
};
-interface UseAutoScaleMainSectionProps {
- autoExpandColumns: boolean;
- containerWidth: number;
- pinnedLeftWidth: number;
- pinnedRightWidth: number;
- mainBodyRef: RefObject;
- isResizing: boolean;
- setHeaders: React.Dispatch>;
-}
-
-/**
- * Hook that wraps setHeaders to automatically apply scaling when headers are updated
- */
-export const useAutoScaleMainSection = ({
- autoExpandColumns,
- containerWidth,
- pinnedLeftWidth,
- pinnedRightWidth,
- mainBodyRef,
- isResizing,
- setHeaders,
-}: UseAutoScaleMainSectionProps) => {
- const optionsRef = useRef({
- autoExpandColumns,
- containerWidth,
- pinnedLeftWidth,
- pinnedRightWidth,
- mainBodyRef,
- isResizing,
- });
-
- // Keep options ref up to date
- optionsRef.current = {
- autoExpandColumns,
- containerWidth,
- pinnedLeftWidth,
- pinnedRightWidth,
- mainBodyRef,
- isResizing,
- };
+export class AutoScaleManager {
+ private config: AutoScaleConfig;
+ private onHeadersUpdate: HeaderUpdateCallback;
+ private isResizingTracker: PreviousValueTracker;
+ private containerWidthTracker: PreviousValueTracker;
+
+ constructor(config: AutoScaleConfig, onHeadersUpdate: HeaderUpdateCallback) {
+ this.config = config;
+ this.onHeadersUpdate = onHeadersUpdate;
+ this.isResizingTracker = new PreviousValueTracker(config.isResizing ?? false);
+ this.containerWidthTracker = new PreviousValueTracker(config.containerWidth);
+ }
- // Wrapped setHeaders that applies auto-scaling
- const setHeadersWithScale = useCallback(
- (headersOrUpdater: HeaderObject[] | ((prev: HeaderObject[]) => HeaderObject[])) => {
- setHeaders((prevHeaders) => {
- const newHeaders =
- typeof headersOrUpdater === "function" ? headersOrUpdater(prevHeaders) : headersOrUpdater;
+ updateConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
- const newHeadersScaled = applyAutoScaleToHeaders(newHeaders, optionsRef.current);
+ const newIsResizing = this.config.isResizing ?? false;
+ const newContainerWidth = this.config.containerWidth;
- return newHeadersScaled;
- });
- },
- [setHeaders],
- );
+ const wasResizing = this.isResizingTracker.get();
+ this.isResizingTracker.set(newIsResizing);
- // Track previous isResizing state to detect when resizing ends
- const prevIsResizingRef = useRef(isResizing);
+ const prevContainerWidth = this.containerWidthTracker.get();
+ this.containerWidthTracker.set(newContainerWidth);
- // When resizing ends, trigger a re-scale to ensure proper column widths
- useEffect(() => {
- const wasResizing = prevIsResizingRef.current;
- const isNowResizing = isResizing;
+ if (wasResizing && !newIsResizing && this.config.autoExpandColumns) {
+ this.triggerAutoScale();
+ }
- if (wasResizing && !isNowResizing && autoExpandColumns) {
- // Resizing just ended - apply auto-scaling
- setHeaders((prevHeaders) => applyAutoScaleToHeaders(prevHeaders, optionsRef.current));
+ const widthChange = Math.abs(newContainerWidth - prevContainerWidth);
+ if (widthChange > 10 && !newIsResizing && this.config.autoExpandColumns) {
+ this.triggerAutoScale();
}
+ }
- prevIsResizingRef.current = isResizing;
- }, [isResizing, autoExpandColumns, setHeaders]);
+ private triggerAutoScale(): void {
+ if (this.onHeadersUpdate) {
+ this.onHeadersUpdate(this.config as any);
+ }
+ }
- // Also trigger re-scale when container width changes significantly
- const prevContainerWidthRef = useRef(containerWidth);
- useEffect(() => {
- const widthChange = Math.abs(containerWidth - prevContainerWidthRef.current);
+ applyAutoScale(headers: HeaderObject[]): HeaderObject[] {
+ return applyAutoScaleToHeaders(headers, this.config);
+ }
- if (widthChange > 10 && !isResizing && autoExpandColumns) {
- setHeaders((prevHeaders) => applyAutoScaleToHeaders(prevHeaders, optionsRef.current));
- }
+ setHeaders(
+ headersOrUpdater: HeaderObject[] | ((prev: HeaderObject[]) => HeaderObject[]),
+ currentHeaders: HeaderObject[],
+ ): HeaderObject[] {
+ const newHeaders =
+ typeof headersOrUpdater === "function" ? headersOrUpdater(currentHeaders) : headersOrUpdater;
- prevContainerWidthRef.current = containerWidth;
- }, [containerWidth, isResizing, autoExpandColumns, setHeaders]);
+ return this.applyAutoScale(newHeaders);
+ }
- return setHeadersWithScale;
-};
+ destroy(): void {
+ this.onHeadersUpdate = () => {};
+ }
+}
diff --git a/packages/core/src/managers/ColumnManager.ts b/packages/core/src/managers/ColumnManager.ts
new file mode 100644
index 000000000..dc4d0bff7
--- /dev/null
+++ b/packages/core/src/managers/ColumnManager.ts
@@ -0,0 +1,185 @@
+import HeaderObject, { Accessor } from "../types/HeaderObject";
+import { ColumnVisibilityState } from "../types/ColumnVisibilityTypes";
+import { Pinned } from "../types/Pinned";
+
+export interface ColumnManagerConfig {
+ headers: HeaderObject[];
+ collapsedHeaders: Set;
+ onColumnOrderChange?: (newHeaders: HeaderObject[]) => void;
+ onColumnVisibilityChange?: (visibilityState: ColumnVisibilityState) => void;
+ onColumnWidthChange?: (headers: HeaderObject[]) => void;
+}
+
+export interface ColumnManagerState {
+ headers: HeaderObject[];
+ collapsedHeaders: Set;
+ columnVisibility: ColumnVisibilityState;
+ draggedHeader: HeaderObject | null;
+ hoveredHeader: HeaderObject | null;
+}
+
+type StateChangeCallback = (state: ColumnManagerState) => void;
+
+export class ColumnManager {
+ private config: ColumnManagerConfig;
+ private state: ColumnManagerState;
+ private subscribers: Set = new Set();
+
+ constructor(config: ColumnManagerConfig) {
+ this.config = config;
+
+ const columnVisibility = this.buildColumnVisibilityState(config.headers);
+
+ this.state = {
+ headers: config.headers,
+ collapsedHeaders: config.collapsedHeaders,
+ columnVisibility,
+ draggedHeader: null,
+ hoveredHeader: null,
+ };
+ }
+
+ private buildColumnVisibilityState(headers: HeaderObject[]): ColumnVisibilityState {
+ const visibility: ColumnVisibilityState = {};
+
+ const processHeaders = (headers: HeaderObject[]) => {
+ headers.forEach((header) => {
+ visibility[header.accessor] = !header.hide;
+ if (header.children && header.children.length > 0) {
+ processHeaders(header.children);
+ }
+ });
+ };
+
+ processHeaders(headers);
+ return visibility;
+ }
+
+ updateConfig(config: Partial): void {
+ const oldHeaders = this.config.headers;
+ this.config = { ...this.config, ...config };
+
+ if (config.headers && config.headers !== oldHeaders) {
+ const columnVisibility = this.buildColumnVisibilityState(config.headers);
+ this.state = {
+ ...this.state,
+ headers: config.headers,
+ columnVisibility,
+ };
+ this.notifySubscribers();
+ }
+
+ if (config.collapsedHeaders) {
+ this.state = {
+ ...this.state,
+ collapsedHeaders: config.collapsedHeaders,
+ };
+ this.notifySubscribers();
+ }
+ }
+
+ subscribe(callback: StateChangeCallback): () => void {
+ this.subscribers.add(callback);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ private notifySubscribers(): void {
+ this.subscribers.forEach((cb) => cb(this.state));
+ }
+
+ setHeaders(headers: HeaderObject[]): void {
+ this.state.headers = headers;
+ const columnVisibility = this.buildColumnVisibilityState(headers);
+ this.state.columnVisibility = columnVisibility;
+ this.config.onColumnOrderChange?.(headers);
+ this.notifySubscribers();
+ }
+
+ setCollapsedHeaders(collapsedHeaders: Set): void {
+ this.state.collapsedHeaders = collapsedHeaders;
+ this.notifySubscribers();
+ }
+
+ toggleColumnCollapse(accessor: Accessor): void {
+ const newCollapsedHeaders = new Set(this.state.collapsedHeaders);
+ if (newCollapsedHeaders.has(accessor)) {
+ newCollapsedHeaders.delete(accessor);
+ } else {
+ newCollapsedHeaders.add(accessor);
+ }
+ this.setCollapsedHeaders(newCollapsedHeaders);
+ }
+
+ setColumnVisibility(accessor: Accessor, visible: boolean): void {
+ const newVisibility = {
+ ...this.state.columnVisibility,
+ [accessor]: visible,
+ };
+
+ this.state.columnVisibility = newVisibility;
+ this.config.onColumnVisibilityChange?.(newVisibility);
+ this.notifySubscribers();
+ }
+
+ updateColumnWidth(accessor: Accessor, width: number | string): void {
+ const updateHeaderWidth = (headers: HeaderObject[]): HeaderObject[] => {
+ return headers.map((header) => {
+ if (header.accessor === accessor) {
+ return { ...header, width };
+ }
+ if (header.children && header.children.length > 0) {
+ return {
+ ...header,
+ children: updateHeaderWidth(header.children),
+ };
+ }
+ return header;
+ });
+ };
+
+ const newHeaders = updateHeaderWidth(this.state.headers);
+ this.state.headers = newHeaders;
+ this.config.onColumnWidthChange?.(newHeaders);
+ this.notifySubscribers();
+ }
+
+ reorderColumns(newHeaders: HeaderObject[]): void {
+ this.setHeaders(newHeaders);
+ }
+
+ setDraggedHeader(header: HeaderObject | null): void {
+ this.state.draggedHeader = header;
+ this.notifySubscribers();
+ }
+
+ setHoveredHeader(header: HeaderObject | null): void {
+ this.state.hoveredHeader = header;
+ this.notifySubscribers();
+ }
+
+ getState(): ColumnManagerState {
+ return this.state;
+ }
+
+ getHeaders(): HeaderObject[] {
+ return this.state.headers;
+ }
+
+ getCollapsedHeaders(): Set {
+ return this.state.collapsedHeaders;
+ }
+
+ getColumnVisibility(): ColumnVisibilityState {
+ return this.state.columnVisibility;
+ }
+
+ isColumnVisible(accessor: Accessor): boolean {
+ return this.state.columnVisibility[accessor] !== false;
+ }
+
+ destroy(): void {
+ this.subscribers.clear();
+ }
+}
diff --git a/packages/core/src/managers/DimensionManager.ts b/packages/core/src/managers/DimensionManager.ts
new file mode 100644
index 000000000..1e03d24ee
--- /dev/null
+++ b/packages/core/src/managers/DimensionManager.ts
@@ -0,0 +1,254 @@
+import HeaderObject from "../types/HeaderObject";
+import {
+ CSS_VAR_BORDER_WIDTH,
+ DEFAULT_BORDER_WIDTH,
+ VIRTUALIZATION_THRESHOLD,
+} from "../consts/general-consts";
+
+export interface DimensionManagerConfig {
+ effectiveHeaders: HeaderObject[];
+ headerHeight?: number;
+ rowHeight: number;
+ height?: string | number;
+ maxHeight?: string | number;
+ totalRowCount: number;
+ footerHeight?: number;
+ containerElement?: HTMLElement;
+}
+
+export interface DimensionManagerState {
+ containerWidth: number;
+ calculatedHeaderHeight: number;
+ maxHeaderDepth: number;
+ contentHeight: number | undefined;
+}
+
+type StateChangeCallback = (state: DimensionManagerState) => void;
+
+export class DimensionManager {
+ private config: DimensionManagerConfig;
+ private state: DimensionManagerState;
+ private subscribers: Set = new Set();
+ private resizeObserver: ResizeObserver | null = null;
+ private rafId: number | null = null;
+
+ constructor(config: DimensionManagerConfig) {
+ this.config = config;
+
+ const maxHeaderDepth = this.calculateMaxHeaderDepth();
+ const calculatedHeaderHeight = this.calculateHeaderHeight(maxHeaderDepth);
+ const contentHeight = this.calculateContentHeight();
+
+ this.state = {
+ containerWidth: 0,
+ calculatedHeaderHeight,
+ maxHeaderDepth,
+ contentHeight,
+ };
+
+ if (config.containerElement) {
+ this.observeContainer(config.containerElement);
+ }
+ }
+
+ private getHeaderDepth(header: HeaderObject): number {
+ if (header.singleRowChildren && header.children?.length) {
+ return 1;
+ }
+ return header.children?.length
+ ? 1 + Math.max(...header.children.map((h) => this.getHeaderDepth(h)))
+ : 1;
+ }
+
+ private calculateMaxHeaderDepth(): number {
+ let maxDepth = 0;
+ this.config.effectiveHeaders.forEach((header) => {
+ const depth = this.getHeaderDepth(header);
+ maxDepth = Math.max(maxDepth, depth);
+ });
+ return maxDepth;
+ }
+
+ private calculateHeaderHeight(maxHeaderDepth: number): number {
+ let borderWidth = DEFAULT_BORDER_WIDTH;
+ if (typeof window !== "undefined") {
+ const rootElement = document.documentElement;
+ const computedStyle = getComputedStyle(rootElement);
+ const borderWidthValue = computedStyle.getPropertyValue(CSS_VAR_BORDER_WIDTH).trim();
+ if (borderWidthValue) {
+ const parsed = parseFloat(borderWidthValue);
+ if (!isNaN(parsed)) {
+ borderWidth = parsed;
+ }
+ }
+ }
+ return maxHeaderDepth * (this.config.headerHeight ?? this.config.rowHeight) + borderWidth;
+ }
+
+ private convertHeightToPixels(heightValue: string | number): number {
+ const container = this.config.containerElement || document.querySelector(".simple-table-root");
+
+ if (typeof heightValue === "string") {
+ if (heightValue.endsWith("px")) {
+ return parseInt(heightValue, 10);
+ } else if (heightValue.endsWith("vh")) {
+ const vh = parseInt(heightValue, 10);
+ return (window.innerHeight * vh) / 100;
+ } else if (heightValue.endsWith("%")) {
+ const percentage = parseInt(heightValue, 10);
+ const parentHeight = container?.parentElement?.clientHeight;
+ if (!parentHeight || parentHeight < 50) {
+ return 0;
+ }
+ return (parentHeight * percentage) / 100;
+ } else {
+ return window.innerHeight;
+ }
+ } else {
+ return heightValue as number;
+ }
+ }
+
+ private calculateContentHeight(): number | undefined {
+ const { height, maxHeight, rowHeight, totalRowCount, headerHeight, footerHeight } = this.config;
+
+ if (maxHeight) {
+ const maxHeightPx = this.convertHeightToPixels(maxHeight);
+
+ if (maxHeightPx === 0) {
+ return undefined;
+ }
+
+ const actualHeaderHeight = headerHeight || rowHeight;
+ const actualFooterHeight = footerHeight || 0;
+ const actualContentHeight =
+ actualHeaderHeight + totalRowCount * rowHeight + actualFooterHeight;
+
+ if (actualContentHeight <= maxHeightPx || totalRowCount < VIRTUALIZATION_THRESHOLD) {
+ return undefined;
+ }
+
+ return Math.max(0, maxHeightPx - actualHeaderHeight);
+ }
+
+ if (!height) return undefined;
+
+ const totalHeightPx = this.convertHeightToPixels(height);
+
+ if (totalHeightPx === 0) {
+ return undefined;
+ }
+
+ return Math.max(0, totalHeightPx - rowHeight);
+ }
+
+ private observeContainer(containerElement: HTMLElement): void {
+ const updateContainerWidth = () => {
+ // Defer notification to the next animation frame to prevent ResizeObserver
+ // loop errors in Chromium. Without this, a synchronous render triggered by
+ // the ResizeObserver callback can modify the observed element's layout within
+ // the same frame, causing Chromium to fire a window error with event.error=null.
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId);
+ }
+ this.rafId = requestAnimationFrame(() => {
+ this.rafId = null;
+ const newWidth = containerElement.clientWidth;
+ if (newWidth !== this.state.containerWidth) {
+ this.state = {
+ ...this.state,
+ containerWidth: newWidth,
+ };
+ this.notifySubscribers();
+ }
+ });
+ };
+
+ this.resizeObserver = new ResizeObserver(updateContainerWidth);
+ this.resizeObserver.observe(containerElement);
+ }
+
+ updateConfig(config: Partial): void {
+ const oldHeaders = this.config.effectiveHeaders;
+ const oldContainerElement = this.config.containerElement;
+
+ this.config = { ...this.config, ...config };
+
+ let needsUpdate = false;
+
+ if (config.effectiveHeaders && config.effectiveHeaders !== oldHeaders) {
+ const maxHeaderDepth = this.calculateMaxHeaderDepth();
+ const calculatedHeaderHeight = this.calculateHeaderHeight(maxHeaderDepth);
+ this.state = {
+ ...this.state,
+ maxHeaderDepth,
+ calculatedHeaderHeight,
+ };
+ needsUpdate = true;
+ }
+
+ if (config.height || config.maxHeight || config.totalRowCount !== undefined) {
+ const contentHeight = this.calculateContentHeight();
+ this.state = {
+ ...this.state,
+ contentHeight,
+ };
+ needsUpdate = true;
+ }
+
+ if (config.containerElement && config.containerElement !== oldContainerElement) {
+ if (this.resizeObserver && oldContainerElement) {
+ this.resizeObserver.unobserve(oldContainerElement);
+ }
+ this.observeContainer(config.containerElement);
+ needsUpdate = true;
+ }
+
+ if (needsUpdate) {
+ this.notifySubscribers();
+ }
+ }
+
+ subscribe(callback: StateChangeCallback): () => void {
+ this.subscribers.add(callback);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ private notifySubscribers(): void {
+ this.subscribers.forEach((cb) => cb(this.state));
+ }
+
+ getState(): DimensionManagerState {
+ return this.state;
+ }
+
+ getContainerWidth(): number {
+ return this.state.containerWidth;
+ }
+
+ getCalculatedHeaderHeight(): number {
+ return this.state.calculatedHeaderHeight;
+ }
+
+ getMaxHeaderDepth(): number {
+ return this.state.maxHeaderDepth;
+ }
+
+ getContentHeight(): number | undefined {
+ return this.state.contentHeight;
+ }
+
+ destroy(): void {
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = null;
+ }
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect();
+ this.resizeObserver = null;
+ }
+ this.subscribers.clear();
+ }
+}
diff --git a/src/hooks/useDragHandler.ts b/packages/core/src/managers/DragHandlerManager.ts
similarity index 56%
rename from src/hooks/useDragHandler.ts
rename to packages/core/src/managers/DragHandlerManager.ts
index 342cce4de..3797d79c3 100644
--- a/src/hooks/useDragHandler.ts
+++ b/packages/core/src/managers/DragHandlerManager.ts
@@ -1,14 +1,9 @@
-import { DragEvent } from "react";
import HeaderObject, { Accessor } from "../types/HeaderObject";
-import DragHandlerProps from "../types/DragHandlerProps";
-import usePrevious from "./usePrevious";
import { deepClone } from "../utils/generalUtils";
-import { useTableContext } from "../context/TableContext";
+import PreviousValueTracker from "../hooks/previousValue";
import { validateFullHeaderTreeEssentialOrder } from "../utils/pinnedColumnUtils";
const REVERT_TO_PREVIOUS_HEADERS_DELAY = 1500;
-let prevUpdateTime = Date.now();
-let prevDraggingPosition = { screenX: 0, screenY: 0 };
export const getHeaderIndexPath = (
headers: HeaderObject[],
@@ -28,7 +23,6 @@ export const getHeaderIndexPath = (
return null;
};
-// Get the sibling array at a given index path (navigates to parent's children)
export const getSiblingArray = (headers: HeaderObject[], indexPath: number[]): HeaderObject[] => {
let current = headers;
for (let i = 0; i < indexPath.length - 1; i++) {
@@ -37,14 +31,12 @@ export const getSiblingArray = (headers: HeaderObject[], indexPath: number[]): H
return current;
};
-// Set the sibling array at a given index path back into the tree
export const setSiblingArray = (
headers: HeaderObject[],
indexPath: number[],
newSiblings: HeaderObject[],
): HeaderObject[] => {
if (indexPath.length === 1) {
- // Root level - return the new siblings as the new root array
return newSiblings;
}
let current = headers;
@@ -55,14 +47,12 @@ export const setSiblingArray = (
return headers;
};
-// Helper function to determine which section a header belongs to based on its pinned property
export const getHeaderSection = (header: HeaderObject): "left" | "main" | "right" => {
if (header.pinned === "left") return "left";
if (header.pinned === "right") return "right";
return "main";
};
-// Helper function to update header's pinned property based on target section
export const updateHeaderPinnedProperty = (
header: HeaderObject,
targetSection: "left" | "main" | "right",
@@ -73,7 +63,6 @@ export const updateHeaderPinnedProperty = (
} else if (targetSection === "right") {
updatedHeader.pinned = "right";
} else {
- // For main section, remove the pinned property
delete updatedHeader.pinned;
}
return updatedHeader;
@@ -84,11 +73,9 @@ export function swapHeaders(
draggedPath: number[],
hoveredPath: number[],
): { newHeaders: HeaderObject[]; emergencyBreak: boolean } {
- // Create a deep copy of headers using our custom deep clone function
const newHeaders = deepClone(headers);
let emergencyBreak = false;
- // Helper function to get a header at a given path
function getHeaderAtPath(headers: HeaderObject[], path: number[]): HeaderObject {
let current = headers;
let header: HeaderObject | undefined;
@@ -99,15 +86,12 @@ export function swapHeaders(
return header;
}
- // Helper function to set a header at a given path
function setHeaderAtPath(headers: HeaderObject[], path: number[], value: HeaderObject): void {
let current = headers;
for (let i = 0; i < path.length - 1; i++) {
if (current[path[i]].children) {
current = current[path[i]].children!;
} else {
- // If the header is not a child, we need to break out of the loop
- // This is an emergency because it meant that the header order has changed while this function was running
emergencyBreak = true;
break;
}
@@ -115,11 +99,9 @@ export function swapHeaders(
current[path[path.length - 1]] = value;
}
- // Get the headers at the dragged and hovered paths
const draggedHeader = getHeaderAtPath(newHeaders, draggedPath);
const hoveredHeader = getHeaderAtPath(newHeaders, hoveredPath);
- // Swap the headers
setHeaderAtPath(newHeaders, draggedPath, hoveredHeader);
setHeaderAtPath(newHeaders, hoveredPath, draggedHeader);
@@ -139,10 +121,8 @@ export function insertHeaderAcrossSections({
let emergencyBreak = false;
try {
- // Determine which sections the headers belong to
const hoveredSection = getHeaderSection(hoveredHeader);
- // Find the indices of both headers
const draggedIndex = newHeaders.findIndex((h) => h.accessor === draggedHeader.accessor);
const hoveredIndex = newHeaders.findIndex((h) => h.accessor === hoveredHeader.accessor);
@@ -151,27 +131,17 @@ export function insertHeaderAcrossSections({
return { newHeaders, emergencyBreak };
}
- // Remove the dragged header from its current position
const [removedHeader] = newHeaders.splice(draggedIndex, 1);
-
- // Update the dragged header's pinned property to match the target section
const updatedDraggedHeader = updateHeaderPinnedProperty(removedHeader, hoveredSection);
- // Calculate the correct insertion index
- // We want to place the dragged header at the hovered header's original position
let insertionIndex = hoveredIndex;
- // If dragged was before hovered, the hovered header shifts left after removal
- // But we want to insert at the hovered header's ORIGINAL position
- // So we don't adjust the index - we use the original hoveredIndex
if (draggedIndex < hoveredIndex) {
// Keep the original hovered index to place dragged at target's original position
} else {
// Dragged was after hovered, hovered position is unchanged after removal
}
- // Insert the updated dragged header at the target position
- // This places it at the hovered header's position
newHeaders.splice(insertionIndex, 0, updatedDraggedHeader);
} catch (error) {
console.error("Error in insertHeaderAcrossSections:", error);
@@ -181,51 +151,68 @@ export function insertHeaderAcrossSections({
return { newHeaders, emergencyBreak };
}
-const useDragHandler = ({
- draggedHeaderRef,
- essentialAccessors,
- headers,
- hoveredHeaderRef,
- onColumnOrderChange,
- onTableHeaderDragEnd,
-}: DragHandlerProps) => {
- const { setHeaders } = useTableContext();
- const prevHeaders = usePrevious(headers);
-
- const handleDragStart = (header: HeaderObject) => {
- draggedHeaderRef.current = header;
- prevUpdateTime = Date.now();
- };
-
- const handleDragOver = ({
+export interface DragHandlerManagerConfig {
+ headers: HeaderObject[];
+ essentialAccessors?: ReadonlySet;
+ onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void;
+ onColumnOrderChange?: (newHeaders: HeaderObject[]) => void;
+ onHeadersChange?: (newHeaders: HeaderObject[]) => void;
+}
+
+export class DragHandlerManager {
+ private config: DragHandlerManagerConfig;
+ private draggedHeader: HeaderObject | null = null;
+ private hoveredHeader: HeaderObject | null = null;
+ private prevUpdateTime: number = Date.now();
+ private prevDraggingPosition = { screenX: 0, screenY: 0 };
+ private prevHeadersTracker: PreviousValueTracker;
+
+ constructor(config: DragHandlerManagerConfig) {
+ this.config = config;
+ this.prevHeadersTracker = new PreviousValueTracker(config.headers);
+ }
+
+ updateConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
+ if (config.headers) {
+ this.prevHeadersTracker.update(config.headers);
+ }
+ }
+
+ getDraggedHeader(): HeaderObject | null {
+ return this.draggedHeader;
+ }
+
+ getHoveredHeader(): HeaderObject | null {
+ return this.hoveredHeader;
+ }
+
+ handleDragStart(header: HeaderObject): void {
+ this.draggedHeader = header;
+ this.prevUpdateTime = Date.now();
+ }
+
+ handleDragOver({
event,
hoveredHeader,
}: {
- event: DragEvent;
+ event: DragEvent;
hoveredHeader: HeaderObject;
- }) => {
- // Prevent click event from firing
+ }): void {
event.preventDefault();
- // If the headers are not set, don't allow the drag
- if (!headers || !draggedHeaderRef.current) return;
-
- // Get the animations on the header
- const animations = event.currentTarget.getAnimations();
- const isAnimating = animations.some((animation) => animation.playState === "running");
+ if (!this.config.headers || !this.draggedHeader) return;
- // Get the distance between the previous dragging position and the current position
const { screenX, screenY } = event;
const distance = Math.sqrt(
- Math.pow(screenX - prevDraggingPosition.screenX, 2) +
- Math.pow(screenY - prevDraggingPosition.screenY, 2),
+ Math.pow(screenX - this.prevDraggingPosition.screenX, 2) +
+ Math.pow(screenY - this.prevDraggingPosition.screenY, 2),
);
- hoveredHeaderRef.current = hoveredHeader;
+ this.hoveredHeader = hoveredHeader;
- const draggedHeader = draggedHeaderRef.current;
+ const draggedHeader = this.draggedHeader;
- // Check if this is a cross-section drag
const draggedSection = getHeaderSection(draggedHeader);
const hoveredSection = getHeaderSection(hoveredHeader);
const isCrossSectionDrag = draggedSection !== hoveredSection;
@@ -234,12 +221,16 @@ const useDragHandler = ({
let emergencyBreak = false;
if (isCrossSectionDrag) {
- return;
+ const result = insertHeaderAcrossSections({
+ headers: this.config.headers,
+ draggedHeader,
+ hoveredHeader,
+ });
+ newHeaders = result.newHeaders;
+ emergencyBreak = result.emergencyBreak;
} else {
- // Handle same-section dragging (existing logic)
- const currentHeaders = headers;
+ const currentHeaders = this.config.headers;
- // Get the index paths of both headers
const draggedHeaderIndexPath = getHeaderIndexPath(currentHeaders, draggedHeader.accessor);
const hoveredHeaderIndexPath = getHeaderIndexPath(currentHeaders, hoveredHeader.accessor);
@@ -253,58 +244,47 @@ const useDragHandler = ({
if (draggedHeaderDepth !== hoveredHeaderDepth) {
const depthDifference = hoveredHeaderDepth - draggedHeaderDepth;
if (depthDifference > 0) {
- // Go up the hierarchy to find the parent at the same depth as the dragged header
targetHoveredIndexPath = hoveredHeaderIndexPath.slice(0, -depthDifference);
}
}
- // Check if both headers share the same parent (for nested headers)
- // Headers share the same parent if all path indices match except the last one
const haveSameParent = (path1: number[], path2: number[]): boolean => {
if (path1.length !== path2.length) return false;
- if (path1.length === 1) return true; // Top-level headers always share the same parent (root)
- // Compare all indices except the last one (which is the position within the parent)
+ if (path1.length === 1) return true;
return path1.slice(0, -1).every((index, i) => index === path2[i]);
};
- // If the headers don't share the same parent, don't allow the drag
if (!haveSameParent(draggedHeaderIndexPath, targetHoveredIndexPath)) {
return;
}
- // Create a copy of the headers
const result = swapHeaders(currentHeaders, draggedHeaderIndexPath, targetHoveredIndexPath);
newHeaders = result.newHeaders;
emergencyBreak = result.emergencyBreak;
}
- const essential = essentialAccessors ?? new Set();
if (
- essential.size > 0 &&
- !emergencyBreak &&
- !validateFullHeaderTreeEssentialOrder(newHeaders, essential as ReadonlySet)
- ) {
- return;
- }
-
- if (
- // If the header is animating, don't allow the drag
- isAnimating ||
- // If the header is the same as the dragged header, don't allow the drag
hoveredHeader.accessor === draggedHeader.accessor ||
- // If the distance is less than 10, don't allow the drag
distance < 10 ||
- // If the new headers are the same as the previous headers, don't allow the drag
- JSON.stringify(newHeaders) === JSON.stringify(headers) ||
+ JSON.stringify(newHeaders) === JSON.stringify(this.config.headers) ||
emergencyBreak
)
return;
- // Delay reverting headers to prevent quick reversion when dragging over wide columns.
+ const essentialAccessors = this.config.essentialAccessors;
+ if (
+ essentialAccessors &&
+ essentialAccessors.size > 0 &&
+ !validateFullHeaderTreeEssentialOrder(newHeaders, essentialAccessors)
+ ) {
+ return;
+ }
+
const now = Date.now();
+ const prevHeaders = this.prevHeadersTracker.get();
const arePreviousHeadersAndNewHeadersTheSame =
JSON.stringify(newHeaders) === JSON.stringify(prevHeaders);
- const shouldRevertToPreviousHeaders = now - prevUpdateTime < REVERT_TO_PREVIOUS_HEADERS_DELAY;
+ const shouldRevertToPreviousHeaders = now - this.prevUpdateTime < REVERT_TO_PREVIOUS_HEADERS_DELAY;
if (
arePreviousHeadersAndNewHeadersTheSame &&
@@ -313,34 +293,28 @@ const useDragHandler = ({
return;
}
- // Update the previous update time
- prevUpdateTime = now;
+ this.prevUpdateTime = now;
+ this.prevDraggingPosition = { screenX, screenY };
- // Update the previous dragging position
- prevDraggingPosition = { screenX, screenY };
-
- // Call the onTableHeaderDragEnd callback with the new headers
- onTableHeaderDragEnd(newHeaders);
- };
+ this.config.onTableHeaderDragEnd(newHeaders);
+ }
- const handleDragEnd = () => {
- // Clear the refs first to remove dragging state
- draggedHeaderRef.current = null;
- hoveredHeaderRef.current = null;
+ handleDragEnd(): void {
+ this.draggedHeader = null;
+ this.hoveredHeader = null;
- // Use setHeaders to trigger a re-render and properly clear the st-dragging class
setTimeout(() => {
- setHeaders((prevHeaders) => [...prevHeaders]);
- // Call the column order change callback
- onColumnOrderChange?.(headers);
+ if (this.config.onHeadersChange) {
+ this.config.onHeadersChange([...this.config.headers]);
+ }
+ if (this.config.onColumnOrderChange) {
+ this.config.onColumnOrderChange(this.config.headers);
+ }
}, 10);
- };
-
- return {
- handleDragStart,
- handleDragOver,
- handleDragEnd,
- };
-};
+ }
-export default useDragHandler;
+ destroy(): void {
+ this.draggedHeader = null;
+ this.hoveredHeader = null;
+ }
+}
diff --git a/packages/core/src/managers/FilterManager.ts b/packages/core/src/managers/FilterManager.ts
new file mode 100644
index 000000000..78a0b46f6
--- /dev/null
+++ b/packages/core/src/managers/FilterManager.ts
@@ -0,0 +1,187 @@
+import { TableFilterState, FilterCondition } from "../types/FilterTypes";
+import { applyFilterToValue } from "../utils/filterUtils";
+import Row from "../types/Row";
+import HeaderObject, { Accessor } from "../types/HeaderObject";
+import { getNestedValue } from "../utils/rowUtils";
+import { flattenAllHeaders } from "../utils/headerUtils";
+
+export interface FilterManagerConfig {
+ rows: Row[];
+ headers: HeaderObject[];
+ externalFilterHandling: boolean;
+ onFilterChange?: (filters: TableFilterState) => void;
+ announce?: (message: string) => void;
+}
+
+export interface FilterManagerState {
+ filters: TableFilterState;
+ filteredRows: Row[];
+}
+
+type StateChangeCallback = (state: FilterManagerState) => void;
+
+export class FilterManager {
+ private config: FilterManagerConfig;
+ private state: FilterManagerState;
+ private subscribers: Set = new Set();
+ private headerLookup: Map = new Map();
+
+ constructor(config: FilterManagerConfig) {
+ this.config = config;
+ this.updateHeaderLookup();
+
+ const filters: TableFilterState = {};
+ const filteredRows = this.computeFilteredRows(config.rows, filters);
+
+ this.state = {
+ filters,
+ filteredRows,
+ };
+ }
+
+ private updateHeaderLookup(): void {
+ const allHeaders = flattenAllHeaders(this.config.headers);
+ this.headerLookup = new Map();
+
+ allHeaders.forEach((header) => {
+ this.headerLookup.set(header.accessor, header);
+ });
+ }
+
+ private computeFilteredRows(tableRows: Row[], filterState: TableFilterState): Row[] {
+ if (this.config.externalFilterHandling) return tableRows;
+ if (!filterState || Object.keys(filterState).length === 0) return tableRows;
+
+ return tableRows.filter((row) => {
+ return Object.values(filterState).every((filter) => {
+ try {
+ const cellValue = getNestedValue(row, filter.accessor);
+ return applyFilterToValue(cellValue, filter);
+ } catch (error) {
+ console.warn(`Filter error for accessor ${filter.accessor}:`, error);
+ return true;
+ }
+ });
+ });
+ }
+
+ updateConfig(config: Partial): void {
+ const oldHeaders = this.config.headers;
+ this.config = { ...this.config, ...config };
+
+ if (config.headers && config.headers !== oldHeaders) {
+ this.updateHeaderLookup();
+ }
+
+ const filteredRows = this.computeFilteredRows(this.config.rows, this.state.filters);
+
+ if (filteredRows !== this.state.filteredRows) {
+ this.state = {
+ ...this.state,
+ filteredRows,
+ };
+ this.notifySubscribers();
+ }
+ }
+
+ subscribe(callback: StateChangeCallback): () => void {
+ this.subscribers.add(callback);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ private notifySubscribers(): void {
+ this.subscribers.forEach((cb) => cb(this.state));
+ }
+
+ updateFilter(filter: FilterCondition): void {
+ const newFilterState = {
+ ...this.state.filters,
+ [filter.accessor]: filter,
+ };
+
+ const filteredRows = this.computeFilteredRows(this.config.rows, newFilterState);
+
+ this.state = {
+ filters: newFilterState,
+ filteredRows,
+ };
+
+ this.config.onFilterChange?.(newFilterState);
+
+ if (this.config.announce) {
+ const header = this.headerLookup.get(filter.accessor);
+ if (header) {
+ this.config.announce(`Filter applied to ${header.label}`);
+ }
+ }
+
+ this.notifySubscribers();
+ }
+
+ clearFilter(accessor: Accessor): void {
+ const newFilterState = { ...this.state.filters };
+ delete newFilterState[accessor];
+
+ const filteredRows = this.computeFilteredRows(this.config.rows, newFilterState);
+
+ this.state = {
+ filters: newFilterState,
+ filteredRows,
+ };
+
+ this.config.onFilterChange?.(newFilterState);
+
+ if (this.config.announce) {
+ const header = this.headerLookup.get(accessor);
+ if (header) {
+ this.config.announce(`Filter removed from ${header.label}`);
+ }
+ }
+
+ this.notifySubscribers();
+ }
+
+ clearAllFilters(): void {
+ const filteredRows = this.config.rows;
+
+ this.state = {
+ filters: {},
+ filteredRows,
+ };
+
+ this.config.onFilterChange?.({});
+
+ if (this.config.announce) {
+ this.config.announce("All filters cleared");
+ }
+
+ this.notifySubscribers();
+ }
+
+ computeFilteredRowsPreview(filter: FilterCondition): Row[] {
+ const previewFilterState = {
+ ...this.state.filters,
+ [filter.accessor]: filter,
+ };
+
+ return this.computeFilteredRows(this.config.rows, previewFilterState);
+ }
+
+ getState(): FilterManagerState {
+ return this.state;
+ }
+
+ getFilters(): TableFilterState {
+ return this.state.filters;
+ }
+
+ getFilteredRows(): Row[] {
+ return this.state.filteredRows;
+ }
+
+ destroy(): void {
+ this.subscribers.clear();
+ }
+}
diff --git a/packages/core/src/managers/RowManager.ts b/packages/core/src/managers/RowManager.ts
new file mode 100644
index 000000000..067174d0f
--- /dev/null
+++ b/packages/core/src/managers/RowManager.ts
@@ -0,0 +1,519 @@
+import HeaderObject, { Accessor } from "../types/HeaderObject";
+import { AggregationConfig } from "../types/AggregationTypes";
+import Row from "../types/Row";
+import RowState from "../types/RowState";
+import TableRow from "../types/TableRow";
+import { flattenAllHeaders } from "../utils/headerUtils";
+import {
+ generateRowId,
+ rowIdToString,
+ getNestedRows,
+ isRowExpanded,
+ calculateNestedGridHeight,
+ calculateFinalNestedGridHeight,
+ isRowArray,
+ getNestedValue,
+ setNestedValue,
+} from "../utils/rowUtils";
+import { HeightOffsets } from "../utils/infiniteScrollUtils";
+import { CustomTheme } from "../types/CustomTheme";
+import { GetRowId } from "../types/GetRowId";
+
+export interface RowManagerConfig {
+ rows: Row[];
+ headers: HeaderObject[];
+ rowGrouping?: Accessor[];
+ getRowId?: GetRowId;
+ rowHeight: number;
+ headerHeight: number;
+ customTheme: CustomTheme;
+ hasLoadingRenderer: boolean;
+ hasErrorRenderer: boolean;
+ hasEmptyRenderer: boolean;
+}
+
+export interface RowManagerState {
+ expandedRows: Map;
+ collapsedRows: Map;
+ expandedDepths: Set;
+ rowStateMap: Map;
+ aggregatedRows: Row[];
+ flattenedRows: TableRow[];
+ heightOffsets: HeightOffsets;
+ paginatableRows: TableRow[];
+ parentEndPositions: number[];
+}
+
+type StateChangeCallback = (state: RowManagerState) => void;
+
+export class RowManager {
+ private config: RowManagerConfig;
+ private state: RowManagerState;
+ private subscribers: Set = new Set();
+
+ constructor(config: RowManagerConfig) {
+ this.config = config;
+
+ const aggregatedRows = this.computeAggregatedRows(config.rows);
+ const flattenedResult = this.computeFlattenedRows(aggregatedRows);
+
+ this.state = {
+ expandedRows: new Map(),
+ collapsedRows: new Map(),
+ expandedDepths: new Set(),
+ rowStateMap: new Map(),
+ aggregatedRows,
+ ...flattenedResult,
+ };
+ }
+
+ private getAllAggregationHeaders(): HeaderObject[] {
+ return flattenAllHeaders(this.config.headers).filter((header) => header.aggregation);
+ }
+
+ private calculateAggregation(
+ childRows: Row[],
+ accessor: Accessor,
+ config: AggregationConfig,
+ nextGroupKey?: string
+ ): any {
+ const allValues: any[] = [];
+
+ const collectValues = (rows: Row[]) => {
+ rows.forEach((row) => {
+ const nextGroupValue = nextGroupKey ? row[nextGroupKey] : undefined;
+ if (nextGroupKey && nextGroupValue && isRowArray(nextGroupValue)) {
+ collectValues(nextGroupValue);
+ } else {
+ const value = getNestedValue(row, accessor);
+ if (value !== undefined && value !== null) {
+ allValues.push(value);
+ }
+ }
+ });
+ };
+
+ collectValues(childRows);
+
+ if (allValues.length === 0) {
+ return undefined;
+ }
+
+ if (config.type === "custom" && config.customFn) {
+ return config.customFn(allValues);
+ }
+
+ const numericValues = config.parseValue
+ ? allValues.map(config.parseValue).filter((val) => !isNaN(val))
+ : allValues
+ .map((val) => {
+ if (typeof val === "number") return val;
+ if (typeof val === "string") return parseFloat(val);
+ return NaN;
+ })
+ .filter((val) => !isNaN(val));
+
+ if (numericValues.length === 0) {
+ return config.type === "count" ? allValues.length : undefined;
+ }
+
+ let result: number;
+
+ switch (config.type) {
+ case "sum":
+ result = numericValues.reduce((sum, val) => sum + val, 0);
+ break;
+ case "average":
+ result = numericValues.reduce((sum, val) => sum + val, 0) / numericValues.length;
+ break;
+ case "count":
+ result = allValues.length;
+ break;
+ case "min":
+ result = Math.min(...numericValues);
+ break;
+ case "max":
+ result = Math.max(...numericValues);
+ break;
+ default:
+ return undefined;
+ }
+
+ return config.formatResult ? config.formatResult(result) : result;
+ }
+
+ private computeAggregatedRows(rows: Row[]): Row[] {
+ const rowGrouping = this.config.rowGrouping;
+
+ if (!rowGrouping || rowGrouping.length === 0) {
+ return rows;
+ }
+
+ const aggregationHeaders = this.getAllAggregationHeaders();
+
+ if (aggregationHeaders.length === 0) {
+ return rows;
+ }
+
+ const aggregatedRows = JSON.parse(JSON.stringify(rows));
+
+ const processRows = (rowsToProcess: Row[], groupingLevel: number = 0): Row[] => {
+ return rowsToProcess.map((row) => {
+ const currentGroupKey = rowGrouping[groupingLevel];
+ const nextGroupKey = rowGrouping[groupingLevel + 1];
+
+ const currentGroupValue = row[currentGroupKey];
+ if (currentGroupValue && isRowArray(currentGroupValue)) {
+ const processedChildren = processRows(currentGroupValue, groupingLevel + 1);
+
+ const aggregatedRow = { ...row };
+ aggregatedRow[currentGroupKey] = processedChildren;
+
+ aggregationHeaders.forEach((header) => {
+ const aggregatedValue = this.calculateAggregation(
+ processedChildren,
+ header.accessor,
+ header.aggregation!,
+ nextGroupKey
+ );
+
+ if (aggregatedValue !== undefined) {
+ setNestedValue(aggregatedRow, header.accessor, aggregatedValue);
+ }
+ });
+
+ return aggregatedRow;
+ }
+
+ return row;
+ });
+ };
+
+ return processRows(aggregatedRows);
+ }
+
+ private computeFlattenedRows(rows: Row[]): {
+ flattenedRows: TableRow[];
+ heightOffsets: HeightOffsets;
+ paginatableRows: TableRow[];
+ parentEndPositions: number[];
+ } {
+ const rowGrouping = this.config.rowGrouping;
+
+ if (!rowGrouping || rowGrouping.length === 0) {
+ const flattenedRows = rows.map((row, index) => {
+ const rowPath = [index];
+ const rowIndexPath = [index];
+ const rowId = generateRowId({
+ row,
+ getRowId: this.config.getRowId,
+ depth: 0,
+ index,
+ rowPath,
+ rowIndexPath,
+ groupingKey: undefined,
+ });
+
+ return {
+ row,
+ depth: 0,
+ displayPosition: index,
+ groupingKey: undefined,
+ position: index,
+ rowId,
+ rowPath,
+ rowIndexPath,
+ absoluteRowIndex: index,
+ isLastGroupRow: false,
+ };
+ });
+
+ const parentEndPositions = rows.map((_, index) => index + 1);
+
+ return {
+ flattenedRows,
+ heightOffsets: [],
+ paginatableRows: flattenedRows,
+ parentEndPositions,
+ };
+ }
+
+ const result: TableRow[] = [];
+ const paginatableRowsBuilder: TableRow[] = [];
+ const heightOffsets: HeightOffsets = [];
+ const parentEndPositions: number[] = [];
+
+ let displayPosition = 0;
+
+ const processRows = (
+ currentRows: Row[],
+ currentDepth: number,
+ parentIdPath: (string | number)[] = [],
+ parentIndexPath: number[] = [],
+ parentIndices: number[] = []
+ ): void => {
+ currentRows.forEach((row, index) => {
+ const currentGroupingKey = rowGrouping[currentDepth];
+ const position = result.length;
+
+ const rowPath = [...parentIdPath, index];
+ const rowIndexPath = [...parentIndexPath, index];
+
+ const rowId = generateRowId({
+ row,
+ getRowId: this.config.getRowId,
+ depth: currentDepth,
+ index,
+ rowPath,
+ rowIndexPath,
+ groupingKey: currentGroupingKey,
+ });
+
+ const isLastGroupRow = currentDepth === 0;
+ const currentRowIndex = result.length;
+
+ const mainRow = {
+ row,
+ depth: currentDepth,
+ displayPosition,
+ groupingKey: currentGroupingKey,
+ position,
+ isLastGroupRow,
+ rowId,
+ rowPath,
+ rowIndexPath,
+ absoluteRowIndex: position,
+ parentIndices: parentIndices.length > 0 ? [...parentIndices] : undefined,
+ };
+ result.push(mainRow);
+ paginatableRowsBuilder.push(mainRow);
+
+ displayPosition++;
+
+ const rowIdKey = rowIdToString(rowId);
+
+ const isExpanded = isRowExpanded(
+ rowIdKey,
+ currentDepth,
+ this.state.expandedDepths,
+ this.state.expandedRows,
+ this.state.collapsedRows
+ );
+
+ if (isExpanded && currentDepth < rowGrouping.length) {
+ const rowState = this.state.rowStateMap?.get(rowIdKey);
+ const nestedRows = getNestedRows(row, currentGroupingKey);
+
+ const expandableHeader = this.config.headers.find((h) => h.expandable && h.nestedTable);
+
+ if (expandableHeader?.nestedTable && nestedRows.length > 0) {
+ const nestedGridPosition = result.length;
+
+ const nestedGridRowHeight =
+ expandableHeader.nestedTable.customTheme?.rowHeight || this.config.rowHeight;
+ const nestedGridHeaderHeight =
+ expandableHeader.nestedTable.customTheme?.headerHeight || this.config.headerHeight;
+
+ const calculatedHeight = calculateNestedGridHeight({
+ childRowCount: nestedRows.length,
+ rowHeight: nestedGridRowHeight,
+ headerHeight: nestedGridHeaderHeight,
+ customTheme: this.config.customTheme,
+ });
+
+ const finalHeight = calculateFinalNestedGridHeight({
+ calculatedHeight,
+ customHeight: expandableHeader.nestedTable.height,
+ customTheme: this.config.customTheme,
+ });
+
+ const extraHeight = finalHeight - this.config.rowHeight;
+
+ heightOffsets.push([nestedGridPosition, extraHeight]);
+
+ const nestedGridRowPath = [...rowPath, currentGroupingKey];
+ result.push({
+ row: {},
+ depth: currentDepth + 1,
+ displayPosition: displayPosition - 1,
+ groupingKey: currentGroupingKey,
+ position: nestedGridPosition,
+ isLastGroupRow: false,
+ rowId: nestedGridRowPath,
+ rowPath: nestedGridRowPath,
+ rowIndexPath,
+ nestedTable: {
+ parentRow: row,
+ expandableHeader,
+ childAccessor: currentGroupingKey,
+ calculatedHeight: finalHeight,
+ },
+ absoluteRowIndex: nestedGridPosition,
+ });
+ } else if (rowState && (rowState.loading || rowState.error || rowState.isEmpty)) {
+ const shouldShowState =
+ (rowState.loading && this.config.hasLoadingRenderer) ||
+ (rowState.error && this.config.hasErrorRenderer) ||
+ (rowState.isEmpty && this.config.hasEmptyRenderer);
+
+ if (shouldShowState) {
+ const statePosition = result.length;
+ const stateRowPath = [...rowPath, currentGroupingKey];
+ result.push({
+ row: {},
+ depth: currentDepth + 1,
+ displayPosition: displayPosition - 1,
+ groupingKey: currentGroupingKey,
+ position: statePosition,
+ isLastGroupRow: false,
+ rowId: stateRowPath,
+ rowPath: stateRowPath,
+ rowIndexPath,
+ stateIndicator: {
+ parentRowId: rowIdKey,
+ parentRow: row,
+ state: rowState,
+ },
+ absoluteRowIndex: statePosition,
+ parentIndices: [...parentIndices, currentRowIndex],
+ });
+ } else if (rowState.loading && !this.config.hasLoadingRenderer) {
+ const skeletonPosition = result.length;
+ const skeletonRowPath = [...rowPath, currentGroupingKey, "loading-skeleton"];
+ result.push({
+ row: {},
+ depth: currentDepth + 1,
+ displayPosition: displayPosition - 1,
+ groupingKey: currentGroupingKey,
+ position: skeletonPosition,
+ isLastGroupRow: false,
+ rowId: skeletonRowPath,
+ rowPath: skeletonRowPath,
+ rowIndexPath,
+ isLoadingSkeleton: true,
+ absoluteRowIndex: skeletonPosition,
+ parentIndices: [...parentIndices, currentRowIndex],
+ });
+ }
+ } else if (nestedRows.length > 0) {
+ const nestedIdPath = [...rowPath, currentGroupingKey];
+ const nestedIndexPath = [...rowIndexPath];
+ processRows(nestedRows, currentDepth + 1, nestedIdPath, nestedIndexPath, [
+ ...parentIndices,
+ currentRowIndex,
+ ]);
+ }
+ }
+
+ if (currentDepth === 0) {
+ parentEndPositions.push(result.length);
+ }
+ });
+ };
+
+ processRows(rows, 0, [], [], []);
+
+ return {
+ flattenedRows: result,
+ heightOffsets,
+ paginatableRows: paginatableRowsBuilder,
+ parentEndPositions,
+ };
+ }
+
+ updateConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
+
+ if (config.rows || config.headers || config.rowGrouping) {
+ const aggregatedRows = this.computeAggregatedRows(this.config.rows);
+ const flattenedResult = this.computeFlattenedRows(aggregatedRows);
+
+ this.state = {
+ ...this.state,
+ aggregatedRows,
+ ...flattenedResult,
+ };
+
+ this.notifySubscribers();
+ }
+ }
+
+ subscribe(callback: StateChangeCallback): () => void {
+ this.subscribers.add(callback);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ private notifySubscribers(): void {
+ this.subscribers.forEach((cb) => cb(this.state));
+ }
+
+ setExpandedRows(expandedRows: Map): void {
+ this.state.expandedRows = expandedRows;
+ const flattenedResult = this.computeFlattenedRows(this.state.aggregatedRows);
+ this.state = {
+ ...this.state,
+ ...flattenedResult,
+ };
+ this.notifySubscribers();
+ }
+
+ setCollapsedRows(collapsedRows: Map): void {
+ this.state.collapsedRows = collapsedRows;
+ const flattenedResult = this.computeFlattenedRows(this.state.aggregatedRows);
+ this.state = {
+ ...this.state,
+ ...flattenedResult,
+ };
+ this.notifySubscribers();
+ }
+
+ setExpandedDepths(expandedDepths: Set): void {
+ this.state.expandedDepths = expandedDepths;
+ const flattenedResult = this.computeFlattenedRows(this.state.aggregatedRows);
+ this.state = {
+ ...this.state,
+ ...flattenedResult,
+ };
+ this.notifySubscribers();
+ }
+
+ setRowStateMap(rowStateMap: Map): void {
+ this.state.rowStateMap = rowStateMap;
+ const flattenedResult = this.computeFlattenedRows(this.state.aggregatedRows);
+ this.state = {
+ ...this.state,
+ ...flattenedResult,
+ };
+ this.notifySubscribers();
+ }
+
+ getState(): RowManagerState {
+ return this.state;
+ }
+
+ getAggregatedRows(): Row[] {
+ return this.state.aggregatedRows;
+ }
+
+ getFlattenedRows(): TableRow[] {
+ return this.state.flattenedRows;
+ }
+
+ getHeightOffsets(): HeightOffsets {
+ return this.state.heightOffsets;
+ }
+
+ getPaginatableRows(): TableRow[] {
+ return this.state.paginatableRows;
+ }
+
+ getParentEndPositions(): number[] {
+ return this.state.parentEndPositions;
+ }
+
+ destroy(): void {
+ this.subscribers.clear();
+ }
+}
diff --git a/packages/core/src/managers/RowSelectionManager.ts b/packages/core/src/managers/RowSelectionManager.ts
new file mode 100644
index 000000000..388054ee5
--- /dev/null
+++ b/packages/core/src/managers/RowSelectionManager.ts
@@ -0,0 +1,210 @@
+import TableRow from "../types/TableRow";
+import RowSelectionChangeProps from "../types/RowSelectionChangeProps";
+import { rowIdToString } from "../utils/rowUtils";
+import {
+ areAllRowsSelected,
+ toggleRowSelection,
+ selectAllRows,
+ deselectAllRows,
+ getSelectedRows,
+ getSelectedRowCount,
+ isRowSelected as utilIsRowSelected,
+} from "../utils/rowSelectionUtils";
+
+export interface RowSelectionManagerConfig {
+ tableRows: TableRow[];
+ onRowSelectionChange?: (props: RowSelectionChangeProps) => void;
+ enableRowSelection?: boolean;
+}
+
+export interface RowSelectionManagerState {
+ selectedRows: Set;
+ selectedRowCount: number;
+ selectedRowsData: any[];
+}
+
+type StateChangeCallback = (state: RowSelectionManagerState) => void;
+
+export class RowSelectionManager {
+ private config: RowSelectionManagerConfig;
+ private state: RowSelectionManagerState;
+ private subscribers: Set = new Set();
+
+ constructor(config: RowSelectionManagerConfig) {
+ this.config = config;
+
+ this.state = {
+ selectedRows: new Set(),
+ selectedRowCount: 0,
+ selectedRowsData: [],
+ };
+ }
+
+ updateConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
+
+ if (config.tableRows) {
+ this.updateDerivedState();
+ }
+ }
+
+ private updateDerivedState(): void {
+ this.state = {
+ ...this.state,
+ selectedRowCount: getSelectedRowCount(this.state.selectedRows),
+ selectedRowsData: getSelectedRows(this.config.tableRows, this.state.selectedRows),
+ };
+ }
+
+ subscribe(callback: StateChangeCallback): () => void {
+ this.subscribers.add(callback);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ private notifySubscribers(): void {
+ this.subscribers.forEach((cb) => cb(this.state));
+ }
+
+ isRowSelected(rowId: string): boolean {
+ if (!this.config.enableRowSelection) return false;
+ return utilIsRowSelected(rowId, this.state.selectedRows);
+ }
+
+ areAllRowsSelected(): boolean {
+ if (!this.config.enableRowSelection) return false;
+ return areAllRowsSelected(this.config.tableRows, this.state.selectedRows);
+ }
+
+ getSelectedRows(): Set {
+ return this.state.selectedRows;
+ }
+
+ getSelectedRowCount(): number {
+ return this.state.selectedRowCount;
+ }
+
+ getSelectedRowsData(): any[] {
+ return this.state.selectedRowsData;
+ }
+
+ setSelectedRows(selectedRows: Set): void {
+ this.state = {
+ ...this.state,
+ selectedRows,
+ };
+ this.updateDerivedState();
+ this.notifySubscribers();
+ }
+
+ handleRowSelect(rowId: string, isSelected: boolean): void {
+ if (!this.config.enableRowSelection) return;
+
+ const newSelectedRows = toggleRowSelection(rowId, this.state.selectedRows);
+ this.state = {
+ ...this.state,
+ selectedRows: newSelectedRows,
+ };
+ this.updateDerivedState();
+
+ if (this.config.onRowSelectionChange) {
+ const tableRow = this.config.tableRows.find(
+ (tr) => rowIdToString(tr.rowId) === rowId
+ );
+ if (tableRow) {
+ this.config.onRowSelectionChange({
+ row: tableRow.row,
+ isSelected,
+ selectedRows: newSelectedRows,
+ });
+ }
+ }
+
+ this.notifySubscribers();
+ }
+
+ handleSelectAll(isSelected: boolean): void {
+ if (!this.config.enableRowSelection) return;
+
+ let newSelectedRows: Set;
+
+ if (isSelected) {
+ newSelectedRows = selectAllRows(this.config.tableRows);
+ if (this.config.onRowSelectionChange) {
+ this.config.tableRows.forEach((tableRow) =>
+ this.config.onRowSelectionChange!({
+ row: tableRow.row,
+ isSelected: true,
+ selectedRows: newSelectedRows,
+ })
+ );
+ }
+ } else {
+ newSelectedRows = deselectAllRows();
+ if (this.config.onRowSelectionChange) {
+ this.state.selectedRows.forEach((rowId) => {
+ const tableRow = this.config.tableRows.find(
+ (tr) => rowIdToString(tr.rowId) === rowId
+ );
+ if (tableRow) {
+ this.config.onRowSelectionChange!({
+ row: tableRow.row,
+ isSelected: false,
+ selectedRows: newSelectedRows,
+ });
+ }
+ });
+ }
+ }
+
+ this.state = {
+ ...this.state,
+ selectedRows: newSelectedRows,
+ };
+ this.updateDerivedState();
+ this.notifySubscribers();
+ }
+
+ handleToggleRow(rowId: string): void {
+ if (!this.config.enableRowSelection) return;
+
+ const wasSelected = this.isRowSelected(rowId);
+ this.handleRowSelect(rowId, !wasSelected);
+ }
+
+ clearSelection(): void {
+ if (!this.config.enableRowSelection) return;
+
+ if (this.config.onRowSelectionChange) {
+ const newSelectedRows = new Set();
+ this.state.selectedRows.forEach((rowId) => {
+ const tableRow = this.config.tableRows.find(
+ (tr) => rowIdToString(tr.rowId) === rowId
+ );
+ if (tableRow) {
+ this.config.onRowSelectionChange!({
+ row: tableRow.row,
+ isSelected: false,
+ selectedRows: newSelectedRows,
+ });
+ }
+ });
+ }
+
+ this.state = {
+ ...this.state,
+ selectedRows: new Set(),
+ };
+ this.updateDerivedState();
+ this.notifySubscribers();
+ }
+
+ getState(): RowSelectionManagerState {
+ return this.state;
+ }
+
+ destroy(): void {
+ this.subscribers.clear();
+ }
+}
diff --git a/packages/core/src/managers/ScrollManager.ts b/packages/core/src/managers/ScrollManager.ts
new file mode 100644
index 000000000..18f8a9490
--- /dev/null
+++ b/packages/core/src/managers/ScrollManager.ts
@@ -0,0 +1,126 @@
+export interface ScrollManagerConfig {
+ onLoadMore?: () => void;
+ infiniteScrollThreshold?: number;
+}
+
+export interface ScrollManagerState {
+ scrollTop: number;
+ scrollLeft: number;
+ scrollDirection: "up" | "down" | "none";
+ isScrolling: boolean;
+}
+
+type StateChangeCallback = (state: ScrollManagerState) => void;
+
+/**
+ * Manages vertical scroll state (scrollTop, direction, isScrolling) and infinite scroll.
+ * Horizontal header/body/scrollbar sync is handled by SectionScrollController.
+ */
+export class ScrollManager {
+ private config: ScrollManagerConfig;
+ private state: ScrollManagerState;
+ private subscribers: Set = new Set();
+ private lastScrollTop: number = 0;
+ private scrollTimeoutId: number | null = null;
+
+ constructor(config: ScrollManagerConfig) {
+ this.config = config;
+
+ this.state = {
+ scrollTop: 0,
+ scrollLeft: 0,
+ scrollDirection: "none",
+ isScrolling: false,
+ };
+ }
+
+ updateConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
+ }
+
+ subscribe(callback: StateChangeCallback): () => void {
+ this.subscribers.add(callback);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ private notifySubscribers(): void {
+ this.subscribers.forEach((cb) => cb(this.state));
+ }
+
+ handleScroll(
+ scrollTop: number,
+ scrollLeft: number,
+ containerHeight: number,
+ contentHeight: number,
+ ): void {
+ const direction =
+ scrollTop > this.lastScrollTop ? "down" : scrollTop < this.lastScrollTop ? "up" : "none";
+ this.lastScrollTop = scrollTop;
+
+ this.state = {
+ scrollTop,
+ scrollLeft,
+ scrollDirection: direction,
+ isScrolling: true,
+ };
+
+ if (this.scrollTimeoutId !== null) {
+ clearTimeout(this.scrollTimeoutId);
+ }
+
+ this.scrollTimeoutId = window.setTimeout(() => {
+ this.state = {
+ ...this.state,
+ isScrolling: false,
+ };
+ this.notifySubscribers();
+ }, 150);
+
+ if (this.config.onLoadMore && this.config.infiniteScrollThreshold) {
+ const distanceFromBottom = contentHeight - (scrollTop + containerHeight);
+ if (distanceFromBottom < this.config.infiniteScrollThreshold) {
+ this.config.onLoadMore();
+ }
+ }
+
+ this.notifySubscribers();
+ }
+
+ setScrolling(isScrolling: boolean): void {
+ this.state = {
+ ...this.state,
+ isScrolling,
+ };
+ this.notifySubscribers();
+ }
+
+ getState(): ScrollManagerState {
+ return this.state;
+ }
+
+ getScrollTop(): number {
+ return this.state.scrollTop;
+ }
+
+ getScrollLeft(): number {
+ return this.state.scrollLeft;
+ }
+
+ getScrollDirection(): "up" | "down" | "none" {
+ return this.state.scrollDirection;
+ }
+
+ isScrolling(): boolean {
+ return this.state.isScrolling;
+ }
+
+ destroy(): void {
+ if (this.scrollTimeoutId !== null) {
+ clearTimeout(this.scrollTimeoutId);
+ this.scrollTimeoutId = null;
+ }
+ this.subscribers.clear();
+ }
+}
diff --git a/packages/core/src/managers/SectionScrollController.ts b/packages/core/src/managers/SectionScrollController.ts
new file mode 100644
index 000000000..6db1f8045
--- /dev/null
+++ b/packages/core/src/managers/SectionScrollController.ts
@@ -0,0 +1,181 @@
+export type SectionId = "pinned-left" | "main" | "pinned-right";
+export type SectionPaneRole = "sticky" | "scrollbar" | "header" | "body";
+
+interface RegisteredPane {
+ element: HTMLElement;
+ role: SectionPaneRole;
+}
+
+export interface SectionScrollControllerConfig {
+ onMainSectionScrollLeft?: (scrollLeft: number) => void;
+}
+
+/** Run column virtualization only when scroll has moved by at least this many px (reduces lag; scroll position still syncs every scroll). */
+const VIRTUALIZATION_THRESHOLD_PX = 20;
+
+/**
+ * Single controller for horizontal scroll sync across all four panes per section:
+ * sticky parent, horizontal scrollbar segment, header, and body.
+ * Scrolling any one pane updates the other three in that section.
+ * All four panes must have the same scroll width (enforced by renderers).
+ */
+export class SectionScrollController {
+ private scrollLeftBySection: Record = {
+ "pinned-left": 0,
+ main: 0,
+ "pinned-right": 0,
+ };
+ private panesBySection: Map> = new Map([
+ ["pinned-left", new Set()],
+ ["main", new Set()],
+ ["pinned-right", new Set()],
+ ]);
+ private scrollHandlers: WeakMap void> = new WeakMap();
+ private config: SectionScrollControllerConfig;
+ /** Guard to avoid re-entrancy when we programmatically set scrollLeft on other panes */
+ private isSyncing = false;
+ /** Last scrollLeft at which we ran main-section virtualization; used to run heavy ops only every N px. */
+ private lastMainVirtualizationScrollLeft: number | null = null;
+
+ constructor(config: SectionScrollControllerConfig = {}) {
+ this.config = config;
+ }
+
+ updateConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
+ }
+
+ /**
+ * Register a pane (sticky, scrollbar, header, or body) for a section.
+ * When any registered pane scrolls, the others in the same section are updated.
+ * If a pane with the same role was already registered (e.g. after re-render), it is replaced.
+ */
+ registerPane(sectionId: SectionId, element: HTMLElement, role: SectionPaneRole): void {
+ const panes = this.panesBySection.get(sectionId)!;
+ const existingSameElement = Array.from(panes).find((p) => p.element === element);
+ if (existingSameElement) return;
+
+ const existingSameRole = Array.from(panes).find((p) => p.role === role);
+ if (existingSameRole) {
+ this.removeScrollListener(existingSameRole.element);
+ panes.delete(existingSameRole);
+ }
+
+ panes.add({ element, role });
+ this.addScrollListener(sectionId, element);
+ // Sync new pane to current section scroll position (e.g. when scrollbar is created after header/body)
+ const current = this.scrollLeftBySection[sectionId] ?? 0;
+ if (element.scrollLeft !== current) {
+ this.isSyncing = true;
+ element.scrollLeft = current;
+ this.isSyncing = false;
+ }
+ }
+
+ /**
+ * Unregister a pane (e.g. when section is removed or re-created).
+ */
+ unregisterPane(sectionId: SectionId, element: HTMLElement): void {
+ const panes = this.panesBySection.get(sectionId);
+ if (!panes) return;
+
+ this.removeScrollListener(element);
+ const toRemove = Array.from(panes).find((p) => p.element === element);
+ if (toRemove) panes.delete(toRemove);
+ }
+
+ /**
+ * Unregister all panes for a section (e.g. on cleanup).
+ */
+ unregisterSection(sectionId: SectionId): void {
+ const panes = this.panesBySection.get(sectionId);
+ if (!panes) return;
+
+ panes.forEach(({ element }) => this.removeScrollListener(element));
+ panes.clear();
+ }
+
+ /**
+ * Set scroll position for a section. Updates state and all registered panes.
+ * Used when a pane fires scroll and when restoring after render.
+ */
+ setSectionScrollLeft(sectionId: SectionId, value: number): void {
+ this.scrollLeftBySection[sectionId] = value;
+ const panes = this.panesBySection.get(sectionId);
+ if (!panes) return;
+
+ panes.forEach(({ element }) => {
+ if (element.scrollLeft !== value) {
+ element.scrollLeft = value;
+ }
+ });
+
+ if (sectionId === "main" && this.config.onMainSectionScrollLeft) {
+ this.lastMainVirtualizationScrollLeft = value;
+ this.config.onMainSectionScrollLeft(value);
+ }
+ }
+
+ getSectionScrollLeft(sectionId: SectionId): number {
+ return this.scrollLeftBySection[sectionId] ?? 0;
+ }
+
+ /**
+ * Restore scroll position to all registered panes from stored state (e.g. after render).
+ */
+ restoreAll(): void {
+ (["pinned-left", "main", "pinned-right"] as SectionId[]).forEach((sectionId) => {
+ const value = this.scrollLeftBySection[sectionId] ?? 0;
+ this.isSyncing = true;
+ this.setSectionScrollLeft(sectionId, value);
+ this.isSyncing = false;
+ });
+ }
+
+ private addScrollListener(sectionId: SectionId, element: HTMLElement): void {
+ this.removeScrollListener(element);
+ const handler = () => {
+ if (this.isSyncing) return;
+ const value = element.scrollLeft;
+ this.scrollLeftBySection[sectionId] = value;
+ this.isSyncing = true;
+
+ const panes = this.panesBySection.get(sectionId);
+ if (panes) {
+ panes.forEach(({ element: paneEl }) => {
+ if (paneEl !== element && paneEl.scrollLeft !== value) {
+ paneEl.scrollLeft = value;
+ }
+ });
+ }
+ // Virtualization (main section only): run only every N px so scroll position sync paints without being blocked
+ if (
+ sectionId === "main" &&
+ this.config.onMainSectionScrollLeft &&
+ (this.lastMainVirtualizationScrollLeft === null ||
+ Math.abs(value - this.lastMainVirtualizationScrollLeft) >= VIRTUALIZATION_THRESHOLD_PX)
+ ) {
+ this.lastMainVirtualizationScrollLeft = value;
+ this.config.onMainSectionScrollLeft(value);
+ }
+
+ this.isSyncing = false;
+ };
+ this.scrollHandlers.set(element, handler);
+ element.addEventListener("scroll", handler, { passive: true });
+ }
+
+ private removeScrollListener(element: HTMLElement): void {
+ const handler = this.scrollHandlers.get(element);
+ if (handler) {
+ element.removeEventListener("scroll", handler);
+ this.scrollHandlers.delete(element);
+ }
+ }
+
+ destroy(): void {
+ (["pinned-left", "main", "pinned-right"] as SectionId[]).forEach((sectionId) =>
+ this.unregisterSection(sectionId),
+ );
+ }
+}
diff --git a/packages/core/src/managers/SelectionManager/SelectionManager.ts b/packages/core/src/managers/SelectionManager/SelectionManager.ts
new file mode 100644
index 000000000..e9d81065a
--- /dev/null
+++ b/packages/core/src/managers/SelectionManager/SelectionManager.ts
@@ -0,0 +1,1458 @@
+import type Cell from "../../types/Cell";
+import type HeaderObject from "../../types/HeaderObject";
+import type TableRowType from "../../types/TableRow";
+import { findLeafHeaders } from "../../utils/headerWidthUtils";
+import { rowIdToString } from "../../utils/rowUtils";
+import { scrollCellIntoView } from "../../utils/cellScrollUtils";
+import {
+ copySelectedCellsToClipboard,
+ pasteClipboardDataToCells,
+ deleteSelectedCellsContent,
+} from "../../utils/cellClipboardUtils";
+import { createSetString, type SelectionManagerConfig } from "./types";
+import { findEdgeInDirection as findEdgeInDirectionUtil } from "./keyboardUtils";
+import { computeSelectionRange } from "./selectionRangeUtils";
+import {
+ getCellFromMousePosition as getCellFromMousePositionUtil,
+ handleAutoScroll as handleAutoScrollUtil,
+ calculateNearestCell as calculateNearestCellUtil,
+} from "./mouseUtils";
+
+export type { SelectionManagerConfig } from "./types";
+export { createSetString } from "./types";
+
+export class SelectionManager {
+ // Configuration
+ private config: SelectionManagerConfig;
+
+ // Internal state
+ private selectedCells: Set = new Set();
+ private selectedColumns: Set = new Set();
+ private lastSelectedColumnIndex: number | null = null;
+ private initialFocusedCell: Cell | null = null;
+ private copyFlashCells: Set = new Set();
+ private warningFlashCells: Set = new Set();
+ private isSelecting: boolean = false;
+ private startCell: Cell | null = null;
+
+ // Event handlers that need to be cleaned up
+ private keydownHandler: ((event: KeyboardEvent) => void) | null = null;
+
+ // Mouse interaction state
+ private currentMouseX: number | null = null;
+ private currentMouseY: number | null = null;
+ private scrollAnimationFrame: number | null = null;
+ private lastSelectionUpdate: number = 0;
+ private selectionThrottleMs: number = 16;
+ private globalMouseMoveHandler: ((event: MouseEvent) => void) | null = null;
+ private globalMouseUpHandler: (() => void) | null = null;
+
+ // Cached derived state
+ private columnsWithSelectedCells: Set = new Set();
+ private rowsWithSelectedCells: Set = new Set();
+ private leafHeaders: HeaderObject[] = [];
+ /** rowId -> table row index; avoids O(tableRows) findIndex per cell in getBorderClass */
+ private rowIdToTableIndex: Map = new Map();
+ /** Set of "rowId\tcolIndex" for O(1) selected membership in syncAllCellClasses */
+ private selectedByRowIdColIndex: Set = new Set();
+ /** When true, all cells are selected without storing R×C cell IDs (fast path for Cmd+A). */
+ private fullTableSelected: boolean = false;
+
+ constructor(config: SelectionManagerConfig) {
+ this.config = config;
+ this.updateDerivedState();
+ this.setupKeyboardNavigation();
+ }
+
+ /**
+ * Update configuration when props change.
+ * When options.positionOnlyBody is true (e.g. scroll-only render), only updates rowIdToTableIndex and
+ * selectedByRowIdColIndex so lookups work for new cells; skips columnsWithSelectedCells/rowsWithSelectedCells.
+ */
+ updateConfig(
+ config: Partial,
+ options?: { positionOnlyBody?: boolean },
+ ): void {
+ this.config = { ...this.config, ...config };
+ if (options?.positionOnlyBody) {
+ this.updateRowIdAndSelectionLookupOnly();
+ } else {
+ this.updateDerivedState();
+ }
+ }
+
+ /**
+ * Update derived state based on current selections.
+ * When fullTableSelected, derives columns/rows from table shape (O(R+C)) instead of iterating selectedCells (O(R×C)).
+ * Otherwise, single pass over selectedCells to build selectedByRowIdColIndex, columnsWithSelectedCells, rowsWithSelectedCells.
+ */
+ private updateDerivedState(): void {
+ // Update leaf headers
+ this.leafHeaders = this.config.headers.flatMap((header) =>
+ findLeafHeaders(header, this.config.collapsedHeaders),
+ );
+
+ // Build rowId -> table row index cache for O(1) lookups in getBorderClass
+ this.rowIdToTableIndex.clear();
+ this.config.tableRows.forEach((r, i) => {
+ this.rowIdToTableIndex.set(rowIdToString(r.rowId), i);
+ });
+
+ if (this.fullTableSelected) {
+ // Fast path: derive from table shape without iterating R×C cell IDs
+ this.selectedByRowIdColIndex.clear();
+ this.columnsWithSelectedCells = new Set();
+ const numCols = this.leafHeaders.length;
+ const offset = this.config.enableRowSelection ? 1 : 0;
+ for (let col = 0; col < numCols; col++) {
+ this.columnsWithSelectedCells.add(col + offset);
+ }
+ this.rowsWithSelectedCells = new Set();
+ this.config.tableRows.forEach((r) => {
+ this.rowsWithSelectedCells.add(rowIdToString(r.rowId));
+ });
+ return;
+ }
+
+ // Single pass over selectedCells: build selectedByRowIdColIndex, columnsWithSelectedCells, rowsWithSelectedCells
+ this.selectedByRowIdColIndex.clear();
+ this.columnsWithSelectedCells = new Set();
+ this.rowsWithSelectedCells = new Set();
+
+ this.selectedCells.forEach((key) => {
+ const parts = key.split("-");
+ if (parts.length >= 3) {
+ const colIndex = parseInt(parts[1], 10);
+ const rowId = parts.slice(2).join("-");
+ if (!isNaN(colIndex)) {
+ this.selectedByRowIdColIndex.add(`${rowId}\t${colIndex}`);
+ this.columnsWithSelectedCells.add(colIndex);
+ this.rowsWithSelectedCells.add(rowId);
+ }
+ }
+ });
+
+ this.selectedColumns.forEach((colIndex) => {
+ this.columnsWithSelectedCells.add(colIndex);
+ this.config.tableRows.forEach((r) => {
+ this.selectedByRowIdColIndex.add(
+ `${rowIdToString(r.rowId)}\t${colIndex}`,
+ );
+ this.rowsWithSelectedCells.add(rowIdToString(r.rowId));
+ });
+ });
+ }
+
+ /**
+ * Minimal update for scroll-only renders: only rowIdToTableIndex and selectedByRowIdColIndex
+ * so isSelected/getBorderClass work for new cells; skips columnsWithSelectedCells and rowsWithSelectedCells.
+ * When fullTableSelected, selectedByRowIdColIndex is left empty (isSelected uses the flag).
+ */
+ private updateRowIdAndSelectionLookupOnly(): void {
+ this.rowIdToTableIndex.clear();
+ this.config.tableRows.forEach((r, i) => {
+ this.rowIdToTableIndex.set(rowIdToString(r.rowId), i);
+ });
+
+ if (this.fullTableSelected) return;
+
+ this.selectedByRowIdColIndex.clear();
+ this.selectedCells.forEach((key) => {
+ const parts = key.split("-");
+ if (parts.length >= 3) {
+ const colIndex = parseInt(parts[1], 10);
+ const rowId = parts.slice(2).join("-");
+ if (!isNaN(colIndex))
+ this.selectedByRowIdColIndex.add(`${rowId}\t${colIndex}`);
+ }
+ });
+ this.selectedColumns.forEach((colIndex) => {
+ this.config.tableRows.forEach((r) => {
+ this.selectedByRowIdColIndex.add(
+ `${rowIdToString(r.rowId)}\t${colIndex}`,
+ );
+ });
+ });
+ }
+
+ /**
+ * Setup keyboard navigation event listener
+ */
+ private setupKeyboardNavigation(): void {
+ this.keydownHandler = this.handleKeyDown.bind(this);
+ document.addEventListener("keydown", this.keydownHandler);
+ }
+
+ /**
+ * Clean up event listeners and resources
+ */
+ destroy(): void {
+ if (this.keydownHandler) {
+ document.removeEventListener("keydown", this.keydownHandler);
+ this.keydownHandler = null;
+ }
+
+ // Clean up mouse handlers if they exist
+ if (this.globalMouseMoveHandler) {
+ document.removeEventListener("mousemove", this.globalMouseMoveHandler);
+ this.globalMouseMoveHandler = null;
+ }
+ if (this.globalMouseUpHandler) {
+ document.removeEventListener("mouseup", this.globalMouseUpHandler);
+ this.globalMouseUpHandler = null;
+ }
+
+ // Cancel any pending animation frames
+ if (this.scrollAnimationFrame !== null) {
+ cancelAnimationFrame(this.scrollAnimationFrame);
+ this.scrollAnimationFrame = null;
+ }
+ }
+
+ /**
+ * Handle keyboard events for navigation and clipboard operations
+ */
+ private handleKeyDown(event: KeyboardEvent): void {
+ if (!this.config.selectableCells) return;
+ if (!this.initialFocusedCell) return;
+
+ // Don't intercept if user is typing in a form element
+ const activeElement = document.activeElement;
+ if (
+ activeElement instanceof HTMLInputElement ||
+ activeElement instanceof HTMLTextAreaElement ||
+ activeElement instanceof HTMLSelectElement ||
+ activeElement?.getAttribute("contenteditable") === "true"
+ ) {
+ return;
+ }
+
+ // Select All: allow even when no selection yet (only requires focus)
+ if ((event.ctrlKey || event.metaKey) && event.key === "a") {
+ event.preventDefault();
+ this.selectAll();
+ return;
+ }
+
+ if (this.selectedCells.size === 0 && !this.fullTableSelected) return;
+
+ // Copy functionality
+ if ((event.ctrlKey || event.metaKey) && event.key === "c") {
+ this.copyToClipboard();
+ return;
+ }
+
+ // Paste functionality
+ if ((event.ctrlKey || event.metaKey) && event.key === "v") {
+ event.preventDefault();
+ this.pasteFromClipboard();
+ return;
+ }
+
+ // Delete functionality
+ if (event.key === "Delete" || event.key === "Backspace") {
+ event.preventDefault();
+ this.deleteSelectedCells();
+ return;
+ }
+
+ // Escape to clear selection
+ if (event.key === "Escape") {
+ this.clearSelection();
+ return;
+ }
+
+ // Arrow key navigation and other keys handled in separate methods
+ this.handleNavigationKeys(event);
+ }
+
+ /**
+ * Handle navigation keys (arrows, home, end, page up/down)
+ */
+ private handleNavigationKeys(event: KeyboardEvent): void {
+ if (!this.initialFocusedCell) return;
+
+ let { rowIndex, colIndex, rowId } = this.initialFocusedCell;
+
+ // Check if the visible rows have changed
+ const currentRow = this.config.tableRows[rowIndex];
+ const currentRowId = currentRow ? rowIdToString(currentRow.rowId) : null;
+ if (currentRowId !== rowId) {
+ const currentRowIndex = this.config.tableRows.findIndex(
+ (visibleRow) => rowIdToString(visibleRow.rowId) === rowId,
+ );
+ if (currentRowIndex !== -1) {
+ rowIndex = currentRowIndex;
+ } else return;
+ }
+
+ // Handle keyboard navigation
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ this.handleArrowUp(event, rowIndex, colIndex);
+ } else if (event.key === "ArrowDown") {
+ event.preventDefault();
+ this.handleArrowDown(event, rowIndex, colIndex);
+ } else if (
+ event.key === "ArrowLeft" ||
+ (event.key === "Tab" && event.shiftKey)
+ ) {
+ event.preventDefault();
+ this.handleArrowLeft(event, rowIndex, colIndex);
+ } else if (event.key === "ArrowRight" || event.key === "Tab") {
+ event.preventDefault();
+ this.handleArrowRight(event, rowIndex, colIndex);
+ } else if (event.key === "Home") {
+ event.preventDefault();
+ this.handleHome(event, rowIndex, colIndex);
+ } else if (event.key === "End") {
+ event.preventDefault();
+ this.handleEnd(event, rowIndex, colIndex);
+ } else if (event.key === "PageUp") {
+ event.preventDefault();
+ this.handlePageUp(event, rowIndex, colIndex);
+ } else if (event.key === "PageDown") {
+ event.preventDefault();
+ this.handlePageDown(event, rowIndex, colIndex);
+ }
+ }
+
+ /**
+ * Helper function to find the edge of data in a direction
+ */
+ private findEdgeInDirection(
+ startRow: number,
+ startCol: number,
+ direction: "up" | "down" | "left" | "right",
+ ): { rowIndex: number; colIndex: number } {
+ return findEdgeInDirectionUtil(
+ this.config.tableRows.length,
+ this.leafHeaders.length,
+ !!this.config.enableRowSelection,
+ startRow,
+ startCol,
+ direction,
+ );
+ }
+
+ /**
+ * Handle arrow up key
+ */
+ private handleArrowUp(
+ event: KeyboardEvent,
+ rowIndex: number,
+ colIndex: number,
+ ): void {
+ if (event.shiftKey) {
+ if (!this.startCell) {
+ this.startCell = this.initialFocusedCell!;
+ }
+
+ let targetRow = rowIndex - 1;
+
+ if (event.ctrlKey || event.metaKey) {
+ const edge = this.findEdgeInDirection(rowIndex, colIndex, "up");
+ targetRow = edge.rowIndex;
+ }
+
+ if (targetRow >= 0) {
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const endCell = { rowIndex: targetRow, colIndex, rowId: newRowId };
+ this.selectCellRange(this.startCell, endCell);
+ }
+ } else {
+ if (rowIndex > 0) {
+ let targetRow = rowIndex - 1;
+
+ if (event.ctrlKey || event.metaKey) {
+ const edge = this.findEdgeInDirection(rowIndex, colIndex, "up");
+ targetRow = edge.rowIndex;
+ }
+
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const newCell = { rowIndex: targetRow, colIndex, rowId: newRowId };
+ this.selectSingleCell(newCell);
+ this.startCell = null;
+ }
+ }
+ }
+
+ /**
+ * Handle arrow down key
+ */
+ private handleArrowDown(
+ event: KeyboardEvent,
+ rowIndex: number,
+ colIndex: number,
+ ): void {
+ if (event.shiftKey) {
+ if (!this.startCell) {
+ this.startCell = this.initialFocusedCell!;
+ }
+
+ let targetRow = rowIndex + 1;
+
+ if (event.ctrlKey || event.metaKey) {
+ const edge = this.findEdgeInDirection(rowIndex, colIndex, "down");
+ targetRow = edge.rowIndex;
+ }
+
+ if (targetRow < this.config.tableRows.length) {
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const endCell = { rowIndex: targetRow, colIndex, rowId: newRowId };
+ this.selectCellRange(this.startCell, endCell);
+ }
+ } else {
+ if (rowIndex < this.config.tableRows.length - 1) {
+ let targetRow = rowIndex + 1;
+
+ if (event.ctrlKey || event.metaKey) {
+ const edge = this.findEdgeInDirection(rowIndex, colIndex, "down");
+ targetRow = edge.rowIndex;
+ }
+
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const newCell = { rowIndex: targetRow, colIndex, rowId: newRowId };
+ this.selectSingleCell(newCell);
+ this.startCell = null;
+ }
+ }
+ }
+
+ /**
+ * Handle arrow left key
+ */
+ private handleArrowLeft(
+ event: KeyboardEvent,
+ rowIndex: number,
+ colIndex: number,
+ ): void {
+ if (event.shiftKey && event.key === "ArrowLeft") {
+ if (!this.startCell) {
+ this.startCell = this.initialFocusedCell!;
+ }
+
+ let targetCol = colIndex - 1;
+
+ if (event.ctrlKey || event.metaKey) {
+ const edge = this.findEdgeInDirection(rowIndex, colIndex, "left");
+ targetCol = edge.colIndex;
+ } else {
+ if (this.config.enableRowSelection && targetCol === 0) {
+ return;
+ }
+ }
+
+ if (targetCol >= 0) {
+ const currentTableRow = this.config.tableRows[rowIndex];
+ const newRowId = rowIdToString(currentTableRow.rowId);
+ const endCell = { rowIndex, colIndex: targetCol, rowId: newRowId };
+ this.selectCellRange(this.startCell, endCell);
+ }
+ } else {
+ if (colIndex > 0) {
+ let targetCol = colIndex - 1;
+
+ if ((event.ctrlKey || event.metaKey) && event.key === "ArrowLeft") {
+ const edge = this.findEdgeInDirection(rowIndex, colIndex, "left");
+ targetCol = edge.colIndex;
+ } else {
+ if (this.config.enableRowSelection && targetCol === 0) {
+ return;
+ }
+ }
+
+ if (targetCol >= 0) {
+ const currentTableRow = this.config.tableRows[rowIndex];
+ const newRowId = rowIdToString(currentTableRow.rowId);
+ const newCell = { rowIndex, colIndex: targetCol, rowId: newRowId };
+ this.selectSingleCell(newCell);
+ this.startCell = null;
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle arrow right key
+ */
+ private handleArrowRight(
+ event: KeyboardEvent,
+ rowIndex: number,
+ colIndex: number,
+ ): void {
+ const maxColIndex = this.config.enableRowSelection
+ ? this.leafHeaders.length
+ : this.leafHeaders.length - 1;
+
+ if (event.shiftKey && event.key === "ArrowRight") {
+ if (!this.startCell) {
+ this.startCell = this.initialFocusedCell!;
+ }
+
+ let targetCol = colIndex + 1;
+
+ if (event.ctrlKey || event.metaKey) {
+ const edge = this.findEdgeInDirection(rowIndex, colIndex, "right");
+ targetCol = edge.colIndex;
+ }
+
+ if (targetCol <= maxColIndex) {
+ const currentTableRow = this.config.tableRows[rowIndex];
+ const newRowId = rowIdToString(currentTableRow.rowId);
+ const endCell = { rowIndex, colIndex: targetCol, rowId: newRowId };
+ this.selectCellRange(this.startCell, endCell);
+ }
+ } else {
+ if (colIndex < maxColIndex) {
+ let targetCol = colIndex + 1;
+
+ if ((event.ctrlKey || event.metaKey) && event.key === "ArrowRight") {
+ const edge = this.findEdgeInDirection(rowIndex, colIndex, "right");
+ targetCol = edge.colIndex;
+ }
+
+ if (targetCol <= maxColIndex) {
+ const currentTableRow = this.config.tableRows[rowIndex];
+ const newRowId = rowIdToString(currentTableRow.rowId);
+ const newCell = { rowIndex, colIndex: targetCol, rowId: newRowId };
+ this.selectSingleCell(newCell);
+ this.startCell = null;
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle home key
+ */
+ private handleHome(
+ event: KeyboardEvent,
+ rowIndex: number,
+ colIndex: number,
+ ): void {
+ if (event.shiftKey) {
+ if (!this.startCell) {
+ this.startCell = this.initialFocusedCell!;
+ }
+
+ let targetRow = rowIndex;
+ const targetCol = this.config.enableRowSelection ? 1 : 0;
+
+ if (event.ctrlKey || event.metaKey) {
+ targetRow = 0;
+ }
+
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const endCell = {
+ rowIndex: targetRow,
+ colIndex: targetCol,
+ rowId: newRowId,
+ };
+ this.selectCellRange(this.startCell, endCell);
+ } else {
+ let targetRow = rowIndex;
+ const targetCol = this.config.enableRowSelection ? 1 : 0;
+
+ if (event.ctrlKey || event.metaKey) {
+ targetRow = 0;
+ }
+
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const newCell = {
+ rowIndex: targetRow,
+ colIndex: targetCol,
+ rowId: newRowId,
+ };
+ this.selectSingleCell(newCell);
+ this.startCell = null;
+ }
+ }
+
+ /**
+ * Handle end key
+ */
+ private handleEnd(
+ event: KeyboardEvent,
+ rowIndex: number,
+ colIndex: number,
+ ): void {
+ if (event.shiftKey) {
+ if (!this.startCell) {
+ this.startCell = this.initialFocusedCell!;
+ }
+
+ let targetRow = rowIndex;
+ const targetCol = this.config.enableRowSelection
+ ? this.leafHeaders.length
+ : this.leafHeaders.length - 1;
+
+ if (event.ctrlKey || event.metaKey) {
+ targetRow = this.config.tableRows.length - 1;
+ }
+
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const endCell = {
+ rowIndex: targetRow,
+ colIndex: targetCol,
+ rowId: newRowId,
+ };
+ this.selectCellRange(this.startCell, endCell);
+ } else {
+ let targetRow = rowIndex;
+ const targetCol = this.config.enableRowSelection
+ ? this.leafHeaders.length
+ : this.leafHeaders.length - 1;
+
+ if (event.ctrlKey || event.metaKey) {
+ targetRow = this.config.tableRows.length - 1;
+ }
+
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const newCell = {
+ rowIndex: targetRow,
+ colIndex: targetCol,
+ rowId: newRowId,
+ };
+ this.selectSingleCell(newCell);
+ this.startCell = null;
+ }
+ }
+
+ /**
+ * Handle page up key
+ */
+ private handlePageUp(
+ event: KeyboardEvent,
+ rowIndex: number,
+ colIndex: number,
+ ): void {
+ const pageSize = 10;
+ let targetRow = Math.max(0, rowIndex - pageSize);
+
+ if (event.shiftKey) {
+ if (!this.startCell) {
+ this.startCell = this.initialFocusedCell!;
+ }
+
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const endCell = { rowIndex: targetRow, colIndex, rowId: newRowId };
+ this.selectCellRange(this.startCell, endCell);
+ } else {
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const newCell = { rowIndex: targetRow, colIndex, rowId: newRowId };
+ this.selectSingleCell(newCell);
+ this.startCell = null;
+ }
+ }
+
+ /**
+ * Handle page down key
+ */
+ private handlePageDown(
+ event: KeyboardEvent,
+ rowIndex: number,
+ colIndex: number,
+ ): void {
+ const pageSize = 10;
+ let targetRow = Math.min(
+ this.config.tableRows.length - 1,
+ rowIndex + pageSize,
+ );
+
+ if (event.shiftKey) {
+ if (!this.startCell) {
+ this.startCell = this.initialFocusedCell!;
+ }
+
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const endCell = { rowIndex: targetRow, colIndex, rowId: newRowId };
+ this.selectCellRange(this.startCell, endCell);
+ } else {
+ const targetTableRow = this.config.tableRows[targetRow];
+ const newRowId = rowIdToString(targetTableRow.rowId);
+ const newCell = { rowIndex: targetRow, colIndex, rowId: newRowId };
+ this.selectSingleCell(newCell);
+ this.startCell = null;
+ }
+ }
+
+ /**
+ * Copy selected cells to clipboard
+ */
+ private copyToClipboard(): void {
+ const cellsToCopy = this.fullTableSelected
+ ? this.buildFullTableSelectedSet()
+ : this.selectedCells;
+ if (cellsToCopy.size === 0) return;
+
+ const text = copySelectedCellsToClipboard(
+ cellsToCopy,
+ this.leafHeaders,
+ this.config.tableRows,
+ this.config.copyHeadersToClipboard,
+ );
+ navigator.clipboard.writeText(text);
+
+ // Trigger copy flash effect
+ this.copyFlashCells = new Set(cellsToCopy);
+ this.updateCellFlashClasses();
+ setTimeout(() => {
+ this.copyFlashCells = new Set();
+ this.updateCellFlashClasses();
+ }, 800);
+ }
+
+ /**
+ * Paste from clipboard to cells
+ */
+ private async pasteFromClipboard(): Promise {
+ if (!this.initialFocusedCell) return;
+
+ try {
+ const clipboardText = await navigator.clipboard.readText();
+ if (!clipboardText) return;
+
+ const { updatedCells, warningCells } = pasteClipboardDataToCells(
+ clipboardText,
+ this.initialFocusedCell,
+ this.leafHeaders,
+ this.config.tableRows,
+ this.config.onCellEdit,
+ this.config.cellRegistry,
+ );
+
+ if (updatedCells.size > 0) {
+ this.copyFlashCells = updatedCells;
+ this.updateCellFlashClasses();
+ setTimeout(() => {
+ this.copyFlashCells = new Set();
+ this.updateCellFlashClasses();
+ }, 800);
+ }
+
+ if (warningCells.size > 0) {
+ this.warningFlashCells = warningCells;
+ this.updateCellFlashClasses();
+ setTimeout(() => {
+ this.warningFlashCells = new Set();
+ this.updateCellFlashClasses();
+ }, 800);
+ }
+ } catch (error) {
+ console.warn("Failed to paste from clipboard:", error);
+ }
+ }
+
+ /**
+ * Delete content from selected cells
+ */
+ private deleteSelectedCells(): void {
+ const cellsToDelete = this.fullTableSelected
+ ? this.buildFullTableSelectedSet()
+ : this.selectedCells;
+ if (cellsToDelete.size === 0) return;
+
+ const { deletedCells, warningCells } = deleteSelectedCellsContent(
+ cellsToDelete,
+ this.leafHeaders,
+ this.config.tableRows,
+ this.config.onCellEdit,
+ this.config.cellRegistry,
+ );
+
+ if (deletedCells.size > 0) {
+ this.copyFlashCells = deletedCells;
+ this.updateCellFlashClasses();
+ setTimeout(() => {
+ this.copyFlashCells = new Set();
+ this.updateCellFlashClasses();
+ }, 800);
+ }
+
+ if (warningCells.size > 0) {
+ this.warningFlashCells = warningCells;
+ this.updateCellFlashClasses();
+ setTimeout(() => {
+ this.warningFlashCells = new Set();
+ this.updateCellFlashClasses();
+ }, 800);
+ }
+ }
+
+ /**
+ * Select all cells in the table. Uses fullTableSelected flag instead of storing R×C cell IDs for O(1) update.
+ */
+ private selectAll(): void {
+ this.fullTableSelected = true;
+ this.selectedCells = new Set();
+ this.selectedColumns = new Set();
+ this.lastSelectedColumnIndex = null;
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+ }
+
+ /**
+ * Build the full set of cell IDs when fullTableSelected. Used for copy/delete/getSelectedCells.
+ */
+ private buildFullTableSelectedSet(): Set {
+ const set = new Set();
+ for (let row = 0; row < this.config.tableRows.length; row++) {
+ for (let col = 0; col < this.leafHeaders.length; col++) {
+ const colIndex = this.config.enableRowSelection ? col + 1 : col;
+ const tableRow = this.config.tableRows[row];
+ const rowId = rowIdToString(tableRow.rowId);
+ set.add(`${row}-${colIndex}-${rowId}`);
+ }
+ }
+ return set;
+ }
+
+ /**
+ * Clear all selections
+ */
+ clearSelection(): void {
+ this.fullTableSelected = false;
+ this.selectedCells = new Set();
+ this.selectedColumns = new Set();
+ this.lastSelectedColumnIndex = null;
+ this.startCell = null;
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+ }
+
+ /**
+ * Set selected cells (for external control)
+ */
+ setSelectedCells(cells: Set): void {
+ this.fullTableSelected = false;
+ this.selectedCells = cells;
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+ }
+
+ /**
+ * Set selected columns (for external control)
+ */
+ setSelectedColumns(columns: Set): void {
+ this.fullTableSelected = false;
+ this.selectedColumns = columns;
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+ }
+
+ /**
+ * Update flash classes on cells (copy/warning animations)
+ */
+ private updateCellFlashClasses(): void {
+ // Use requestAnimationFrame to ensure DOM is ready
+ requestAnimationFrame(() => {
+ const allCells = document.querySelectorAll(
+ ".st-cell[data-row-index][data-col-index][data-row-id]",
+ );
+
+ allCells.forEach((cellElement) => {
+ if (!(cellElement instanceof HTMLElement)) return;
+
+ const rowIndex = parseInt(
+ cellElement.getAttribute("data-row-index") || "-1",
+ 10,
+ );
+ const colIndex = parseInt(
+ cellElement.getAttribute("data-col-index") || "-1",
+ 10,
+ );
+ const rowId = cellElement.getAttribute("data-row-id");
+
+ if (rowIndex < 0 || colIndex < 0 || !rowId) return;
+
+ const cellId = createSetString({ rowIndex, colIndex, rowId });
+ const isInitialFocused = this.isInitialFocusedCell({
+ rowIndex,
+ colIndex,
+ rowId,
+ });
+
+ // Update copy flash classes
+ if (this.copyFlashCells.has(cellId)) {
+ cellElement.classList.add(
+ isInitialFocused
+ ? "st-cell-copy-flash-first"
+ : "st-cell-copy-flash",
+ );
+ } else {
+ cellElement.classList.remove(
+ "st-cell-copy-flash-first",
+ "st-cell-copy-flash",
+ );
+ }
+
+ // Update warning flash classes
+ if (this.warningFlashCells.has(cellId)) {
+ cellElement.classList.add(
+ isInitialFocused
+ ? "st-cell-warning-flash-first"
+ : "st-cell-warning-flash",
+ );
+ } else {
+ cellElement.classList.remove(
+ "st-cell-warning-flash-first",
+ "st-cell-warning-flash",
+ );
+ }
+ });
+ });
+ }
+
+ private static readonly SELECTION_CLASSES = [
+ "st-cell-selected",
+ "st-cell-selected-first",
+ "st-cell-column-selected",
+ "st-cell-column-selected-first",
+ "st-selected-top-border",
+ "st-selected-bottom-border",
+ "st-selected-left-border",
+ "st-selected-right-border",
+ ] as const;
+
+ /**
+ * Apply selection classes to all currently rendered cells. Used after drag ends
+ * so that the DOM (which may have been replaced during scroll) reflects selection.
+ * Only adds/removes classes that changed to reduce DOM writes.
+ * Fast path when there is no selection: one pass to clear all selection classes.
+ */
+ private syncAllCellClasses(): void {
+ const root = this.config.tableRoot ?? document;
+ const allCells = root.querySelectorAll(
+ ".st-cell[data-row-index][data-col-index][data-row-id]",
+ );
+ const noSelection =
+ !this.fullTableSelected &&
+ this.selectedCells.size === 0 &&
+ this.selectedColumns.size === 0;
+
+ if (noSelection) {
+ for (let i = 0; i < allCells.length; i++) {
+ const cellElement = allCells[i];
+ if (!(cellElement instanceof HTMLElement)) continue;
+ for (const cls of SelectionManager.SELECTION_CLASSES) {
+ if (cellElement.classList.contains(cls)) {
+ cellElement.classList.remove(cls);
+ }
+ }
+ if (cellElement.getAttribute("tabindex") !== "-1") {
+ cellElement.setAttribute("tabindex", "-1");
+ }
+ }
+ return;
+ }
+
+ for (let i = 0; i < allCells.length; i++) {
+ const cellElement = allCells[i];
+ if (!(cellElement instanceof HTMLElement)) continue;
+
+ const rowIndex = parseInt(
+ cellElement.getAttribute("data-row-index") || "-1",
+ 10,
+ );
+ const colIndex = parseInt(
+ cellElement.getAttribute("data-col-index") || "-1",
+ 10,
+ );
+ const rowId = cellElement.getAttribute("data-row-id");
+
+ if (rowIndex < 0 || colIndex < 0 || !rowId) continue;
+
+ const cell: Cell = { rowIndex, colIndex, rowId };
+ const isSelected = this.isSelected(cell);
+ const isColumnSelected = this.selectedColumns.has(colIndex);
+ const isIndividuallySelected = isSelected && !isColumnSelected;
+ const isInitialFocused = this.isInitialFocusedCell(cell);
+ const borderClass = this.getBorderClass(cell);
+
+ const desiredClasses = new Set();
+ if (isIndividuallySelected) {
+ desiredClasses.add(
+ isInitialFocused ? "st-cell-selected-first" : "st-cell-selected",
+ );
+ const borderClasses = borderClass.split(" ").filter(Boolean);
+ borderClasses.forEach((cls) => desiredClasses.add(cls));
+ }
+ if (isColumnSelected) {
+ desiredClasses.add(
+ isInitialFocused
+ ? "st-cell-column-selected-first"
+ : "st-cell-column-selected",
+ );
+ }
+
+ for (const cls of SelectionManager.SELECTION_CLASSES) {
+ const shouldHave = desiredClasses.has(cls);
+ const has = cellElement.classList.contains(cls);
+ if (shouldHave && !has) {
+ cellElement.classList.add(cls);
+ } else if (!shouldHave && has) {
+ cellElement.classList.remove(cls);
+ }
+ }
+
+ const tabindex = isInitialFocused ? "0" : "-1";
+ if (cellElement.getAttribute("tabindex") !== tabindex) {
+ cellElement.setAttribute("tabindex", tabindex);
+ }
+ if (isInitialFocused && document.activeElement !== cellElement) {
+ const activeElement = document.activeElement;
+ const isActiveInsideCell =
+ activeElement && cellElement.contains(activeElement);
+ if (!isActiveInsideCell) {
+ cellElement.focus();
+ }
+ }
+ }
+ }
+
+ /**
+ * Update all cell classes based on current selection state
+ * Directly manipulates the DOM without triggering React re-renders.
+ * When isSelecting (drag) or fullTableSelected (Cmd+A), run synchronously so classes are applied
+ * before any scroll-triggered render or next frame.
+ */
+ private updateAllCellClasses(): void {
+ if (this.isSelecting || this.fullTableSelected) {
+ this.syncAllCellClasses();
+ } else {
+ requestAnimationFrame(() => this.syncAllCellClasses());
+ }
+ }
+
+ /**
+ * Check if a cell is selected. Uses selectedByRowIdColIndex for O(1) membership.
+ * When fullTableSelected, returns true for any cell without lookup.
+ */
+ isSelected({ colIndex, rowIndex, rowId }: Cell): boolean {
+ if (this.fullTableSelected) return true;
+ const rowIdStr = String(rowId);
+ if (this.selectedByRowIdColIndex.has(`${rowIdStr}\t${colIndex}`))
+ return true;
+ // Fallback: DOM may have virtualized rowIndex; try direct key
+ const tableRowIndex = this.rowIdToTableIndex.get(rowIdStr);
+ if (tableRowIndex !== undefined) {
+ const cellId = createSetString({
+ rowIndex: tableRowIndex,
+ colIndex,
+ rowId: rowIdStr,
+ });
+ if (this.selectedCells.has(cellId)) return true;
+ }
+ const cellId = createSetString({ colIndex, rowIndex, rowId });
+ return this.selectedCells.has(cellId);
+ }
+
+ /**
+ * Get border class for a cell based on its selection state. Uses rowIdToTableIndex for O(1) lookups.
+ * When fullTableSelected, short-circuits with border classes for the full grid (no neighbor lookups).
+ */
+ getBorderClass({ colIndex, rowIndex, rowId }: Cell): string {
+ if (this.isSelecting) {
+ return "";
+ }
+
+ const rowIdStr = String(rowId);
+ const tableIndex = this.rowIdToTableIndex.get(rowIdStr) ?? rowIndex;
+
+ if (this.fullTableSelected) {
+ const firstCol = this.config.enableRowSelection ? 1 : 0;
+ const lastCol = this.config.enableRowSelection
+ ? this.leafHeaders.length
+ : this.leafHeaders.length - 1;
+ const classes: string[] = [];
+ if (tableIndex === 0) classes.push("st-selected-top-border");
+ if (tableIndex === this.config.tableRows.length - 1)
+ classes.push("st-selected-bottom-border");
+ if (colIndex === firstCol) classes.push("st-selected-left-border");
+ if (colIndex === lastCol) classes.push("st-selected-right-border");
+ return classes.join(" ");
+ }
+
+ const classes: string[] = [];
+ const topRow = this.config.tableRows[tableIndex - 1];
+ const topRowId = topRow ? rowIdToString(topRow.rowId) : null;
+ const bottomRow = this.config.tableRows[tableIndex + 1];
+ const bottomRowId = bottomRow ? rowIdToString(bottomRow.rowId) : null;
+
+ const topSelected =
+ topRowId !== null &&
+ this.isSelected({ colIndex, rowIndex: tableIndex - 1, rowId: topRowId });
+ const bottomSelected =
+ bottomRowId !== null &&
+ this.isSelected({
+ colIndex,
+ rowIndex: tableIndex + 1,
+ rowId: bottomRowId,
+ });
+ const leftSelected = this.isSelected({
+ colIndex: colIndex - 1,
+ rowIndex: tableIndex,
+ rowId,
+ });
+ const rightSelected = this.isSelected({
+ colIndex: colIndex + 1,
+ rowIndex: tableIndex,
+ rowId,
+ });
+
+ if (
+ !topRowId ||
+ !topSelected ||
+ (this.selectedColumns.has(colIndex) && tableIndex === 0)
+ )
+ classes.push("st-selected-top-border");
+ if (
+ !bottomRowId ||
+ !bottomSelected ||
+ (this.selectedColumns.has(colIndex) &&
+ tableIndex === this.config.tableRows.length - 1)
+ )
+ classes.push("st-selected-bottom-border");
+ if (!leftSelected) classes.push("st-selected-left-border");
+ if (!rightSelected) classes.push("st-selected-right-border");
+
+ return classes.join(" ");
+ }
+
+ /**
+ * Check if a cell is the initial focused cell
+ */
+ isInitialFocusedCell({ rowIndex, colIndex, rowId }: Cell): boolean {
+ if (!this.initialFocusedCell) return false;
+ // Match by rowId and colIndex so we recognize the anchor cell after scroll/re-render
+ // (DOM cells use virtualized rowIndex; initialFocusedCell may store table or visible index).
+ return (
+ colIndex === this.initialFocusedCell.colIndex &&
+ String(rowId) === String(this.initialFocusedCell.rowId)
+ );
+ }
+
+ /**
+ * Check if a cell is currently showing copy flash animation
+ */
+ isCopyFlashing({ colIndex, rowIndex, rowId }: Cell): boolean {
+ const cellId = createSetString({ colIndex, rowIndex, rowId });
+ return this.copyFlashCells.has(cellId);
+ }
+
+ /**
+ * Check if a cell is currently showing warning flash animation
+ */
+ isWarningFlashing({ colIndex, rowIndex, rowId }: Cell): boolean {
+ const cellId = createSetString({ colIndex, rowIndex, rowId });
+ return this.warningFlashCells.has(cellId);
+ }
+
+ /**
+ * Get columns that have selected cells
+ */
+ getColumnsWithSelectedCells(): Set {
+ return this.columnsWithSelectedCells;
+ }
+
+ /**
+ * Get rows that have selected cells
+ */
+ getRowsWithSelectedCells(): Set {
+ return this.rowsWithSelectedCells;
+ }
+
+ /**
+ * Get selected cells. When fullTableSelected, builds and returns the full set on demand.
+ */
+ getSelectedCells(): Set {
+ if (this.fullTableSelected) return this.buildFullTableSelectedSet();
+ return this.selectedCells;
+ }
+
+ /**
+ * Get selected columns
+ */
+ getSelectedColumns(): Set {
+ return this.selectedColumns;
+ }
+
+ /**
+ * Get last selected column index
+ */
+ getLastSelectedColumnIndex(): number | null {
+ return this.lastSelectedColumnIndex;
+ }
+
+ /**
+ * Get start cell for range selection
+ */
+ getStartCell(): Cell | null {
+ return this.startCell;
+ }
+
+ /**
+ * Set the initial focused cell (e.g. when clearing selection from header drag).
+ */
+ setInitialFocusedCell(cell: Cell | null): void {
+ this.initialFocusedCell = cell;
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+ }
+
+ /**
+ * Select a single cell
+ */
+ selectSingleCell(cell: Cell): void {
+ const maxColIndex = this.config.enableRowSelection
+ ? this.leafHeaders.length
+ : this.leafHeaders.length - 1;
+
+ if (
+ cell.rowIndex >= 0 &&
+ cell.rowIndex < this.config.tableRows.length &&
+ cell.colIndex >= 0 &&
+ cell.colIndex <= maxColIndex
+ ) {
+ this.fullTableSelected = false;
+ const cellId = createSetString(cell);
+
+ this.selectedColumns = new Set();
+ this.lastSelectedColumnIndex = null;
+ this.selectedCells = new Set([cellId]);
+ this.initialFocusedCell = cell;
+
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+
+ // Scroll the cell into view
+ setTimeout(
+ () =>
+ scrollCellIntoView(
+ cell,
+ this.config.rowHeight,
+ this.config.customTheme,
+ this.config.tableRows,
+ ),
+ 0,
+ );
+ }
+ }
+
+ /**
+ * Select a range of cells from startCell to endCell
+ */
+ selectCellRange(startCell: Cell, endCell: Cell): void {
+ this.fullTableSelected = false;
+ const newSelectedCells = computeSelectionRange(
+ startCell,
+ endCell,
+ this.config.tableRows,
+ !!this.config.enableRowSelection,
+ );
+
+ this.selectedColumns = new Set();
+ this.lastSelectedColumnIndex = null;
+ this.selectedCells = newSelectedCells;
+ this.initialFocusedCell = endCell;
+
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+
+ // Scroll the end cell into view
+ setTimeout(
+ () =>
+ scrollCellIntoView(
+ endCell,
+ this.config.rowHeight,
+ this.config.customTheme,
+ this.config.tableRows,
+ ),
+ 0,
+ );
+ }
+
+ /**
+ * Select one or more columns
+ */
+ selectColumns(columnIndices: number[], isShiftKey = false): void {
+ this.fullTableSelected = false;
+ this.selectedCells = new Set();
+ this.initialFocusedCell = null;
+
+ const newSelection = new Set(isShiftKey ? this.selectedColumns : []);
+ columnIndices.forEach((idx) => newSelection.add(idx));
+ this.selectedColumns = newSelection;
+
+ if (columnIndices.length > 0) {
+ this.lastSelectedColumnIndex = columnIndices[columnIndices.length - 1];
+ }
+
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+ }
+
+ /**
+ * Update selection range during mouse drag. Skips derived state and class sync when selection unchanged.
+ */
+ private updateSelectionRange(startCell: Cell, endCell: Cell): void {
+ this.fullTableSelected = false;
+ const newSelectedCells = computeSelectionRange(
+ startCell,
+ endCell,
+ this.config.tableRows,
+ !!this.config.enableRowSelection,
+ );
+ if (this.selectedCells.size === newSelectedCells.size) {
+ const allSame = Array.from(newSelectedCells).every((id) =>
+ this.selectedCells.has(id),
+ );
+ if (allSame) return;
+ }
+ this.selectedCells = newSelectedCells;
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+ }
+
+ /**
+ * Calculate the nearest cell to a given mouse position
+ */
+ private calculateNearestCell(clientX: number, clientY: number): Cell | null {
+ return calculateNearestCellUtil(clientX, clientY);
+ }
+
+ /**
+ * Get cell from mouse position
+ */
+ private getCellFromMousePosition(
+ clientX: number,
+ clientY: number,
+ ): Cell | null {
+ return getCellFromMousePositionUtil(clientX, clientY);
+ }
+
+ /**
+ * Handle auto-scrolling when dragging near edges
+ */
+ private handleAutoScroll(clientX: number, clientY: number): void {
+ handleAutoScrollUtil(clientX, clientY);
+ }
+
+ /**
+ * Continuous scroll loop during mouse drag
+ */
+ private continuousScroll(): void {
+ if (!this.isSelecting || !this.startCell) {
+ if (this.scrollAnimationFrame !== null) {
+ cancelAnimationFrame(this.scrollAnimationFrame);
+ this.scrollAnimationFrame = null;
+ }
+ return;
+ }
+
+ // Only process if mouse position has been captured
+ if (this.currentMouseX !== null && this.currentMouseY !== null) {
+ this.handleAutoScroll(this.currentMouseX, this.currentMouseY);
+
+ const now = Date.now();
+ if (now - this.lastSelectionUpdate >= this.selectionThrottleMs) {
+ const cellAtPosition = this.getCellFromMousePosition(
+ this.currentMouseX,
+ this.currentMouseY,
+ );
+ if (cellAtPosition) {
+ this.updateSelectionRange(this.startCell, cellAtPosition);
+ this.lastSelectionUpdate = now;
+ }
+ }
+ }
+
+ this.scrollAnimationFrame = requestAnimationFrame(() =>
+ this.continuousScroll(),
+ );
+ }
+
+ /**
+ * Handle mouse down on a cell to start selection
+ */
+ handleMouseDown({ colIndex, rowIndex, rowId }: Cell): void {
+ if (!this.config.selectableCells) return;
+
+ this.fullTableSelected = false;
+ this.isSelecting = true;
+ this.startCell = { rowIndex, colIndex, rowId };
+
+ setTimeout(() => {
+ this.selectedColumns = new Set();
+ this.lastSelectedColumnIndex = null;
+ const cellId = createSetString({ colIndex, rowIndex, rowId });
+ this.selectedCells = new Set([cellId]);
+ this.initialFocusedCell = { rowIndex, colIndex, rowId };
+ this.updateDerivedState();
+ this.updateAllCellClasses();
+ }, 0);
+
+ this.currentMouseX = null;
+ this.currentMouseY = null;
+ this.lastSelectionUpdate = 0;
+
+ this.globalMouseMoveHandler = (event: MouseEvent) => {
+ if (!this.isSelecting || !this.startCell) return;
+ this.currentMouseX = event.clientX;
+ this.currentMouseY = event.clientY;
+ };
+
+ this.globalMouseUpHandler = () => {
+ this.isSelecting = false;
+
+ if (this.scrollAnimationFrame !== null) {
+ cancelAnimationFrame(this.scrollAnimationFrame);
+ this.scrollAnimationFrame = null;
+ }
+
+ if (this.globalMouseMoveHandler) {
+ document.removeEventListener("mousemove", this.globalMouseMoveHandler);
+ this.globalMouseMoveHandler = null;
+ }
+ if (this.globalMouseUpHandler) {
+ document.removeEventListener("mouseup", this.globalMouseUpHandler);
+ this.globalMouseUpHandler = null;
+ }
+
+ // Re-render table so body DOM is rebuilt; then apply selection classes to the new DOM
+ // in the same tick (so test/UI see them without waiting for rAF).
+ this.config.onSelectionDragEnd?.();
+ this.syncAllCellClasses();
+ requestAnimationFrame(() => this.syncAllCellClasses());
+ };
+
+ document.addEventListener("mousemove", this.globalMouseMoveHandler);
+ document.addEventListener("mouseup", this.globalMouseUpHandler);
+
+ this.scrollAnimationFrame = requestAnimationFrame(() =>
+ this.continuousScroll(),
+ );
+ }
+
+ /**
+ * Handle mouse over a cell during selection drag
+ */
+ handleMouseOver({ colIndex, rowIndex, rowId }: Cell): void {
+ if (!this.config.selectableCells) return;
+ if (this.isSelecting && this.startCell) {
+ this.updateSelectionRange(this.startCell, { colIndex, rowIndex, rowId });
+ }
+ }
+}
diff --git a/packages/core/src/managers/SelectionManager/index.ts b/packages/core/src/managers/SelectionManager/index.ts
new file mode 100644
index 000000000..a7e94b985
--- /dev/null
+++ b/packages/core/src/managers/SelectionManager/index.ts
@@ -0,0 +1,5 @@
+export {
+ SelectionManager,
+ createSetString,
+} from "./SelectionManager";
+export type { SelectionManagerConfig } from "./SelectionManager";
diff --git a/packages/core/src/managers/SelectionManager/keyboardUtils.ts b/packages/core/src/managers/SelectionManager/keyboardUtils.ts
new file mode 100644
index 000000000..d65fe418c
--- /dev/null
+++ b/packages/core/src/managers/SelectionManager/keyboardUtils.ts
@@ -0,0 +1,26 @@
+/**
+ * Find the edge of data in a direction (pure helper for keyboard navigation).
+ */
+export function findEdgeInDirection(
+ tableRowsLength: number,
+ leafHeadersLength: number,
+ enableRowSelection: boolean,
+ startRow: number,
+ startCol: number,
+ direction: "up" | "down" | "left" | "right",
+): { rowIndex: number; colIndex: number } {
+ let targetRow = startRow;
+ let targetCol = startCol;
+
+ if (direction === "up") {
+ targetRow = 0;
+ } else if (direction === "down") {
+ targetRow = tableRowsLength - 1;
+ } else if (direction === "left") {
+ targetCol = enableRowSelection ? 1 : 0;
+ } else if (direction === "right") {
+ targetCol = enableRowSelection ? leafHeadersLength : leafHeadersLength - 1;
+ }
+
+ return { rowIndex: targetRow, colIndex: targetCol };
+}
diff --git a/packages/core/src/managers/SelectionManager/mouseUtils.ts b/packages/core/src/managers/SelectionManager/mouseUtils.ts
new file mode 100644
index 000000000..a05633ceb
--- /dev/null
+++ b/packages/core/src/managers/SelectionManager/mouseUtils.ts
@@ -0,0 +1,173 @@
+import type Cell from "../../types/Cell";
+
+/**
+ * Calculate the nearest cell to a given mouse position.
+ * Uses row buckets: one getBoundingClientRect per row to find the row, then only
+ * measures cells in that row (O(rows + cols) instead of O(rows * cols)).
+ */
+export function calculateNearestCell(
+ clientX: number,
+ clientY: number,
+): Cell | null {
+ const tableContainer = document.querySelector(".st-body-container");
+ if (!tableContainer) return null;
+
+ const rect = tableContainer.getBoundingClientRect();
+ const clampedX = Math.max(rect.left, Math.min(rect.right, clientX));
+ const clampedY = Math.max(rect.top, Math.min(rect.bottom, clientY));
+
+ const cellElements = document.querySelectorAll(
+ ".st-cell[data-row-index][data-col-index][data-row-id]:not(.st-selection-cell)",
+ );
+ if (cellElements.length === 0) return null;
+
+ // Group cells by row (use rowId so we merge same row across sections if needed)
+ const byRow = new Map();
+ for (let i = 0; i < cellElements.length; i++) {
+ const el = cellElements[i];
+ const rowId = el.getAttribute("data-row-id");
+ const key = rowId ?? el.getAttribute("data-row-index") ?? "";
+ if (!key) continue;
+ let list = byRow.get(key);
+ if (!list) {
+ list = [];
+ byRow.set(key, list);
+ }
+ list.push(el);
+ }
+
+ // One getBoundingClientRect per row to get Y bounds
+ const rowBounds: { key: string; top: number; bottom: number }[] = [];
+ byRow.forEach((cells, key) => {
+ const r = cells[0].getBoundingClientRect();
+ rowBounds.push({ key, top: r.top, bottom: r.bottom });
+ });
+ rowBounds.sort((a, b) => a.top - b.top);
+
+ // Find row that contains clampedY (or closest)
+ let bestRowKey: string | null = null;
+ let bestRowDistance = Infinity;
+ for (let i = 0; i < rowBounds.length; i++) {
+ const { key, top, bottom } = rowBounds[i];
+ if (clampedY >= top && clampedY <= bottom) {
+ bestRowKey = key;
+ bestRowDistance = 0;
+ break;
+ }
+ const mid = (top + bottom) / 2;
+ const d = Math.abs(clampedY - mid);
+ if (d < bestRowDistance) {
+ bestRowDistance = d;
+ bestRowKey = key;
+ }
+ }
+
+ if (bestRowKey === null) return null;
+ const rowCells = byRow.get(bestRowKey);
+ if (!rowCells || rowCells.length === 0) return null;
+
+ // Only measure cells in the chosen row
+ let closestCell: HTMLElement | null = null;
+ let minDistance = Infinity;
+ for (let i = 0; i < rowCells.length; i++) {
+ const htmlCell = rowCells[i];
+ const rowIndex = parseInt(htmlCell.getAttribute("data-row-index") ?? "-1", 10);
+ const colIndex = parseInt(htmlCell.getAttribute("data-col-index") ?? "-1", 10);
+ const rowId = htmlCell.getAttribute("data-row-id");
+ if (rowIndex < 0 || colIndex < 0 || rowId == null || rowId === "") continue;
+
+ const cellRect = htmlCell.getBoundingClientRect();
+ const cellCenterX = cellRect.left + cellRect.width / 2;
+ const cellCenterY = cellRect.top + cellRect.height / 2;
+
+ const distance = Math.sqrt(
+ (cellCenterX - clampedX) ** 2 + (cellCenterY - clampedY) ** 2,
+ );
+ if (distance < minDistance) {
+ minDistance = distance;
+ closestCell = htmlCell;
+ }
+ }
+
+ if (closestCell !== null) {
+ const rowIndex = parseInt(
+ closestCell.getAttribute("data-row-index") || "-1",
+ 10,
+ );
+ const colIndex = parseInt(
+ closestCell.getAttribute("data-col-index") || "-1",
+ 10,
+ );
+ const rowId = closestCell.getAttribute("data-row-id");
+ if (rowIndex >= 0 && colIndex >= 0 && rowId !== null) {
+ return { rowIndex, colIndex, rowId };
+ }
+ }
+ return null;
+}
+
+/**
+ * Get cell from mouse position (element under point, or nearest cell).
+ */
+export function getCellFromMousePosition(
+ clientX: number,
+ clientY: number,
+): Cell | null {
+ const element = document.elementFromPoint(clientX, clientY);
+ if (!element) return null;
+
+ const cellElement = element.closest(".st-cell");
+
+ if (cellElement instanceof HTMLElement) {
+ const rowIndex = parseInt(
+ cellElement.getAttribute("data-row-index") || "-1",
+ 10,
+ );
+ const colIndex = parseInt(
+ cellElement.getAttribute("data-col-index") || "-1",
+ 10,
+ );
+ const rowId = cellElement.getAttribute("data-row-id");
+
+ if (rowIndex >= 0 && colIndex >= 0 && rowId !== null) {
+ return { rowIndex, colIndex, rowId };
+ }
+ }
+
+ return calculateNearestCell(clientX, clientY);
+}
+
+/**
+ * Handle auto-scrolling when dragging near table edges.
+ */
+export function handleAutoScroll(clientX: number, clientY: number): void {
+ const tableContainer = document.querySelector(".st-body-container");
+ if (!tableContainer) return;
+
+ const rect = tableContainer.getBoundingClientRect();
+ const scrollMargin = 50;
+ const scrollSpeed = 10;
+
+ if (clientY < rect.top + scrollMargin) {
+ const distance = Math.max(0, rect.top - clientY);
+ const speedMultiplier = Math.min(3, 1 + distance / 100);
+ tableContainer.scrollTop -= scrollSpeed * speedMultiplier;
+ } else if (clientY > rect.bottom - scrollMargin) {
+ const distance = Math.max(0, clientY - rect.bottom);
+ const speedMultiplier = Math.min(3, 1 + distance / 100);
+ tableContainer.scrollTop += scrollSpeed * speedMultiplier;
+ }
+
+ const mainBody = document.querySelector(".st-body-main");
+ if (mainBody) {
+ if (clientX < rect.left + scrollMargin) {
+ const distance = Math.max(0, rect.left - clientX);
+ const speedMultiplier = Math.min(3, 1 + distance / 100);
+ mainBody.scrollLeft -= scrollSpeed * speedMultiplier;
+ } else if (clientX > rect.right - scrollMargin) {
+ const distance = Math.max(0, clientX - rect.right);
+ const speedMultiplier = Math.min(3, 1 + distance / 100);
+ mainBody.scrollLeft += scrollSpeed * speedMultiplier;
+ }
+ }
+}
diff --git a/packages/core/src/managers/SelectionManager/selectionRangeUtils.ts b/packages/core/src/managers/SelectionManager/selectionRangeUtils.ts
new file mode 100644
index 000000000..8354224cf
--- /dev/null
+++ b/packages/core/src/managers/SelectionManager/selectionRangeUtils.ts
@@ -0,0 +1,55 @@
+import type Cell from "../../types/Cell";
+import type TableRowType from "../../types/TableRow";
+import { rowIdToString } from "../../utils/rowUtils";
+import { createSetString } from "./types";
+
+/**
+ * Compute the set of cell IDs for a selection range.
+ * Resolves rowId to current row index (for virtualized/sorted tables) then fills the rectangle.
+ */
+export function computeSelectionRange(
+ startCell: Cell,
+ endCell: Cell,
+ tableRows: TableRowType[],
+ enableRowSelection: boolean,
+): Set {
+ const newSelectedCells = new Set();
+
+ const rowIdToIndexMap = new Map();
+ tableRows.forEach((tableRow, index) => {
+ const rowId = rowIdToString(tableRow.rowId);
+ rowIdToIndexMap.set(rowId, index);
+ });
+
+ const startRowCurrentIndex = rowIdToIndexMap.get(String(startCell.rowId));
+ const endRowCurrentIndex = rowIdToIndexMap.get(String(endCell.rowId));
+
+ const startRow =
+ startRowCurrentIndex !== undefined
+ ? startRowCurrentIndex
+ : startCell.rowIndex;
+ const endRow =
+ endRowCurrentIndex !== undefined ? endRowCurrentIndex : endCell.rowIndex;
+
+ const minRow = Math.min(startRow, endRow);
+ const maxRow = Math.max(startRow, endRow);
+ const minCol = Math.min(startCell.colIndex, endCell.colIndex);
+ const maxCol = Math.max(startCell.colIndex, endCell.colIndex);
+
+ for (let row = minRow; row <= maxRow; row++) {
+ for (let col = minCol; col <= maxCol; col++) {
+ if (row >= 0 && row < tableRows.length) {
+ if (enableRowSelection && col === 0) {
+ continue;
+ }
+ const tableRow = tableRows[row];
+ const rowId = rowIdToString(tableRow.rowId);
+ newSelectedCells.add(
+ createSetString({ colIndex: col, rowIndex: row, rowId }),
+ );
+ }
+ }
+ }
+
+ return newSelectedCells;
+}
diff --git a/packages/core/src/managers/SelectionManager/types.ts b/packages/core/src/managers/SelectionManager/types.ts
new file mode 100644
index 000000000..8d2871bfd
--- /dev/null
+++ b/packages/core/src/managers/SelectionManager/types.ts
@@ -0,0 +1,25 @@
+import type HeaderObject from "../../types/HeaderObject";
+import type { Accessor } from "../../types/HeaderObject";
+import type TableRowType from "../../types/TableRow";
+import type Cell from "../../types/Cell";
+import type { CustomTheme } from "../../types/CustomTheme";
+
+export const createSetString = ({ rowIndex, colIndex, rowId }: Cell) =>
+ `${rowIndex}-${colIndex}-${rowId}`;
+
+export interface SelectionManagerConfig {
+ selectableCells: boolean;
+ headers: HeaderObject[];
+ tableRows: TableRowType[];
+ onCellEdit?: (props: any) => void;
+ cellRegistry?: Map;
+ collapsedHeaders?: Set;
+ rowHeight: number;
+ enableRowSelection?: boolean;
+ copyHeadersToClipboard?: boolean;
+ customTheme: CustomTheme;
+ /** Called when a selection drag ends so the table can re-render and apply selection classes. */
+ onSelectionDragEnd?: () => void;
+ /** Root element of the table; sync scopes cell queries to this so only this table's cells are updated. */
+ tableRoot?: HTMLElement;
+}
diff --git a/packages/core/src/managers/SortManager.ts b/packages/core/src/managers/SortManager.ts
new file mode 100644
index 000000000..0e979aa0f
--- /dev/null
+++ b/packages/core/src/managers/SortManager.ts
@@ -0,0 +1,287 @@
+import HeaderObject, { Accessor } from "../types/HeaderObject";
+import Row from "../types/Row";
+import SortColumn, { SortDirection } from "../types/SortColumn";
+import { handleSort } from "../utils/sortUtils";
+import { isRowArray } from "../utils/rowUtils";
+import { flattenAllHeaders } from "../utils/headerUtils";
+import { calculateAggregatedRows } from "../hooks/useAggregatedRows";
+
+export interface SortManagerConfig {
+ headers: HeaderObject[];
+ tableRows: Row[];
+ externalSortHandling: boolean;
+ onSortChange?: (sort: SortColumn | null) => void;
+ rowGrouping?: string[];
+ initialSortColumn?: string;
+ initialSortDirection?: SortDirection;
+ announce?: (message: string) => void;
+}
+
+export interface SortManagerState {
+ sort: SortColumn | null;
+ sortedRows: Row[];
+}
+
+type StateChangeCallback = (state: SortManagerState) => void;
+
+export class SortManager {
+ private config: SortManagerConfig;
+ private state: SortManagerState;
+ private subscribers: Set = new Set();
+ private headerLookup: Map = new Map();
+
+ constructor(config: SortManagerConfig) {
+ this.config = config;
+ this.updateHeaderLookup();
+
+ const initialSort = this.getInitialSort();
+ const sortedRows = this.computeSortedRows(config.tableRows, initialSort);
+
+ this.state = {
+ sort: initialSort,
+ sortedRows,
+ };
+ }
+
+ private updateHeaderLookup(): void {
+ const allHeaders = flattenAllHeaders(this.config.headers);
+ this.headerLookup = new Map();
+
+ allHeaders.forEach((header) => {
+ this.headerLookup.set(header.accessor, header);
+ });
+ }
+
+ private getInitialSort(): SortColumn | null {
+ if (!this.config.initialSortColumn) return null;
+
+ const targetHeader = this.headerLookup.get(this.config.initialSortColumn);
+ if (!targetHeader) return null;
+
+ return {
+ key: targetHeader,
+ direction: this.config.initialSortDirection || "asc",
+ };
+ }
+
+ private sortNestedRows({
+ groupingKeys,
+ headers,
+ rows,
+ sortColumn,
+ }: {
+ groupingKeys: string[];
+ headers: HeaderObject[];
+ rows: Row[];
+ sortColumn: SortColumn;
+ }): Row[] {
+ const sortedData = handleSort({ headers, rows, sortColumn });
+
+ if (!groupingKeys || groupingKeys.length === 0) {
+ return sortedData;
+ }
+
+ return sortedData.map((row) => {
+ const currentGroupingKey = groupingKeys[0];
+ const nestedData = row[currentGroupingKey];
+
+ if (isRowArray(nestedData)) {
+ const sortedNestedData = this.sortNestedRows({
+ rows: nestedData,
+ sortColumn,
+ headers,
+ groupingKeys: groupingKeys.slice(1),
+ });
+
+ return {
+ ...row,
+ [currentGroupingKey]: sortedNestedData,
+ };
+ }
+
+ return row;
+ });
+ }
+
+ private computeSortedRows(tableRows: Row[], sortColumn: SortColumn | null): Row[] {
+ if (this.config.externalSortHandling) return tableRows;
+
+ // Always calculate aggregated values so parent rows display aggregated values
+ // regardless of whether a sort is active.
+ const aggregatedRows = calculateAggregatedRows({
+ rows: tableRows,
+ headers: this.config.headers,
+ rowGrouping: this.config.rowGrouping,
+ });
+
+ if (!sortColumn) return aggregatedRows;
+
+ if (this.config.rowGrouping && this.config.rowGrouping.length > 0) {
+ return this.sortNestedRows({
+ groupingKeys: this.config.rowGrouping,
+ headers: this.config.headers,
+ rows: aggregatedRows,
+ sortColumn,
+ });
+ } else {
+ return handleSort({ headers: this.config.headers, rows: aggregatedRows, sortColumn });
+ }
+ }
+
+ updateConfig(config: Partial): void {
+ const oldHeaders = this.config.headers;
+ this.config = { ...this.config, ...config };
+
+ if (config.headers && config.headers !== oldHeaders) {
+ this.updateHeaderLookup();
+ }
+
+ const sortedRows = this.computeSortedRows(this.config.tableRows, this.state.sort);
+
+ if (sortedRows !== this.state.sortedRows) {
+ this.state = {
+ ...this.state,
+ sortedRows,
+ };
+ this.notifySubscribers();
+ }
+ }
+
+ subscribe(callback: StateChangeCallback): () => void {
+ this.subscribers.add(callback);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ private notifySubscribers(): void {
+ this.subscribers.forEach((cb) => cb(this.state));
+ }
+
+ updateSort(props?: { accessor: Accessor; direction?: SortDirection }): void {
+ if (!props) {
+ this.state = {
+ ...this.state,
+ sort: null,
+ sortedRows: this.config.tableRows,
+ };
+ this.config.onSortChange?.(null);
+ this.notifySubscribers();
+ return;
+ }
+
+ const { accessor, direction } = props;
+ const targetHeader = this.headerLookup.get(accessor);
+
+ if (!targetHeader) {
+ return;
+ }
+
+ let newSortColumn: SortColumn | null = null;
+
+ if (direction) {
+ newSortColumn = {
+ key: targetHeader,
+ direction: direction,
+ };
+ } else {
+ const sortingOrder = targetHeader.sortingOrder || ["asc", "desc", null];
+
+ let currentIndex = -1;
+ if (this.state.sort && this.state.sort.key.accessor === accessor) {
+ currentIndex = sortingOrder.indexOf(this.state.sort.direction);
+ }
+
+ const nextIndex = (currentIndex + 1) % sortingOrder.length;
+ const nextDirection = sortingOrder[nextIndex];
+
+ if (nextDirection === null) {
+ newSortColumn = null;
+ } else {
+ newSortColumn = {
+ key: targetHeader,
+ direction: nextDirection,
+ };
+ }
+ }
+
+ const sortedRows = this.computeSortedRows(this.config.tableRows, newSortColumn);
+
+ this.state = {
+ sort: newSortColumn,
+ sortedRows,
+ };
+
+ this.config.onSortChange?.(newSortColumn);
+
+ if (this.config.announce) {
+ if (newSortColumn) {
+ const directionText = newSortColumn.direction === "asc" ? "ascending" : "descending";
+ this.config.announce(`Sorted by ${targetHeader.label}, ${directionText}`);
+ } else {
+ this.config.announce(`Sort removed from ${targetHeader.label}`);
+ }
+ }
+
+ this.notifySubscribers();
+ }
+
+ computeSortedRowsPreview(accessor: Accessor): Row[] {
+ const findHeaderRecursively = (headers: HeaderObject[]): HeaderObject | undefined => {
+ for (const header of headers) {
+ if (header.accessor === accessor) {
+ return header;
+ }
+ if (header.children && header.children.length > 0) {
+ const found = findHeaderRecursively(header.children);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const targetHeader = findHeaderRecursively(this.config.headers);
+
+ if (!targetHeader) {
+ return this.config.tableRows;
+ }
+
+ let previewSortColumn: SortColumn | null = null;
+ const sortingOrder = targetHeader.sortingOrder || ["asc", "desc", null];
+
+ let currentIndex = -1;
+ if (this.state.sort && this.state.sort.key.accessor === accessor) {
+ currentIndex = sortingOrder.indexOf(this.state.sort.direction);
+ }
+
+ const nextIndex = (currentIndex + 1) % sortingOrder.length;
+ const nextDirection = sortingOrder[nextIndex];
+
+ if (nextDirection === null) {
+ previewSortColumn = null;
+ } else {
+ previewSortColumn = {
+ key: targetHeader,
+ direction: nextDirection,
+ };
+ }
+
+ return this.computeSortedRows(this.config.tableRows, previewSortColumn);
+ }
+
+ getState(): SortManagerState {
+ return this.state;
+ }
+
+ getSortColumn(): SortColumn | null {
+ return this.state.sort;
+ }
+
+ getSortedRows(): Row[] {
+ return this.state.sortedRows;
+ }
+
+ destroy(): void {
+ this.subscribers.clear();
+ }
+}
diff --git a/packages/core/src/managers/TableManager.ts b/packages/core/src/managers/TableManager.ts
new file mode 100644
index 000000000..079b17a48
--- /dev/null
+++ b/packages/core/src/managers/TableManager.ts
@@ -0,0 +1,289 @@
+import HeaderObject, { Accessor } from "../types/HeaderObject";
+import Row from "../types/Row";
+import SortColumn, { SortDirection } from "../types/SortColumn";
+import { TableFilterState, FilterCondition } from "../types/FilterTypes";
+import { ColumnVisibilityState } from "../types/ColumnVisibilityTypes";
+import { CustomTheme } from "../types/CustomTheme";
+import { GetRowId } from "../types/GetRowId";
+import RowState from "../types/RowState";
+
+import { SortManager, SortManagerConfig } from "./SortManager";
+import { FilterManager, FilterManagerConfig } from "./FilterManager";
+import { RowManager, RowManagerConfig } from "./RowManager";
+import { ColumnManager, ColumnManagerConfig } from "./ColumnManager";
+import { DimensionManager, DimensionManagerConfig } from "./DimensionManager";
+import { ScrollManager, ScrollManagerConfig } from "./ScrollManager";
+import { SelectionManager, SelectionManagerConfig } from "./SelectionManager";
+
+export interface TableManagerConfig {
+ headers: HeaderObject[];
+ rows: Row[];
+ rowHeight: number;
+ headerHeight?: number;
+ customTheme: CustomTheme;
+
+ externalSortHandling?: boolean;
+ externalFilterHandling?: boolean;
+ rowGrouping?: Accessor[];
+ getRowId?: GetRowId;
+ selectableCells?: boolean;
+ selectableColumns?: boolean;
+ enableRowSelection?: boolean;
+ copyHeadersToClipboard?: boolean;
+
+ height?: string | number;
+ maxHeight?: string | number;
+
+ initialSortColumn?: string;
+ initialSortDirection?: SortDirection;
+
+ onSortChange?: (sort: SortColumn | null) => void;
+ onFilterChange?: (filters: TableFilterState) => void;
+ onColumnOrderChange?: (newHeaders: HeaderObject[]) => void;
+ onColumnVisibilityChange?: (visibilityState: ColumnVisibilityState) => void;
+ onColumnWidthChange?: (headers: HeaderObject[]) => void;
+ onLoadMore?: () => void;
+ onCellEdit?: (props: any) => void;
+
+ announce?: (message: string) => void;
+
+ containerElement?: HTMLElement;
+ cellRegistry?: Map;
+ collapsedHeaders?: Set;
+}
+
+export interface TableManagerState {
+ isReady: boolean;
+}
+
+type StateChangeCallback = () => void;
+
+export class TableManager {
+ private config: TableManagerConfig;
+ private state: TableManagerState;
+ private subscribers: Set = new Set();
+
+ public sortManager: SortManager;
+ public filterManager: FilterManager;
+ public rowManager: RowManager;
+ public columnManager: ColumnManager;
+ public dimensionManager: DimensionManager;
+ public scrollManager: ScrollManager;
+ public selectionManager: SelectionManager;
+
+ constructor(config: TableManagerConfig) {
+ this.config = config;
+
+ this.state = {
+ isReady: false,
+ };
+
+ const sortConfig: SortManagerConfig = {
+ headers: config.headers,
+ tableRows: config.rows,
+ externalSortHandling: config.externalSortHandling || false,
+ onSortChange: config.onSortChange,
+ rowGrouping: config.rowGrouping,
+ initialSortColumn: config.initialSortColumn,
+ initialSortDirection: config.initialSortDirection,
+ announce: config.announce,
+ };
+ this.sortManager = new SortManager(sortConfig);
+
+ const filterConfig: FilterManagerConfig = {
+ rows: config.rows,
+ headers: config.headers,
+ externalFilterHandling: config.externalFilterHandling || false,
+ onFilterChange: config.onFilterChange,
+ announce: config.announce,
+ };
+ this.filterManager = new FilterManager(filterConfig);
+
+ const sortedRows = this.sortManager.getSortedRows();
+ const filteredRows = this.filterManager.getFilteredRows();
+
+ const rowConfig: RowManagerConfig = {
+ rows: this.applyFiltersAndSort(config.rows),
+ headers: config.headers,
+ rowGrouping: config.rowGrouping,
+ getRowId: config.getRowId,
+ rowHeight: config.rowHeight,
+ headerHeight: config.headerHeight || config.rowHeight,
+ customTheme: config.customTheme,
+ hasLoadingRenderer: false,
+ hasErrorRenderer: false,
+ hasEmptyRenderer: false,
+ };
+ this.rowManager = new RowManager(rowConfig);
+
+ const columnConfig: ColumnManagerConfig = {
+ headers: config.headers,
+ collapsedHeaders: config.collapsedHeaders || new Set(),
+ onColumnOrderChange: config.onColumnOrderChange,
+ onColumnVisibilityChange: config.onColumnVisibilityChange,
+ onColumnWidthChange: config.onColumnWidthChange,
+ };
+ this.columnManager = new ColumnManager(columnConfig);
+
+ const dimensionConfig: DimensionManagerConfig = {
+ effectiveHeaders: config.headers,
+ headerHeight: config.headerHeight,
+ rowHeight: config.rowHeight,
+ height: config.height,
+ maxHeight: config.maxHeight,
+ totalRowCount: config.rows.length,
+ containerElement: config.containerElement,
+ };
+ this.dimensionManager = new DimensionManager(dimensionConfig);
+
+ const scrollConfig: ScrollManagerConfig = {
+ onLoadMore: config.onLoadMore,
+ };
+ this.scrollManager = new ScrollManager(scrollConfig);
+
+ const selectionConfig: SelectionManagerConfig = {
+ selectableCells: config.selectableCells || false,
+ headers: config.headers,
+ tableRows: [],
+ onCellEdit: config.onCellEdit,
+ cellRegistry: config.cellRegistry,
+ collapsedHeaders: config.collapsedHeaders,
+ rowHeight: config.rowHeight,
+ enableRowSelection: config.enableRowSelection,
+ copyHeadersToClipboard: config.copyHeadersToClipboard || false,
+ customTheme: config.customTheme,
+ };
+ this.selectionManager = new SelectionManager(selectionConfig);
+
+ this.setupManagerSubscriptions();
+
+ this.state.isReady = true;
+ this.notifySubscribers();
+ }
+
+ private setupManagerSubscriptions(): void {
+ this.sortManager.subscribe(() => {
+ this.handleSortChange();
+ });
+
+ this.filterManager.subscribe(() => {
+ this.handleFilterChange();
+ });
+
+ this.rowManager.subscribe(() => {
+ this.notifySubscribers();
+ });
+
+ this.columnManager.subscribe(() => {
+ this.notifySubscribers();
+ });
+
+ this.dimensionManager.subscribe(() => {
+ this.notifySubscribers();
+ });
+
+ this.scrollManager.subscribe(() => {
+ this.notifySubscribers();
+ });
+ }
+
+ private applyFiltersAndSort(rows: Row[]): Row[] {
+ const filteredRows = this.filterManager.getFilteredRows();
+ const sortedRows = this.sortManager.getSortedRows();
+
+ if (this.config.externalFilterHandling && this.config.externalSortHandling) {
+ return rows;
+ } else if (this.config.externalFilterHandling) {
+ return sortedRows;
+ } else if (this.config.externalSortHandling) {
+ return filteredRows;
+ } else {
+ const sortedAndFiltered = this.sortManager.getSortedRows();
+ return sortedAndFiltered;
+ }
+ }
+
+ private handleSortChange(): void {
+ const processedRows = this.applyFiltersAndSort(this.config.rows);
+ this.rowManager.updateConfig({ rows: processedRows });
+ this.notifySubscribers();
+ }
+
+ private handleFilterChange(): void {
+ const processedRows = this.applyFiltersAndSort(this.config.rows);
+ this.sortManager.updateConfig({ tableRows: processedRows });
+ this.rowManager.updateConfig({ rows: processedRows });
+ this.notifySubscribers();
+ }
+
+ updateConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
+
+ if (config.headers !== undefined) {
+ this.sortManager.updateConfig({ headers: config.headers });
+ this.filterManager.updateConfig({ headers: config.headers });
+ this.rowManager.updateConfig({ headers: config.headers });
+ this.columnManager.updateConfig({ headers: config.headers });
+ this.dimensionManager.updateConfig({ effectiveHeaders: config.headers });
+ this.selectionManager.updateConfig({ headers: config.headers });
+ }
+
+ if (config.rows !== undefined) {
+ this.sortManager.updateConfig({ tableRows: config.rows });
+ this.filterManager.updateConfig({ rows: config.rows });
+
+ const processedRows = this.applyFiltersAndSort(config.rows);
+ this.rowManager.updateConfig({ rows: processedRows });
+
+ this.dimensionManager.updateConfig({ totalRowCount: config.rows.length });
+ }
+
+ if (config.rowHeight !== undefined) {
+ this.rowManager.updateConfig({ rowHeight: config.rowHeight });
+ this.dimensionManager.updateConfig({ rowHeight: config.rowHeight });
+ this.selectionManager.updateConfig({ rowHeight: config.rowHeight });
+ }
+
+ if (config.customTheme !== undefined) {
+ this.rowManager.updateConfig({ customTheme: config.customTheme });
+ this.selectionManager.updateConfig({ customTheme: config.customTheme });
+ }
+
+ if (config.collapsedHeaders !== undefined) {
+ this.columnManager.updateConfig({ collapsedHeaders: config.collapsedHeaders });
+ this.selectionManager.updateConfig({ collapsedHeaders: config.collapsedHeaders });
+ }
+
+ this.notifySubscribers();
+ }
+
+ subscribe(callback: StateChangeCallback): () => void {
+ this.subscribers.add(callback);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ private notifySubscribers(): void {
+ this.subscribers.forEach((cb) => cb());
+ }
+
+ getState(): TableManagerState {
+ return this.state;
+ }
+
+ isReady(): boolean {
+ return this.state.isReady;
+ }
+
+ destroy(): void {
+ this.sortManager.destroy();
+ this.filterManager.destroy();
+ this.rowManager.destroy();
+ this.columnManager.destroy();
+ this.dimensionManager.destroy();
+ this.scrollManager.destroy();
+ this.selectionManager.destroy();
+ this.subscribers.clear();
+ }
+}
diff --git a/src/styles/all-themes.css b/packages/core/src/styles/all-themes.css
similarity index 100%
rename from src/styles/all-themes.css
rename to packages/core/src/styles/all-themes.css
diff --git a/src/styles/base.css b/packages/core/src/styles/base.css
similarity index 91%
rename from src/styles/base.css
rename to packages/core/src/styles/base.css
index 9d60fee8d..78f23fd37 100644
--- a/src/styles/base.css
+++ b/packages/core/src/styles/base.css
@@ -16,6 +16,7 @@
--st-resize-handle-width: 2px;
--st-resize-handle-container-width: 10px;
--st-border-width: 1px;
+ --st-footer-height: 49px;
/* Animation variables */
--st-transition-duration: 0.2s;
@@ -149,6 +150,7 @@ input {
.simple-table-root {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+ color: var(--st-cell-color);
}
/* Wrapper for the table */
@@ -177,12 +179,15 @@ input {
.st-content {
display: flex;
flex-direction: column;
+ width: 100%;
}
/* Header */
.st-header-container {
display: flex;
background-color: var(--st-header-background-color);
+ flex-shrink: 0;
+ width: 100%;
}
.st-header-container.st-header-scroll-padding::after {
@@ -197,10 +202,14 @@ input {
.st-header-pinned-left,
.st-header-main,
.st-header-pinned-right {
- display: grid;
border-bottom: var(--st-border-width) solid var(--st-border-color);
}
+.st-header-grid {
+ position: relative;
+ min-width: 100%;
+}
+
.st-horizontal-scrollbar-middle,
.st-horizontal-scrollbar-left,
.st-horizontal-scrollbar-right,
@@ -237,9 +246,19 @@ input {
.st-body-pinned-right {
border-left: var(--st-border-width) solid var(--st-border-color);
}
+
.st-header-main {
flex-grow: 1;
}
+
+/* Header must only scroll horizontally; prevent vertical scroll (vanilla refactor removed display:grid from header sections) */
+.st-header-main,
+.st-header-pinned-left,
+.st-header-pinned-right {
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
.st-header-main::-webkit-scrollbar {
display: none;
}
@@ -297,7 +316,7 @@ input {
padding: 48px 24px;
color: var(--st-header-text-color);
opacity: 0.6;
- font-size: 14px;
+ font-size: 0.9em;
}
.st-body-main,
@@ -350,9 +369,9 @@ input {
}
.st-row.hovered .st-cell-editing,
.st-row.hovered
- .st-cell:not(.st-cell-selected):not(.st-cell-selected-first):not(.st-cell-column-selected):not(
- .st-cell-column-selected-first
- ) {
+ .st-cell:not(.st-cell-selected):not(.st-cell-selected-first):not(
+ .st-cell-column-selected
+ ):not(.st-cell-column-selected-first) {
background-color: var(--st-hover-row-background-color);
}
/* Separate hover background for sub-cells */
@@ -377,9 +396,9 @@ input {
/* Selected row hover state - should override selected background when hovering */
.st-row.selected.hovered .st-cell-editing,
.st-row.selected.hovered
- .st-cell:not(.st-cell-selected):not(.st-cell-selected-first):not(.st-cell-column-selected):not(
- .st-cell-column-selected-first
- ) {
+ .st-cell:not(.st-cell-selected):not(.st-cell-selected-first):not(
+ .st-cell-column-selected
+ ):not(.st-cell-column-selected-first) {
background-color: var(--st-hover-row-background-color);
}
@@ -400,9 +419,34 @@ input {
color: var(--st-cell-color);
}
+/* Cell-level row background classes (for absolute positioned cells) */
+.st-cell.st-cell-even-row {
+ background-color: var(--st-even-row-background-color);
+}
+.st-cell.st-cell-odd-row {
+ background-color: var(--st-odd-row-background-color);
+}
+.st-cell.st-cell-even-row .st-cell-content {
+ color: var(--st-cell-color);
+}
+.st-cell.st-cell-odd-row .st-cell-content {
+ color: var(--st-cell-odd-row-color);
+}
+
+/* Selected row styling for cells */
+.st-cell.st-cell-selected-row {
+ background-color: var(--st-selected-row-background-color);
+}
+
+/* Row hover styling for cells */
+.st-cell.st-row-hovered:not(.st-cell-selected):not(.st-cell-selected-first):not(
+ .st-cell-column-selected
+ ):not(.st-cell-column-selected-first) {
+ background-color: var(--st-hover-row-background-color);
+}
+
/* Styles for table header cells */
.st-header-cell {
- position: sticky;
top: 0;
background-color: var(--st-header-background-color);
font-weight: var(--st-font-weight-bold);
@@ -555,7 +599,8 @@ input {
.st-resizeable .st-body-pinned-right .st-cell-content,
.st-resizeable .st-sticky-section-right .st-cell-content {
padding-left: calc(
- var(--st-cell-padding) + var(--st-spacing-small) + var(--st-resize-handle-container-width)
+ var(--st-cell-padding) + var(--st-spacing-small) +
+ var(--st-resize-handle-container-width)
);
padding-right: var(--st-cell-padding);
}
@@ -567,7 +612,8 @@ input {
.st-resizeable .st-sticky-section-main .st-cell-content {
padding-left: var(--st-cell-padding);
padding-right: calc(
- var(--st-cell-padding) + var(--st-spacing-small) + var(--st-resize-handle-container-width)
+ var(--st-cell-padding) + var(--st-spacing-small) +
+ var(--st-resize-handle-container-width)
);
}
@@ -585,6 +631,13 @@ input {
.st-cell {
border: var(--st-border-width) solid transparent;
}
+
+/* Remove browser default focus ring when Tab moves focus to a cell; selection styling indicates focus */
+.st-cell:focus,
+.st-cell:focus-visible {
+ outline: none;
+}
+
.st-cell.even-column:not(.st-cell-selected) {
background-color: var(--st-even-column-background-color);
}
@@ -672,12 +725,11 @@ input {
.st-row-separator {
position: absolute;
+ width: 100%;
min-width: 100%;
- display: grid;
cursor: pointer;
height: 1px;
background-color: var(--st-border-color);
- grid-column: 1 / -1;
}
.st-row-separator.st-last-group-row {
background-color: var(--st-last-group-row-separator-border-color);
@@ -831,6 +883,7 @@ input {
display: flex;
justify-content: space-between;
align-items: center;
+ min-height: var(--st-footer-height, 49px);
background-color: var(--st-footer-background-color);
padding: var(--st-spacing-medium);
border-top: var(--st-border-width) solid var(--st-border-color);
@@ -846,7 +899,7 @@ input {
.st-footer-results-text {
color: var(--st-cell-color);
- font-size: 0.875rem;
+ font-size: 0.9em;
white-space: nowrap;
}
@@ -865,7 +918,8 @@ input {
background-color: transparent;
border: none;
border-radius: var(--st-border-radius);
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
}
.st-next-prev-btn {
fill: var(--st-next-prev-btn-color);
@@ -892,7 +946,8 @@ input {
color: var(--st-page-btn-color);
border: none;
border-radius: var(--st-border-radius);
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -936,7 +991,8 @@ input {
font-size: 0.875rem;
}
.editable-cell-input:focus {
- border: var(--st-border-width) solid var(--st-editable-cell-focus-border-color);
+ border: var(--st-border-width) solid
+ var(--st-editable-cell-focus-border-color);
}
.st-column-editor {
display: flex;
@@ -946,6 +1002,7 @@ input {
border-left: var(--st-border-width) solid var(--st-border-color);
color: var(--st-column-editor-text-color);
flex-shrink: 0;
+ height: 100%;
}
.st-column-editor-text {
@@ -1001,8 +1058,6 @@ input {
padding-right: var(--st-spacing-medium);
padding-left: var(--st-spacing-medium);
padding-bottom: var(--st-spacing-medium);
- overflow: auto;
- flex-grow: 1;
}
.st-column-editor-lists {
@@ -1027,6 +1082,32 @@ input {
padding-bottom: var(--st-spacing-small);
}
+.st-column-editor-footer {
+ position: sticky;
+ bottom: 0;
+ padding: var(--st-spacing-small) var(--st-spacing-medium);
+ background-color: var(--st-column-editor-popout-background-color);
+ border-top: var(--st-border-width) solid var(--st-border-color);
+ z-index: 1;
+}
+
+.st-column-editor-reset-btn {
+ width: 100%;
+ padding: var(--st-spacing-small) var(--st-spacing-medium);
+ font-size: 0.8rem;
+ font-weight: 500;
+ 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-column-editor-reset-btn:hover {
+ background-color: var(--st-hover-background-color);
+}
+
.st-column-pin-btn {
display: flex;
align-items: center;
@@ -1185,6 +1266,7 @@ input {
.st-checkbox-label {
display: flex;
align-items: center;
+ justify-content: center;
cursor: pointer;
gap: var(--st-spacing-small);
min-width: 0;
@@ -1260,7 +1342,8 @@ input {
border-bottom: var(--st-border-width) solid var(--st-border-color);
color: var(--st-cell-color);
font-weight: var(--st-font-weight-bold);
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
}
.st-group-header:hover {
@@ -1334,38 +1417,6 @@ input {
animation: cell-flash 0.6s ease-in-out;
}
-/* Animation z-index handling */
-.st-animating {
- z-index: 10 !important;
- position: relative;
- /* Performance optimizations for buttery smooth animations */
- contain: style layout paint; /* Isolate this element's rendering */
- transform-style: preserve-3d; /* Enable 3D rendering context */
- isolation: isolate; /* Create new stacking context */
-}
-
-/* Respect user's motion preferences */
-@media (prefers-reduced-motion: reduce) {
- .st-animating {
- /* For users who prefer reduced motion, keep animations short and simple */
- transition-duration: 0.15s !important;
- animation-duration: 0.15s !important;
- }
-
- /* Disable FLIP animations entirely for users who prefer no motion */
- .st-animating {
- transform: none !important;
- transition: none !important;
- }
-}
-
-/* Enhanced animation easing for modern browsers */
-@supports (transition-timing-function: cubic-bezier(0.2, 0, 0.2, 1)) {
- .st-animating {
- transition-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
- }
-}
-
@keyframes copy-flash {
0% {
background-color: var(--st-copy-flash-color);
@@ -1460,8 +1511,10 @@ input {
padding: var(--st-spacing-small) var(--st-spacing-medium);
cursor: pointer;
white-space: nowrap;
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
color: var(--st-cell-color);
+ font-size: 0.9em;
}
.st-dropdown-item:hover {
@@ -1560,7 +1613,8 @@ input {
width: 24px;
border-radius: 50%;
cursor: pointer;
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
font-size: 0.9em;
}
@@ -1594,7 +1648,8 @@ input {
padding: 12px 8px;
border-radius: var(--st-border-radius);
cursor: pointer;
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
}
.st-datepicker-month:hover,
@@ -1621,7 +1676,8 @@ input {
border-radius: var(--st-border-radius);
padding: 6px 12px;
cursor: pointer;
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
color: var(--st-cell-color);
}
@@ -1652,12 +1708,13 @@ input {
padding: var(--st-spacing-small);
border: var(--st-border-width) solid var(--st-border-color);
border-radius: var(--st-border-radius);
- font-size: 14px;
+ font-size: 0.9em;
background-color: var(--st-odd-row-background-color);
color: var(--st-cell-color);
font-family: inherit;
outline: none;
- transition: border-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: border-color var(--st-transition-duration)
+ var(--st-transition-ease);
}
.st-filter-input:focus {
@@ -1675,13 +1732,14 @@ input {
padding: var(--st-spacing-medium) var(--st-spacing-medium);
border: var(--st-border-width) solid var(--st-border-color);
border-radius: var(--st-border-radius);
- font-size: 14px;
+ font-size: 0.9em;
background-color: var(--st-odd-row-background-color);
color: var(--st-cell-color);
font-family: inherit;
cursor: pointer;
outline: none;
- transition: border-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: border-color var(--st-transition-duration)
+ var(--st-transition-ease);
}
.st-filter-select:focus {
@@ -1702,10 +1760,11 @@ input {
border: none;
border-radius: var(--st-border-radius);
cursor: pointer;
- font-size: 14px;
+ font-size: 0.9em;
font-weight: 500;
font-family: inherit;
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
}
.st-filter-button:focus {
outline: 2px solid var(--st-focus-ring-color);
@@ -1754,14 +1813,15 @@ input {
background-color: var(--st-odd-row-background-color);
color: var(--st-cell-color);
font-family: inherit;
- font-size: 14px;
+ font-size: 0.9em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--st-spacing-medium);
outline: none;
- transition: border-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: border-color var(--st-transition-duration)
+ var(--st-transition-ease);
}
.st-custom-select-trigger:focus {
@@ -1809,12 +1869,13 @@ input {
flex-shrink: 0;
padding: var(--st-spacing-small);
cursor: pointer;
- transition: background-color var(--st-transition-duration) var(--st-transition-ease);
+ transition: background-color var(--st-transition-duration)
+ var(--st-transition-ease);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--st-cell-color);
- font-size: 14px;
+ font-size: 0.9em;
}
.st-custom-select-option:hover,
@@ -1877,7 +1938,7 @@ input {
/* Enum option label */
.st-enum-option-label {
- font-size: 14px;
+ font-size: 0.9em;
color: var(--st-cell-color);
user-select: none;
}
@@ -1886,14 +1947,14 @@ input {
.st-enum-no-results {
padding: var(--st-spacing-medium);
text-align: center;
- font-size: 14px;
+ font-size: 0.9em;
color: var(--st-slate-400);
font-style: italic;
}
/* Row number styling */
.st-row-number {
- font-size: 12px;
+ font-size: 0.8em;
opacity: 0.6;
user-select: none;
font-weight: 500;
@@ -1941,18 +2002,18 @@ input {
/* Maintain column borders on hover */
.st-column-borders
.st-row.hovered
- .st-cell:not(.st-last-column):not(.st-cell-selected):not(.st-cell-selected-first):not(
- .st-cell-column-selected
- ):not(.st-cell-column-selected-first) {
+ .st-cell:not(.st-last-column):not(.st-cell-selected):not(
+ .st-cell-selected-first
+ ):not(.st-cell-column-selected):not(.st-cell-column-selected-first) {
box-shadow: var(--st-border-width) 0 0 0 var(--st-border-color);
}
/* Maintain column borders on selected row hover */
.st-column-borders
.st-row.selected.hovered
- .st-cell:not(.st-last-column):not(.st-cell-selected):not(.st-cell-selected-first):not(
- .st-cell-column-selected
- ):not(.st-cell-column-selected-first) {
+ .st-cell:not(.st-last-column):not(.st-cell-selected):not(
+ .st-cell-selected-first
+ ):not(.st-cell-column-selected):not(.st-cell-column-selected-first) {
box-shadow: var(--st-border-width) 0 0 0 var(--st-border-color);
}
@@ -1965,7 +2026,7 @@ input {
color: var(--st-tooltip-text-color);
padding: var(--st-tooltip-padding);
border-radius: var(--st-tooltip-border-radius);
- font-size: var(--st-tooltip-font-size);
+ font-size: 0.8em;
line-height: 1.4;
max-width: 300px;
word-wrap: break-word;
diff --git a/src/styles/themes/dark.css b/packages/core/src/styles/themes/dark.css
similarity index 100%
rename from src/styles/themes/dark.css
rename to packages/core/src/styles/themes/dark.css
diff --git a/src/styles/themes/frost.css b/packages/core/src/styles/themes/frost.css
similarity index 100%
rename from src/styles/themes/frost.css
rename to packages/core/src/styles/themes/frost.css
diff --git a/src/styles/themes/light.css b/packages/core/src/styles/themes/light.css
similarity index 100%
rename from src/styles/themes/light.css
rename to packages/core/src/styles/themes/light.css
diff --git a/src/styles/themes/modern-dark.css b/packages/core/src/styles/themes/modern-dark.css
similarity index 100%
rename from src/styles/themes/modern-dark.css
rename to packages/core/src/styles/themes/modern-dark.css
diff --git a/src/styles/themes/modern-light.css b/packages/core/src/styles/themes/modern-light.css
similarity index 100%
rename from src/styles/themes/modern-light.css
rename to packages/core/src/styles/themes/modern-light.css
diff --git a/src/styles/themes/neutral.css b/packages/core/src/styles/themes/neutral.css
similarity index 100%
rename from src/styles/themes/neutral.css
rename to packages/core/src/styles/themes/neutral.css
diff --git a/src/styles/themes/sky.css b/packages/core/src/styles/themes/sky.css
similarity index 100%
rename from src/styles/themes/sky.css
rename to packages/core/src/styles/themes/sky.css
diff --git a/src/styles/themes/violet.css b/packages/core/src/styles/themes/violet.css
similarity index 100%
rename from src/styles/themes/violet.css
rename to packages/core/src/styles/themes/violet.css
diff --git a/src/types/AggregationTypes.ts b/packages/core/src/types/AggregationTypes.ts
similarity index 52%
rename from src/types/AggregationTypes.ts
rename to packages/core/src/types/AggregationTypes.ts
index 9f8aa5dcd..db8b8030f 100644
--- a/src/types/AggregationTypes.ts
+++ b/packages/core/src/types/AggregationTypes.ts
@@ -1,8 +1,10 @@
+import type CellValue from "./CellValue";
+
export type AggregationType = "sum" | "average" | "count" | "min" | "max" | "custom";
export type AggregationConfig = {
type: AggregationType;
- parseValue?: (value: any) => number; // for parsing string values like "$15.0M" to numbers
+ parseValue?: (value: CellValue) => number; // for parsing string values like "$15.0M" to numbers
formatResult?: (value: number) => string; // for formatting the aggregated result back to string
- customFn?: (values: any[]) => any; // for custom aggregation logic
+ customFn?: (values: CellValue[]) => number; // for custom aggregation logic
};
diff --git a/src/types/BoundingBox.ts b/packages/core/src/types/BoundingBox.ts
similarity index 100%
rename from src/types/BoundingBox.ts
rename to packages/core/src/types/BoundingBox.ts
diff --git a/src/types/Cell.ts b/packages/core/src/types/Cell.ts
similarity index 100%
rename from src/types/Cell.ts
rename to packages/core/src/types/Cell.ts
diff --git a/src/types/CellChangeProps.ts b/packages/core/src/types/CellChangeProps.ts
similarity index 100%
rename from src/types/CellChangeProps.ts
rename to packages/core/src/types/CellChangeProps.ts
diff --git a/src/types/CellClickProps.ts b/packages/core/src/types/CellClickProps.ts
similarity index 100%
rename from src/types/CellClickProps.ts
rename to packages/core/src/types/CellClickProps.ts
diff --git a/packages/core/src/types/CellRendererProps.ts b/packages/core/src/types/CellRendererProps.ts
new file mode 100644
index 000000000..482beb395
--- /dev/null
+++ b/packages/core/src/types/CellRendererProps.ts
@@ -0,0 +1,35 @@
+import type { Accessor } from "./HeaderObject";
+import type Row from "./Row";
+import type Theme from "./Theme";
+import type CellValue from "./CellValue";
+
+interface CellRendererProps {
+ accessor: Accessor;
+ colIndex: number;
+ row: Row;
+ rowIndex: number;
+ rowPath?: (string | number)[];
+ theme: Theme;
+ value: CellValue; // The raw cell value
+ formattedValue?: string | number | string[] | number[] | null | undefined | boolean; // The formatted cell value (from valueFormatter if present)
+}
+
+/**
+ * CellRenderer return type:
+ * - string | number | null: rendered as text in the cell
+ * - Node (HTMLElement, DocumentFragment, etc.): appended directly into the cell for full DOM control
+ *
+ * Example (text):
+ * cellRenderer: ({ value, row }) => `${value} (${row.status})`
+ *
+ * Example (custom HTML):
+ * cellRenderer: ({ row }) => {
+ * const span = document.createElement('span');
+ * span.className = 'badge';
+ * span.textContent = String(row.status);
+ * return span;
+ * }
+ */
+export type CellRenderer = (props: CellRendererProps) => string | number | null | Node;
+
+export default CellRendererProps;
diff --git a/src/types/CellValue.ts b/packages/core/src/types/CellValue.ts
similarity index 80%
rename from src/types/CellValue.ts
rename to packages/core/src/types/CellValue.ts
index 49ad425cd..d3e209850 100644
--- a/src/types/CellValue.ts
+++ b/packages/core/src/types/CellValue.ts
@@ -6,6 +6,6 @@ type CellValue =
| null
| string[]
| number[]
- | Record[];
+ | Record[];
export default CellValue;
diff --git a/src/types/ColumnEditorConfig.ts b/packages/core/src/types/ColumnEditorConfig.ts
similarity index 50%
rename from src/types/ColumnEditorConfig.ts
rename to packages/core/src/types/ColumnEditorConfig.ts
index a603cf11a..a91566e0c 100644
--- a/src/types/ColumnEditorConfig.ts
+++ b/packages/core/src/types/ColumnEditorConfig.ts
@@ -1,43 +1,6 @@
-import { ReactNode } from "react";
import HeaderObject from "./HeaderObject";
import { ColumnEditorRowRenderer } from "./ColumnEditorRowRendererProps";
-/**
- * Props passed to the column editor custom renderer
- */
-export interface ColumnEditorCustomRendererProps {
- /** The search input section (when searchEnabled) */
- searchSection: ReactNode;
- /** The list of column checkboxes (pinned left, then unpinned, then pinned right) */
- listSection: ReactNode;
- /** Pinned-left section list only */
- pinnedLeftList?: ReactNode;
- /** Unpinned (main) section list only */
- unpinnedList?: ReactNode;
- /** Pinned-right section list only */
- pinnedRightList?: ReactNode;
- /** Flattened headers for all panel sections combined (left, then main, then right) */
- flattenedHeaders: import("../components/simple-table/table-column-editor/columnEditorUtils").FlattenedHeader[];
- /** Current search term */
- searchTerm: string;
- /** Setter for search term */
- setSearchTerm: (term: string) => void;
- /** Whether search is enabled */
- searchEnabled: boolean;
- /** Search placeholder text */
- searchPlaceholder: string;
- /** All headers (unflattened) */
- headers: HeaderObject[];
- /** Reset columns to default order and visibility */
- resetColumns: () => void;
-}
-
-/**
- * Custom renderer for the entire column editor popout content.
- * Receives the default search and list sections as props for flexible layout.
- */
-export type ColumnEditorCustomRenderer = (props: ColumnEditorCustomRendererProps) => ReactNode;
-
/**
* Custom search function for filtering columns in the column editor
* @param header - The header object to check
@@ -65,12 +28,10 @@ export interface ColumnEditorConfig {
allowColumnPinning?: boolean;
/** Custom renderer for column editor row layout to reposition icons and labels */
rowRenderer?: ColumnEditorRowRenderer;
- /** Custom renderer for the entire column editor popout. Receives searchSection, listSection, flattenedHeaders, searchTerm, etc. */
- customRenderer?: ColumnEditorCustomRenderer;
}
export const DEFAULT_COLUMN_EDITOR_CONFIG: Required<
- Omit
+ Omit
> = {
text: "Columns",
searchEnabled: true,
@@ -82,4 +43,4 @@ export const DEFAULT_COLUMN_EDITOR_CONFIG: Required<
export type MergedColumnEditorConfig = Required<
Pick
> &
- Pick;
+ Pick;
diff --git a/src/types/ColumnEditorRowRendererProps.ts b/packages/core/src/types/ColumnEditorRowRendererProps.ts
similarity index 80%
rename from src/types/ColumnEditorRowRendererProps.ts
rename to packages/core/src/types/ColumnEditorRowRendererProps.ts
index be5b57160..7d6ed162d 100644
--- a/src/types/ColumnEditorRowRendererProps.ts
+++ b/packages/core/src/types/ColumnEditorRowRendererProps.ts
@@ -1,15 +1,15 @@
-import { ReactNode } from "react";
import type { Accessor } from "./HeaderObject";
import type HeaderObject from "./HeaderObject";
-import type { PanelSection } from "../utils/pinnedColumnUtils";
+import type { IconElement } from "./IconsConfig";
+import type { PanelSection } from "./PanelSection";
export interface ColumnEditorRowRendererComponents {
- expandIcon?: ReactNode;
- checkbox?: ReactNode;
- dragIcon?: ReactNode;
- labelContent?: ReactNode;
+ expandIcon?: IconElement;
+ checkbox?: HTMLElement | string;
+ dragIcon?: IconElement;
+ labelContent?: string | HTMLElement;
/** Default pin column (outline / filled); omit when building a fully custom row */
- pinIcon?: ReactNode;
+ pinIcon?: IconElement;
}
/** Pin / unpin actions for column editor rows (also use for lock/tooltip UX via HeaderObject.isEssential). */
@@ -38,7 +38,7 @@ interface ColumnEditorRowRendererProps {
pinControl?: ColumnEditorPinControl;
}
-export type ColumnEditorRowRenderer = (props: ColumnEditorRowRendererProps) => ReactNode | string;
+export type ColumnEditorRowRenderer = (props: ColumnEditorRowRendererProps) => HTMLElement | string | null;
export default ColumnEditorRowRendererProps;
export type { PanelSection };
diff --git a/src/types/ColumnVisibilityTypes.ts b/packages/core/src/types/ColumnVisibilityTypes.ts
similarity index 100%
rename from src/types/ColumnVisibilityTypes.ts
rename to packages/core/src/types/ColumnVisibilityTypes.ts
diff --git a/src/types/CustomTheme.ts b/packages/core/src/types/CustomTheme.ts
similarity index 100%
rename from src/types/CustomTheme.ts
rename to packages/core/src/types/CustomTheme.ts
diff --git a/src/types/DragHandlerProps.ts b/packages/core/src/types/DragHandlerProps.ts
similarity index 67%
rename from src/types/DragHandlerProps.ts
rename to packages/core/src/types/DragHandlerProps.ts
index 8a1fa7a43..710123a63 100644
--- a/src/types/DragHandlerProps.ts
+++ b/packages/core/src/types/DragHandlerProps.ts
@@ -1,11 +1,10 @@
-import { MutableRefObject } from "react";
import HeaderObject, { Accessor } from "./HeaderObject";
type useDragHandlerProps = {
- draggedHeaderRef: MutableRefObject;
+ draggedHeaderRef: { current: HeaderObject | null };
essentialAccessors?: ReadonlySet;
headers: HeaderObject[];
- hoveredHeaderRef: MutableRefObject;
+ hoveredHeaderRef: { current: HeaderObject | null };
onColumnOrderChange?: (newHeaders: HeaderObject[]) => void;
onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void;
};
diff --git a/src/types/EnumOption.ts b/packages/core/src/types/EnumOption.ts
similarity index 100%
rename from src/types/EnumOption.ts
rename to packages/core/src/types/EnumOption.ts
diff --git a/src/types/FilterTypes.ts b/packages/core/src/types/FilterTypes.ts
similarity index 100%
rename from src/types/FilterTypes.ts
rename to packages/core/src/types/FilterTypes.ts
diff --git a/packages/core/src/types/FlattenedHeader.ts b/packages/core/src/types/FlattenedHeader.ts
new file mode 100644
index 000000000..670e79285
--- /dev/null
+++ b/packages/core/src/types/FlattenedHeader.ts
@@ -0,0 +1,11 @@
+import HeaderObject from "./HeaderObject";
+import type { PanelSection } from "./PanelSection";
+
+export type FlattenedHeader = {
+ header: HeaderObject;
+ visualIndex: number;
+ depth: number;
+ parent: HeaderObject | null;
+ indexPath: number[];
+ panelSection?: PanelSection;
+};
diff --git a/src/types/FooterRendererProps.ts b/packages/core/src/types/FooterRendererProps.ts
similarity index 77%
rename from src/types/FooterRendererProps.ts
rename to packages/core/src/types/FooterRendererProps.ts
index 5f1b1e393..2b8b62469 100644
--- a/src/types/FooterRendererProps.ts
+++ b/packages/core/src/types/FooterRendererProps.ts
@@ -1,15 +1,15 @@
-import { ReactNode } from "react";
+import type { IconElement } from "./IconsConfig";
interface FooterRendererProps {
currentPage: number;
endRow: number;
hasNextPage: boolean;
hasPrevPage: boolean;
- nextIcon?: ReactNode;
+ nextIcon?: IconElement;
onNextPage: () => Promise;
onPageChange: (page: number) => void;
onPrevPage: () => void;
- prevIcon?: ReactNode;
+ prevIcon?: IconElement;
rowsPerPage: number;
startRow: number;
totalPages: number;
diff --git a/src/types/GenerateRowIdParams.ts b/packages/core/src/types/GenerateRowIdParams.ts
similarity index 100%
rename from src/types/GenerateRowIdParams.ts
rename to packages/core/src/types/GenerateRowIdParams.ts
diff --git a/src/types/GetRowId.ts b/packages/core/src/types/GetRowId.ts
similarity index 100%
rename from src/types/GetRowId.ts
rename to packages/core/src/types/GetRowId.ts
diff --git a/src/types/HandleResizeStartProps.ts b/packages/core/src/types/HandleResizeStartProps.ts
similarity index 52%
rename from src/types/HandleResizeStartProps.ts
rename to packages/core/src/types/HandleResizeStartProps.ts
index 36f9b3009..48a7d9cfd 100644
--- a/src/types/HandleResizeStartProps.ts
+++ b/packages/core/src/types/HandleResizeStartProps.ts
@@ -1,6 +1,9 @@
-import { Dispatch, RefObject, SetStateAction, TouchEvent } from "react";
import { HeaderObject, Accessor } from "..";
+export interface RefObject {
+ current: T | null;
+}
+
export type HandleResizeStartProps = {
autoExpandColumns: boolean;
collapsedHeaders: Set;
@@ -9,12 +12,12 @@ export type HandleResizeStartProps = {
forceUpdate: () => void;
header: HeaderObject;
headers: HeaderObject[];
- mainBodyRef: RefObject;
+ mainBodyRef: RefObject;
onColumnWidthChange?: (headers: HeaderObject[]) => void;
- pinnedLeftRef: RefObject;
- pinnedRightRef: RefObject;
+ pinnedLeftRef: RefObject;
+ pinnedRightRef: RefObject;
reverse: boolean;
- setHeaders: Dispatch>;
- setIsResizing: Dispatch>;
+ setHeaders: (headers: HeaderObject[] | ((prev: HeaderObject[]) => HeaderObject[])) => void;
+ setIsResizing: (isResizing: boolean | ((prev: boolean) => boolean)) => void;
startWidth: number;
};
diff --git a/src/types/HeaderDropdownProps.ts b/packages/core/src/types/HeaderDropdownProps.ts
similarity index 61%
rename from src/types/HeaderDropdownProps.ts
rename to packages/core/src/types/HeaderDropdownProps.ts
index 735dbff7f..2be712703 100644
--- a/src/types/HeaderDropdownProps.ts
+++ b/packages/core/src/types/HeaderDropdownProps.ts
@@ -1,4 +1,3 @@
-import { ReactNode } from "react";
import HeaderRendererProps from "./HeaderRendererProps";
interface HeaderDropdownProps extends HeaderRendererProps {
@@ -12,6 +11,8 @@ interface HeaderDropdownProps extends HeaderRendererProps {
};
}
-export type HeaderDropdown = (props: HeaderDropdownProps) => ReactNode;
+export type VanillaHeaderDropdown = (props: HeaderDropdownProps) => HTMLElement | string | null;
+
+export type HeaderDropdown = (props: HeaderDropdownProps) => HTMLElement | string | null;
export default HeaderDropdownProps;
diff --git a/src/types/HeaderObject.ts b/packages/core/src/types/HeaderObject.ts
similarity index 100%
rename from src/types/HeaderObject.ts
rename to packages/core/src/types/HeaderObject.ts
diff --git a/src/types/HeaderRendererProps.ts b/packages/core/src/types/HeaderRendererProps.ts
similarity index 55%
rename from src/types/HeaderRendererProps.ts
rename to packages/core/src/types/HeaderRendererProps.ts
index 0f4650334..f1f3f32c7 100644
--- a/src/types/HeaderRendererProps.ts
+++ b/packages/core/src/types/HeaderRendererProps.ts
@@ -1,12 +1,12 @@
-import { ReactNode } from "react";
import type { Accessor } from "./HeaderObject";
import type HeaderObject from "./HeaderObject";
+import type { IconElement } from "./IconsConfig";
export interface HeaderRendererComponents {
- sortIcon?: ReactNode;
- filterIcon?: ReactNode;
- collapseIcon?: ReactNode;
- labelContent?: ReactNode;
+ sortIcon?: IconElement;
+ filterIcon?: IconElement;
+ collapseIcon?: IconElement;
+ labelContent?: string | HTMLElement;
}
interface HeaderRendererProps {
@@ -16,6 +16,6 @@ interface HeaderRendererProps {
components?: HeaderRendererComponents;
}
-export type HeaderRenderer = (props: HeaderRendererProps) => ReactNode | string;
+export type HeaderRenderer = (props: HeaderRendererProps) => HTMLElement | string | null;
export default HeaderRendererProps;
diff --git a/packages/core/src/types/IconsConfig.ts b/packages/core/src/types/IconsConfig.ts
new file mode 100644
index 000000000..e6633f8b3
--- /dev/null
+++ b/packages/core/src/types/IconsConfig.ts
@@ -0,0 +1,20 @@
+/** Single icon value used across header, footer, and column editor props */
+export type IconElement = SVGSVGElement | HTMLElement | string;
+
+export interface VanillaIconsConfig {
+ drag?: IconElement;
+ expand?: IconElement;
+ filter?: IconElement;
+ headerCollapse?: IconElement;
+ headerExpand?: IconElement;
+ next?: IconElement;
+ prev?: IconElement;
+ sortDown?: IconElement;
+ sortUp?: IconElement;
+ /** Label for pin-to-left control in column editor (default: "L") */
+ pinnedLeftIcon?: IconElement;
+ /** Label for pin-to-right control in column editor (default: "R") */
+ pinnedRightIcon?: IconElement;
+}
+
+export interface IconsConfig extends VanillaIconsConfig {}
diff --git a/src/types/OnNextPage.ts b/packages/core/src/types/OnNextPage.ts
similarity index 100%
rename from src/types/OnNextPage.ts
rename to packages/core/src/types/OnNextPage.ts
diff --git a/packages/core/src/types/OnRowGroupExpandProps.ts b/packages/core/src/types/OnRowGroupExpandProps.ts
new file mode 100644
index 000000000..0babfafb1
--- /dev/null
+++ b/packages/core/src/types/OnRowGroupExpandProps.ts
@@ -0,0 +1,18 @@
+import Row from "./Row";
+import { Accessor } from "./HeaderObject";
+
+interface OnRowGroupExpandProps {
+ row: Row;
+ depth: number;
+ event: MouseEvent | KeyboardEvent;
+ groupingKey?: string;
+ isExpanded: boolean;
+ rowIndexPath: number[];
+ rowIdPath?: (string | number)[];
+ groupingKeys: Accessor[];
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ setEmpty: (isEmpty: boolean, message?: string) => void;
+}
+
+export default OnRowGroupExpandProps;
diff --git a/src/types/OnSortProps.ts b/packages/core/src/types/OnSortProps.ts
similarity index 100%
rename from src/types/OnSortProps.ts
rename to packages/core/src/types/OnSortProps.ts
diff --git a/packages/core/src/types/PanelSection.ts b/packages/core/src/types/PanelSection.ts
new file mode 100644
index 000000000..5c6ad2110
--- /dev/null
+++ b/packages/core/src/types/PanelSection.ts
@@ -0,0 +1 @@
+export type PanelSection = "left" | "main" | "right";
diff --git a/src/types/Pinned.ts b/packages/core/src/types/Pinned.ts
similarity index 100%
rename from src/types/Pinned.ts
rename to packages/core/src/types/Pinned.ts
diff --git a/packages/core/src/types/PinnedSectionsState.ts b/packages/core/src/types/PinnedSectionsState.ts
new file mode 100644
index 000000000..00c9b3c83
--- /dev/null
+++ b/packages/core/src/types/PinnedSectionsState.ts
@@ -0,0 +1,7 @@
+import { Accessor } from "./HeaderObject";
+
+export type PinnedSectionsState = {
+ left: Accessor[];
+ main: Accessor[];
+ right: Accessor[];
+};
diff --git a/src/types/QuickFilterTypes.ts b/packages/core/src/types/QuickFilterTypes.ts
similarity index 100%
rename from src/types/QuickFilterTypes.ts
rename to packages/core/src/types/QuickFilterTypes.ts
diff --git a/src/types/Row.ts b/packages/core/src/types/Row.ts
similarity index 100%
rename from src/types/Row.ts
rename to packages/core/src/types/Row.ts
diff --git a/packages/core/src/types/RowButton.ts b/packages/core/src/types/RowButton.ts
new file mode 100644
index 000000000..57e9cfa20
--- /dev/null
+++ b/packages/core/src/types/RowButton.ts
@@ -0,0 +1,17 @@
+import Row from "./Row";
+
+export interface RowButtonProps {
+ row: Row;
+ rowIndex: number; // The position of the row in the table
+}
+
+// BREAKING CHANGE: RowButton now returns HTMLElement instead of ReactNode
+// Users must provide vanilla JS functions that create DOM elements
+// Example:
+// rowButtons={[(props) => {
+// const button = document.createElement('button');
+// button.textContent = 'Edit';
+// button.onclick = () => handleEdit(props.row);
+// return button;
+// }]}
+export type RowButton = (props: RowButtonProps) => HTMLElement | null;
diff --git a/src/types/RowId.ts b/packages/core/src/types/RowId.ts
similarity index 100%
rename from src/types/RowId.ts
rename to packages/core/src/types/RowId.ts
diff --git a/src/types/RowSelectionChangeProps.ts b/packages/core/src/types/RowSelectionChangeProps.ts
similarity index 100%
rename from src/types/RowSelectionChangeProps.ts
rename to packages/core/src/types/RowSelectionChangeProps.ts
diff --git a/src/types/RowState.ts b/packages/core/src/types/RowState.ts
similarity index 100%
rename from src/types/RowState.ts
rename to packages/core/src/types/RowState.ts
diff --git a/packages/core/src/types/RowStateRendererProps.ts b/packages/core/src/types/RowStateRendererProps.ts
new file mode 100644
index 000000000..cc4c30fb4
--- /dev/null
+++ b/packages/core/src/types/RowStateRendererProps.ts
@@ -0,0 +1,25 @@
+import type Row from "./Row";
+
+export interface LoadingStateRendererProps {
+ parentRow?: Row;
+}
+
+export interface ErrorStateRendererProps {
+ error: string;
+ parentRow?: Row;
+}
+
+export interface EmptyStateRendererProps {
+ message?: string;
+ parentRow?: Row;
+}
+
+export type VanillaLoadingStateRenderer = string | HTMLElement | ((props: LoadingStateRendererProps) => HTMLElement | string);
+
+export type VanillaErrorStateRenderer = string | HTMLElement | ((props: ErrorStateRendererProps) => HTMLElement | string);
+
+export type VanillaEmptyStateRenderer = string | HTMLElement | ((props: EmptyStateRendererProps) => HTMLElement | string);
+
+export type LoadingStateRenderer = VanillaLoadingStateRenderer;
+export type ErrorStateRenderer = VanillaErrorStateRenderer;
+export type EmptyStateRenderer = VanillaEmptyStateRenderer;
diff --git a/src/types/SelectionType.ts b/packages/core/src/types/SelectionType.ts
similarity index 100%
rename from src/types/SelectionType.ts
rename to packages/core/src/types/SelectionType.ts
diff --git a/packages/core/src/types/SharedTableProps.ts b/packages/core/src/types/SharedTableProps.ts
new file mode 100644
index 000000000..325532bbc
--- /dev/null
+++ b/packages/core/src/types/SharedTableProps.ts
@@ -0,0 +1,23 @@
+import HeaderObject from "./HeaderObject";
+
+export interface RefObject {
+ current: T | null;
+}
+
+interface SharedTableProps {
+ centerHeaderRef: RefObject;
+ draggedHeaderRef: { current: HeaderObject | null };
+ headerContainerRef: RefObject;
+ headers: HeaderObject[];
+ hoveredHeaderRef: { current: HeaderObject | null };
+ mainBodyRef: RefObject;
+ onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void;
+ pinnedLeftColumns: HeaderObject[];
+ pinnedLeftHeaderRef: RefObject;
+ pinnedRightColumns: HeaderObject[];
+ pinnedRightHeaderRef: RefObject;
+ rowHeight: number;
+ tableBodyContainerRef: RefObject;
+}
+
+export default SharedTableProps;
diff --git a/packages/core/src/types/SimpleTableConfig.ts b/packages/core/src/types/SimpleTableConfig.ts
new file mode 100644
index 000000000..71c94779f
--- /dev/null
+++ b/packages/core/src/types/SimpleTableConfig.ts
@@ -0,0 +1,93 @@
+import HeaderObject, { Accessor } from "./HeaderObject";
+import Row from "./Row";
+import {
+ VanillaEmptyStateRenderer,
+ VanillaErrorStateRenderer,
+ VanillaLoadingStateRenderer,
+} from "./RowStateRendererProps";
+import FooterRendererProps from "./FooterRendererProps";
+import { VanillaHeaderDropdown } from "./HeaderDropdownProps";
+import SortColumn, { SortDirection } from "./SortColumn";
+import CellClickProps from "./CellClickProps";
+import CellChangeProps from "./CellChangeProps";
+import { ColumnVisibilityState } from "./ColumnVisibilityTypes";
+import { TableFilterState } from "./FilterTypes";
+import OnNextPage from "./OnNextPage";
+import OnRowGroupExpandProps from "./OnRowGroupExpandProps";
+import RowSelectionChangeProps from "./RowSelectionChangeProps";
+import { RowButton } from "./RowButton";
+import Theme from "./Theme";
+import { CustomThemeProps } from "./CustomTheme";
+import { GetRowId } from "./GetRowId";
+import { ColumnEditorConfig } from "./ColumnEditorConfig";
+import { VanillaIconsConfig } from "./IconsConfig";
+import { QuickFilterConfig } from "./QuickFilterTypes";
+
+export interface SimpleTableConfig {
+ allowAnimations?: boolean;
+ autoExpandColumns?: boolean;
+ canExpandRowGroup?: (row: Row) => boolean;
+ cellUpdateFlash?: boolean;
+ className?: string;
+ columnBorders?: boolean;
+ columnEditorConfig?: ColumnEditorConfig;
+ columnEditorText?: string;
+ columnReordering?: boolean;
+ columnResizing?: boolean;
+ copyHeadersToClipboard?: boolean;
+ customTheme?: CustomThemeProps;
+ defaultHeaders: HeaderObject[];
+ editColumns?: boolean;
+ editColumnsInitOpen?: boolean;
+ emptyStateRenderer?: VanillaEmptyStateRenderer;
+ enableHeaderEditing?: boolean;
+ enableRowSelection?: boolean;
+ enableStickyParents?: boolean;
+ errorStateRenderer?: VanillaErrorStateRenderer;
+ expandAll?: boolean;
+ externalFilterHandling?: boolean;
+ externalSortHandling?: boolean;
+ footerRenderer?: (props: FooterRendererProps) => HTMLElement | string | null;
+ headerDropdown?: VanillaHeaderDropdown;
+ height?: string | number;
+ hideFooter?: boolean;
+ hideHeader?: boolean;
+ icons?: VanillaIconsConfig;
+ includeHeadersInCSVExport?: boolean;
+ initialSortColumn?: string;
+ initialSortDirection?: SortDirection;
+ isLoading?: boolean;
+ loadingStateRenderer?: VanillaLoadingStateRenderer;
+ maxHeight?: string | number;
+ onCellClick?: (props: CellClickProps) => void;
+ onCellEdit?: (props: CellChangeProps) => void;
+ onColumnOrderChange?: (newHeaders: HeaderObject[]) => void;
+ onColumnSelect?: (header: HeaderObject) => void;
+ onColumnVisibilityChange?: (visibilityState: ColumnVisibilityState) => void;
+ onColumnWidthChange?: (headers: HeaderObject[]) => void;
+ onFilterChange?: (filters: TableFilterState) => void;
+ onGridReady?: () => void;
+ onHeaderEdit?: (header: HeaderObject, newLabel: string) => void;
+ onLoadMore?: () => void;
+ onNextPage?: OnNextPage;
+ onPageChange?: (page: number) => void | Promise;
+ onRowGroupExpand?: (props: OnRowGroupExpandProps) => void | Promise;
+ onRowSelectionChange?: (props: RowSelectionChangeProps) => void;
+ onSortChange?: (sort: SortColumn | null) => void;
+ quickFilter?: QuickFilterConfig;
+ rowButtons?: RowButton[];
+ rowGrouping?: Accessor[];
+ getRowId?: GetRowId;
+ rows: Row[];
+ rowsPerPage?: number;
+ selectableCells?: boolean;
+ selectableColumns?: boolean;
+ serverSidePagination?: boolean;
+ shouldPaginate?: boolean;
+ tableEmptyStateRenderer?: HTMLElement | string | null;
+ theme?: Theme;
+ totalRowCount?: number;
+ useHoverRowBackground?: boolean;
+ useOddColumnBackground?: boolean;
+ useOddEvenRowBackground?: boolean;
+}
diff --git a/src/types/SimpleTableProps.ts b/packages/core/src/types/SimpleTableProps.ts
similarity index 86%
rename from src/types/SimpleTableProps.ts
rename to packages/core/src/types/SimpleTableProps.ts
index 51c80377f..9d7f26205 100644
--- a/src/types/SimpleTableProps.ts
+++ b/packages/core/src/types/SimpleTableProps.ts
@@ -1,4 +1,4 @@
-import { MutableRefObject, ReactNode } from "react";
+import { TableAPI } from "./TableAPI";
import HeaderObject, { Accessor } from "./HeaderObject";
import Row from "./Row";
import {
@@ -26,7 +26,6 @@ import { IconsConfig } from "./IconsConfig";
import { QuickFilterConfig } from "./QuickFilterTypes";
export interface SimpleTableProps {
- allowAnimations?: boolean; // Flag for allowing animations
autoExpandColumns?: boolean; // Flag for converting pixel widths to proportional fr units that fill table width
canExpandRowGroup?: (row: Row) => boolean; // Function to conditionally control if a row group can be expanded
cellUpdateFlash?: boolean; // Flag for flash animation after cell update
@@ -47,14 +46,14 @@ export interface SimpleTableProps {
enableStickyParents?: boolean; // Flag for enabling sticky parent rows during scrolling in grouped tables (default: false)
errorStateRenderer?: ErrorStateRenderer; // Custom renderer for error states
expandAll?: boolean; // Flag for expanding all rows by default
- expandIcon?: ReactNode; // @deprecated Use icons.expand instead
+ expandIcon?: IconsConfig["expand"]; // @deprecated Use icons.expand instead
externalFilterHandling?: boolean; // Flag to let consumer handle filter logic completely
externalSortHandling?: boolean; // Flag to let consumer handle sort logic completely
- filterIcon?: ReactNode; // @deprecated Use icons.filter instead
- footerRenderer?: (props: FooterRendererProps) => ReactNode; // Custom footer renderer
- headerCollapseIcon?: ReactNode; // @deprecated Use icons.headerCollapse instead
+ filterIcon?: IconsConfig["filter"]; // @deprecated Use icons.filter instead
+ footerRenderer?: (props: FooterRendererProps) => HTMLElement | string | null; // Custom footer renderer
+ headerCollapseIcon?: IconsConfig["headerCollapse"]; // @deprecated Use icons.headerCollapse instead
headerDropdown?: HeaderDropdown; // Custom dropdown component for headers
- headerExpandIcon?: ReactNode; // @deprecated Use icons.headerExpand instead
+ headerExpandIcon?: IconsConfig["headerExpand"]; // @deprecated Use icons.headerExpand instead
height?: string | number; // Height of the table
hideFooter?: boolean; // Flag for hiding the footer
hideHeader?: boolean; // Flag for hiding the header
@@ -65,7 +64,7 @@ export interface SimpleTableProps {
isLoading?: boolean; // Flag for showing loading skeleton state
loadingStateRenderer?: LoadingStateRenderer; // Custom renderer for loading states
maxHeight?: string | number; // Maximum height of the table (enables adaptive height with virtualization)
- nextIcon?: ReactNode; // @deprecated Use icons.next instead
+ nextIcon?: IconsConfig["next"]; // @deprecated Use icons.next instead
onCellClick?: (props: CellClickProps) => void;
onCellEdit?: (props: CellChangeProps) => void;
onColumnOrderChange?: (newHeaders: HeaderObject[]) => void;
@@ -81,7 +80,7 @@ export interface SimpleTableProps {
onRowGroupExpand?: (props: OnRowGroupExpandProps) => void | Promise; // Callback when a row is expanded/collapsed
onRowSelectionChange?: (props: RowSelectionChangeProps) => void; // Callback when row selection changes
onSortChange?: (sort: SortColumn | null) => void; // Callback when sort is applied
- prevIcon?: ReactNode; // @deprecated Use icons.prev instead
+ prevIcon?: IconsConfig["prev"]; // @deprecated Use icons.prev instead
quickFilter?: QuickFilterConfig; // Global search configuration across all columns
rowButtons?: RowButton[]; // Array of buttons to show in each row
rowGrouping?: Accessor[]; // Array of property names that define row grouping hierarchy
@@ -92,10 +91,10 @@ export interface SimpleTableProps {
selectableColumns?: boolean; // Flag for selectable column headers
serverSidePagination?: boolean; // Flag to disable internal pagination slicing (for server-side pagination)
shouldPaginate?: boolean; // Flag for pagination
- sortDownIcon?: ReactNode; // @deprecated Use icons.sortDown instead
- sortUpIcon?: ReactNode; // @deprecated Use icons.sortUp instead
- tableEmptyStateRenderer?: ReactNode; // Custom empty state component when table has no rows
- tableRef?: MutableRefObject;
+ sortDownIcon?: IconsConfig["sortDown"]; // @deprecated Use icons.sortDown instead
+ sortUpIcon?: IconsConfig["sortUp"]; // @deprecated Use icons.sortUp instead
+ tableEmptyStateRenderer?: HTMLElement | string | null; // Custom empty state component when table has no rows
+ tableRef?: { current: TableRefType | null };
theme?: Theme; // Theme
totalRowCount?: number; // Total number of rows on server (for server-side pagination)
useHoverRowBackground?: boolean; // Flag for using hover row background
diff --git a/src/types/SortColumn.ts b/packages/core/src/types/SortColumn.ts
similarity index 100%
rename from src/types/SortColumn.ts
rename to packages/core/src/types/SortColumn.ts
diff --git a/packages/core/src/types/TableAPI.ts b/packages/core/src/types/TableAPI.ts
new file mode 100644
index 000000000..1db86eede
--- /dev/null
+++ b/packages/core/src/types/TableAPI.ts
@@ -0,0 +1,55 @@
+import UpdateDataProps from "./UpdateCellProps";
+import HeaderObject, { Accessor } from "./HeaderObject";
+import TableRow from "./TableRow";
+import SortColumn, { SortDirection } from "./SortColumn";
+import { TableFilterState, FilterCondition } from "./FilterTypes";
+import Cell from "./Cell";
+import type { PinnedSectionsState } from "./PinnedSectionsState";
+
+export interface SetHeaderRenameProps {
+ accessor: Accessor;
+}
+
+export interface ExportToCSVProps {
+ filename?: string;
+}
+
+export type TableAPI = {
+ updateData: (props: UpdateDataProps) => void;
+ setHeaderRename: (props: SetHeaderRenameProps) => void;
+ getVisibleRows: () => TableRow[];
+ getAllRows: () => TableRow[];
+ getHeaders: () => HeaderObject[];
+ exportToCSV: (props?: ExportToCSVProps) => void;
+ getSortState: () => SortColumn | null;
+ applySortState: (props?: { accessor: Accessor; direction?: SortDirection }) => Promise