+ const role =
+ Platform.OS === 'web'
+ ? ('columnheader' as const)
+ : onPress
+ ? ('button' as const)
+ : undefined;
+
+ const structuralProps = {
+ role,
+ // Native has no column-header semantics, so a title that is not already a
+ // pressable has to opt in, or its text is absorbed by whichever ancestor
+ // happens to be focusable and the columns read as one run-on stop.
+ accessible: Platform.OS === 'web' ? undefined : true,
+ ...webAriaProps({
+ 'aria-colindex': resolved.index == null ? undefined : resolved.index + 1,
+ // `none` is what advertises a column as sortable but currently unsorted.
+ 'aria-sort': sortDirection ?? (onPress ? ('none' as const) : undefined),
+ }),
+ 'aria-label':
+ ariaLabel ??
+ // On native the sort state has nowhere to go but the name.
+ (Platform.OS !== 'web' && sortDirection && columnLabel
+ ? `${columnLabel}, ${sortAccessibilityLabels[sortDirection]}`
+ : undefined),
+ };
+
+ const containerStyle = [
+ styles.container,
+ resolved.style,
+ alignStyles.container,
+ style,
+ ];
+
+ const content = (
+ <>
{icon}
1
- ? numeric
- ? direction === 'rtl'
- ? styles.leftText
- : styles.rightText
- : styles.centerText
- : {},
+ alignStyles.text,
sortDirection ? styles.sorted : { color: alphaTextColor },
textStyle,
]}
- numberOfLines={numberOfLines}
+ numberOfLines={lines}
maxFontSizeMultiplier={maxFontSizeMultiplier}
>
{children}
+ >
+ );
+
+ if (!onPress) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
);
};
@@ -160,33 +310,15 @@ DataTableTitle.displayName = 'DataTable.Title';
const styles = StyleSheet.create({
container: {
- flex: 1,
flexDirection: 'row',
- alignContent: 'center',
- paddingVertical: 12,
- },
-
- rightText: {
- textAlign: 'right',
- },
-
- leftText: {
- textAlign: 'left',
- },
-
- centerText: {
- textAlign: 'center',
- },
-
- right: {
- justifyContent: 'flex-end',
+ alignItems: 'center',
+ paddingVertical: TITLE_VERTICAL_PADDING,
},
cell: {
- lineHeight: 24,
- fontSize: 12,
+ lineHeight: LINE_HEIGHT,
+ fontSize: TITLE_FONT_SIZE,
fontWeight: '500',
- alignItems: 'center',
},
sorted: {
@@ -194,7 +326,7 @@ const styles = StyleSheet.create({
},
icon: {
- height: 24,
+ height: LINE_HEIGHT,
justifyContent: 'center',
},
});
diff --git a/src/components/DataTable/columns.ts b/src/components/DataTable/columns.ts
new file mode 100644
index 0000000000..d895c78fe0
--- /dev/null
+++ b/src/components/DataTable/columns.ts
@@ -0,0 +1,67 @@
+export type DataTableColumnAlign = 'start' | 'center' | 'end';
+
+/**
+ * A shared definition of one table column.
+ *
+ * Passing these to `DataTable` makes the header and every row agree on width
+ * and alignment from a single place.
+ */
+export type DataTableColumn = {
+ /**
+ * Stable identifier. Pass the same value as `column` on the matching
+ * `DataTable.Title` and `DataTable.Cell`.
+ */
+ key: string;
+ /**
+ * Flex grow factor. Defaults to 1 when neither `flex` nor `width` is set.
+ */
+ flex?: number;
+ /**
+ * Fixed width in dp. Takes precedence over `flex`.
+ */
+ width?: number;
+ /**
+ * Minimum width of the column. Only reachable when the table is
+ * allowed to overflow, i.e. under `layout="fixed"` inside a horizontal
+ * `ScrollView`.
+ */
+ minWidth?: number;
+ maxWidth?: number;
+ /**
+ * Content alignment within the column. Defaults to `end` for numeric
+ * columns and `start` otherwise.
+ */
+ align?: DataTableColumnAlign;
+ /**
+ * Whether the column holds numbers. Numeric columns use tabular figures, so
+ * digits line up between rows, and align to `end` unless `align` says
+ * otherwise.
+ */
+ numeric?: boolean;
+};
+
+/**
+ * How a table distributes its columns.
+ *
+ * - `fluid` (default) - columns share the table's width through flex.
+ * - `fixed` - columns keep their declared width and the row may exceed the
+ * viewport. Wrap the table in a horizontal `ScrollView`.
+ */
+export type DataTableLayout = 'fluid' | 'fixed';
+
+/**
+ * Layout props shared by `DataTable.Title` and `DataTable.Cell`. Any of them
+ * overrides the matching field of the shared column definition.
+ */
+export type ColumnLayoutProps = {
+ /**
+ * Which column this belongs to - a `DataTableColumn` key, or a 0-based index.
+ */
+ column?: string | number;
+ flex?: number;
+ width?: number;
+ minWidth?: number;
+ maxWidth?: number;
+ align?: DataTableColumnAlign;
+ numeric?: boolean;
+};
diff --git a/src/components/DataTable/tokens.ts b/src/components/DataTable/tokens.ts
new file mode 100644
index 0000000000..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..2b9f6e817b
--- /dev/null
+++ b/src/components/DataTable/utils.ts
@@ -0,0 +1,106 @@
+import * as React from 'react';
+
+/** Whether a child is a particular `DataTable` sub-component. */
+export const isDataTableElement = (
+ child: React.ReactNode,
+ displayName: string
+): child is React.ReactElement
=>
+ React.isValidElement(child) &&
+ typeof child.type !== 'string' &&
+ 'displayName' in child.type &&
+ child.type.displayName === displayName;
+
+/** The text of a node, when it has one. */
+export const getNodeText = (node: React.ReactNode): string | undefined => {
+ if (typeof node === 'string') {
+ return node;
+ }
+
+ if (typeof node === 'number') {
+ return String(node);
+ }
+
+ return undefined;
+};
+
+type LabelledProps = {
+ 'aria-label'?: string;
+ accessibilityLabel?: string;
+ children?: React.ReactNode;
+};
+
+/**
+ * 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 49f863720a..0000000000
--- a/src/components/__tests__/DataTable.test.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { describe, expect, it } from '@jest/globals';
-
-import { render, screen } from '../../test-utils';
-import Checkbox from '../Checkbox';
-import DataTable from '../DataTable/DataTable';
-
-describe('DataTable.Header', () => {
- it('renders data table header', async () => {
- const tree = (
- await render(
-
- Dessert
- Calories
-
- )
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
-
-describe('DataTable.Title', () => {
- it('renders data table title with sort icon', async () => {
- const tree = (
- await render(
- Dessert
- )
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders right aligned data table title', async () => {
- const tree = (
- await render(Calories)
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders data table title with press handler', async () => {
- const tree = (
- await render(
- {}}>
- Dessert
-
- )
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
-
-describe('DataTable.Cell', () => {
- it('renders data table cell', async () => {
- const tree = (
- await render(Cupcake)
- ).toJSON();
- expect(tree).toMatchSnapshot();
- });
-
- it('renders right aligned data table cell', async () => {
- const tree = (
- await render(356)
- ).toJSON();
- expect(tree).toMatchSnapshot();
- });
-
- it('renders data table cell with text container', async () => {
- await render(
- Table cell
- );
-
- expect(screen.getByText('Table cell')).toBeOnTheScreen();
- expect(screen.getByTestId('table-cell-text-container')).toBeOnTheScreen();
- });
-
- it('renders data table cell children without text container', async () => {
- await render(
-
-
-
- );
-
- expect(
- screen.queryByTestId('table-cell-text-container')
- ).not.toBeOnTheScreen();
- });
-});
-
-describe('DataTable.Pagination', () => {
- it('renders data table pagination', async () => {
- const tree = (
- await render(
- {}}
- />
- )
- ).toJSON();
- expect(tree).toMatchSnapshot();
- });
-
- it('renders data table pagination with label', async () => {
- const tree = (
- await render(
- {}}
- label="11-20 of 150"
- />
- )
- ).toJSON();
- expect(tree).toMatchSnapshot();
- });
-
- it('renders data table pagination with fast-forward buttons', async () => {
- const { toJSON } = await render(
- {}}
- label="11-20 of 150"
- showFastPaginationControls
- />
- );
-
- expect(screen.getByLabelText('page-first')).toBeOnTheScreen();
- expect(screen.getByLabelText('page-last')).toBeOnTheScreen();
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders data table pagination without options select', async () => {
- await render(
- {}}
- label="11-20 of 150"
- showFastPaginationControls
- />
- );
-
- expect(screen.queryByLabelText('Options Select')).not.toBeOnTheScreen();
- });
-
- it('renders data table pagination with options select', async () => {
- const { toJSON } = await render(
- {}}
- label="11-20 of 150"
- showFastPaginationControls
- numberOfItemsPerPageList={[2, 4, 6]}
- numberOfItemsPerPage={2}
- onItemsPerPageChange={() => {}}
- selectPageDropdownLabel={'Rows per page'}
- />
- );
-
- expect(screen.getByLabelText('Options Select')).toBeOnTheScreen();
- expect(screen.getByLabelText('selectPageDropdownLabel')).toBeOnTheScreen();
-
- expect(toJSON()).toMatchSnapshot();
- });
-});
diff --git a/src/components/__tests__/DataTable/DataTable.test.tsx b/src/components/__tests__/DataTable/DataTable.test.tsx
new file mode 100644
index 0000000000..96d5f883a5
--- /dev/null
+++ b/src/components/__tests__/DataTable/DataTable.test.tsx
@@ -0,0 +1,1198 @@
+import { Platform, StyleSheet, useWindowDimensions } from 'react-native';
+
+import { afterEach, describe, expect, it, jest } from '@jest/globals';
+import * as Reanimated from 'react-native-reanimated';
+
+import { LocaleProvider } from '../../../core/locale';
+import PaperProvider from '../../../core/PaperProvider';
+import { getTheme } from '../../../core/theming';
+import { render, screen } from '../../../test-utils';
+import Checkbox from '../../Checkbox';
+import type { DataTableColumn } from '../../DataTable/columns';
+import DataTable from '../../DataTable/DataTable';
+
+const columns: readonly DataTableColumn[] = [
+ { key: 'name', flex: 2 },
+ { key: 'calories', numeric: true },
+];
+
+const Table = ({
+ children,
+ ...props
+}: Partial> = {}) => (
+
+
+ {}} sortDirection="ascending">
+ Dessert
+
+ Calories
+
+ {children ?? (
+
+ Frozen yogurt
+ 159
+
+ )}
+
+);
+
+// Cells of a row-focused table are hidden from the accessibility tree on
+// purpose, so layout assertions have to look past that.
+const hidden = { includeHiddenElements: true };
+
+const mockFontScale = (fontScale: number) => {
+ jest.mocked(useWindowDimensions).mockReturnValue({
+ fontScale,
+ width: 750,
+ height: 1334,
+ scale: 2,
+ });
+};
+
+afterEach(() => {
+ Platform.OS = 'ios';
+ mockFontScale(1);
+});
+
+describe('DataTable', () => {
+ it('names itself as a table and reports its shape', async () => {
+ await render();
+
+ const table = screen.getByTestId('table');
+
+ expect(table).toHaveProp('role', 'table');
+ });
+
+ it('exposes row and column counts on the web', async () => {
+ Platform.OS = 'web';
+
+ await render();
+
+ const table = screen.getByTestId('table');
+
+ // Six data rows plus the header row.
+ expect(table).toHaveProp('aria-rowcount', 7);
+ expect(table).toHaveProp('aria-colcount', 2);
+ });
+
+ it('does not name the container on native, where it would swallow the rows', async () => {
+ await render();
+
+ const table = screen.getByTestId('table');
+
+ // A container with an accessibility label becomes a single screen-reader
+ // stop on Android, hiding every row inside it.
+ expect(table).not.toHaveProp('aria-label');
+ expect(table).not.toHaveProp('accessibilityLabel');
+ });
+
+ it('names the container on the web, where the role makes it meaningful', async () => {
+ Platform.OS = 'web';
+
+ await render();
+
+ const table = screen.getByTestId('table');
+
+ expect(table).toHaveProp('aria-label', 'Nutrition');
+ });
+
+ it('keeps grid attributes off native, where they mean nothing', async () => {
+ await render();
+
+ expect(screen.getByTestId('table')).not.toHaveProp('aria-rowcount');
+ });
+});
+
+describe('DataTable.Row', () => {
+ it('announces a row as one item naming every column', async () => {
+ await render();
+
+ expect(
+ screen.getByRole('row', {
+ name: 'Dessert, Frozen yogurt, Calories, 159, row 3 of 6',
+ })
+ ).toBeOnTheScreen();
+ });
+
+ it('does not repeat the column name when a cell carries its own label', async () => {
+ await render(
+
+
+ Frozen yogurt
+ 159
+
+
+ );
+
+ // Not "Calories, One fifty nine" - the label is already complete.
+ expect(
+ screen.getByRole('row', {
+ name: 'Dessert, Frozen yogurt, One fifty nine, row 3 of 6',
+ })
+ ).toBeOnTheScreen();
+ });
+
+ it('does not report a read-only row as disabled', async () => {
+ await render();
+
+ expect(
+ screen.getByRole('row', { name: /Frozen yogurt/ })
+ ).not.toBeDisabled();
+ });
+
+ it('announces a pressable row as a button so it reads as activatable', async () => {
+ await render(
+
+ {}}>
+ Frozen yogurt
+ 159
+
+
+ );
+
+ expect(
+ screen.getByRole('button', { name: /Frozen yogurt/ })
+ ).toBeOnTheScreen();
+ });
+
+ it('leaves the position out when the total is unknown', async () => {
+ await render(
+
+
+ Dessert
+
+
+ Frozen yogurt
+
+
+ );
+
+ // One rendered row, indexed from 0, so the count is derived as 1.
+ expect(
+ screen.getByRole('row', { name: 'Dessert, Frozen yogurt, row 1 of 1' })
+ ).toBeOnTheScreen();
+ });
+
+ it('numbers rows against the whole set when only a page is rendered', async () => {
+ Platform.OS = 'web';
+
+ await render();
+
+ // Row index 2 of the data set, 1-based, offset by the header row.
+ expect(screen.getByTestId('row')).toHaveProp('aria-rowindex', 4);
+ });
+
+ it('honours an explicitly passed index, as virtualized lists must', async () => {
+ await render(
+
+ );
+
+ expect(screen.getByRole('row', { name: /row 5 of 6/ })).toBeOnTheScreen();
+ });
+});
+
+describe('DataTable.Cell', () => {
+ it('falls back to per-cell focus when a cell is interactive', async () => {
+ await render(
+
+
+ {}}>Frozen yogurt
+ 159
+
+
+ );
+
+ // The row must not swallow the pressable cell.
+ expect(screen.queryByRole('row', { name: /Frozen yogurt/ })).toBeNull();
+ expect(
+ screen.getByRole('cell', { name: 'Dessert, Frozen yogurt' })
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByRole('cell', { name: 'Calories, 159' })
+ ).toBeOnTheScreen();
+ });
+
+ it('keeps element content reachable instead of hiding it behind a row label', async () => {
+ await render(
+
+ );
+
+ expect(screen.queryByRole('row', { name: /159/ })).toBeNull();
+ expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen();
+ });
+
+ it('does not collapse a row whose cell holds an element, even when labelled', async () => {
+ await render(
+
+
+ Frozen yogurt
+
+ {}}
+ testID="row-checkbox"
+ />
+
+
+
+ );
+
+ // An element owns its own semantics and may be interactive, so collapsing
+ // the row would put it out of reach.
+ expect(screen.queryByRole('row', { name: /Frozen yogurt/ })).toBeNull();
+ expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen();
+ });
+
+ it('does not wrap element content in an accessibility element of its own', async () => {
+ await render(
+
+ );
+
+ // Making the cell accessible would swallow the checkbox's own state.
+ expect(screen.getByTestId('cell')).not.toHaveProp('accessible', true);
+ expect(screen.getByTestId('row-checkbox')).toBeOnTheScreen();
+ });
+
+ it('treats a cell label as the complete name, not a value to decorate', async () => {
+ await render(
+
+ );
+
+ // Not "Dessert, Ninety nine" - an explicit label replaces the composed one.
+ expect(screen.getByRole('cell', { name: 'Ninety nine' })).toBeOnTheScreen();
+ });
+
+ it('gives one stop per cell under nativeFocusMode="cell"', async () => {
+ await render();
+
+ expect(screen.queryByRole('row', { name: /Frozen yogurt/ })).toBeNull();
+ expect(
+ screen.getByRole('cell', { name: 'Dessert, Frozen yogurt' })
+ ).toBeOnTheScreen();
+ });
+
+ it('numbers columns on the web', async () => {
+ Platform.OS = 'web';
+
+ await render(
+
+
+ Frozen yogurt
+
+ 159
+
+
+
+ );
+
+ expect(screen.getByTestId('first')).toHaveProp('aria-colindex', 1);
+ expect(screen.getByTestId('second')).toHaveProp('aria-colindex', 2);
+ expect(screen.getByTestId('second')).toHaveProp('role', 'cell');
+ // The roles already say which column a cell is in; repeating it in the
+ // name would make linear reading twice as long.
+ expect(screen.getByTestId('first')).not.toHaveProp('aria-label');
+ });
+
+ it('does not invent a testID when none was given', async () => {
+ await render(Frozen yogurt);
+
+ expect(screen.queryByTestId('undefined-text-container')).toBeNull();
+ });
+
+ it('renders text content inside a text container', async () => {
+ await render(
+ Table cell
+ );
+
+ expect(screen.getByText('Table cell')).toBeOnTheScreen();
+ expect(screen.getByTestId('table-cell-text-container')).toBeOnTheScreen();
+ });
+
+ it('renders element content verbatim, without a text container', async () => {
+ await render(
+
+
+
+ );
+
+ expect(
+ screen.queryByTestId('table-cell-text-container')
+ ).not.toBeOnTheScreen();
+ });
+
+ it('lets essential data wrap as soon as text is enlarged at all', async () => {
+ // Android's display-size setting narrows the layout without moving the
+ // font scale much, so content truncates well before 2x.
+ mockFontScale(1.15);
+
+ await render(
+ Frozen yogurt
+ );
+
+ expect(screen.getByTestId('small-bump-text-container')).not.toHaveProp(
+ 'numberOfLines'
+ );
+ });
+
+ it('honours an explicit limit when text is enlarged', async () => {
+ // Asking for 2 lines means 2 lines. Quietly granting more at large scale
+ // would override an instruction the consumer gave deliberately.
+ mockFontScale(2);
+
+ await render(
+
+ Frozen yogurt
+
+ );
+
+ expect(screen.getByTestId('pinned-text-container')).toHaveProp(
+ 'numberOfLines',
+ 2
+ );
+ });
+
+ it('honours an explicit limit when text is shrunk', async () => {
+ mockFontScale(0.85);
+
+ await render(
+
+ Frozen yogurt
+
+ );
+
+ expect(screen.getByTestId('pinned-text-container')).toHaveProp(
+ 'numberOfLines',
+ 2
+ );
+ });
+
+ it('never clamps a limit of 0, whatever the scale', async () => {
+ mockFontScale(1);
+
+ await render(
+
+ Frozen yogurt
+
+ );
+
+ expect(screen.getByTestId('free-text-container')).not.toHaveProp(
+ 'numberOfLines'
+ );
+ });
+
+ it('clamps to one line by default and honours an explicit limit', async () => {
+ await render(
+ <>
+ Frozen yogurt
+
+ Frozen yogurt
+
+ >
+ );
+
+ expect(screen.getByTestId('clamped-text-container')).toHaveProp(
+ 'numberOfLines',
+ 1
+ );
+ expect(screen.getByTestId('wrapping-text-container')).not.toHaveProp(
+ 'numberOfLines'
+ );
+ });
+});
+
+describe('DataTable.Title', () => {
+ it('does not present an unsortable column as a control', async () => {
+ await render(
+
+ Calories
+
+ );
+
+ expect(screen.queryByRole('button')).toBeNull();
+ expect(screen.getByTestId('title')).not.toBeDisabled();
+ });
+
+ it('makes every column header its own stop on native', async () => {
+ await render(
+
+ Calories per piece
+
+ );
+
+ // Otherwise the header's text is absorbed by whatever ancestor happens to
+ // be focusable, and the columns are read as one run-on stop.
+ expect(screen.getByTestId('plain')).toHaveProp('accessible', true);
+ });
+
+ it('announces a sortable column and its sort state', async () => {
+ await render();
+
+ expect(
+ screen.getByRole('button', { name: 'Dessert, sorted ascending' })
+ ).toBeOnTheScreen();
+ });
+
+ it('takes localized sort wording', async () => {
+ await render(
+
+ {}}
+ sortDirection="descending"
+ sortAccessibilityLabels={{
+ ascending: 'rosnąco',
+ descending: 'malejąco',
+ }}
+ >
+ Dessert
+
+
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Dessert, malejąco' })
+ ).toBeOnTheScreen();
+ });
+
+ it('exposes sort state and column semantics on the web', async () => {
+ Platform.OS = 'web';
+
+ await render(
+
+ {}}
+ sortDirection="ascending"
+ >
+ Dessert
+
+
+ Calories
+
+
+ );
+
+ const sortable = screen.getByTestId('sortable');
+
+ expect(sortable).toHaveProp('role', 'columnheader');
+ expect(sortable).toHaveProp('aria-sort', 'ascending');
+ expect(sortable).toHaveProp('aria-colindex', 1);
+
+ const plain = screen.getByTestId('plain');
+
+ expect(plain).toHaveProp('aria-colindex', 2);
+ // An unsortable column advertises no sort state at all.
+ expect(plain).not.toHaveProp('aria-sort');
+ });
+
+ it('advertises a sortable but unsorted column on the web', async () => {
+ Platform.OS = 'web';
+
+ await render(
+
+ {}}>
+ Dessert
+
+
+ );
+
+ expect(screen.getByTestId('title')).toHaveProp('aria-sort', 'none');
+ });
+
+ it('does not rotate the sort indicator on first render', async () => {
+ const withTiming = jest.spyOn(Reanimated, 'withTiming');
+
+ await render(
+ {}} sortDirection="descending">
+ Calories
+
+ );
+
+ // The indicator starts at the right angle rather than spinning into it.
+ expect(withTiming).not.toHaveBeenCalled();
+
+ withTiming.mockRestore();
+ });
+
+ it('rotates the sort indicator when the direction changes', async () => {
+ const withTiming = jest.spyOn(Reanimated, 'withTiming');
+
+ const view = await render(
+ {}} sortDirection="ascending">
+ Calories
+
+ );
+
+ await view.rerender(
+ {}} sortDirection="descending">
+ Calories
+
+ );
+
+ expect(withTiming).toHaveBeenCalledWith(
+ 180,
+ expect.objectContaining({
+ duration: getTheme().motion.duration.short3,
+ reduceMotion: Reanimated.ReduceMotion.Never,
+ })
+ );
+
+ withTiming.mockRestore();
+ });
+
+ it('tells Reanimated to suppress the rotation under reduced motion', async () => {
+ const withTiming = jest.spyOn(Reanimated, 'withTiming');
+
+ const view = await render(
+
+ {}} sortDirection="ascending">
+ Calories
+
+
+ );
+
+ await view.rerender(
+
+ {}} sortDirection="descending">
+ Calories
+
+
+ );
+
+ expect(withTiming).toHaveBeenCalledWith(
+ 180,
+ expect.objectContaining({
+ reduceMotion: Reanimated.ReduceMotion.Always,
+ })
+ );
+
+ withTiming.mockRestore();
+ });
+});
+
+describe('DataTable column contract', () => {
+ it('shares width and alignment from a single definition', async () => {
+ await render(
+
+
+
+ Dessert
+
+
+
+
+ Frozen yogurt
+
+
+
+ );
+
+ expect(screen.getByTestId('title')).toHaveStyle({ flex: 2 });
+ expect(screen.getByTestId('cell', hidden)).toHaveStyle({ flex: 2 });
+ });
+
+ it('lets an explicit style win over the shared definition', async () => {
+ await render(
+
+
+
+ Frozen yogurt
+
+
+
+ );
+
+ expect(screen.getByTestId('cell', hidden)).toHaveStyle({ flex: 5 });
+ });
+
+ it('resolves columns by position when no key is given', async () => {
+ await render(
+
+
+ Frozen yogurt
+ 159
+
+
+ );
+
+ expect(screen.getByTestId('first', hidden)).toHaveStyle({ flex: 2 });
+ // The second column declares no flex, so it falls back to 1.
+ expect(screen.getByTestId('second', hidden)).toHaveStyle({ flex: 1 });
+ });
+
+ it('names cells from the header, with no second declaration', async () => {
+ await render(
+
+
+ Dessert
+ Calories
+
+
+ Frozen yogurt
+ 159
+
+
+ );
+
+ expect(
+ screen.getByRole('cell', { name: 'Calories, 159' })
+ ).toBeOnTheScreen();
+ });
+
+ it('names a cell from an explicit label when the header is not text', async () => {
+ await render(
+
+
+
+
+
+
+
+ Yes
+
+
+ );
+
+ expect(
+ screen.getByRole('cell', { name: 'Selected, Yes' })
+ ).toBeOnTheScreen();
+ });
+
+ it('keeps declared widths when columns must not shrink', async () => {
+ await render(
+
+
+
+ Frozen yogurt
+
+
+
+ );
+
+ expect(screen.getByTestId('cell', hidden)).toHaveStyle({
+ width: 120,
+ flexShrink: 0,
+ });
+ });
+
+ it('lays out fluid tables at full width and fixed ones at content width', async () => {
+ await render(
+ <>
+
+
+ Frozen yogurt
+
+
+
+
+ Frozen yogurt
+
+
+ >
+ );
+
+ expect(screen.getByTestId('fluid')).toHaveStyle({ width: '100%' });
+ expect(screen.getByTestId('fixed')).not.toHaveStyle({ width: '100%' });
+ });
+
+ it('warns when a fixed column has no width to hold', async () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ await render(
+
+
+ Frozen yogurt
+
+
+ );
+
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('layout="fixed"')
+ );
+
+ warn.mockRestore();
+ });
+});
+
+describe('DataTable metrics', () => {
+ it('separates rows and the header with the divider color role', async () => {
+ await render(
+
+
+ Dessert
+
+
+ Frozen yogurt
+
+
+ );
+
+ // The same role `Divider` uses, in both themes.
+ expect(screen.getByTestId('header')).toHaveStyle({
+ borderBottomColor: getTheme().colors.outlineVariant,
+ });
+ expect(screen.getByTestId('row')).toHaveStyle({
+ borderBottomColor: getTheme().colors.outlineVariant,
+ });
+ });
+
+ it('draws the header rule heavier than the row separators', async () => {
+ await render(
+
+
+ Dessert
+
+
+ Frozen yogurt
+
+
+ );
+
+ expect(screen.getByTestId('header')).toHaveStyle({ borderBottomWidth: 1 });
+ expect(screen.getByTestId('row')).toHaveStyle({
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ });
+ });
+
+ it('keeps rows at a touch-target height with room for wrapped content', async () => {
+ await render(
+
+
+ Frozen yogurt
+
+
+ );
+
+ expect(screen.getByTestId('row')).toHaveStyle({
+ minHeight: 48,
+ paddingHorizontal: 16,
+ paddingVertical: 4,
+ });
+ });
+
+ it('gives a static row the same containing block as a pressable one', async () => {
+ await render(
+
+
+ Frozen yogurt
+
+
+ );
+
+ // `TouchableRipple` sets this, so absolutely positioned children resolve
+ // against the same box whether or not the row is pressable.
+ expect(screen.getByTestId('static')).toHaveStyle({ position: 'relative' });
+ });
+});
+
+describe('DataTable alignment', () => {
+ it('aligns start and numeric columns against the writing direction', async () => {
+ await render(
+ <>
+ Frozen yogurt
+
+ 159
+
+
+ 6
+
+ >
+ );
+
+ expect(screen.getByTestId('start')).toHaveStyle({
+ justifyContent: 'flex-start',
+ });
+ expect(screen.getByTestId('start-text-container')).toHaveStyle({
+ textAlign: 'left',
+ });
+ expect(screen.getByTestId('numeric')).toHaveStyle({
+ justifyContent: 'flex-end',
+ });
+ expect(screen.getByTestId('numeric-text-container')).toHaveStyle({
+ textAlign: 'right',
+ // Tabular figures keep digits lined up between rows.
+ fontVariant: ['tabular-nums'],
+ });
+ expect(screen.getByTestId('center-text-container')).toHaveStyle({
+ textAlign: 'center',
+ });
+ });
+
+ it('mirrors text alignment in right-to-left layouts', async () => {
+ await render(
+
+ Frozen yogurt
+
+ 159
+
+
+ );
+
+ expect(screen.getByTestId('start-text-container')).toHaveStyle({
+ textAlign: 'right',
+ });
+ expect(screen.getByTestId('numeric-text-container')).toHaveStyle({
+ textAlign: 'left',
+ });
+ // `justifyContent` is logical, so it resolves against the direction on its
+ // own and must not be mirrored here too.
+ expect(screen.getByTestId('numeric')).toHaveStyle({
+ justifyContent: 'flex-end',
+ });
+ });
+
+ it('treats `numeric` as the data and `align` as the position', async () => {
+ await render(
+ <>
+
+ 159
+
+
+ 159
+
+
+ Shipped
+
+ >
+ );
+
+ // Numbers land at the end of the column unless told otherwise.
+ expect(screen.getByTestId('default')).toHaveStyle({
+ justifyContent: 'flex-end',
+ });
+
+ // A centred column of numbers still gets lined-up digits.
+ expect(screen.getByTestId('centered')).toHaveStyle({
+ justifyContent: 'center',
+ });
+ expect(screen.getByTestId('centered-text-container')).toHaveStyle({
+ fontVariant: ['tabular-nums'],
+ });
+
+ // Non-numeric content can still be end-aligned, without tabular figures.
+ expect(screen.getByTestId('text')).toHaveStyle({
+ justifyContent: 'flex-end',
+ });
+ expect(screen.getByTestId('text-text-container')).not.toHaveStyle({
+ fontVariant: ['tabular-nums'],
+ });
+ });
+
+ it('takes `numeric` from the shared column definition', async () => {
+ await render(
+
+
+
+ 159
+
+
+
+ );
+
+ expect(screen.getByTestId('cell', hidden)).toHaveStyle({
+ justifyContent: 'flex-end',
+ });
+ });
+});
+
+// Snapshots complement the assertions above rather than replacing them: those
+// state the contract, these catch structural drift nobody thought to assert.
+// Fixtures are kept minimal on purpose - a snapshot too long to read in review
+// is a rubber stamp, which is how `aria-disabled="true"` survived in the old
+// 2828-line file.
+describe('DataTable snapshots', () => {
+ it('renders a table', async () => {
+ const tree = (
+ await render(
+
+
+ Dessert
+
+
+ Frozen yogurt
+
+
+ )
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders a header', async () => {
+ const tree = (
+ await render(
+
+ Dessert
+
+ )
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders a sortable title', async () => {
+ const tree = (
+ await render(
+ {}} sortDirection="descending">
+ Dessert
+
+ )
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders a static title', async () => {
+ const tree = (
+ await render(Calories)
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders a static row', async () => {
+ const tree = (
+ await render(
+
+ Frozen yogurt
+
+ )
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders a pressable row', async () => {
+ const tree = (
+ await render(
+ {}}>
+ Frozen yogurt
+
+ )
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders a cell', async () => {
+ const tree = (
+ await render(159)
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders pagination', async () => {
+ const tree = (
+ await render(
+ {}}
+ label="1-2 of 6"
+ />
+ )
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
+
+describe('DataTable.Pagination', () => {
+ it('does not name its containers on native, where they would swallow the controls', async () => {
+ await render(
+ {}}
+ label="1-2 of 6"
+ numberOfItemsPerPageList={[2, 4]}
+ numberOfItemsPerPage={2}
+ onItemsPerPageChange={() => {}}
+ selectPageDropdownLabel="Rows per page"
+ />
+ );
+
+ // Same bug as the table container: an accessibility label on a view makes
+ // it one screen-reader stop on Android, hiding the buttons inside it.
+ expect(screen.getByTestId('pager')).not.toHaveProp('aria-label');
+ expect(screen.getByTestId('options-select')).not.toHaveProp('aria-label');
+ });
+
+ it('names the pagination region on the web', async () => {
+ Platform.OS = 'web';
+
+ await render(
+ {}}
+ />
+ );
+
+ const pager = screen.getByTestId('pager');
+
+ expect(pager).toHaveProp('role', 'group');
+ expect(pager).toHaveProp('aria-label', 'Pagination');
+ });
+
+ it('makes its text labels their own stops on native', async () => {
+ await render(
+ {}}
+ label="1-2 of 6"
+ numberOfItemsPerPageList={[2, 4]}
+ numberOfItemsPerPage={2}
+ onItemsPerPageChange={() => {}}
+ selectPageDropdownLabel="Rows per page"
+ />
+ );
+
+ // Unclaimed text is merged into whatever ancestor is focusable, which on
+ // native is the enclosing scroll view - the whole screen.
+ expect(screen.getByTestId('select-page-dropdown-label')).toHaveProp(
+ 'accessible',
+ true
+ );
+ expect(screen.getByText('1-2 of 6')).toHaveProp('accessible', true);
+ });
+
+ it('gives every control a human name', async () => {
+ await render(
+ {}}
+ label="11-20 of 150"
+ showFastPaginationControls
+ />
+ );
+
+ expect(screen.getByLabelText('First page')).toBeOnTheScreen();
+ expect(screen.getByLabelText('Previous page')).toBeOnTheScreen();
+ expect(screen.getByLabelText('Next page')).toBeOnTheScreen();
+ expect(screen.getByLabelText('Last page')).toBeOnTheScreen();
+ });
+
+ it('takes localized wording for every control', async () => {
+ await render(
+ {}}
+ label="11-20 of 150"
+ showFastPaginationControls
+ labels={{
+ firstPage: 'Pierwsza strona',
+ lastPage: 'Ostatnia strona',
+ }}
+ />
+ );
+
+ expect(screen.getByLabelText('Pierwsza strona')).toBeOnTheScreen();
+ expect(screen.getByLabelText('Ostatnia strona')).toBeOnTheScreen();
+ // Untouched entries keep their defaults.
+ expect(screen.getByLabelText('Next page')).toBeOnTheScreen();
+ });
+
+ it('names the page position when there is no visible range', async () => {
+ await render(
+ {}}
+ />
+ );
+
+ expect(screen.getByLabelText('Page 4 of 15')).toBeOnTheScreen();
+ });
+
+ it('lets the visible range speak for itself', async () => {
+ await render(
+ {}}
+ label="11-20 of 150"
+ />
+ );
+
+ expect(screen.queryByLabelText('Page 4 of 15')).toBeNull();
+ expect(screen.getByText('11-20 of 150')).toBeOnTheScreen();
+ });
+
+ it('renders the rows-per-page selector only when it can work', async () => {
+ const view = await render(
+ {}}
+ label="11-20 of 150"
+ />
+ );
+
+ expect(screen.queryByTestId('options-select')).not.toBeOnTheScreen();
+
+ await view.rerender(
+ {}}
+ label="11-20 of 150"
+ numberOfItemsPerPageList={[2, 4, 6]}
+ numberOfItemsPerPage={2}
+ onItemsPerPageChange={() => {}}
+ selectPageDropdownLabel="Rows per page"
+ />
+ );
+
+ expect(screen.getByTestId('options-select')).toBeOnTheScreen();
+ expect(screen.getByTestId('select-page-dropdown-label')).toBeOnTheScreen();
+ });
+
+ it('announces the selected page size and that it opens a menu', async () => {
+ await render(
+ {}}
+ numberOfItemsPerPageList={[2, 4, 6]}
+ numberOfItemsPerPage={2}
+ onItemsPerPageChange={() => {}}
+ selectPageDropdownLabel="Rows per page"
+ />
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Rows per page, 2', expanded: false })
+ ).toBeOnTheScreen();
+ });
+});
diff --git a/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap
new file mode 100644
index 0000000000..b2cfc454ea
--- /dev/null
+++ b/src/components/__tests__/DataTable/__snapshots__/DataTable.test.tsx.snap
@@ -0,0 +1,1047 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DataTable snapshots renders a cell 1`] = `
+
+
+ 159
+
+
+`;
+
+exports[`DataTable snapshots renders a header 1`] = `
+
+
+
+ Dessert
+
+
+
+`;
+
+exports[`DataTable snapshots renders a pressable row 1`] = `
+
+
+
+
+ Frozen yogurt
+
+
+
+
+`;
+
+exports[`DataTable snapshots renders a sortable title 1`] = `
+
+
+
+ arrow-up
+
+
+
+ Dessert
+
+
+`;
+
+exports[`DataTable snapshots renders a static row 1`] = `
+
+
+
+
+ Frozen yogurt
+
+
+
+
+`;
+
+exports[`DataTable snapshots renders a static title 1`] = `
+
+
+ Calories
+
+
+`;
+
+exports[`DataTable snapshots renders a table 1`] = `
+
+
+
+
+ Dessert
+
+
+
+
+
+
+
+ Frozen yogurt
+
+
+
+
+
+`;
+
+exports[`DataTable snapshots renders pagination 1`] = `
+
+
+ 1-2 of 6
+
+
+
+
+
+
+
+ chevron-left
+
+
+
+
+
+
+
+
+
+
+ chevron-right
+
+
+
+
+
+
+
+`;
diff --git a/src/components/__tests__/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 e9bc774f78..0000000000
--- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap
+++ /dev/null
@@ -1,2828 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`DataTable.Cell renders data table cell 1`] = `
-
-
- Cupcake
-
-
-`;
-
-exports[`DataTable.Cell renders right aligned data table cell 1`] = `
-
-
- 356
-
-
-`;
-
-exports[`DataTable.Header renders data table header 1`] = `
-
-
-
- Dessert
-
-
-
-
- Calories
-
-
-
-`;
-
-exports[`DataTable.Pagination renders data table pagination 1`] = `
-
-
-
-
-
-
-
-
- chevron-left
-
-
-
-
-
-
-
-
-
-
- chevron-right
-
-
-
-
-
-
-
-`;
-
-exports[`DataTable.Pagination renders data table pagination with fast-forward buttons 1`] = `
-
-
- 11-20 of 150
-
-
-
-
-
-
-
- page-first
-
-
-
-
-
-
-
-
-
-
- chevron-left
-
-
-
-
-
-
-
-
-
-
- chevron-right
-
-
-
-
-
-
-
-
-
-
- page-last
-
-
-
-
-
-
-
-`;
-
-exports[`DataTable.Pagination renders data table pagination with label 1`] = `
-
-
- 11-20 of 150
-
-
-
-
-
-
-
- chevron-left
-
-
-
-
-
-
-
-
-
-
- chevron-right
-
-
-
-
-
-
-
-`;
-
-exports[`DataTable.Pagination renders data table pagination with options select 1`] = `
-
-
-
- Rows per page
-
-
-
-
-
-
-
-
- menu-down
-
-
-
- 2
-
-
-
-
-
-
-
-
- 11-20 of 150
-
-
-
-
-
-
-
- page-first
-
-
-
-
-
-
-
-
-
-
- chevron-left
-
-
-
-
-
-
-
-
-
-
- chevron-right
-
-
-
-
-
-
-
-
-
-
- page-last
-
-
-
-
-
-
-
-`;
-
-exports[`DataTable.Title renders data table title with press handler 1`] = `
-
-
-
- arrow-up
-
-
-
- Dessert
-
-
-`;
-
-exports[`DataTable.Title renders data table title with sort icon 1`] = `
-
-
-
- arrow-up
-
-
-
- Dessert
-
-
-`;
-
-exports[`DataTable.Title renders right aligned data table title 1`] = `
-
-
- Calories
-
-
-`;
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..1652d1c920 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -84,6 +84,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 : {};
+}