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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ // 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(
+
+ );
+
+ // 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 : {};
+}