Fix responsive DataGrid contraction
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user