Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,8 @@
"codegen",
"codeshare",
"codesign",
"colcount",
"colindex",
"colorscale",
"commentbubbles",
"contenteditable",
Expand Down Expand Up @@ -884,6 +886,8 @@
"RNLinksdk",
"rnmapbox",
"rock",
"rowcount",
"rowindex",
"rpartition",
"rsbuild",
"rsdoctor",
Expand Down Expand Up @@ -1023,6 +1027,7 @@
"viewability",
"viewport",
"viewports",
"virtualizes",
"voidings",
"vorbis",
"vvcf",
Expand Down
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5991,6 +5991,8 @@ const CONST = {
TABLE: 'table',
/** Use for table rows. */
ROW: 'row',
/** Use to group table rows together (e.g. thead/tbody). */
ROWGROUP: 'rowgroup',
/** Use for column header cells in a table. */
COLUMNHEADER: 'columnheader',
/** Use for data cells in a table row. */
Expand Down
23 changes: 18 additions & 5 deletions src/components/Badge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';

import getPlatform from '@libs/getPlatform';

import variables from '@styles/variables';

import CONST from '@src/CONST';
Expand All @@ -15,6 +17,11 @@ import Icon from './Icon';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import Text from './Text';

// Hiding the badge's content from assistive tech is only needed on the web, where `accessible` does not group the
// children into a single element. On native, `accessible` already announces the badge as one element, and `aria-hidden`
// would additionally hide the content from queries (e.g. testing-library's getByText).
const shouldHideBadgeContentFromAssistiveTech = getPlatform() === CONST.PLATFORM.WEB;

type BadgeProps = {
/** Is Success type */
success?: boolean;
Expand Down Expand Up @@ -109,13 +116,18 @@ function Badge({
<Wrapper
style={pressable ? wrapperStyles : wrapperStyles({focused: false, hovered: false, isDisabled: false, isScreenReaderActive: false, pressed: false})}
onPress={onPress}
role={pressable ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION}
accessibilityLabel={pressable ? text : undefined}
aria-label={!pressable ? text : undefined}
accessible={false}
role={pressable ? CONST.ROLE.BUTTON : undefined}
accessibilityLabel={text}
// Make the badge a single accessibility element named by its text. The icon and text are hidden from
// assistive tech so a screen reader announces the badge as one item (via accessibilityLabel) instead of
// stepping through the icon and text as separate stops.
accessible
>
{!!icon && (
<View style={[!!text && styles.mr1, iconStyles]}>
<View
style={[!!text && styles.mr1, iconStyles]}
aria-hidden={(shouldHideBadgeContentFromAssistiveTech && !!text) || undefined}
>
<Icon
width={iconSize}
height={iconSize}
Expand All @@ -139,6 +151,7 @@ function Badge({
isDeleted ? styles.offlineFeedbackDeleted : {},
]}
numberOfLines={1}
aria-hidden={shouldHideBadgeContentFromAssistiveTech || undefined}
>
{text}
</Text>
Expand Down
16 changes: 15 additions & 1 deletion src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import useHighlighting from './middlewares/highlight';
import useSearching from './middlewares/searching';
import useSelection from './middlewares/selection';
import useSorting from './middlewares/sorting';
import {shouldUseTableSemantics} from './tableAccessibility';
import TableContext from './TableContext';
import TableSemanticContainer from './TableSemanticContainer';

/**
* Builds the Proxy exposed through the Table's ref, forwarding to `tableMethods` first and
Expand Down Expand Up @@ -284,9 +286,21 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
onSearchStringChange,
};

const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout);
// The selection checkbox renders as an extra leading column when selection is enabled (always visible in the wide
// web layout where semantics apply), so it has to be counted alongside the configured data columns.
const semanticColumnCount = columns.length + (selectionEnabled ? 1 : 0);

return (
<TableContext.Provider value={contextValue as unknown as TableContextValue<TableData, string, string>}>
{children}
<TableSemanticContainer
isEnabled={isTableSemanticsEnabled}
title={title}
rowCount={processedData.length}
columnCount={semanticColumnCount}
>
{children}
</TableSemanticContainer>

<Modal
shouldPreventScrollOnFocus
Expand Down
2 changes: 2 additions & 0 deletions src/components/Table/TableBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {StyleSheet, View} from 'react-native';

import type {TableData} from '.';

import {getRowGroupAccessibilityProps, shouldUseTableSemantics} from './tableAccessibility';
import {useTableContext} from './TableContext';

/**
Expand Down Expand Up @@ -95,6 +96,7 @@ function TableBody<DataType extends TableData>({contentContainerStyle, style, ..
return (
<View
style={[styles.flex1, styles.mnh0, style]}
{...getRowGroupAccessibilityProps(shouldUseTableSemantics(shouldUseNarrowTableLayout))}
Comment thread
aimane-chnaif marked this conversation as resolved.
{...props}
>
<FlashList<DataType>
Expand Down
105 changes: 79 additions & 26 deletions src/components/Table/TableHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {View} from 'react-native';

import type {TableColumn, TableData} from './types';

import {getColumnHeaderAccessibilityProps, getRowAccessibilityProps, shouldUseTableSemantics} from './tableAccessibility';
import {useTableContext} from './TableContext';

/**
Expand Down Expand Up @@ -68,6 +69,7 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
// shouldUseNarrowLayout is always true in an RHP. Other tables keep the original behavior. Visual padding below still uses shouldUseNarrowLayout.
const selectionUsesNarrowLayout = shouldEnableSelectionInNarrowPaneModal ? isSmallScreenWidth : shouldUseNarrowLayout;
const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !selectionUsesNarrowLayout);
const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout);

if (shouldUseNarrowTableLayout && !title) {
return null;
Expand Down Expand Up @@ -116,6 +118,7 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
!shouldUseNarrowTableLayout && {gridTemplateColumns: gridTemplateColumns.join(' ')},
style,
]}
{...getRowAccessibilityProps(isTableSemanticsEnabled, 0, true)}
{...props}
>
{shouldUseNarrowTableLayout && (
Expand Down Expand Up @@ -145,19 +148,28 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
{!shouldUseNarrowTableLayout && (
<>
{!!selectionEnabled && (
<Checkbox
disabled={!hasSelectableRows}
isChecked={isEverySelectableRowSelected}
isIndeterminate={isSelectionIndeterminate && !isEverySelectableRowSelected}
onPress={tableMethods.handleSelectAll}
accessibilityLabel={translate('workspace.common.selectAll')}
/>
// When semantics apply, this is exposed as the first (non-sortable) column header so the header
// column count matches the data rows, which include the selection checkbox cell. The
// accessibility props are empty otherwise, leaving the checkbox's layout unchanged.
<View {...getColumnHeaderAccessibilityProps(isTableSemanticsEnabled, false, false, undefined, 1)}>
<Checkbox
disabled={!hasSelectableRows}
isChecked={isEverySelectableRowSelected}
isIndeterminate={isSelectionIndeterminate && !isEverySelectableRowSelected}
onPress={tableMethods.handleSelectAll}
accessibilityLabel={translate('workspace.common.selectAll')}
/>
</View>
)}

{columns.map((column) => {
{columns.map((column, index) => {
return (
<TableHeaderColumn
column={column}
isTableSemanticsEnabled={isTableSemanticsEnabled}
// 1-based, and offset by the leading selection column (column 1) when present, so it
// aligns with the matching data cell's aria-colindex.
columnIndex={index + 1 + (isSelectionCheckboxVisible ? 1 : 0)}
key={column.key}
/>
);
Expand All @@ -174,7 +186,15 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
* @template DataType - The type of items in the table's data array.
* @template ColumnKey - A string literal type representing the valid column keys.
*/
function TableHeaderColumn<DataType extends TableData, ColumnKey extends string = string>({column}: {column: TableColumn<ColumnKey>}) {
function TableHeaderColumn<DataType extends TableData, ColumnKey extends string = string>({
column,
isTableSemanticsEnabled,
columnIndex,
}: {
column: TableColumn<ColumnKey>;
isTableSemanticsEnabled: boolean;
columnIndex: number;
}) {
const theme = useTheme();
const toggleCount = useRef(0);
const styles = useThemeStyles();
Expand Down Expand Up @@ -212,35 +232,68 @@ function TableHeaderColumn<DataType extends TableData, ColumnKey extends string
!column.sortable && styles.cursorDefault,
];

return (
<PressableWithFeedback
accessible
accessibilityLabel={column.label}
accessibilityRole="button"
disabled={!column.sortable}
sentryLabel={CONST.SENTRY_LABEL.TABLE_HEADER.SORTABLE_COLUMN}
style={tableHeaderStyles}
onPress={() => toggleSorting(column.key)}
>
const label = (
<>
<Text
numberOfLines={1}
color={theme.textSupporting}
style={[styles.lh16, isSortingByColumn ? styles.textMicroBoldSupporting : styles.textMicroSupporting]}
// The button is already named by accessibilityLabel, so the visible label is hidden from assistive tech
// to avoid the header being announced twice (e.g. "Name Name").
aria-hidden={isTableSemanticsEnabled ? true : undefined}
>
{column.label}
</Text>

{isSortingByColumn && (
<Icon
additionalStyles={styles.ml1}
width={variables.iconSizeExtraSmall}
height={variables.iconSizeExtraSmall}
src={sortIcon}
fill={theme.icon}
/>
// The sort direction is already conveyed by aria-sort on the columnheader, so the icon is decorative.
// Icon's native "hidden" props don't map to the web, so the wrapper hides it from assistive tech there.
<View aria-hidden={isTableSemanticsEnabled ? true : undefined}>
<Icon
additionalStyles={styles.ml1}
width={variables.iconSizeExtraSmall}
height={variables.iconSizeExtraSmall}
src={sortIcon}
fill={theme.icon}
/>
</View>
)}
</>
);

const sortButton = (
<PressableWithFeedback
accessible
accessibilityLabel={column.label}
accessibilityRole="button"
disabled={!column.sortable}
sentryLabel={CONST.SENTRY_LABEL.TABLE_HEADER.SORTABLE_COLUMN}
style={
isTableSemanticsEnabled
? [styles.flexRow, styles.alignItemsCenter, styles.tableHeaderContentHeight, styles.flex1, !column.sortable && styles.cursorDefault]
: tableHeaderStyles
}
onPress={() => toggleSorting(column.key)}
>
{label}
</PressableWithFeedback>
);

if (!isTableSemanticsEnabled) {
return sortButton;
}

// Table semantics: the columnheader cell carries the ARIA role, sort state and column index, and it wraps a focusable
// button (the sort control). Keeping the interactive element separate from the cell means a screen reader focuses and
// announces the header once (via the button's accessibilityLabel) instead of re-reading the cell's contents.
return (
<View
style={[column.styling?.flex ? {flex: column.styling.flex} : styles.flex1, column.styling?.containerStyles]}
{...getColumnHeaderAccessibilityProps(true, !!column.sortable, isSortingByColumn, activeSorting.order, columnIndex)}
>
{sortButton}
</View>
);
}

export default TableHeader;
Loading
Loading