fix: enforce DataGrid cover boundary

This commit is contained in:
2026-07-31 04:44:24 +02:00
parent 7b8072d049
commit 5211e07d0b
4 changed files with 151 additions and 32 deletions
+10 -6
View File
@@ -22,15 +22,17 @@ layout, container resize, persisted-layout restore, and pointer resize.
| `initialFit` | `resizeBehavior` | Initial layout | Pointer resize | | `initialFit` | `resizeBehavior` | Initial layout | Pointer resize |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `container` | `cover` | Real columns fill the container. Hard-minimum excess scrolls horizontally. | The active change is compensated by ordinary peer columns. Passive maxima are soft; no blank filler column is created. | | `container` | `cover` | Real columns fill the container. Hard-minimum excess scrolls horizontally. | Growth may create horizontal overflow. Shrink first consumes overflow, then grows resizable columns to the right; it stops before underflow. |
| `content` | `cover` | After measurement, the same cover invariant applies. | Same as cover above. | | `content` | `cover` | After measurement, the same cover invariant applies. | Same as cover above. |
| `container` | `constrained` | Real columns fill the container. | Peer compensation respects every hard min/max and stops the active resize when capacity is exhausted. | | `container` | `constrained` | Real columns fill the container. | Peer compensation respects every hard min/max and stops the active resize when capacity is exhausted. |
| `content` | `free` | Declared content widths are retained. | Only the active column changes, so underflow or horizontal overflow is allowed. | | `content` | `free` | Declared content widths are retained. | Only the active column changes, so underflow or horizontal overflow is allowed. |
| `container` | `free` | The initial layout fills the container. | Later user resizing is free. A right-sticky column promotes this mode to cover so its edge remains stable. | | `container` | `free` | The initial layout fills the container. | Later user resizing is free. A right-sticky column promotes this mode to cover so its edge remains stable. |
Sticky columns do not absorb ordinary cover residuals and are not resize Sticky columns do not absorb ordinary cover residuals and are not resize
compensation targets. A last resizable column compensates against columns to compensation targets. A last resizable column may grow into overflow. It may
its left, so it follows the same right-edge invariant as earlier columns. shrink only by the current overflow, because shrinking farther would require a
blank filler track. Dragging farther past that stop does not bank width changes:
the column remains stopped until the pointer crosses the same boundary again.
## Persistence ## Persistence
@@ -41,8 +43,9 @@ behavior. A changed signature discards the old override and recomputes the
declared layout. 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.
there is no second fit pass and therefore no drag-end snap. Reconciliation may grow it to prevent underflow, but never shrinks intentional
user overflow, so there is no drag-end snap.
## Regression Matrix ## Regression Matrix
@@ -54,7 +57,8 @@ there is no second fit pass and therefore no drag-end snap.
- fixed-only cover grids; - fixed-only cover grids;
- persisted overrides under growth and viewport pressure; - persisted overrides under growth and viewport pressure;
- stale layout signatures; - stale layout signatures;
- first, middle, and last-column peer compensation; - first and middle-column right-side compensation;
- last-resizable-column overflow, underflow stop, and reverse-pointer boundary;
- free, cover, and constrained resizing; - free, cover, and constrained resizing;
- cover-expanded tracks that already exceed preferred maxima; and - cover-expanded tracks that already exceed preferred maxima; and
- preservation of the pointer layout across the commit fit. - preservation of the pointer layout across the commit fit.
+5 -3
View File
@@ -135,9 +135,11 @@ type DataGridBaseProps<T> = {
/** @deprecated Use initialFit instead. */ /** @deprecated Use initialFit instead. */
fit?: DataGridInitialFit; fit?: DataGridInitialFit;
/** /**
* Cover keeps the right edge fixed by redistributing every resize across * Cover prevents underflow while allowing horizontal overflow. Shrinking
* real columns. Free permits underflow and horizontal growth. Free is * consumes existing overflow and then grows eligible columns to the right;
* automatically promoted to cover when a right-sticky column exists. * it stops when no such capacity remains. Free permits both underflow and
* overflow. Free is automatically promoted to cover when a right-sticky
* column exists.
*/ */
resizeBehavior?: DataGridResizeBehavior; resizeBehavior?: DataGridResizeBehavior;
className?: string; className?: string;
+40 -11
View File
@@ -210,15 +210,8 @@ export function fitDataGridColumns(
resizeTargetForLayout(column, widths, widths[column.id]) resizeTargetForLayout(column, widths, widths[column.id])
) )
); );
// A persisted user layout is preferred, not a reason to overflow. Under // Explicit user widths are hard during reconciliation. Cover prevents
// viewport pressure every ordinary track may shrink down to its hard floor. // underflow, but intentional user growth may remain horizontally scrollable.
remaining = applyDataGridDistribution(
widths,
remaining,
coverageColumns.map((column) =>
resizeTargetForLayout(column, widths, widths[column.id])
)
);
} }
const roundedWidths = roundDataGridWidths(widths); const roundedWidths = roundDataGridWidths(widths);
@@ -268,6 +261,43 @@ export function resizeDataGridColumn(
}; };
} }
if (fitMode === "cover") {
if (boundedDelta >= 0) {
widths[columnId] = startWidth + boundedDelta;
return {
widths: roundDataGridWidths(widths),
appliedDelta: roundWidth(boundedDelta)
};
}
const requestedShrink = -boundedDelta;
const freeShrink = Math.min(
requestedShrink,
Math.max(0, uncompensatedShrinkRoom)
);
const rightTargets = columns
.slice(activeIndex + 1)
.filter((column) => column.resizable && !column.sticky)
.map((column) =>
resizeTargetForLayout(
column,
widths,
dataGridColumnResizeWeight(column)
)
);
const compensation = distributeDataGridResizeAmount(
requestedShrink - freeShrink,
rightTargets
);
applyDataGridAmounts(widths, compensation.amounts);
const appliedShrink = freeShrink + compensation.applied;
widths[columnId] = startWidth - appliedShrink;
return {
widths: roundDataGridWidths(widths),
appliedDelta: roundWidth(-appliedShrink)
};
}
const freeShrink = boundedDelta < 0 const freeShrink = boundedDelta < 0
? Math.min(-boundedDelta, Math.max(0, uncompensatedShrinkRoom)) ? Math.min(-boundedDelta, Math.max(0, uncompensatedShrinkRoom))
: 0; : 0;
@@ -282,8 +312,7 @@ export function resizeDataGridColumn(
resizeTargetForLayout( resizeTargetForLayout(
column, column,
widths, widths,
dataGridColumnResizeWeight(column), dataGridColumnResizeWeight(column)
fitMode === "cover" ? DATA_GRID_MAX_TRACK_WIDTH : undefined
) )
); );
const distribution = distributeDataGridResizeAmount(remainingCompensation, targets); const distribution = distributeDataGridResizeAmount(remainingCompensation, targets);
+96 -12
View File
@@ -94,13 +94,25 @@ assertWidths(
const overriddenOverflowLayout = fitDataGridColumns(weightedColumns, 500, {}, { primary: 450 }); const overriddenOverflowLayout = fitDataGridColumns(weightedColumns, 500, {}, { primary: 450 });
assertWidths( assertWidths(
overriddenOverflowLayout.widths, overriddenOverflowLayout.widths,
{ fixed: 72, primary: 328, secondary: 100 }, { fixed: 72, primary: 450, secondary: 100 },
"a cover layout treats a persisted user width as preferred under viewport pressure" "a cover layout preserves an explicit user width under viewport pressure"
); );
assertEqual( assertEqual(
overriddenOverflowLayout.overflowWidth, overriddenOverflowLayout.overflowWidth,
122,
"intentional user width may remain horizontally scrollable"
);
const overriddenUnderflowLayout = fitDataGridColumns(
weightedColumns,
800,
{},
{ fixed: 80, primary: 200, secondary: 200 }
);
assertEqual(
overriddenUnderflowLayout.underflowWidth,
0, 0,
"a persisted user preference does not create avoidable horizontal overflow" "a persisted cover layout is expanded when it would otherwise underflow"
); );
const clampedOverrideLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 900 }, "free"); const clampedOverrideLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 900 }, "free");
@@ -176,15 +188,22 @@ const resizeBase = { first: 200, middle: 300, last: 300, actions: 100 };
const resizedFirst = resizeDataGridColumn(resizeColumns, resizeBase, "first", 120, "cover"); const resizedFirst = resizeDataGridColumn(resizeColumns, resizeBase, "first", 120, "cover");
assertWidths( assertWidths(
resizedFirst.widths, resizedFirst.widths,
{ first: 320, middle: 240, last: 240, actions: 100 }, { first: 320, middle: 300, last: 300, actions: 100 },
"right-side peers compensate an earlier cover-column resize" "growing an earlier cover column may create horizontal overflow"
);
const shrunkFirst = resizeDataGridColumn(resizeColumns, resizeBase, "first", -80, "cover");
assertWidths(
shrunkFirst.widths,
{ first: 120, middle: 340, last: 340, actions: 100 },
"shrinking an earlier cover column grows resizable columns to its right"
); );
const resizedLast = resizeDataGridColumn(resizeColumns, resizeBase, "last", 120, "cover"); const resizedLast = resizeDataGridColumn(resizeColumns, resizeBase, "last", 120, "cover");
assertWidths( assertWidths(
resizedLast.widths, resizedLast.widths,
{ first: 140, middle: 240, last: 420, actions: 100 }, { first: 200, middle: 300, last: 420, actions: 100 },
"left-side peers compensate the last resizable cover column" "the last resizable cover column may grow into horizontal overflow"
); );
const committedLayout = fitDataGridColumns( const committedLayout = fitDataGridColumns(
@@ -197,7 +216,72 @@ const committedLayout = fitDataGridColumns(
assertWidths( assertWidths(
committedLayout.widths, committedLayout.widths,
resizedLast.widths, resizedLast.widths,
"committing a cover resize preserves the drag layout without a mouse-up snap" "committing a cover resize preserves intentional overflow without a mouse-up snap"
);
const stoppedLastShrink = resizeDataGridColumn(
resizeColumns,
resizeBase,
"last",
-120,
"cover"
);
assertWidths(
stoppedLastShrink.widths,
resizeBase,
"the last resizable column stops before its shrink would require a filler"
);
assertEqual(
stoppedLastShrink.appliedDelta,
0,
"the stopped last-column shrink reports no applied movement"
);
const overflowedResizeBase = { first: 200, middle: 300, last: 420, actions: 100 };
const consumedLastOverflow = resizeDataGridColumn(
resizeColumns,
overflowedResizeBase,
"last",
-180,
"cover",
120
);
assertWidths(
consumedLastOverflow.widths,
{ first: 200, middle: 300, last: 300, actions: 100 },
"the last resizable column consumes overflow and stops at exact coverage"
);
assertEqual(
consumedLastOverflow.appliedDelta,
-120,
"last-column shrink is clamped to the available overflow"
);
const stillPastLastStop = resizeDataGridColumn(
resizeColumns,
overflowedResizeBase,
"last",
-150,
"cover",
120
);
assertWidths(
stillPastLastStop.widths,
consumedLastOverflow.widths,
"reversing the pointer while it remains beyond the stop does not move the column"
);
const crossedLastStop = resizeDataGridColumn(
resizeColumns,
overflowedResizeBase,
"last",
-110,
"cover",
120
);
assertEqual(
crossedLastStop.widths.last,
310,
"the column resumes only after the pointer crosses the cover boundary"
); );
const fixedPeerResize = resizeDataGridColumn([ const fixedPeerResize = resizeDataGridColumn([
@@ -207,8 +291,8 @@ const fixedPeerResize = resizeDataGridColumn([
], { editable: 200, fixed: 300, actions: 100 }, "editable", 80, "cover"); ], { editable: 200, fixed: 300, actions: 100 }, "editable", 80, "cover");
assertWidths( assertWidths(
fixedPeerResize.widths, fixedPeerResize.widths,
{ editable: 280, fixed: 220, actions: 100 }, { editable: 280, fixed: 300, actions: 100 },
"non-resizable tracks may compensate layout without exposing a resize handle" "growth does not resize a fixed peer merely to avoid overflow"
); );
const coverBeyondPassiveMax = resizeDataGridColumn([ const coverBeyondPassiveMax = resizeDataGridColumn([
@@ -218,8 +302,8 @@ const coverBeyondPassiveMax = resizeDataGridColumn([
], { first: 200, second: 300, actions: 100 }, "first", -80, "cover"); ], { first: 200, second: 300, actions: 100 }, "first", -80, "cover");
assertWidths( assertWidths(
coverBeyondPassiveMax.widths, coverBeyondPassiveMax.widths,
{ first: 120, second: 380, actions: 100 }, { first: 200, second: 300, actions: 100 },
"cover compensation may exceed a peer's preferred maximum to retain full coverage" "cover shrinking stops when right-side peers have reached their maximum"
); );
const constrainedAtPassiveMax = resizeDataGridColumn([ const constrainedAtPassiveMax = resizeDataGridColumn([