Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/react-stately/src/table/TableUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ export function calculateColumnSizes(
getDefaultWidth?: (index: number) => ColumnSize | null | undefined,
getDefaultMinWidth?: (index: number) => ColumnSize | null | undefined
): number[] {
// cascadeRounding below assumes the target sizes sum to an integer. A table
// width can be fractional (e.g. a percentage-based size); distribute the
// whole-pixel part across the columns and add the leftover fraction to the
// last column at the end. This keeps the columns flush with the edge of the
// table without overflowing it (which would add a scrollbar, #9448).
let fractionalWidth = availableWidth - Math.floor(availableWidth);
availableWidth = Math.floor(availableWidth);
let hasNonFrozenItems = false;
let flexItems: FlexItem[] = columns.map((column, index) => {
let width: ColumnSize = (
Expand Down Expand Up @@ -254,7 +261,15 @@ export function calculateColumnSizes(
});
}

return cascadeRounding(flexItems);
let columnSizes = cascadeRounding(flexItems);

// Give the leftover sub-pixel width to the last column so the columns sum
// exactly to the (possibly fractional) available width.
if (fractionalWidth > 0 && columnSizes.length > 0) {
columnSizes[columnSizes.length - 1] += fractionalWidth;
}

return columnSizes;
}

function cascadeRounding(flexItems: FlexItem[]): number[] {
Expand Down
63 changes: 63 additions & 0 deletions packages/react-stately/test/table/TableUtils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,69 @@ describe('TableUtils', () => {
);
expect(widths).toStrictEqual([133, 134, 533]);
});

it('keeps columns flush with a fractional table width', () => {
// A percentage-based table width can be fractional. Rounding every column
// to an integer would push the sum past the available width and produce a
// horizontal scrollbar; flooring would leave a gap at the table edge.
// Instead the leftover fraction goes to the last column so the widths sum
// exactly to the table width.
// https://github.com/adobe/react-spectrum/issues/9448
let tableWidth = 1000.5;
let widths = calculateColumnSizes(
tableWidth,
[
{key: 'name', width: '1fr'},
{key: 'type', width: '1fr'}
],
new Map(),
() => 150,
() => 50
);
expect(widths).toStrictEqual([500, 500.5]);
expect(widths.reduce((a, b) => a + b, 0)).toBe(tableWidth);
});

it('keeps integer table widths as whole-number columns', () => {
// An integer table width must not pick up a fractional last column from
// floating-point accumulation; column widths stay whole numbers.
let widths = calculateColumnSizes(
1000,
[
{key: 'name', width: '1fr'},
{key: 'type', width: '1fr'}
],
new Map(),
() => 150,
() => 50
);
expect(widths).toStrictEqual([500, 500]);
expect(widths.every(Number.isInteger)).toBe(true);
});

it('handles js fp rounding errors', () => {
let widths = calculateColumnSizes(
1000.7,
[
{key: 'name', width: '1fr'},
{key: 'type', width: '1fr'}
],
new Map(),
() => 150,
() => 50
);
// Every column but the last is a whole number.
expect(widths.slice(0, -1).every(Number.isInteger)).toBe(true);
// The columns sum exactly to the available width, so the table never
// overflows (no horizontal scrollbar) regardless of the sub-pixel value
// of the last column.
expect(widths.reduce((a, b) => a + b, 0)).toBe(1000.7);
expect(widths[0]).toBe(500);
// The last column carries the fractional remainder. It cannot be exactly
// 500.7 in floating point (1000.7 - 500 !== 500.7), but it is within one
// ULP, which is far below a device pixel.
expect(widths[widths.length - 1]).toBeCloseTo(500.7, 10);
});
});

describe('table column layout', () => {
Expand Down