From e7390effcbd1378dc2a357bb2e038a93de6c2024 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 26 Aug 2026 17:18:31 +0200 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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} + +```