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
+23 -3
View File
@@ -116,6 +116,8 @@ type DataGridState = {
widths?: Record<string, number>;
/** Pixel widths explicitly selected by a user with a resize handle. */
userWidths?: Record<string, number>;
/** 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<string, number>;
uncompensatedShrinkRoom: number;
behavior: DataGridResizeBehavior;
containerWidth: number;
};
const STORAGE_PREFIX = "govoplan.datagrid.";
@@ -371,7 +374,8 @@ export default function DataGrid<T>({
nextContainerWidth,
measuredWidths,
userWidths,
effectiveResizeBehavior
effectiveResizeBehavior,
current.userLayoutContainerWidth
);
if (
signatureMatches
@@ -428,6 +432,7 @@ export default function DataGrid<T>({
[activeResize.columnId]: resized.widths[activeResize.columnId]
}
: resized.widths,
userLayoutContainerWidth: activeResize.containerWidth,
layoutSignature,
fillColumnId: undefined
}));
@@ -681,7 +686,8 @@ export default function DataGrid<T>({
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
};
+46 -6
View File
@@ -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<string, number> = {},
userWidths: Record<string, number> = {},
fitMode: DataGridFitMode = "cover"
fitMode: DataGridFitMode = "cover",
userLayoutContainerWidth?: number
): DataGridColumnLayout {
const safeContainerWidth = Math.max(0, containerWidth);
const widths: Record<string, number> = {};
@@ -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 {
+2
View File
@@ -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);
+51
View File
@@ -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"
);