From e7390effcbd1378dc2a357bb2e038a93de6c2024 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 26 Aug 2026 17:18:31 +0200 Subject: [PATCH 01/20] refactor(data-table): add accessibility and column primitives --- .../DataTable/DataTableColumnsContext.tsx | 174 ++++++++++++++++++ src/components/DataTable/DataTableContext.tsx | 42 +++++ src/components/DataTable/columns.ts | 67 +++++++ src/components/DataTable/tokens.ts | 30 +++ .../DataTable/useReflowedNumberOfLines.ts | 20 ++ src/components/DataTable/utils.ts | 101 ++++++++++ .../__tests__/DataTable/utils.test.ts | 114 ++++++++++++ src/utils/webAriaProps.ts | 15 ++ 8 files changed, 563 insertions(+) create mode 100644 src/components/DataTable/DataTableColumnsContext.tsx create mode 100644 src/components/DataTable/DataTableContext.tsx create mode 100644 src/components/DataTable/columns.ts create mode 100644 src/components/DataTable/tokens.ts create mode 100644 src/components/DataTable/useReflowedNumberOfLines.ts create mode 100644 src/components/DataTable/utils.ts create mode 100644 src/components/__tests__/DataTable/utils.test.ts create mode 100644 src/utils/webAriaProps.ts diff --git a/src/components/DataTable/DataTableColumnsContext.tsx b/src/components/DataTable/DataTableColumnsContext.tsx new file mode 100644 index 0000000000..e18e502ecd --- /dev/null +++ b/src/components/DataTable/DataTableColumnsContext.tsx @@ -0,0 +1,174 @@ +import * as React from 'react'; +import type { TextStyle, ViewStyle } from 'react-native'; + +import type { + ColumnLayoutProps, + DataTableColumn, + DataTableColumnAlign, + DataTableLayout, +} from './columns'; +import { useLocale } from '../../core/locale'; + +export type ColumnsContextValue = { + columns: readonly DataTableColumn[]; + byKey: ReadonlyMap; + layout: DataTableLayout; +}; + +export const ColumnsContext = React.createContext( + null +); + +/** + * The position of an element within its `DataTable.Header` or `DataTable.Row`. + * + * Published by the row rather than read from the child list, so it survives + * consumer wrapper components - a `` that renders a + * `DataTable.Cell` resolves its column just like an inline one. + */ +export const ColumnIndexContext = React.createContext(null); + +/** + * Wraps each child in its positional index. Providers render no host node, so + * this leaves layout and the rendered tree untouched. + */ +export const withColumnIndices = (children: React.ReactNode) => + React.Children.map(children, (child, index) => + child == null ? ( + child + ) : ( + + {child} + + ) + ); + +export type ResolvedColumn = { + style: ViewStyle; + align: DataTableColumnAlign; + numeric: boolean; + index?: number; + descriptor?: DataTableColumn; +}; + +/** + * Resolves one cell's column: its shared definition and its layout style. + */ +export const useColumn = ({ + column, + flex, + width, + minWidth, + maxWidth, + align, + numeric, +}: ColumnLayoutProps): ResolvedColumn => { + const context = React.useContext(ColumnsContext); + const positional = React.useContext(ColumnIndexContext); + + const index = typeof column === 'number' ? column : (positional ?? undefined); + + const descriptor = React.useMemo(() => { + if (!context) { + return undefined; + } + + if (typeof column === 'string') { + return context.byKey.get(column); + } + + return index == null ? undefined : context.columns[index]; + }, [context, column, index]); + + const resolvedNumeric = numeric ?? descriptor?.numeric ?? false; + const resolvedAlign = + align ?? descriptor?.align ?? (resolvedNumeric ? 'end' : 'start'); + + const resolvedWidth = width ?? descriptor?.width; + const resolvedFlex = flex ?? descriptor?.flex; + const resolvedMinWidth = minWidth ?? descriptor?.minWidth; + const resolvedMaxWidth = maxWidth ?? descriptor?.maxWidth; + const isFixed = context?.layout === 'fixed'; + + const style = React.useMemo(() => { + // A declared width has to hold even when the row overflows its parent, + // which flexBasis alone does not guarantee. + if (resolvedWidth != null) { + return { + width: resolvedWidth, + flexGrow: 0, + flexShrink: 0, + flexBasis: resolvedWidth, + }; + } + + if (isFixed) { + return { + flexGrow: resolvedFlex ?? 0, + flexShrink: 0, + flexBasis: resolvedMinWidth ?? 0, + minWidth: resolvedMinWidth, + maxWidth: resolvedMaxWidth, + }; + } + + return { + flex: resolvedFlex ?? 1, + minWidth: resolvedMinWidth, + maxWidth: resolvedMaxWidth, + }; + }, [ + resolvedWidth, + resolvedFlex, + resolvedMinWidth, + resolvedMaxWidth, + isFixed, + ]); + + return { + style, + align: resolvedAlign, + numeric: resolvedNumeric, + index, + descriptor, + }; +}; + +export type AlignStyles = { + container: ViewStyle; + text: TextStyle; +}; + +/** + * Container and text alignment for a column. + * + * `justifyContent` is logical and flips with the container's writing + * direction; `textAlign` has to be mapped to a physical value because React + * Native has no `textAlign: 'start' | 'end'`. + */ +export const useAlignStyles = ( + align: DataTableColumnAlign, + numeric: boolean +): AlignStyles => { + const { direction } = useLocale(); + + return React.useMemo(() => { + const isEnd = align === 'end'; + const isRTL = direction === 'rtl'; + + return { + container: { + justifyContent: isEnd + ? 'flex-end' + : align === 'center' + ? 'center' + : 'flex-start', + }, + text: { + textAlign: + align === 'center' ? 'center' : isEnd === isRTL ? 'left' : 'right', + ...(numeric ? { fontVariant: ['tabular-nums' as const] } : null), + }, + }; + }, [align, numeric, direction]); +}; diff --git a/src/components/DataTable/DataTableContext.tsx b/src/components/DataTable/DataTableContext.tsx new file mode 100644 index 0000000000..a8e742988c --- /dev/null +++ b/src/components/DataTable/DataTableContext.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import type { FormatRowPosition } from './utils'; + +/** + * Where a screen reader stops when moving through a table on iOS or Android. + * + * Neither platform has any notion of table semantics - the ARIA roles map to + * no accessibility trait at all - so the structure has to be conveyed through + * composed labels instead. + * + * - `row` (default) - one stop per row, naming every column and the row's + * position. Falls back to `cell` for rows that hold interactive or non-text + * content. + * - `cell` - one stop per cell, each naming its column. + */ +export type NativeFocusMode = 'row' | 'cell'; + +export type DataTableContextValue = { + /** Total rows in the data set, not just the rendered page. */ + rowCount?: number; + /** Names of the columns, indexed by column position. */ + columnLabels: ReadonlyArray; + /** Data rows are offset by one on the web when a header row is present. */ + hasHeader: boolean; + nativeFocusMode: NativeFocusMode; + formatRowPosition: FormatRowPosition | null; + /** Called by `DataTable.Header` to publish the column names it derived. */ + setHeaderLabels: (labels: ReadonlyArray | null) => void; +}; + +export const DataTableContext = + React.createContext(null); + +export type DataTableRowContextValue = { + /** This is the header row, so its cells are column headers. */ + header: boolean; + rowIsFocusUnit: boolean; +}; + +export const DataTableRowContext = + React.createContext(null); diff --git a/src/components/DataTable/columns.ts b/src/components/DataTable/columns.ts new file mode 100644 index 0000000000..d895c78fe0 --- /dev/null +++ b/src/components/DataTable/columns.ts @@ -0,0 +1,67 @@ +export type DataTableColumnAlign = 'start' | 'center' | 'end'; + +/** + * A shared definition of one table column. + * + * Passing these to `DataTable` makes the header and every row agree on width + * and alignment from a single place. + */ +export type DataTableColumn = { + /** + * Stable identifier. Pass the same value as `column` on the matching + * `DataTable.Title` and `DataTable.Cell`. + */ + key: string; + /** + * Flex grow factor. Defaults to 1 when neither `flex` nor `width` is set. + */ + flex?: number; + /** + * Fixed width in dp. Takes precedence over `flex`. + */ + width?: number; + /** + * Minimum width of the column. Only reachable when the table is + * allowed to overflow, i.e. under `layout="fixed"` inside a horizontal + * `ScrollView`. + */ + minWidth?: number; + maxWidth?: number; + /** + * Content alignment within the column. Defaults to `end` for numeric + * columns and `start` otherwise. + */ + align?: DataTableColumnAlign; + /** + * Whether the column holds numbers. Numeric columns use tabular figures, so + * digits line up between rows, and align to `end` unless `align` says + * otherwise. + */ + numeric?: boolean; +}; + +/** + * How a table distributes its columns. + * + * - `fluid` (default) - columns share the table's width through flex. + * - `fixed` - columns keep their declared width and the row may exceed the + * viewport. Wrap the table in a horizontal `ScrollView`. + */ +export type DataTableLayout = 'fluid' | 'fixed'; + +/** + * Layout props shared by `DataTable.Title` and `DataTable.Cell`. Any of them + * overrides the matching field of the shared column definition. + */ +export type ColumnLayoutProps = { + /** + * Which column this belongs to - a `DataTableColumn` key, or a 0-based index. + */ + column?: string | number; + flex?: number; + width?: number; + minWidth?: number; + maxWidth?: number; + align?: DataTableColumnAlign; + numeric?: boolean; +}; diff --git a/src/components/DataTable/tokens.ts b/src/components/DataTable/tokens.ts new file mode 100644 index 0000000000..8f89719418 --- /dev/null +++ b/src/components/DataTable/tokens.ts @@ -0,0 +1,30 @@ +/** Minimum row height. A touch-target floor, not a cap - rows grow with content. */ +export const ROW_MIN_HEIGHT = 48; + +/** Horizontal padding of the header row and of each data row. */ +export const HORIZONTAL_PADDING = 16; + +/** Vertical padding of a column title. */ +export const TITLE_VERTICAL_PADDING = 12; + +/** Vertical padding of a data row, giving wrapped cell content room. */ +export const ROW_VERTICAL_PADDING = 4; + +/** Line height of title and cell text. */ +export const LINE_HEIGHT = 24; + +/** Font size of title text. */ +export const TITLE_FONT_SIZE = 12; + +/** Size of the sort-direction indicator. */ +export const SORT_ICON_SIZE = 16; + +/** + * At or above this OS font scale, titles and cells stop truncating to a single + * line and are allowed to wrap. + * + * MD guidance discourages multiline text in tables + * (https://github.com/callstack/react-native-paper/issues/2381), but at large + * font scales truncating loses content outright, failing WCAG 1.4.4 and 1.4.10. + */ +export const REFLOW_FONT_SCALE = 1.5; diff --git a/src/components/DataTable/useReflowedNumberOfLines.ts b/src/components/DataTable/useReflowedNumberOfLines.ts new file mode 100644 index 0000000000..c943953c7b --- /dev/null +++ b/src/components/DataTable/useReflowedNumberOfLines.ts @@ -0,0 +1,20 @@ +import { useWindowDimensions } from 'react-native'; + +import { REFLOW_FONT_SCALE } from './tokens'; + +/** + * How many lines a title or cell may use. + * + * An explicit value is always honoured. Otherwise text is clamped to one line + * at ordinary font scales, and allowed to wrap once the OS font scale + * gets large enough that clamping would throw content away. + */ +export default function useReflowedNumberOfLines(numberOfLines?: number) { + const { fontScale } = useWindowDimensions(); + + if (numberOfLines != null) { + return numberOfLines || undefined; + } + + return fontScale >= REFLOW_FONT_SCALE ? undefined : 1; +} diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts new file mode 100644 index 0000000000..77dbd8e13a --- /dev/null +++ b/src/components/DataTable/utils.ts @@ -0,0 +1,101 @@ +import * as React from 'react'; + +/** Whether a child is a particular `DataTable` sub-component. */ +export const isDataTableElement =

( + child: React.ReactNode, + displayName: string +): child is React.ReactElement

=> + React.isValidElement(child) && + typeof child.type !== 'string' && + 'displayName' in child.type && + child.type.displayName === displayName; + +/** The text of a node, when it has one. */ +export const getNodeText = (node: React.ReactNode): string | undefined => { + if (typeof node === 'string') { + return node; + } + + if (typeof node === 'number') { + return String(node); + } + + return undefined; +}; + +type LabelledProps = { + 'aria-label'?: string; + accessibilityLabel?: string; + children?: React.ReactNode; +}; + +/** + * The accessible name of a title or cell: an explicit label if given, and + * otherwise its text content. + */ +export const getElementLabel = (props: LabelledProps): string | undefined => + props['aria-label'] ?? + props.accessibilityLabel ?? + getNodeText(props.children); + +/** + * Names a cell by the column it belongs to. + */ +export const composeCellLabel = ({ + columnLabel, + value, +}: { + columnLabel?: string; + value?: string; +}): string | undefined => { + if (value == null) { + return columnLabel; + } + + return columnLabel ? `${columnLabel}, ${value}` : value; +}; + +export type RowPositionInfo = { position: number; rowCount?: number }; +export type FormatRowPosition = (info: RowPositionInfo) => string; + +/** Default wording for a row's position within the table. */ +export const defaultFormatRowPosition: FormatRowPosition = ({ + position, + rowCount, +}) => (rowCount != null ? `row ${position} of ${rowCount}` : `row ${position}`); + +/** Flattens a row into a single announcement. */ +export const composeRowLabel = ({ + cellLabels, + rowIndex, + rowCount, + formatRowPosition, +}: { + cellLabels: ReadonlyArray; + rowIndex?: number; + rowCount?: number; + formatRowPosition?: FormatRowPosition | null; +}): string | undefined => { + const parts = cellLabels.filter((label): label is string => label != null); + + const position = + formatRowPosition && rowIndex != null + ? formatRowPosition({ position: rowIndex + 1, rowCount }) + : undefined; + + if (position) { + parts.push(position); + } + + return parts.length > 0 ? parts.join(', ') : undefined; +}; + +export type SortAccessibilityLabels = { + ascending: string; + descending: string; +}; + +export const defaultSortAccessibilityLabels: SortAccessibilityLabels = { + ascending: 'sorted ascending', + descending: 'sorted descending', +}; diff --git a/src/components/__tests__/DataTable/utils.test.ts b/src/components/__tests__/DataTable/utils.test.ts new file mode 100644 index 0000000000..3f4b91da5f --- /dev/null +++ b/src/components/__tests__/DataTable/utils.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + composeCellLabel, + composeRowLabel, + defaultFormatRowPosition, + getElementLabel, + getNodeText, +} from '../../DataTable/utils'; + +describe('getNodeText', () => { + it('reads strings and numbers', () => { + expect(getNodeText('Cupcake')).toBe('Cupcake'); + expect(getNodeText(356)).toBe('356'); + expect(getNodeText(0)).toBe('0'); + }); + + it('gives up on anything that is not plainly readable', () => { + expect(getNodeText(null)).toBeUndefined(); + expect(getNodeText(undefined)).toBeUndefined(); + expect(getNodeText(['a', 'b'])).toBeUndefined(); + }); +}); + +describe('getElementLabel', () => { + it('prefers an explicit label over the content', () => { + expect( + getElementLabel({ 'aria-label': 'Calories', children: 'kcal' }) + ).toBe('Calories'); + }); + + it('falls back to the content', () => { + expect(getElementLabel({ children: 159 })).toBe('159'); + }); + + it('has no label for content it cannot read', () => { + expect(getElementLabel({ children: [1, 2] })).toBeUndefined(); + }); +}); + +describe('composeCellLabel', () => { + it('names the column the value belongs to', () => { + expect(composeCellLabel({ columnLabel: 'Calories', value: '159' })).toBe( + 'Calories, 159' + ); + }); + + it('falls back to the value alone when the column has no name', () => { + expect(composeCellLabel({ value: '159' })).toBe('159'); + }); + + it('falls back to the column name when there is no value', () => { + expect(composeCellLabel({ columnLabel: 'Calories' })).toBe('Calories'); + expect(composeCellLabel({})).toBeUndefined(); + }); +}); + +describe('defaultFormatRowPosition', () => { + it('states the position within the set', () => { + expect(defaultFormatRowPosition({ position: 3, rowCount: 6 })).toBe( + 'row 3 of 6' + ); + }); + + it('leaves the total out when it is unknown', () => { + expect(defaultFormatRowPosition({ position: 3 })).toBe('row 3'); + }); +}); + +describe('composeRowLabel', () => { + const cellLabels = ['Dessert, Frozen yogurt', 'Calories, 159']; + + it('flattens the cells and the position into one announcement', () => { + expect( + composeRowLabel({ + cellLabels, + rowIndex: 2, + rowCount: 6, + formatRowPosition: defaultFormatRowPosition, + }) + ).toBe('Dessert, Frozen yogurt, Calories, 159, row 3 of 6'); + }); + + it('skips cells that have no label', () => { + expect( + composeRowLabel({ + cellLabels: ['Dessert, Frozen yogurt', undefined], + formatRowPosition: null, + }) + ).toBe('Dessert, Frozen yogurt'); + }); + + it('leaves the position out when it is turned off', () => { + expect( + composeRowLabel({ cellLabels, rowIndex: 2, formatRowPosition: null }) + ).toBe('Dessert, Frozen yogurt, Calories, 159'); + }); + + it('leaves the position out when the row index is unknown', () => { + expect( + composeRowLabel({ + cellLabels, + rowCount: 6, + formatRowPosition: defaultFormatRowPosition, + }) + ).toBe('Dessert, Frozen yogurt, Calories, 159'); + }); + + it('has no label for an empty row', () => { + expect( + composeRowLabel({ cellLabels: [], formatRowPosition: null }) + ).toBeUndefined(); + }); +}); diff --git a/src/utils/webAriaProps.ts b/src/utils/webAriaProps.ts new file mode 100644 index 0000000000..664481b32f --- /dev/null +++ b/src/utils/webAriaProps.ts @@ -0,0 +1,15 @@ +import { Platform } from 'react-native'; + +/** ARIA attributes for the web. */ +export type WebAriaProps = { + 'aria-rowcount'?: number; + 'aria-colcount'?: number; + 'aria-rowindex'?: number; + 'aria-colindex'?: number; + 'aria-sort'?: 'ascending' | 'descending' | 'none' | 'other'; +}; + +/** Returns the given ARIA attributes on the web and nothing anywhere else. */ +export default function webAriaProps(props: WebAriaProps): WebAriaProps { + return Platform.OS === 'web' ? props : {}; +} From 40fd57b4105ab1ba9c1ed5f6925929249667b3c3 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 26 Aug 2026 17:21:50 +0200 Subject: [PATCH 02/20] feat(button): expose aria-expanded --- src/components/Button/Button.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index bfc7782d66..c50e7b66ac 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -89,6 +89,11 @@ export type Props = $Omit, 'mode'> & { * Accessibility role for the button. The "button" role is set by default. */ role?: Role; + /** + * Whether the control the button opens is currently expanded. Set this when + * the button anchors a menu or another disclosure. + */ + 'aria-expanded'?: boolean; /** * Function to execute on press. */ @@ -170,6 +175,7 @@ const Button = ({ textColor: customTextColor, children, 'aria-label': ariaLabel, + 'aria-expanded': ariaExpanded, accessibilityHint, role = 'button', hitSlop, @@ -349,6 +355,7 @@ const Button = ({ aria-label={ariaLabel} accessibilityHint={accessibilityHint} role={role} + aria-expanded={ariaExpanded} aria-disabled={disabled} accessible={accessible} hitSlop={hitSlop} From 5e94593ce57c8c3737d98971af88edb874e4a835 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Fri, 28 Aug 2026 10:44:40 +0200 Subject: [PATCH 03/20] feat(data-table): expose table structure to assistive technology --- example/src/Examples/DataTableExample.tsx | 30 +- src/components/DataTable/DataTable.tsx | 193 +- src/components/DataTable/DataTableCell.tsx | 221 +- src/components/DataTable/DataTableHeader.tsx | 54 +- src/components/DataTable/DataTableRow.tsx | 208 +- src/components/DataTable/DataTableTitle.tsx | 316 +- src/components/DataTable/tokens.ts | 10 - .../DataTable/useReflowedNumberOfLines.ts | 4 +- src/components/DataTable/utils.ts | 11 +- src/components/__tests__/DataTable.test.tsx | 169 - .../__tests__/DataTable/DataTable.test.tsx | 1023 ++++++ .../__snapshots__/DataTable.test.tsx.snap | 1048 ++++++ .../__snapshots__/DataTable.test.tsx.snap | 2828 ----------------- src/index.tsx | 11 + 14 files changed, 2932 insertions(+), 3194 deletions(-) delete mode 100644 src/components/__tests__/DataTable.test.tsx create mode 100644 src/components/__tests__/DataTable/DataTable.test.tsx create mode 100644 src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap delete mode 100644 src/components/__tests__/__snapshots__/DataTable.test.tsx.snap diff --git a/example/src/Examples/DataTableExample.tsx b/example/src/Examples/DataTableExample.tsx index 69858746c2..13ea6c872a 100644 --- a/example/src/Examples/DataTableExample.tsx +++ b/example/src/Examples/DataTableExample.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { StyleSheet } from 'react-native'; import { DataTable, Card } from 'react-native-paper'; +import type { DataTableColumn } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; @@ -12,6 +13,14 @@ type ItemsState = Array<{ fat: number; }>; +// Declared once, outside the component: every title and cell reads its width +// and alignment from here, and the reference has to stay stable. +const columns: readonly DataTableColumn[] = [ + { key: 'name', flex: 2 }, + { key: 'calories', numeric: true }, + { key: 'fat', numeric: true }, +]; + const DataTableExample = () => { const [sortAscending, setSortAscending] = React.useState(true); const [page, setPage] = React.useState(0); @@ -74,26 +83,30 @@ const DataTableExample = () => { return ( - + setSortAscending(!sortAscending)} - style={styles.first} > Dessert - + Calories per piece - Fat (g) + Fat (g) {sortedItems.slice(from, to).map((item) => ( - {item.name} - {item.calories} - {item.fat} + {item.name} + {item.calories} + {item.fat} ))} @@ -120,9 +133,6 @@ const styles = StyleSheet.create({ content: { padding: 8, }, - first: { - flex: 2, - }, }); export default DataTableExample; diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 0707212406..7430cb1739 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -1,8 +1,12 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; +import type { DataTableColumn, DataTableLayout } from './columns'; import DataTableCell from './DataTableCell'; +import { ColumnsContext } from './DataTableColumnsContext'; +import { DataTableContext } from './DataTableContext'; +import type { NativeFocusMode } from './DataTableContext'; import DataTableHeader, { DataTableHeader as _DataTableHeader, } from './DataTableHeader'; @@ -10,15 +14,54 @@ import DataTablePagination, { DataTablePagination as _DataTablePagination, } from './DataTablePagination'; import DataTableRow, { DataTableRow as _DataTableRow } from './DataTableRow'; +import type { Props as DataTableRowProps } from './DataTableRow'; import DataTableTitle, { DataTableTitle as _DataTableTitle, } from './DataTableTitle'; +import { defaultFormatRowPosition, isDataTableElement } from './utils'; +import type { FormatRowPosition } from './utils'; +import webAriaProps from '../../utils/webAriaProps'; export type Props = ViewProps & { /** * Content of the `DataTable`. */ children: React.ReactNode; + /** + * Shared column definitions. The header and every row read width and + * alignment from here. + */ + columns?: readonly DataTableColumn[]; + /** + * How columns are distributed. + * - `fluid` (default) shares the table's width through flex; + * - `fixed` keeps declared widths and lets the row overflow, for use inside + * a horizontal `ScrollView`. + */ + layout?: DataTableLayout; + /** + * Total number of rows in the data set, which can be larger than the number + * rendered when the table is paginated or virtualized. Announced to screen + * readers as the row count. + */ + rowCount?: number; + /** + * Index of the first *rendered* row within the data set. Set this alongside + * `rowCount` when showing a page of a larger set, so row positions are + * announced against the whole set rather than the page. + */ + firstRowIndex?: number; + /** + * Where a screen reader stops when moving through the table on iOS and + * Android, respectively. Defaults to `row`, which announces a whole row at + * once. + */ + nativeFocusMode?: NativeFocusMode; + /** + * Wording of a row's position within the table, used when a row is announced + * as a whole. Pass `null` to leave the position out. + */ + formatRowPosition?: FormatRowPosition | null; style?: StyleProp; }; @@ -30,6 +73,12 @@ export type Props = ViewProps & { * import * as React from 'react'; * import { DataTable } from 'react-native-paper'; * + * const columns = [ + * { key: 'name', flex: 2 }, + * { key: 'calories', numeric: true }, + * { key: 'fat', numeric: true }, + * ]; + * * const MyComponent = () => { * const [page, setPage] = React.useState(0); * const [numberOfItemsPerPageList] = React.useState([2, 3, 4]); @@ -72,18 +121,23 @@ export type Props = ViewProps & { * }, [itemsPerPage]); * * return ( - * + * * * Dessert - * Calories - * Fat + * Calories + * Fat * * * {items.slice(from, to).map((item) => ( * * {item.name} - * {item.calories} - * {item.fat} + * {item.calories} + * {item.fat} * * ))} * @@ -105,11 +159,126 @@ export type Props = ViewProps & { * export default MyComponent; * ``` */ -const DataTable = ({ children, style, ...rest }: Props) => ( - - {children} - -); +const DataTable = ({ + children, + columns, + layout = 'fluid', + rowCount, + firstRowIndex = 0, + nativeFocusMode = 'row', + formatRowPosition = defaultFormatRowPosition, + style, + ...rest +}: Props) => { + const [headerLabels, setHeaderLabels] = React.useState | null>(null); + + if ( + __DEV__ && + layout === 'fixed' && + columns?.some((column) => column.width == null && column.minWidth == null) + ) { + console.warn( + 'DataTable with layout="fixed" needs a `width` or `minWidth` on every column, otherwise columns collapse to their content' + ); + } + + // Covers the common `items.map(...)` shape. Virtualized rows have no children + // to walk, so those pass `index` themselves. + const { rows, renderedRowCount } = React.useMemo(() => { + let rendered = 0; + + const rows = React.Children.map(children, (child) => { + if (!isDataTableElement(child, 'DataTable.Row')) { + return child; + } + + const offset = rendered++; + + return child.props.index === undefined + ? React.cloneElement(child, { index: firstRowIndex + offset }) + : child; + }); + + return { rows, renderedRowCount: rendered }; + }, [children, firstRowIndex]); + + const resolvedRowCount = + rowCount ?? + (renderedRowCount > 0 ? firstRowIndex + renderedRowCount : undefined); + + const hasHeader = headerLabels !== null; + + const columnCount = columns?.length ?? headerLabels?.length; + + const tableContext = React.useMemo( + () => ({ + rowCount: resolvedRowCount, + columnLabels: headerLabels ?? [], + hasHeader, + nativeFocusMode, + formatRowPosition, + setHeaderLabels, + }), + [ + resolvedRowCount, + headerLabels, + hasHeader, + nativeFocusMode, + formatRowPosition, + ] + ); + + const columnsContext = React.useMemo( + () => + columns + ? { + columns, + byKey: new Map(columns.map((column) => [column.key, column])), + layout, + } + : null, + [columns, layout] + ); + + const { 'aria-label': ariaLabel, accessibilityLabel, ...viewProps } = rest; + + const nameProps = + Platform.OS === 'web' + ? { 'aria-label': ariaLabel, accessibilityLabel } + : null; + + const content = ( + + {rows} + + ); + + return ( + + {columnsContext ? ( + + {content} + + ) : ( + content + )} + + ); +}; // @component ./DataTableHeader.tsx DataTable.Header = DataTableHeader; @@ -127,7 +296,7 @@ DataTable.Cell = DataTableCell; DataTable.Pagination = DataTablePagination; const styles = StyleSheet.create({ - container: { + fluid: { width: '100%', }, }); diff --git a/src/components/DataTable/DataTableCell.tsx b/src/components/DataTable/DataTableCell.tsx index ce59206af6..73ed75fef6 100644 --- a/src/components/DataTable/DataTableCell.tsx +++ b/src/components/DataTable/DataTableCell.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { StyleProp, ViewStyle, @@ -7,37 +7,56 @@ import type { GestureResponderEvent, } from 'react-native'; +import type { ColumnLayoutProps } from './columns'; +import { useAlignStyles, useColumn } from './DataTableColumnsContext'; +import { DataTableContext, DataTableRowContext } from './DataTableContext'; +import useReflowedNumberOfLines from './useReflowedNumberOfLines'; +import { composeCellLabel, getElementLabel } from './utils'; import type { $RemoveChildren } from '../../types'; +import hasTouchHandler from '../../utils/hasTouchHandler'; +import webAriaProps from '../../utils/webAriaProps'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; -export type Props = $RemoveChildren & { - /** - * Content of the `DataTableCell`. - */ - children: React.ReactNode; - /** - * Align the text to the right. Generally monetary or number fields are aligned to right. - */ - numeric?: boolean; - /** - * Function to execute on press. - */ - onPress?: (e: GestureResponderEvent) => void; - style?: StyleProp; - /** - * Text content style of the `DataTableCell`. - */ - textStyle?: StyleProp; - /** - * Specifies the largest possible scale a text font can reach. - */ - maxFontSizeMultiplier?: number; - /** - * testID to be used on tests. - */ - testID?: string; -}; +export type Props = $RemoveChildren & + ColumnLayoutProps & { + /** + * Content of the `DataTableCell`. + */ + children: React.ReactNode; + /** + * Whether the column holds numbers. Numeric content uses tabular figures, + * so digits line up between rows, and aligns to the end of the column + * unless `align` says otherwise. + */ + numeric?: boolean; + /** + * Function to execute on press. + */ + onPress?: (e: GestureResponderEvent) => void; + style?: StyleProp; + /** + * Text content style of the `DataTableCell`. + */ + textStyle?: StyleProp; + /** + * The number of lines to show, honoured exactly at every font scale. + * Pass `0` to never clamp. + * + * Only the default is scale-aware: with nothing passed, text is clamped to + * a single line at the default font scale and left unclamped once the user + * has enlarged text, where truncating would drop content. + */ + numberOfLines?: number; + /** + * Specifies the largest possible scale a text font can reach. + */ + maxFontSizeMultiplier?: number; + /** + * testID to be used on tests. + */ + testID?: string; + }; /** * A component to show a single cell inside of a table. @@ -59,8 +78,9 @@ export type Props = $RemoveChildren & { * export default MyComponent; * ``` * - * If you want to support multiline text, please use View instead, as multiline text doesn't comply with - * MD Guidelines (https://github.com/callstack/react-native-paper/issues/2381). + * Cell text is clamped to a single line by default, in line with MD guidance + * (https://github.com/callstack/react-native-paper/issues/2381). Pass + * `numberOfLines` to allow more.. * * @extends TouchableRipple props https://callstack.github.io/react-native-paper/docs/components/TouchableRipple */ @@ -69,23 +89,130 @@ const DataTableCell = ({ textStyle, style, numeric, + column, + flex, + width, + minWidth, + maxWidth, + align, + numberOfLines, maxFontSizeMultiplier, testID, + onPress, + onLongPress, + onPressIn, + onPressOut, + disabled, + accessible, + 'aria-label': ariaLabel, + // Must not reach the plain view a static cell renders as. + rippleColor, + underlayColor, + background, + borderless, + centered, ...rest }: Props) => { + const table = React.useContext(DataTableContext); + const row = React.useContext(DataTableRowContext); + + const resolved = useColumn({ + column, + flex, + width, + minWidth, + maxWidth, + align, + numeric, + }); + const alignStyles = useAlignStyles(resolved.align, resolved.numeric); + const lines = useReflowedNumberOfLines(numberOfLines); + + const interactive = hasTouchHandler({ + onPress, + onLongPress, + onPressIn, + onPressOut, + }); + + const columnLabel = + resolved.index == null ? undefined : table?.columnLabels?.[resolved.index]; + + const value = getElementLabel({ 'aria-label': ariaLabel, children }); + + const isWeb = Platform.OS === 'web'; + + const cellIsFocusUnit = + !isWeb && + !row?.rowIsFocusUnit && + !row?.header && + !interactive && + // An element child renders verbatim; naming the cell would hide its own + // role and state behind this label. + !React.isValidElement(children) && + value != null; + + const label = + ariaLabel ?? (isWeb ? undefined : composeCellLabel({ columnLabel, value })); + + const structuralProps = { + role: row?.header ? ('columnheader' as const) : ('cell' as const), + ...webAriaProps({ + 'aria-colindex': resolved.index == null ? undefined : resolved.index + 1, + }), + accessible: accessible ?? (cellIsFocusUnit || undefined), + 'aria-label': label, + }; + + const containerStyle = [ + styles.container, + resolved.style, + alignStyles.container, + style, + ]; + + const content = ( + + {children} + + ); + + if (!interactive) { + return ( + + {content} + + ); + } + return ( - - {children} - + {content} ); }; @@ -93,12 +220,13 @@ const DataTableCell = ({ const CellContent = ({ children, textStyle, + numberOfLines, maxFontSizeMultiplier, testID, -}: Pick< - Props, - 'children' | 'textStyle' | 'testID' | 'maxFontSizeMultiplier' ->) => { +}: Pick & { + textStyle?: StyleProp; + numberOfLines?: number; +}) => { if (React.isValidElement(children)) { return children; } @@ -106,9 +234,9 @@ const CellContent = ({ return ( {children} @@ -119,14 +247,9 @@ DataTableCell.displayName = 'DataTable.Cell'; const styles = StyleSheet.create({ container: { - flex: 1, flexDirection: 'row', alignItems: 'center', }, - - right: { - justifyContent: 'flex-end', - }, }); export default DataTableCell; diff --git a/src/components/DataTable/DataTableHeader.tsx b/src/components/DataTable/DataTableHeader.tsx index cacad34340..6388b28898 100644 --- a/src/components/DataTable/DataTableHeader.tsx +++ b/src/components/DataTable/DataTableHeader.tsx @@ -2,8 +2,16 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; +import useLatestCallback from 'use-latest-callback'; + +import { withColumnIndices } from './DataTableColumnsContext'; +import { DataTableContext, DataTableRowContext } from './DataTableContext'; +import type { Props as DataTableTitleProps } from './DataTableTitle'; +import { HORIZONTAL_PADDING } from './tokens'; +import { getElementLabel, isDataTableElement } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; +import webAriaProps from '../../utils/webAriaProps'; export type Props = ViewProps & { /** @@ -50,12 +58,48 @@ const DataTableHeader = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const borderBottomColor = theme.colors.surfaceVariant; + const table = React.useContext(DataTableContext); + const borderBottomColor = theme.colors.outlineVariant; + + const labels = React.useMemo(() => { + const labels: Array = []; + + React.Children.forEach(children, (child, index) => { + labels[index] = isDataTableElement( + child, + 'DataTable.Title' + ) + ? getElementLabel(child.props) + : undefined; + }); + + return labels; + }, [children]); + + const setHeaderLabels = table?.setHeaderLabels; + const publish = useLatestCallback(() => setHeaderLabels?.(labels)); + const signature = labels.join(' '); + + React.useEffect(publish, [publish, signature]); + + React.useEffect(() => () => setHeaderLabels?.(null), [setHeaderLabels]); + + const rowContext = React.useMemo( + () => ({ header: true, rowIsFocusUnit: false }), + [] + ); return ( - - {children} - + + + {withColumnIndices(children)} + + ); }; @@ -64,7 +108,7 @@ DataTableHeader.displayName = 'DataTable.Header'; const styles = StyleSheet.create({ header: { flexDirection: 'row', - paddingHorizontal: 16, + paddingHorizontal: HORIZONTAL_PADDING, borderBottomWidth: StyleSheet.hairlineWidth * 2, }, }); diff --git a/src/components/DataTable/DataTableRow.tsx b/src/components/DataTable/DataTableRow.tsx index 51964a5f34..e6d8b7752e 100644 --- a/src/components/DataTable/DataTableRow.tsx +++ b/src/components/DataTable/DataTableRow.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, StyleProp, @@ -7,8 +7,25 @@ import type { ViewStyle, } from 'react-native'; +import type { Props as DataTableCellProps } from './DataTableCell'; +import { withColumnIndices } from './DataTableColumnsContext'; +import { DataTableContext, DataTableRowContext } from './DataTableContext'; +import { + HORIZONTAL_PADDING, + ROW_MIN_HEIGHT, + ROW_VERTICAL_PADDING, +} from './tokens'; +import { + composeCellLabel, + composeRowLabel, + getExplicitLabel, + getNodeText, + isDataTableElement, +} from './utils'; import { useInternalTheme } from '../../core/theming'; import type { $RemoveChildren, ThemeProp } from '../../types'; +import hasTouchHandler from '../../utils/hasTouchHandler'; +import webAriaProps from '../../utils/webAriaProps'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; export type Props = $RemoveChildren & { @@ -20,6 +37,11 @@ export type Props = $RemoveChildren & { * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Index of this row within the data set, counting from 0. Announced to + * screen readers as the row's position. + */ + index?: number; style?: StyleProp; /** * @optional @@ -55,25 +77,181 @@ export type Props = $RemoveChildren & { */ const DataTableRow = ({ onPress, + onLongPress, + onPressIn, + onPressOut, + disabled, style, children, pointerEvents, + index, + accessible, + 'aria-label': ariaLabel, theme: themeOverrides, + // Must not reach the plain view a static row renders as. + rippleColor, + underlayColor, + background, + borderless, + centered, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const borderBottomColor = theme.colors.surfaceVariant; + const table = React.useContext(DataTableContext); + const borderBottomColor = theme.colors.outlineVariant; - return ( - { + const cellLabels: Array = []; + let describable = true; + + React.Children.forEach(children, (child, position) => { + if (child == null || typeof child === 'boolean') { + return; + } + + if (!isDataTableElement(child, 'DataTable.Cell')) { + describable = false; + return; + } + + const { column } = child.props; + const columnIndex = typeof column === 'number' ? column : position; + + if ( + hasTouchHandler({ + onPress: child.props.onPress, + onLongPress: child.props.onLongPress, + onPressIn: child.props.onPressIn, + onPressOut: child.props.onPressOut, + }) + ) { + describable = false; + } + + // An element renders verbatim and carries its own semantics, which may + // include being interactive. A label on the cell says nothing about that, + // so it cannot make the content safe to hide. + if (React.isValidElement(child.props.children)) { + describable = false; + } + + const explicit = getExplicitLabel(child.props); + const text = getNodeText(child.props.children); + + if (explicit == null && text == null) { + describable = false; + } + + cellLabels[columnIndex] = + explicit ?? + composeCellLabel({ + columnLabel: columnLabels?.[columnIndex], + value: text, + }); + }); + + return { cellLabels, describable }; + }, [children, columnLabels]); + + const rowIsFocusUnit = + // Table roles convey nothing on iOS or Android, so the label has to. + Platform.OS !== 'web' && + table?.nativeFocusMode !== 'cell' && + accessible !== false && + describable; + + const label = + ariaLabel ?? + (rowIsFocusUnit + ? composeRowLabel({ + cellLabels, + rowIndex: index, + rowCount: table?.rowCount, + formatRowPosition: table?.formatRowPosition, + }) + : undefined); + + const rowContext = React.useMemo( + () => ({ header: false, rowIsFocusUnit }), + [rowIsFocusUnit] + ); + + const structuralProps = { + // Native maps `button` to a real trait; `row` maps to nothing there. + role: + Platform.OS === 'web' || !interactive + ? ('row' as const) + : ('button' as const), + ...webAriaProps({ + 'aria-rowindex': + index == null ? undefined : index + 1 + (table?.hasHeader ? 1 : 0), + }), + accessible: accessible ?? (rowIsFocusUnit || undefined), + 'aria-label': label, + }; + + const content = ( + - - {children} - - + {withColumnIndices(children)} + + ); + + return ( + + {interactive ? ( + + {content} + + ) : ( + + {content} + + )} + ); }; @@ -83,8 +261,12 @@ const styles = StyleSheet.create({ container: { borderStyle: 'solid', borderBottomWidth: StyleSheet.hairlineWidth, - minHeight: 48, - paddingHorizontal: 16, + minHeight: ROW_MIN_HEIGHT, + paddingHorizontal: HORIZONTAL_PADDING, + paddingVertical: ROW_VERTICAL_PADDING, + }, + static: { + position: 'relative', }, content: { flex: 1, diff --git a/src/components/DataTable/DataTableTitle.tsx b/src/components/DataTable/DataTableTitle.tsx index 4a972341e6..1319f273ca 100644 --- a/src/components/DataTable/DataTableTitle.tsx +++ b/src/components/DataTable/DataTableTitle.tsx @@ -1,5 +1,11 @@ import * as React from 'react'; -import { Animated, PixelRatio, Pressable, StyleSheet } from 'react-native'; +import { + AccessibilityInfo, + Platform, + Pressable, + StyleSheet, + View, +} from 'react-native'; import type { GestureResponderEvent, PressableProps, @@ -8,47 +14,75 @@ import type { ViewStyle, } from 'react-native'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import type { ColumnLayoutProps } from './columns'; +import { useAlignStyles, useColumn } from './DataTableColumnsContext'; +import { + LINE_HEIGHT, + SORT_ICON_SIZE, + TITLE_FONT_SIZE, + TITLE_VERTICAL_PADDING, +} from './tokens'; +import useReflowedNumberOfLines from './useReflowedNumberOfLines'; +import { defaultSortAccessibilityLabels, getElementLabel } from './utils'; +import type { SortAccessibilityLabels } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../types'; +import webAriaProps from '../../utils/webAriaProps'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; import Text from '../Typography/Text'; -export type Props = PressableProps & { - /** - * Text content of the `DataTableTitle`. - */ - children: React.ReactNode; - /** - * Align the text to the right. Generally monetary or number fields are aligned to right. - */ - numeric?: boolean; - /** - * Direction of sorting. An arrow indicating the direction is displayed when this is given. - */ - sortDirection?: 'ascending' | 'descending'; - /** - * The number of lines to show. - */ - numberOfLines?: number; - /** - * Function to execute on press. - */ - onPress?: (e: GestureResponderEvent) => void; - style?: StyleProp; - /** - * Text content style of the `DataTableTitle`. - */ - textStyle?: StyleProp; - /** - * Specifies the largest possible scale a text font can reach. - */ - maxFontSizeMultiplier?: number; - /** - * @optional - */ - theme?: ThemeProp; -}; +export type Props = PressableProps & + ColumnLayoutProps & { + /** + * Text content of the `DataTableTitle`. + */ + children: React.ReactNode; + /** + * Whether the column holds numbers. Numeric content aligns to the end of + * the column unless `align` says otherwise. + */ + numeric?: boolean; + /** + * Direction of sorting. An arrow indicating the direction is displayed when this is given. + */ + sortDirection?: 'ascending' | 'descending'; + /** + * Wording used to announce the sort state, both as part of the column's + * accessible name and in the announcement made when sorting changes. + */ + sortAccessibilityLabels?: SortAccessibilityLabels; + /** + * The number of lines to show. + */ + numberOfLines?: number; + /** + * Function to execute on press. + */ + onPress?: (e: GestureResponderEvent) => void; + style?: StyleProp; + /** + * Text content style of the `DataTableTitle`. + */ + textStyle?: StyleProp; + /** + * Specifies the largest possible scale a text font can reach. + */ + maxFontSizeMultiplier?: number; + /** + * @optional + */ + theme?: ThemeProp; + }; /** * A component to display title in table header. @@ -81,77 +115,193 @@ const DataTableTitle = ({ children, onPress, sortDirection, + sortAccessibilityLabels = defaultSortAccessibilityLabels, textStyle, style, theme: themeOverrides, - numberOfLines = 1, + column, + flex, + width, + minWidth, + maxWidth, + align, + numberOfLines, maxFontSizeMultiplier, + 'aria-label': ariaLabel, + // Must not reach the plain view a static title renders as. + android_ripple, + android_disableSound, + delayLongPress, + pressRetentionOffset, + unstable_pressDelay, + testOnly_pressed, + disabled, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); - const { current: spinAnim } = React.useRef( - new Animated.Value(sortDirection === 'ascending' ? 0 : 1) + const reduceMotion = useReduceMotion(); + + const resolved = useColumn({ + column, + flex, + width, + minWidth, + maxWidth, + align, + numeric, + }); + const alignStyles = useAlignStyles(resolved.align, resolved.numeric); + const lines = useReflowedNumberOfLines(numberOfLines); + + const rotation = useSharedValue(sortDirection === 'ascending' ? 0 : 180); + const isFirstRender = React.useRef(true); + + const { duration, easing } = theme.motion; + + const timingConfig = React.useMemo( + () => ({ + duration: duration.short3, + easing: Easing.bezier(...easing.standard), + reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, + }), + [duration.short3, easing.standard, reduceMotion] ); React.useEffect(() => { - Animated.timing(spinAnim, { - toValue: sortDirection === 'ascending' ? 0 : 1, - duration: 150, - useNativeDriver: true, - }).start(); - }, [sortDirection, spinAnim]); + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + rotation.value = withTiming( + sortDirection === 'ascending' ? 0 : 180, + timingConfig + ); + }, [sortDirection, rotation, timingConfig]); + + const columnLabel = getElementLabel({ 'aria-label': ariaLabel, children }); + + const previousSortDirection = React.useRef(sortDirection); + + React.useEffect(() => { + const previous = previousSortDirection.current; + previousSortDirection.current = sortDirection; + + if (previous === sortDirection || Platform.OS === 'web') { + return; + } + + // Only the column that gained a direction announces, or a toggle would + // announce twice. + if (!sortDirection || !columnLabel) { + return; + } + + AccessibilityInfo.announceForAccessibility( + `${columnLabel}, ${sortAccessibilityLabels[sortDirection]}` + ); + }, [sortDirection, columnLabel, sortAccessibilityLabels]); const textColor = theme.colors.onSurface; const alphaTextColor = theme.colors.onSurfaceVariant; - const spin = spinAnim.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '180deg'], - }); + const iconAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); const icon = sortDirection ? ( - + ) : null; - return ( - + const role = + Platform.OS === 'web' + ? ('columnheader' as const) + : onPress + ? ('button' as const) + : undefined; + + const structuralProps = { + role, + // Native has no column-header semantics, so a title that is not already a + // pressable has to opt in, or its text is absorbed by whichever ancestor + // happens to be focusable and the columns read as one run-on stop. + accessible: Platform.OS === 'web' ? undefined : true, + ...webAriaProps({ + 'aria-colindex': resolved.index == null ? undefined : resolved.index + 1, + // `none` is what advertises a column as sortable but currently unsorted. + 'aria-sort': sortDirection ?? (onPress ? ('none' as const) : undefined), + }), + 'aria-label': + ariaLabel ?? + // On native the sort state has nowhere to go but the name. + (Platform.OS !== 'web' && sortDirection && columnLabel + ? `${columnLabel}, ${sortAccessibilityLabels[sortDirection]}` + : undefined), + }; + + const containerStyle = [ + styles.container, + resolved.style, + alignStyles.container, + style, + ]; + + const content = ( + <> {icon} 1 - ? numeric - ? direction === 'rtl' - ? styles.leftText - : styles.rightText - : styles.centerText - : {}, + alignStyles.text, sortDirection ? styles.sorted : { color: alphaTextColor }, textStyle, ]} - numberOfLines={numberOfLines} + numberOfLines={lines} maxFontSizeMultiplier={maxFontSizeMultiplier} > {children} + + ); + + if (!onPress) { + return ( + + {content} + + ); + } + + return ( + + {content} ); }; @@ -160,33 +310,15 @@ DataTableTitle.displayName = 'DataTable.Title'; const styles = StyleSheet.create({ container: { - flex: 1, flexDirection: 'row', - alignContent: 'center', - paddingVertical: 12, - }, - - rightText: { - textAlign: 'right', - }, - - leftText: { - textAlign: 'left', - }, - - centerText: { - textAlign: 'center', - }, - - right: { - justifyContent: 'flex-end', + alignItems: 'center', + paddingVertical: TITLE_VERTICAL_PADDING, }, cell: { - lineHeight: 24, - fontSize: 12, + lineHeight: LINE_HEIGHT, + fontSize: TITLE_FONT_SIZE, fontWeight: '500', - alignItems: 'center', }, sorted: { @@ -194,7 +326,7 @@ const styles = StyleSheet.create({ }, icon: { - height: 24, + height: LINE_HEIGHT, justifyContent: 'center', }, }); diff --git a/src/components/DataTable/tokens.ts b/src/components/DataTable/tokens.ts index 8f89719418..7e32e3287f 100644 --- a/src/components/DataTable/tokens.ts +++ b/src/components/DataTable/tokens.ts @@ -18,13 +18,3 @@ export const TITLE_FONT_SIZE = 12; /** Size of the sort-direction indicator. */ export const SORT_ICON_SIZE = 16; - -/** - * At or above this OS font scale, titles and cells stop truncating to a single - * line and are allowed to wrap. - * - * MD guidance discourages multiline text in tables - * (https://github.com/callstack/react-native-paper/issues/2381), but at large - * font scales truncating loses content outright, failing WCAG 1.4.4 and 1.4.10. - */ -export const REFLOW_FONT_SCALE = 1.5; diff --git a/src/components/DataTable/useReflowedNumberOfLines.ts b/src/components/DataTable/useReflowedNumberOfLines.ts index c943953c7b..7abae77687 100644 --- a/src/components/DataTable/useReflowedNumberOfLines.ts +++ b/src/components/DataTable/useReflowedNumberOfLines.ts @@ -1,7 +1,5 @@ import { useWindowDimensions } from 'react-native'; -import { REFLOW_FONT_SCALE } from './tokens'; - /** * How many lines a title or cell may use. * @@ -16,5 +14,5 @@ export default function useReflowedNumberOfLines(numberOfLines?: number) { return numberOfLines || undefined; } - return fontScale >= REFLOW_FONT_SCALE ? undefined : 1; + return fontScale > 1 ? undefined : 1; } diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts index 77dbd8e13a..2b9f6e817b 100644 --- a/src/components/DataTable/utils.ts +++ b/src/components/DataTable/utils.ts @@ -29,14 +29,19 @@ type LabelledProps = { children?: React.ReactNode; }; +/** + * A label the consumer set, which is the element's complete accessible name - + * never a value for a column name to be prefixed onto. + */ +export const getExplicitLabel = (props: LabelledProps): string | undefined => + props['aria-label'] ?? props.accessibilityLabel; + /** * The accessible name of a title or cell: an explicit label if given, and * otherwise its text content. */ export const getElementLabel = (props: LabelledProps): string | undefined => - props['aria-label'] ?? - props.accessibilityLabel ?? - getNodeText(props.children); + getExplicitLabel(props) ?? getNodeText(props.children); /** * Names a cell by the column it belongs to. diff --git a/src/components/__tests__/DataTable.test.tsx b/src/components/__tests__/DataTable.test.tsx deleted file mode 100644 index 49f863720a..0000000000 --- a/src/components/__tests__/DataTable.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; - -import { render, screen } from '../../test-utils'; -import Checkbox from '../Checkbox'; -import DataTable from '../DataTable/DataTable'; - -describe('DataTable.Header', () => { - it('renders data table header', async () => { - const tree = ( - await render( - - Dessert - Calories - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); - }); -}); - -describe('DataTable.Title', () => { - it('renders data table title with sort icon', async () => { - const tree = ( - await render( - Dessert - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); - }); - - it('renders right aligned data table title', async () => { - const tree = ( - await render(Calories) - ).toJSON(); - - expect(tree).toMatchSnapshot(); - }); - - it('renders data table title with press handler', async () => { - const tree = ( - await render( - {}}> - Dessert - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); - }); -}); - -describe('DataTable.Cell', () => { - it('renders data table cell', async () => { - const tree = ( - await render(Cupcake) - ).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders right aligned data table cell', async () => { - const tree = ( - await render(356) - ).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders data table cell with text container', async () => { - await render( - Table cell - ); - - expect(screen.getByText('Table cell')).toBeOnTheScreen(); - expect(screen.getByTestId('table-cell-text-container')).toBeOnTheScreen(); - }); - - it('renders data table cell children without text container', async () => { - await render( - - - - ); - - expect( - screen.queryByTestId('table-cell-text-container') - ).not.toBeOnTheScreen(); - }); -}); - -describe('DataTable.Pagination', () => { - it('renders data table pagination', async () => { - const tree = ( - await render( - {}} - /> - ) - ).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders data table pagination with label', async () => { - const tree = ( - await render( - {}} - label="11-20 of 150" - /> - ) - ).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders data table pagination with fast-forward buttons', async () => { - const { toJSON } = await render( - {}} - label="11-20 of 150" - showFastPaginationControls - /> - ); - - expect(screen.getByLabelText('page-first')).toBeOnTheScreen(); - expect(screen.getByLabelText('page-last')).toBeOnTheScreen(); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders data table pagination without options select', async () => { - await render( - {}} - label="11-20 of 150" - showFastPaginationControls - /> - ); - - expect(screen.queryByLabelText('Options Select')).not.toBeOnTheScreen(); - }); - - it('renders data table pagination with options select', async () => { - const { toJSON } = await render( - {}} - label="11-20 of 150" - showFastPaginationControls - numberOfItemsPerPageList={[2, 4, 6]} - numberOfItemsPerPage={2} - onItemsPerPageChange={() => {}} - selectPageDropdownLabel={'Rows per page'} - /> - ); - - expect(screen.getByLabelText('Options Select')).toBeOnTheScreen(); - expect(screen.getByLabelText('selectPageDropdownLabel')).toBeOnTheScreen(); - - expect(toJSON()).toMatchSnapshot(); - }); -}); diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx new file mode 100644 index 0000000000..60f9b21cf5 --- /dev/null +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -0,0 +1,1023 @@ +import { Platform, StyleSheet, useWindowDimensions } from 'react-native'; + +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import * as Reanimated from 'react-native-reanimated'; + +import { LocaleProvider } from '../../../core/locale'; +import PaperProvider from '../../../core/PaperProvider'; +import { getTheme } from '../../../core/theming'; +import { render, screen } from '../../../test-utils'; +import Checkbox from '../../Checkbox'; +import type { DataTableColumn } from '../../DataTable/columns'; +import DataTable from '../../DataTable/DataTable'; + +const columns: readonly DataTableColumn[] = [ + { key: 'name', flex: 2 }, + { key: 'calories', numeric: true }, +]; + +const Table = ({ + children, + ...props +}: Partial> = {}) => ( + + + {}} sortDirection="ascending"> + Dessert + + Calories + + {children ?? ( + + Frozen yogurt + 159 + + )} + +); + +// Cells of a row-focused table are hidden from the accessibility tree on +// purpose, so layout assertions have to look past that. +const hidden = { includeHiddenElements: true }; + +const mockFontScale = (fontScale: number) => { + jest.mocked(useWindowDimensions).mockReturnValue({ + fontScale, + width: 750, + height: 1334, + scale: 2, + }); +}; + +afterEach(() => { + Platform.OS = 'ios'; + mockFontScale(1); +}); + +describe('DataTable', () => { + it('names itself as a table and reports its shape', async () => { + await render(); + + const table = screen.getByTestId('table'); + + expect(table).toHaveProp('role', 'table'); + }); + + it('exposes row and column counts on the web', async () => { + Platform.OS = 'web'; + + await render(
); + + const table = screen.getByTestId('table'); + + // Six data rows plus the header row. + expect(table).toHaveProp('aria-rowcount', 7); + expect(table).toHaveProp('aria-colcount', 2); + }); + + it('does not name the container on native, where it would swallow the rows', async () => { + await render(
); + + const table = screen.getByTestId('table'); + + // A container with an accessibility label becomes a single screen-reader + // stop on Android, hiding every row inside it. + expect(table).not.toHaveProp('aria-label'); + expect(table).not.toHaveProp('accessibilityLabel'); + }); + + it('names the container on the web, where the role makes it meaningful', async () => { + Platform.OS = 'web'; + + await render(
); + + const table = screen.getByTestId('table'); + + expect(table).toHaveProp('aria-label', 'Nutrition'); + }); + + it('keeps grid attributes off native, where they mean nothing', async () => { + await render(
); + + expect(screen.getByTestId('table')).not.toHaveProp('aria-rowcount'); + }); +}); + +describe('DataTable.Row', () => { + it('announces a row as one item naming every column', async () => { + await render(
); + + expect( + screen.getByRole('row', { + name: 'Dessert, Frozen yogurt, Calories, 159, row 3 of 6', + }) + ).toBeOnTheScreen(); + }); + + it('does not repeat the column name when a cell carries its own label', async () => { + await render( +
+ + Frozen yogurt + 159 + +
+ ); + + // Not "Calories, One fifty nine" - the label is already complete. + expect( + screen.getByRole('row', { + name: 'Dessert, Frozen yogurt, One fifty nine, row 3 of 6', + }) + ).toBeOnTheScreen(); + }); + + it('does not report a read-only row as disabled', async () => { + await render(); + + expect( + screen.getByRole('row', { name: /Frozen yogurt/ }) + ).not.toBeDisabled(); + }); + + it('announces a pressable row as a button so it reads as activatable', async () => { + await render( +
+ {}}> + Frozen yogurt + 159 + +
+ ); + + expect( + screen.getByRole('button', { name: /Frozen yogurt/ }) + ).toBeOnTheScreen(); + }); + + it('leaves the position out when the total is unknown', async () => { + await render( + + + Dessert + + + Frozen yogurt + + + ); + + // One rendered row, indexed from 0, so the count is derived as 1. + expect( + screen.getByRole('row', { name: 'Dessert, Frozen yogurt, row 1 of 1' }) + ).toBeOnTheScreen(); + }); + + it('numbers rows against the whole set when only a page is rendered', async () => { + Platform.OS = 'web'; + + await render(); + + // Row index 2 of the data set, 1-based, offset by the header row. + expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 4); + }); + + it('honours an explicitly passed index, as virtualized lists must', async () => { + await render( +
+ + Frozen yogurt + +
+ ); + + expect(screen.getByRole('row', { name: /row 5 of 6/ })).toBeOnTheScreen(); + }); +}); + +describe('DataTable.Cell', () => { + it('falls back to per-cell focus when a cell is interactive', async () => { + await render( + + + {}}>Frozen yogurt + 159 + +
+ ); + + // The row must not swallow the pressable cell. + expect(screen.queryByRole('row', { name: /Frozen yogurt/ })).toBeNull(); + expect( + screen.getByRole('cell', { name: 'Dessert, Frozen yogurt' }) + ).toBeOnTheScreen(); + expect( + screen.getByRole('cell', { name: 'Calories, 159' }) + ).toBeOnTheScreen(); + }); + + it('keeps element content reachable instead of hiding it behind a row label', async () => { + await render( + + + + + + 159 + +
+ ); + + expect(screen.queryByRole('row', { name: /159/ })).toBeNull(); + expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); + }); + + it('does not collapse a row whose cell holds an element, even when labelled', async () => { + await render( + + + Frozen yogurt + + {}} + testID="row-checkbox" + /> + + +
+ ); + + // An element owns its own semantics and may be interactive, so collapsing + // the row would put it out of reach. + expect(screen.queryByRole('row', { name: /Frozen yogurt/ })).toBeNull(); + expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); + }); + + it('does not wrap element content in an accessibility element of its own', async () => { + await render( + + + + + + +
+ ); + + // Making the cell accessible would swallow the checkbox's own state. + expect(screen.getByTestId('cell')).not.toHaveProp('accessible', true); + expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); + }); + + it('treats a cell label as the complete name, not a value to decorate', async () => { + await render( + + + 99 + +
+ ); + + // Not "Dessert, Ninety nine" - an explicit label replaces the composed one. + expect(screen.getByRole('cell', { name: 'Ninety nine' })).toBeOnTheScreen(); + }); + + it('gives one stop per cell under nativeFocusMode="cell"', async () => { + await render(); + + expect(screen.queryByRole('row', { name: /Frozen yogurt/ })).toBeNull(); + expect( + screen.getByRole('cell', { name: 'Dessert, Frozen yogurt' }) + ).toBeOnTheScreen(); + }); + + it('numbers columns on the web', async () => { + Platform.OS = 'web'; + + await render( +
+ + Frozen yogurt + + 159 + + +
+ ); + + expect(screen.getByTestId('first')).toHaveProp('aria-colindex', 1); + expect(screen.getByTestId('second')).toHaveProp('aria-colindex', 2); + expect(screen.getByTestId('second')).toHaveProp('role', 'cell'); + // The roles already say which column a cell is in; repeating it in the + // name would make linear reading twice as long. + expect(screen.getByTestId('first')).not.toHaveProp('aria-label'); + }); + + it('does not invent a testID when none was given', async () => { + await render(Frozen yogurt); + + expect(screen.queryByTestId('undefined-text-container')).toBeNull(); + }); + + it('renders text content inside a text container', async () => { + await render( + Table cell + ); + + expect(screen.getByText('Table cell')).toBeOnTheScreen(); + expect(screen.getByTestId('table-cell-text-container')).toBeOnTheScreen(); + }); + + it('renders element content verbatim, without a text container', async () => { + await render( + + + + ); + + expect( + screen.queryByTestId('table-cell-text-container') + ).not.toBeOnTheScreen(); + }); + + it('lets essential data wrap as soon as text is enlarged at all', async () => { + // Android's display-size setting narrows the layout without moving the + // font scale much, so content truncates well before 2x. + mockFontScale(1.15); + + await render( + Frozen yogurt + ); + + expect(screen.getByTestId('small-bump-text-container')).not.toHaveProp( + 'numberOfLines' + ); + }); + + it('honours an explicit limit when text is enlarged', async () => { + // Asking for 2 lines means 2 lines. Quietly granting more at large scale + // would override an instruction the consumer gave deliberately. + mockFontScale(2); + + await render( + + Frozen yogurt + + ); + + expect(screen.getByTestId('pinned-text-container')).toHaveProp( + 'numberOfLines', + 2 + ); + }); + + it('honours an explicit limit when text is shrunk', async () => { + mockFontScale(0.85); + + await render( + + Frozen yogurt + + ); + + expect(screen.getByTestId('pinned-text-container')).toHaveProp( + 'numberOfLines', + 2 + ); + }); + + it('never clamps a limit of 0, whatever the scale', async () => { + mockFontScale(1); + + await render( + + Frozen yogurt + + ); + + expect(screen.getByTestId('free-text-container')).not.toHaveProp( + 'numberOfLines' + ); + }); + + it('clamps to one line by default and honours an explicit limit', async () => { + await render( + <> + Frozen yogurt + + Frozen yogurt + + + ); + + expect(screen.getByTestId('clamped-text-container')).toHaveProp( + 'numberOfLines', + 1 + ); + expect(screen.getByTestId('wrapping-text-container')).not.toHaveProp( + 'numberOfLines' + ); + }); +}); + +describe('DataTable.Title', () => { + it('does not present an unsortable column as a control', async () => { + await render( + + Calories + + ); + + expect(screen.queryByRole('button')).toBeNull(); + expect(screen.getByTestId('title')).not.toBeDisabled(); + }); + + it('makes every column header its own stop on native', async () => { + await render( + + Calories per piece + + ); + + // Otherwise the header's text is absorbed by whatever ancestor happens to + // be focusable, and the columns are read as one run-on stop. + expect(screen.getByTestId('plain')).toHaveProp('accessible', true); + }); + + it('announces a sortable column and its sort state', async () => { + await render(); + + expect( + screen.getByRole('button', { name: 'Dessert, sorted ascending' }) + ).toBeOnTheScreen(); + }); + + it('takes localized sort wording', async () => { + await render( + + {}} + sortDirection="descending" + sortAccessibilityLabels={{ + ascending: 'rosnąco', + descending: 'malejąco', + }} + > + Dessert + + + ); + + expect( + screen.getByRole('button', { name: 'Dessert, malejąco' }) + ).toBeOnTheScreen(); + }); + + it('exposes sort state and column semantics on the web', async () => { + Platform.OS = 'web'; + + await render( + + {}} + sortDirection="ascending" + > + Dessert + + + Calories + + + ); + + const sortable = screen.getByTestId('sortable'); + + expect(sortable).toHaveProp('role', 'columnheader'); + expect(sortable).toHaveProp('aria-sort', 'ascending'); + expect(sortable).toHaveProp('aria-colindex', 1); + + const plain = screen.getByTestId('plain'); + + expect(plain).toHaveProp('aria-colindex', 2); + // An unsortable column advertises no sort state at all. + expect(plain).not.toHaveProp('aria-sort'); + }); + + it('advertises a sortable but unsorted column on the web', async () => { + Platform.OS = 'web'; + + await render( + + {}}> + Dessert + + + ); + + expect(screen.getByTestId('title')).toHaveProp('aria-sort', 'none'); + }); + + it('does not rotate the sort indicator on first render', async () => { + const withTiming = jest.spyOn(Reanimated, 'withTiming'); + + await render( + {}} sortDirection="descending"> + Calories + + ); + + // The indicator starts at the right angle rather than spinning into it. + expect(withTiming).not.toHaveBeenCalled(); + + withTiming.mockRestore(); + }); + + it('rotates the sort indicator when the direction changes', async () => { + const withTiming = jest.spyOn(Reanimated, 'withTiming'); + + const view = await render( + {}} sortDirection="ascending"> + Calories + + ); + + await view.rerender( + {}} sortDirection="descending"> + Calories + + ); + + expect(withTiming).toHaveBeenCalledWith( + 180, + expect.objectContaining({ + duration: getTheme().motion.duration.short3, + reduceMotion: Reanimated.ReduceMotion.Never, + }) + ); + + withTiming.mockRestore(); + }); + + it('tells Reanimated to suppress the rotation under reduced motion', async () => { + const withTiming = jest.spyOn(Reanimated, 'withTiming'); + + const view = await render( + + {}} sortDirection="ascending"> + Calories + + + ); + + await view.rerender( + + {}} sortDirection="descending"> + Calories + + + ); + + expect(withTiming).toHaveBeenCalledWith( + 180, + expect.objectContaining({ + reduceMotion: Reanimated.ReduceMotion.Always, + }) + ); + + withTiming.mockRestore(); + }); +}); + +describe('DataTable column contract', () => { + it('shares width and alignment from a single definition', async () => { + await render( + + + + Dessert + + + + + Frozen yogurt + + + + ); + + expect(screen.getByTestId('title')).toHaveStyle({ flex: 2 }); + expect(screen.getByTestId('cell', hidden)).toHaveStyle({ flex: 2 }); + }); + + it('lets an explicit style win over the shared definition', async () => { + await render( + + + + Frozen yogurt + + + + ); + + expect(screen.getByTestId('cell', hidden)).toHaveStyle({ flex: 5 }); + }); + + it('resolves columns by position when no key is given', async () => { + await render( + + + Frozen yogurt + 159 + + + ); + + expect(screen.getByTestId('first', hidden)).toHaveStyle({ flex: 2 }); + // The second column declares no flex, so it falls back to 1. + expect(screen.getByTestId('second', hidden)).toHaveStyle({ flex: 1 }); + }); + + it('names cells from the header, with no second declaration', async () => { + await render( + + + Dessert + Calories + + + Frozen yogurt + 159 + + + ); + + expect( + screen.getByRole('cell', { name: 'Calories, 159' }) + ).toBeOnTheScreen(); + }); + + it('names a cell from an explicit label when the header is not text', async () => { + await render( + + + + + + + + Yes + + + ); + + expect( + screen.getByRole('cell', { name: 'Selected, Yes' }) + ).toBeOnTheScreen(); + }); + + it('keeps declared widths when columns must not shrink', async () => { + await render( + + + + Frozen yogurt + + + + ); + + expect(screen.getByTestId('cell', hidden)).toHaveStyle({ + width: 120, + flexShrink: 0, + }); + }); + + it('lays out fluid tables at full width and fixed ones at content width', async () => { + await render( + <> + + + Frozen yogurt + + + + + Frozen yogurt + + + + ); + + expect(screen.getByTestId('fluid')).toHaveStyle({ width: '100%' }); + expect(screen.getByTestId('fixed')).not.toHaveStyle({ width: '100%' }); + }); + + it('warns when a fixed column has no width to hold', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( + + + Frozen yogurt + + + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('layout="fixed"') + ); + + warn.mockRestore(); + }); +}); + +describe('DataTable metrics', () => { + it('separates rows and the header with the divider color role', async () => { + await render( + + + Dessert + + + Frozen yogurt + + + ); + + // The same role `Divider` uses, in both themes. + expect(screen.getByTestId('header')).toHaveStyle({ + borderBottomColor: getTheme().colors.outlineVariant, + }); + expect(screen.getByTestId('row')).toHaveStyle({ + borderBottomColor: getTheme().colors.outlineVariant, + }); + }); + + it('draws the header rule heavier than the row separators', async () => { + await render( + + + Dessert + + + Frozen yogurt + + + ); + + expect(screen.getByTestId('header')).toHaveStyle({ borderBottomWidth: 1 }); + expect(screen.getByTestId('row')).toHaveStyle({ + borderBottomWidth: StyleSheet.hairlineWidth, + }); + }); + + it('keeps rows at a touch-target height with room for wrapped content', async () => { + await render( + + + Frozen yogurt + + + ); + + expect(screen.getByTestId('row')).toHaveStyle({ + minHeight: 48, + paddingHorizontal: 16, + paddingVertical: 4, + }); + }); + + it('gives a static row the same containing block as a pressable one', async () => { + await render( + + + Frozen yogurt + + + ); + + // `TouchableRipple` sets this, so absolutely positioned children resolve + // against the same box whether or not the row is pressable. + expect(screen.getByTestId('static')).toHaveStyle({ position: 'relative' }); + }); +}); + +describe('DataTable alignment', () => { + it('aligns start and numeric columns against the writing direction', async () => { + await render( + <> + Frozen yogurt + + 159 + + + 6 + + + ); + + expect(screen.getByTestId('start')).toHaveStyle({ + justifyContent: 'flex-start', + }); + expect(screen.getByTestId('start-text-container')).toHaveStyle({ + textAlign: 'left', + }); + expect(screen.getByTestId('numeric')).toHaveStyle({ + justifyContent: 'flex-end', + }); + expect(screen.getByTestId('numeric-text-container')).toHaveStyle({ + textAlign: 'right', + // Tabular figures keep digits lined up between rows. + fontVariant: ['tabular-nums'], + }); + expect(screen.getByTestId('center-text-container')).toHaveStyle({ + textAlign: 'center', + }); + }); + + it('mirrors text alignment in right-to-left layouts', async () => { + await render( + + Frozen yogurt + + 159 + + + ); + + expect(screen.getByTestId('start-text-container')).toHaveStyle({ + textAlign: 'right', + }); + expect(screen.getByTestId('numeric-text-container')).toHaveStyle({ + textAlign: 'left', + }); + // `justifyContent` is logical, so it resolves against the direction on its + // own and must not be mirrored here too. + expect(screen.getByTestId('numeric')).toHaveStyle({ + justifyContent: 'flex-end', + }); + }); + + it('treats `numeric` as the data and `align` as the position', async () => { + await render( + <> + + 159 + + + 159 + + + Shipped + + + ); + + // Numbers land at the end of the column unless told otherwise. + expect(screen.getByTestId('default')).toHaveStyle({ + justifyContent: 'flex-end', + }); + + // A centred column of numbers still gets lined-up digits. + expect(screen.getByTestId('centered')).toHaveStyle({ + justifyContent: 'center', + }); + expect(screen.getByTestId('centered-text-container')).toHaveStyle({ + fontVariant: ['tabular-nums'], + }); + + // Non-numeric content can still be end-aligned, without tabular figures. + expect(screen.getByTestId('text')).toHaveStyle({ + justifyContent: 'flex-end', + }); + expect(screen.getByTestId('text-text-container')).not.toHaveStyle({ + fontVariant: ['tabular-nums'], + }); + }); + + it('takes `numeric` from the shared column definition', async () => { + await render( + + + + 159 + + + + ); + + expect(screen.getByTestId('cell', hidden)).toHaveStyle({ + justifyContent: 'flex-end', + }); + }); +}); + +// Snapshots complement the assertions above rather than replacing them: those +// state the contract, these catch structural drift nobody thought to assert. +// Fixtures are kept minimal on purpose - a snapshot too long to read in review +// is a rubber stamp, which is how `aria-disabled="true"` survived in the old +// 2828-line file. +describe('DataTable snapshots', () => { + it('renders a table', async () => { + const tree = ( + await render( + + + Dessert + + + Frozen yogurt + + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders a header', async () => { + const tree = ( + await render( + + Dessert + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders a sortable title', async () => { + const tree = ( + await render( + {}} sortDirection="descending"> + Dessert + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders a static title', async () => { + const tree = ( + await render(Calories) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders a static row', async () => { + const tree = ( + await render( + + Frozen yogurt + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders a pressable row', async () => { + const tree = ( + await render( + {}}> + Frozen yogurt + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders a cell', async () => { + const tree = ( + await render(159) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders pagination', async () => { + const tree = ( + await render( + {}} + label="1-2 of 6" + /> + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap new file mode 100644 index 0000000000..389ba226a9 --- /dev/null +++ b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap @@ -0,0 +1,1048 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataTable snapshots renders a cell 1`] = ` + + + 159 + + +`; + +exports[`DataTable snapshots renders a header 1`] = ` + + + + Dessert + + + +`; + +exports[`DataTable snapshots renders a pressable row 1`] = ` + + + + + Frozen yogurt + + + + +`; + +exports[`DataTable snapshots renders a sortable title 1`] = ` + + + + arrow-up + + + + Dessert + + +`; + +exports[`DataTable snapshots renders a static row 1`] = ` + + + + + Frozen yogurt + + + + +`; + +exports[`DataTable snapshots renders a static title 1`] = ` + + + Calories + + +`; + +exports[`DataTable snapshots renders a table 1`] = ` + + + + + Dessert + + + + + + + + Frozen yogurt + + + + + +`; + +exports[`DataTable snapshots renders pagination 1`] = ` + + + 1-2 of 6 + + + + + + + + chevron-left + + + + + + + + + + + chevron-right + + + + + + + +`; diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap deleted file mode 100644 index e9bc774f78..0000000000 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ /dev/null @@ -1,2828 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DataTable.Cell renders data table cell 1`] = ` - - - Cupcake - - -`; - -exports[`DataTable.Cell renders right aligned data table cell 1`] = ` - - - 356 - - -`; - -exports[`DataTable.Header renders data table header 1`] = ` - - - - Dessert - - - - - Calories - - - -`; - -exports[`DataTable.Pagination renders data table pagination 1`] = ` - - - - - - - - - chevron-left - - - - - - - - - - - chevron-right - - - - - - - -`; - -exports[`DataTable.Pagination renders data table pagination with fast-forward buttons 1`] = ` - - - 11-20 of 150 - - - - - - - - page-first - - - - - - - - - - - chevron-left - - - - - - - - - - - chevron-right - - - - - - - - - - - page-last - - - - - - - -`; - -exports[`DataTable.Pagination renders data table pagination with label 1`] = ` - - - 11-20 of 150 - - - - - - - - chevron-left - - - - - - - - - - - chevron-right - - - - - - - -`; - -exports[`DataTable.Pagination renders data table pagination with options select 1`] = ` - - - - Rows per page - - - - - - - - - menu-down - - - - 2 - - - - - - - - - 11-20 of 150 - - - - - - - - page-first - - - - - - - - - - - chevron-left - - - - - - - - - - - chevron-right - - - - - - - - - - - page-last - - - - - - - -`; - -exports[`DataTable.Title renders data table title with press handler 1`] = ` - - - - arrow-up - - - - Dessert - - -`; - -exports[`DataTable.Title renders data table title with sort icon 1`] = ` - - - - arrow-up - - - - Dessert - - -`; - -exports[`DataTable.Title renders right aligned data table title 1`] = ` - - - Calories - - -`; diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..95318e5883 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -84,6 +84,17 @@ export type { Props as DataTableHeaderProps } from './components/DataTable/DataT export type { Props as DataTablePaginationProps } from './components/DataTable/DataTablePagination'; export type { Props as DataTableRowProps } from './components/DataTable/DataTableRow'; export type { Props as DataTableTitleProps } from './components/DataTable/DataTableTitle'; +export type { + ColumnLayoutProps as DataTableColumnLayoutProps, + DataTableColumn, + DataTableColumnAlign, + DataTableLayout, +} from './components/DataTable/columns'; +export type { NativeFocusMode as DataTableNativeFocusMode } from './components/DataTable/DataTableContext'; +export type { + FormatRowPosition as DataTableFormatRowPosition, + SortAccessibilityLabels as DataTableSortAccessibilityLabels, +} from './components/DataTable/utils'; export type { Props as DialogProps } from './components/Dialog/Dialog'; export type { Props as DialogActionsProps } from './components/Dialog/DialogActions'; export type { Props as DialogContentProps } from './components/Dialog/DialogContent'; From 5909225515348000511a9197a421ab1557eb98b6 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Fri, 28 Aug 2026 11:54:37 +0200 Subject: [PATCH 04/20] fix(data-table): give pagination controls real accessible names --- .../DataTable/DataTablePagination.tsx | 120 +++++++++--- .../__tests__/DataTable/DataTable.test.tsx | 175 ++++++++++++++++++ .../__snapshots__/DataTable.test.tsx.snap | 11 +- src/index.tsx | 1 + 4 files changed, 275 insertions(+), 32 deletions(-) diff --git a/src/components/DataTable/DataTablePagination.tsx b/src/components/DataTable/DataTablePagination.tsx index 32f4b8b4fb..79a38ebcf6 100644 --- a/src/components/DataTable/DataTablePagination.tsx +++ b/src/components/DataTable/DataTablePagination.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; +import { HORIZONTAL_PADDING } from './tokens'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -11,6 +12,51 @@ import MaterialCommunityIcon from '../MaterialCommunityIcon'; import Menu from '../Menu/Menu'; import Text from '../Typography/Text'; +export type DataTablePaginationLabels = { + /** + * Accessible name for the pagination region. Used on the web, where it names + * a real ARIA group. Defaults to `'Pagination'`. + */ + container?: string; + /** + * Accessible name for the rows-per-page selector. Defaults to + * `'Rows per page'`. + */ + itemsPerPage?: string; + /** + * Defaults to `'First page'`. + */ + firstPage?: string; + /** + * Defaults to `'Previous page'`. + */ + previousPage?: string; + /** + * Defaults to `'Next page'`. + */ + nextPage?: string; + /** + * Defaults to `'Last page'`. + */ + lastPage?: string; + /** + * Accessible name for the visible-range label, used when no `label` is + * given. Receives 1-based page numbers. Defaults to + * `` ({ page, numberOfPages }) => `Page ${page} of ${numberOfPages}` ``. + */ + pageStatus?: (info: { page: number; numberOfPages: number }) => string; +}; + +const defaultLabels: Required = { + container: 'Pagination', + itemsPerPage: 'Rows per page', + firstPage: 'First page', + previousPage: 'Previous page', + nextPage: 'Next page', + lastPage: 'Last page', + pageStatus: ({ page, numberOfPages }) => `Page ${page} of ${numberOfPages}`, +}; + export type Props = ViewProps & PaginationControlsProps & PaginationDropdownProps & { @@ -30,6 +76,10 @@ export type Props = ViewProps & * AccessibilityLabel for `label`. */ 'aria-label'?: string; + /** + * Wording of the controls' accessible names. Pass this to localize them. + */ + labels?: DataTablePaginationLabels; style?: StyleProp; /** * @optional @@ -84,8 +134,11 @@ const PaginationControls = ({ numberOfPages, onPageChange, showFastPaginationControls, + labels, theme: themeOverrides, -}: PaginationControlsProps) => { +}: PaginationControlsProps & { + labels: Required; +}) => { const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); @@ -106,7 +159,7 @@ const PaginationControls = ({ iconColor={textColor} disabled={page === 0} onPress={() => onPageChange(0)} - aria-label="page-first" + aria-label={labels.firstPage} theme={theme} /> ) : null} @@ -122,7 +175,7 @@ const PaginationControls = ({ iconColor={textColor} disabled={page === 0} onPress={() => onPageChange(page - 1)} - aria-label="chevron-left" + aria-label={labels.previousPage} theme={theme} /> onPageChange(page + 1)} - aria-label="chevron-right" + aria-label={labels.nextPage} theme={theme} /> {showFastPaginationControls ? ( @@ -153,7 +206,7 @@ const PaginationControls = ({ iconColor={textColor} disabled={numberOfPages === 0 || page === numberOfPages - 1} onPress={() => onPageChange(numberOfPages - 1)} - aria-label="page-last" + aria-label={labels.lastPage} theme={theme} /> ) : null} @@ -165,8 +218,11 @@ const PaginationDropdown = ({ numberOfItemsPerPageList, numberOfItemsPerPage, onItemsPerPageChange, + labels, theme: themeOverrides, -}: PaginationDropdownProps) => { +}: PaginationDropdownProps & { + labels: Required; +}) => { const theme = useInternalTheme(themeOverrides); const { colors } = theme; const [showSelect, toggleSelect] = React.useState(false); @@ -183,6 +239,8 @@ const PaginationDropdown = ({ style={styles.button} icon="menu-down" contentStyle={styles.contentStyle} + aria-label={`${labels.itemsPerPage}, ${numberOfItemsPerPage}`} + aria-expanded={showSelect} theme={theme} > {`${numberOfItemsPerPage}`} @@ -267,6 +325,7 @@ const PaginationDropdown = ({ const DataTablePagination = ({ label, 'aria-label': accessibilityLabel, + labels: labelOverrides, page, numberOfPages, onPageChange, @@ -283,28 +342,30 @@ const DataTablePagination = ({ const theme = useInternalTheme(themeOverrides); const labelColor = theme.colors.onSurfaceVariant; + const labels = React.useMemo( + () => ({ ...defaultLabels, ...labelOverrides }), + [labelOverrides] + ); + + const isWeb = Platform.OS === 'web'; + const regionProps = isWeb + ? { role: 'group' as const, 'aria-label': labels.container } + : null; + + const textIsFocusUnit = isWeb ? undefined : true; + return ( - + {numberOfItemsPerPageList && numberOfItemsPerPage && onItemsPerPageChange && ( - + {selectPageDropdownLabel} @@ -312,6 +373,7 @@ const DataTablePagination = ({ numberOfItemsPerPageList={numberOfItemsPerPageList} numberOfItemsPerPage={numberOfItemsPerPage} onItemsPerPageChange={onItemsPerPageChange} + labels={labels} theme={theme} /> @@ -319,7 +381,13 @@ const DataTablePagination = ({ {label} @@ -329,6 +397,7 @@ const DataTablePagination = ({ onPageChange={onPageChange} page={page} numberOfPages={numberOfPages} + labels={labels} theme={theme} /> @@ -343,7 +412,7 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', flexDirection: 'row', alignItems: 'center', - paddingLeft: 16, + paddingStart: HORIZONTAL_PADDING, flexWrap: 'wrap', }, optionsContainer: { @@ -353,11 +422,10 @@ const styles = StyleSheet.create({ }, label: { fontSize: 12, - marginRight: 16, + marginEnd: HORIZONTAL_PADDING, }, button: { - textAlign: 'center', - marginRight: 16, + marginEnd: HORIZONTAL_PADDING, }, iconsContainer: { flexDirection: 'row', diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index 60f9b21cf5..96d5f883a5 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -1021,3 +1021,178 @@ describe('DataTable snapshots', () => { expect(tree).toMatchSnapshot(); }); }); + +describe('DataTable.Pagination', () => { + it('does not name its containers on native, where they would swallow the controls', async () => { + await render( + {}} + label="1-2 of 6" + numberOfItemsPerPageList={[2, 4]} + numberOfItemsPerPage={2} + onItemsPerPageChange={() => {}} + selectPageDropdownLabel="Rows per page" + /> + ); + + // Same bug as the table container: an accessibility label on a view makes + // it one screen-reader stop on Android, hiding the buttons inside it. + expect(screen.getByTestId('pager')).not.toHaveProp('aria-label'); + expect(screen.getByTestId('options-select')).not.toHaveProp('aria-label'); + }); + + it('names the pagination region on the web', async () => { + Platform.OS = 'web'; + + await render( + {}} + /> + ); + + const pager = screen.getByTestId('pager'); + + expect(pager).toHaveProp('role', 'group'); + expect(pager).toHaveProp('aria-label', 'Pagination'); + }); + + it('makes its text labels their own stops on native', async () => { + await render( + {}} + label="1-2 of 6" + numberOfItemsPerPageList={[2, 4]} + numberOfItemsPerPage={2} + onItemsPerPageChange={() => {}} + selectPageDropdownLabel="Rows per page" + /> + ); + + // Unclaimed text is merged into whatever ancestor is focusable, which on + // native is the enclosing scroll view - the whole screen. + expect(screen.getByTestId('select-page-dropdown-label')).toHaveProp( + 'accessible', + true + ); + expect(screen.getByText('1-2 of 6')).toHaveProp('accessible', true); + }); + + it('gives every control a human name', async () => { + await render( + {}} + label="11-20 of 150" + showFastPaginationControls + /> + ); + + expect(screen.getByLabelText('First page')).toBeOnTheScreen(); + expect(screen.getByLabelText('Previous page')).toBeOnTheScreen(); + expect(screen.getByLabelText('Next page')).toBeOnTheScreen(); + expect(screen.getByLabelText('Last page')).toBeOnTheScreen(); + }); + + it('takes localized wording for every control', async () => { + await render( + {}} + label="11-20 of 150" + showFastPaginationControls + labels={{ + firstPage: 'Pierwsza strona', + lastPage: 'Ostatnia strona', + }} + /> + ); + + expect(screen.getByLabelText('Pierwsza strona')).toBeOnTheScreen(); + expect(screen.getByLabelText('Ostatnia strona')).toBeOnTheScreen(); + // Untouched entries keep their defaults. + expect(screen.getByLabelText('Next page')).toBeOnTheScreen(); + }); + + it('names the page position when there is no visible range', async () => { + await render( + {}} + /> + ); + + expect(screen.getByLabelText('Page 4 of 15')).toBeOnTheScreen(); + }); + + it('lets the visible range speak for itself', async () => { + await render( + {}} + label="11-20 of 150" + /> + ); + + expect(screen.queryByLabelText('Page 4 of 15')).toBeNull(); + expect(screen.getByText('11-20 of 150')).toBeOnTheScreen(); + }); + + it('renders the rows-per-page selector only when it can work', async () => { + const view = await render( + {}} + label="11-20 of 150" + /> + ); + + expect(screen.queryByTestId('options-select')).not.toBeOnTheScreen(); + + await view.rerender( + {}} + label="11-20 of 150" + numberOfItemsPerPageList={[2, 4, 6]} + numberOfItemsPerPage={2} + onItemsPerPageChange={() => {}} + selectPageDropdownLabel="Rows per page" + /> + ); + + expect(screen.getByTestId('options-select')).toBeOnTheScreen(); + expect(screen.getByTestId('select-page-dropdown-label')).toBeOnTheScreen(); + }); + + it('announces the selected page size and that it opens a menu', async () => { + await render( + {}} + numberOfItemsPerPageList={[2, 4, 6]} + numberOfItemsPerPage={2} + onItemsPerPageChange={() => {}} + selectPageDropdownLabel="Rows per page" + /> + ); + + expect( + screen.getByRole('button', { name: 'Rows per page, 2', expanded: false }) + ).toBeOnTheScreen(); + }); +}); diff --git a/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap index 389ba226a9..b2cfc454ea 100644 --- a/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap @@ -711,7 +711,6 @@ exports[`DataTable snapshots renders a table 1`] = ` exports[`DataTable snapshots renders pagination 1`] = ` Date: Fri, 28 Aug 2026 13:23:48 +0200 Subject: [PATCH 05/20] doc(data-table): update migration docs --- docs/6.x/docs/guides/migration.md | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 1fcd32bd25..ed9032e67e 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -126,3 +126,112 @@ const theme = { style={{ fontSize: 16, color: '#1C1B1F' }} /> ``` + +### DataTable + +The Paper 6.x `DataTable` adds table semantics. The structure it produces and the accessible names it exposes have both changed. Existing tables should still be working. + +#### Touch handling + +Rows, cells and titles with no touch handler render a plain `View` instead of a disabled touchable + +```tsx +// Before (v5): announced as a disabled control + + {item.name} + + +// After (v6): pass a handler if the row is meant to be pressable + select(item)}> + {item.name} + +``` + +#### Screen reader announcements + +- new `rowCount`, `firstRowIndex` needed for correct row positions when paginating +- `nativeFocusMode="cell"` gives one stop per cell instead of one per row +- `accessible={false}` on a row opts that row out +- `formatRowPosition` replaces the wording, or removes it with `null` + +```tsx +// Before (v5) + + {items.slice(from, to).map((item) => ( + {/* ... */} + ))} + + +// After (v6) + + {items.slice(from, to).map((item) => ( + {/* ... */} + ))} + +``` + +#### Pagination labels + +- `labels` is new, and localizes every control +- `aria-label="pagination-container"` and `aria-label="Options Select"` were removed; query `testID="options-select"` instead + +```tsx +// After (v6) + + `Strona ${page} z ${numberOfPages}`, + }} + /* ... */ +/> +``` + +#### Alignment + +- `numeric` is unchanged, and now also applies tabular figures +- `align` is new, accepts `'start'`, `'center'`, `'end'` + +```tsx +// Before (v5): right-aligned +{item.calories} + +// After (v6): right-aligned, plus lined-up digits +{item.calories} + +// Centred, still with lined-up digits +{item.calories} + +// Right-aligned text that is not numeric +{item.status} +``` + +`align` defaults to `'end'` for numeric columns and `'start'` otherwise. + +#### Text wrapping + +- **single line, always** → single line at the default font scale, unclamped above it +- `numberOfLines` is honoured exactly at every font scale; pass `0` to never clamp + +#### Column definitions + +- `columns` on `DataTable` is new and optional +- `column` on a title or cell selects one by key, and is only needed where position is unreliable + +```tsx +// Before (v5) +const styles = StyleSheet.create({ first: { flex: 2 } }); + +Dessert +{item.name} + +// After (v6) +const columns = [{ key: 'name', flex: 2 }, { key: 'calories', numeric: true }]; + + + Dessert + {item.name} + +``` From a91122e3aff9871e22253f13a8473ffea06e544f Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Tue, 1 Sep 2026 16:38:17 +0200 Subject: [PATCH 06/20] fix(data-table): adress the pr comments --- src/components/DataTable/DataTable.tsx | 72 +++++++++++-------- src/components/DataTable/DataTableCell.tsx | 7 +- src/components/DataTable/DataTableContext.tsx | 11 ++- src/components/DataTable/DataTableHeader.tsx | 30 +------- src/components/DataTable/DataTableRow.tsx | 25 +++++-- src/components/DataTable/utils.ts | 20 ++++++ .../__tests__/DataTable/DataTable.test.tsx | 58 +++++++++++++++ .../__snapshots__/DataTable.test.tsx.snap | 3 - 8 files changed, 159 insertions(+), 67 deletions(-) diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 7430cb1739..d2232eb36f 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -5,20 +5,24 @@ import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; import type { DataTableColumn, DataTableLayout } from './columns'; import DataTableCell from './DataTableCell'; import { ColumnsContext } from './DataTableColumnsContext'; -import { DataTableContext } from './DataTableContext'; +import { DataTableContext, RowIndexContext } from './DataTableContext'; import type { NativeFocusMode } from './DataTableContext'; import DataTableHeader, { DataTableHeader as _DataTableHeader, } from './DataTableHeader'; +import type { Props as DataTableHeaderProps } from './DataTableHeader'; import DataTablePagination, { DataTablePagination as _DataTablePagination, } from './DataTablePagination'; import DataTableRow, { DataTableRow as _DataTableRow } from './DataTableRow'; -import type { Props as DataTableRowProps } from './DataTableRow'; import DataTableTitle, { DataTableTitle as _DataTableTitle, } from './DataTableTitle'; -import { defaultFormatRowPosition, isDataTableElement } from './utils'; +import { + defaultFormatRowPosition, + isDataTableElement, + readColumnLabels, +} from './utils'; import type { FormatRowPosition } from './utils'; import webAriaProps from '../../utils/webAriaProps'; @@ -170,10 +174,6 @@ const DataTable = ({ style, ...rest }: Props) => { - const [headerLabels, setHeaderLabels] = React.useState | null>(null); - if ( __DEV__ && layout === 'fixed' && @@ -184,46 +184,62 @@ const DataTable = ({ ); } - // Covers the common `items.map(...)` shape. Virtualized rows have no children - // to walk, so those pass `index` themselves. - const { rows, renderedRowCount } = React.useMemo(() => { - let rendered = 0; + // Everything the rows need to know about the table has to be resolved while + // rendering: an effect would leave the first render - the only one a server + // renders - with the wrong row indices and no column names. + const { rows, columnLabels, hasHeader, renderedRowCount } = + React.useMemo(() => { + let columnLabels: Array = []; + let hasHeader = false; + let rendered = 0; + + const rows = React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } - const rows = React.Children.map(children, (child) => { - if (!isDataTableElement(child, 'DataTable.Row')) { - return child; - } + if ( + isDataTableElement(child, 'DataTable.Header') + ) { + hasHeader = true; + columnLabels = readColumnLabels(child.props.children); + return child; + } - const offset = rendered++; + if (isDataTableElement(child, 'DataTable.Pagination')) { + return child; + } - return child.props.index === undefined - ? React.cloneElement(child, { index: firstRowIndex + offset }) - : child; - }); + // Anything else is taken for a row. Virtualized rows have no children + // here to number, so those pass `index` themselves. + return ( + + {child} + + ); + }); - return { rows, renderedRowCount: rendered }; - }, [children, firstRowIndex]); + return { rows, columnLabels, hasHeader, renderedRowCount: rendered }; + }, [children, firstRowIndex]); const resolvedRowCount = rowCount ?? (renderedRowCount > 0 ? firstRowIndex + renderedRowCount : undefined); - const hasHeader = headerLabels !== null; - - const columnCount = columns?.length ?? headerLabels?.length; + const columnCount = + columns?.length ?? (hasHeader ? columnLabels.length : undefined); const tableContext = React.useMemo( () => ({ rowCount: resolvedRowCount, - columnLabels: headerLabels ?? [], + columnLabels, hasHeader, nativeFocusMode, formatRowPosition, - setHeaderLabels, }), [ resolvedRowCount, - headerLabels, + columnLabels, hasHeader, nativeFocusMode, formatRowPosition, diff --git a/src/components/DataTable/DataTableCell.tsx b/src/components/DataTable/DataTableCell.tsx index 73ed75fef6..d5e6551659 100644 --- a/src/components/DataTable/DataTableCell.tsx +++ b/src/components/DataTable/DataTableCell.tsx @@ -152,8 +152,13 @@ const DataTableCell = ({ !React.isValidElement(children) && value != null; + // The cell speaks for itself only when it is an accessibility element of its + // own: a focus unit, or a control the user can activate. + const namesItself = !isWeb && (cellIsFocusUnit || interactive); + const label = - ariaLabel ?? (isWeb ? undefined : composeCellLabel({ columnLabel, value })); + ariaLabel ?? + (namesItself ? composeCellLabel({ columnLabel, value }) : undefined); const structuralProps = { role: row?.header ? ('columnheader' as const) : ('cell' as const), diff --git a/src/components/DataTable/DataTableContext.tsx b/src/components/DataTable/DataTableContext.tsx index a8e742988c..7fecd08d94 100644 --- a/src/components/DataTable/DataTableContext.tsx +++ b/src/components/DataTable/DataTableContext.tsx @@ -25,8 +25,6 @@ export type DataTableContextValue = { hasHeader: boolean; nativeFocusMode: NativeFocusMode; formatRowPosition: FormatRowPosition | null; - /** Called by `DataTable.Header` to publish the column names it derived. */ - setHeaderLabels: (labels: ReadonlyArray | null) => void; }; export const DataTableContext = @@ -40,3 +38,12 @@ export type DataTableRowContextValue = { export const DataTableRowContext = React.createContext(null); + +/** + * The position of a row within the data set. + * + * Published by the table rather than cloned onto the child list, so it + * survives consumer wrapper components - a `` that renders a + * `DataTable.Row` is numbered just like an inline one. + */ +export const RowIndexContext = React.createContext(null); diff --git a/src/components/DataTable/DataTableHeader.tsx b/src/components/DataTable/DataTableHeader.tsx index 6388b28898..2c67e37cfa 100644 --- a/src/components/DataTable/DataTableHeader.tsx +++ b/src/components/DataTable/DataTableHeader.tsx @@ -2,13 +2,9 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; -import useLatestCallback from 'use-latest-callback'; - import { withColumnIndices } from './DataTableColumnsContext'; -import { DataTableContext, DataTableRowContext } from './DataTableContext'; -import type { Props as DataTableTitleProps } from './DataTableTitle'; +import { DataTableRowContext } from './DataTableContext'; import { HORIZONTAL_PADDING } from './tokens'; -import { getElementLabel, isDataTableElement } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import webAriaProps from '../../utils/webAriaProps'; @@ -58,32 +54,8 @@ const DataTableHeader = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const table = React.useContext(DataTableContext); const borderBottomColor = theme.colors.outlineVariant; - const labels = React.useMemo(() => { - const labels: Array = []; - - React.Children.forEach(children, (child, index) => { - labels[index] = isDataTableElement( - child, - 'DataTable.Title' - ) - ? getElementLabel(child.props) - : undefined; - }); - - return labels; - }, [children]); - - const setHeaderLabels = table?.setHeaderLabels; - const publish = useLatestCallback(() => setHeaderLabels?.(labels)); - const signature = labels.join(' '); - - React.useEffect(publish, [publish, signature]); - - React.useEffect(() => () => setHeaderLabels?.(null), [setHeaderLabels]); - const rowContext = React.useMemo( () => ({ header: true, rowIsFocusUnit: false }), [] diff --git a/src/components/DataTable/DataTableRow.tsx b/src/components/DataTable/DataTableRow.tsx index e6d8b7752e..dca806f870 100644 --- a/src/components/DataTable/DataTableRow.tsx +++ b/src/components/DataTable/DataTableRow.tsx @@ -9,7 +9,11 @@ import type { import type { Props as DataTableCellProps } from './DataTableCell'; import { withColumnIndices } from './DataTableColumnsContext'; -import { DataTableContext, DataTableRowContext } from './DataTableContext'; +import { + DataTableContext, + DataTableRowContext, + RowIndexContext, +} from './DataTableContext'; import { HORIZONTAL_PADDING, ROW_MIN_HEIGHT, @@ -40,6 +44,10 @@ export type Props = $RemoveChildren & { /** * Index of this row within the data set, counting from 0. Announced to * screen readers as the row's position. + * + * Rows rendered among the `DataTable`'s own children are numbered by their + * position there; pass this for rows rendered outside it, as a virtualized + * list does. */ index?: number; style?: StyleProp; @@ -98,8 +106,13 @@ const DataTableRow = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); const table = React.useContext(DataTableContext); + const position = React.useContext(RowIndexContext); const borderBottomColor = theme.colors.outlineVariant; + // A virtualized list renders rows outside the table's own child list, so + // those pass their index themselves. + const rowIndex = index ?? position ?? undefined; + const interactive = hasTouchHandler({ onPress, onLongPress, @@ -176,7 +189,7 @@ const DataTableRow = ({ (rowIsFocusUnit ? composeRowLabel({ cellLabels, - rowIndex: index, + rowIndex, rowCount: table?.rowCount, formatRowPosition: table?.formatRowPosition, }) @@ -195,9 +208,13 @@ const DataTableRow = ({ : ('button' as const), ...webAriaProps({ 'aria-rowindex': - index == null ? undefined : index + 1 + (table?.hasHeader ? 1 : 0), + rowIndex == null + ? undefined + : rowIndex + 1 + (table?.hasHeader ? 1 : 0), }), - accessible: accessible ?? (rowIsFocusUnit || undefined), + // Left unset, `Pressable` defaults it to `true`, which would swallow + // whatever an interactive row holds. + accessible: accessible ?? rowIsFocusUnit, 'aria-label': label, }; diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts index 2b9f6e817b..72bab193c7 100644 --- a/src/components/DataTable/utils.ts +++ b/src/components/DataTable/utils.ts @@ -60,6 +60,26 @@ export const composeCellLabel = ({ return columnLabel ? `${columnLabel}, ${value}` : value; }; +/** + * The column names of a header row, indexed by column position. + */ +export const readColumnLabels = ( + children: React.ReactNode +): Array => { + const labels: Array = []; + + React.Children.forEach(children, (child, position) => { + labels[position] = isDataTableElement( + child, + 'DataTable.Title' + ) + ? getElementLabel(child.props) + : undefined; + }); + + return labels; +}; + export type RowPositionInfo = { position: number; rowCount?: number }; export type FormatRowPosition = (info: RowPositionInfo) => string; diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index 96d5f883a5..e3a0f2ca43 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -182,6 +182,46 @@ describe('DataTable.Row', () => { expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 4); }); + it('numbers a row a consumer component renders, not just an inline one', async () => { + const NameRow = ({ name }: { name: string }) => ( + + {name} + 159 + + ); + + await render( +
+ +
+ ); + + // The position reaches the row through context, so a wrapper component + // does not swallow it on the way down. + expect( + screen.getByRole('row', { + name: 'Dessert, Frozen yogurt, Calories, 159, row 3 of 6', + }) + ).toBeOnTheScreen(); + }); + + it('lets a control inside a pressable row keep its own stop', async () => { + await render( + + {}}> + + + + +
+ ); + + // Left unset, the row would default to being one accessibility element + // and the checkbox would be out of reach. + expect(screen.getByTestId('row')).toHaveProp('accessible', false); + expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); + }); + it('honours an explicitly passed index, as virtualized lists must', async () => { await render( @@ -270,6 +310,24 @@ describe('DataTable.Cell', () => { expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); }); + it('does not name a cell after its column when it holds a control', async () => { + await render( +
+ + + + + 159 + +
+ ); + + // Not "Dessert" - the column name on the container would bury the + // checkbox's own role and state behind it. + expect(screen.getByTestId('cell')).not.toHaveProp('aria-label'); + expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); + }); + it('treats a cell label as the complete name, not a value to decorate', async () => { await render( diff --git a/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap index b2cfc454ea..052ccf11eb 100644 --- a/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap @@ -196,7 +196,6 @@ exports[`DataTable snapshots renders a pressable row 1`] = ` } > Date: Thu, 3 Sep 2026 13:09:53 +0200 Subject: [PATCH 07/20] fix(data-table): count only real rows and survive fragment children --- docs/6.x/docs/guides/migration.md | 1 + src/components/DataTable/DataTable.tsx | 64 +++++++---- src/components/DataTable/DataTableTitle.tsx | 7 +- src/components/DataTable/utils.ts | 19 ++- .../__tests__/DataTable/DataTable.test.tsx | 108 +++++++++++++++++- 5 files changed, 169 insertions(+), 30 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index ed9032e67e..68f114cc1c 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -153,6 +153,7 @@ Rows, cells and titles with no touch handler render a plain `View` instead of a - `nativeFocusMode="cell"` gives one stop per cell instead of one per row - `accessible={false}` on a row opts that row out - `formatRowPosition` replaces the wording, or removes it with `null` +- rows are numbered by their position among the table's rows, wherever they sit: a wrapper grouping them is looked through, and anything beside them - an empty state, a caption - is not counted. Pass `index` on a row the table never renders itself, as a virtualized list does ```tsx // Before (v5) diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index d2232eb36f..62b334627b 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -175,7 +175,7 @@ const DataTable = ({ ...rest }: Props) => { if ( - __DEV__ && + process.env.NODE_ENV !== 'production' && layout === 'fixed' && columns?.some((column) => column.width == null && column.minWidth == null) ) { @@ -193,31 +193,49 @@ const DataTable = ({ let hasHeader = false; let rendered = 0; - const rows = React.Children.map(children, (child) => { - if (!React.isValidElement(child)) { - return child; - } + const number = (child: React.ReactNode) => ( + + {child} + + ); - if ( - isDataTableElement(child, 'DataTable.Header') - ) { - hasHeader = true; - columnLabels = readColumnLabels(child.props.children); - return child; - } + const walk = (nodes: React.ReactNode): React.ReactNode => + React.Children.map(nodes, (child) => { + if (!React.isValidElement<{ children?: React.ReactNode }>(child)) { + return child; + } + + // Read before the guards below narrow `child` to `never`. + const { children: nested } = child.props; + + if ( + isDataTableElement(child, 'DataTable.Header') + ) { + hasHeader = true; + columnLabels = readColumnLabels(nested); + return child; + } + + if (isDataTableElement(child, 'DataTable.Pagination')) { + return child; + } + + if (isDataTableElement(child, 'DataTable.Row')) { + return number(child); + } + + // Anything the table can see into is descended into, not counted: + // a wrapper must not make its rows one row, nor an empty state a row. + if (nested !== undefined) { + return React.cloneElement(child, undefined, walk(nested)); + } - if (isDataTableElement(child, 'DataTable.Pagination')) { - return child; - } + // What is left renders its row out of sight, so it counts as one. A + // row the table never sees - a virtualized one - passes `index`. + return number(child); + }); - // Anything else is taken for a row. Virtualized rows have no children - // here to number, so those pass `index` themselves. - return ( - - {child} - - ); - }); + const rows = walk(children); return { rows, columnLabels, hasHeader, renderedRowCount: rendered }; }, [children, firstRowIndex]); diff --git a/src/components/DataTable/DataTableTitle.tsx b/src/components/DataTable/DataTableTitle.tsx index 1319f273ca..6b8522ac24 100644 --- a/src/components/DataTable/DataTableTitle.tsx +++ b/src/components/DataTable/DataTableTitle.tsx @@ -182,6 +182,10 @@ const DataTableTitle = ({ const columnLabel = getElementLabel({ 'aria-label': ariaLabel, children }); + const describable = !React.Children.toArray(children).some( + React.isValidElement + ); + const previousSortDirection = React.useRef(sortDirection); React.useEffect(() => { @@ -239,7 +243,8 @@ const DataTableTitle = ({ // Native has no column-header semantics, so a title that is not already a // pressable has to opt in, or its text is absorbed by whichever ancestor // happens to be focusable and the columns read as one run-on stop. - accessible: Platform.OS === 'web' ? undefined : true, + accessible: + Platform.OS === 'web' || !(onPress || describable) ? undefined : true, ...webAriaProps({ 'aria-colindex': resolved.index == null ? undefined : resolved.index + 1, // `none` is what advertises a column as sortable but currently unsorted. diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts index 72bab193c7..9b5da1b499 100644 --- a/src/components/DataTable/utils.ts +++ b/src/components/DataTable/utils.ts @@ -4,11 +4,20 @@ import * as React from 'react'; export const isDataTableElement =

( child: React.ReactNode, displayName: string -): child is React.ReactElement

=> - React.isValidElement(child) && - typeof child.type !== 'string' && - 'displayName' in child.type && - child.type.displayName === displayName; +): child is React.ReactElement

=> { + if (!React.isValidElement(child)) { + return false; + } + + const type: unknown = child.type; + + return ( + (typeof type === 'function' || + (typeof type === 'object' && type !== null)) && + 'displayName' in type && + (type as { displayName?: unknown }).displayName === displayName + ); +}; /** The text of a node, when it has one. */ export const getNodeText = (node: React.ReactNode): string | undefined => { diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index e3a0f2ca43..6e9f32c556 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -1,4 +1,10 @@ -import { Platform, StyleSheet, useWindowDimensions } from 'react-native'; +import { + Platform, + StyleSheet, + Text as RNText, + useWindowDimensions, + View, +} from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import * as Reanimated from 'react-native-reanimated'; @@ -233,6 +239,92 @@ describe('DataTable.Row', () => { expect(screen.getByRole('row', { name: /row 5 of 6/ })).toBeOnTheScreen(); }); + + it('does not take an empty state beside the rows for a row', async () => { + Platform.OS = 'web'; + + await render( + + + Dessert + + Only one dessert left + + Frozen yogurt + + + ); + + // The text is neither counted nor allowed to push the row down a place. + expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); + expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); + }); + + it('numbers rows grouped in a wrapper one by one', async () => { + Platform.OS = 'web'; + + await render( + + + Dessert + + + + Frozen yogurt + + + Eclair + + + + ); + + expect(screen.getByTestId('first')).toHaveProp('aria-rowindex', 2); + expect(screen.getByTestId('second')).toHaveProp('aria-rowindex', 3); + expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 3); + }); + + it('numbers rows given in a fragment', async () => { + Platform.OS = 'web'; + + await render( + + + Dessert + + <> + + Frozen yogurt + + + Eclair + + + + ); + + expect(screen.getByTestId('first')).toHaveProp('aria-rowindex', 2); + expect(screen.getByTestId('second')).toHaveProp('aria-rowindex', 3); + }); + + it('renders a fragment inside a row or a header', async () => { + await render( + + + <> + Dessert + + + + <> + Frozen yogurt + + + + ); + + expect(screen.getByTestId('row')).toBeOnTheScreen(); + }); }); describe('DataTable.Cell', () => { @@ -503,6 +595,20 @@ describe('DataTable.Title', () => { expect(screen.getByTestId('plain')).toHaveProp('accessible', true); }); + it('lets a control inside a column header keep its own stop', async () => { + await render( + + + + + + ); + + // Made one accessibility element, the header would swallow the checkbox. + expect(screen.getByTestId('title')).not.toHaveProp('accessible', true); + expect(screen.getByTestId('select-all')).toBeOnTheScreen(); + }); + it('announces a sortable column and its sort state', async () => { await render(

); From 59302c0fa3ac0107eee56eec08b46bbe38e4ffb8 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Fri, 4 Sep 2026 17:06:39 +0200 Subject: [PATCH 08/20] fix(data-table): tell a row component from a wrapper by its type --- docs/6.x/docs/guides/migration.md | 2 +- src/components/DataTable/DataTable.tsx | 29 +++++-- src/components/DataTable/utils.ts | 37 ++++++++- .../__tests__/DataTable/DataTable.test.tsx | 80 +++++++++++++++++++ 4 files changed, 139 insertions(+), 9 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 68f114cc1c..272c5307dd 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -153,7 +153,7 @@ Rows, cells and titles with no touch handler render a plain `View` instead of a - `nativeFocusMode="cell"` gives one stop per cell instead of one per row - `accessible={false}` on a row opts that row out - `formatRowPosition` replaces the wording, or removes it with `null` -- rows are numbered by their position among the table's rows, wherever they sit: a wrapper grouping them is looked through, and anything beside them - an empty state, a caption - is not counted. Pass `index` on a row the table never renders itself, as a virtualized list does +- rows are numbered by their position among the table's rows, wherever they sit: a fragment, a `View` or any wrapper the table can see rows inside is looked through, and anything beside them - an empty state, a caption - is not counted. A component of your own counts as the one row it renders; pass `index` on a row the table never renders itself, as a virtualized list does ```tsx // Before (v5) diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 62b334627b..7f2d0b34a0 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -19,8 +19,10 @@ import DataTableTitle, { DataTableTitle as _DataTableTitle, } from './DataTableTitle'; import { + containsTableStructure, defaultFormatRowPosition, isDataTableElement, + isTransparentContainer, readColumnLabels, } from './utils'; import type { FormatRowPosition } from './utils'; @@ -69,6 +71,11 @@ export type Props = ViewProps & { style?: StyleProp; }; +// `React.Children.map` always hands back an array, which is one child too many +// for a wrapper written around `React.Children.only`. +const unwrapSingleChild = (children: React.ReactNode): React.ReactNode => + Array.isArray(children) && children.length === 1 ? children[0] : children; + /** * Data tables allow displaying sets of data. * @@ -224,14 +231,24 @@ const DataTable = ({ return number(child); } - // Anything the table can see into is descended into, not counted: - // a wrapper must not make its rows one row, nor an empty state a row. - if (nested !== undefined) { - return React.cloneElement(child, undefined, walk(nested)); + // A container the table sees through, or a wrapper it can see the + // table's own parts inside, keeps its rows where they are: descend + // into it, so a wrapper does not make its rows one row, nor an empty + // state a row. + if (isTransparentContainer(child) || containsTableStructure(nested)) { + return nested === undefined + ? child + : React.cloneElement( + child, + undefined, + unwrapSingleChild(walk(nested)) + ); } - // What is left renders its row out of sight, so it counts as one. A - // row the table never sees - a virtualized one - passes `index`. + // What is left renders out of sight - a row component of the + // consumer's own, most often - so it counts as one row. A row the + // table never sees at all, as a virtualized list renders, passes + // `index` itself. return number(child); }); diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts index 9b5da1b499..07fc6dd215 100644 --- a/src/components/DataTable/utils.ts +++ b/src/components/DataTable/utils.ts @@ -1,4 +1,7 @@ import * as React from 'react'; +import { ScrollView, Text as NativeText, View } from 'react-native'; + +import Text from '../Typography/Text'; /** Whether a child is a particular `DataTable` sub-component. */ export const isDataTableElement =

( @@ -9,16 +12,46 @@ export const isDataTableElement =

( return false; } - const type: unknown = child.type; + const { type } = child; return ( (typeof type === 'function' || (typeof type === 'object' && type !== null)) && 'displayName' in type && - (type as { displayName?: unknown }).displayName === displayName + type.displayName === displayName ); }; +/** + * Whether the table can see through an element to the children it was given: + * the primitives it is built from render what they are handed, so a row inside + * one keeps its own place in the table. + */ +export const isTransparentContainer = (child: React.ReactElement): boolean => + child.type === React.Fragment || + child.type === View || + child.type === ScrollView || + child.type === NativeText || + child.type === Text; + +const structuralParts = [ + 'DataTable.Header', + 'DataTable.Row', + 'DataTable.Pagination', +]; + +/** + * Whether a subtree holds a part of the table's structure - a header, a row or + * the pagination - at any depth. + */ +export const containsTableStructure = (node: React.ReactNode): boolean => + React.Children.toArray(node).some( + (child) => + structuralParts.some((part) => isDataTableElement(child, part)) || + (React.isValidElement<{ children?: React.ReactNode }>(child) && + containsTableStructure(child.props.children)) + ); + /** The text of a node, when it has one. */ export const getNodeText = (node: React.ReactNode): string | undefined => { if (typeof node === 'string') { diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index 6e9f32c556..d03ab722db 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { Platform, StyleSheet, @@ -211,6 +212,51 @@ describe('DataTable.Row', () => { ).toBeOnTheScreen(); }); + it('numbers a row component that takes children of its own', async () => { + Platform.OS = 'web'; + + const NameRow = ({ children }: { children: React.ReactNode }) => ( + + {children} + 159 + + ); + + await render( + + + Dessert + + Frozen yogurt + + ); + + // Children make it a wrapper to look at, not one to look through: what it + // renders is a row, and the only row the table has. + expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); + expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); + }); + + it('leaves the children of a row component as it was given them', async () => { + const NameRow = ({ children }: { children: React.ReactNode }) => ( + + {React.Children.only(children)} + + ); + + await render( +

+ + Frozen yogurt + +
+ ); + + // A component counting its own children must not be handed an array of + // one where it was written a single child. + expect(screen.getByText('Frozen yogurt')).toBeOnTheScreen(); + }); + it('lets a control inside a pressable row keep its own stop', async () => { await render( @@ -284,6 +330,40 @@ describe('DataTable.Row', () => { expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 3); }); + it('finds the header and pagination inside a wrapper it cannot see through', async () => { + Platform.OS = 'web'; + + const Wrap = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + await render( + + + + Dessert + + + + Frozen yogurt + + + {}} + /> + + + ); + + // Neither wrapper holds a row, but each holds a part of the table - proof + // enough that it groups rather than renders a row of its own. + expect(screen.getByTestId('table')).toHaveProp('aria-colcount', 1); + expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); + expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); + }); + it('numbers rows given in a fragment', async () => { Platform.OS = 'web'; From 20bc409175586c57125d5789534649b716847370 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Mon, 14 Sep 2026 10:57:51 +0200 Subject: [PATCH 09/20] fix(data-table): keep pressable-only props out of the emitted types --- src/components/DataTable/DataTableTitle.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/DataTable/DataTableTitle.tsx b/src/components/DataTable/DataTableTitle.tsx index 5c71cce586..0d05e1135c 100644 --- a/src/components/DataTable/DataTableTitle.tsx +++ b/src/components/DataTable/DataTableTitle.tsx @@ -128,16 +128,19 @@ const DataTableTitle = ({ numberOfLines, maxFontSizeMultiplier, 'aria-label': ariaLabel, - // Must not reach the plain view a static title renders as. - android_ripple, - android_disableSound, - delayLongPress, - pressRetentionOffset, - unstable_pressDelay, - testOnly_pressed, disabled, ...rest }: Props) => { + const { + android_ripple, + android_disableSound, + delayLongPress, + pressRetentionOffset, + unstable_pressDelay, + testOnly_pressed, + ...viewProps + } = rest; + const theme = useInternalTheme(themeOverrides); const reduceMotion = useReduceMotion(); @@ -286,7 +289,7 @@ const DataTableTitle = ({ if (!onPress) { return ( - + {content} ); @@ -295,7 +298,7 @@ const DataTableTitle = ({ return ( Date: Wed, 16 Sep 2026 09:42:36 +0200 Subject: [PATCH 10/20] fix(data-table): leave wrapper as written instead of cloning --- docs/6.x/docs/guides/migration.md | 2 +- src/components/DataTable/DataTable.tsx | 36 ++++++------ src/components/DataTable/DataTableRow.tsx | 10 ++++ src/components/DataTable/utils.ts | 18 +++--- .../__tests__/DataTable/DataTable.test.tsx | 58 +++++++++++++++++-- 5 files changed, 91 insertions(+), 33 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 8ba51494f6..9fba301d33 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -357,7 +357,7 @@ Rows, cells and titles with no touch handler render a plain `View` instead of a - `nativeFocusMode="cell"` gives one stop per cell instead of one per row - `accessible={false}` on a row opts that row out - `formatRowPosition` replaces the wording, or removes it with `null` -- rows are numbered by their position among the table's rows, wherever they sit: a fragment, a `View` or any wrapper the table can see rows inside is looked through, and anything beside them - an empty state, a caption - is not counted. A component of your own counts as the one row it renders; pass `index` on a row the table never renders itself, as a virtualized list does +- rows are numbered by their position among the table's rows. A component of your own counts as the one row. A row inside a `View` or a wrapper of your own is counted but not numbered: pass `index` on such a row. ```tsx // Before (v5) diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 7f2d0b34a0..dfda81c159 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -22,7 +22,8 @@ import { containsTableStructure, defaultFormatRowPosition, isDataTableElement, - isTransparentContainer, + isGroupingContainer, + isTextElement, readColumnLabels, } from './utils'; import type { FormatRowPosition } from './utils'; @@ -71,11 +72,6 @@ export type Props = ViewProps & { style?: StyleProp; }; -// `React.Children.map` always hands back an array, which is one child too many -// for a wrapper written around `React.Children.only`. -const unwrapSingleChild = (children: React.ReactNode): React.ReactNode => - Array.isArray(children) && children.length === 1 ? children[0] : children; - /** * Data tables allow displaying sets of data. * @@ -213,7 +209,10 @@ const DataTable = ({ } // Read before the guards below narrow `child` to `never`. - const { children: nested } = child.props; + const { + type, + props: { children: nested }, + } = child; if ( isDataTableElement(child, 'DataTable.Header') @@ -231,18 +230,17 @@ const DataTable = ({ return number(child); } - // A container the table sees through, or a wrapper it can see the - // table's own parts inside, keeps its rows where they are: descend - // into it, so a wrapper does not make its rows one row, nor an empty - // state a row. - if (isTransparentContainer(child) || containsTableStructure(nested)) { - return nested === undefined - ? child - : React.cloneElement( - child, - undefined, - unwrapSingleChild(walk(nested)) - ); + if (isTextElement(child)) { + return child; + } + + if (type === React.Fragment) { + return walk(nested); + } + + if (isGroupingContainer(child) || containsTableStructure(nested)) { + void walk(nested); + return child; } // What is left renders out of sight - a row component of the diff --git a/src/components/DataTable/DataTableRow.tsx b/src/components/DataTable/DataTableRow.tsx index 8aa09b5fdf..b9441c3490 100644 --- a/src/components/DataTable/DataTableRow.tsx +++ b/src/components/DataTable/DataTableRow.tsx @@ -117,6 +117,16 @@ const DataTableRow = ({ // those pass their index themselves. const rowIndex = index ?? position ?? undefined; + if ( + process.env.NODE_ENV !== 'production' && + table != null && + rowIndex == null + ) { + console.warn( + 'DataTable.Row has no position: a row inside a wrapper, or in a list, needs `index`' + ); + } + const interactive = hasTouchHandler({ onPress, onLongPress, diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts index 07fc6dd215..77ae4f471f 100644 --- a/src/components/DataTable/utils.ts +++ b/src/components/DataTable/utils.ts @@ -22,17 +22,17 @@ export const isDataTableElement =

( ); }; +/** Whether an element is text, which is never a row and never holds one. */ +export const isTextElement = (child: React.ReactElement): boolean => + child.type === NativeText || child.type === Text; + /** - * Whether the table can see through an element to the children it was given: - * the primitives it is built from render what they are handed, so a row inside - * one keeps its own place in the table. + * Whether an element only groups the children it was given: the primitives + * the table is built from render what they are handed, so a row inside one + * keeps its own place in the table. */ -export const isTransparentContainer = (child: React.ReactElement): boolean => - child.type === React.Fragment || - child.type === View || - child.type === ScrollView || - child.type === NativeText || - child.type === Text; +export const isGroupingContainer = (child: React.ReactElement): boolean => + child.type === View || child.type === ScrollView; const structuralParts = [ 'DataTable.Header', diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index ef659c3624..2afd466989 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -328,28 +328,78 @@ describe('DataTable.Row', () => { expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); }); - it('numbers rows grouped in a wrapper one by one', async () => { + it('does not take an element inside an empty state for a row', async () => { Platform.OS = 'web'; + const Badge = () => !; + + await render( + + + Dessert + + + Only one dessert left + + + Frozen yogurt + + + ); + + expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); + expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); + }); + + it('counts rows grouped in a wrapper without rebuilding the wrapper', async () => { + Platform.OS = 'web'; + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await render( Dessert - + Frozen yogurt - + Eclair + + Gingerbread + ); expect(screen.getByTestId('first')).toHaveProp('aria-rowindex', 2); expect(screen.getByTestId('second')).toHaveProp('aria-rowindex', 3); - expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 3); + expect(screen.getByTestId('third')).toHaveProp('aria-rowindex', 4); + expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 4); + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); + + it('warns about a row in a wrapper that was given no index', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( + + + + Frozen yogurt + + + + ); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`index`')); + + warn.mockRestore(); }); it('finds the header and pagination inside a wrapper it cannot see through', async () => { From c7eebe820a5ff21cac61d5689143924664b21a75 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Thu, 15 Oct 2026 16:43:42 +0200 Subject: [PATCH 11/20] fix(data-table): omit a divider between rows from counting as a row --- src/components/DataTable/DataTable.tsx | 4 ++-- src/components/DataTable/utils.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index dfda81c159..115d5220f4 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -23,7 +23,7 @@ import { defaultFormatRowPosition, isDataTableElement, isGroupingContainer, - isTextElement, + isNeverRow, readColumnLabels, } from './utils'; import type { FormatRowPosition } from './utils'; @@ -230,7 +230,7 @@ const DataTable = ({ return number(child); } - if (isTextElement(child)) { + if (isNeverRow(child)) { return child; } diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts index 77ae4f471f..c3a26b7309 100644 --- a/src/components/DataTable/utils.ts +++ b/src/components/DataTable/utils.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import { ScrollView, Text as NativeText, View } from 'react-native'; +import Divider from '../Divider'; import Text from '../Typography/Text'; /** Whether a child is a particular `DataTable` sub-component. */ @@ -22,9 +23,12 @@ export const isDataTableElement =

( ); }; -/** Whether an element is text, which is never a row and never holds one. */ -export const isTextElement = (child: React.ReactElement): boolean => - child.type === NativeText || child.type === Text; +/** + * Whether an element is text or a divider, which is never a row and never + * holds one. + */ +export const isNeverRow = (child: React.ReactElement): boolean => + child.type === NativeText || child.type === Text || child.type === Divider; /** * Whether an element only groups the children it was given: the primitives From ecc09287e462d6a6d974cb975502645a28127bc0 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Fri, 23 Oct 2026 10:15:00 +0200 Subject: [PATCH 12/20] fix: apply review suggestions from @satya164 --- docs/6.x/docs/guides/migration.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index f51b5167a3..5029b93a86 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -533,8 +533,12 @@ const styles = StyleSheet.create({ first: { flex: 2 } }); const columns = [{ key: 'name', flex: 2 }, { key: 'calories', numeric: true }]; - Dessert - {item.name} + + Dessert + + + {item.name} + ``` From 4bb0ebbcb34232297ce1d39f06cb6bc0ef5cbdf5 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Fri, 23 Oct 2026 10:43:03 +0200 Subject: [PATCH 13/20] fix: mirror text alignment in RTL on the web only --- .../DataTable/DataTableColumnsContext.tsx | 11 +++++--- .../__tests__/DataTable/DataTable.test.tsx | 28 +++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/components/DataTable/DataTableColumnsContext.tsx b/src/components/DataTable/DataTableColumnsContext.tsx index e18e502ecd..1f1d98e461 100644 --- a/src/components/DataTable/DataTableColumnsContext.tsx +++ b/src/components/DataTable/DataTableColumnsContext.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { Platform } from 'react-native'; import type { TextStyle, ViewStyle } from 'react-native'; import type { @@ -143,8 +144,9 @@ export type AlignStyles = { * Container and text alignment for a column. * * `justifyContent` is logical and flips with the container's writing - * direction; `textAlign` has to be mapped to a physical value because React - * Native has no `textAlign: 'start' | 'end'`. + * direction; `textAlign` has to be a physical value because React Native has + * no `textAlign: 'start' | 'end'`. iOS and Android mirror `left` and `right` + * in RTL layouts on their own, so only the web needs the mapping. */ export const useAlignStyles = ( align: DataTableColumnAlign, @@ -154,7 +156,8 @@ export const useAlignStyles = ( return React.useMemo(() => { const isEnd = align === 'end'; - const isRTL = direction === 'rtl'; + // iOS and Android mirror left and right in RTL layouts. The web does not. + const mirror = Platform.OS === 'web' && direction === 'rtl'; return { container: { @@ -166,7 +169,7 @@ export const useAlignStyles = ( }, text: { textAlign: - align === 'center' ? 'center' : isEnd === isRTL ? 'left' : 'right', + align === 'center' ? 'center' : isEnd !== mirror ? 'right' : 'left', ...(numeric ? { fontVariant: ['tabular-nums' as const] } : null), }, }; diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index 2afd466989..4342e50863 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -1136,7 +1136,7 @@ describe('DataTable alignment', () => { }); }); - it('mirrors text alignment in right-to-left layouts', async () => { + it('keeps physical text alignment in right-to-left layouts on native', async () => { await render( Frozen yogurt @@ -1146,11 +1146,13 @@ describe('DataTable alignment', () => { ); + // iOS and Android mirror `left` and `right` in RTL layouts themselves, so + // mapping them here too would flip the alignment twice. expect(cellText('start')).toHaveStyle({ - textAlign: 'right', + textAlign: 'left', }); expect(cellText('numeric')).toHaveStyle({ - textAlign: 'left', + textAlign: 'right', }); // `justifyContent` is logical, so it resolves against the direction on its // own and must not be mirrored here too. @@ -1159,6 +1161,26 @@ describe('DataTable alignment', () => { }); }); + it('mirrors text alignment in right-to-left layouts on the web', async () => { + Platform.OS = 'web'; + + await render( + + Frozen yogurt + + 159 + + + ); + + expect(cellText('start')).toHaveStyle({ + textAlign: 'right', + }); + expect(cellText('numeric')).toHaveStyle({ + textAlign: 'left', + }); + }); + it('treats `numeric` as the data and `align` as the position', async () => { await render( <> From 34a72c469b8feef1348c94daeb030bd602183b5c Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 28 Oct 2026 09:44:45 +0100 Subject: [PATCH 14/20] refactor(data-table): let cells register with their row --- src/components/DataTable/DataTableCell.tsx | 34 +++++- src/components/DataTable/DataTableContext.tsx | 18 +++ src/components/DataTable/DataTableRow.tsx | 105 +++++++----------- .../__tests__/DataTable/DataTable.test.tsx | 21 ++++ 4 files changed, 115 insertions(+), 63 deletions(-) diff --git a/src/components/DataTable/DataTableCell.tsx b/src/components/DataTable/DataTableCell.tsx index 4b83294ed4..be6c7c2fba 100644 --- a/src/components/DataTable/DataTableCell.tsx +++ b/src/components/DataTable/DataTableCell.tsx @@ -11,7 +11,12 @@ import type { ColumnLayoutProps } from './columns'; import { useAlignStyles, useColumn } from './DataTableColumnsContext'; import { DataTableContext, DataTableRowContext } from './DataTableContext'; import useReflowedNumberOfLines from './useReflowedNumberOfLines'; -import { composeCellLabel, getElementLabel } from './utils'; +import { + composeCellLabel, + getElementLabel, + getExplicitLabel, + getNodeText, +} from './utils'; import hasTouchHandler from '../../utils/hasTouchHandler'; import webAriaProps from '../../utils/webAriaProps'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -143,6 +148,33 @@ const DataTableCell = ({ const value = getElementLabel({ 'aria-label': ariaLabel, children }); + const explicitLabel = getExplicitLabel({ + 'aria-label': ariaLabel, + accessibilityLabel: rest.accessibilityLabel, + }); + const text = getNodeText(children); + + const registerCell = row?.registerCell; + const cellId = React.useId(); + + const describable = + !interactive && + !React.isValidElement(children) && + (explicitLabel != null || text != null); + + const labelInRow = + explicitLabel ?? composeCellLabel({ columnLabel, value: text }); + + React.useLayoutEffect( + () => + registerCell?.(cellId, { + index: resolved.index, + label: labelInRow, + describable, + }), + [registerCell, cellId, resolved.index, labelInRow, describable] + ); + const isWeb = Platform.OS === 'web'; const cellIsFocusUnit = diff --git a/src/components/DataTable/DataTableContext.tsx b/src/components/DataTable/DataTableContext.tsx index 7fecd08d94..183c263e5b 100644 --- a/src/components/DataTable/DataTableContext.tsx +++ b/src/components/DataTable/DataTableContext.tsx @@ -30,10 +30,28 @@ export type DataTableContextValue = { export const DataTableContext = React.createContext(null); +/** + * What a cell tells its row, so the row can announce every cell at once + * without reading its own children. + */ +export type RegisteredCell = { + /** Column position of the cell, when it is known. */ + index?: number; + /** The cell's name as part of the row, column name included. */ + label?: string; + /** Plain text and not interactive, so it is safe to hide behind the row. */ + describable: boolean; +}; + export type DataTableRowContextValue = { /** This is the header row, so its cells are column headers. */ header: boolean; rowIsFocusUnit: boolean; + /** + * Set while the row can become one accessibility element. Returns the + * function that unregisters the cell. + */ + registerCell?: (id: string, cell: RegisteredCell) => () => void; }; export const DataTableRowContext = diff --git a/src/components/DataTable/DataTableRow.tsx b/src/components/DataTable/DataTableRow.tsx index b9441c3490..b04ed8b737 100644 --- a/src/components/DataTable/DataTableRow.tsx +++ b/src/components/DataTable/DataTableRow.tsx @@ -7,25 +7,19 @@ import type { ViewStyle, } from 'react-native'; -import type { Props as DataTableCellProps } from './DataTableCell'; import { withColumnIndices } from './DataTableColumnsContext'; import { DataTableContext, DataTableRowContext, RowIndexContext, } from './DataTableContext'; +import type { RegisteredCell } from './DataTableContext'; import { HORIZONTAL_PADDING, ROW_MIN_HEIGHT, ROW_VERTICAL_PADDING, } from './tokens'; -import { - composeCellLabel, - composeRowLabel, - getExplicitLabel, - getNodeText, - isDataTableElement, -} from './utils'; +import { composeRowLabel } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; @@ -38,7 +32,9 @@ export type Props = Omit< 'children' > & { /** - * Content of the `DataTableRow`. + * Content of the `DataTableRow`. Put all content in `DataTable.Cell`s: on + * iOS and Android a row of plain-text cells is announced as one item, and + * content outside a cell would be hidden with the cells. */ children: React.ReactNode; /** @@ -134,69 +130,50 @@ const DataTableRow = ({ onPressOut, }); - const columnLabels = table?.columnLabels; - - // Collapsing a row is only safe when every cell is plain text and none is - // separately interactive, or its content would be out of reach. - const { cellLabels, describable } = React.useMemo(() => { - const cellLabels: Array = []; - let describable = true; + // Table roles convey nothing on iOS or Android, so the label has to. + const collectsCells = + Platform.OS !== 'web' && + table?.nativeFocusMode !== 'cell' && + accessible !== false; - React.Children.forEach(children, (child, position) => { - if (child == null || typeof child === 'boolean') { - return; - } + const [cells, setCells] = React.useState>( + () => new Map() + ); - if (!isDataTableElement(child, 'DataTable.Cell')) { - describable = false; - return; - } + const registerCell = React.useCallback((id: string, cell: RegisteredCell) => { + setCells((previous) => new Map(previous).set(id, cell)); - const { column } = child.props; - const columnIndex = typeof column === 'number' ? column : position; + return () => + setCells((previous) => { + const next = new Map(previous); + next.delete(id); + return next; + }); + }, []); - if ( - hasTouchHandler({ - onPress: child.props.onPress, - onLongPress: child.props.onLongPress, - onPressIn: child.props.onPressIn, - onPressOut: child.props.onPressOut, - }) - ) { - describable = false; - } + // Collapsing a row is only safe when every cell is plain text and none is + // separately interactive, or its content would be out of reach. + const { cellLabels, describable } = React.useMemo(() => { + const indexed: Array = []; + const unindexed: Array = []; + let describable = cells.size > 0; - // An element renders verbatim and carries its own semantics, which may - // include being interactive. A label on the cell says nothing about that, - // so it cannot make the content safe to hide. - if (React.isValidElement(child.props.children)) { + cells.forEach((cell) => { + if (!cell.describable) { describable = false; } - const explicit = getExplicitLabel(child.props); - const text = getNodeText(child.props.children); - - if (explicit == null && text == null) { - describable = false; + if (cell.index == null) { + unindexed.push(cell.label); + } else { + indexed[cell.index] = cell.label; } - - cellLabels[columnIndex] = - explicit ?? - composeCellLabel({ - columnLabel: columnLabels?.[columnIndex], - value: text, - }); }); - return { cellLabels, describable }; - }, [children, columnLabels]); + return { cellLabels: [...indexed, ...unindexed], describable }; + }, [cells]); - const rowIsFocusUnit = - // Table roles convey nothing on iOS or Android, so the label has to. - Platform.OS !== 'web' && - table?.nativeFocusMode !== 'cell' && - accessible !== false && - describable; + const rowIsFocusUnit = collectsCells && describable; const label = ariaLabel ?? @@ -210,8 +187,12 @@ const DataTableRow = ({ : undefined); const rowContext = React.useMemo( - () => ({ header: false, rowIsFocusUnit }), - [rowIsFocusUnit] + () => ({ + header: false, + rowIsFocusUnit, + registerCell: collectsCells ? registerCell : undefined, + }), + [rowIsFocusUnit, collectsCells, registerCell] ); const structuralProps = { diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index 4342e50863..cd829d24f1 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -143,6 +143,27 @@ describe('DataTable.Row', () => { ).toBeOnTheScreen(); }); + it('names a cell a consumer component renders, not just an inline one', async () => { + const CaloriesCell = ({ value }: { value: number }) => ( + {value} + ); + + await render( +

+ + Frozen yogurt + + +
+ ); + + expect( + screen.getByRole('row', { + name: 'Dessert, Frozen yogurt, Calories, 159, row 3 of 6', + }) + ).toBeOnTheScreen(); + }); + it('does not repeat the column name when a cell carries its own label', async () => { await render( From 25974243be377f50efca79529349be2829eec052 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 28 Oct 2026 10:15:46 +0100 Subject: [PATCH 15/20] refactor(data-table): resolve columns by key and let titles name them --- docs/6.x/docs/guides/migration.md | 14 +- example/src/Examples/DataTableExample.tsx | 11 +- src/components/DataTable/DataTable.tsx | 156 +++++++------- .../DataTable/DataTableColumnsContext.tsx | 56 ++--- src/components/DataTable/DataTableContext.tsx | 10 +- src/components/DataTable/DataTableHeader.tsx | 3 +- src/components/DataTable/DataTableRow.tsx | 3 +- src/components/DataTable/DataTableTitle.tsx | 13 ++ src/components/DataTable/columns.ts | 1 + src/components/DataTable/utils.ts | 20 -- .../__tests__/DataTable/DataTable.test.tsx | 194 +++++++++++++----- 11 files changed, 289 insertions(+), 192 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 5029b93a86..382a52aaea 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -451,6 +451,7 @@ Rows, cells and titles with no touch handler render a plain `View` instead of a #### Screen reader announcements - new `rowCount`, `firstRowIndex` needed for correct row positions when paginating +- on iOS and Android, a cell is announced with the title of its column, matched by `column` - `nativeFocusMode="cell"` gives one stop per cell instead of one per row - `accessible={false}` on a row opts that row out - `formatRowPosition` replaces the wording, or removes it with `null` @@ -520,7 +521,7 @@ Rows, cells and titles with no touch handler render a plain `View` instead of a #### Column definitions - `columns` on `DataTable` is new and optional -- `column` on a title or cell selects one by key, and is only needed where position is unreliable +- `column` on a title or cell selects a column by key, and is required when the table has `columns` ```tsx // Before (v5) @@ -530,14 +531,19 @@ const styles = StyleSheet.create({ first: { flex: 2 } }); {item.name} // After (v6) -const columns = [{ key: 'name', flex: 2 }, { key: 'calories', numeric: true }]; +const columns = [ + { key: 'name', flex: 2 }, + { key: 'calories', numeric: true }, +]; - Dessert + Dessert + Calories - {item.name} + {item.name} + {item.calories} ``` diff --git a/example/src/Examples/DataTableExample.tsx b/example/src/Examples/DataTableExample.tsx index 13ea6c872a..86998d669b 100644 --- a/example/src/Examples/DataTableExample.tsx +++ b/example/src/Examples/DataTableExample.tsx @@ -91,22 +91,23 @@ const DataTableExample = () => { > setSortAscending(!sortAscending)} > Dessert - + Calories per piece - Fat (g) + Fat (g) {sortedItems.slice(from, to).map((item) => ( - {item.name} - {item.calories} - {item.fat} + {item.name} + {item.calories} + {item.fat} ))} diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 115d5220f4..25fc465ae9 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -10,7 +10,6 @@ import type { NativeFocusMode } from './DataTableContext'; import DataTableHeader, { DataTableHeader as _DataTableHeader, } from './DataTableHeader'; -import type { Props as DataTableHeaderProps } from './DataTableHeader'; import DataTablePagination, { DataTablePagination as _DataTablePagination, } from './DataTablePagination'; @@ -24,7 +23,6 @@ import { isDataTableElement, isGroupingContainer, isNeverRow, - readColumnLabels, } from './utils'; import type { FormatRowPosition } from './utils'; import webAriaProps from '../../utils/webAriaProps'; @@ -36,7 +34,8 @@ export type Props = ViewProps & { children: React.ReactNode; /** * Shared column definitions. The header and every row read width and - * alignment from here. + * alignment from here. Every `DataTable.Title` and `DataTable.Cell` then + * needs `column`. */ columns?: readonly DataTableColumn[]; /** @@ -135,16 +134,16 @@ export type Props = ViewProps & { * firstRowIndex={from} * > * - * Dessert - * Calories - * Fat + * Dessert + * Calories + * Fat * * * {items.slice(from, to).map((item) => ( * - * {item.name} - * {item.calories} - * {item.fat} + * {item.name} + * {item.calories} + * {item.fat} * * ))} * @@ -189,83 +188,99 @@ const DataTable = ({ // Everything the rows need to know about the table has to be resolved while // rendering: an effect would leave the first render - the only one a server - // renders - with the wrong row indices and no column names. - const { rows, columnLabels, hasHeader, renderedRowCount } = - React.useMemo(() => { - let columnLabels: Array = []; - let hasHeader = false; - let rendered = 0; - - const number = (child: React.ReactNode) => ( - - {child} - - ); - - const walk = (nodes: React.ReactNode): React.ReactNode => - React.Children.map(nodes, (child) => { - if (!React.isValidElement<{ children?: React.ReactNode }>(child)) { - return child; - } + // renders - with the wrong row indices. + const { rows, hasHeader, renderedRowCount } = React.useMemo(() => { + let hasHeader = false; + let rendered = 0; - // Read before the guards below narrow `child` to `never`. - const { - type, - props: { children: nested }, - } = child; - - if ( - isDataTableElement(child, 'DataTable.Header') - ) { - hasHeader = true; - columnLabels = readColumnLabels(nested); - return child; - } + const number = (child: React.ReactNode) => ( + + {child} + + ); - if (isDataTableElement(child, 'DataTable.Pagination')) { - return child; - } + const walk = (nodes: React.ReactNode): React.ReactNode => + React.Children.map(nodes, (child) => { + if (!React.isValidElement<{ children?: React.ReactNode }>(child)) { + return child; + } - if (isDataTableElement(child, 'DataTable.Row')) { - return number(child); - } + // Read before the guards below narrow `child` to `never`. + const { + type, + props: { children: nested }, + } = child; - if (isNeverRow(child)) { - return child; - } - - if (type === React.Fragment) { - return walk(nested); - } + if (isDataTableElement(child, 'DataTable.Header')) { + hasHeader = true; + return child; + } - if (isGroupingContainer(child) || containsTableStructure(nested)) { - void walk(nested); - return child; - } + if (isDataTableElement(child, 'DataTable.Pagination')) { + return child; + } - // What is left renders out of sight - a row component of the - // consumer's own, most often - so it counts as one row. A row the - // table never sees at all, as a virtualized list renders, passes - // `index` itself. + if (isDataTableElement(child, 'DataTable.Row')) { return number(child); - }); + } + + if (isNeverRow(child)) { + return child; + } + + if (type === React.Fragment) { + return walk(nested); + } + + if (isGroupingContainer(child) || containsTableStructure(nested)) { + void walk(nested); + return child; + } + + // What is left renders out of sight - a row component of the + // consumer's own, most often - so it counts as one row. A row the + // table never sees at all, as a virtualized list renders, passes + // `index` itself. + return number(child); + }); - const rows = walk(children); + const rows = walk(children); - return { rows, columnLabels, hasHeader, renderedRowCount: rendered }; - }, [children, firstRowIndex]); + return { rows, hasHeader, renderedRowCount: rendered }; + }, [children, firstRowIndex]); const resolvedRowCount = rowCount ?? (renderedRowCount > 0 ? firstRowIndex + renderedRowCount : undefined); - const columnCount = - columns?.length ?? (hasHeader ? columnLabels.length : undefined); + const [columnLabels, setColumnLabels] = React.useState< + ReadonlyArray + >([]); + + const registerColumnLabel = React.useCallback( + (index: number, label: string) => { + setColumnLabels((previous) => { + const next = [...previous]; + next[index] = label; + return next; + }); + + return () => + setColumnLabels((previous) => { + const next = [...previous]; + next[index] = undefined; + return next; + }); + }, + [] + ); const tableContext = React.useMemo( () => ({ rowCount: resolvedRowCount, columnLabels, + registerColumnLabel: + Platform.OS === 'web' ? undefined : registerColumnLabel, hasHeader, nativeFocusMode, formatRowPosition, @@ -273,6 +288,7 @@ const DataTable = ({ [ resolvedRowCount, columnLabels, + registerColumnLabel, hasHeader, nativeFocusMode, formatRowPosition, @@ -284,7 +300,9 @@ const DataTable = ({ columns ? { columns, - byKey: new Map(columns.map((column) => [column.key, column])), + indexByKey: new Map( + columns.map((column, index) => [column.key, index]) + ), layout, } : null, @@ -307,7 +325,7 @@ const DataTable = ({ resolvedRowCount == null ? undefined : resolvedRowCount + (hasHeader ? 1 : 0), - 'aria-colcount': columnCount, + 'aria-colcount': columns?.length, })} {...viewProps} style={[layout === 'fluid' && styles.fluid, style]} diff --git a/src/components/DataTable/DataTableColumnsContext.tsx b/src/components/DataTable/DataTableColumnsContext.tsx index 1f1d98e461..fd01b7137a 100644 --- a/src/components/DataTable/DataTableColumnsContext.tsx +++ b/src/components/DataTable/DataTableColumnsContext.tsx @@ -12,7 +12,7 @@ import { useLocale } from '../../core/locale'; export type ColumnsContextValue = { columns: readonly DataTableColumn[]; - byKey: ReadonlyMap; + indexByKey: ReadonlyMap; layout: DataTableLayout; }; @@ -20,30 +20,6 @@ export const ColumnsContext = React.createContext( null ); -/** - * The position of an element within its `DataTable.Header` or `DataTable.Row`. - * - * Published by the row rather than read from the child list, so it survives - * consumer wrapper components - a `` that renders a - * `DataTable.Cell` resolves its column just like an inline one. - */ -export const ColumnIndexContext = React.createContext(null); - -/** - * Wraps each child in its positional index. Providers render no host node, so - * this leaves layout and the rendered tree untouched. - */ -export const withColumnIndices = (children: React.ReactNode) => - React.Children.map(children, (child, index) => - child == null ? ( - child - ) : ( - - {child} - - ) - ); - export type ResolvedColumn = { style: ViewStyle; align: DataTableColumnAlign; @@ -54,6 +30,10 @@ export type ResolvedColumn = { /** * Resolves one cell's column: its shared definition and its layout style. + * + * The column comes from the `column` prop alone, never from the element's + * place among its siblings, so a title or cell inside a wrapper component or a + * fragment resolves just like an inline one. */ export const useColumn = ({ column, @@ -65,21 +45,25 @@ export const useColumn = ({ numeric, }: ColumnLayoutProps): ResolvedColumn => { const context = React.useContext(ColumnsContext); - const positional = React.useContext(ColumnIndexContext); - const index = typeof column === 'number' ? column : (positional ?? undefined); - - const descriptor = React.useMemo(() => { - if (!context) { - return undefined; + if (process.env.NODE_ENV !== 'production' && context) { + if (column == null) { + console.warn( + 'DataTable.Title and DataTable.Cell need `column` when the DataTable has `columns`, otherwise they get no column definition' + ); + } else if (typeof column === 'string' && !context.indexByKey.has(column)) { + console.warn(`DataTable has no column with the key "${column}"`); } + } - if (typeof column === 'string') { - return context.byKey.get(column); - } + const index = + typeof column === 'number' + ? column + : column == null + ? undefined + : context?.indexByKey.get(column); - return index == null ? undefined : context.columns[index]; - }, [context, column, index]); + const descriptor = index == null ? undefined : context?.columns[index]; const resolvedNumeric = numeric ?? descriptor?.numeric ?? false; const resolvedAlign = diff --git a/src/components/DataTable/DataTableContext.tsx b/src/components/DataTable/DataTableContext.tsx index 183c263e5b..911567aed6 100644 --- a/src/components/DataTable/DataTableContext.tsx +++ b/src/components/DataTable/DataTableContext.tsx @@ -19,8 +19,16 @@ export type NativeFocusMode = 'row' | 'cell'; export type DataTableContextValue = { /** Total rows in the data set, not just the rendered page. */ rowCount?: number; - /** Names of the columns, indexed by column position. */ + /** + * Names of the columns, indexed by column position. Filled on iOS and + * Android only, where cells are announced with their column name. + */ columnLabels: ReadonlyArray; + /** + * Set on iOS and Android. A title passes its column position and name, and + * gets back the function that unregisters it. + */ + registerColumnLabel?: (index: number, label: string) => () => void; /** Data rows are offset by one on the web when a header row is present. */ hasHeader: boolean; nativeFocusMode: NativeFocusMode; diff --git a/src/components/DataTable/DataTableHeader.tsx b/src/components/DataTable/DataTableHeader.tsx index 78892fa4c3..afc007703c 100644 --- a/src/components/DataTable/DataTableHeader.tsx +++ b/src/components/DataTable/DataTableHeader.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; -import { withColumnIndices } from './DataTableColumnsContext'; import { DataTableRowContext } from './DataTableContext'; import { HORIZONTAL_PADDING } from './tokens'; import { useInternalTheme } from '../../core/theming'; @@ -69,7 +68,7 @@ const DataTableHeader = ({ {...rest} style={[styles.header, { borderBottomColor }, style]} > - {withColumnIndices(children)} + {children} ); diff --git a/src/components/DataTable/DataTableRow.tsx b/src/components/DataTable/DataTableRow.tsx index b04ed8b737..806682d4c1 100644 --- a/src/components/DataTable/DataTableRow.tsx +++ b/src/components/DataTable/DataTableRow.tsx @@ -7,7 +7,6 @@ import type { ViewStyle, } from 'react-native'; -import { withColumnIndices } from './DataTableColumnsContext'; import { DataTableContext, DataTableRowContext, @@ -225,7 +224,7 @@ const DataTableRow = ({ } : null)} > - {withColumnIndices(children)} + {children} ); diff --git a/src/components/DataTable/DataTableTitle.tsx b/src/components/DataTable/DataTableTitle.tsx index 0d05e1135c..2b02494e5f 100644 --- a/src/components/DataTable/DataTableTitle.tsx +++ b/src/components/DataTable/DataTableTitle.tsx @@ -24,6 +24,7 @@ import Animated, { import type { ColumnLayoutProps } from './columns'; import { useAlignStyles, useColumn } from './DataTableColumnsContext'; +import { DataTableContext } from './DataTableContext'; import { LINE_HEIGHT, SORT_ICON_SIZE, @@ -185,6 +186,18 @@ const DataTableTitle = ({ const columnLabel = getElementLabel({ 'aria-label': ariaLabel, children }); + const registerColumnLabel = + React.useContext(DataTableContext)?.registerColumnLabel; + const columnIndex = resolved.index; + + React.useLayoutEffect(() => { + if (registerColumnLabel && columnIndex != null && columnLabel != null) { + return registerColumnLabel(columnIndex, columnLabel); + } + + return undefined; + }, [registerColumnLabel, columnIndex, columnLabel]); + const describable = !React.Children.toArray(children).some( React.isValidElement ); diff --git a/src/components/DataTable/columns.ts b/src/components/DataTable/columns.ts index d895c78fe0..3012d11f5f 100644 --- a/src/components/DataTable/columns.ts +++ b/src/components/DataTable/columns.ts @@ -56,6 +56,7 @@ export type DataTableLayout = 'fluid' | 'fixed'; export type ColumnLayoutProps = { /** * Which column this belongs to - a `DataTableColumn` key, or a 0-based index. + * Required when the `DataTable` has `columns`. */ column?: string | number; flex?: number; diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts index c3a26b7309..81de4d24f9 100644 --- a/src/components/DataTable/utils.ts +++ b/src/components/DataTable/utils.ts @@ -106,26 +106,6 @@ export const composeCellLabel = ({ return columnLabel ? `${columnLabel}, ${value}` : value; }; -/** - * The column names of a header row, indexed by column position. - */ -export const readColumnLabels = ( - children: React.ReactNode -): Array => { - const labels: Array = []; - - React.Children.forEach(children, (child, position) => { - labels[position] = isDataTableElement( - child, - 'DataTable.Title' - ) - ? getElementLabel(child.props) - : undefined; - }); - - return labels; -}; - export type RowPositionInfo = { position: number; rowCount?: number }; export type FormatRowPosition = (info: RowPositionInfo) => string; diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index cd829d24f1..0521118d32 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -38,21 +38,36 @@ const columns: readonly DataTableColumn[] = [ { key: 'calories', numeric: true }, ]; +const tableColumns: readonly DataTableColumn[] = [ + { key: 'name' }, + { key: 'calories', numeric: true }, +]; + const Table = ({ children, ...props }: Partial> = {}) => ( - + - {}} sortDirection="ascending"> + {}} + sortDirection="ascending" + > Dessert - Calories + Calories {children ?? ( - Frozen yogurt - 159 + Frozen yogurt + 159 )} @@ -145,13 +160,13 @@ describe('DataTable.Row', () => { it('names a cell a consumer component renders, not just an inline one', async () => { const CaloriesCell = ({ value }: { value: number }) => ( - {value} + {value} ); await render(
- Frozen yogurt + Frozen yogurt
@@ -168,8 +183,10 @@ describe('DataTable.Row', () => { await render( - Frozen yogurt - 159 + Frozen yogurt + + 159 +
); @@ -194,8 +211,10 @@ describe('DataTable.Row', () => { await render( {}}> - Frozen yogurt - 159 + Frozen yogurt + + 159 +
); @@ -207,12 +226,12 @@ describe('DataTable.Row', () => { it('leaves the position out when the total is unknown', async () => { await render( - + - Dessert + Dessert - Frozen yogurt + Frozen yogurt ); @@ -235,8 +254,8 @@ describe('DataTable.Row', () => { it('numbers a row a consumer component renders, not just an inline one', async () => { const NameRow = ({ name }: { name: string }) => ( - {name} - 159 + {name} + 159 ); @@ -283,7 +302,9 @@ describe('DataTable.Row', () => { it('leaves the children of a row component as it was given them', async () => { const NameRow = ({ children }: { children: React.ReactNode }) => ( - {React.Children.only(children)} + + {React.Children.only(children)} + ); @@ -304,7 +325,7 @@ describe('DataTable.Row', () => { await render( {}}> - + @@ -321,7 +342,7 @@ describe('DataTable.Row', () => { await render(
- Frozen yogurt + Frozen yogurt
); @@ -431,14 +452,14 @@ describe('DataTable.Row', () => { ); await render( - + - Dessert + Dessert - Frozen yogurt + Frozen yogurt { await render( - {}}>Frozen yogurt - 159 + {}}> + Frozen yogurt + + + 159 +
); @@ -525,10 +550,12 @@ describe('DataTable.Cell', () => { await render( - + - 159 + + 159 +
); @@ -541,8 +568,8 @@ describe('DataTable.Cell', () => { await render( - Frozen yogurt - + Frozen yogurt + {}} @@ -563,7 +590,7 @@ describe('DataTable.Cell', () => { await render(
- + @@ -579,10 +606,12 @@ describe('DataTable.Cell', () => { await render(
- + - 159 + + 159 +
); @@ -597,7 +626,9 @@ describe('DataTable.Cell', () => { await render( - 99 + + 99 +
); @@ -621,8 +652,10 @@ describe('DataTable.Cell', () => { await render( - Frozen yogurt - + + Frozen yogurt + + 159 @@ -802,12 +835,13 @@ describe('DataTable.Title', () => { {}} sortDirection="ascending" > Dessert - + Calories @@ -940,31 +974,80 @@ describe('DataTable column contract', () => { expect(screen.getByTestId('cell', hidden)).toHaveStyle({ flex: 5 }); }); - it('resolves columns by position when no key is given', async () => { + it('resolves cells by key, not by their place among siblings', async () => { + Platform.OS = 'web'; + + await render( + + + <> + + Frozen yogurt + + + 159 + + + + + ); + + // A fragment is one child to its row, so a positional guess would put + // both cells in the first column. + expect(screen.getByTestId('first')).toHaveProp('aria-colindex', 1); + expect(screen.getByTestId('second')).toHaveProp('aria-colindex', 2); + expect(cellText('second')).toHaveStyle({ textAlign: 'right' }); + }); + + it('warns about a cell that names no column of the table', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await render( - Frozen yogurt - 159 + Frozen yogurt + 6 ); - expect(screen.getByTestId('first', hidden)).toHaveStyle({ flex: 2 }); - // The second column declares no flex, so it falls back to 1. - expect(screen.getByTestId('second', hidden)).toHaveStyle({ flex: 1 }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`column`')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"fat"')); + + warn.mockRestore(); + }); + + it('names cells from a title a consumer component renders', async () => { + const SortTitle = (props: React.ComponentProps) => ( + {}} {...props} /> + ); + + await render( + + + Dessert + + + Frozen yogurt + + + ); + + expect( + screen.getByRole('cell', { name: 'Dessert, Frozen yogurt' }) + ).toBeOnTheScreen(); }); it('names cells from the header, with no second declaration', async () => { await render( - + - Dessert - Calories + Dessert + Calories - Frozen yogurt - 159 + Frozen yogurt + 159 ); @@ -976,14 +1059,14 @@ describe('DataTable column contract', () => { it('names a cell from an explicit label when the header is not text', async () => { await render( - + - + - Yes + Yes ); @@ -1024,7 +1107,7 @@ describe('DataTable column contract', () => { layout="fixed" > - Frozen yogurt + Frozen yogurt @@ -1265,12 +1348,17 @@ describe('DataTable snapshots', () => { it('renders a table', async () => { const tree = ( await render( - + - Dessert + Dessert - Frozen yogurt + Frozen yogurt ) From 41f7bedb6e1609a2485e5a52641c43efd8aee4c5 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 28 Oct 2026 10:32:11 +0100 Subject: [PATCH 16/20] refactor(data-table): take row positions from props, not children --- docs/6.x/docs/guides/migration.md | 13 +- example/src/Examples/DataTableExample.tsx | 5 +- src/components/DataTable/DataTable.tsx | 103 +----- src/components/DataTable/DataTableContext.tsx | 9 - src/components/DataTable/DataTableRow.tsx | 30 +- src/components/DataTable/utils.ts | 56 ---- .../__tests__/DataTable/DataTable.test.tsx | 296 ++++++------------ 7 files changed, 123 insertions(+), 389 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 382a52aaea..4e80c32920 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -450,12 +450,13 @@ Rows, cells and titles with no touch handler render a plain `View` instead of a #### Screen reader announcements -- new `rowCount`, `firstRowIndex` needed for correct row positions when paginating +- new `rowCount` on the table and `index` on each row give the row positions. On a page of a larger set, `index` counts from the start of the whole set +- new `hasHeader` defaults to `true`. Pass `hasHeader={false}` for a table without a `DataTable.Header`, or every row position on the web is one too high - on iOS and Android, a cell is announced with the title of its column, matched by `column` - `nativeFocusMode="cell"` gives one stop per cell instead of one per row - `accessible={false}` on a row opts that row out - `formatRowPosition` replaces the wording, or removes it with `null` -- rows are numbered by their position among the table's rows. A component of your own counts as the one row. A row inside a `View` or a wrapper of your own is counted but not numbered: pass `index` on such a row. +- the table does not count its children, so rows in a `View`, a fragment or a component of your own get the same position as inline rows ```tsx // Before (v5) @@ -466,9 +467,11 @@ Rows, cells and titles with no touch handler render a plain `View` instead of a // After (v6) - - {items.slice(from, to).map((item) => ( - {/* ... */} + + {items.slice(from, to).map((item, i) => ( + + {/* ... */} + ))} ``` diff --git a/example/src/Examples/DataTableExample.tsx b/example/src/Examples/DataTableExample.tsx index 86998d669b..e94fe39c4e 100644 --- a/example/src/Examples/DataTableExample.tsx +++ b/example/src/Examples/DataTableExample.tsx @@ -87,7 +87,6 @@ const DataTableExample = () => { aria-label="Nutrition" columns={columns} rowCount={sortedItems.length} - firstRowIndex={from} > { Fat (g) - {sortedItems.slice(from, to).map((item) => ( - + {sortedItems.slice(from, to).map((item, i) => ( + {item.name} {item.calories} {item.fat} diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 25fc465ae9..ff6a86a0b7 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -5,7 +5,7 @@ import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; import type { DataTableColumn, DataTableLayout } from './columns'; import DataTableCell from './DataTableCell'; import { ColumnsContext } from './DataTableColumnsContext'; -import { DataTableContext, RowIndexContext } from './DataTableContext'; +import { DataTableContext } from './DataTableContext'; import type { NativeFocusMode } from './DataTableContext'; import DataTableHeader, { DataTableHeader as _DataTableHeader, @@ -17,13 +17,7 @@ import DataTableRow, { DataTableRow as _DataTableRow } from './DataTableRow'; import DataTableTitle, { DataTableTitle as _DataTableTitle, } from './DataTableTitle'; -import { - containsTableStructure, - defaultFormatRowPosition, - isDataTableElement, - isGroupingContainer, - isNeverRow, -} from './utils'; +import { defaultFormatRowPosition } from './utils'; import type { FormatRowPosition } from './utils'; import webAriaProps from '../../utils/webAriaProps'; @@ -48,15 +42,14 @@ export type Props = ViewProps & { /** * Total number of rows in the data set, which can be larger than the number * rendered when the table is paginated or virtualized. Announced to screen - * readers as the row count. + * readers as the row count. Pass `index` on every `DataTable.Row` with it. */ rowCount?: number; /** - * Index of the first *rendered* row within the data set. Set this alongside - * `rowCount` when showing a page of a larger set, so row positions are - * announced against the whole set rather than the page. + * Whether the table has a `DataTable.Header`. On the web the header is the + * first row, so it shifts the position of every data row by one. */ - firstRowIndex?: number; + hasHeader?: boolean; /** * Where a screen reader stops when moving through the table on iOS and * Android, respectively. Defaults to `row`, which announces a whole row at @@ -131,7 +124,6 @@ export type Props = ViewProps & { * aria-label="Nutrition" * columns={columns} * rowCount={items.length} - * firstRowIndex={from} * > * * Dessert @@ -139,8 +131,8 @@ export type Props = ViewProps & { * Fat * * - * {items.slice(from, to).map((item) => ( - * + * {items.slice(from, to).map((item, i) => ( + * * {item.name} * {item.calories} * {item.fat} @@ -170,7 +162,7 @@ const DataTable = ({ columns, layout = 'fluid', rowCount, - firstRowIndex = 0, + hasHeader = true, nativeFocusMode = 'row', formatRowPosition = defaultFormatRowPosition, style, @@ -186,73 +178,6 @@ const DataTable = ({ ); } - // Everything the rows need to know about the table has to be resolved while - // rendering: an effect would leave the first render - the only one a server - // renders - with the wrong row indices. - const { rows, hasHeader, renderedRowCount } = React.useMemo(() => { - let hasHeader = false; - let rendered = 0; - - const number = (child: React.ReactNode) => ( - - {child} - - ); - - const walk = (nodes: React.ReactNode): React.ReactNode => - React.Children.map(nodes, (child) => { - if (!React.isValidElement<{ children?: React.ReactNode }>(child)) { - return child; - } - - // Read before the guards below narrow `child` to `never`. - const { - type, - props: { children: nested }, - } = child; - - if (isDataTableElement(child, 'DataTable.Header')) { - hasHeader = true; - return child; - } - - if (isDataTableElement(child, 'DataTable.Pagination')) { - return child; - } - - if (isDataTableElement(child, 'DataTable.Row')) { - return number(child); - } - - if (isNeverRow(child)) { - return child; - } - - if (type === React.Fragment) { - return walk(nested); - } - - if (isGroupingContainer(child) || containsTableStructure(nested)) { - void walk(nested); - return child; - } - - // What is left renders out of sight - a row component of the - // consumer's own, most often - so it counts as one row. A row the - // table never sees at all, as a virtualized list renders, passes - // `index` itself. - return number(child); - }); - - const rows = walk(children); - - return { rows, hasHeader, renderedRowCount: rendered }; - }, [children, firstRowIndex]); - - const resolvedRowCount = - rowCount ?? - (renderedRowCount > 0 ? firstRowIndex + renderedRowCount : undefined); - const [columnLabels, setColumnLabels] = React.useState< ReadonlyArray >([]); @@ -277,7 +202,7 @@ const DataTable = ({ const tableContext = React.useMemo( () => ({ - rowCount: resolvedRowCount, + rowCount, columnLabels, registerColumnLabel: Platform.OS === 'web' ? undefined : registerColumnLabel, @@ -286,7 +211,7 @@ const DataTable = ({ formatRowPosition, }), [ - resolvedRowCount, + rowCount, columnLabels, registerColumnLabel, hasHeader, @@ -322,15 +247,13 @@ const DataTable = ({ {...nameProps} {...webAriaProps({ 'aria-rowcount': - resolvedRowCount == null - ? undefined - : resolvedRowCount + (hasHeader ? 1 : 0), + rowCount == null ? undefined : rowCount + (hasHeader ? 1 : 0), 'aria-colcount': columns?.length, })} {...viewProps} style={[layout === 'fluid' && styles.fluid, style]} > - {rows} + {children} ); diff --git a/src/components/DataTable/DataTableContext.tsx b/src/components/DataTable/DataTableContext.tsx index 911567aed6..ab9ea6971e 100644 --- a/src/components/DataTable/DataTableContext.tsx +++ b/src/components/DataTable/DataTableContext.tsx @@ -64,12 +64,3 @@ export type DataTableRowContextValue = { export const DataTableRowContext = React.createContext(null); - -/** - * The position of a row within the data set. - * - * Published by the table rather than cloned onto the child list, so it - * survives consumer wrapper components - a `` that renders a - * `DataTable.Row` is numbered just like an inline one. - */ -export const RowIndexContext = React.createContext(null); diff --git a/src/components/DataTable/DataTableRow.tsx b/src/components/DataTable/DataTableRow.tsx index 806682d4c1..5c955597a0 100644 --- a/src/components/DataTable/DataTableRow.tsx +++ b/src/components/DataTable/DataTableRow.tsx @@ -7,11 +7,7 @@ import type { ViewStyle, } from 'react-native'; -import { - DataTableContext, - DataTableRowContext, - RowIndexContext, -} from './DataTableContext'; +import { DataTableContext, DataTableRowContext } from './DataTableContext'; import type { RegisteredCell } from './DataTableContext'; import { HORIZONTAL_PADDING, @@ -42,11 +38,8 @@ export type Props = Omit< onPress?: (e: GestureResponderEvent) => void; /** * Index of this row within the data set, counting from 0. Announced to - * screen readers as the row's position. - * - * Rows rendered among the `DataTable`'s own children are numbered by their - * position there; pass this for rows rendered outside it, as a virtualized - * list does. + * screen readers as the row's position. On a paginated page, pass the index + * within the whole set, not within the page. */ index?: number; style?: StyleProp; @@ -105,20 +98,15 @@ const DataTableRow = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); const table = React.useContext(DataTableContext); - const position = React.useContext(RowIndexContext); const borderBottomColor = theme.colors.outlineVariant; - // A virtualized list renders rows outside the table's own child list, so - // those pass their index themselves. - const rowIndex = index ?? position ?? undefined; - if ( process.env.NODE_ENV !== 'production' && - table != null && - rowIndex == null + table?.rowCount != null && + index == null ) { console.warn( - 'DataTable.Row has no position: a row inside a wrapper, or in a list, needs `index`' + 'DataTable.Row needs `index` when the DataTable has `rowCount`, otherwise its position is not announced' ); } @@ -179,7 +167,7 @@ const DataTableRow = ({ (rowIsFocusUnit ? composeRowLabel({ cellLabels, - rowIndex, + rowIndex: index, rowCount: table?.rowCount, formatRowPosition: table?.formatRowPosition, }) @@ -202,9 +190,7 @@ const DataTableRow = ({ : ('button' as const), ...webAriaProps({ 'aria-rowindex': - rowIndex == null - ? undefined - : rowIndex + 1 + (table?.hasHeader ? 1 : 0), + index == null ? undefined : index + 1 + (table?.hasHeader ? 1 : 0), }), // Left unset, `Pressable` defaults it to `true`, which would swallow // whatever an interactive row holds. diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts index 81de4d24f9..1b5f79d931 100644 --- a/src/components/DataTable/utils.ts +++ b/src/components/DataTable/utils.ts @@ -1,60 +1,4 @@ import * as React from 'react'; -import { ScrollView, Text as NativeText, View } from 'react-native'; - -import Divider from '../Divider'; -import Text from '../Typography/Text'; - -/** Whether a child is a particular `DataTable` sub-component. */ -export const isDataTableElement =

( - child: React.ReactNode, - displayName: string -): child is React.ReactElement

=> { - if (!React.isValidElement(child)) { - return false; - } - - const { type } = child; - - return ( - (typeof type === 'function' || - (typeof type === 'object' && type !== null)) && - 'displayName' in type && - type.displayName === displayName - ); -}; - -/** - * Whether an element is text or a divider, which is never a row and never - * holds one. - */ -export const isNeverRow = (child: React.ReactElement): boolean => - child.type === NativeText || child.type === Text || child.type === Divider; - -/** - * Whether an element only groups the children it was given: the primitives - * the table is built from render what they are handed, so a row inside one - * keeps its own place in the table. - */ -export const isGroupingContainer = (child: React.ReactElement): boolean => - child.type === View || child.type === ScrollView; - -const structuralParts = [ - 'DataTable.Header', - 'DataTable.Row', - 'DataTable.Pagination', -]; - -/** - * Whether a subtree holds a part of the table's structure - a header, a row or - * the pagination - at any depth. - */ -export const containsTableStructure = (node: React.ReactNode): boolean => - React.Children.toArray(node).some( - (child) => - structuralParts.some((part) => isDataTableElement(child, part)) || - (React.isValidElement<{ children?: React.ReactNode }>(child) && - containsTableStructure(child.props.children)) - ); /** The text of a node, when it has one. */ export const getNodeText = (node: React.ReactNode): string | undefined => { diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index 0521118d32..1f8cd262ec 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -1,11 +1,5 @@ import * as React from 'react'; -import { - Platform, - StyleSheet, - Text as RNText, - useWindowDimensions, - View, -} from 'react-native'; +import { Platform, StyleSheet, useWindowDimensions, View } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import * as Reanimated from 'react-native-reanimated'; @@ -51,7 +45,6 @@ const Table = ({ aria-label="Nutrition" columns={tableColumns} rowCount={6} - firstRowIndex={2} {...props} > @@ -65,7 +58,7 @@ const Table = ({ Calories {children ?? ( - + Frozen yogurt 159 @@ -165,7 +158,7 @@ describe('DataTable.Row', () => { await render(

- + Frozen yogurt @@ -182,7 +175,7 @@ describe('DataTable.Row', () => { it('does not repeat the column name when a cell carries its own label', async () => { await render(
- + Frozen yogurt 159 @@ -210,7 +203,7 @@ describe('DataTable.Row', () => { it('announces a pressable row as a button so it reads as activatable', async () => { await render(
- {}}> + {}}> Frozen yogurt 159 @@ -224,7 +217,7 @@ describe('DataTable.Row', () => { ).toBeOnTheScreen(); }); - it('leaves the position out when the total is unknown', async () => { + it('leaves the position out when the row has no index', async () => { await render( @@ -236,9 +229,22 @@ describe('DataTable.Row', () => { ); - // One rendered row, indexed from 0, so the count is derived as 1. expect( - screen.getByRole('row', { name: 'Dessert, Frozen yogurt, row 1 of 1' }) + screen.getByRole('row', { name: 'Dessert, Frozen yogurt' }) + ).toBeOnTheScreen(); + }); + + it('leaves the total out when the table has no row count', async () => { + await render( + + + Frozen yogurt + + + ); + + expect( + screen.getByRole('row', { name: 'Frozen yogurt, row 1' }) ).toBeOnTheScreen(); }); @@ -251,158 +257,79 @@ describe('DataTable.Row', () => { expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 4); }); - it('numbers a row a consumer component renders, not just an inline one', async () => { - const NameRow = ({ name }: { name: string }) => ( - - {name} - 159 - - ); - - await render( -
- -
- ); - - // The position reaches the row through context, so a wrapper component - // does not swallow it on the way down. - expect( - screen.getByRole('row', { - name: 'Dessert, Frozen yogurt, Calories, 159, row 3 of 6', - }) - ).toBeOnTheScreen(); - }); - - it('numbers a row component that takes children of its own', async () => { + it('does not offset rows when the table has no header', async () => { Platform.OS = 'web'; - const NameRow = ({ children }: { children: React.ReactNode }) => ( - - {children} - 159 - - ); - await render( - - - Dessert - - Frozen yogurt + + + Frozen yogurt + ); - // Children make it a wrapper to look at, not one to look through: what it - // renders is a row, and the only row the table has. - expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); - expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); + expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 6); + expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 3); }); - it('leaves the children of a row component as it was given them', async () => { - const NameRow = ({ children }: { children: React.ReactNode }) => ( - - - {React.Children.only(children)} - - - ); + it('counts a wrapped header or pagination as no row', async () => { + Platform.OS = 'web'; - await render( - - - Frozen yogurt - -
+ const MyHeader = () => ( + + Dessert + ); - - // A component counting its own children must not be handed an array of - // one where it was written a single child. - expect(screen.getByText('Frozen yogurt')).toBeOnTheScreen(); - }); - - it('lets a control inside a pressable row keep its own stop', async () => { - await render( - - {}}> - - - - -
+ const MyPagination = () => ( + {}} + /> ); - // Left unset, the row would default to being one accessibility element - // and the checkbox would be out of reach. - expect(screen.getByTestId('row')).toHaveProp('accessible', false); - expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); - }); - - it('honours an explicitly passed index, as virtualized lists must', async () => { await render( - - + + + Frozen yogurt -
- ); - - expect(screen.getByRole('row', { name: /row 5 of 6/ })).toBeOnTheScreen(); - }); - - it('does not take an empty state beside the rows for a row', async () => { - Platform.OS = 'web'; - - await render( - - - Dessert - - Only one dessert left - - Frozen yogurt - + ); - // The text is neither counted nor allowed to push the row down a place. + // The count and the positions come from props, so what the table's + // children look like changes neither. expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); }); - it('does not take an element inside an empty state for a row', async () => { - Platform.OS = 'web'; - - const Badge = () => !; + it('names and numbers a row under a wrapped header on native', async () => { + const MyHeader = () => ( + + Dessert + + ); await render( - - - Dessert - - - Only one dessert left - - - Frozen yogurt + + + + Frozen yogurt ); - expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); - expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); + expect( + screen.getByRole('row', { name: 'Dessert, Frozen yogurt, row 1 of 1' }) + ).toBeOnTheScreen(); }); - it('counts rows grouped in a wrapper without rebuilding the wrapper', async () => { + it('numbers rows in a wrapper by their index', async () => { Platform.OS = 'web'; - const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - await render( - - - Dessert - + Frozen yogurt @@ -411,31 +338,21 @@ describe('DataTable.Row', () => { Eclair - - Gingerbread - ); expect(screen.getByTestId('first')).toHaveProp('aria-rowindex', 2); expect(screen.getByTestId('second')).toHaveProp('aria-rowindex', 3); - expect(screen.getByTestId('third')).toHaveProp('aria-rowindex', 4); - expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 4); - expect(warn).not.toHaveBeenCalled(); - - warn.mockRestore(); }); - it('warns about a row in a wrapper that was given no index', async () => { + it('warns about a row with no index in a table with a row count', async () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); await render( - - - - Frozen yogurt - - + + + Frozen yogurt + ); @@ -444,61 +361,33 @@ describe('DataTable.Row', () => { warn.mockRestore(); }); - it('finds the header and pagination inside a wrapper it cannot see through', async () => { - Platform.OS = 'web'; - - const Wrap = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - + it('lets a control inside a pressable row keep its own stop', async () => { await render( - - - - Dessert - - - - Frozen yogurt + + {}}> + + + - - {}} - /> - - +
); - // Neither wrapper holds a row, but each holds a part of the table - proof - // enough that it groups rather than renders a row of its own. - expect(screen.getByTestId('table')).toHaveProp('aria-colcount', 1); - expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); - expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); + // Left unset, the row would default to being one accessibility element + // and the checkbox would be out of reach. + expect(screen.getByTestId('row')).toHaveProp('accessible', false); + expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); }); - it('numbers rows given in a fragment', async () => { - Platform.OS = 'web'; - + it('honours an explicitly passed index, as virtualized lists must', async () => { await render( - - - Dessert - - <> - - Frozen yogurt - - - Eclair - - - + + + Frozen yogurt + +
); - expect(screen.getByTestId('first')).toHaveProp('aria-rowindex', 2); - expect(screen.getByTestId('second')).toHaveProp('aria-rowindex', 3); + expect(screen.getByRole('row', { name: /row 5 of 6/ })).toBeOnTheScreen(); }); it('renders a fragment inside a row or a header', async () => { @@ -525,7 +414,7 @@ describe('DataTable.Cell', () => { it('falls back to per-cell focus when a cell is interactive', async () => { await render( - + {}}> Frozen yogurt @@ -549,7 +438,7 @@ describe('DataTable.Cell', () => { it('keeps element content reachable instead of hiding it behind a row label', async () => { await render(
- + @@ -567,7 +456,7 @@ describe('DataTable.Cell', () => { it('does not collapse a row whose cell holds an element, even when labelled', async () => { await render(
- + Frozen yogurt { it('does not wrap element content in an accessibility element of its own', async () => { await render(
- + @@ -605,7 +494,7 @@ describe('DataTable.Cell', () => { it('does not name a cell after its column when it holds a control', async () => { await render(
- + @@ -625,7 +514,7 @@ describe('DataTable.Cell', () => { it('treats a cell label as the complete name, not a value to decorate', async () => { await render(
- + 99 @@ -651,7 +540,7 @@ describe('DataTable.Cell', () => { await render(
- + Frozen yogurt @@ -1352,12 +1241,11 @@ describe('DataTable snapshots', () => { aria-label="Nutrition" columns={[{ key: 'name' }]} rowCount={6} - firstRowIndex={2} > Dessert - + Frozen yogurt From a12d8e649bfe7ed00bcd0ec29df148cf598f3e97 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 28 Oct 2026 10:50:50 +0100 Subject: [PATCH 17/20] fix(data-table): rotate the sort arrow with a CSS transition --- src/components/DataTable/DataTableTitle.tsx | 45 ++------ .../__tests__/DataTable/DataTable.test.tsx | 101 +++++++++--------- 2 files changed, 56 insertions(+), 90 deletions(-) diff --git a/src/components/DataTable/DataTableTitle.tsx b/src/components/DataTable/DataTableTitle.tsx index 2b02494e5f..d97e7cd52d 100644 --- a/src/components/DataTable/DataTableTitle.tsx +++ b/src/components/DataTable/DataTableTitle.tsx @@ -14,13 +14,7 @@ import type { ViewStyle, } from 'react-native'; -import Animated, { - Easing, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import Animated, { cubicBezier, type CSSStyle } from 'react-native-reanimated'; import type { ColumnLayoutProps } from './columns'; import { useAlignStyles, useColumn } from './DataTableColumnsContext'; @@ -158,32 +152,6 @@ const DataTableTitle = ({ const alignStyles = useAlignStyles(resolved.align, resolved.numeric); const lines = useReflowedNumberOfLines(numberOfLines); - const rotation = useSharedValue(sortDirection === 'ascending' ? 0 : 180); - const isFirstRender = React.useRef(true); - - const { duration, easing } = theme.motion; - - const timingConfig = React.useMemo( - () => ({ - duration: duration.short3, - easing: Easing.bezier(...easing.standard), - reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, - }), - [duration.short3, easing.standard, reduceMotion] - ); - - React.useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false; - return; - } - - rotation.value = withTiming( - sortDirection === 'ascending' ? 0 : 180, - timingConfig - ); - }, [sortDirection, rotation, timingConfig]); - const columnLabel = getElementLabel({ 'aria-label': ariaLabel, children }); const registerColumnLabel = @@ -227,13 +195,16 @@ const DataTableTitle = ({ const alphaTextColor = theme.colors.onSurfaceVariant; - const iconAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${rotation.value}deg` }], - })); + const iconStyle: CSSStyle = { + transform: [{ rotate: sortDirection === 'ascending' ? '0deg' : '180deg' }], + transitionProperty: 'transform', + transitionDuration: reduceMotion ? 0 : theme.motion.duration.short3, + transitionTimingFunction: cubicBezier(...theme.motion.easing.standard), + }; const icon = sortDirection ? ( { - const ReanimatedModule = jest.requireActual< - typeof import('react-native-reanimated') - >('react-native-reanimated'); - - return { - __esModule: true, - ...ReanimatedModule, - default: ReanimatedModule.default, - withTiming: jest.fn(ReanimatedModule.withTiming), - }; -}); - const columns: readonly DataTableColumn[] = [ { key: 'name', flex: 2 }, { key: 'calories', numeric: true }, @@ -76,6 +61,19 @@ const hidden = { includeHiddenElements: true }; const cellText = (testID: string) => within(screen.getByTestId(testID, hidden)).getByText(/\S/); +// The sort indicator is hidden from the accessibility tree and has no testID +// of its own, so reach it as the first child of its title. +const sortIndicator = (testID: string) => { + // eslint-disable-next-line testing-library/no-node-access -- the indicator is hidden and has no testID, and only its animated style is under test. + const [indicator] = screen.getByTestId(testID).children; + + if (indicator == null || typeof indicator === 'string') { + throw new Error(`${testID} renders no sort indicator`); + } + + return indicator; +}; + const mockFontScale = (fontScale: number) => { jest.mocked(useWindowDimensions).mockReturnValue({ fontScale, @@ -88,7 +86,6 @@ const mockFontScale = (fontScale: number) => { afterEach(() => { Platform.OS = 'ios'; mockFontScale(1); - jest.mocked(Reanimated.withTiming).mockClear(); }); describe('DataTable', () => { @@ -763,68 +760,66 @@ describe('DataTable.Title', () => { expect(screen.getByTestId('title')).toHaveProp('aria-sort', 'none'); }); - it('does not rotate the sort indicator on first render', async () => { - const withTiming = jest.mocked(Reanimated.withTiming); - + it('points the sort indicator the right way from the first render', async () => { await render( - {}} sortDirection="descending"> + {}} + sortDirection="descending" + > Calories ); - // The indicator starts at the right angle rather than spinning into it. - expect(withTiming).not.toHaveBeenCalled(); + expect(getAnimatedStyle(sortIndicator('title'))).toMatchObject({ + transform: [{ rotate: '180deg' }], + }); }); it('rotates the sort indicator when the direction changes', async () => { - const withTiming = jest.mocked(Reanimated.withTiming); - const view = await render( - {}} sortDirection="ascending"> + {}} + sortDirection="ascending" + > Calories ); await view.rerender( - {}} sortDirection="descending"> + {}} + sortDirection="descending" + > Calories ); - expect(withTiming).toHaveBeenCalledWith( - 180, - expect.objectContaining({ - duration: LightTheme.motion.duration.short3, - reduceMotion: Reanimated.ReduceMotion.Never, - }) - ); + expect(getAnimatedStyle(sortIndicator('title'))).toMatchObject({ + transform: [{ rotate: '180deg' }], + transitionProperty: 'transform', + transitionDuration: LightTheme.motion.duration.short3, + }); }); - it('tells Reanimated to suppress the rotation under reduced motion', async () => { - const withTiming = jest.mocked(Reanimated.withTiming); - - const view = await render( - - {}} sortDirection="ascending"> - Calories - - - ); - - await view.rerender( + it('skips the rotation under reduced motion', async () => { + await render( - {}} sortDirection="descending"> + {}} + sortDirection="ascending" + > Calories ); - expect(withTiming).toHaveBeenCalledWith( - 180, - expect.objectContaining({ - reduceMotion: Reanimated.ReduceMotion.Always, - }) - ); + expect(getAnimatedStyle(sortIndicator('title'))).toMatchObject({ + transitionDuration: 0, + }); }); }); From 530a65a61b4e87bca9f8cf5ae347b353798ed75f Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 28 Oct 2026 11:39:19 +0100 Subject: [PATCH 18/20] fix(data-table): put a button inside a sortable column header on web --- src/components/DataTable/DataTableTitle.tsx | 38 ++++++++++++++++ .../__tests__/DataTable/DataTable.test.tsx | 45 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/components/DataTable/DataTableTitle.tsx b/src/components/DataTable/DataTableTitle.tsx index d97e7cd52d..97c12ad14f 100644 --- a/src/components/DataTable/DataTableTitle.tsx +++ b/src/components/DataTable/DataTableTitle.tsx @@ -279,6 +279,36 @@ const DataTableTitle = ({ ); } + if (Platform.OS === 'web') { + const { 'aria-label': buttonLabel, ...headerProps } = structuralProps; + const { testID, ...buttonProps } = viewProps; + + return ( + + + {content} + + + ); + } + return ( { expect(screen.getByTestId('title')).toHaveProp('aria-sort', 'none'); }); + it('puts a button inside a sortable column header on the web', async () => { + Platform.OS = 'web'; + + const onPress = jest.fn(); + + await render( + + + Dessert + + + ); + + const header = screen.getByTestId('title'); + const button = within(header).getByRole('button', { + name: 'Sort by dessert', + }); + + expect(header).toHaveProp('role', 'columnheader'); + expect(header).not.toHaveProp('aria-label'); + + await userEvent.press(button); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('keeps a sortable title one button on native', async () => { + await render( + + {}}> + Dessert + + + ); + + // Native has no column-header role, so the title itself is the control. + expect(screen.getByTestId('title')).toHaveProp('role', 'button'); + }); + it('points the sort indicator the right way from the first render', async () => { await render( Date: Wed, 28 Oct 2026 16:21:44 +0100 Subject: [PATCH 19/20] fix(data-table): keep pressable rows out of the tab order on web --- docs/6.x/docs/guides/migration.md | 17 ++++++ example/src/Examples/DataTableExample.tsx | 52 ++++++++++++++++++- src/components/DataTable/DataTableRow.tsx | 8 +++ .../__tests__/DataTable/DataTable.test.tsx | 39 ++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 4e80c32920..2184ab81a3 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -448,6 +448,23 @@ Rows, cells and titles with no touch handler render a plain `View` instead of a ``` +On the web, a pressable row is clickable but is no longer a Tab stop, because a table row cannot also be a button. Put a control in a cell so keyboard and screen reader users can reach the action: + +```tsx + toggle(item)}> + + toggle(item)} + aria-label={`Select ${item.name}`} + /> + + {item.name} + +``` + +Pass `tabIndex={0}` on the row to keep it a Tab stop anyway. + #### Screen reader announcements - new `rowCount` on the table and `index` on each row give the row positions. On a page of a larger set, `index` counts from the start of the whole set diff --git a/example/src/Examples/DataTableExample.tsx b/example/src/Examples/DataTableExample.tsx index e94fe39c4e..cb0aba7bdb 100644 --- a/example/src/Examples/DataTableExample.tsx +++ b/example/src/Examples/DataTableExample.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { StyleSheet } from 'react-native'; -import { DataTable, Card } from 'react-native-paper'; +import { Card, Checkbox, DataTable } from 'react-native-paper'; import type { DataTableColumn } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; @@ -16,6 +16,7 @@ type ItemsState = Array<{ // Declared once, outside the component: every title and cell reads its width // and alignment from here, and the reference has to stay stable. const columns: readonly DataTableColumn[] = [ + { key: 'selected', width: 48 }, { key: 'name', flex: 2 }, { key: 'calories', numeric: true }, { key: 'fat', numeric: true }, @@ -24,6 +25,9 @@ const columns: readonly DataTableColumn[] = [ const DataTableExample = () => { const [sortAscending, setSortAscending] = React.useState(true); const [page, setPage] = React.useState(0); + const [selected, setSelected] = React.useState>( + () => new Set() + ); const [items] = React.useState([ { key: 1, @@ -80,6 +84,24 @@ const DataTableExample = () => { setPage(0); }, [itemsPerPage]); + const toggle = (key: number) => + setSelected((previous) => { + const next = new Set(previous); + + if (!next.delete(key)) { + next.add(key); + } + + return next; + }); + + const allSelected = selected.size === items.length; + + const toggleAll = () => + setSelected( + allSelected ? new Set() : new Set(items.map((item) => item.key)) + ); + return ( @@ -89,6 +111,19 @@ const DataTableExample = () => { rowCount={sortedItems.length} > + + 0 + ? 'indeterminate' + : 'unchecked' + } + onPress={toggleAll} + aria-label="Select all" + /> + { {sortedItems.slice(from, to).map((item, i) => ( - + // The row is a pointer shortcut. On the web it is no Tab stop, so + // the checkbox is how keyboard and screen reader users select it. + toggle(item.key)} + > + + toggle(item.key)} + aria-label={`Select ${item.name}`} + /> + {item.name} {item.calories} {item.fat} diff --git a/src/components/DataTable/DataTableRow.tsx b/src/components/DataTable/DataTableRow.tsx index 5c955597a0..743f99164c 100644 --- a/src/components/DataTable/DataTableRow.tsx +++ b/src/components/DataTable/DataTableRow.tsx @@ -34,6 +34,11 @@ export type Props = Omit< children: React.ReactNode; /** * Function to execute on press. + * + * On the web, a pressable row is clickable but is no Tab stop, because a + * table row cannot also be a button. Put a control in a cell, such as a + * `Checkbox` or a `Button`, so keyboard and screen reader users can reach + * the action. */ onPress?: (e: GestureResponderEvent) => void; /** @@ -196,6 +201,9 @@ const DataTableRow = ({ // whatever an interactive row holds. accessible: accessible ?? rowIsFocusUnit, 'aria-label': label, + ...(Platform.OS === 'web' && interactive + ? { tabIndex: -1 as const } + : null), }; const content = ( diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index d95869a615..afa52f1631 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -215,6 +215,45 @@ describe('DataTable.Row', () => { ).toBeOnTheScreen(); }); + it('keeps a pressable row out of the tab order on the web', async () => { + Platform.OS = 'web'; + + const onPress = jest.fn<() => void>(); + + await render( +
+ + Frozen yogurt + +
+ ); + + const row = screen.getByTestId('row'); + + // A table row cannot also be a button, so it must not be a Tab stop that + // announces itself as a plain row. A click still presses it. + expect(row).toHaveProp('role', 'row'); + expect(row).toHaveProp('tabIndex', -1); + + await userEvent.press(row); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('lets a consumer put a pressable row back in the tab order', async () => { + Platform.OS = 'web'; + + await render( + + {}} tabIndex={0}> + Frozen yogurt + +
+ ); + + expect(screen.getByTestId('row')).toHaveProp('tabIndex', 0); + }); + it('leaves the position out when the row has no index', async () => { await render( From 1d0bb1c32fa22796bc6e71476e28974392129bcd Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 28 Oct 2026 16:26:46 +0100 Subject: [PATCH 20/20] fix: remove duplicate and prop-only tests --- .../__tests__/DataTable/DataTable.test.tsx | 254 +----------------- .../__snapshots__/DataTable.test.tsx.snap | 217 --------------- 2 files changed, 2 insertions(+), 469 deletions(-) diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx index afa52f1631..5441104467 100644 --- a/src/components/__tests__/DataTable/DataTable.test.tsx +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { Platform, useWindowDimensions } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { userEvent } from '@testing-library/react-native'; @@ -90,7 +90,7 @@ afterEach(() => { }); describe('DataTable', () => { - it('names itself as a table and reports its shape', async () => { + it('names itself as a table', async () => { await render(); const table = screen.getByTestId('table'); @@ -309,79 +309,6 @@ describe('DataTable.Row', () => { expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 3); }); - it('counts a wrapped header or pagination as no row', async () => { - Platform.OS = 'web'; - - const MyHeader = () => ( - - Dessert - - ); - const MyPagination = () => ( - {}} - /> - ); - - await render( - - - - Frozen yogurt - - - - ); - - // The count and the positions come from props, so what the table's - // children look like changes neither. - expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 2); - expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 2); - }); - - it('names and numbers a row under a wrapped header on native', async () => { - const MyHeader = () => ( - - Dessert - - ); - - await render( - - - - Frozen yogurt - - - ); - - expect( - screen.getByRole('row', { name: 'Dessert, Frozen yogurt, row 1 of 1' }) - ).toBeOnTheScreen(); - }); - - it('numbers rows in a wrapper by their index', async () => { - Platform.OS = 'web'; - - await render( - - - - Frozen yogurt - - - Eclair - - - - ); - - expect(screen.getByTestId('first')).toHaveProp('aria-rowindex', 2); - expect(screen.getByTestId('second')).toHaveProp('aria-rowindex', 3); - }); - it('warns about a row with no index in a table with a row count', async () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); @@ -414,37 +341,6 @@ describe('DataTable.Row', () => { expect(screen.getByTestId('row')).toHaveProp('accessible', false); expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); }); - - it('honours an explicitly passed index, as virtualized lists must', async () => { - await render( -
- - Frozen yogurt - -
- ); - - expect(screen.getByRole('row', { name: /row 5 of 6/ })).toBeOnTheScreen(); - }); - - it('renders a fragment inside a row or a header', async () => { - await render( - - - <> - Dessert - - - - <> - Frozen yogurt - - - - ); - - expect(screen.getByTestId('row')).toBeOnTheScreen(); - }); }); describe('DataTable.Cell', () => { @@ -472,24 +368,6 @@ describe('DataTable.Cell', () => { ).toBeOnTheScreen(); }); - it('keeps element content reachable instead of hiding it behind a row label', async () => { - await render( - - - - - - - 159 - - -
- ); - - expect(screen.queryByRole('row', { name: /159/ })).toBeNull(); - expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); - }); - it('does not collapse a row whose cell holds an element, even when labelled', async () => { await render( @@ -596,19 +474,6 @@ describe('DataTable.Cell', () => { expect(screen.getByTestId('first')).not.toHaveProp('aria-label'); }); - it('wraps text content in a text of its own', async () => { - const tree = ( - await render( - Table cell - ) - ).toJSON(); - - expect(screen.getByText('Table cell')).toBeOnTheScreen(); - // Whether the text is wrapped is structure, not behaviour, so the shape - // itself is the assertion. - expect(tree).toMatchSnapshot(); - }); - it('renders element content verbatim, without a text container', async () => { const tree = ( await render( @@ -648,30 +513,6 @@ describe('DataTable.Cell', () => { expect(cellText('pinned')).toHaveProp('numberOfLines', 2); }); - it('honours an explicit limit when text is shrunk', async () => { - mockFontScale(0.85); - - await render( - - Frozen yogurt - - ); - - expect(cellText('pinned')).toHaveProp('numberOfLines', 2); - }); - - it('never clamps a limit of 0, whatever the scale', async () => { - mockFontScale(1); - - await render( - - Frozen yogurt - - ); - - expect(cellText('free')).not.toHaveProp('numberOfLines'); - }); - it('clamps to one line by default and honours an explicit limit', async () => { await render( <> @@ -844,22 +685,6 @@ describe('DataTable.Title', () => { expect(screen.getByTestId('title')).toHaveProp('role', 'button'); }); - it('points the sort indicator the right way from the first render', async () => { - await render( - {}} - sortDirection="descending" - > - Calories - - ); - - expect(getAnimatedStyle(sortIndicator('title'))).toMatchObject({ - transform: [{ rotate: '180deg' }], - }); - }); - it('rotates the sort indicator when the direction changes', async () => { const view = await render( { ).toBeOnTheScreen(); }); - it('names cells from the header, with no second declaration', async () => { - await render( - - - Dessert - Calories - - - Frozen yogurt - 159 - - - ); - - expect( - screen.getByRole('cell', { name: 'Calories, 159' }) - ).toBeOnTheScreen(); - }); - it('names a cell from an explicit label when the header is not text', async () => { await render( @@ -1126,24 +932,6 @@ describe('DataTable metrics', () => { }); }); - it('draws the header rule heavier than the row separators', async () => { - await render( - - - Dessert - - - Frozen yogurt - - - ); - - expect(screen.getByTestId('header')).toHaveStyle({ borderBottomWidth: 1 }); - expect(screen.getByTestId('row')).toHaveStyle({ - borderBottomWidth: StyleSheet.hairlineWidth, - }); - }); - it('keeps rows at a touch-target height with room for wrapped content', async () => { await render( @@ -1159,20 +947,6 @@ describe('DataTable metrics', () => { paddingVertical: 4, }); }); - - it('gives a static row the same containing block as a pressable one', async () => { - await render( - - - Frozen yogurt - - - ); - - // `TouchableRipple` sets this, so absolutely positioned children resolve - // against the same box whether or not the row is pressable. - expect(screen.getByTestId('static')).toHaveStyle({ position: 'relative' }); - }); }); describe('DataTable alignment', () => { @@ -1334,18 +1108,6 @@ describe('DataTable snapshots', () => { expect(tree).toMatchSnapshot(); }); - it('renders a header', async () => { - const tree = ( - await render( - - Dessert - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); - }); - it('renders a sortable title', async () => { const tree = ( await render( @@ -1366,18 +1128,6 @@ describe('DataTable snapshots', () => { expect(tree).toMatchSnapshot(); }); - it('renders a static row', async () => { - const tree = ( - await render( - - Frozen yogurt - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); - }); - it('renders a pressable row', async () => { const tree = ( await render( diff --git a/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap index d5789f9ba8..79c79fd7c7 100644 --- a/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap @@ -56,83 +56,6 @@ exports[`DataTable snapshots renders a cell 1`] = ` `; -exports[`DataTable snapshots renders a header 1`] = ` - - - - Dessert - - - -`; - exports[`DataTable snapshots renders a pressable row 1`] = ` `; -exports[`DataTable snapshots renders a static row 1`] = ` - - - - - Frozen yogurt - - - - -`; - exports[`DataTable snapshots renders a static title 1`] = ` `; - -exports[`DataTable.Cell wraps text content in a text of its own 1`] = ` - - - Table cell - - -`;