From 7ea0cb8655f229806e710be182703f35494b58cd Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 5 Aug 2026 22:42:21 +0200 Subject: [PATCH] Fix responsive DataGrid contraction --- docs/DATAGRID_SIZING_CONTRACT.md | 13 +++-- webui/src/components/table/DataGrid.tsx | 26 ++++++++-- webui/src/components/table/dataGridSizing.ts | 52 +++++++++++++++++--- webui/src/styles/tables.css | 2 + webui/tests/data-grid-sizing.test.ts | 51 +++++++++++++++++++ 5 files changed, 132 insertions(+), 12 deletions(-) diff --git a/docs/DATAGRID_SIZING_CONTRACT.md b/docs/DATAGRID_SIZING_CONTRACT.md index 103d8e1..c943543 100644 --- a/docs/DATAGRID_SIZING_CONTRACT.md +++ b/docs/DATAGRID_SIZING_CONTRACT.md @@ -36,7 +36,8 @@ the column remains stopped until the pointer crosses the same boundary again. ## Persistence -Only the pixel layout resulting from an explicit user resize is persisted. +Only the pixel layout resulting from an explicit user resize is persisted, +together with the container width at which the user selected it. Persisted widths are keyed by a signature containing column IDs, declared widths and bounds, resize affordances, sticky placement, initial fit, and resize behavior. A changed signature discards the old override and recomputes the @@ -44,8 +45,13 @@ declared layout. Container reconciliation is suspended while a pointer drag is active. On release, the already-rendered pixel layout becomes the persisted preference. -Reconciliation may grow it to prevent underflow, but never shrinks intentional -user overflow, so there is no drag-end snap. +Reconciliation at that same container width never shrinks intentional user +overflow, so there is no drag-end snap. If the surrounding layout later +contracts, persisted tracks may shrink toward their hard minima. The layout +retains only the amount of horizontal overflow deliberately created by the +user; an exact-cover layout therefore remains exact-cover at narrower widths. +Legacy snapshots from the former hard-pixel persistence contract are discarded +once and recomputed from the declared column layout. ## Regression Matrix @@ -56,6 +62,7 @@ user overflow, so there is no drag-end snap. - hard-minimum horizontal overflow; - fixed-only cover grids; - persisted overrides under growth and viewport pressure; +- responsive contraction of persisted layouts without losing deliberate overflow; - stale layout signatures; - first and middle-column right-side compensation; - last-resizable-column overflow, underflow stop, and reverse-pointer boundary; diff --git a/webui/src/components/table/DataGrid.tsx b/webui/src/components/table/DataGrid.tsx index 0f1ac02..8ae7102 100644 --- a/webui/src/components/table/DataGrid.tsx +++ b/webui/src/components/table/DataGrid.tsx @@ -116,6 +116,8 @@ type DataGridState = { widths?: Record; /** Pixel widths explicitly selected by a user with a resize handle. */ userWidths?: Record; + /** Container width at which the current user layout was selected. */ + userLayoutContainerWidth?: number; layoutSignature?: string; /** Legacy layout state; removed when persisted state is sanitized. */ fillColumnId?: string | null; @@ -197,6 +199,7 @@ type ColumnResizeState = { baseWidths: Record; uncompensatedShrinkRoom: number; behavior: DataGridResizeBehavior; + containerWidth: number; }; const STORAGE_PREFIX = "govoplan.datagrid."; @@ -371,7 +374,8 @@ export default function DataGrid({ nextContainerWidth, measuredWidths, userWidths, - effectiveResizeBehavior + effectiveResizeBehavior, + current.userLayoutContainerWidth ); if ( signatureMatches @@ -428,6 +432,7 @@ export default function DataGrid({ [activeResize.columnId]: resized.widths[activeResize.columnId] } : resized.widths, + userLayoutContainerWidth: activeResize.containerWidth, layoutSignature, fillColumnId: undefined })); @@ -681,7 +686,8 @@ export default function DataGrid({ startX: event.clientX, baseWidths, uncompensatedShrinkRoom: shrinkRoomWithoutScroll, - behavior: effectiveResizeBehavior + behavior: effectiveResizeBehavior, + containerWidth: Math.max(1, scrollElement?.clientWidth ?? 0) }); }}> @@ -1148,11 +1154,17 @@ function loadState(key: string, layoutSignature: string): DataGridState { if (!value) return { layoutSignature }; const parsed = JSON.parse(value) as DataGridState; const userWidths = dataGridWidthsForLayout(parsed.layoutSignature, layoutSignature, parsed.widths); + const userLayoutContainerWidth = userWidths + && Number.isFinite(parsed.userLayoutContainerWidth) + && (parsed.userLayoutContainerWidth ?? 0) > 0 + ? parsed.userLayoutContainerWidth + : undefined; return { sort: parsed.sort, filters: parsed.filters, widths: userWidths, userWidths, + userLayoutContainerWidth, layoutSignature }; } catch { @@ -1165,6 +1177,7 @@ function persistedState(state: DataGridState, layoutSignature: string): DataGrid sort: state.sort, filters: state.filters, widths: state.userWidths, + userLayoutContainerWidth: state.userWidths ? state.userLayoutContainerWidth : undefined, layoutSignature }; } @@ -1277,15 +1290,22 @@ layoutSignature: string) const nextUserWidths = signatureMatches ? sanitizeWidths(state.userWidths) : {}; const normalizedWidths = Object.keys(nextWidths).length > 0 ? roundWidthRecord(nextWidths) : undefined; const normalizedUserWidths = Object.keys(nextUserWidths).length > 0 ? roundWidthRecord(nextUserWidths) : undefined; + const normalizedUserLayoutContainerWidth = normalizedUserWidths + && Number.isFinite(state.userLayoutContainerWidth) + && (state.userLayoutContainerWidth ?? 0) > 0 + ? Math.round(state.userLayoutContainerWidth ?? 0) + : undefined; const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, normalizedWidths ?? {}); const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {}); const fillChanged = state.fillColumnId !== undefined; const signatureChanged = state.layoutSignature !== layoutSignature; - if (!widthsChanged && !userWidthsChanged && !fillChanged && !signatureChanged) return state; + const userContainerChanged = state.userLayoutContainerWidth !== normalizedUserLayoutContainerWidth; + if (!widthsChanged && !userWidthsChanged && !userContainerChanged && !fillChanged && !signatureChanged) return state; return { ...state, widths: normalizedWidths, userWidths: normalizedUserWidths, + userLayoutContainerWidth: normalizedUserLayoutContainerWidth, layoutSignature, fillColumnId: undefined }; diff --git a/webui/src/components/table/dataGridSizing.ts b/webui/src/components/table/dataGridSizing.ts index d8a7620..513c301 100644 --- a/webui/src/components/table/dataGridSizing.ts +++ b/webui/src/components/table/dataGridSizing.ts @@ -52,7 +52,7 @@ export function dataGridLayoutSignature( column.fill ? "fill" : "", column.sticky ?? "" ].join(":")).join("|"); - return `${columnSignature}::${initialFit}::${resizeBehavior}`; + return `v2::${columnSignature}::${initialFit}::${resizeBehavior}`; } export function dataGridWidthsForLayout( @@ -133,7 +133,8 @@ export function fitDataGridColumns( containerWidth: number, measuredWidths: Record = {}, userWidths: Record = {}, - fitMode: DataGridFitMode = "cover" + fitMode: DataGridFitMode = "cover", + userLayoutContainerWidth?: number ): DataGridColumnLayout { const safeContainerWidth = Math.max(0, containerWidth); const widths: Record = {}; @@ -152,7 +153,35 @@ export function fitDataGridColumns( } const preferredTotal = totalDataGridColumnWidth(columns, widths); - let remaining = safeContainerWidth - preferredTotal; + const responsiveUserLayout = fitMode !== "free" + && Number.isFinite(userLayoutContainerWidth) + && (userLayoutContainerWidth ?? 0) > 0 + && safeContainerWidth < (userLayoutContainerWidth ?? 0) + && Object.keys(userWidths).length > 0; + const intentionalOverflow = responsiveUserLayout + ? Math.max( + 0, + columns.reduce((total, column) => { + const minimum = effectiveDataGridColumnMinWidth(column); + const maximum = effectiveDataGridColumnMaxWidth(column, minimum); + const override = userWidths[column.id]; + const baselineWidth = override !== undefined && Number.isFinite(override) + ? clampWidth( + override, + minimum, + fitMode === "cover" ? DATA_GRID_MAX_TRACK_WIDTH : maximum + ) + : preferredDataGridColumnWidth( + column, + userLayoutContainerWidth ?? safeContainerWidth, + measuredWidths[column.id] + ); + return total + baselineWidth; + }, 0) - (userLayoutContainerWidth ?? 0) + ) + : 0; + const fitTargetWidth = safeContainerWidth + intentionalOverflow; + let remaining = fitTargetWidth - preferredTotal; const ordinaryColumns = columns.filter((column) => !column.sticky); const coverageColumns = ordinaryColumns.length > 0 ? ordinaryColumns : columns; const automaticColumns = coverageColumns.filter((column) => userWidths[column.id] === undefined); @@ -210,12 +239,23 @@ export function fitDataGridColumns( resizeTargetForLayout(column, widths, widths[column.id]) ) ); - // Explicit user widths are hard during reconciliation. Cover prevents - // underflow, but intentional user growth may remain horizontally scrollable. + if (responsiveUserLayout && remaining < -0.01) { + remaining = applyDataGridDistribution( + widths, + remaining, + coverageColumns + .filter((column) => userWidths[column.id] !== undefined) + .map((column) => resizeTargetForLayout(column, widths, widths[column.id])) + ); + } + // At the container where it was chosen, an explicit user layout remains + // hard and cannot snap on mouse-up. A later container contraction may + // reclaim its tracks down to their hard minima while retaining any + // deliberate overflow selected by the user. } const roundedWidths = roundDataGridWidths(widths); - closeCoveredRoundingGap(columns, roundedWidths, safeContainerWidth, fitMode, remaining); + closeCoveredRoundingGap(columns, roundedWidths, fitTargetWidth, fitMode, remaining); const renderedTotal = totalDataGridColumnWidth(columns, roundedWidths); const renderedDifference = safeContainerWidth - renderedTotal; return { diff --git a/webui/src/styles/tables.css b/webui/src/styles/tables.css index 794b874..e5f9154 100644 --- a/webui/src/styles/tables.css +++ b/webui/src/styles/tables.css @@ -130,7 +130,9 @@ /* Reusable data grid */ .data-grid-shell { width: 100%; + max-width: 100%; min-width: 0; + contain: inline-size; overflow: hidden; border: var(--border-line); border-radius: var(--radius-sm); diff --git a/webui/tests/data-grid-sizing.test.ts b/webui/tests/data-grid-sizing.test.ts index bfed297..82fbcfa 100644 --- a/webui/tests/data-grid-sizing.test.ts +++ b/webui/tests/data-grid-sizing.test.ts @@ -219,6 +219,52 @@ assertWidths( "committing a cover resize preserves intentional overflow without a mouse-up snap" ); +const contractedUserLayout = fitDataGridColumns( + resizeColumns, + 700, + {}, + resizedLast.widths, + "cover", + 900 +); +assertEqual( + contractedUserLayout.overflowWidth, + 120, + "container contraction retains only the overflow deliberately selected by the user" +); +assertEqual( + Object.values(contractedUserLayout.widths).reduce((total, width) => total + width, 0), + 820, + "persisted tracks contract with their container instead of retaining the first pixel layout" +); +assertEqual( + Object.entries(contractedUserLayout.widths).every(([columnId, width]) => { + const column = resizeColumns.find((candidate) => candidate.id === columnId); + return Boolean(column && width >= effectiveDataGridColumnMinWidth(column)); + }), + true, + "responsive user layouts retain every hard column minimum" +); + +const contractedCoveredLayout = fitDataGridColumns( + resizeColumns, + 700, + {}, + resizeBase, + "cover", + 900 +); +assertEqual( + contractedCoveredLayout.overflowWidth, + 0, + "an exact-cover user layout continues to cover a narrower container without avoidable overflow" +); +assertEqual( + Object.values(contractedCoveredLayout.widths).reduce((total, width) => total + width, 0), + 700, + "an exact-cover layout shrinks to the newly available width" +); + const stoppedLastShrink = resizeDataGridColumn( resizeColumns, resizeBase, @@ -361,3 +407,8 @@ assertEqual( false, "free and cover layouts keep independent user overrides" ); +assertEqual( + originalSignature.startsWith("v2::"), + true, + "the responsive persistence contract invalidates legacy hard-width snapshots" +);