Skip to content

Commit 405f72e

Browse files
authored
fix(AnalyticalTable): announce selection change by screen readers (UI5#8768)
Also fixes the header selection cell announcement being forwarded to every body cell.
1 parent 4995ed3 commit 405f72e

7 files changed

Lines changed: 125 additions & 14 deletions

File tree

packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2996,7 +2996,7 @@ describe('AnalyticalTable', () => {
29962996
cy.get('[data-column-id="__ui5wcr__internal_selection_column"][role="columnheader"]').should(
29972997
'have.attr',
29982998
'aria-label',
2999-
' Selection Column',
2999+
'Selection Column',
30003000
);
30013001

30023002
let selectCalled = 0;
@@ -3493,12 +3493,18 @@ describe('AnalyticalTable', () => {
34933493
cy.get('[data-visible-column-index="0"][data-visible-row-index="0"]')
34943494
.as('selAll')
34953495
.should('have.attr', 'title', 'Select All')
3496-
.and('have.attr', 'aria-label', 'To select all rows, press the spacebar. Selection Column')
3497-
.click();
3496+
.and('have.attr', 'aria-label', 'Selection Column');
3497+
cy.get('@selAll')
3498+
.invoke('attr', 'aria-describedby')
3499+
.should('match', /^header-select-all-/);
3500+
cy.get('@selAll').click();
34983501

34993502
cy.get('@selAll').should('have.text', 'Select All');
35003503
cy.get('@selAll').contains('Select All').should('not.be.visible');
3501-
cy.get('@selAll').should('have.attr', 'aria-label', 'To deselect all rows, press the spacebar. Selection Column');
3504+
cy.get('@selAll').should('have.attr', 'aria-label', 'Selection Column');
3505+
cy.get('@selAll')
3506+
.invoke('attr', 'aria-describedby')
3507+
.should('match', /^header-deselect-all-/);
35023508

35033509
cy.get('@selectSpy').should('have.been.calledOnce');
35043510
cy.get('@selAll').should('have.attr', 'title', 'Deselect All');

packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface ColumnHeaderProps {
5252
title?: string;
5353
['aria-sort']?: AriaAttributes['aria-sort'];
5454
['aria-label']?: AriaAttributes['aria-label'];
55+
['aria-describedby']?: AriaAttributes['aria-describedby'];
5556
}
5657

5758
export const ColumnHeader = (props: ColumnHeaderProps) => {
@@ -82,6 +83,7 @@ export const ColumnHeader = (props: ColumnHeaderProps) => {
8283
title,
8384
'aria-label': ariaLabel,
8485
'aria-sort': ariaSort,
86+
'aria-describedby': ariaDescribedBy,
8587
showVerticalEndBorder,
8688
classNames,
8789
} = props;
@@ -216,6 +218,7 @@ export const ColumnHeader = (props: ColumnHeaderProps) => {
216218
onKeyUp={handleHeaderCellKeyUp}
217219
aria-label={ariaLabel}
218220
aria-sort={ariaSort}
221+
aria-describedby={ariaDescribedBy}
219222
title={title}
220223
>
221224
<div

packages/main/src/components/AnalyticalTable/hooks/useA11y.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,19 @@ const setHeaderProps = (
103103
headerProps,
104104
{ column, instance }: { column: TableInstance['column']; instance: TableInstance },
105105
) => {
106-
const { translatableTexts, selectionMode } = instance.webComponentsReactProperties;
106+
const { translatableTexts, selectionMode, a11yElementIds } = instance.webComponentsReactProperties;
107107

108108
if (!column) {
109109
return headerProps;
110110
}
111111
const isFiltered = column?.filterValue && column?.filterValue.length > 0;
112112

113-
const updatedProps = {};
113+
const updatedProps: {
114+
'aria-label'?: string;
115+
'aria-sort'?: string;
116+
'aria-describedby'?: string;
117+
isFiltered?: boolean;
118+
} = {};
114119
updatedProps['aria-label'] = column.headerLabel ??= '';
115120

116121
if (updatedProps['aria-label']) {
@@ -132,21 +137,22 @@ const setHeaderProps = (
132137
}
133138

134139
if (selectionMode === AnalyticalTableSelectionMode.Multiple && column.id === '__ui5wcr__internal_selection_column') {
135-
updatedProps['aria-label'] += instance.isAllRowsSelected
136-
? translatableTexts.deselectAllA11yText
137-
: translatableTexts.selectAllA11yText;
140+
// aria-describedby so body cells don't inherit it via aria-labelledby
141+
updatedProps['aria-describedby'] = instance.isAllRowsSelected
142+
? a11yElementIds.headerDeselectAllDescId
143+
: a11yElementIds.headerSelectAllDescId;
138144
}
139145

140146
if (column.id === '__ui5wcr__internal_selection_column') {
141-
updatedProps['aria-label'] += ' ' + translatableTexts.selectionHeaderCellText;
147+
updatedProps['aria-label'] += (updatedProps['aria-label'] ? ' ' : '') + translatableTexts.selectionHeaderCellText;
142148
}
143149

144150
if (column.id === '__ui5wcr__internal_highlight_column') {
145-
updatedProps['aria-label'] += ' ' + translatableTexts.highlightHeaderCellText;
151+
updatedProps['aria-label'] += (updatedProps['aria-label'] ? ' ' : '') + translatableTexts.highlightHeaderCellText;
146152
}
147153

148154
if (column.id === '__ui5wcr__internal_navigation_column') {
149-
updatedProps['aria-label'] += ' ' + translatableTexts.navigationHeaderCellText;
155+
updatedProps['aria-label'] += (updatedProps['aria-label'] ? ' ' : '') + translatableTexts.navigationHeaderCellText;
150156
}
151157

152158
updatedProps['aria-label'] ||= undefined;

packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,40 @@
1-
import { enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils';
1+
import announce from '@ui5/webcomponents-base/dist/util/InvisibleMessage.js';
2+
import { useI18nBundle } from '@ui5/webcomponents-react-base/hooks';
3+
import { debounce, enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils';
24
import { useEffect, useRef } from 'react';
35
import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js';
6+
import {
7+
ALL_ROWS_DESELECTED,
8+
ALL_ROWS_DESELECTED_FILTERED,
9+
ALL_ROWS_SELECTED,
10+
ALL_ROWS_SELECTED_FILTERED,
11+
ROW_DESELECTED,
12+
ROW_DESELECTED_MULTI,
13+
ROW_DESELECTED_MULTI_FILTERED,
14+
ROW_SELECTED,
15+
ROW_SELECTED_MULTI,
16+
ROW_SELECTED_MULTI_FILTERED,
17+
} from '../../../i18n/i18n-defaults.js';
418
import { ensurePluginOrder } from '../react-table/index.js';
519
import type { AnalyticalTablePropTypes, ReactTableHooks, TableInstance } from '../types/index.js';
620

721
type OnRowSelectEvent = Parameters<NonNullable<AnalyticalTablePropTypes['onRowSelect']>>[0];
822
type OnRowSelectDetail = OnRowSelectEvent['detail'];
923

24+
// debounce announce to prevent excessive successive announcements
25+
const debouncedAnnounce = debounce((announcement: string) => {
26+
announce(announcement, 'Polite');
27+
}, 200);
28+
1029
const useInstance = (instance: TableInstance) => {
1130
const { webComponentsReactProperties, rowsById, preFilteredRowsById, state, plugins } = instance;
1231
const { selectedRowIds, filters, globalFilter } = state;
1332
const { onRowSelect, selectionMode } = webComponentsReactProperties;
1433

1534
ensurePluginOrder(plugins, ['useRowSelect'], 'useSelectionChangeCallback');
1635

36+
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
37+
1738
const prevSelectedRowIdsRef = useRef(selectedRowIds);
1839

1940
// react-table instance is intentionally mutable
@@ -58,13 +79,46 @@ const useInstance = (instance: TableInstance) => {
5879
selectedRowIds: payload.selectedRowIds,
5980
}) as OnRowSelectEvent,
6081
);
82+
let allRowsMsgKey;
83+
if (instance.isAllRowsSelected) {
84+
allRowsMsgKey = isFiltered ? ALL_ROWS_SELECTED_FILTERED : ALL_ROWS_SELECTED;
85+
} else {
86+
allRowsMsgKey = isFiltered ? ALL_ROWS_DESELECTED_FILTERED : ALL_ROWS_DESELECTED;
87+
}
88+
debouncedAnnounce(i18nBundle.getText(allRowsMsgKey));
6189
} else {
6290
onRowSelect?.(enrichEventWithDetails(e as Event & { detail?: OnRowSelectDetail }, payload) as OnRowSelectEvent);
91+
92+
if (row) {
93+
const isSelected = row.isSelected;
94+
if (selectionMode === AnalyticalTableSelectionMode.Multiple) {
95+
const count = instance.selectedFlatRows.length;
96+
let rowMsgKey;
97+
if (isSelected) {
98+
rowMsgKey = isFiltered ? ROW_SELECTED_MULTI_FILTERED : ROW_SELECTED_MULTI;
99+
} else {
100+
rowMsgKey = isFiltered ? ROW_DESELECTED_MULTI_FILTERED : ROW_DESELECTED_MULTI;
101+
}
102+
debouncedAnnounce(i18nBundle.getText(rowMsgKey, count));
103+
} else if (selectionMode === AnalyticalTableSelectionMode.Single) {
104+
debouncedAnnounce(i18nBundle.getText(isSelected ? ROW_SELECTED : ROW_DESELECTED));
105+
}
106+
}
63107
}
64108
}
65109

66110
prevSelectedRowIdsRef.current = selectedRowIds;
67-
}, [selectedRowIds, rowsById, preFilteredRowsById, filters, globalFilter, selectionMode, instance, onRowSelect]);
111+
}, [
112+
selectedRowIds,
113+
rowsById,
114+
preFilteredRowsById,
115+
filters,
116+
globalFilter,
117+
selectionMode,
118+
instance,
119+
onRowSelect,
120+
i18nBundle,
121+
]);
68122
};
69123

70124
export const useSelectionChangeCallback = (hooks: ReactTableHooks) => {

packages/main/src/components/AnalyticalTable/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ const AnalyticalTable = forwardRef<AnalyticalTableDomRef, AnalyticalTablePropTyp
219219
const cellExpandDescId = `cell-expand-${uniqueId}`;
220220
const cellCollapseDescId = `cell-collapse-${uniqueId}`;
221221
const cellEmptyDescId = `cell-empty-${uniqueId}`;
222+
const headerSelectAllDescId = `header-select-all-${uniqueId}`;
223+
const headerDeselectAllDescId = `header-deselect-all-${uniqueId}`;
222224

223225
const tableRef = useRef<DivWithCustomScrollProp>(null);
224226
const parentRef = useRef<DivWithCustomScrollProp>(null);
@@ -261,6 +263,8 @@ const AnalyticalTable = forwardRef<AnalyticalTableDomRef, AnalyticalTablePropTyp
261263
cellExpandDescId,
262264
cellCollapseDescId,
263265
cellEmptyDescId,
266+
headerSelectAllDescId,
267+
headerDeselectAllDescId,
264268
},
265269
translatableTexts: {
266270
selectAllText: i18nBundle.getText(SELECT_ALL),
@@ -945,6 +949,12 @@ const AnalyticalTable = forwardRef<AnalyticalTableDomRef, AnalyticalTablePropTyp
945949
<span id={cellUnselectDescId} className={classNames.hiddenA11yText}>
946950
{i18nBundle.getText(UNSELECT_PRESS_SPACE)}
947951
</span>
952+
<span id={headerSelectAllDescId} className={classNames.hiddenA11yText}>
953+
{i18nBundle.getText(SELECT_ALL_PRESS_SPACE)}
954+
</span>
955+
<span id={headerDeselectAllDescId} className={classNames.hiddenA11yText}>
956+
{i18nBundle.getText(UNSELECT_ALL_PRESS_SPACE)}
957+
</span>
948958
{/* expand */}
949959
<span id={cellExpandDescId} className={classNames.hiddenA11yText}>
950960
{i18nBundle.getText(EXPAND_PRESS_SPACE)}

packages/main/src/components/AnalyticalTable/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ export interface WCRPropertiesType {
279279
cellExpandDescId: string;
280280
cellCollapseDescId: string;
281281
cellEmptyDescId: string;
282+
headerSelectAllDescId: string;
283+
headerDeselectAllDescId: string;
282284
};
283285
translatableTexts: {
284286
selectAllText: string;

packages/main/src/i18n/messagebundle.properties

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,36 @@ ROW_X_EXPANDED=Row {0} expanded
266266
#XACT: ARIA live announcement for collapsed row number {0}
267267
ROW_X_COLLAPSED=Row {0} collapsed
268268

269+
#XACT: ARIA live announcement when a row is selected (single selection mode)
270+
ROW_SELECTED=Row selected
271+
272+
#XACT: ARIA live announcement when a row is deselected (single selection mode)
273+
ROW_DESELECTED=Row deselected
274+
275+
#XACT: ARIA live announcement when a row is selected in multi selection mode. The placeholder is the total number of selected rows.
276+
ROW_SELECTED_MULTI=Row selected, {0} rows selected
277+
278+
#XACT: ARIA live announcement when a row is deselected in multi selection mode. The placeholder is the total number of selected rows.
279+
ROW_DESELECTED_MULTI=Row deselected, {0} rows selected
280+
281+
#XACT: ARIA live announcement when a row is selected in multi selection mode while a filter is active. The placeholder is the number of currently visible (non-filtered-out) selected rows.
282+
ROW_SELECTED_MULTI_FILTERED=Row selected, {0} visible rows selected
283+
284+
#XACT: ARIA live announcement when a row is deselected in multi selection mode while a filter is active. The placeholder is the number of currently visible (non-filtered-out) selected rows.
285+
ROW_DESELECTED_MULTI_FILTERED=Row deselected, {0} visible rows selected
286+
287+
#XACT: ARIA live announcement when the header "Select All" checkbox is toggled and all rows become selected
288+
ALL_ROWS_SELECTED=All rows selected
289+
290+
#XACT: ARIA live announcement when the header "Select All" checkbox is toggled and all rows become deselected
291+
ALL_ROWS_DESELECTED=All rows deselected
292+
293+
#XACT: ARIA live announcement when the header "Select All" checkbox is toggled while a filter is active and all visible (non-filtered-out) rows become selected
294+
ALL_ROWS_SELECTED_FILTERED=All visible rows selected
295+
296+
#XACT: ARIA live announcement when the header "Select All" checkbox is toggled while a filter is active and all visible (non-filtered-out) rows become deselected
297+
ALL_ROWS_DESELECTED_FILTERED=All visible rows deselected
298+
269299
#XACT: Aria label text for selectable table cells in unselected state
270300
SELECT_PRESS_SPACE=To select the row, press the spacebar.
271301

0 commit comments

Comments
 (0)