Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9b7bcff
refactor: replace `resizableColumnKeys` with `resizableColumns` and i…
war-in Oct 6, 2026
a61f9e1
Merge branch 'war-in/resizable-table-columns-basics' into war-in/resi…
war-in Oct 6, 2026
acc0dd1
fix: make the last column shrink when dragging the previous column
war-in Oct 6, 2026
73b9476
fix: rename and clean up code
war-in Oct 6, 2026
0dc7ea6
refactor: simplify column resizing logic by replacing `resizableColum…
war-in Oct 6, 2026
0017576
Merge remote-tracking branch 'upstream/main' into war-in/resizable-ta…
war-in Oct 9, 2026
fdcbd4a
Merge branch 'main' into war-in/resizable-table-columns-automatic-wid…
war-in Oct 12, 2026
0a32b50
fix: drop the "not stored" check to fix UI jump
war-in Oct 12, 2026
3f288cc
Merge branch 'main' into war-in/resizable-table-columns-automatic-wid…
war-in Oct 12, 2026
93d2583
fix: respect maximum widths when growing absorbers
war-in Oct 12, 2026
cd3625d
fix: keep user-sized columns fixed and absorb drags in unset columns …
war-in Oct 12, 2026
dd5f3c5
fix: keep layout stable between drags and refreshes
war-in Oct 13, 2026
acc96cb
Merge branch 'main' into war-in/resizable-table-columns-automatic-wid…
war-in Oct 13, 2026
5d98846
test: add user-resized columns test case
war-in Oct 13, 2026
c2d7f93
refactor: freeze layout after the first drag
war-in Oct 15, 2026
6aee9ea
test: improve tests
war-in Oct 15, 2026
eb2a912
test: improve tests
war-in Oct 15, 2026
10011cd
Merge branch 'refs/heads/main' into war-in/resizable-table-columns-au…
war-in Oct 15, 2026
6aa43de
chore: remove unused export
war-in Oct 15, 2026
6cfb222
fix: let a drag use the leftover room a fixed-width last column fills
war-in Oct 19, 2026
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
20 changes: 12 additions & 8 deletions src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {canMeasureText} from '@libs/measureTextWidth';
import {acquireBackgroundInputFocusSuppression} from '@libs/ModalFocusManager';

import CONST from '@src/CONST';
import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths';
import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths';

import type {FlashListRef} from '@shopify/flash-list';
import type {ReactElement} from 'react';
Expand Down Expand Up @@ -145,8 +145,8 @@ function createTableHandle<DataType extends TableData, ColumnKey extends string
}

type TableContentProps<DataType extends TableData, ColumnKey extends string, FilterKey extends string> = TableProps<DataType, ColumnKey, FilterKey> & {
/** Stored dragged widths for this table's `columnResizingID`. */
columnWidthOverrides?: ColumnWidthOverrides;
/** Layout the last drag saved for this `columnResizingID`. */
storedTableColumnWidths?: StoredTableColumnWidths;
};

function TableContent<DataType extends TableData, ColumnKey extends string = string, FilterKey extends string = string>({
Expand All @@ -170,7 +170,7 @@ function TableContent<DataType extends TableData, ColumnKey extends string = str
shouldPreserveSelectionOnSearchAndFilter,
shouldFooterRenderAsLastRow,
columnResizingID,
columnWidthOverrides,
storedTableColumnWidths,
onRowSelectionChange,
onSearchStringChange,
onSortingChange,
Expand Down Expand Up @@ -278,7 +278,7 @@ function TableContent<DataType extends TableData, ColumnKey extends string = str
// static tracks and never measure the table.
const isDynamicSizingEnabled = shouldUseDynamicColumns && !shouldUseNarrowTableLayout && canMeasureText();

// Dragged widths are applied by the dynamic sizing resolver, so resizing requires it.
// The saved layout is applied by the dynamic sizing resolver, so resizing requires it.
const {isBetaEnabled} = usePermissions();
const isColumnResizingEnabled = isDynamicSizingEnabled && !!columnResizingID && isBetaEnabled(CONST.BETAS.RESIZABLE_TABLE_COLUMNS);

Expand All @@ -289,8 +289,10 @@ function TableContent<DataType extends TableData, ColumnKey extends string = str
scrollWidth: dynamicScrollWidth,
rowWidth: dynamicRowWidth,
resizableColumnKeys,
getResizedColumnWidths,
resolvedColumnWidths,
dragMinWidths,
getStoredTableColumnWidths,
} = useDynamicColumnWidths<DataType, ColumnKey>({
columns,
data,
Expand All @@ -299,14 +301,16 @@ function TableContent<DataType extends TableData, ColumnKey extends string = str
// In the wide layout the checkbox column is rendered whenever selection is enabled.
hasSelectionColumn: !!selectionEnabled,
isColumnResizingEnabled,
columnWidthOverrides,
storedTableColumnWidths,
});

const columnResize = useColumnResize({
columnResizingID: isColumnResizingEnabled ? columnResizingID : undefined,
resizableColumnKeys,
getResizedColumnWidths,
resolvedColumnWidths,
dragMinWidths,
getStoredTableColumnWidths,
columnGap: styles.gap3.gap,
});

Expand Down Expand Up @@ -522,12 +526,12 @@ function TableContent<DataType extends TableData, ColumnKey extends string = str
function TableWithStoredColumnWidths<DataType extends TableData, ColumnKey extends string = string, FilterKey extends string = string>(
props: TableProps<DataType, ColumnKey, FilterKey> & {columnResizingID: string},
) {
const columnWidthOverrides = useStoredColumnWidths(props.columnResizingID);
const storedTableColumnWidths = useStoredColumnWidths(props.columnResizingID);

return (
<TableContent
{...props}
columnWidthOverrides={columnWidthOverrides}
storedTableColumnWidths={storedTableColumnWidths}
/>
);
}
Expand Down
70 changes: 0 additions & 70 deletions src/components/Table/columnResize/applyColumnWidthOverrides.ts

This file was deleted.

55 changes: 55 additions & 0 deletions src/components/Table/columnResize/getAbsorbedColumnWidths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/** Splits a column's resize among the columns to its right, so the row keeps its width instead of overflowing. */
import CONST from '@src/CONST';

const {MIN_WIDTH} = CONST.TABLES.COLUMN_RESIZE;

/** A later column that absorbs another column's resize. */
type ColumnAbsorber = {
columnKey: string;

/** The layout's minimum, so a drag and a reload land on the same widths. */
minWidth: number;

/** Widest it may grow to, so a column meant to truncate long values never takes another column's freed width past its cap. */
maxWidth: number;
};

/**
* Splits a resize delta equally among the absorbers. An absorber that runs out of room between its minimum and maximum
* stops absorbing and its share moves to the rest. Whatever none of them can absorb is left over, so the row overflows
* and scrolls, or falls short and the growable column fills the gap. Shares are whole px and sum exactly. Absorbers without a start width are skipped. Returns each absorber's new width by column key.
*/
function getAbsorbedColumnWidths(absorbers: ColumnAbsorber[], startWidths: Record<string, number>, delta: number): Record<string, number> {
const absorbersWithRoom = absorbers.flatMap(({columnKey, minWidth, maxWidth}) => {
const startWidth = startWidths[columnKey];

if (startWidth === undefined) {
return [];
}

const roundedStartWidth = Math.round(startWidth);

// How far it can move in the delta's direction: shrinking down to its minimum, or growing up to its maximum.
const room = delta > 0 ? Math.max(roundedStartWidth - Math.max(Math.round(minWidth), MIN_WIDTH), 0) : Math.max(Math.round(maxWidth) - roundedStartWidth, 0);
Comment thread
war-in marked this conversation as resolved.
Resolved

return [{columnKey, startWidth: roundedStartWidth, room}];
});

// Settled from the least room up, so an absorber hitting its minimum leaves its unabsorbed share to the rest. Stable, so ties keep render order.
const settleOrder = absorbersWithRoom.toSorted((first, second) => first.room - second.room);
const absorbedWidths: Record<string, number> = {};
let remainingDelta = Math.round(delta);

for (const [position, absorber] of settleOrder.entries()) {
const equalShare = Math.round(remainingDelta / (settleOrder.length - position));
const share = Math.max(Math.min(equalShare, absorber.room), -absorber.room);

absorbedWidths[absorber.columnKey] = absorber.startWidth - share;
remainingDelta -= share;
}

return absorbedWidths;
}

export default getAbsorbedColumnWidths;
export type {ColumnAbsorber};
6 changes: 6 additions & 0 deletions src/components/Table/columnResize/getColumnsWidthSum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Sums a row's column widths, counting a column without a width as 0. */
function getColumnsWidthSum(columns: Array<{key: string}>, columnWidths: Record<string, number>): number {
return columns.reduce((total, column) => total + (columnWidths[column.key] ?? 0), 0);
}

export default getColumnsWidthSum;
50 changes: 50 additions & 0 deletions src/components/Table/columnResize/getFrozenColumnWidths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** Repaints a saved layout, with the columns the user never dragged absorbing room that changed since the save. */
import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths';

import type {ResizableColumn} from './resolveDraggedColumnWidths';

import getAbsorbedColumnWidths from './getAbsorbedColumnWidths';
import getColumnsWidthSum from './getColumnsWidthSum';
import {getAbsorbedDelta, getColumnAbsorbers} from './resolveDraggedColumnWidths';

type GetFrozenColumnWidthsParams = {
/** Every column, in render order. A column added since the save starts at its content width. */
columns: ResizableColumn[];

storedTableColumnWidths: StoredTableColumnWidths;

/** Measured like the saved one, so they can be compared. */
availableColumnsWidth: number;
};

/**
* Room gained or lost since the save, from the table width or from added and hidden columns, first offsets the saved
* overflow or short row, like a drag does. The rest is split equally among the auto-sized columns. Widths are recomputed
* from the save every time, so resizing the window back restores them exactly.
*/
function getFrozenColumnWidths({
columns,
storedTableColumnWidths: {availableColumnsWidth: savedAvailableColumnsWidth, columnWidths: savedColumnWidths, resizedColumnKeys},
availableColumnsWidth,
}: GetFrozenColumnWidthsParams): Record<string, number> {
const baseColumnWidths: Record<string, number> = {};

for (const column of columns) {
baseColumnWidths[column.key] = column.declaredWidth ?? savedColumnWidths[column.key] ?? Math.min(column.contentWidth, column.maxWidth);
Comment thread
war-in marked this conversation as resolved.
Resolved
Comment thread
war-in marked this conversation as resolved.
Resolved
}

// Every saved column, hidden ones included, so a hidden column's width counts as freed room.
const savedColumnsWidth = Object.values(savedColumnWidths).reduce((total, width) => total + width, 0);
const extraRoom = availableColumnsWidth - savedAvailableColumnsWidth + savedColumnsWidth - getColumnsWidthSum(columns, baseColumnWidths);
const absorbers = getColumnAbsorbers(
columns.filter((column) => savedColumnWidths[column.key] !== undefined),
resizedColumnKeys,
);

// Negated because a positive delta shrinks absorbers.
const absorbedDelta = getAbsorbedDelta(-extraRoom, savedColumnsWidth - savedAvailableColumnsWidth);

return {...baseColumnWidths, ...getAbsorbedColumnWidths(absorbers, baseColumnWidths, absorbedDelta)};
}

export default getFrozenColumnWidths;
Loading
Loading