From 39b58d446d9c4707e5320787975a5049dab664bb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 4 Aug 2026 09:48:42 +0530 Subject: [PATCH 1/2] feat: size table columns from their content Signed-off-by: krishna2323 --- src/components/Table/Table.tsx | 27 ++- src/components/Table/TableBody.tsx | 5 +- src/components/Table/TableContext.tsx | 16 +- src/components/Table/TableHeader.tsx | 24 +- src/components/Table/TableRow.tsx | 19 +- .../Table/TableSemanticContainer.tsx | 22 +- .../Table/calculateDynamicColumnWidths.ts | 124 ++++++++++ .../Table/getGridTemplateColumns.ts | 4 +- src/components/Table/types.ts | 52 ++++- .../Table/useDynamicColumnWidths.ts | 220 ++++++++++++++++++ .../Tables/WorkspaceMembersTable/index.tsx | 21 +- src/libs/measureTextWidth/index.native.ts | 10 + src/libs/measureTextWidth/index.ts | 82 +++++++ src/libs/measureTextWidth/types.ts | 23 ++ src/styles/utils/overflow.ts | 3 + .../utils/overflowXAuto/index.native.ts | 6 + src/styles/utils/overflowXAuto/index.ts | 12 + src/styles/utils/overflowXAuto/types.ts | 5 + .../unit/calculateDynamicColumnWidthsTest.ts | 90 +++++++ .../Table/TableSemanticContainerTest.tsx | 4 + 20 files changed, 753 insertions(+), 16 deletions(-) create mode 100644 src/components/Table/calculateDynamicColumnWidths.ts create mode 100644 src/components/Table/useDynamicColumnWidths.ts create mode 100644 src/libs/measureTextWidth/index.native.ts create mode 100644 src/libs/measureTextWidth/index.ts create mode 100644 src/libs/measureTextWidth/types.ts create mode 100644 src/styles/utils/overflowXAuto/index.native.ts create mode 100644 src/styles/utils/overflowXAuto/index.ts create mode 100644 src/styles/utils/overflowXAuto/types.ts create mode 100644 tests/unit/calculateDynamicColumnWidthsTest.ts diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 2ab31d0b53a8..ec061bb551e9 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -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'; @@ -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 @@ -192,6 +194,7 @@ function Table>(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({ + columns, + data, + tableWidth, + isEnabled: shouldUseDynamicColumns && !shouldUseNarrowTableLayout, + // In the wide layout the checkbox column is rendered whenever selection is enabled. + hasSelectionColumn: !!selectionEnabled, + }); + const tableMethods: TableMethods = { ...filterMethods, ...sortMethods, @@ -272,6 +293,8 @@ function Table {children} diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index dcd60258d889..6becfdbd8091 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -72,6 +72,7 @@ function TableBody({contentContainerStyle, style, .. hasSearchString, isEmptyResult, originalDataLength, + dynamicScrollWidth, } = useTableContext(); const {contentContainerStyle: listContentContainerStyle, ListEmptyComponent, ListHeaderComponent, ...restListProps} = listProps ?? {}; @@ -104,7 +105,9 @@ function TableBody({contentContainerStyle, style, .. return ( diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 45bc5169bb53..3ec1264b38ad 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -36,7 +36,19 @@ type TableContextValue>; + columns: Array>; + + /** + * 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 | undefined; @@ -83,6 +95,8 @@ const defaultTableContextValue: TableContextValue = { processedData: [], originalDataLength: 0, columns: [], + dynamicGridTemplateColumns: undefined, + dynamicScrollWidth: undefined, activeFilters: {}, activeSorting: { columnKey: undefined, diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 5ade6ea22a1e..036ae671551a 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -64,8 +64,19 @@ function TableHeader(); + const { + columns, + isEmptyResult, + title, + shouldUseNarrowTableLayout, + tableMethods, + selectionEnabled, + processedData, + isMobileSelectionEnabled, + shouldEnableSelectionInNarrowPaneModal, + dynamicGridTemplateColumns, + dynamicScrollWidth, + } = useTableContext(); // 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; @@ -80,7 +91,9 @@ function TableHeader; + column: TableColumn; isTableSemanticsEnabled: boolean; columnIndex: number; }) { diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 72b22eea2e04..08371bc6439e 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -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 @@ -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; @@ -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 = [ diff --git a/src/components/Table/TableSemanticContainer.tsx b/src/components/Table/TableSemanticContainer.tsx index adf8caa2a717..b06f12a169f3 100644 --- a/src/components/Table/TableSemanticContainer.tsx +++ b/src/components/Table/TableSemanticContainer.tsx @@ -1,5 +1,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; +import type {LayoutChangeEvent} from 'react-native'; + import React from 'react'; import {View} from 'react-native'; @@ -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; }; @@ -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) { @@ -67,7 +82,10 @@ function TableSemanticContainer({isEnabled, title, rowCount, columnCount, render renderedChildren.push( {rowGroup} diff --git a/src/components/Table/calculateDynamicColumnWidths.ts b/src/components/Table/calculateDynamicColumnWidths.ts new file mode 100644 index 000000000000..7c0b7df086ad --- /dev/null +++ b/src/components/Table/calculateDynamicColumnWidths.ts @@ -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}; diff --git a/src/components/Table/getGridTemplateColumns.ts b/src/components/Table/getGridTemplateColumns.ts index 2dcf9f276c0a..1f4df34e63e7 100644 --- a/src/components/Table/getGridTemplateColumns.ts +++ b/src/components/Table/getGridTemplateColumns.ts @@ -1,4 +1,4 @@ -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. @@ -6,7 +6,7 @@ import type {TableColumn} from './types'; * 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(columns: Array>): string[] { +function getGridTemplateColumns(columns: Array>): string[] { return columns.map((column) => (column.width ? `${column.width}px` : `${column.styling?.flex ?? 1}fr`)); } diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index b3bf88bca4ab..4aebd4903070 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -36,12 +36,48 @@ type TableColumnStyling = { labelStyles?: StyleProp; }; +/** + * A run of text inside a cell, described well enough to measure how wide it renders. + */ +type MeasurableCellContent = { + /** The text rendered in the cell. */ + text: string; + + /** Font size the text renders at. Defaults to the app's normal text size. */ + fontSize?: number; + + /** Font weight the text renders at. Defaults to the normal weight. */ + fontWeight?: string; +}; + +/** + * Describes how to size a column from its content, used when the table opts into dynamic column widths via + * `shouldUseDynamicColumns`. Columns that omit this are sized from their header label alone. + */ +type TableColumnDynamicSizing = { + /** + * The text runs rendered in this column's cell for a row. The widest measurement across every row drives the + * column's content width, so return every run that can be the widest one (e.g. both lines of a two-line cell). + */ + getContentToMeasure: (item: DataType) => MeasurableCellContent[]; + + /** Width of the cell's non-text content, e.g. an avatar plus its gap. */ + extraWidth?: number; + + /** Smallest width this column may shrink to. Defaults to the width of its header label. */ + minWidth?: number; + + /** Largest width this column may claim, so one long value can't starve the other columns. */ + maxWidth?: number; +}; + /** * Defines the configuration for a single table column. * * @template ColumnKey - A string literal type representing the valid column keys. + * @template DataType - The type of items in the table's data array. */ -type TableColumn = { +type TableColumn = { /** Unique identifier for the column, used for sorting and data binding. */ key: ColumnKey; @@ -56,6 +92,9 @@ type TableColumn = { /** Optional styling configuration for the column. */ styling?: TableColumnStyling; + + /** Optional configuration for sizing this column from its content. Only read when the table sets `shouldUseDynamicColumns`. */ + dynamicSizing?: TableColumnDynamicSizing; }; type TableRow = DataType & { @@ -156,7 +195,14 @@ type TableProps>; + columns: Array>; + + /** + * Whether columns should be sized from their content instead of being split equally. Columns describe what to + * measure through `TableColumn.dynamicSizing`. Web-only and wide-layout-only: narrow layouts render as cards, and + * native can't measure text synchronously, so both keep the equal-width layout. + */ + shouldUseDynamicColumns?: boolean; /** Optional filter configuration for dropdown filters. */ filters?: FilterConfig; @@ -209,6 +255,8 @@ export type { TableData, TableRow, TableColumn, + TableColumnDynamicSizing, + MeasurableCellContent, TableRenderRowProps, TableMethods, TableHandle, diff --git a/src/components/Table/useDynamicColumnWidths.ts b/src/components/Table/useDynamicColumnWidths.ts new file mode 100644 index 000000000000..6d27d28349f5 --- /dev/null +++ b/src/components/Table/useDynamicColumnWidths.ts @@ -0,0 +1,220 @@ +import measureTextWidth from '@libs/measureTextWidth'; + +import variables from '@styles/variables'; + +import {useMemo} from 'react'; + +import type {DynamicColumnConstraints} from './calculateDynamicColumnWidths'; +import type {TableColumn, TableData} from './types'; + +import calculateDynamicColumnWidths from './calculateDynamicColumnWidths'; + +/** Horizontal margin on the header row and every data row (`styles.mh5`). */ +const ROW_HORIZONTAL_MARGIN = 20; + +/** Horizontal padding inside the header row and every data row on wide layouts (`styles.ph3`). */ +const ROW_HORIZONTAL_PADDING = 12; + +/** Gap between columns (`styles.gap3`). */ +const COLUMN_GAP = 12; + +/** Width the sort arrow adds to a header label (`variables.iconSizeExtraSmall` plus `styles.ml1`). */ +const SORT_ICON_WIDTH = variables.iconSizeExtraSmall + 4; + +/** + * How many of the longest candidate strings are measured per column. Character count is a good but imperfect proxy for + * rendered width in a proportional font, so the longest few are all measured and the widest of them wins. + */ +const MEASURED_CANDIDATES_PER_COLUMN = 5; + +/** + * Share of the available width a single column may claim by default. A column with one very long value would otherwise + * squeeze all of its siblings down to their minimum width. + */ +const DEFAULT_MAX_WIDTH_RATIO = 0.4; + +type UseDynamicColumnWidthsParams = { + /** Column configuration for the table. */ + columns: Array>; + + /** + * The table's rows. This is the unprocessed data rather than the filtered/sorted result, so column widths stay put + * while the user searches or filters instead of reflowing on every keystroke. + */ + data: DataType[]; + + /** Measured width of the area the table renders into, including the rows' own margin and padding. */ + tableWidth: number; + + /** Whether dynamic sizing should run at all. Callers pass `false` on narrow layouts and when they haven't opted in. */ + isEnabled: boolean; + + /** Whether the leading selection checkbox column is rendered, since it takes width from the data columns. */ + hasSelectionColumn: boolean; +}; + +/** + * Measures how wide a column's widest cell content renders, or `null` when the platform can't measure text. + */ +function measureColumnContentWidth(column: TableColumn, data: DataType[]): number | null { + const dynamicSizing = column.dynamicSizing; + + if (!dynamicSizing) { + return 0; + } + + // Text is grouped by font, because the same string renders wider in a larger or bolder font, so the longest string + // overall isn't necessarily the widest one. + const textsByFont = new Map(); + + for (const item of data) { + for (const content of dynamicSizing.getContentToMeasure(item)) { + if (!content.text) { + continue; + } + + const fontKey = `${content.fontSize ?? ''}|${content.fontWeight ?? ''}`; + const existingTexts = textsByFont.get(fontKey); + + if (existingTexts) { + existingTexts.texts.push(content.text); + } else { + textsByFont.set(fontKey, {fontSize: content.fontSize, fontWeight: content.fontWeight, texts: [content.text]}); + } + } + } + + let widestContentWidth = 0; + + for (const {fontSize, fontWeight, texts} of textsByFont.values()) { + // Only the widest string can decide the column's width, and character count is a good (if imperfect) proxy for + // rendered width, so just the longest few strings are measured. + const candidates = [...texts].sort((first, second) => second.length - first.length).slice(0, MEASURED_CANDIDATES_PER_COLUMN); + + for (const text of candidates) { + const width = measureTextWidth(text, {fontSize, fontWeight}); + + if (width === null) { + return null; + } + + widestContentWidth = Math.max(widestContentWidth, width); + } + } + + return widestContentWidth === 0 ? 0 : widestContentWidth + (dynamicSizing.extraWidth ?? 0); +} + +/** + * Measures how wide a column's header label renders, or `null` when the platform can't measure text. The label is + * measured in the bold font the header uses while the column is sorted, so sorting a column never truncates its label. + */ +function measureHeaderLabelWidth(label: string): number | null { + const width = measureTextWidth(label, {fontSize: variables.fontSizeSmall, fontWeight: '700'}); + + if (width === null) { + return null; + } + + return width === 0 ? 0 : width + SORT_ICON_WIDTH; +} + +/** + * Resolves the CSS grid tracks for a table whose columns are sized from their content. + * + * The tracks have to be identical for the header and every data row, because each row is its own grid: a content-based + * CSS track (`max-content`) would resolve per row and misalign the columns. So the widths are measured once here and + * shared, and the result is a plain track list the header and rows both render. + * + * Returns `undefined` when dynamic sizing doesn't apply — it isn't enabled, the table hasn't been measured yet, text + * can't be measured (native), or the content already fits in equal columns. Callers then fall back to the table's + * static tracks. + */ +function useDynamicColumnWidths({ + columns, + data, + tableWidth, + isEnabled, + hasSelectionColumn, +}: UseDynamicColumnWidthsParams): {gridTemplateColumns: string[] | undefined; scrollWidth: number | undefined} { + return useMemo(() => { + const noDynamicWidths = {gridTemplateColumns: undefined, scrollWidth: undefined}; + + if (!isEnabled || tableWidth <= 0) { + return noDynamicWidths; + } + + // A column with a percentage or other non-numeric width can't be subtracted from the budget, so the whole table + // keeps its static tracks rather than being laid out from a wrong budget. + if (columns.some((column) => column.width !== undefined && typeof column.width !== 'number')) { + return noDynamicWidths; + } + + const fixedColumns = columns.filter((column): column is TableColumn & {width: number} => typeof column.width === 'number'); + const dynamicColumns = columns.filter((column) => typeof column.width !== 'number'); + + if (dynamicColumns.length === 0) { + return noDynamicWidths; + } + + const selectionColumnWidth = hasSelectionColumn ? variables.tableCheckboxColumnWidth : 0; + const totalColumnCount = columns.length + (hasSelectionColumn ? 1 : 0); + const totalGapWidth = Math.max(totalColumnCount - 1, 0) * COLUMN_GAP; + const fixedColumnsWidth = fixedColumns.reduce((total, column) => total + column.width, 0); + const rowChromeWidth = (ROW_HORIZONTAL_MARGIN + ROW_HORIZONTAL_PADDING) * 2; + const availableWidth = tableWidth - rowChromeWidth - totalGapWidth - fixedColumnsWidth - selectionColumnWidth; + + if (availableWidth <= 0) { + return noDynamicWidths; + } + + const defaultMaxWidth = Math.max(availableWidth * DEFAULT_MAX_WIDTH_RATIO, availableWidth / dynamicColumns.length); + + const constraints: DynamicColumnConstraints[] = []; + + for (const column of dynamicColumns) { + const contentWidth = measureColumnContentWidth(column, data); + const headerLabelWidth = measureHeaderLabelWidth(column.label); + + // Text measurement is unavailable (native), so the table keeps its static, content-independent tracks. + if (contentWidth === null || headerLabelWidth === null) { + return noDynamicWidths; + } + + constraints.push({ + contentWidth, + minWidth: column.dynamicSizing?.minWidth ?? headerLabelWidth, + maxWidth: column.dynamicSizing?.maxWidth ?? defaultMaxWidth, + }); + } + + const {widths, shouldScrollHorizontally} = calculateDynamicColumnWidths(constraints, availableWidth); + + // The columns fit equally, which is exactly what the static `1fr` tracks already do. + if (widths.length === 0) { + return noDynamicWidths; + } + + let dynamicColumnIndex = 0; + const gridTemplateColumns = columns.map((column) => { + if (typeof column.width === 'number') { + return `${column.width}px`; + } + + const width = widths.at(dynamicColumnIndex) ?? 0; + dynamicColumnIndex++; + return `${width}px`; + }); + + if (!shouldScrollHorizontally) { + return {gridTemplateColumns, scrollWidth: undefined}; + } + + // The rows are wider than the table, so the caller scrolls them horizontally at exactly the width they need. + const scrollWidth = widths.reduce((total, width) => total + width, 0) + fixedColumnsWidth + selectionColumnWidth + totalGapWidth + ROW_HORIZONTAL_PADDING * 2; + + return {gridTemplateColumns, scrollWidth}; + }, [columns, data, tableWidth, isEnabled, hasSelectionColumn]); +} + +export default useDynamicColumnWidths; diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 95c3fb783fea..c99d1c9f850e 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -51,6 +51,9 @@ type WorkspaceMembersTableProps = { onRowSelectionChange: (selectedRowKeys: string[]) => void; }; +/** Width the member cell's avatar and the space after it take before the name and email start. */ +const MEMBER_CELL_AVATAR_WIDTH = variables.avatarSizeSmall + 12; + const WORKSPACE_MEMBER_FILTER_VALUES = { ADMINS: 'admins', APPROVERS: 'approvers', @@ -76,11 +79,20 @@ export default function WorkspaceMembersTable({ const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; - const workspaceMembersColumns: Array> = [ + const workspaceMembersColumns: Array> = [ { key: 'member', label: translate('common.member'), sortable: true, + dynamicSizing: { + // The cell stacks the member's name above their email, so whichever of the two renders wider decides the + // column's width. + getContentToMeasure: (item) => [ + {text: item.name, fontSize: variables.fontSizeNormal}, + {text: item.email, fontSize: variables.fontSizeLabel}, + ], + extraWidth: MEMBER_CELL_AVATAR_WIDTH, + }, }, ...(shouldShowCustomField1Column @@ -89,6 +101,9 @@ export default function WorkspaceMembersTable({ sortable: true, key: 'customField1' as const, label: translate('workspace.common.customField1'), + dynamicSizing: { + getContentToMeasure: (item: WorkspaceMemberRowData) => (item.employeeUserID ? [{text: item.employeeUserID, fontSize: variables.fontSizeNormal}] : []), + }, }, ] : []), @@ -98,6 +113,9 @@ export default function WorkspaceMembersTable({ sortable: true, key: 'customField2' as const, label: translate('workspace.common.customField2'), + dynamicSizing: { + getContentToMeasure: (item: WorkspaceMemberRowData) => (item.employeePayrollID ? [{text: item.employeePayrollID, fontSize: variables.fontSizeNormal}] : []), + }, }, ] : []), @@ -317,6 +335,7 @@ export default function WorkspaceMembersTable({ return ( null; + +export default measureTextWidth; +export type {MeasurableFont, MeasureTextWidth} from './types'; diff --git a/src/libs/measureTextWidth/index.ts b/src/libs/measureTextWidth/index.ts new file mode 100644 index 000000000000..2a07e3fe99f6 --- /dev/null +++ b/src/libs/measureTextWidth/index.ts @@ -0,0 +1,82 @@ +import FontUtils from '@styles/utils/FontUtils'; +import variables from '@styles/variables'; + +import type {MeasurableFont, MeasureTextWidth} from './types'; + +/** + * Upper bound on cached measurements. Text measurement is only used to size layouts, so a coarse cap is enough to keep + * the cache from growing without bound on long-lived sessions. + */ +const MAX_CACHE_SIZE = 5000; + +const measurementCache = new Map(); + +let measurementContext: CanvasRenderingContext2D | null | undefined; + +/** + * Lazily creates the offscreen 2d context used for measurement. Measuring through a canvas costs no layout or reflow, + * unlike measuring by mounting text into the document. + */ +function getMeasurementContext(): CanvasRenderingContext2D | null { + if (measurementContext !== undefined) { + return measurementContext; + } + + if (typeof document === 'undefined') { + measurementContext = null; + return measurementContext; + } + + measurementContext = document.createElement('canvas').getContext('2d'); + return measurementContext; +} + +/** + * Builds a CSS `font` shorthand from the measurable parts of a text style. + */ +function getFontShorthand({fontSize, fontWeight, fontFamily}: MeasurableFont): string { + const size = fontSize ?? variables.fontSizeNormal; + const weight = fontWeight ?? FontUtils.fontWeight.normal; + const family = fontFamily ?? FontUtils.fontFamily.platform.EXP_NEUE.fontFamily; + + return `${weight} ${size}px ${family}`; +} + +/** + * Measures how wide `text` renders in the given font, in px. + * + * Web measures through a canvas, so this is synchronous and does not touch the document's layout. + */ +const measureTextWidth: MeasureTextWidth = (text, font = {}) => { + if (!text) { + return 0; + } + + const context = getMeasurementContext(); + + if (!context) { + return null; + } + + const fontShorthand = getFontShorthand(font); + const cacheKey = `${fontShorthand}|${text}`; + const cachedWidth = measurementCache.get(cacheKey); + + if (cachedWidth !== undefined) { + return cachedWidth; + } + + context.font = fontShorthand; + const width = context.measureText(text).width; + + if (measurementCache.size >= MAX_CACHE_SIZE) { + measurementCache.clear(); + } + + measurementCache.set(cacheKey, width); + + return width; +}; + +export default measureTextWidth; +export type {MeasurableFont, MeasureTextWidth} from './types'; diff --git a/src/libs/measureTextWidth/types.ts b/src/libs/measureTextWidth/types.ts new file mode 100644 index 000000000000..77ac5ecf1ff0 --- /dev/null +++ b/src/libs/measureTextWidth/types.ts @@ -0,0 +1,23 @@ +/** + * The subset of a text style that affects how wide a string renders. + */ +type MeasurableFont = { + /** Font size in px. Defaults to the app's normal text size. */ + fontSize?: number; + + /** CSS font weight, e.g. `'400'` or `'700'`. Defaults to the normal weight. */ + fontWeight?: string; + + /** Font family stack. Defaults to Expensify Neue. */ + fontFamily?: string; +}; + +/** + * Measures how wide `text` renders in the given font, in px. + * + * Returns `null` when the platform cannot measure text synchronously (i.e. everywhere except the web), which callers + * must treat as "no measurement available" and fall back to a layout that doesn't depend on content width. + */ +type MeasureTextWidth = (text: string, font?: MeasurableFont) => number | null; + +export type {MeasurableFont, MeasureTextWidth}; diff --git a/src/styles/utils/overflow.ts b/src/styles/utils/overflow.ts index 219099b6c255..7025aac01bfc 100644 --- a/src/styles/utils/overflow.ts +++ b/src/styles/utils/overflow.ts @@ -1,6 +1,7 @@ import type {ViewStyle} from 'react-native'; import overflowAuto from './overflowAuto'; +import overflowXAuto from './overflowXAuto'; import overflowXHidden from './overflowXHidden'; import overscrollBehaviorContain from './overscrollBehaviorContain'; @@ -32,6 +33,8 @@ export default { overflowXHidden, + overflowXAuto, + overscrollBehaviorContain, overflowAuto, diff --git a/src/styles/utils/overflowXAuto/index.native.ts b/src/styles/utils/overflowXAuto/index.native.ts new file mode 100644 index 000000000000..ffce061d9df0 --- /dev/null +++ b/src/styles/utils/overflowXAuto/index.native.ts @@ -0,0 +1,6 @@ +import type OverflowXAutoStyles from './types'; + +// Horizontal overflow scrolling doesn't exist in react-native, so this is a no-op on native. +const overflowXAuto: OverflowXAutoStyles = {}; + +export default overflowXAuto; diff --git a/src/styles/utils/overflowXAuto/index.ts b/src/styles/utils/overflowXAuto/index.ts new file mode 100644 index 000000000000..cc16d20650a0 --- /dev/null +++ b/src/styles/utils/overflowXAuto/index.ts @@ -0,0 +1,12 @@ +import type OverflowXAutoStyles from './types'; + +/** + * Web-only style. Scrolls horizontally when the content is wider than the container, while leaving vertical scrolling + * to whatever is nested inside. + */ +const overflowXAuto: OverflowXAutoStyles = { + overflowX: 'auto', + overflowY: 'hidden', +}; + +export default overflowXAuto; diff --git a/src/styles/utils/overflowXAuto/types.ts b/src/styles/utils/overflowXAuto/types.ts new file mode 100644 index 000000000000..d09c3b4b20d7 --- /dev/null +++ b/src/styles/utils/overflowXAuto/types.ts @@ -0,0 +1,5 @@ +import type {ViewStyle} from 'react-native'; + +type OverflowXAutoStyles = Pick; + +export default OverflowXAutoStyles; diff --git a/tests/unit/calculateDynamicColumnWidthsTest.ts b/tests/unit/calculateDynamicColumnWidthsTest.ts new file mode 100644 index 000000000000..df4f84054c3e --- /dev/null +++ b/tests/unit/calculateDynamicColumnWidthsTest.ts @@ -0,0 +1,90 @@ +import type {DynamicColumnConstraints} from '@components/Table/calculateDynamicColumnWidths'; +import calculateDynamicColumnWidths from '@components/Table/calculateDynamicColumnWidths'; + +function buildConstraints(contentWidth: number, minWidth = 50, maxWidth = 1000): DynamicColumnConstraints { + return {contentWidth, minWidth, maxWidth}; +} + +function sumOf(values: number[]): number { + return values.reduce((total, value) => total + value, 0); +} + +describe('calculateDynamicColumnWidths', () => { + describe('when no width has to be resolved', () => { + it('keeps equal columns when there are no columns', () => { + expect(calculateDynamicColumnWidths([], 900)).toEqual({widths: [], shouldScrollHorizontally: false}); + }); + + it('keeps equal columns when the table has not been measured yet', () => { + expect(calculateDynamicColumnWidths([buildConstraints(400), buildConstraints(100)], 0)).toEqual({widths: [], shouldScrollHorizontally: false}); + }); + + it('keeps equal columns when every column fits inside an equal share', () => { + // An equal share is 300px and no column needs more than that. + const result = calculateDynamicColumnWidths([buildConstraints(120), buildConstraints(300), buildConstraints(80)], 900); + + expect(result).toEqual({widths: [], shouldScrollHorizontally: false}); + }); + }); + + describe('when the content fits but unevenly', () => { + it('grows the long column, shrinks the short ones, and fills the available width', () => { + const result = calculateDynamicColumnWidths([buildConstraints(600), buildConstraints(100), buildConstraints(80)], 900); + + expect(result.shouldScrollHorizontally).toBe(false); + expect(sumOf(result.widths)).toBe(900); + // Each column gets its content width plus an equal share of the 120px left over. + expect(result.widths).toEqual([640, 140, 120]); + }); + + it('does not let a column grow past its maximum width', () => { + const result = calculateDynamicColumnWidths([buildConstraints(800, 50, 500), buildConstraints(100), buildConstraints(80)], 900); + + expect(result.shouldScrollHorizontally).toBe(false); + expect(sumOf(result.widths)).toBe(900); + // The first column is capped at 500px, so the 220px left over is split equally. + expect(result.widths).toEqual([574, 173, 153]); + }); + + it('gives a column at least its minimum width even when its content is narrower', () => { + const result = calculateDynamicColumnWidths([buildConstraints(600), buildConstraints(10, 200)], 900); + + expect(result.widths.at(1)).toBeGreaterThanOrEqual(200); + expect(sumOf(result.widths)).toBe(900); + }); + }); + + describe('when the content does not fit', () => { + it('shrinks every column toward its minimum width in proportion to its slack', () => { + // 1200px of content in a 700px row, with 200px of that already committed to minimum widths. + const result = calculateDynamicColumnWidths([buildConstraints(900, 100), buildConstraints(300, 100)], 700); + + expect(result.shouldScrollHorizontally).toBe(false); + expect(sumOf(result.widths)).toBe(700); + // 500px of slack is shared out at 50%: 100 + 800 * 0.5 and 100 + 200 * 0.5. + expect(result.widths).toEqual([500, 200]); + }); + + it('pins the columns to their minimum widths and scrolls once even those do not fit', () => { + const result = calculateDynamicColumnWidths([buildConstraints(900, 400), buildConstraints(300, 300)], 600); + + expect(result).toEqual({widths: [400, 300], shouldScrollHorizontally: true}); + }); + + it('does not scroll when the minimum widths add up to exactly the available width', () => { + const result = calculateDynamicColumnWidths([buildConstraints(900, 400), buildConstraints(300, 200)], 600); + + expect(result).toEqual({widths: [400, 200], shouldScrollHorizontally: false}); + }); + }); + + describe('rounding', () => { + it('gives the rounding remainder to the widest column so the columns fill the row exactly', () => { + const result = calculateDynamicColumnWidths([buildConstraints(500), buildConstraints(100), buildConstraints(100)], 701); + + expect(sumOf(result.widths)).toBe(701); + expect(result.widths.every((width) => Number.isInteger(width))).toBe(true); + expect(result.widths.at(0)).toBe(Math.max(...result.widths)); + }); + }); +}); diff --git a/tests/unit/components/Table/TableSemanticContainerTest.tsx b/tests/unit/components/Table/TableSemanticContainerTest.tsx index 7435f7b956ef..bbe5ff8df4e5 100644 --- a/tests/unit/components/Table/TableSemanticContainerTest.tsx +++ b/tests/unit/components/Table/TableSemanticContainerTest.tsx @@ -38,6 +38,8 @@ function renderContainer( rowCount={rowCount} columnCount={4} rendersBodyWhenEmpty={rendersBodyWhenEmpty} + scrollWidth={undefined} + onLayout={undefined} > {children} , @@ -122,6 +124,8 @@ describe('TableSemanticContainer', () => { rowCount={rowCount} columnCount={4} rendersBodyWhenEmpty={false} + scrollWidth={undefined} + onLayout={undefined} > From c010bc6d85ade567196daaa741b0687e90768c0f Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 4 Aug 2026 23:29:47 +0530 Subject: [PATCH 2/2] fix: satisfy knip, React Compiler, and cspell for dynamic column widths Signed-off-by: krishna2323 --- .../Table/calculateDynamicColumnWidths.ts | 2 +- src/components/Table/types.ts | 2 -- src/components/Table/useDynamicColumnWidths.ts | 18 ++++++++---------- src/libs/measureTextWidth/index.native.ts | 1 - src/libs/measureTextWidth/index.ts | 1 - 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/components/Table/calculateDynamicColumnWidths.ts b/src/components/Table/calculateDynamicColumnWidths.ts index 7c0b7df086ad..f329b9eb2933 100644 --- a/src/components/Table/calculateDynamicColumnWidths.ts +++ b/src/components/Table/calculateDynamicColumnWidths.ts @@ -121,4 +121,4 @@ function calculateDynamicColumnWidths(constraints: DynamicColumnConstraints[], a } export default calculateDynamicColumnWidths; -export type {DynamicColumnConstraints, CalculatedDynamicColumnWidths}; +export type {DynamicColumnConstraints}; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 4aebd4903070..51f07715c245 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -255,8 +255,6 @@ export type { TableData, TableRow, TableColumn, - TableColumnDynamicSizing, - MeasurableCellContent, TableRenderRowProps, TableMethods, TableHandle, diff --git a/src/components/Table/useDynamicColumnWidths.ts b/src/components/Table/useDynamicColumnWidths.ts index 6d27d28349f5..1090d6d4743a 100644 --- a/src/components/Table/useDynamicColumnWidths.ts +++ b/src/components/Table/useDynamicColumnWidths.ts @@ -123,7 +123,7 @@ function measureHeaderLabelWidth(label: string): number | null { * Resolves the CSS grid tracks for a table whose columns are sized from their content. * * The tracks have to be identical for the header and every data row, because each row is its own grid: a content-based - * CSS track (`max-content`) would resolve per row and misalign the columns. So the widths are measured once here and + * CSS track (`max-content`) would resolve per row, leaving the columns out of line. So the widths are measured once and * shared, and the result is a plain track list the header and rows both render. * * Returns `undefined` when dynamic sizing doesn't apply — it isn't enabled, the table hasn't been measured yet, text @@ -195,16 +195,14 @@ function useDynamicColumnWidths { - if (typeof column.width === 'number') { - return `${column.width}px`; - } + // Keyed by column rather than tracked with a running index, so the tracks can be built without mutating a counter + // from inside the mapping callback (which the React Compiler can't compile). + const widthByColumnKey = new Map(); + for (const [index, column] of dynamicColumns.entries()) { + widthByColumnKey.set(column.key, widths.at(index) ?? 0); + } - const width = widths.at(dynamicColumnIndex) ?? 0; - dynamicColumnIndex++; - return `${width}px`; - }); + const gridTemplateColumns = columns.map((column) => (typeof column.width === 'number' ? `${column.width}px` : `${widthByColumnKey.get(column.key) ?? 0}px`)); if (!shouldScrollHorizontally) { return {gridTemplateColumns, scrollWidth: undefined}; diff --git a/src/libs/measureTextWidth/index.native.ts b/src/libs/measureTextWidth/index.native.ts index 45ec6086bb5f..c19d4bc0cf40 100644 --- a/src/libs/measureTextWidth/index.native.ts +++ b/src/libs/measureTextWidth/index.native.ts @@ -7,4 +7,3 @@ import type {MeasureTextWidth} from './types'; const measureTextWidth: MeasureTextWidth = () => null; export default measureTextWidth; -export type {MeasurableFont, MeasureTextWidth} from './types'; diff --git a/src/libs/measureTextWidth/index.ts b/src/libs/measureTextWidth/index.ts index 2a07e3fe99f6..2f9c3361be03 100644 --- a/src/libs/measureTextWidth/index.ts +++ b/src/libs/measureTextWidth/index.ts @@ -79,4 +79,3 @@ const measureTextWidth: MeasureTextWidth = (text, font = {}) => { }; export default measureTextWidth; -export type {MeasurableFont, MeasureTextWidth} from './types';