Fix responsive DataGrid contraction

This commit is contained in:
2026-08-05 22:42:21 +02:00
parent b553513c9f
commit 7ea0cb8655
5 changed files with 132 additions and 12 deletions
+10 -3
View File
@@ -36,7 +36,8 @@ the column remains stopped until the pointer crosses the same boundary again.
## Persistence ## 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 Persisted widths are keyed by a signature containing column IDs, declared
widths and bounds, resize affordances, sticky placement, initial fit, and resize widths and bounds, resize affordances, sticky placement, initial fit, and resize
behavior. A changed signature discards the old override and recomputes the 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 Container reconciliation is suspended while a pointer drag is active. On
release, the already-rendered pixel layout becomes the persisted preference. release, the already-rendered pixel layout becomes the persisted preference.
Reconciliation may grow it to prevent underflow, but never shrinks intentional Reconciliation at that same container width never shrinks intentional user
user overflow, so there is no drag-end snap. 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 ## Regression Matrix
@@ -56,6 +62,7 @@ user overflow, so there is no drag-end snap.
- hard-minimum horizontal overflow; - hard-minimum horizontal overflow;
- fixed-only cover grids; - fixed-only cover grids;
- persisted overrides under growth and viewport pressure; - persisted overrides under growth and viewport pressure;
- responsive contraction of persisted layouts without losing deliberate overflow;
- stale layout signatures; - stale layout signatures;
- first and middle-column right-side compensation; - first and middle-column right-side compensation;
- last-resizable-column overflow, underflow stop, and reverse-pointer boundary; - last-resizable-column overflow, underflow stop, and reverse-pointer boundary;
+23 -3
View File
@@ -116,6 +116,8 @@ type DataGridState = {
widths?: Record<string, number>; widths?: Record<string, number>;
/** Pixel widths explicitly selected by a user with a resize handle. */ /** Pixel widths explicitly selected by a user with a resize handle. */
userWidths?: Record<string, number>; userWidths?: Record<string, number>;
/** Container width at which the current user layout was selected. */
userLayoutContainerWidth?: number;
layoutSignature?: string; layoutSignature?: string;
/** Legacy layout state; removed when persisted state is sanitized. */ /** Legacy layout state; removed when persisted state is sanitized. */
fillColumnId?: string | null; fillColumnId?: string | null;
@@ -197,6 +199,7 @@ type ColumnResizeState = {
baseWidths: Record<string, number>; baseWidths: Record<string, number>;
uncompensatedShrinkRoom: number; uncompensatedShrinkRoom: number;
behavior: DataGridResizeBehavior; behavior: DataGridResizeBehavior;
containerWidth: number;
}; };
const STORAGE_PREFIX = "govoplan.datagrid."; const STORAGE_PREFIX = "govoplan.datagrid.";
@@ -371,7 +374,8 @@ export default function DataGrid<T>({
nextContainerWidth, nextContainerWidth,
measuredWidths, measuredWidths,
userWidths, userWidths,
effectiveResizeBehavior effectiveResizeBehavior,
current.userLayoutContainerWidth
); );
if ( if (
signatureMatches signatureMatches
@@ -428,6 +432,7 @@ export default function DataGrid<T>({
[activeResize.columnId]: resized.widths[activeResize.columnId] [activeResize.columnId]: resized.widths[activeResize.columnId]
} }
: resized.widths, : resized.widths,
userLayoutContainerWidth: activeResize.containerWidth,
layoutSignature, layoutSignature,
fillColumnId: undefined fillColumnId: undefined
})); }));
@@ -681,7 +686,8 @@ export default function DataGrid<T>({
startX: event.clientX, startX: event.clientX,
baseWidths, baseWidths,
uncompensatedShrinkRoom: shrinkRoomWithoutScroll, 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 }; if (!value) return { layoutSignature };
const parsed = JSON.parse(value) as DataGridState; const parsed = JSON.parse(value) as DataGridState;
const userWidths = dataGridWidthsForLayout(parsed.layoutSignature, layoutSignature, parsed.widths); const userWidths = dataGridWidthsForLayout(parsed.layoutSignature, layoutSignature, parsed.widths);
const userLayoutContainerWidth = userWidths
&& Number.isFinite(parsed.userLayoutContainerWidth)
&& (parsed.userLayoutContainerWidth ?? 0) > 0
? parsed.userLayoutContainerWidth
: undefined;
return { return {
sort: parsed.sort, sort: parsed.sort,
filters: parsed.filters, filters: parsed.filters,
widths: userWidths, widths: userWidths,
userWidths, userWidths,
userLayoutContainerWidth,
layoutSignature layoutSignature
}; };
} catch { } catch {
@@ -1165,6 +1177,7 @@ function persistedState(state: DataGridState, layoutSignature: string): DataGrid
sort: state.sort, sort: state.sort,
filters: state.filters, filters: state.filters,
widths: state.userWidths, widths: state.userWidths,
userLayoutContainerWidth: state.userWidths ? state.userLayoutContainerWidth : undefined,
layoutSignature layoutSignature
}; };
} }
@@ -1277,15 +1290,22 @@ layoutSignature: string)
const nextUserWidths = signatureMatches ? sanitizeWidths(state.userWidths) : {}; const nextUserWidths = signatureMatches ? sanitizeWidths(state.userWidths) : {};
const normalizedWidths = Object.keys(nextWidths).length > 0 ? roundWidthRecord(nextWidths) : undefined; const normalizedWidths = Object.keys(nextWidths).length > 0 ? roundWidthRecord(nextWidths) : undefined;
const normalizedUserWidths = Object.keys(nextUserWidths).length > 0 ? roundWidthRecord(nextUserWidths) : 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 widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, normalizedWidths ?? {});
const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {}); const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {});
const fillChanged = state.fillColumnId !== undefined; const fillChanged = state.fillColumnId !== undefined;
const signatureChanged = state.layoutSignature !== layoutSignature; 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 { return {
...state, ...state,
widths: normalizedWidths, widths: normalizedWidths,
userWidths: normalizedUserWidths, userWidths: normalizedUserWidths,
userLayoutContainerWidth: normalizedUserLayoutContainerWidth,
layoutSignature, layoutSignature,
fillColumnId: undefined fillColumnId: undefined
}; };
+46 -6
View File
@@ -52,7 +52,7 @@ export function dataGridLayoutSignature(
column.fill ? "fill" : "", column.fill ? "fill" : "",
column.sticky ?? "" column.sticky ?? ""
].join(":")).join("|"); ].join(":")).join("|");
return `${columnSignature}::${initialFit}::${resizeBehavior}`; return `v2::${columnSignature}::${initialFit}::${resizeBehavior}`;
} }
export function dataGridWidthsForLayout( export function dataGridWidthsForLayout(
@@ -133,7 +133,8 @@ export function fitDataGridColumns(
containerWidth: number, containerWidth: number,
measuredWidths: Record<string, number> = {}, measuredWidths: Record<string, number> = {},
userWidths: Record<string, number> = {}, userWidths: Record<string, number> = {},
fitMode: DataGridFitMode = "cover" fitMode: DataGridFitMode = "cover",
userLayoutContainerWidth?: number
): DataGridColumnLayout { ): DataGridColumnLayout {
const safeContainerWidth = Math.max(0, containerWidth); const safeContainerWidth = Math.max(0, containerWidth);
const widths: Record<string, number> = {}; const widths: Record<string, number> = {};
@@ -152,7 +153,35 @@ export function fitDataGridColumns(
} }
const preferredTotal = totalDataGridColumnWidth(columns, widths); 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 ordinaryColumns = columns.filter((column) => !column.sticky);
const coverageColumns = ordinaryColumns.length > 0 ? ordinaryColumns : columns; const coverageColumns = ordinaryColumns.length > 0 ? ordinaryColumns : columns;
const automaticColumns = coverageColumns.filter((column) => userWidths[column.id] === undefined); const automaticColumns = coverageColumns.filter((column) => userWidths[column.id] === undefined);
@@ -210,12 +239,23 @@ export function fitDataGridColumns(
resizeTargetForLayout(column, widths, widths[column.id]) resizeTargetForLayout(column, widths, widths[column.id])
) )
); );
// Explicit user widths are hard during reconciliation. Cover prevents if (responsiveUserLayout && remaining < -0.01) {
// underflow, but intentional user growth may remain horizontally scrollable. 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); const roundedWidths = roundDataGridWidths(widths);
closeCoveredRoundingGap(columns, roundedWidths, safeContainerWidth, fitMode, remaining); closeCoveredRoundingGap(columns, roundedWidths, fitTargetWidth, fitMode, remaining);
const renderedTotal = totalDataGridColumnWidth(columns, roundedWidths); const renderedTotal = totalDataGridColumnWidth(columns, roundedWidths);
const renderedDifference = safeContainerWidth - renderedTotal; const renderedDifference = safeContainerWidth - renderedTotal;
return { return {
+2
View File
@@ -130,7 +130,9 @@
/* Reusable data grid */ /* Reusable data grid */
.data-grid-shell { .data-grid-shell {
width: 100%; width: 100%;
max-width: 100%;
min-width: 0; min-width: 0;
contain: inline-size;
overflow: hidden; overflow: hidden;
border: var(--border-line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
+51
View File
@@ -219,6 +219,52 @@ assertWidths(
"committing a cover resize preserves intentional overflow without a mouse-up snap" "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( const stoppedLastShrink = resizeDataGridColumn(
resizeColumns, resizeColumns,
resizeBase, resizeBase,
@@ -361,3 +407,8 @@ assertEqual(
false, false,
"free and cover layouts keep independent user overrides" "free and cover layouts keep independent user overrides"
); );
assertEqual(
originalSignature.startsWith("v2::"),
true,
"the responsive persistence contract invalidates legacy hard-width snapshots"
);