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
|
## 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;
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user