diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index 7588d063c..be93319ec 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -10,6 +10,30 @@ export interface ChangelogEntry { link?: string; }[]; } +export const v3_5_3: ChangelogEntry = { + version: "3.5.3", + date: "2026-05-09", + title: "Pinned & auto-expand resize fixes", + description: "Fixes for nested pinned headers, auto-expand resize math, and viewport-based width caps.", + changes: [ + { + type: "bugfix", + description: + "Column drag treats nested headers under a pinned parent as pinned (section detection).", + }, + { + type: "bugfix", + description: + "Auto-expand resize syncs leaf widths from the DOM and uses storage headers so drag math matches layout.", + }, + { + type: "bugfix", + description: + "Pinned/main auto-expand width caps use the real pinned strip and main body viewports; positive growth clamps only when the section actually widens.", + }, + ], +}; + export const v3_5_2: ChangelogEntry = { version: "3.5.2", date: "2026-05-03", @@ -1653,6 +1677,7 @@ export const v1_4_4: ChangelogEntry = { // Array of all changelog entries (newest first) export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + v3_5_3, v3_5_2, v3_4_2, v3_4_0, diff --git a/packages/angular/package.json b/packages/angular/package.json index 075ea48e2..b7a5255f2 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "3.5.2", + "version": "3.5.3", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/angular/src/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 13e8ab178..41fa1685b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "3.5.2", + "version": "3.5.3", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/src/index.d.ts", diff --git a/packages/core/src/managers/DragHandlerManager.ts b/packages/core/src/managers/DragHandlerManager.ts index 3797d79c3..f611daa91 100644 --- a/packages/core/src/managers/DragHandlerManager.ts +++ b/packages/core/src/managers/DragHandlerManager.ts @@ -1,7 +1,9 @@ import HeaderObject, { Accessor } from "../types/HeaderObject"; +import type { Pinned } from "../types/Pinned"; import { deepClone } from "../utils/generalUtils"; import PreviousValueTracker from "../hooks/previousValue"; import { validateFullHeaderTreeEssentialOrder } from "../utils/pinnedColumnUtils"; +import { findParentHeader } from "../utils/collapseUtils"; const REVERT_TO_PREVIOUS_HEADERS_DELAY = 1500; @@ -47,9 +49,23 @@ export const setSiblingArray = ( return headers; }; -export const getHeaderSection = (header: HeaderObject): "left" | "main" | "right" => { - if (header.pinned === "left") return "left"; - if (header.pinned === "right") return "right"; +/** Pinned side of the root column that owns this header (nested leaves inherit parent pin). */ +const getRootPinnedForSection = ( + header: HeaderObject, + rootHeaders: HeaderObject[], +): Pinned | undefined => { + if (header.pinned) return header.pinned; + const parent = findParentHeader(rootHeaders, header.accessor); + return parent ? getRootPinnedForSection(parent, rootHeaders) : undefined; +}; + +export const getHeaderSection = ( + header: HeaderObject, + rootHeaders: HeaderObject[], +): "left" | "main" | "right" => { + const p = getRootPinnedForSection(header, rootHeaders); + if (p === "left") return "left"; + if (p === "right") return "right"; return "main"; }; @@ -121,7 +137,7 @@ export function insertHeaderAcrossSections({ let emergencyBreak = false; try { - const hoveredSection = getHeaderSection(hoveredHeader); + const hoveredSection = getHeaderSection(hoveredHeader, newHeaders); const draggedIndex = newHeaders.findIndex((h) => h.accessor === draggedHeader.accessor); const hoveredIndex = newHeaders.findIndex((h) => h.accessor === hoveredHeader.accessor); @@ -213,8 +229,8 @@ export class DragHandlerManager { const draggedHeader = this.draggedHeader; - const draggedSection = getHeaderSection(draggedHeader); - const hoveredSection = getHeaderSection(hoveredHeader); + const draggedSection = getHeaderSection(draggedHeader, this.config.headers); + const hoveredSection = getHeaderSection(hoveredHeader, this.config.headers); const isCrossSectionDrag = draggedSection !== hoveredSection; let newHeaders: HeaderObject[]; diff --git a/packages/core/src/utils/headerCell/dragging.ts b/packages/core/src/utils/headerCell/dragging.ts index 36a2deee7..520ca050d 100644 --- a/packages/core/src/utils/headerCell/dragging.ts +++ b/packages/core/src/utils/headerCell/dragging.ts @@ -181,8 +181,8 @@ export const attachDragHandlers = ( const draggedHeader = draggedHeaderRef.current; if (!draggedHeader) return; - const draggedSection = getHeaderSection(draggedHeader); - const hoveredSection = getHeaderSection(header); + const draggedSection = getHeaderSection(draggedHeader, liveHeaders); + const hoveredSection = getHeaderSection(header, liveHeaders); const isCrossSectionDrag = draggedSection !== hoveredSection; let newHeaders: HeaderObject[]; diff --git a/packages/core/src/utils/headerCell/resizing.ts b/packages/core/src/utils/headerCell/resizing.ts index b253033ce..eab64807d 100644 --- a/packages/core/src/utils/headerCell/resizing.ts +++ b/packages/core/src/utils/headerCell/resizing.ts @@ -1,16 +1,17 @@ import { TABLE_HEADER_CELL_WIDTH_DEFAULT } from "../../consts/general-consts"; -import HeaderObject from "../../types/HeaderObject"; +import HeaderObject, { Accessor } from "../../types/HeaderObject"; import { getCellId } from "../cellUtils"; -import { calculateHeaderContentWidth, removeAllFractionalWidths } from "../headerWidthUtils"; +import { + calculateHeaderContentWidth, + getAllVisibleLeafHeaders, + removeAllFractionalWidths, +} from "../headerWidthUtils"; import { getHeaderIndexPath, getSiblingArray, setSiblingArray, } from "../../managers/DragHandlerManager"; -import { - applyColumnAutoFitWithAutoExpand, - handleResizeStart, -} from "../resizeUtils"; +import { applyColumnAutoFitWithAutoExpand, handleResizeStart } from "../resizeUtils"; import { updateColumnWidthsInDOM } from "../resizeUtils/domUpdates"; import { HeaderRenderContext } from "./types"; import { addTrackedEventListener, throttle } from "./eventTracking"; @@ -21,14 +22,41 @@ const getStyleRoot = (context: HeaderRenderContext): ParentNode | null => { return main.closest(".simple-table-root") ?? main; }; +const findHeaderInTree = (roots: HeaderObject[], accessor: Accessor): HeaderObject | undefined => { + for (const h of roots) { + if (h.accessor === accessor) return h; + if (h.children?.length) { + const found = findHeaderInTree(h.children, accessor); + if (found) return found; + } + } + return undefined; +}; + +/** Align storage `width` with painted layout so auto-expand resize math matches the viewport. */ +const syncVisibleLeafWidthsFromDom = ( + roots: HeaderObject[], + collapsedHeaders: Set | undefined, +): void => { + const leaves = getAllVisibleLeafHeaders(roots, collapsedHeaders); + for (const leaf of leaves) { + const cell = document.getElementById( + getCellId({ accessor: leaf.accessor, rowId: "header" }), + ); + const w = cell?.offsetWidth; + if (w != null && w > 0) { + leaf.width = w; + } + } +}; + export const createResizeHandle = ( header: HeaderObject, context: HeaderRenderContext, isLastMainAutoExpandColumn: boolean, ): HTMLElement | null => { const { columnResizing } = context; - const isSelectionColumn = - header.isSelectionColumn && context.enableRowSelection; + const isSelectionColumn = header.isSelectionColumn && context.enableRowSelection; if (!columnResizing || isSelectionColumn || isLastMainAutoExpandColumn) { return null; @@ -52,15 +80,27 @@ export const createResizeHandle = ( styleRoot: getStyleRoot(context), }); + /** Auto-expand: mutate canonical storage + DOM-synced widths. Otherwise: effective tree (matches main). */ + const resolveResizeHeaders = (): { headers: HeaderObject[]; header: HeaderObject } => { + if (context.autoExpandColumns) { + const storage = context.getHeaders(); + syncVisibleLeafWidthsFromDom(storage, context.collapsedHeaders); + const storageHeader = findHeaderInTree(storage, header.accessor) ?? header; + return { headers: storage, header: storageHeader }; + } + return { headers: context.headers, header }; + }; + const performAutoFit = () => { const headerCell = document.getElementById( getCellId({ accessor: header.accessor, rowId: "header" }), ); if (context.autoExpandColumns) { + const { headers: resizeHeaders, header: resizeHeader } = resolveResizeHeaders(); applyColumnAutoFitWithAutoExpand({ - header, - headers: context.headers, + header: resizeHeader, + headers: resizeHeaders, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, mainBodyRef: context.mainBodyRef, @@ -69,7 +109,7 @@ export const createResizeHandle = ( getTargetLeafWidth: (leafHeader) => calculateHeaderContentWidth(leafHeader.accessor, measureOptions(leafHeader)), }); - const next = [...context.headers]; + const next = [...resizeHeaders]; context.setHeaders(next); if (context.onColumnWidthChange) { context.onColumnWidthChange(next); @@ -89,11 +129,7 @@ export const createResizeHandle = ( i === headerIndex ? { ...h, width: contentWidth } : h, ); - const updatedHeaders = setSiblingArray( - context.headers, - path, - updatedSiblings, - ); + const updatedHeaders = setSiblingArray(context.headers, path, updatedSiblings); updatedHeaders.forEach((h) => removeAllFractionalWidths(h)); @@ -131,14 +167,15 @@ export const createResizeHandle = ( )?.offsetWidth; throttle(() => { + const { headers: resizeHeaders, header: resizeHeader } = resolveResizeHeaders(); handleResizeStart({ autoExpandColumns: context.autoExpandColumns, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, event: event, forceUpdate: context.forceUpdate, - header, - headers: context.headers, + header: resizeHeader, + headers: resizeHeaders, mainBodyRef: context.mainBodyRef, onColumnWidthChange: context.onColumnWidthChange, pinnedLeftRef: context.pinnedLeftRef, @@ -151,11 +188,7 @@ export const createResizeHandle = ( }, 10); }; - addTrackedEventListener( - resizeContainer, - "mousedown", - handleMouseDown as EventListener, - ); + addTrackedEventListener(resizeContainer, "mousedown", handleMouseDown as EventListener); const handleTouchStart = (event: Event) => { const touchEvent = event as globalThis.TouchEvent; @@ -164,14 +197,15 @@ export const createResizeHandle = ( )?.offsetWidth; throttle(() => { + const { headers: resizeHeaders, header: resizeHeader } = resolveResizeHeaders(); handleResizeStart({ autoExpandColumns: context.autoExpandColumns, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, event: touchEvent as any, forceUpdate: context.forceUpdate, - header, - headers: context.headers, + header: resizeHeader, + headers: resizeHeaders, mainBodyRef: context.mainBodyRef, onColumnWidthChange: context.onColumnWidthChange, pinnedLeftRef: context.pinnedLeftRef, @@ -186,11 +220,7 @@ export const createResizeHandle = ( addTrackedEventListener(resizeContainer, "touchstart", handleTouchStart); - addTrackedEventListener( - resizeContainer, - "dblclick", - runAutoFitDebounced as EventListener, - ); + addTrackedEventListener(resizeContainer, "dblclick", runAutoFitDebounced as EventListener); return resizeContainer; }; diff --git a/packages/core/src/utils/resizeUtils/autoExpandResize.ts b/packages/core/src/utils/resizeUtils/autoExpandResize.ts index 551142803..c86f8ae43 100644 --- a/packages/core/src/utils/resizeUtils/autoExpandResize.ts +++ b/packages/core/src/utils/resizeUtils/autoExpandResize.ts @@ -16,6 +16,7 @@ export const handleResizeWithAutoExpand = ({ headers, initialWidthsMap, isParentResize = false, + pinnedBodyViewportWidth, resizedHeader, reverse, rootPinned, @@ -30,6 +31,8 @@ export const handleResizeWithAutoExpand = ({ headers: HeaderObject[]; initialWidthsMap: Map; isParentResize?: boolean; + /** When set, caps pinned growth so column sums do not exceed this scrollport (policy max can be wider). */ + pinnedBodyViewportWidth?: number; resizedHeader: HeaderObject; reverse: boolean; rootPinned: Pinned | undefined; @@ -37,28 +40,34 @@ export const handleResizeWithAutoExpand = ({ sectionWidth: number; startWidth: number; }): void => { - // For pinned sections, clamp delta to prevent exceeding max section width - // This prevents the drag from causing unwanted auto-scaling of other columns - let clampedDelta = delta; - if (rootPinned && containerWidth > 0) { - // Check if we have both pinned sections + /** Sum of leaf widths in this section at drag start (initialWidthsMap is section-scoped). */ + const pinnedSectionWidthSum = (): number => + Array.from(initialWidthsMap.values()).reduce((a, b) => a + b, 0); + + /** + * Cap positive delta only when it would grow the pinned section's *total* width past policy max. + * Do not apply before redistributive resizes (neighbors shrink): total stays the same, so clamping + * here would wrongly block growth when the section already sits at max width (e.g. after DOM sync). + */ + const clampPinnedPositiveDeltaIfNetSectionGrows = (positiveDelta: number): number => { + if (!rootPinned || containerWidth <= 0 || positiveDelta <= 0) { + return positiveDelta; + } const hasPinnedLeft = headers.some((h) => h.pinned === "left" && !h.hide); const hasPinnedRight = headers.some((h) => h.pinned === "right" && !h.hide); - - // Calculate the max allowed width for this pinned section - const maxSectionWidth = getMaxPinnedSectionWidth(containerWidth, hasPinnedLeft, hasPinnedRight); - - const currentSectionWidth = Array.from(initialWidthsMap.values()).reduce((a, b) => a + b, 0); - const newSectionWidth = currentSectionWidth + delta; - - // If growing beyond max section width, clamp the delta - if (delta > 0 && newSectionWidth > maxSectionWidth) { - clampedDelta = Math.max(0, maxSectionWidth - currentSectionWidth); - } - } - - // Use clamped delta for all calculations - delta = clampedDelta; + const policyMax = getMaxPinnedSectionWidth( + containerWidth, + hasPinnedLeft, + hasPinnedRight, + ); + const viewportCap = + pinnedBodyViewportWidth != null && pinnedBodyViewportWidth > 0 + ? pinnedBodyViewportWidth + : Number.POSITIVE_INFINITY; + const maxSectionWidth = Math.min(policyMax, viewportCap); + const headroom = Math.max(0, maxSectionWidth - pinnedSectionWidthSum()); + return Math.min(positiveDelta, headroom); + }; // Special handling for parent header resize (multiple children) if (isParentResize && childrenToResize.length > 1) { @@ -133,6 +142,10 @@ export const handleResizeWithAutoExpand = ({ } } + if (delta > 0 && !needsCompensation) { + actualDelta = clampPinnedPositiveDeltaIfNetSectionGrows(actualDelta); + } + // Resize all children proportionally const totalOriginalWidth = childrenToResize.reduce((sum, child) => { return sum + (initialWidthsMap.get(child.accessor as string) || 100); @@ -230,7 +243,9 @@ export const handleResizeWithAutoExpand = ({ // In this case, just resize normally to grow/shrink the pinned section itself // In autoExpandColumns mode, ignore header minWidth to prevent horizontal overflow const minWidth = MIN_COLUMN_WIDTH; - resizedHeader.width = Math.max(startWidth + delta, minWidth); + const appliedBoundaryDelta = + delta > 0 ? clampPinnedPositiveDeltaIfNetSectionGrows(delta) : delta; + resizedHeader.width = Math.max(startWidth + appliedBoundaryDelta, minWidth); return; } @@ -252,7 +267,8 @@ export const handleResizeWithAutoExpand = ({ if (newTotalWidthIfNoCompensation <= effectiveSectionWidth) { // We have room to grow without shrinking others - resizedHeader.width = startWidth + delta; + const appliedRoomDelta = delta > 0 ? clampPinnedPositiveDeltaIfNetSectionGrows(delta) : delta; + resizedHeader.width = startWidth + appliedRoomDelta; return; } diff --git a/packages/core/src/utils/resizeUtils/index.ts b/packages/core/src/utils/resizeUtils/index.ts index 297ef35fe..f0500cfb4 100644 --- a/packages/core/src/utils/resizeUtils/index.ts +++ b/packages/core/src/utils/resizeUtils/index.ts @@ -1,6 +1,7 @@ import type HeaderObject from "../../types/HeaderObject"; import type { Accessor } from "../../types/HeaderObject"; import type { HandleResizeStartProps } from "../../types/HandleResizeStartProps"; +import type { Pinned } from "../../types/Pinned"; import { findLeafHeaders, getHeaderWidthInPixels, @@ -34,6 +35,23 @@ const resolveContainerWidthForResize = ( return main.clientWidth; }; +/** Scrollport width of the pinned body strip (fits column sums without horizontal scroll). */ +const readPinnedBodyViewportWidth = ( + mainBodyRef: HandleResizeStartProps["mainBodyRef"], + rootPinned: Pinned | undefined, +): number | undefined => { + const main = mainBodyRef?.current; + if (!main || !rootPinned) return undefined; + const tableRoot = main.closest(".simple-table-root"); + if (!(tableRoot instanceof HTMLElement)) return undefined; + const sel = + rootPinned === "right" ? ".st-body-pinned-right" : ".st-body-pinned-left"; + const pinned = tableRoot.querySelector(sel); + return pinned instanceof HTMLElement && pinned.clientWidth > 0 + ? pinned.clientWidth + : undefined; +}; + /** * Handler for when resize dragging starts */ @@ -119,7 +137,20 @@ export const handleResizeStart = ({ sectionWidth = mainWidth; } - initialMainAvailable = effectiveContainerWidth - leftWidth - rightWidth; + const computedMainAvailable = Math.max( + 0, + effectiveContainerWidth - leftWidth - rightWidth, + ); + const mainViewportWidth = + mainBodyRef?.current != null && mainBodyRef.current.clientWidth > 0 + ? mainBodyRef.current.clientWidth + : 0; + // Pinned widths from the model + container width do not always match the real + // main scroll viewport (splitter, extra chrome). Prefer the DOM viewport when known. + initialMainAvailable = + mainViewportWidth > 0 + ? Math.min(computedMainAvailable, mainViewportWidth) + : computedMainAvailable; } } @@ -192,6 +223,10 @@ export const handleResizeStart = ({ headers, initialWidthsMap, isParentResize: childrenToResize.length > 1, + pinnedBodyViewportWidth: readPinnedBodyViewportWidth( + mainBodyRef, + rootPinned, + ), resizedHeader: headerToResize, reverse, rootPinned, @@ -439,7 +474,18 @@ export const applyColumnAutoFitWithAutoExpand = ({ sectionWidth = mainWidth; } - initialMainAvailable = effectiveContainerWidth - leftWidth - rightWidth; + const computedMainAvailable = Math.max( + 0, + effectiveContainerWidth - leftWidth - rightWidth, + ); + const mainViewportWidth = + mainBodyRef?.current != null && mainBodyRef.current.clientWidth > 0 + ? mainBodyRef.current.clientWidth + : 0; + initialMainAvailable = + mainViewportWidth > 0 + ? Math.min(computedMainAvailable, mainViewportWidth) + : computedMainAvailable; } let isBoundaryResize = false; @@ -510,6 +556,7 @@ export const applyColumnAutoFitWithAutoExpand = ({ headers, initialWidthsMap, isParentResize: childrenToResize.length > 1, + pinnedBodyViewportWidth: readPinnedBodyViewportWidth(mainBodyRef, rootPinned), resizedHeader: headerToResize, reverse, rootPinned, diff --git a/packages/core/stories/examples/sales-example/SalesExample.ts b/packages/core/stories/examples/sales-example/SalesExample.ts index 405c59731..93b675bff 100644 --- a/packages/core/stories/examples/sales-example/SalesExample.ts +++ b/packages/core/stories/examples/sales-example/SalesExample.ts @@ -9,14 +9,13 @@ import { SALES_HEADERS } from "./sales-headers"; import salesData from "./sales-data.json"; export const salesExampleDefaults = { - animations: { enabled: true }, columnResizing: true, columnReordering: true, selectableCells: true, autoExpandColumns: true, - enableRowSelection: true, theme: "modern-dark" as const, height: "70dvh", + editColumns: true, }; export function renderSalesExample(args?: Partial): HTMLElement { diff --git a/packages/core/stories/examples/sales-example/sales-headers.ts b/packages/core/stories/examples/sales-example/sales-headers.ts index 73e98228e..f06450778 100644 --- a/packages/core/stories/examples/sales-example/sales-headers.ts +++ b/packages/core/stories/examples/sales-example/sales-headers.ts @@ -6,6 +6,7 @@ import type { HeaderObject } from "../../../src/index"; export const SALES_HEADERS: HeaderObject[] = [ { + pinned: "left", accessor: "repName", label: "Sales Representative", width: "2fr", @@ -15,6 +16,7 @@ export const SALES_HEADERS: HeaderObject[] = [ type: "string", }, { + pinned: "left", accessor: "salesMetrics", label: "Sales Metrics", width: 600, diff --git a/packages/react/package.json b/packages/react/package.json index 9e1a236b6..3d253eba8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/react", - "version": "3.5.2", + "version": "3.5.3", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/react/src/index.d.ts", diff --git a/packages/solid/package.json b/packages/solid/package.json index 288bd1d0c..83427230f 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/solid", - "version": "3.5.2", + "version": "3.5.3", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/solid/src/index.d.ts", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index bdf0f815a..dea1ee095 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/svelte", - "version": "3.5.2", + "version": "3.5.3", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/vue/package.json b/packages/vue/package.json index 83c179b72..78b6c9f96 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/vue", - "version": "3.5.2", + "version": "3.5.3", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/vue/src/index.d.ts",