Skip to content

Commit 14ce85a

Browse files
authored
Making TableView columns announce sort info and sort order changes (#2137)
1 parent 2126a08 commit 14ce85a

File tree

7 files changed

+163
-16
lines changed

7 files changed

+163
-16
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"sortable": "sortable column",
3+
"ascending": "ascending",
4+
"descending": "descending",
5+
"ascendingSort": "sorted by column {columnName} in ascending order",
6+
"descendingSort": "sorted by column {columnName} in descending order"
7+
}

packages/@react-aria/table/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@react-aria/grid": "3.0.0-beta.1",
2323
"@react-aria/i18n": "^3.3.1",
2424
"@react-aria/interactions": "^3.5.0",
25+
"@react-aria/live-announcer": "^3.0.0",
2526
"@react-aria/selection": "^3.5.0",
2627
"@react-aria/utils": "^3.8.1",
2728
"@react-stately/table": "3.0.0-beta.1",

packages/@react-aria/table/src/useTable.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {announce} from '@react-aria/live-announcer';
1314
import {GridAria, GridProps, useGrid} from '@react-aria/grid';
1415
import {gridIds} from './utils';
16+
// @ts-ignore
17+
import intlMessages from '../intl/*.json';
1518
import {Layout} from '@react-stately/virtualizer';
19+
import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/utils';
1620
import {Node} from '@react-types/shared';
1721
import {RefObject, useMemo} from 'react';
1822
import {TableKeyboardDelegate} from './TableKeyboardDelegate';
1923
import {TableState} from '@react-stately/table';
2024
import {useCollator, useLocale} from '@react-aria/i18n';
21-
import {useId} from '@react-aria/utils';
25+
import {useMessageFormatter} from '@react-aria/i18n';
2226

2327
interface TableProps<T> extends GridProps {
2428
/** The layout object for the table. Computes what content is visible and how to position and style them. */
@@ -95,7 +99,21 @@ export function useTable<T>(props: TableProps<T>, state: TableState<T>, ref: Ref
9599
gridProps['aria-rowcount'] = state.collection.size + state.collection.headerRows.length;
96100
}
97101

102+
let {column, direction: sortDirection} = state.sortDescriptor || {};
103+
let formatMessage = useMessageFormatter(intlMessages);
104+
let sortDescription = useMemo(() => {
105+
let columnName = state.collection.columns.find(c => c.key === column)?.textValue;
106+
return sortDirection && column ? formatMessage(`${sortDirection}Sort`, {columnName}) : undefined;
107+
}, [sortDirection, column, state.collection.columns]);
108+
109+
let descriptionProps = useDescription(sortDescription);
110+
111+
// Only announce after initial render, tabbing to the table will tell you the initial sort info already
112+
useUpdateEffect(() => {
113+
announce(sortDescription, 'assertive', 500);
114+
}, [sortDescription]);
115+
98116
return {
99-
gridProps
117+
gridProps: mergeProps(gridProps, descriptionProps)
100118
};
101119
}

packages/@react-aria/table/src/useTableColumnHeader.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
import {getColumnHeaderId} from './utils';
1414
import {GridNode} from '@react-types/grid';
1515
import {HTMLAttributes, RefObject} from 'react';
16-
import {mergeProps} from '@react-aria/utils';
16+
// @ts-ignore
17+
import intlMessages from '../intl/*.json';
18+
import {isAndroid, mergeProps, useDescription} from '@react-aria/utils';
1719
import {TableState} from '@react-stately/table';
1820
import {useFocusable} from '@react-aria/focus';
1921
import {useGridCell} from '@react-aria/grid';
22+
import {useMessageFormatter} from '@react-aria/i18n';
2023
import {usePress} from '@react-aria/interactions';
2124

22-
2325
interface ColumnHeaderProps {
2426
/** An object representing the [column header](https://www.w3.org/TR/wai-aria-1.1/#columnheader). Contains all the relevant information that makes up the column header. */
2527
node: GridNode<unknown>,
@@ -40,31 +42,48 @@ interface ColumnHeaderAria {
4042
*/
4143
export function useTableColumnHeader<T>(props: ColumnHeaderProps, state: TableState<T>, ref: RefObject<HTMLElement>): ColumnHeaderAria {
4244
let {node} = props;
45+
let allowsSorting = node.props.allowsSorting;
4346
let {gridCellProps} = useGridCell(props, state, ref);
4447

4548
let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single';
4649
let {pressProps} = usePress({
47-
isDisabled: !node.props.allowsSorting || isSelectionCellDisabled,
50+
isDisabled: !allowsSorting || isSelectionCellDisabled,
4851
onPress() {
4952
state.sort(node.key);
5053
}
5154
});
5255

5356
// Needed to pick up the focusable context, enabling things like Tooltips for example
5457
let {focusableProps} = useFocusable({}, ref);
58+
5559
let ariaSort: HTMLAttributes<HTMLElement>['aria-sort'] = null;
56-
if (node.props.allowsSorting) {
57-
ariaSort = state.sortDescriptor?.column === node.key ? state.sortDescriptor.direction : 'none';
60+
let isSortedColumn = state.sortDescriptor?.column === node.key;
61+
let sortDirection = state.sortDescriptor?.direction;
62+
// aria-sort not supported in Android Talkback
63+
if (node.props.allowsSorting && !isAndroid()) {
64+
ariaSort = isSortedColumn ? sortDirection : 'none';
65+
}
66+
67+
let formatMessage = useMessageFormatter(intlMessages);
68+
let sortDescription;
69+
if (allowsSorting) {
70+
sortDescription = `${formatMessage('sortable')}`;
71+
// Android Talkback doesn't support aria-sort so we add sort order details to the aria-described by here
72+
if (isSortedColumn && sortDirection && isAndroid()) {
73+
sortDescription = `${sortDescription}, ${formatMessage(sortDirection)}`;
74+
}
5875
}
5976

77+
let descriptionProps = useDescription(sortDescription);
78+
6079
return {
6180
columnHeaderProps: {
62-
...mergeProps(gridCellProps, pressProps, focusableProps),
81+
...mergeProps(gridCellProps, pressProps, focusableProps, descriptionProps),
6382
role: 'columnheader',
6483
id: getColumnHeaderId(state, node.key),
6584
'aria-colspan': node.colspan && node.colspan > 1 ? node.colspan : null,
66-
'aria-sort': ariaSort,
67-
'aria-disabled': isSelectionCellDisabled || undefined
85+
'aria-disabled': isSelectionCellDisabled || undefined,
86+
'aria-sort': ariaSort
6887
}
6988
};
7089
}

packages/@react-spectrum/table/test/Table.test.js

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,27 @@ for (let i = 1; i <= 100; i++) {
6464
manyItems.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i, baz: 'Baz ' + i});
6565
}
6666

67+
function ExampleSortTable() {
68+
let [sortDescriptor, setSortDescriptor] = React.useState({column: 'bar', direction: 'ascending'});
69+
70+
return (
71+
<TableView aria-label="Table" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
72+
<TableHeader>
73+
<Column key="foo" allowsSorting>Foo</Column>
74+
<Column key="bar" allowsSorting>Bar</Column>
75+
<Column key="baz">Baz</Column>
76+
</TableHeader>
77+
<TableBody>
78+
<Row>
79+
<Cell>Foo 1</Cell>
80+
<Cell>Bar 1</Cell>
81+
<Cell>Baz 1</Cell>
82+
</Row>
83+
</TableBody>
84+
</TableView>
85+
);
86+
}
87+
6788
describe('TableView', function () {
6889
let offsetWidth, offsetHeight;
6990

@@ -152,6 +173,7 @@ describe('TableView', function () {
152173

153174
for (let header of headers) {
154175
expect(header).not.toHaveAttribute('aria-sort');
176+
expect(header).not.toHaveAttribute('aria-describedby');
155177
}
156178

157179
expect(headers[0]).toHaveTextContent('Foo');
@@ -233,6 +255,7 @@ describe('TableView', function () {
233255

234256
for (let header of headers) {
235257
expect(header).not.toHaveAttribute('aria-sort');
258+
expect(header).not.toHaveAttribute('aria-describedby');
236259
}
237260

238261
let checkbox = within(headers[0]).getByRole('checkbox');
@@ -2242,6 +2265,22 @@ describe('TableView', function () {
22422265
triggerPress(row);
22432266
expect(announce).toHaveBeenLastCalledWith('Sam Smith not selected.');
22442267
});
2268+
2269+
it('should announce changes in sort order', function () {
2270+
let tree = render(<ExampleSortTable />);
2271+
let table = tree.getByRole('grid');
2272+
let columnheaders = within(table).getAllByRole('columnheader');
2273+
expect(columnheaders).toHaveLength(3);
2274+
2275+
triggerPress(columnheaders[1]);
2276+
expect(announce).toHaveBeenLastCalledWith('sorted by column Bar in descending order', 'assertive', 500);
2277+
triggerPress(columnheaders[1]);
2278+
expect(announce).toHaveBeenLastCalledWith('sorted by column Bar in ascending order', 'assertive', 500);
2279+
triggerPress(columnheaders[0]);
2280+
expect(announce).toHaveBeenLastCalledWith('sorted by column Foo in ascending order', 'assertive', 500);
2281+
triggerPress(columnheaders[0]);
2282+
expect(announce).toHaveBeenLastCalledWith('sorted by column Foo in descending order', 'assertive', 500);
2283+
});
22452284
});
22462285
});
22472286

@@ -3089,7 +3128,7 @@ describe('TableView', function () {
30893128
});
30903129

30913130
describe('sorting', function () {
3092-
it('should set aria-sort="none" on sortable column headers', function () {
3131+
it('should set the proper aria-describedby and aria-sort on sortable column headers', function () {
30933132
let tree = render(
30943133
<TableView aria-label="Table">
30953134
<TableHeader>
@@ -3113,9 +3152,14 @@ describe('TableView', function () {
31133152
expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none');
31143153
expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none');
31153154
expect(columnheaders[2]).not.toHaveAttribute('aria-sort');
3155+
expect(columnheaders[0]).toHaveAttribute('aria-describedby');
3156+
expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3157+
expect(columnheaders[1]).toHaveAttribute('aria-describedby');
3158+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3159+
expect(columnheaders[2]).not.toHaveAttribute('aria-describedby');
31163160
});
31173161

3118-
it('should set aria-sort="ascending" on sorted column header', function () {
3162+
it('should set the proper aria-describedby and aria-sort on an ascending sorted column header', function () {
31193163
let tree = render(
31203164
<TableView aria-label="Table" sortDescriptor={{column: 'bar', direction: 'ascending'}}>
31213165
<TableHeader>
@@ -3139,9 +3183,14 @@ describe('TableView', function () {
31393183
expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none');
31403184
expect(columnheaders[1]).toHaveAttribute('aria-sort', 'ascending');
31413185
expect(columnheaders[2]).not.toHaveAttribute('aria-sort');
3186+
expect(columnheaders[0]).toHaveAttribute('aria-describedby');
3187+
expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3188+
expect(columnheaders[1]).toHaveAttribute('aria-describedby');
3189+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3190+
expect(columnheaders[2]).not.toHaveAttribute('aria-describedby');
31423191
});
31433192

3144-
it('should set aria-sort="descending" on sorted column header', function () {
3193+
it('should set the proper aria-describedby and aria-sort on an descending sorted column header', function () {
31453194
let tree = render(
31463195
<TableView aria-label="Table" sortDescriptor={{column: 'bar', direction: 'descending'}}>
31473196
<TableHeader>
@@ -3165,6 +3214,33 @@ describe('TableView', function () {
31653214
expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none');
31663215
expect(columnheaders[1]).toHaveAttribute('aria-sort', 'descending');
31673216
expect(columnheaders[2]).not.toHaveAttribute('aria-sort');
3217+
expect(columnheaders[0]).toHaveAttribute('aria-describedby');
3218+
expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3219+
expect(columnheaders[1]).toHaveAttribute('aria-describedby');
3220+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3221+
expect(columnheaders[2]).not.toHaveAttribute('aria-describedby');
3222+
});
3223+
3224+
it('should add sort direction info to the column header\'s aria-describedby for Android', function () {
3225+
let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android');
3226+
let tree = render(<ExampleSortTable />);
3227+
3228+
let table = tree.getByRole('grid');
3229+
let columnheaders = within(table).getAllByRole('columnheader');
3230+
expect(columnheaders).toHaveLength(3);
3231+
expect(columnheaders[0]).not.toHaveAttribute('aria-sort');
3232+
expect(columnheaders[1]).not.toHaveAttribute('aria-sort');
3233+
expect(columnheaders[2]).not.toHaveAttribute('aria-sort');
3234+
expect(columnheaders[0]).toHaveAttribute('aria-describedby');
3235+
expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3236+
expect(columnheaders[1]).toHaveAttribute('aria-describedby');
3237+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column, ascending');
3238+
expect(columnheaders[2]).not.toHaveAttribute('aria-describedby');
3239+
3240+
triggerPress(columnheaders[1]);
3241+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column, descending');
3242+
3243+
uaMock.mockRestore();
31683244
});
31693245

31703246
it('should fire onSortChange when there is no existing sortDescriptor', function () {
@@ -3192,6 +3268,11 @@ describe('TableView', function () {
31923268
expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none');
31933269
expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none');
31943270
expect(columnheaders[2]).not.toHaveAttribute('aria-sort');
3271+
expect(columnheaders[0]).toHaveAttribute('aria-describedby');
3272+
expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3273+
expect(columnheaders[1]).toHaveAttribute('aria-describedby');
3274+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3275+
expect(columnheaders[2]).not.toHaveAttribute('aria-describedby');
31953276

31963277
triggerPress(columnheaders[0]);
31973278

@@ -3224,6 +3305,11 @@ describe('TableView', function () {
32243305
expect(columnheaders[0]).toHaveAttribute('aria-sort', 'ascending');
32253306
expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none');
32263307
expect(columnheaders[2]).not.toHaveAttribute('aria-sort');
3308+
expect(columnheaders[0]).toHaveAttribute('aria-describedby');
3309+
expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3310+
expect(columnheaders[1]).toHaveAttribute('aria-describedby');
3311+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3312+
expect(columnheaders[2]).not.toHaveAttribute('aria-describedby');
32273313

32283314
triggerPress(columnheaders[0]);
32293315

@@ -3253,9 +3339,14 @@ describe('TableView', function () {
32533339
let table = tree.getByRole('grid');
32543340
let columnheaders = within(table).getAllByRole('columnheader');
32553341
expect(columnheaders).toHaveLength(3);
3342+
expect(columnheaders[0]).toHaveAttribute('aria-describedby');
32563343
expect(columnheaders[0]).toHaveAttribute('aria-sort', 'descending');
32573344
expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none');
32583345
expect(columnheaders[2]).not.toHaveAttribute('aria-sort');
3346+
expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3347+
expect(columnheaders[1]).toHaveAttribute('aria-describedby');
3348+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3349+
expect(columnheaders[2]).not.toHaveAttribute('aria-describedby');
32593350

32603351
triggerPress(columnheaders[0]);
32613352

@@ -3288,6 +3379,11 @@ describe('TableView', function () {
32883379
expect(columnheaders[0]).toHaveAttribute('aria-sort', 'ascending');
32893380
expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none');
32903381
expect(columnheaders[2]).not.toHaveAttribute('aria-sort');
3382+
expect(columnheaders[0]).toHaveAttribute('aria-describedby');
3383+
expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3384+
expect(columnheaders[1]).toHaveAttribute('aria-describedby');
3385+
expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column');
3386+
expect(columnheaders[2]).not.toHaveAttribute('aria-describedby');
32913387

32923388
triggerPress(columnheaders[1]);
32933389

packages/@react-stately/table/src/Column.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,15 @@ function Column<T>(props: ColumnProps<T>): ReactElement { // eslint-disable-line
2222

2323
Column.getCollectionNode = function* getCollectionNode<T>(props: ColumnProps<T>, context: CollectionBuilderContext<T>): Generator<PartialNode<T>, void, GridNode<T>[]> {
2424
let {title, children, childColumns} = props;
25+
26+
let rendered = title || children;
27+
let textValue = props.textValue || (typeof rendered === 'string' ? rendered : '') || props['aria-label'];
28+
2529
let fullNodes = yield {
2630
type: 'column',
2731
hasChildNodes: !!childColumns || (title && React.Children.count(children) > 0),
28-
rendered: title || children,
32+
rendered,
33+
textValue,
2934
props,
3035
*childNodes() {
3136
if (childColumns) {

packages/@react-types/table/src/index.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ export interface ColumnProps<T> {
6464
/** Whether the column allows sorting. */
6565
allowsSorting?: boolean,
6666
/** Whether a column is a [row header](https://www.w3.org/TR/wai-aria-1.1/#rowheader) and should be announced by assistive technology during row navigation. */
67-
isRowHeader?: boolean
68-
67+
isRowHeader?: boolean,
68+
/** A string representation of the column's contents, used for accessibility announcements. */
69+
textValue?: string
6970
}
7071

7172
// TODO: how to support these in CollectionBuilder...

0 commit comments

Comments
 (0)