Skip to content

Commit a7a35c8

Browse files
authored
feat(AnalyticalTable - useF2CellEdit): update to follow latest a11y specs (#8307)
1 parent 3e52380 commit a7a35c8

4 files changed

Lines changed: 141 additions & 15 deletions

File tree

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

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4590,26 +4590,71 @@ describe('AnalyticalTable', () => {
45904590
cy.focused().should('have.attr', 'data-column-index', '0');
45914591

45924592
cy.realPress('Tab');
4593-
cy.focused().should('have.text', 'After');
4593+
cy.log('Cell 1-0');
4594+
cy.focused().should('have.attr', 'data-row-index', '1');
4595+
cy.focused().should('have.attr', 'data-column-index', '0');
45944596

45954597
cy.realPress(['Shift', 'Tab']);
4596-
cy.log('Cell 0-0');
4598+
cy.log('Header 0-0');
45974599
cy.focused().should('have.attr', 'data-row-index', '0');
45984600
cy.focused().should('have.attr', 'data-column-index', '0');
45994601

4602+
cy.realPress('Tab');
46004603
cy.log('Cell 1-0');
4601-
cy.realPress('ArrowDown');
46024604
cy.focused().should('have.attr', 'data-row-index', '1');
46034605
cy.focused().should('have.attr', 'data-column-index', '0');
46044606

4607+
cy.realPress('ArrowDown');
4608+
cy.realPress('ArrowDown');
4609+
cy.realPress('ArrowRight');
4610+
cy.realPress('ArrowRight');
4611+
cy.log('Cell 3-2');
4612+
cy.focused().should('have.attr', 'data-row-index', '3');
4613+
cy.focused().should('have.attr', 'data-column-index', '2');
4614+
4615+
cy.realPress(['Shift', 'Tab']);
4616+
cy.log('Header 0-2');
4617+
cy.focused().should('have.attr', 'data-row-index', '0');
4618+
cy.focused().should('have.attr', 'data-column-index', '2');
4619+
46054620
cy.realPress('Tab');
4606-
cy.focused().should('have.text', 'After');
4621+
cy.log('Cell 3-2');
4622+
cy.focused().should('have.attr', 'data-row-index', '3');
4623+
cy.focused().should('have.attr', 'data-column-index', '2');
4624+
4625+
cy.realPress('Home');
4626+
cy.log('Cell 3-0');
4627+
cy.focused().should('have.attr', 'data-row-index', '3');
4628+
cy.focused().should('have.attr', 'data-column-index', '0');
46074629

46084630
cy.realPress(['Shift', 'Tab']);
4609-
cy.log('Cell 1-0');
4610-
cy.focused().should('have.attr', 'data-row-index', '1');
4631+
cy.log('Header 0-0');
4632+
cy.focused().should('have.attr', 'data-row-index', '0');
46114633
cy.focused().should('have.attr', 'data-column-index', '0');
46124634

4635+
cy.realPress('ArrowRight');
4636+
cy.realPress('ArrowRight');
4637+
cy.realPress('ArrowRight');
4638+
cy.log('Header 0-3');
4639+
cy.focused().should('have.attr', 'data-row-index', '0');
4640+
cy.focused().should('have.attr', 'data-column-index', '3');
4641+
4642+
cy.realPress('Tab');
4643+
cy.log('Cell 3-3');
4644+
cy.focused().should('have.attr', 'data-row-index', '3');
4645+
cy.focused().should('have.attr', 'data-column-index', '3');
4646+
4647+
cy.realPress('Tab');
4648+
cy.focused().should('have.text', 'After');
4649+
4650+
cy.realPress(['Shift', 'Tab']);
4651+
cy.log('Cell 3-3');
4652+
cy.focused().should('have.attr', 'data-row-index', '3');
4653+
cy.focused().should('have.attr', 'data-column-index', '3');
4654+
4655+
cy.realPress('Home');
4656+
cy.realPress('PageUp');
4657+
cy.log('Cell 1-0');
46134658
cy.realPress('F2');
46144659
cy.log('Input 1-0');
46154660
cy.focused().should('have.attr', 'type', 'text');
@@ -4686,6 +4731,9 @@ describe('AnalyticalTable', () => {
46864731
cy.focused().should('have.attr', 'data-row-index', '0');
46874732
cy.focused().should('have.attr', 'data-column-index', '0');
46884733
cy.realPress('Tab');
4734+
cy.focused().should('have.attr', 'data-row-index', '1');
4735+
cy.focused().should('have.attr', 'data-column-index', '0');
4736+
cy.realPress('Tab');
46894737
cy.focused().should('have.text', 'After');
46904738
cy.realPress(['Shift', 'Tab']);
46914739
cy.realPress('ArrowDown');

packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ To **ensure the hook works correctly**, make sure that:
2121
The hook manages focus, keyboard navigation, and `tabindex` for cells with interactive content:
2222

2323
- Pressing `F2` moves focus between the cell container and its first interactive element.
24+
- Pressing `Tab` on a focused header cell moves focus to the body cell in the same column at the last focused body row (or the first row if none was focused).
25+
- Pressing `Shift+Tab` on a focused body cell moves focus back to the header cell of the same column.
2426
- Updates the cell's `aria-label` with the interactive element's name for accessibility.
2527
- Prevents standard navigation keys from interfering when editing a cell.
2628

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,19 +109,20 @@ const measureElement = (el: HTMLElement) => {
109109
* The `AnalyticalTable` provides a set of convenient functions for responsive table design, including virtualization of rows and columns, infinite scrolling and customizable columns that will, unless otherwise defined, distribute the available space equally among themselves.
110110
* It also provides several possibilities for working with the data, including sorting, filtering, grouping and aggregation.
111111
*
112-
* __Note:__ The `AnalyticalTable` has some limitations and includes features that do not have a defined design specification.
113-
* To follow UXC guidelines, please refer to the table below:
112+
* __Note:__ Some features listed below have technical limitations, lack a defined design specification, or should be enabled by default to align with UXC guidelines:
114113
*
115114
*| Function / Feature | Reason |
116115
*|---------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
117-
*| No sticky columns/rows | Not supported due to technical limitations. |
116+
*| `useF2CellEdit` plugin hook | To mimic the `sap.ui.table` edit-mode and tabbing behavior, use the `useF2CellEdit` plugin hook. |
117+
*| No sticky columns/rows | Not supported due to technical limitations. |
118118
*| Pop-in behavior | The `sap.ui.table` doesn’t support pop-in behavior (unlike `sap.m.Table`); it’s unclear whether this should be part of the design. |
119119
*| `visibleRowCountMode: "Auto"` | `"AutoWithEmptyRows"` is preferred. `"Auto"` mode can lead to inconsistent table heights depending on the container. |
120120
*| `alwaysShowBusyIndicator` | Should generally be `true`, only if loading times are over 1 second, the default skeleton loading indicator is sufficient: [Fiori Skeleton Loading](https://www.sap.com/design-system/fiori-design-ios/ui-elements/patterns/skeleton-loading/?external). |
121121
*| `scaleWidthMode` | Only the default mode is available out of the box for the `sap.m.Table`; similar behavior to the `"Grow"` mode can be achieved in `sap.ui.table` using `autoResizeColumn`. |
122-
*| `renderRowSubComponent` | There is no design/UX concept for this functionality. |
123-
*| `useIndeterminateRowSelection` | There is no design/UX concept for this functionality. |
122+
*| `renderRowSubComponent` | There is no design/UX concept for this functionality. |
123+
*| `useIndeterminateRowSelection` | There is no design/UX concept for this functionality. |
124124
*| `useRowDisableSelection` (deprecated) | Table rows should not be disabled. |
125+
*
125126
*/
126127
const AnalyticalTable = forwardRef<AnalyticalTableDomRef, AnalyticalTablePropTypes>((props, ref) => {
127128
const {

packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useI18nBundle } from '@ui5/webcomponents-react-base';
22
import type { Ui5DomRef } from '@ui5/webcomponents-react-base';
33
import type { FocusEventHandler, KeyboardEventHandler } from 'react';
4-
import { useCallback, useEffect } from 'react';
5-
import { INCLUDES_X } from '../../../i18n/i18n-defaults.js';
6-
import type { CellInstance, CellType, ReactTableHooks, TableInstance } from '../types/index.js';
4+
import { useCallback, useEffect, useRef } from 'react';
5+
import { INCLUDES_X, MOVE_TO_CONTENT_F2 } from '../../../i18n/i18n-defaults.js';
6+
import type { CellInstance, CellType, ColumnType, ReactTableHooks, TableInstance } from '../types/index.js';
77
import { NAVIGATION_KEYS } from '../util/index.js';
88

99
const NON_STANDARD_INTERACTIVE_ELEMENTS = [
@@ -28,6 +28,8 @@ const NON_STANDARD_INTERACTIVE_ELEMENTS = [
2828
*
2929
* It manages focus, keyboard navigation, and `tabindex` for cells with interactive content:
3030
* - Pressing `F2` moves focus between the cell container and its first interactive element.
31+
* - Pressing `Tab` on a focused header cell moves focus to the body cell in the same column at the last focused body row (or the first row if none was focused).
32+
* - Pressing `Shift+Tab` on a focused body cell moves focus back to the header cell of the same column.
3133
* - Updates the cell's `aria-label` with the interactive element's name for accessibility.
3234
* - Prevents standard navigation keys from interfering when editing a cell.
3335
*
@@ -64,10 +66,12 @@ const NON_STANDARD_INTERACTIVE_ELEMENTS = [
6466
*/
6567
export const useF2CellEdit = (hooks: ReactTableHooks) => {
6668
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
69+
const lastFocusedBodyRowRef = useRef<number | null>(null);
6770

6871
const setCellProps = useCallback(
6972
(props, { cell, instance }: { cell: CellType; instance: TableInstance }) => {
70-
const { dispatch, state } = instance;
73+
const { dispatch, state, webComponentsReactProperties } = instance;
74+
const { tableRef } = webComponentsReactProperties;
7175
const { interactiveElementName } = cell.column;
7276
const inputName =
7377
typeof interactiveElementName === 'function' ? interactiveElementName(cell) : interactiveElementName;
@@ -96,6 +100,25 @@ export const useF2CellEdit = (hooks: ReactTableHooks) => {
96100
e.currentTarget.focus();
97101
}
98102
}
103+
104+
// Shift+Tab on body cell -> focus same column header cell
105+
if (e.key === 'Tab' && e.shiftKey && e.currentTarget === e.target) {
106+
const rowIndex = parseInt(e.currentTarget.dataset.rowIndex, 10);
107+
const columnIndex = parseInt(e.currentTarget.dataset.columnIndex, 10);
108+
109+
if (rowIndex > 0) {
110+
lastFocusedBodyRowRef.current = rowIndex;
111+
const headerCell: HTMLElement | null = tableRef.current.querySelector(
112+
`div[data-column-index="${columnIndex}"][data-row-index="0"]`,
113+
);
114+
if (headerCell) {
115+
e.preventDefault();
116+
e.currentTarget.tabIndex = -1;
117+
headerCell.tabIndex = 0;
118+
headerCell.focus();
119+
}
120+
}
121+
}
99122
};
100123

101124
const handleFocus: FocusEventHandler<HTMLDivElement> = (e) => {
@@ -108,14 +131,66 @@ export const useF2CellEdit = (hooks: ReactTableHooks) => {
108131
} else {
109132
dispatch({ type: 'CELL_CONTENT_TAB_INDEX', payload: -1 });
110133
}
134+
135+
const rowIndex = parseInt(e.currentTarget.dataset.rowIndex, 10);
136+
if (rowIndex > 0) {
137+
lastFocusedBodyRowRef.current = rowIndex;
138+
}
111139
};
112140

113141
return [props, { onKeyDown: handleKeyDown, onFocus: handleFocus, 'aria-label': ariaLabel }];
114142
},
115143
[i18nBundle],
116144
);
117145

146+
const setHeaderProps = useCallback((headerProps, { instance }: { instance: TableInstance; column: ColumnType }) => {
147+
const { webComponentsReactProperties } = instance;
148+
const { tableRef } = webComponentsReactProperties;
149+
150+
// Tab on header cell -> focus same column body cell
151+
const handleKeyDown: KeyboardEventHandler<HTMLElement> = (e) => {
152+
if (typeof headerProps.onKeyDown === 'function') {
153+
headerProps.onKeyDown(e);
154+
}
155+
156+
if (e.key === 'Tab' && !e.shiftKey && e.currentTarget === e.target) {
157+
const columnIndex = parseInt(e.currentTarget.dataset.columnIndex, 10);
158+
const targetRowIndex = lastFocusedBodyRowRef.current ?? 1;
159+
let targetCell: HTMLElement | null = tableRef.current.querySelector(
160+
`div[data-column-index="${columnIndex}"][data-row-index="${targetRowIndex}"]`,
161+
);
162+
if (!targetCell) {
163+
targetCell = tableRef.current.querySelector(
164+
`div[data-column-index="${columnIndex}"][data-visible-row-index="1"]`,
165+
);
166+
}
167+
if (targetCell) {
168+
e.preventDefault();
169+
e.currentTarget.tabIndex = -1;
170+
targetCell.tabIndex = 0;
171+
targetCell.focus();
172+
targetCell.scrollIntoView({ block: 'nearest' });
173+
}
174+
}
175+
};
176+
177+
return [headerProps, { onKeyDown: handleKeyDown }];
178+
}, []);
179+
180+
const setTableProps = useCallback(
181+
(tableProps) => {
182+
const f2Description = i18nBundle.getText(MOVE_TO_CONTENT_F2);
183+
const existingDescription = tableProps['aria-description'];
184+
const ariaDescription = existingDescription ? `${existingDescription} ${f2Description}` : f2Description;
185+
186+
return [tableProps, { 'aria-description': ariaDescription }];
187+
},
188+
[i18nBundle],
189+
);
190+
191+
hooks.getTableProps.push(setTableProps);
118192
hooks.getCellProps.push(setCellProps);
193+
hooks.getHeaderProps.push(setHeaderProps);
119194
hooks.stateReducers.push(stateReducer);
120195
hooks.useInstanceBeforeDimensions.push(useInstanceBeforeDimensions);
121196
};

0 commit comments

Comments
 (0)