diff --git a/docs/DATAGRID_SIZING_CONTRACT.md b/docs/DATAGRID_SIZING_CONTRACT.md index 94d9f47..103d8e1 100644 --- a/docs/DATAGRID_SIZING_CONTRACT.md +++ b/docs/DATAGRID_SIZING_CONTRACT.md @@ -22,15 +22,17 @@ layout, container resize, persisted-layout restore, and 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. | | `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. | | `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 -compensation targets. A last resizable column compensates against columns to -its left, so it follows the same right-edge invariant as earlier columns. +compensation targets. A last resizable column may grow into overflow. It may +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 @@ -41,8 +43,9 @@ behavior. A changed signature discards the old override and recomputes the declared layout. Container reconciliation is suspended while a pointer drag is active. On -release, the already-rendered pixel layout becomes the persisted preference; -there is no second fit pass and therefore no drag-end snap. +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. ## Regression Matrix @@ -54,7 +57,8 @@ there is no second fit pass and therefore no drag-end snap. - fixed-only cover grids; - persisted overrides under growth and viewport pressure; - 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; - cover-expanded tracks that already exceed preferred maxima; and - preservation of the pointer layout across the commit fit. diff --git a/webui/src/components/table/DataGrid.tsx b/webui/src/components/table/DataGrid.tsx index 4512a6b..0f1ac02 100644 --- a/webui/src/components/table/DataGrid.tsx +++ b/webui/src/components/table/DataGrid.tsx @@ -135,9 +135,11 @@ type DataGridBaseProps = { /** @deprecated Use initialFit instead. */ fit?: DataGridInitialFit; /** - * Cover keeps the right edge fixed by redistributing every resize across - * real columns. Free permits underflow and horizontal growth. Free is - * automatically promoted to cover when a right-sticky column exists. + * Cover prevents underflow while allowing horizontal overflow. Shrinking + * consumes existing overflow and then grows eligible columns to the right; + * 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; className?: string; diff --git a/webui/src/components/table/dataGridSizing.ts b/webui/src/components/table/dataGridSizing.ts index 4efe9a3..d8a7620 100644 --- a/webui/src/components/table/dataGridSizing.ts +++ b/webui/src/components/table/dataGridSizing.ts @@ -210,15 +210,8 @@ export function fitDataGridColumns( resizeTargetForLayout(column, widths, widths[column.id]) ) ); - // A persisted user layout is preferred, not a reason to overflow. Under - // viewport pressure every ordinary track may shrink down to its hard floor. - remaining = applyDataGridDistribution( - widths, - remaining, - coverageColumns.map((column) => - resizeTargetForLayout(column, widths, widths[column.id]) - ) - ); + // Explicit user widths are hard during reconciliation. Cover prevents + // underflow, but intentional user growth may remain horizontally scrollable. } 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 ? Math.min(-boundedDelta, Math.max(0, uncompensatedShrinkRoom)) : 0; @@ -282,8 +312,7 @@ export function resizeDataGridColumn( resizeTargetForLayout( column, widths, - dataGridColumnResizeWeight(column), - fitMode === "cover" ? DATA_GRID_MAX_TRACK_WIDTH : undefined + dataGridColumnResizeWeight(column) ) ); const distribution = distributeDataGridResizeAmount(remainingCompensation, targets); diff --git a/webui/tests/data-grid-sizing.test.ts b/webui/tests/data-grid-sizing.test.ts index aecfd1e..bfed297 100644 --- a/webui/tests/data-grid-sizing.test.ts +++ b/webui/tests/data-grid-sizing.test.ts @@ -94,13 +94,25 @@ assertWidths( const overriddenOverflowLayout = fitDataGridColumns(weightedColumns, 500, {}, { primary: 450 }); assertWidths( overriddenOverflowLayout.widths, - { fixed: 72, primary: 328, secondary: 100 }, - "a cover layout treats a persisted user width as preferred under viewport pressure" + { fixed: 72, primary: 450, secondary: 100 }, + "a cover layout preserves an explicit user width under viewport pressure" ); assertEqual( 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, - "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"); @@ -176,15 +188,22 @@ const resizeBase = { first: 200, middle: 300, last: 300, actions: 100 }; const resizedFirst = resizeDataGridColumn(resizeColumns, resizeBase, "first", 120, "cover"); assertWidths( resizedFirst.widths, - { first: 320, middle: 240, last: 240, actions: 100 }, - "right-side peers compensate an earlier cover-column resize" + { first: 320, middle: 300, last: 300, actions: 100 }, + "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"); assertWidths( resizedLast.widths, - { first: 140, middle: 240, last: 420, actions: 100 }, - "left-side peers compensate the last resizable cover column" + { first: 200, middle: 300, last: 420, actions: 100 }, + "the last resizable cover column may grow into horizontal overflow" ); const committedLayout = fitDataGridColumns( @@ -197,7 +216,72 @@ const committedLayout = fitDataGridColumns( assertWidths( committedLayout.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([ @@ -207,8 +291,8 @@ const fixedPeerResize = resizeDataGridColumn([ ], { editable: 200, fixed: 300, actions: 100 }, "editable", 80, "cover"); assertWidths( fixedPeerResize.widths, - { editable: 280, fixed: 220, actions: 100 }, - "non-resizable tracks may compensate layout without exposing a resize handle" + { editable: 280, fixed: 300, actions: 100 }, + "growth does not resize a fixed peer merely to avoid overflow" ); const coverBeyondPassiveMax = resizeDataGridColumn([ @@ -218,8 +302,8 @@ const coverBeyondPassiveMax = resizeDataGridColumn([ ], { first: 200, second: 300, actions: 100 }, "first", -80, "cover"); assertWidths( coverBeyondPassiveMax.widths, - { first: 120, second: 380, actions: 100 }, - "cover compensation may exceed a peer's preferred maximum to retain full coverage" + { first: 200, second: 300, actions: 100 }, + "cover shrinking stops when right-side peers have reached their maximum" ); const constrainedAtPassiveMax = resizeDataGridColumn([