From 97a58e576eac1f54f478bade821acffe7fff9549 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 3 May 2026 11:57:21 -0500 Subject: [PATCH 1/5] Pinned column resize fix --- .../core/src/managers/DragHandlerManager.ts | 28 ++++++-- .../core/src/utils/headerCell/dragging.ts | 4 +- .../core/src/utils/headerCell/resizing.ts | 71 +++++++++++-------- packages/core/src/utils/resizeUtils/index.ts | 7 +- .../examples/sales-example/SalesExample.ts | 2 +- 5 files changed, 70 insertions(+), 42 deletions(-) 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..333dda526 100644 --- a/packages/core/src/utils/headerCell/resizing.ts +++ b/packages/core/src/utils/headerCell/resizing.ts @@ -1,5 +1,5 @@ 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 { @@ -7,10 +7,7 @@ import { 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 +18,32 @@ const getStyleRoot = (context: HeaderRenderContext): ParentNode | null => { return main.closest(".simple-table-root") ?? main; }; +/** Resize handlers must mutate the live table tree, not a stale `context.headers` snapshot. */ +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; +}; + +const resolveLiveResizeHeader = ( + context: HeaderRenderContext, + headerFromCell: HeaderObject, +): HeaderObject => { + return findHeaderInTree(context.getHeaders(), headerFromCell.accessor) ?? headerFromCell; +}; + 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; @@ -53,14 +68,16 @@ export const createResizeHandle = ( }); const performAutoFit = () => { + const liveHeaders = context.getHeaders(); + const liveHeader = resolveLiveResizeHeader(context, header); const headerCell = document.getElementById( getCellId({ accessor: header.accessor, rowId: "header" }), ); if (context.autoExpandColumns) { applyColumnAutoFitWithAutoExpand({ - header, - headers: context.headers, + header: liveHeader, + headers: liveHeaders, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, mainBodyRef: context.mainBodyRef, @@ -69,7 +86,7 @@ export const createResizeHandle = ( getTargetLeafWidth: (leafHeader) => calculateHeaderContentWidth(leafHeader.accessor, measureOptions(leafHeader)), }); - const next = [...context.headers]; + const next = [...liveHeaders]; context.setHeaders(next); if (context.onColumnWidthChange) { context.onColumnWidthChange(next); @@ -79,21 +96,17 @@ export const createResizeHandle = ( const contentWidth = calculateHeaderContentWidth(header.accessor, measureOptions(header)); - const path = getHeaderIndexPath(context.headers, header.accessor); + const path = getHeaderIndexPath(liveHeaders, liveHeader.accessor); if (!path) return; - const siblings = getSiblingArray(context.headers, path); + const siblings = getSiblingArray(liveHeaders, path); const headerIndex = path[path.length - 1]; const updatedSiblings = siblings.map((h, i) => i === headerIndex ? { ...h, width: contentWidth } : h, ); - const updatedHeaders = setSiblingArray( - context.headers, - path, - updatedSiblings, - ); + const updatedHeaders = setSiblingArray(liveHeaders, path, updatedSiblings); updatedHeaders.forEach((h) => removeAllFractionalWidths(h)); @@ -131,14 +144,16 @@ export const createResizeHandle = ( )?.offsetWidth; throttle(() => { + const liveHeaders = context.getHeaders(); + const liveHeader = resolveLiveResizeHeader(context, header); handleResizeStart({ autoExpandColumns: context.autoExpandColumns, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, event: event, forceUpdate: context.forceUpdate, - header, - headers: context.headers, + header: liveHeader, + headers: liveHeaders, mainBodyRef: context.mainBodyRef, onColumnWidthChange: context.onColumnWidthChange, pinnedLeftRef: context.pinnedLeftRef, @@ -151,11 +166,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 +175,16 @@ export const createResizeHandle = ( )?.offsetWidth; throttle(() => { + const liveHeaders = context.getHeaders(); + const liveHeader = resolveLiveResizeHeader(context, header); handleResizeStart({ autoExpandColumns: context.autoExpandColumns, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, event: touchEvent as any, forceUpdate: context.forceUpdate, - header, - headers: context.headers, + header: liveHeader, + headers: liveHeaders, mainBodyRef: context.mainBodyRef, onColumnWidthChange: context.onColumnWidthChange, pinnedLeftRef: context.pinnedLeftRef, @@ -186,11 +199,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/index.ts b/packages/core/src/utils/resizeUtils/index.ts index 297ef35fe..a84a7a411 100644 --- a/packages/core/src/utils/resizeUtils/index.ts +++ b/packages/core/src/utils/resizeUtils/index.ts @@ -157,7 +157,10 @@ export const handleResizeStart = ({ (rootPinned === "right" && idx === 0); } - if (atBoundary) { + // Proportional main scaling is for multi-column pinned strips. A single pinned + // leaf has no pinned siblings to balance; scaling every main column looks wrong + // and destabilizes layouts (e.g. one narrow root wrongly left-pinned beside a wide group in main). + if (atBoundary && sectionLeafs.length >= 2) { isBoundaryResize = true; const mainHeaders = headers.filter((h) => !h.pinned); mainLeafHeaders = getAllVisibleLeafHeaders(mainHeaders, collapsedHeaders); @@ -475,7 +478,7 @@ export const applyColumnAutoFitWithAutoExpand = ({ (rootPinned === "right" && idx === 0); } - if (atBoundary) { + if (atBoundary && sectionLeafs.length >= 2) { isBoundaryResize = true; const mainHeaders = headers.filter((h) => !h.pinned); mainLeafHeaders = getAllVisibleLeafHeaders(mainHeaders, collapsedHeaders); diff --git a/packages/core/stories/examples/sales-example/SalesExample.ts b/packages/core/stories/examples/sales-example/SalesExample.ts index 405c59731..51bcdfc03 100644 --- a/packages/core/stories/examples/sales-example/SalesExample.ts +++ b/packages/core/stories/examples/sales-example/SalesExample.ts @@ -14,9 +14,9 @@ export const salesExampleDefaults = { columnReordering: true, selectableCells: true, autoExpandColumns: true, - enableRowSelection: true, theme: "modern-dark" as const, height: "70dvh", + editColumns: true, }; export function renderSalesExample(args?: Partial): HTMLElement { From 698f4395273fedaf1bef9249fd767e9a2950c3b6 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 3 May 2026 12:32:55 -0500 Subject: [PATCH 2/5] Columns in pinned section resize fixes --- .../src/utils/resizeUtils/autoExpandResize.ts | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/core/src/utils/resizeUtils/autoExpandResize.ts b/packages/core/src/utils/resizeUtils/autoExpandResize.ts index 551142803..1f19896e2 100644 --- a/packages/core/src/utils/resizeUtils/autoExpandResize.ts +++ b/packages/core/src/utils/resizeUtils/autoExpandResize.ts @@ -37,28 +37,28 @@ 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 deltas only when they would grow the pinned section's total width. + * Do not apply before redistributive resizes (neighbors shrink), where the net total stays the same. + */ + const clampPinnedDeltaIfNetSectionGrows = (positiveGrowDelta: number): number => { + if (!rootPinned || containerWidth <= 0 || positiveGrowDelta <= 0) { + return positiveGrowDelta; + } 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 maxSectionWidth = getMaxPinnedSectionWidth( + containerWidth, + hasPinnedLeft, + hasPinnedRight, + ); + const headroom = Math.max(0, maxSectionWidth - pinnedSectionWidthSum()); + return Math.min(positiveGrowDelta, headroom); + }; // Special handling for parent header resize (multiple children) if (isParentResize && childrenToResize.length > 1) { @@ -133,6 +133,10 @@ export const handleResizeWithAutoExpand = ({ } } + if (delta > 0 && !needsCompensation) { + actualDelta = clampPinnedDeltaIfNetSectionGrows(actualDelta); + } + // Resize all children proportionally const totalOriginalWidth = childrenToResize.reduce((sum, child) => { return sum + (initialWidthsMap.get(child.accessor as string) || 100); @@ -230,7 +234,8 @@ 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 ? clampPinnedDeltaIfNetSectionGrows(delta) : delta; + resizedHeader.width = Math.max(startWidth + appliedBoundaryDelta, minWidth); return; } @@ -252,7 +257,8 @@ export const handleResizeWithAutoExpand = ({ if (newTotalWidthIfNoCompensation <= effectiveSectionWidth) { // We have room to grow without shrinking others - resizedHeader.width = startWidth + delta; + const appliedRoomDelta = clampPinnedDeltaIfNetSectionGrows(delta); + resizedHeader.width = startWidth + appliedRoomDelta; return; } From b2f78d499cbd48b10014a4ff1cf8a5fb613da861 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 9 May 2026 22:56:46 -0500 Subject: [PATCH 3/5] More resize fixes --- .../core/src/utils/headerCell/resizing.ts | 67 ++++++++++++------- .../src/utils/resizeUtils/autoExpandResize.ts | 21 +++--- packages/core/src/utils/resizeUtils/index.ts | 7 +- .../examples/sales-example/SalesExample.ts | 1 - .../examples/sales-example/sales-headers.ts | 2 + 5 files changed, 60 insertions(+), 38 deletions(-) diff --git a/packages/core/src/utils/headerCell/resizing.ts b/packages/core/src/utils/headerCell/resizing.ts index 333dda526..eab64807d 100644 --- a/packages/core/src/utils/headerCell/resizing.ts +++ b/packages/core/src/utils/headerCell/resizing.ts @@ -1,7 +1,11 @@ import { TABLE_HEADER_CELL_WIDTH_DEFAULT } from "../../consts/general-consts"; import HeaderObject, { Accessor } from "../../types/HeaderObject"; import { getCellId } from "../cellUtils"; -import { calculateHeaderContentWidth, removeAllFractionalWidths } from "../headerWidthUtils"; +import { + calculateHeaderContentWidth, + getAllVisibleLeafHeaders, + removeAllFractionalWidths, +} from "../headerWidthUtils"; import { getHeaderIndexPath, getSiblingArray, @@ -18,7 +22,6 @@ const getStyleRoot = (context: HeaderRenderContext): ParentNode | null => { return main.closest(".simple-table-root") ?? main; }; -/** Resize handlers must mutate the live table tree, not a stale `context.headers` snapshot. */ const findHeaderInTree = (roots: HeaderObject[], accessor: Accessor): HeaderObject | undefined => { for (const h of roots) { if (h.accessor === accessor) return h; @@ -30,11 +33,21 @@ const findHeaderInTree = (roots: HeaderObject[], accessor: Accessor): HeaderObje return undefined; }; -const resolveLiveResizeHeader = ( - context: HeaderRenderContext, - headerFromCell: HeaderObject, -): HeaderObject => { - return findHeaderInTree(context.getHeaders(), headerFromCell.accessor) ?? headerFromCell; +/** 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 = ( @@ -67,17 +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 liveHeaders = context.getHeaders(); - const liveHeader = resolveLiveResizeHeader(context, header); const headerCell = document.getElementById( getCellId({ accessor: header.accessor, rowId: "header" }), ); if (context.autoExpandColumns) { + const { headers: resizeHeaders, header: resizeHeader } = resolveResizeHeaders(); applyColumnAutoFitWithAutoExpand({ - header: liveHeader, - headers: liveHeaders, + header: resizeHeader, + headers: resizeHeaders, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, mainBodyRef: context.mainBodyRef, @@ -86,7 +109,7 @@ export const createResizeHandle = ( getTargetLeafWidth: (leafHeader) => calculateHeaderContentWidth(leafHeader.accessor, measureOptions(leafHeader)), }); - const next = [...liveHeaders]; + const next = [...resizeHeaders]; context.setHeaders(next); if (context.onColumnWidthChange) { context.onColumnWidthChange(next); @@ -96,17 +119,17 @@ export const createResizeHandle = ( const contentWidth = calculateHeaderContentWidth(header.accessor, measureOptions(header)); - const path = getHeaderIndexPath(liveHeaders, liveHeader.accessor); + const path = getHeaderIndexPath(context.headers, header.accessor); if (!path) return; - const siblings = getSiblingArray(liveHeaders, path); + const siblings = getSiblingArray(context.headers, path); const headerIndex = path[path.length - 1]; const updatedSiblings = siblings.map((h, i) => i === headerIndex ? { ...h, width: contentWidth } : h, ); - const updatedHeaders = setSiblingArray(liveHeaders, path, updatedSiblings); + const updatedHeaders = setSiblingArray(context.headers, path, updatedSiblings); updatedHeaders.forEach((h) => removeAllFractionalWidths(h)); @@ -144,16 +167,15 @@ export const createResizeHandle = ( )?.offsetWidth; throttle(() => { - const liveHeaders = context.getHeaders(); - const liveHeader = resolveLiveResizeHeader(context, header); + const { headers: resizeHeaders, header: resizeHeader } = resolveResizeHeaders(); handleResizeStart({ autoExpandColumns: context.autoExpandColumns, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, event: event, forceUpdate: context.forceUpdate, - header: liveHeader, - headers: liveHeaders, + header: resizeHeader, + headers: resizeHeaders, mainBodyRef: context.mainBodyRef, onColumnWidthChange: context.onColumnWidthChange, pinnedLeftRef: context.pinnedLeftRef, @@ -175,16 +197,15 @@ export const createResizeHandle = ( )?.offsetWidth; throttle(() => { - const liveHeaders = context.getHeaders(); - const liveHeader = resolveLiveResizeHeader(context, header); + const { headers: resizeHeaders, header: resizeHeader } = resolveResizeHeaders(); handleResizeStart({ autoExpandColumns: context.autoExpandColumns, collapsedHeaders: context.collapsedHeaders, containerWidth: context.containerWidth, event: touchEvent as any, forceUpdate: context.forceUpdate, - header: liveHeader, - headers: liveHeaders, + header: resizeHeader, + headers: resizeHeaders, mainBodyRef: context.mainBodyRef, onColumnWidthChange: context.onColumnWidthChange, pinnedLeftRef: context.pinnedLeftRef, diff --git a/packages/core/src/utils/resizeUtils/autoExpandResize.ts b/packages/core/src/utils/resizeUtils/autoExpandResize.ts index 1f19896e2..a03f1c81a 100644 --- a/packages/core/src/utils/resizeUtils/autoExpandResize.ts +++ b/packages/core/src/utils/resizeUtils/autoExpandResize.ts @@ -42,12 +42,13 @@ export const handleResizeWithAutoExpand = ({ Array.from(initialWidthsMap.values()).reduce((a, b) => a + b, 0); /** - * Cap positive deltas only when they would grow the pinned section's total width. - * Do not apply before redistributive resizes (neighbors shrink), where the net total stays the same. + * 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 clampPinnedDeltaIfNetSectionGrows = (positiveGrowDelta: number): number => { - if (!rootPinned || containerWidth <= 0 || positiveGrowDelta <= 0) { - return positiveGrowDelta; + 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); @@ -57,7 +58,7 @@ export const handleResizeWithAutoExpand = ({ hasPinnedRight, ); const headroom = Math.max(0, maxSectionWidth - pinnedSectionWidthSum()); - return Math.min(positiveGrowDelta, headroom); + return Math.min(positiveDelta, headroom); }; // Special handling for parent header resize (multiple children) @@ -134,7 +135,7 @@ export const handleResizeWithAutoExpand = ({ } if (delta > 0 && !needsCompensation) { - actualDelta = clampPinnedDeltaIfNetSectionGrows(actualDelta); + actualDelta = clampPinnedPositiveDeltaIfNetSectionGrows(actualDelta); } // Resize all children proportionally @@ -234,7 +235,8 @@ 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; - const appliedBoundaryDelta = delta > 0 ? clampPinnedDeltaIfNetSectionGrows(delta) : delta; + const appliedBoundaryDelta = + delta > 0 ? clampPinnedPositiveDeltaIfNetSectionGrows(delta) : delta; resizedHeader.width = Math.max(startWidth + appliedBoundaryDelta, minWidth); return; } @@ -257,7 +259,8 @@ export const handleResizeWithAutoExpand = ({ if (newTotalWidthIfNoCompensation <= effectiveSectionWidth) { // We have room to grow without shrinking others - const appliedRoomDelta = clampPinnedDeltaIfNetSectionGrows(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 a84a7a411..297ef35fe 100644 --- a/packages/core/src/utils/resizeUtils/index.ts +++ b/packages/core/src/utils/resizeUtils/index.ts @@ -157,10 +157,7 @@ export const handleResizeStart = ({ (rootPinned === "right" && idx === 0); } - // Proportional main scaling is for multi-column pinned strips. A single pinned - // leaf has no pinned siblings to balance; scaling every main column looks wrong - // and destabilizes layouts (e.g. one narrow root wrongly left-pinned beside a wide group in main). - if (atBoundary && sectionLeafs.length >= 2) { + if (atBoundary) { isBoundaryResize = true; const mainHeaders = headers.filter((h) => !h.pinned); mainLeafHeaders = getAllVisibleLeafHeaders(mainHeaders, collapsedHeaders); @@ -478,7 +475,7 @@ export const applyColumnAutoFitWithAutoExpand = ({ (rootPinned === "right" && idx === 0); } - if (atBoundary && sectionLeafs.length >= 2) { + if (atBoundary) { isBoundaryResize = true; const mainHeaders = headers.filter((h) => !h.pinned); mainLeafHeaders = getAllVisibleLeafHeaders(mainHeaders, collapsedHeaders); diff --git a/packages/core/stories/examples/sales-example/SalesExample.ts b/packages/core/stories/examples/sales-example/SalesExample.ts index 51bcdfc03..93b675bff 100644 --- a/packages/core/stories/examples/sales-example/SalesExample.ts +++ b/packages/core/stories/examples/sales-example/SalesExample.ts @@ -9,7 +9,6 @@ import { SALES_HEADERS } from "./sales-headers"; import salesData from "./sales-data.json"; export const salesExampleDefaults = { - animations: { enabled: true }, columnResizing: true, columnReordering: true, selectableCells: true, 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, From d35f49a2ca5302118590858b732ea2b67f60f92a Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 9 May 2026 23:19:39 -0500 Subject: [PATCH 4/5] Max pinned section width fix --- .../src/utils/resizeUtils/autoExpandResize.ts | 13 +++-- packages/core/src/utils/resizeUtils/index.ts | 51 ++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/resizeUtils/autoExpandResize.ts b/packages/core/src/utils/resizeUtils/autoExpandResize.ts index a03f1c81a..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; @@ -52,11 +55,16 @@ export const handleResizeWithAutoExpand = ({ } const hasPinnedLeft = headers.some((h) => h.pinned === "left" && !h.hide); const hasPinnedRight = headers.some((h) => h.pinned === "right" && !h.hide); - const maxSectionWidth = getMaxPinnedSectionWidth( + 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); }; @@ -259,8 +267,7 @@ export const handleResizeWithAutoExpand = ({ if (newTotalWidthIfNoCompensation <= effectiveSectionWidth) { // We have room to grow without shrinking others - const appliedRoomDelta = - delta > 0 ? clampPinnedPositiveDeltaIfNetSectionGrows(delta) : 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, From 42aa2ab45b3e9bb49f2bf0505a0f460691234c0e Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 9 May 2026 23:24:52 -0500 Subject: [PATCH 5/5] Changelog --- apps/marketing/src/constants/changelog.ts | 25 +++++++++++++++++++++++ packages/angular/package.json | 2 +- packages/core/package.json | 2 +- packages/react/package.json | 2 +- packages/solid/package.json | 2 +- packages/svelte/package.json | 2 +- packages/vue/package.json | 2 +- 7 files changed, 31 insertions(+), 6 deletions(-) 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/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",