diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index a9c85b92f0e..f67e27bcea6 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -2996,7 +2996,7 @@ describe('AnalyticalTable', () => { cy.get('[data-column-id="__ui5wcr__internal_selection_column"][role="columnheader"]').should( 'have.attr', 'aria-label', - ' Selection Column', + 'Selection Column', ); let selectCalled = 0; @@ -3493,12 +3493,18 @@ describe('AnalyticalTable', () => { cy.get('[data-visible-column-index="0"][data-visible-row-index="0"]') .as('selAll') .should('have.attr', 'title', 'Select All') - .and('have.attr', 'aria-label', 'To select all rows, press the spacebar. Selection Column') - .click(); + .and('have.attr', 'aria-label', 'Selection Column'); + cy.get('@selAll') + .invoke('attr', 'aria-describedby') + .should('match', /^header-select-all-/); + cy.get('@selAll').click(); cy.get('@selAll').should('have.text', 'Select All'); cy.get('@selAll').contains('Select All').should('not.be.visible'); - cy.get('@selAll').should('have.attr', 'aria-label', 'To deselect all rows, press the spacebar. Selection Column'); + cy.get('@selAll').should('have.attr', 'aria-label', 'Selection Column'); + cy.get('@selAll') + .invoke('attr', 'aria-describedby') + .should('match', /^header-deselect-all-/); cy.get('@selectSpy').should('have.been.calledOnce'); cy.get('@selAll').should('have.attr', 'title', 'Deselect All'); diff --git a/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx b/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx index cd2cb639a05..c4f1d57f57e 100644 --- a/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx +++ b/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx @@ -52,6 +52,7 @@ export interface ColumnHeaderProps { title?: string; ['aria-sort']?: AriaAttributes['aria-sort']; ['aria-label']?: AriaAttributes['aria-label']; + ['aria-describedby']?: AriaAttributes['aria-describedby']; } export const ColumnHeader = (props: ColumnHeaderProps) => { @@ -82,6 +83,7 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { title, 'aria-label': ariaLabel, 'aria-sort': ariaSort, + 'aria-describedby': ariaDescribedBy, showVerticalEndBorder, classNames, } = props; @@ -216,6 +218,7 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { onKeyUp={handleHeaderCellKeyUp} aria-label={ariaLabel} aria-sort={ariaSort} + aria-describedby={ariaDescribedBy} title={title} >
{ - const { translatableTexts, selectionMode } = instance.webComponentsReactProperties; + const { translatableTexts, selectionMode, a11yElementIds } = instance.webComponentsReactProperties; if (!column) { return headerProps; } const isFiltered = column?.filterValue && column?.filterValue.length > 0; - const updatedProps = {}; + const updatedProps: { + 'aria-label'?: string; + 'aria-sort'?: string; + 'aria-describedby'?: string; + isFiltered?: boolean; + } = {}; updatedProps['aria-label'] = column.headerLabel ??= ''; if (updatedProps['aria-label']) { @@ -132,21 +137,22 @@ const setHeaderProps = ( } if (selectionMode === AnalyticalTableSelectionMode.Multiple && column.id === '__ui5wcr__internal_selection_column') { - updatedProps['aria-label'] += instance.isAllRowsSelected - ? translatableTexts.deselectAllA11yText - : translatableTexts.selectAllA11yText; + // aria-describedby so body cells don't inherit it via aria-labelledby + updatedProps['aria-describedby'] = instance.isAllRowsSelected + ? a11yElementIds.headerDeselectAllDescId + : a11yElementIds.headerSelectAllDescId; } if (column.id === '__ui5wcr__internal_selection_column') { - updatedProps['aria-label'] += ' ' + translatableTexts.selectionHeaderCellText; + updatedProps['aria-label'] += (updatedProps['aria-label'] ? ' ' : '') + translatableTexts.selectionHeaderCellText; } if (column.id === '__ui5wcr__internal_highlight_column') { - updatedProps['aria-label'] += ' ' + translatableTexts.highlightHeaderCellText; + updatedProps['aria-label'] += (updatedProps['aria-label'] ? ' ' : '') + translatableTexts.highlightHeaderCellText; } if (column.id === '__ui5wcr__internal_navigation_column') { - updatedProps['aria-label'] += ' ' + translatableTexts.navigationHeaderCellText; + updatedProps['aria-label'] += (updatedProps['aria-label'] ? ' ' : '') + translatableTexts.navigationHeaderCellText; } updatedProps['aria-label'] ||= undefined; diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts index 23b8b906c00..f43978de340 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts @@ -1,12 +1,31 @@ -import { enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils'; +import announce from '@ui5/webcomponents-base/dist/util/InvisibleMessage.js'; +import { useI18nBundle } from '@ui5/webcomponents-react-base/hooks'; +import { debounce, enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils'; import { useEffect, useRef } from 'react'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; +import { + ALL_ROWS_DESELECTED, + ALL_ROWS_DESELECTED_FILTERED, + ALL_ROWS_SELECTED, + ALL_ROWS_SELECTED_FILTERED, + ROW_DESELECTED, + ROW_DESELECTED_MULTI, + ROW_DESELECTED_MULTI_FILTERED, + ROW_SELECTED, + ROW_SELECTED_MULTI, + ROW_SELECTED_MULTI_FILTERED, +} from '../../../i18n/i18n-defaults.js'; import { ensurePluginOrder } from '../react-table/index.js'; import type { AnalyticalTablePropTypes, ReactTableHooks, TableInstance } from '../types/index.js'; type OnRowSelectEvent = Parameters>[0]; type OnRowSelectDetail = OnRowSelectEvent['detail']; +// debounce announce to prevent excessive successive announcements +const debouncedAnnounce = debounce((announcement: string) => { + announce(announcement, 'Polite'); +}, 200); + const useInstance = (instance: TableInstance) => { const { webComponentsReactProperties, rowsById, preFilteredRowsById, state, plugins } = instance; const { selectedRowIds, filters, globalFilter } = state; @@ -14,6 +33,8 @@ const useInstance = (instance: TableInstance) => { ensurePluginOrder(plugins, ['useRowSelect'], 'useSelectionChangeCallback'); + const i18nBundle = useI18nBundle('@ui5/webcomponents-react'); + const prevSelectedRowIdsRef = useRef(selectedRowIds); // react-table instance is intentionally mutable @@ -58,13 +79,46 @@ const useInstance = (instance: TableInstance) => { selectedRowIds: payload.selectedRowIds, }) as OnRowSelectEvent, ); + let allRowsMsgKey; + if (instance.isAllRowsSelected) { + allRowsMsgKey = isFiltered ? ALL_ROWS_SELECTED_FILTERED : ALL_ROWS_SELECTED; + } else { + allRowsMsgKey = isFiltered ? ALL_ROWS_DESELECTED_FILTERED : ALL_ROWS_DESELECTED; + } + debouncedAnnounce(i18nBundle.getText(allRowsMsgKey)); } else { onRowSelect?.(enrichEventWithDetails(e as Event & { detail?: OnRowSelectDetail }, payload) as OnRowSelectEvent); + + if (row) { + const isSelected = row.isSelected; + if (selectionMode === AnalyticalTableSelectionMode.Multiple) { + const count = instance.selectedFlatRows.length; + let rowMsgKey; + if (isSelected) { + rowMsgKey = isFiltered ? ROW_SELECTED_MULTI_FILTERED : ROW_SELECTED_MULTI; + } else { + rowMsgKey = isFiltered ? ROW_DESELECTED_MULTI_FILTERED : ROW_DESELECTED_MULTI; + } + debouncedAnnounce(i18nBundle.getText(rowMsgKey, count)); + } else if (selectionMode === AnalyticalTableSelectionMode.Single) { + debouncedAnnounce(i18nBundle.getText(isSelected ? ROW_SELECTED : ROW_DESELECTED)); + } + } } } prevSelectedRowIdsRef.current = selectedRowIds; - }, [selectedRowIds, rowsById, preFilteredRowsById, filters, globalFilter, selectionMode, instance, onRowSelect]); + }, [ + selectedRowIds, + rowsById, + preFilteredRowsById, + filters, + globalFilter, + selectionMode, + instance, + onRowSelect, + i18nBundle, + ]); }; export const useSelectionChangeCallback = (hooks: ReactTableHooks) => { diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index 6e012b125ab..e8d52a2cb00 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -219,6 +219,8 @@ const AnalyticalTable = forwardRef(null); const parentRef = useRef(null); @@ -261,6 +263,8 @@ const AnalyticalTable = forwardRef {i18nBundle.getText(UNSELECT_PRESS_SPACE)} + + {i18nBundle.getText(SELECT_ALL_PRESS_SPACE)} + + + {i18nBundle.getText(UNSELECT_ALL_PRESS_SPACE)} + {/* expand */} {i18nBundle.getText(EXPAND_PRESS_SPACE)} diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts index d9e3cfe8f82..031e1815827 100644 --- a/packages/main/src/components/AnalyticalTable/types/index.ts +++ b/packages/main/src/components/AnalyticalTable/types/index.ts @@ -279,6 +279,8 @@ export interface WCRPropertiesType { cellExpandDescId: string; cellCollapseDescId: string; cellEmptyDescId: string; + headerSelectAllDescId: string; + headerDeselectAllDescId: string; }; translatableTexts: { selectAllText: string; diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index 0159fed3cda..04ecee664f3 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -266,6 +266,36 @@ ROW_X_EXPANDED=Row {0} expanded #XACT: ARIA live announcement for collapsed row number {0} ROW_X_COLLAPSED=Row {0} collapsed +#XACT: ARIA live announcement when a row is selected (single selection mode) +ROW_SELECTED=Row selected + +#XACT: ARIA live announcement when a row is deselected (single selection mode) +ROW_DESELECTED=Row deselected + +#XACT: ARIA live announcement when a row is selected in multi selection mode. The placeholder is the total number of selected rows. +ROW_SELECTED_MULTI=Row selected, {0} rows selected + +#XACT: ARIA live announcement when a row is deselected in multi selection mode. The placeholder is the total number of selected rows. +ROW_DESELECTED_MULTI=Row deselected, {0} rows selected + +#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. +ROW_SELECTED_MULTI_FILTERED=Row selected, {0} visible rows selected + +#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. +ROW_DESELECTED_MULTI_FILTERED=Row deselected, {0} visible rows selected + +#XACT: ARIA live announcement when the header "Select All" checkbox is toggled and all rows become selected +ALL_ROWS_SELECTED=All rows selected + +#XACT: ARIA live announcement when the header "Select All" checkbox is toggled and all rows become deselected +ALL_ROWS_DESELECTED=All rows deselected + +#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 +ALL_ROWS_SELECTED_FILTERED=All visible rows selected + +#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 +ALL_ROWS_DESELECTED_FILTERED=All visible rows deselected + #XACT: Aria label text for selectable table cells in unselected state SELECT_PRESS_SPACE=To select the row, press the spacebar.