From 92cc562e2b87cc6ff990007e012b8d024e9ba922 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 27 Oct 2025 11:47:39 +0100 Subject: [PATCH 1/9] fix(AnalyticalTable): improve accessibility --- .../AnalyticalTable.stories.tsx | 5 +- .../AnalyticalTable/ColumnHeader/index.tsx | 3 + .../AnalyticalTable/defaults/Column/Cell.tsx | 7 + .../defaults/Column/ColumnHeaderModal.tsx | 5 +- .../AnalyticalTable/hooks/useA11y.ts | 47 ++- .../src/components/AnalyticalTable/index.tsx | 367 ++++++++++-------- .../components/AnalyticalTable/types/index.ts | 14 +- .../main/src/i18n/messagebundle.properties | 9 + 8 files changed, 265 insertions(+), 192 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx index e9614dcb487..938527c1b8d 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx @@ -66,7 +66,7 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { }, { Header: () => Friend Age, - headerLabel: 'Friend Age', + headerLabel: 'Custom Header Label', accessor: 'friend.age', autoResizable: true, hAlign: TextAlign.End, @@ -130,7 +130,7 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { return `${cell.cellLabel} press TAB to focus active elements inside this cell`; }, }, - ], + ].slice(0, 2), filterable: true, alternateRowColor: true, columnOrder: ['friend.name', 'friend.age', 'name'], @@ -195,6 +195,7 @@ const meta = { visibleRows: 5, // sb actions has a huge impact on performance here. onTableScroll: undefined, + header: 'TableTitle', }, argTypes: { data: { control: { disable: true } }, diff --git a/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx b/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx index c7535af48ad..e8d0809f319 100644 --- a/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx +++ b/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx @@ -200,6 +200,8 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { borderInlineStart: dragOver ? `3px solid ${ThemingParameters.sapSelectedColor}` : undefined, }} aria-haspopup={hasPopover ? 'menu' : undefined} + aria-expanded={hasPopover ? (popoverOpen ? 'true' : 'false') : undefined} + aria-controls={hasPopover ? `${id}-popover` : undefined} role={role} draggable={isDraggable} onDragEnter={onDragEnter} @@ -278,6 +280,7 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { // render the popover and add the props to the table instance column.render(RenderColumnTypes.Popover, { popoverProps: { + id: `${id}-popover`, openerRef: columnHeaderRef, setOpen: setPopoverOpen, }, diff --git a/packages/main/src/components/AnalyticalTable/defaults/Column/Cell.tsx b/packages/main/src/components/AnalyticalTable/defaults/Column/Cell.tsx index 006da6ff629..02f31b565c5 100644 --- a/packages/main/src/components/AnalyticalTable/defaults/Column/Cell.tsx +++ b/packages/main/src/components/AnalyticalTable/defaults/Column/Cell.tsx @@ -11,11 +11,18 @@ export const Cell = (props: CellInstance) => { if (isGrouped) { cellContent += ` (${row.subRows.length})`; } + return ( diff --git a/packages/main/src/components/AnalyticalTable/defaults/Column/ColumnHeaderModal.tsx b/packages/main/src/components/AnalyticalTable/defaults/Column/ColumnHeaderModal.tsx index e3760e801cf..7fd53c41279 100644 --- a/packages/main/src/components/AnalyticalTable/defaults/Column/ColumnHeaderModal.tsx +++ b/packages/main/src/components/AnalyticalTable/defaults/Column/ColumnHeaderModal.tsx @@ -34,7 +34,7 @@ import type { TableInstanceWithPopoverProps } from '../../types/index.js'; import { RenderColumnTypes } from '../../types/index.js'; export const ColumnHeaderModal = (instance: TableInstanceWithPopoverProps) => { - const { setOpen, openerRef } = instance.popoverProps; + const { setOpen, openerRef, id } = instance.popoverProps; const { column, state, webComponentsReactProperties } = instance; const { isRtl, groupBy } = state; const { onGroup, onSort, classes: classNames } = webComponentsReactProperties; @@ -174,10 +174,11 @@ export const ColumnHeaderModal = (instance: TableInstanceWithPopoverProps) => { ref.current.open = true; }); } - }, []); + }, [openerRef]); return ( ; - 'aria-expanded'?: string | boolean; + 'aria-expanded'?: string; 'aria-label'?: string; 'aria-colindex'?: number; + 'aria-describedby'?: string; role?: string; } const setCellProps = (cellProps, { cell, instance }: { cell: TableInstance['cell']; instance: TableInstance }) => { const { column, row, value } = cell; const columnIndex = instance.visibleColumns.findIndex(({ id }) => id === column.id); - const { alwaysShowSubComponent, renderRowSubComponent, translatableTexts, selectionMode, selectionBehavior } = + const { alwaysShowSubComponent, renderRowSubComponent, selectionMode, selectionBehavior, a11yElementIds, uniqueId } = instance.webComponentsReactProperties; - const updatedCellProps: UpdatedCellProptypes = { 'aria-colindex': columnIndex + 1, role: 'gridcell' }; // aria index is 1 based, not 0 + const updatedCellProps: UpdatedCellProptypes = { + // aria index is 1 based, not 0 + 'aria-colindex': columnIndex + 1, + role: 'gridcell', + // header label + 'aria-describedby': `${uniqueId}${column.id}`, + 'aria-label': '', + }; const RowSubComponent = typeof renderRowSubComponent === 'function' ? renderRowSubComponent(row) : undefined; const rowIsExpandable = row.canExpand || (RowSubComponent && !alwaysShowSubComponent); @@ -30,24 +38,17 @@ const setCellProps = (cellProps, { cell, instance }: { cell: TableInstance['cell const isFirstUserCol = userCols[0]?.id === column.id || userCols[0]?.accessor === column.accessor; updatedCellProps['data-is-first-column'] = isFirstUserCol; - updatedCellProps['aria-label'] = column.headerLabel || (typeof column.Header === 'string' ? column.Header : ''); - updatedCellProps['aria-label'] &&= `${updatedCellProps['aria-label']} `; updatedCellProps['aria-label'] += value || value === 0 ? `${value} ` : ''; if ((isFirstUserCol && rowIsExpandable) || (row.isGrouped && row.canExpand)) { updatedCellProps.onKeyDown = row.getToggleRowExpandedProps?.()?.onKeyDown; - let ariaLabel = ''; - if (row.isGrouped) { - ariaLabel += translatableTexts.groupedA11yText + ','; - } if (row.isExpanded) { updatedCellProps['aria-expanded'] = 'true'; - ariaLabel += ` ${translatableTexts.collapseA11yText}`; + updatedCellProps['aria-describedby'] += ' ' + a11yElementIds.cellCollapseDescId; } else { updatedCellProps['aria-expanded'] = 'false'; - ariaLabel += ` ${translatableTexts.expandA11yText}`; + updatedCellProps['aria-describedby'] += ' ' + a11yElementIds.cellExpandDescId; } - updatedCellProps['aria-label'] += ariaLabel; } else if ( (selectionMode !== AnalyticalTableSelectionMode.None && selectionBehavior !== AnalyticalTableSelectionBehavior.RowSelector && @@ -56,16 +57,18 @@ const setCellProps = (cellProps, { cell, instance }: { cell: TableInstance['cell ) { if (row.isSelected) { updatedCellProps['aria-selected'] = 'true'; - updatedCellProps['aria-label'] += ` ${translatableTexts.unselectA11yText}`; + updatedCellProps['aria-describedby'] = ' ' + a11yElementIds.cellUnselectDescId; } else { updatedCellProps['aria-selected'] = 'false'; - updatedCellProps['aria-label'] += ` ${translatableTexts.selectA11yText}`; + updatedCellProps['aria-describedby'] = ' ' + a11yElementIds.cellSelectDescId; } } const { cellLabel } = cell.column; if (typeof cellLabel === 'function') { - cell.cellLabel = updatedCellProps['aria-label']; + cell.cellLabel = ''; updatedCellProps['aria-label'] = cellLabel({ cell, instance }); + } else { + updatedCellProps['aria-label'] ||= undefined; } return [cellProps, updatedCellProps]; }; @@ -108,6 +111,20 @@ const setHeaderProps = ( : translatableTexts.selectAllA11yText; } + if (column.id === '__ui5wcr__internal_selection_column') { + updatedProps['aria-label'] += translatableTexts.selectionHeaderCellText; + } + + if (column.id === '__ui5wcr__internal_highlight_column') { + updatedProps['aria-label'] += translatableTexts.highlightHeaderCellText; + } + + if (column.id === '__ui5wcr__internal_navigation_column') { + updatedProps['aria-label'] += translatableTexts.navigationHeaderCellText; + } + + updatedProps['aria-label'] ||= undefined; + return [headerProps, { isFiltered, ...updatedProps }]; }; diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index 2fc54505fcd..57f57b956bf 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -39,8 +39,10 @@ import { EXPAND_PRESS_SPACE, FILTERED, GROUPED, + HIGHLIGHT_COLUMN, INVALID_TABLE, LIST_NO_DATA, + NAVIGATION_COLUMN, NO_DATA_FILTERED, PLEASE_WAIT, ROW_COLLAPSED, @@ -48,6 +50,7 @@ import { SELECT_ALL, SELECT_ALL_PRESS_SPACE, SELECT_PRESS_SPACE, + SELECTION_COLUMN, UNSELECT_ALL_PRESS_SPACE, UNSELECT_PRESS_SPACE, } from '../../i18n/i18n-defaults.js'; @@ -199,8 +202,13 @@ const AnalyticalTable = forwardRef(null); const parentRef = useRef(null); @@ -237,13 +245,15 @@ const AnalyticalTable = forwardRef - {header && ( - - {header} - - )} - {extension &&
{extension}
} - +
- {loading && (!!rows.length || alwaysShowBusyIndicator) && ( - + {header && ( + + {header} + )} - {showOverlay && ( - <> - -
- - )} -
{extension}
} + -