Skip to content
Draft
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
27 changes: 26 additions & 1 deletion src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import CONST from '@src/CONST';

import type {FlashListRef} from '@shopify/flash-list';
import type {LayoutChangeEvent} from 'react-native';

import React, {useImperativeHandle, useRef} from 'react';
import React, {useCallback, useImperativeHandle, useRef, useState} from 'react';

import type {TableContextValue} from './TableContext';
import type {TableData, TableHandle, TableMethods, TableProps, TableRow} from './types';
Expand All @@ -26,6 +27,7 @@ import {shouldUseTableSemantics} from './tableAccessibility';
import {doesBodyRenderWhenEmpty} from './TableBody';
import TableContext from './TableContext';
import TableSemanticContainer from './TableSemanticContainer';
import useDynamicColumnWidths from './useDynamicColumnWidths';

/**
* Builds the Proxy exposed through the Table's ref, forwarding to `tableMethods` first and
Expand Down Expand Up @@ -192,6 +194,7 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
children,
selectionEnabled,
shouldEnableSelectionInNarrowPaneModal,
shouldUseDynamicColumns = false,
onRowSelectionChange,
onSearchStringChange,
...listProps
Expand Down Expand Up @@ -238,6 +241,24 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi

const listRef = useRef<FlashListRef<DataType>>(null);

const [tableWidth, setTableWidth] = useState(0);

const handleTableLayout = useCallback((event: LayoutChangeEvent) => {
setTableWidth(event.nativeEvent.layout.width);
}, []);

// Columns are sized from the full data set rather than the processed one, so the widths stay put while the user
// searches or filters instead of reflowing on every keystroke. Narrow layouts render as cards with no columns to
// size, and the measurement itself is unavailable on native, so both keep the static tracks.
const {gridTemplateColumns: dynamicGridTemplateColumns, scrollWidth: dynamicScrollWidth} = useDynamicColumnWidths<DataType, ColumnKey>({
columns,
data,
tableWidth,
isEnabled: shouldUseDynamicColumns && !shouldUseNarrowTableLayout,
// In the wide layout the checkbox column is rendered whenever selection is enabled.
hasSelectionColumn: !!selectionEnabled,
});

const tableMethods: TableMethods<ColumnKey, FilterKey> = {
...filterMethods,
...sortMethods,
Expand Down Expand Up @@ -272,6 +293,8 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
processedData,
originalDataLength,
columns,
dynamicGridTemplateColumns,
dynamicScrollWidth,
filterConfig: filters,
activeFilters: currentFilters,
activeSorting,
Expand Down Expand Up @@ -307,6 +330,8 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
rowCount={processedData.length}
columnCount={semanticColumnCount}
rendersBodyWhenEmpty={rendersBodyWhenEmpty}
scrollWidth={dynamicScrollWidth}
onLayout={shouldUseDynamicColumns ? handleTableLayout : undefined}
>
{children}
</TableSemanticContainer>
Expand Down
5 changes: 4 additions & 1 deletion src/components/Table/TableBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function TableBody<DataType extends TableData>({contentContainerStyle, style, ..
hasSearchString,
isEmptyResult,
originalDataLength,
dynamicScrollWidth,
} = useTableContext<DataType>();
const {contentContainerStyle: listContentContainerStyle, ListEmptyComponent, ListHeaderComponent, ...restListProps} = listProps ?? {};

Expand Down Expand Up @@ -104,7 +105,9 @@ function TableBody<DataType extends TableData>({contentContainerStyle, style, ..

return (
<View
style={[styles.flex1, styles.mnh0, style]}
// When the columns don't fit, this node grows to the width the rows need so they aren't clipped, and the
// scrolling itself happens on the ancestor that also holds the header.
style={[styles.flex1, styles.mnh0, !!dynamicScrollWidth && {width: dynamicScrollWidth}, style]}
{...getRowGroupAccessibilityProps(shouldUseTableSemantics(shouldUseNarrowTableLayout))}
{...props}
>
Expand Down
16 changes: 15 additions & 1 deletion src/components/Table/TableContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,19 @@ type TableContextValue<DataType extends TableData, ColumnKey extends string = st
originalDataLength: number;

/** Column configuration for the table. */
columns: Array<TableColumn<ColumnKey>>;
columns: Array<TableColumn<ColumnKey, DataType>>;

/**
* The CSS grid tracks the header and every row must render, when the columns are sized from their content.
* `undefined` means the columns keep their static tracks (fixed widths and equal `1fr` shares).
*/
dynamicGridTemplateColumns: string[] | undefined;

/**
* The width the rows need when the content is too wide to fit, so the header and rows can be scrolled horizontally
* together. `undefined` whenever the content fits.
*/
dynamicScrollWidth: number | undefined;

/** Filter configuration for dropdown filters. */
filterConfig: FilterConfig<FilterKey> | undefined;
Expand Down Expand Up @@ -83,6 +95,8 @@ const defaultTableContextValue: TableContextValue<TableData, string> = {
processedData: [],
originalDataLength: 0,
columns: [],
dynamicGridTemplateColumns: undefined,
dynamicScrollWidth: undefined,
activeFilters: {},
activeSorting: {
columnKey: undefined,
Expand Down
24 changes: 20 additions & 4 deletions src/components/Table/TableHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,19 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
const {translate} = useLocalize();
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, processedData, isMobileSelectionEnabled, shouldEnableSelectionInNarrowPaneModal} =
useTableContext<DataType, ColumnKey>();
const {
columns,
isEmptyResult,
title,
shouldUseNarrowTableLayout,
tableMethods,
selectionEnabled,
processedData,
isMobileSelectionEnabled,
shouldEnableSelectionInNarrowPaneModal,
dynamicGridTemplateColumns,
dynamicScrollWidth,
} = useTableContext<DataType, ColumnKey>();
// Tables inside a narrow pane modal (RHP) opt into keying the header checkbox off the real screen size, since
// shouldUseNarrowLayout is always true in an RHP. Other tables keep the original behavior. Visual padding below still uses shouldUseNarrowLayout.
const selectionUsesNarrowLayout = shouldEnableSelectionInNarrowPaneModal ? isSmallScreenWidth : shouldUseNarrowLayout;
Expand All @@ -80,7 +91,9 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
return null;
}

const gridTemplateColumns = getGridTemplateColumns(columns);
// The tracks resolved from the columns' content are shared by the header and every row, so they take precedence over
// the static ones. They're only ever set on wide web layouts.
const gridTemplateColumns = dynamicGridTemplateColumns ? [...dynamicGridTemplateColumns] : getGridTemplateColumns(columns);

if (isSelectionCheckboxVisible) {
gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`);
Expand Down Expand Up @@ -117,6 +130,9 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
// Use Grid on web when available (will override flex if supported)
styles.dGrid,
!shouldUseNarrowTableLayout && {gridTemplateColumns: gridTemplateColumns.join(' ')},
// The columns are wider than the table, so the header takes the same width as the rows it labels and
// scrolls with them.
!!dynamicScrollWidth && {width: dynamicScrollWidth},
style,
]}
{...getRowAccessibilityProps(isTableSemanticsEnabled, 0, true)}
Expand Down Expand Up @@ -192,7 +208,7 @@ function TableHeaderColumn<DataType extends TableData, ColumnKey extends string
isTableSemanticsEnabled,
columnIndex,
}: {
column: TableColumn<ColumnKey>;
column: TableColumn<ColumnKey, DataType>;
isTableSemanticsEnabled: boolean;
columnIndex: number;
}) {
Expand Down
19 changes: 17 additions & 2 deletions src/components/Table/TableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,17 @@ export default function TableRow({
const {translate} = useLocalize();
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth, shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout();
const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, isMobileSelectionEnabled, shouldEnableSelectionInNarrowPaneModal = false} = useTableContext();
const {
processedData,
columns,
shouldUseNarrowTableLayout,
tableMethods,
selectionEnabled,
isMobileSelectionEnabled,
shouldEnableSelectionInNarrowPaneModal = false,
dynamicGridTemplateColumns,
dynamicScrollWidth,
} = useTableContext();

// Tables inside a narrow pane modal (RHP) opt into keying the selection UX off the real screen size (isSmallScreenWidth),
// because shouldUseNarrowLayout is always true in an RHP and would otherwise suppress selection entirely. All other
Expand All @@ -83,7 +93,9 @@ export default function TableRow({
const item = processedData.at(rowIndex);
const rowCount = processedData.length;
const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout);
const gridTemplateColumns = getGridTemplateColumns(columns);
// The tracks resolved from the columns' content are shared by the header and every row, so they take precedence over
// the static ones. They're only ever set on wide web layouts.
const gridTemplateColumns = dynamicGridTemplateColumns ? [...dynamicGridTemplateColumns] : getGridTemplateColumns(columns);
const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !selectionUsesNarrowLayout);

const isDisabled = !!disabled;
Expand Down Expand Up @@ -112,6 +124,9 @@ export default function TableRow({
isLastRow && styles.tableBottomRadius,
item.selected && [styles.activeComponentBG, {borderColor: theme.buttonHoveredBG}],
shouldUseNarrowTableLayout ? styles.tableRowHeightCompact : styles.tableRowHeight,
// The columns are wider than the table, so every row takes the width its content needs and the whole
// header/body run scrolls horizontally.
!!dynamicScrollWidth && {width: dynamicScrollWidth},
];

const tableRowContentContainerStyles = [
Expand Down
22 changes: 20 additions & 2 deletions src/components/Table/TableSemanticContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import useThemeStyles from '@hooks/useThemeStyles';

import type {LayoutChangeEvent} from 'react-native';

import React from 'react';
import {View} from 'react-native';

Expand Down Expand Up @@ -27,6 +29,19 @@ type TableSemanticContainerProps = {
*/
rendersBodyWhenEmpty: boolean;

/**
* The width the rows need when the columns are too wide to fit. Set only in that case, and it makes the header/body
* run scroll horizontally as one, so the header stays aligned with the rows it labels.
*/
scrollWidth: number | undefined;

/**
* Measures the width the table's columns have to share. This node is the right thing to measure because it keeps the
* table's own width even while its content overflows and scrolls, so measuring it can't feed back into the widths it
* produced.
*/
onLayout: ((event: LayoutChangeEvent) => void) | undefined;

/** Table children — expected to contain a contiguous `TableHeader`/`TableBody` run. */
children: React.ReactNode;
};
Expand All @@ -38,7 +53,7 @@ type TableSemanticContainerProps = {
* narrow card layout. Header and body are contiguous in every table, so grouping the consecutive run keeps a single
* table container while preserving child order.
*/
function TableSemanticContainer({isEnabled, title, rowCount, columnCount, rendersBodyWhenEmpty, children}: TableSemanticContainerProps) {
function TableSemanticContainer({isEnabled, title, rowCount, columnCount, rendersBodyWhenEmpty, scrollWidth, onLayout, children}: TableSemanticContainerProps) {
const styles = useThemeStyles();

if (!isEnabled) {
Expand Down Expand Up @@ -67,7 +82,10 @@ function TableSemanticContainer({isEnabled, title, rowCount, columnCount, render
renderedChildren.push(
<View
key={`tableSemanticContainer-${renderedChildren.length}`}
style={[styles.flex1, styles.mnh0]}
// The header and the body share this node, so scrolling it horizontally keeps them aligned. Vertical
// scrolling stays with the body's own list.
style={[styles.flex1, styles.mnh0, !!scrollWidth && styles.overflowXAuto]}
onLayout={onLayout}
{...getTableContainerAccessibilityProps(true, title, rowCount, columnCount)}
>
{rowGroup}
Expand Down
124 changes: 124 additions & 0 deletions src/components/Table/calculateDynamicColumnWidths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Sizing constraints for a single dynamically sized column.
*/
type DynamicColumnConstraints = {
/** Width the column's widest content needs in order to render untruncated, including non-text extras like avatars. */
contentWidth: number;

/** Smallest width the column may shrink to. Below the sum of these, the table has to scroll horizontally. */
minWidth: number;

/** Largest width the column may claim, so a single very long value can't starve its siblings. */
maxWidth: number;
};

/**
* The layout the dynamic columns resolved to.
*/
type CalculatedDynamicColumnWidths = {
/**
* Resolved px width per column, in input order. Empty when the columns should keep equal `1fr` tracks, which is the
* case when every column's content fits inside an equal share of the available width.
*/
widths: number[];

/**
* Whether the columns had to be pinned to their minimum widths because they cannot all fit, meaning the caller has
* to let the table scroll horizontally.
*/
shouldScrollHorizontally: boolean;
};

const EQUAL_WIDTHS: CalculatedDynamicColumnWidths = {widths: [], shouldScrollHorizontally: false};

function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

function sum(values: number[]): number {
return values.reduce((total, value) => total + value, 0);
}

/**
* Rounds widths down to whole px and hands the rounding remainder to the widest column, so the columns add up to
* exactly `availableWidth` and no sub-pixel gap is left at the end of the row.
*/
function roundWidths(widths: number[], availableWidth: number): number[] {
const roundedWidths = widths.map((width) => Math.floor(width));
const remainder = availableWidth - sum(roundedWidths);

if (remainder <= 0) {
return roundedWidths;
}

const widestColumnIndex = roundedWidths.indexOf(Math.max(...roundedWidths));
roundedWidths[widestColumnIndex] += remainder;

return roundedWidths;
}

/**
* Resolves the widths of a table's dynamically sized columns from what their content needs and how much room the table
* has, implementing three behaviors in order:
*
* 1. Every column's content fits inside an equal share of the available width, so the columns stay equal (`1fr`).
* 2. The content fits overall but unevenly, so each column takes what it needs and the leftover space is split equally.
* A column with long content grows and its short-content siblings shrink.
* 3. The content does not fit, so every column shrinks toward its minimum width in proportion to how much slack it has.
* Once even the minimum widths don't fit, the columns are pinned to those minimums and the table scrolls.
*
* @param constraints - Sizing constraints per column, in column order.
* @param availableWidth - Width the dynamic columns share, i.e. the row's width minus padding, gaps, and any
* fixed-width columns.
*/
function calculateDynamicColumnWidths(constraints: DynamicColumnConstraints[], availableWidth: number): CalculatedDynamicColumnWidths {
if (constraints.length === 0 || availableWidth <= 0) {
return EQUAL_WIDTHS;
}

const minWidths = constraints.map((constraint) => constraint.minWidth);
const desiredWidths = constraints.map((constraint, index) => clamp(constraint.contentWidth, minWidths.at(index) ?? 0, Math.max(constraint.maxWidth, minWidths.at(index) ?? 0)));

// 1. Equal columns already give every column enough room, so nothing needs resizing.
const equalShare = availableWidth / constraints.length;
if (desiredWidths.every((desiredWidth) => desiredWidth <= equalShare)) {
return EQUAL_WIDTHS;
}

// 2. Everything fits, so each column takes what it needs and the leftover space is shared equally.
const totalDesiredWidth = sum(desiredWidths);
if (totalDesiredWidth <= availableWidth) {
const leftoverPerColumn = (availableWidth - totalDesiredWidth) / constraints.length;
return {
widths: roundWidths(
desiredWidths.map((desiredWidth) => desiredWidth + leftoverPerColumn),
availableWidth,
),
shouldScrollHorizontally: false,
};
}

// 3. Nothing fits. Columns shrink toward their minimum width proportionally to their slack, and once even the
// minimum widths overflow, they're pinned there and the table scrolls horizontally instead of truncating further.
const totalMinWidth = sum(minWidths);
if (totalMinWidth >= availableWidth) {
return {widths: minWidths, shouldScrollHorizontally: totalMinWidth > availableWidth};
}

const totalSlack = totalDesiredWidth - totalMinWidth;
const slackRatio = (availableWidth - totalMinWidth) / totalSlack;

return {
widths: roundWidths(
desiredWidths.map((desiredWidth, index) => {
const minWidth = minWidths.at(index) ?? 0;
return minWidth + (desiredWidth - minWidth) * slackRatio;
}),
availableWidth,
),
shouldScrollHorizontally: false,
};
}

export default calculateDynamicColumnWidths;
export type {DynamicColumnConstraints, CalculatedDynamicColumnWidths};
4 changes: 2 additions & 2 deletions src/components/Table/getGridTemplateColumns.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type {TableColumn} from './types';
import type {TableColumn, TableData} from './types';

/**
* Builds the CSS grid track list that lays out a table's columns on wide layouts.
*
* A column with a fixed `width` gets a `px` track. Every other column gets an `fr` track sized by its
* `styling.flex`, so a column can claim a larger share of the leftover space than its siblings.
*/
function getGridTemplateColumns<ColumnKey extends string = string>(columns: Array<TableColumn<ColumnKey>>): string[] {
function getGridTemplateColumns<ColumnKey extends string = string, DataType extends TableData = TableData>(columns: Array<TableColumn<ColumnKey, DataType>>): string[] {
return columns.map((column) => (column.width ? `${column.width}px` : `${column.styling?.flex ?? 1}fr`));
}

Expand Down
Loading
Loading