diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..2184ab81a3 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -428,6 +428,146 @@ const theme = { /> ``` +### 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} + +``` + +On the web, a pressable row is clickable but is no longer a Tab stop, because a table row cannot also be a button. Put a control in a cell so keyboard and screen reader users can reach the action: + +```tsx + toggle(item)}> + + toggle(item)} + aria-label={`Select ${item.name}`} + /> + + {item.name} + +``` + +Pass `tabIndex={0}` on the row to keep it a Tab stop anyway. + +#### Screen reader announcements + +- new `rowCount` on the table and `index` on each row give the row positions. On a page of a larger set, `index` counts from the start of the whole set +- new `hasHeader` defaults to `true`. Pass `hasHeader={false}` for a table without a `DataTable.Header`, or every row position on the web is one too high +- on iOS and Android, a cell is announced with the title of its column, matched by `column` +- `nativeFocusMode="cell"` gives one stop per cell instead of one per row +- `accessible={false}` on a row opts that row out +- `formatRowPosition` replaces the wording, or removes it with `null` +- the table does not count its children, so rows in a `View`, a fragment or a component of your own get the same position as inline rows + +```tsx +// Before (v5) + + {items.slice(from, to).map((item) => ( + {/* ... */} + ))} + + +// After (v6) + + {items.slice(from, to).map((item, i) => ( + + {/* ... */} + + ))} + +``` + +#### Pagination labels + +- `labels` is new, and localizes every control +- `aria-label="pagination-container"` and `aria-label="Options Select"` were removed: reach the controls by role or text, e.g. `getByRole('button', { name: 'Rows per page, 2' })` + +```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 a column by key, and is required when the table has `columns` + +```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 + Calories + + + {item.name} + {item.calories} + + +``` + ### Card #### `Card` layout diff --git a/example/src/Examples/DataTableExample.tsx b/example/src/Examples/DataTableExample.tsx index 69858746c2..cb0aba7bdb 100644 --- a/example/src/Examples/DataTableExample.tsx +++ b/example/src/Examples/DataTableExample.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { StyleSheet } from 'react-native'; -import { DataTable, Card } from 'react-native-paper'; +import { Card, Checkbox, DataTable } from 'react-native-paper'; +import type { DataTableColumn } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; @@ -12,9 +13,21 @@ 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: 'selected', width: 48 }, + { 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); + const [selected, setSelected] = React.useState>( + () => new Set() + ); const [items] = React.useState([ { key: 1, @@ -71,29 +84,77 @@ const DataTableExample = () => { setPage(0); }, [itemsPerPage]); + const toggle = (key: number) => + setSelected((previous) => { + const next = new Set(previous); + + if (!next.delete(key)) { + next.add(key); + } + + return next; + }); + + const allSelected = selected.size === items.length; + + const toggleAll = () => + setSelected( + allSelected ? new Set() : new Set(items.map((item) => item.key)) + ); + return ( - + + + 0 + ? 'indeterminate' + : 'unchecked' + } + onPress={toggleAll} + aria-label="Select all" + /> + 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} + {sortedItems.slice(from, to).map((item, i) => ( + // The row is a pointer shortcut. On the web it is no Tab stop, so + // the checkbox is how keyboard and screen reader users select it. + toggle(item.key)} + > + + toggle(item.key)} + aria-label={`Select ${item.name}`} + /> + + {item.name} + {item.calories} + {item.fat} ))} @@ -120,9 +181,6 @@ const styles = StyleSheet.create({ content: { padding: 8, }, - first: { - flex: 2, - }, }); export default DataTableExample; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6f07113572..ba18df0f5b 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -90,6 +90,11 @@ export type Props = Omit & { * 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. */ @@ -171,6 +176,7 @@ const Button = ({ textColor: customTextColor, children, 'aria-label': ariaLabel, + 'aria-expanded': ariaExpanded, accessibilityHint, role = 'button', hitSlop, @@ -330,6 +336,7 @@ const Button = ({ aria-label={ariaLabel} accessibilityHint={accessibilityHint} role={role} + aria-expanded={ariaExpanded} aria-disabled={disabled} accessible={accessible} hitSlop={hitSlop} diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 0707212406..ff6a86a0b7 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'; @@ -13,12 +17,50 @@ import DataTableRow, { DataTableRow as _DataTableRow } from './DataTableRow'; import DataTableTitle, { DataTableTitle as _DataTableTitle, } from './DataTableTitle'; +import { defaultFormatRowPosition } 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. Every `DataTable.Title` and `DataTable.Cell` then + * needs `column`. + */ + 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. Pass `index` on every `DataTable.Row` with it. + */ + rowCount?: number; + /** + * Whether the table has a `DataTable.Header`. On the web the header is the + * first row, so it shifts the position of every data row by one. + */ + hasHeader?: boolean; + /** + * Where a screen reader stops when moving through the table on iOS and + * Android, respectively. Defaults to `row`, which announces a whole row at + * 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 +72,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 +120,22 @@ export type Props = ViewProps & { * }, [itemsPerPage]); * * return ( - * + * * - * Dessert - * Calories - * Fat + * Dessert + * Calories + * Fat * * - * {items.slice(from, to).map((item) => ( - * - * {item.name} - * {item.calories} - * {item.fat} + * {items.slice(from, to).map((item, i) => ( + * + * {item.name} + * {item.calories} + * {item.fat} * * ))} * @@ -105,11 +157,118 @@ export type Props = ViewProps & { * export default MyComponent; * ``` */ -const DataTable = ({ children, style, ...rest }: Props) => ( - - {children} - -); +const DataTable = ({ + children, + columns, + layout = 'fluid', + rowCount, + hasHeader = true, + nativeFocusMode = 'row', + formatRowPosition = defaultFormatRowPosition, + style, + ...rest +}: Props) => { + if ( + process.env.NODE_ENV !== 'production' && + 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' + ); + } + + const [columnLabels, setColumnLabels] = React.useState< + ReadonlyArray + >([]); + + const registerColumnLabel = React.useCallback( + (index: number, label: string) => { + setColumnLabels((previous) => { + const next = [...previous]; + next[index] = label; + return next; + }); + + return () => + setColumnLabels((previous) => { + const next = [...previous]; + next[index] = undefined; + return next; + }); + }, + [] + ); + + const tableContext = React.useMemo( + () => ({ + rowCount, + columnLabels, + registerColumnLabel: + Platform.OS === 'web' ? undefined : registerColumnLabel, + hasHeader, + nativeFocusMode, + formatRowPosition, + }), + [ + rowCount, + columnLabels, + registerColumnLabel, + hasHeader, + nativeFocusMode, + formatRowPosition, + ] + ); + + const columnsContext = React.useMemo( + () => + columns + ? { + columns, + indexByKey: new Map( + columns.map((column, index) => [column.key, index]) + ), + layout, + } + : null, + [columns, layout] + ); + + const { 'aria-label': ariaLabel, accessibilityLabel, ...viewProps } = rest; + + const nameProps = + Platform.OS === 'web' + ? { 'aria-label': ariaLabel, accessibilityLabel } + : null; + + const content = ( + + {children} + + ); + + return ( + + {columnsContext ? ( + + {content} + + ) : ( + content + )} + + ); +}; // @component ./DataTableHeader.tsx DataTable.Header = DataTableHeader; @@ -127,7 +286,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 af332b3ae4..be6c7c2fba 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,6 +7,18 @@ 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, + getExplicitLabel, + getNodeText, +} from './utils'; +import hasTouchHandler from '../../utils/hasTouchHandler'; +import webAriaProps from '../../utils/webAriaProps'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; @@ -14,33 +26,45 @@ import Text from '../Typography/Text'; export type Props = Omit< React.PropsWithoutRef, 'children' -> & { - /** - * 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; -}; +> & + 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. @@ -62,8 +86,9 @@ export type Props = Omit< * 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 */ @@ -72,22 +97,161 @@ 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 explicitLabel = getExplicitLabel({ + 'aria-label': ariaLabel, + accessibilityLabel: rest.accessibilityLabel, + }); + const text = getNodeText(children); + + const registerCell = row?.registerCell; + const cellId = React.useId(); + + const describable = + !interactive && + !React.isValidElement(children) && + (explicitLabel != null || text != null); + + const labelInRow = + explicitLabel ?? composeCellLabel({ columnLabel, value: text }); + + React.useLayoutEffect( + () => + registerCell?.(cellId, { + index: resolved.index, + label: labelInRow, + describable, + }), + [registerCell, cellId, resolved.index, labelInRow, describable] + ); + + const isWeb = Platform.OS === 'web'; + + const cellIsFocusUnit = + !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; + + // The cell speaks for itself only when it is an accessibility element of its + // own: a focus unit, or a control the user can activate. + const namesItself = !isWeb && (cellIsFocusUnit || interactive); + + const label = + ariaLabel ?? + (namesItself ? composeCellLabel({ columnLabel, value }) : undefined); + + 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} ); }; @@ -95,8 +259,12 @@ const DataTableCell = ({ const CellContent = ({ children, textStyle, + numberOfLines, maxFontSizeMultiplier, -}: Pick) => { +}: Pick & { + textStyle?: StyleProp; + numberOfLines?: number; +}) => { if (React.isValidElement(children)) { return children; } @@ -104,7 +272,7 @@ const CellContent = ({ return ( {children} @@ -116,14 +284,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/DataTableColumnsContext.tsx b/src/components/DataTable/DataTableColumnsContext.tsx new file mode 100644 index 0000000000..fd01b7137a --- /dev/null +++ b/src/components/DataTable/DataTableColumnsContext.tsx @@ -0,0 +1,161 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; +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[]; + indexByKey: ReadonlyMap; + layout: DataTableLayout; +}; + +export const ColumnsContext = React.createContext( + null +); + +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. + * + * The column comes from the `column` prop alone, never from the element's + * place among its siblings, so a title or cell inside a wrapper component or a + * fragment resolves just like an inline one. + */ +export const useColumn = ({ + column, + flex, + width, + minWidth, + maxWidth, + align, + numeric, +}: ColumnLayoutProps): ResolvedColumn => { + const context = React.useContext(ColumnsContext); + + if (process.env.NODE_ENV !== 'production' && context) { + if (column == null) { + console.warn( + 'DataTable.Title and DataTable.Cell need `column` when the DataTable has `columns`, otherwise they get no column definition' + ); + } else if (typeof column === 'string' && !context.indexByKey.has(column)) { + console.warn(`DataTable has no column with the key "${column}"`); + } + } + + const index = + typeof column === 'number' + ? column + : column == null + ? undefined + : context?.indexByKey.get(column); + + const descriptor = index == null ? undefined : context?.columns[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 a physical value because React Native has + * no `textAlign: 'start' | 'end'`. iOS and Android mirror `left` and `right` + * in RTL layouts on their own, so only the web needs the mapping. + */ +export const useAlignStyles = ( + align: DataTableColumnAlign, + numeric: boolean +): AlignStyles => { + const { direction } = useLocale(); + + return React.useMemo(() => { + const isEnd = align === 'end'; + // iOS and Android mirror left and right in RTL layouts. The web does not. + const mirror = Platform.OS === 'web' && direction === 'rtl'; + + return { + container: { + justifyContent: isEnd + ? 'flex-end' + : align === 'center' + ? 'center' + : 'flex-start', + }, + text: { + textAlign: + align === 'center' ? 'center' : isEnd !== mirror ? 'right' : 'left', + ...(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..ab9ea6971e --- /dev/null +++ b/src/components/DataTable/DataTableContext.tsx @@ -0,0 +1,66 @@ +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. Filled on iOS and + * Android only, where cells are announced with their column name. + */ + columnLabels: ReadonlyArray; + /** + * Set on iOS and Android. A title passes its column position and name, and + * gets back the function that unregisters it. + */ + registerColumnLabel?: (index: number, label: string) => () => void; + /** Data rows are offset by one on the web when a header row is present. */ + hasHeader: boolean; + nativeFocusMode: NativeFocusMode; + formatRowPosition: FormatRowPosition | null; +}; + +export const DataTableContext = + React.createContext(null); + +/** + * What a cell tells its row, so the row can announce every cell at once + * without reading its own children. + */ +export type RegisteredCell = { + /** Column position of the cell, when it is known. */ + index?: number; + /** The cell's name as part of the row, column name included. */ + label?: string; + /** Plain text and not interactive, so it is safe to hide behind the row. */ + describable: boolean; +}; + +export type DataTableRowContextValue = { + /** This is the header row, so its cells are column headers. */ + header: boolean; + rowIsFocusUnit: boolean; + /** + * Set while the row can become one accessibility element. Returns the + * function that unregisters the cell. + */ + registerCell?: (id: string, cell: RegisteredCell) => () => void; +}; + +export const DataTableRowContext = + React.createContext(null); diff --git a/src/components/DataTable/DataTableHeader.tsx b/src/components/DataTable/DataTableHeader.tsx index 800f92b1a4..afc007703c 100644 --- a/src/components/DataTable/DataTableHeader.tsx +++ b/src/components/DataTable/DataTableHeader.tsx @@ -2,8 +2,11 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; +import { DataTableRowContext } from './DataTableContext'; +import { HORIZONTAL_PADDING } from './tokens'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; +import webAriaProps from '../../utils/webAriaProps'; export type Props = ViewProps & { /** @@ -50,12 +53,24 @@ const DataTableHeader = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const borderBottomColor = theme.colors.surfaceVariant; + const borderBottomColor = theme.colors.outlineVariant; + + const rowContext = React.useMemo( + () => ({ header: true, rowIsFocusUnit: false }), + [] + ); return ( - - {children} - + + + {children} + + ); }; @@ -64,7 +79,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/DataTablePagination.tsx b/src/components/DataTable/DataTablePagination.tsx index 0fd2cb7a04..f6bd861316 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 '../../theme/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,23 +342,29 @@ 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} @@ -307,6 +372,7 @@ const DataTablePagination = ({ numberOfItemsPerPageList={numberOfItemsPerPageList} numberOfItemsPerPage={numberOfItemsPerPage} onItemsPerPageChange={onItemsPerPageChange} + labels={labels} theme={theme} /> @@ -314,7 +380,13 @@ const DataTablePagination = ({ {label} @@ -324,6 +396,7 @@ const DataTablePagination = ({ onPageChange={onPageChange} page={page} numberOfPages={numberOfPages} + labels={labels} theme={theme} /> @@ -338,7 +411,7 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', flexDirection: 'row', alignItems: 'center', - paddingLeft: 16, + paddingStart: HORIZONTAL_PADDING, flexWrap: 'wrap', }, optionsContainer: { @@ -348,11 +421,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/DataTable/DataTableRow.tsx b/src/components/DataTable/DataTableRow.tsx index a833fda7b1..743f99164c 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,18 @@ import type { ViewStyle, } from 'react-native'; +import { DataTableContext, DataTableRowContext } from './DataTableContext'; +import type { RegisteredCell } from './DataTableContext'; +import { + HORIZONTAL_PADDING, + ROW_MIN_HEIGHT, + ROW_VERTICAL_PADDING, +} from './tokens'; +import { composeRowLabel } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; +import hasTouchHandler from '../../utils/hasTouchHandler'; +import webAriaProps from '../../utils/webAriaProps'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -17,13 +27,26 @@ export type Props = Omit< 'children' > & { /** - * Content of the `DataTableRow`. + * Content of the `DataTableRow`. Put all content in `DataTable.Cell`s: on + * iOS and Android a row of plain-text cells is announced as one item, and + * content outside a cell would be hidden with the cells. */ children: React.ReactNode; /** * Function to execute on press. + * + * On the web, a pressable row is clickable but is no Tab stop, because a + * table row cannot also be a button. Put a control in a cell, such as a + * `Checkbox` or a `Button`, so keyboard and screen reader users can reach + * the action. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Index of this row within the data set, counting from 0. Announced to + * screen readers as the row's position. On a paginated page, pass the index + * within the whole set, not within the page. + */ + index?: number; style?: StyleProp; /** * @optional @@ -59,25 +82,181 @@ export type Props = Omit< */ 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 ( - >( + () => new Map() + ); + + const registerCell = React.useCallback((id: string, cell: RegisteredCell) => { + setCells((previous) => new Map(previous).set(id, cell)); + + return () => + setCells((previous) => { + const next = new Map(previous); + next.delete(id); + return next; + }); + }, []); + + // Collapsing a row is only safe when every cell is plain text and none is + // separately interactive, or its content would be out of reach. + const { cellLabels, describable } = React.useMemo(() => { + const indexed: Array = []; + const unindexed: Array = []; + let describable = cells.size > 0; + + cells.forEach((cell) => { + if (!cell.describable) { + describable = false; + } + + if (cell.index == null) { + unindexed.push(cell.label); + } else { + indexed[cell.index] = cell.label; + } + }); + + return { cellLabels: [...indexed, ...unindexed], describable }; + }, [cells]); + + const rowIsFocusUnit = collectsCells && describable; + + const label = + ariaLabel ?? + (rowIsFocusUnit + ? composeRowLabel({ + cellLabels, + rowIndex: index, + rowCount: table?.rowCount, + formatRowPosition: table?.formatRowPosition, + }) + : undefined); + + const rowContext = React.useMemo( + () => ({ + header: false, + rowIsFocusUnit, + registerCell: collectsCells ? registerCell : undefined, + }), + [rowIsFocusUnit, collectsCells, registerCell] + ); + + 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), + }), + // Left unset, `Pressable` defaults it to `true`, which would swallow + // whatever an interactive row holds. + accessible: accessible ?? rowIsFocusUnit, + 'aria-label': label, + ...(Platform.OS === 'web' && interactive + ? { tabIndex: -1 as const } + : null), + }; + + const content = ( + - - {children} - - + {children} + + ); + + return ( + + {interactive ? ( + + {content} + + ) : ( + + {content} + + )} + ); }; @@ -87,8 +266,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 d6420ea41e..97c12ad14f 100644 --- a/src/components/DataTable/DataTableTitle.tsx +++ b/src/components/DataTable/DataTableTitle.tsx @@ -1,5 +1,11 @@ import * as React from 'react'; -import { PixelRatio, Pressable, StyleSheet } from 'react-native'; +import { + AccessibilityInfo, + Platform, + Pressable, + StyleSheet, + View, +} from 'react-native'; import type { GestureResponderEvent, PressableProps, @@ -8,56 +14,70 @@ import type { ViewStyle, } from 'react-native'; -import Animated, { - Easing, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import Animated, { cubicBezier, type CSSStyle } from 'react-native-reanimated'; +import type { ColumnLayoutProps } from './columns'; +import { useAlignStyles, useColumn } from './DataTableColumnsContext'; +import { DataTableContext } from './DataTableContext'; +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 '../../theme/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. @@ -90,77 +110,220 @@ const DataTableTitle = ({ children, onPress, sortDirection, + sortAccessibilityLabels = defaultSortAccessibilityLabels, textStyle, style, theme: themeOverrides, - numberOfLines = 1, + column, + flex, + width, + minWidth, + maxWidth, + align, + numberOfLines, maxFontSizeMultiplier, + 'aria-label': ariaLabel, + disabled, ...rest }: Props) => { + const { + android_ripple, + android_disableSound, + delayLongPress, + pressRetentionOffset, + unstable_pressDelay, + testOnly_pressed, + ...viewProps + } = rest; + const theme = useInternalTheme(themeOverrides); const reduceMotion = useReduceMotion(); const { direction } = useLocale(); + const resolved = useColumn({ + column, + flex, + width, + minWidth, + maxWidth, + align, + numeric, + }); + const alignStyles = useAlignStyles(resolved.align, resolved.numeric); + const lines = useReflowedNumberOfLines(numberOfLines); + + const columnLabel = getElementLabel({ 'aria-label': ariaLabel, children }); + + const registerColumnLabel = + React.useContext(DataTableContext)?.registerColumnLabel; + const columnIndex = resolved.index; - const spin = useSharedValue(sortDirection === 'ascending' ? 0 : 180); + React.useLayoutEffect(() => { + if (registerColumnLabel && columnIndex != null && columnLabel != null) { + return registerColumnLabel(columnIndex, columnLabel); + } + + return undefined; + }, [registerColumnLabel, columnIndex, columnLabel]); + + const describable = !React.Children.toArray(children).some( + React.isValidElement + ); + + const previousSortDirection = React.useRef(sortDirection); React.useEffect(() => { - spin.value = withTiming(sortDirection === 'ascending' ? 0 : 180, { - duration: 150, - easing: Easing.inOut(Easing.ease), - reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, - }); - }, [reduceMotion, sortDirection, spin]); + const previous = previousSortDirection.current; + previousSortDirection.current = sortDirection; + + if (previous === sortDirection || Platform.OS === 'web') { + return; + } - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${spin.value}deg` }], - })); + // 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 iconStyle: CSSStyle = { + transform: [{ rotate: sortDirection === 'ascending' ? '0deg' : '180deg' }], + transitionProperty: 'transform', + transitionDuration: reduceMotion ? 0 : theme.motion.duration.short3, + transitionTimingFunction: cubicBezier(...theme.motion.easing.standard), + }; + const icon = sortDirection ? ( - + ) : 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' || !(onPress || describable) ? undefined : true, + ...webAriaProps({ + 'aria-colindex': resolved.index == null ? undefined : resolved.index + 1, + // `none` is what advertises a column as sortable but currently unsorted. + '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} + + ); + } + + if (Platform.OS === 'web') { + const { 'aria-label': buttonLabel, ...headerProps } = structuralProps; + const { testID, ...buttonProps } = viewProps; + + return ( + + + {content} + + + ); + } + + return ( + + {content} ); }; @@ -169,33 +332,23 @@ DataTableTitle.displayName = 'DataTable.Title'; const styles = StyleSheet.create({ container: { - flex: 1, flexDirection: 'row', - alignContent: 'center', - paddingVertical: 12, - }, - - rightText: { - textAlign: 'right', - }, - - leftText: { - textAlign: 'left', + alignItems: 'center', + paddingVertical: TITLE_VERTICAL_PADDING, }, - centerText: { - textAlign: 'center', + header: { + flexDirection: 'row', }, - right: { - justifyContent: 'flex-end', + button: { + flexGrow: 1, }, cell: { - lineHeight: 24, - fontSize: 12, + lineHeight: LINE_HEIGHT, + fontSize: TITLE_FONT_SIZE, fontWeight: '500', - alignItems: 'center', }, sorted: { @@ -203,7 +356,7 @@ const styles = StyleSheet.create({ }, icon: { - height: 24, + height: LINE_HEIGHT, justifyContent: 'center', }, }); diff --git a/src/components/DataTable/columns.ts b/src/components/DataTable/columns.ts new file mode 100644 index 0000000000..3012d11f5f --- /dev/null +++ b/src/components/DataTable/columns.ts @@ -0,0 +1,68 @@ +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. + * Required when the `DataTable` has `columns`. + */ + 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..7e32e3287f --- /dev/null +++ b/src/components/DataTable/tokens.ts @@ -0,0 +1,20 @@ +/** 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; diff --git a/src/components/DataTable/useReflowedNumberOfLines.ts b/src/components/DataTable/useReflowedNumberOfLines.ts new file mode 100644 index 0000000000..7abae77687 --- /dev/null +++ b/src/components/DataTable/useReflowedNumberOfLines.ts @@ -0,0 +1,18 @@ +import { useWindowDimensions } from 'react-native'; + +/** + * 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 > 1 ? undefined : 1; +} diff --git a/src/components/DataTable/utils.ts b/src/components/DataTable/utils.ts new file mode 100644 index 0000000000..1b5f79d931 --- /dev/null +++ b/src/components/DataTable/utils.ts @@ -0,0 +1,96 @@ +import * as React from 'react'; + +/** 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; +}; + +/** + * 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 => + getExplicitLabel(props) ?? 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.test.tsx b/src/components/__tests__/DataTable.test.tsx deleted file mode 100644 index 7a29a72800..0000000000 --- a/src/components/__tests__/DataTable.test.tsx +++ /dev/null @@ -1,172 +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 content', async () => { - const tree = ( - await render( - Table cell - ) - ).toJSON(); - - expect(screen.getByText('Table cell')).toBeOnTheScreen(); - expect(tree).toMatchSnapshot(); - }); - - it('renders data table cell children without wrapping text component', async () => { - const tree = ( - await render( - - - - ) - ).toJSON(); - - expect(screen.getByTestId('table-cell-checkbox')).toBeOnTheScreen(); - expect(tree).toMatchSnapshot(); - }); -}); - -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..5441104467 --- /dev/null +++ b/src/components/__tests__/DataTable/DataTable.test.tsx @@ -0,0 +1,1341 @@ +import * as React from 'react'; +import { Platform, useWindowDimensions } from 'react-native'; + +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { userEvent } from '@testing-library/react-native'; +import { getAnimatedStyle } from 'react-native-reanimated'; + +import { LocaleProvider } from '../../../core/locale'; +import PaperProvider from '../../../core/PaperProvider'; +import { render, screen, within } from '../../../test-utils'; +import { LightTheme } from '../../../theme/schemes'; +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 tableColumns: readonly DataTableColumn[] = [ + { key: 'name' }, + { 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 }; + +// The text node inside a cell carries the clamping and the alignment. It has +// no testID of its own - components no longer derive one from their parent - +// so reach it through the cell. +const cellText = (testID: string) => + within(screen.getByTestId(testID, hidden)).getByText(/\S/); + +// The sort indicator is hidden from the accessibility tree and has no testID +// of its own, so reach it as the first child of its title. +const sortIndicator = (testID: string) => { + // eslint-disable-next-line testing-library/no-node-access -- the indicator is hidden and has no testID, and only its animated style is under test. + const [indicator] = screen.getByTestId(testID).children; + + if (indicator == null || typeof indicator === 'string') { + throw new Error(`${testID} renders no sort indicator`); + } + + return indicator; +}; + +const mockFontScale = (fontScale: number) => { + jest.mocked(useWindowDimensions).mockReturnValue({ + fontScale, + width: 750, + height: 1334, + scale: 2, + }); +}; + +afterEach(() => { + Platform.OS = 'ios'; + mockFontScale(1); +}); + +describe('DataTable', () => { + it('names itself as a table', 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('names a cell a consumer component renders, not just an inline one', async () => { + const CaloriesCell = ({ value }: { value: number }) => ( + {value} + ); + + await render( +
+ + Frozen yogurt + + +
+ ); + + expect( + screen.getByRole('row', { + name: 'Dessert, Frozen yogurt, Calories, 159, row 3 of 6', + }) + ).toBeOnTheScreen(); + }); + + it('does not repeat the column name when a cell carries its own label', async () => { + await render( + + + 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('keeps a pressable row out of the tab order on the web', async () => { + Platform.OS = 'web'; + + const onPress = jest.fn<() => void>(); + + await render( + + + Frozen yogurt + +
+ ); + + const row = screen.getByTestId('row'); + + // A table row cannot also be a button, so it must not be a Tab stop that + // announces itself as a plain row. A click still presses it. + expect(row).toHaveProp('role', 'row'); + expect(row).toHaveProp('tabIndex', -1); + + await userEvent.press(row); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('lets a consumer put a pressable row back in the tab order', async () => { + Platform.OS = 'web'; + + await render( + + {}} tabIndex={0}> + Frozen yogurt + +
+ ); + + expect(screen.getByTestId('row')).toHaveProp('tabIndex', 0); + }); + + it('leaves the position out when the row has no index', async () => { + await render( + + + Dessert + + + Frozen yogurt + + + ); + + expect( + screen.getByRole('row', { name: 'Dessert, Frozen yogurt' }) + ).toBeOnTheScreen(); + }); + + it('leaves the total out when the table has no row count', async () => { + await render( + + + Frozen yogurt + + + ); + + expect( + screen.getByRole('row', { name: 'Frozen yogurt, row 1' }) + ).toBeOnTheScreen(); + }); + + 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('does not offset rows when the table has no header', async () => { + Platform.OS = 'web'; + + await render( + + + Frozen yogurt + + + ); + + expect(screen.getByTestId('table')).toHaveProp('aria-rowcount', 6); + expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 3); + }); + + it('warns about a row with no index in a table with a row count', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( + + + Frozen yogurt + + + ); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`index`')); + + warn.mockRestore(); + }); + + it('lets a control inside a pressable row keep its own stop', async () => { + await render( +
+ {}}> + + + + +
+ ); + + // Left unset, the row would default to being one accessibility element + // and the checkbox would be out of reach. + expect(screen.getByTestId('row')).toHaveProp('accessible', false); + expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); + }); +}); + +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('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('does not name a cell after its column when it holds a control', async () => { + await render( + + + + + + + 159 + + +
+ ); + + // Not "Dessert" - the column name on the container would bury the + // checkbox's own role and state behind it. + expect(screen.getByTestId('cell')).not.toHaveProp('aria-label'); + expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen(); + }); + + it('treats a cell label as the complete name, not a value to decorate', async () => { + await render( + + + + 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('renders element content verbatim, without a text container', async () => { + const tree = ( + await render( + + + + ) + ).toJSON(); + + expect(screen.getByTestId('table-cell-checkbox')).toBeOnTheScreen(); + expect(tree).toMatchSnapshot(); + }); + + 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(cellText('small-bump')).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(cellText('pinned')).toHaveProp('numberOfLines', 2); + }); + + it('clamps to one line by default and honours an explicit limit', async () => { + await render( + <> + Frozen yogurt + + Frozen yogurt + + + ); + + expect(cellText('clamped')).toHaveProp('numberOfLines', 1); + expect(cellText('wrapping')).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('lets a control inside a column header keep its own stop', async () => { + await render( + + + + + + ); + + // Made one accessibility element, the header would swallow the checkbox. + expect(screen.getByTestId('title')).not.toHaveProp('accessible', true); + expect(screen.getByTestId('select-all')).toBeOnTheScreen(); + }); + + it('announces a sortable column and its sort state', async () => { + await render(); + + 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('puts a button inside a sortable column header on the web', async () => { + Platform.OS = 'web'; + + const onPress = jest.fn(); + + await render( + + + Dessert + + + ); + + const header = screen.getByTestId('title'); + const button = within(header).getByRole('button', { + name: 'Sort by dessert', + }); + + expect(header).toHaveProp('role', 'columnheader'); + expect(header).not.toHaveProp('aria-label'); + + await userEvent.press(button); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('keeps a sortable title one button on native', async () => { + await render( + + {}}> + Dessert + + + ); + + // Native has no column-header role, so the title itself is the control. + expect(screen.getByTestId('title')).toHaveProp('role', 'button'); + }); + + it('rotates the sort indicator when the direction changes', async () => { + const view = await render( + {}} + sortDirection="ascending" + > + Calories + + ); + + await view.rerender( + {}} + sortDirection="descending" + > + Calories + + ); + + expect(getAnimatedStyle(sortIndicator('title'))).toMatchObject({ + transform: [{ rotate: '180deg' }], + transitionProperty: 'transform', + transitionDuration: LightTheme.motion.duration.short3, + }); + }); + + it('skips the rotation under reduced motion', async () => { + await render( + + {}} + sortDirection="ascending" + > + Calories + + + ); + + expect(getAnimatedStyle(sortIndicator('title'))).toMatchObject({ + transitionDuration: 0, + }); + }); +}); + +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 cells by key, not by their place among siblings', async () => { + Platform.OS = 'web'; + + await render( + + + <> + + Frozen yogurt + + + 159 + + + + + ); + + // A fragment is one child to its row, so a positional guess would put + // both cells in the first column. + expect(screen.getByTestId('first')).toHaveProp('aria-colindex', 1); + expect(screen.getByTestId('second')).toHaveProp('aria-colindex', 2); + expect(cellText('second')).toHaveStyle({ textAlign: 'right' }); + }); + + it('warns about a cell that names no column of the table', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( + + + Frozen yogurt + 6 + + + ); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`column`')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"fat"')); + + warn.mockRestore(); + }); + + it('names cells from a title a consumer component renders', async () => { + const SortTitle = (props: React.ComponentProps) => ( + {}} {...props} /> + ); + + await render( + + + Dessert + + + Frozen yogurt + + + ); + + expect( + screen.getByRole('cell', { name: 'Dessert, Frozen yogurt' }) + ).toBeOnTheScreen(); + }); + + it('names 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: LightTheme.colors.outlineVariant, + }); + expect(screen.getByTestId('row')).toHaveStyle({ + borderBottomColor: LightTheme.colors.outlineVariant, + }); + }); + + 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, + }); + }); +}); + +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(cellText('start')).toHaveStyle({ + textAlign: 'left', + }); + expect(screen.getByTestId('numeric')).toHaveStyle({ + justifyContent: 'flex-end', + }); + expect(cellText('numeric')).toHaveStyle({ + textAlign: 'right', + // Tabular figures keep digits lined up between rows. + fontVariant: ['tabular-nums'], + }); + expect(cellText('center')).toHaveStyle({ + textAlign: 'center', + }); + }); + + it('keeps physical text alignment in right-to-left layouts on native', async () => { + await render( + + Frozen yogurt + + 159 + + + ); + + // iOS and Android mirror `left` and `right` in RTL layouts themselves, so + // mapping them here too would flip the alignment twice. + expect(cellText('start')).toHaveStyle({ + textAlign: 'left', + }); + expect(cellText('numeric')).toHaveStyle({ + textAlign: 'right', + }); + // `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('mirrors text alignment in right-to-left layouts on the web', async () => { + Platform.OS = 'web'; + + await render( + + Frozen yogurt + + 159 + + + ); + + expect(cellText('start')).toHaveStyle({ + textAlign: 'right', + }); + expect(cellText('numeric')).toHaveStyle({ + textAlign: 'left', + }); + }); + + it('treats `numeric` as the data and `align` as the position', async () => { + await render( + <> + + 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(cellText('centered')).toHaveStyle({ + fontVariant: ['tabular-nums'], + }); + + // Non-numeric content can still be end-aligned, without tabular figures. + expect(screen.getByTestId('text')).toHaveStyle({ + justifyContent: 'flex-end', + }); + expect(cellText('text')).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 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 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(); + }); +}); + +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.queryByLabelText('Options Select')).toBeNull(); + }); + + 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.getByText('Rows per page')).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.queryByRole('button', { name: 'Rows per page, 2' }) + ).toBeNull(); + + await view.rerender( + {}} + label="11-20 of 150" + numberOfItemsPerPageList={[2, 4, 6]} + numberOfItemsPerPage={2} + onItemsPerPageChange={() => {}} + selectPageDropdownLabel="Rows per page" + /> + ); + + expect( + screen.getByRole('button', { name: 'Rows per page, 2' }) + ).toBeOnTheScreen(); + expect(screen.getByText('Rows per page')).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 new file mode 100644 index 0000000000..79c79fd7c7 --- /dev/null +++ b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap @@ -0,0 +1,1045 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataTable snapshots renders a cell 1`] = ` + + + 159 + + +`; + +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 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 + + + + + + +`; + +exports[`DataTable.Cell renders element content verbatim, without a text container 1`] = ` + + + + + + + + + + + + + + + +`; 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/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap deleted file mode 100644 index 9084841e9b..0000000000 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ /dev/null @@ -1,2924 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DataTable.Cell renders data table cell 1`] = ` - - - Cupcake - - -`; - -exports[`DataTable.Cell renders data table cell children without wrapping text component 1`] = ` - - - - - - - - - - - - - - - -`; - -exports[`DataTable.Cell renders data table cell with text content 1`] = ` - - - Table cell - - -`; - -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 1807d2f249..bde103839c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -83,6 +83,18 @@ 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 { DataTablePaginationLabels } from './components/DataTable/DataTablePagination'; +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'; 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 : {}; +}