Skip to content

Commit 8e1c001

Browse files
committed
feat(AnalyticalTable): introduce useF2CellEdit plugin hook
1 parent 96baa0a commit 8e1c001

File tree

5 files changed

+169
-11
lines changed

5 files changed

+169
-11
lines changed

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

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import type { KeyboardEventHandler } from 'react';
12
import { useCallback, useEffect, useRef } from 'react';
23
import { actions } from 'react-table';
34
import type { ColumnType, ReactTableHooks, TableInstance } from '../types/index.js';
45
import { getLeafHeaders } from '../util/index.js';
56

67
const CELL_DATA_ATTRIBUTES = ['visibleColumnIndex', 'columnIndex', 'rowIndex', 'visibleRowIndex'];
8+
const NAVIGATION_KEYS = new Set([
9+
'End',
10+
'Home',
11+
'PageDown',
12+
'PageUp',
13+
'ArrowRight',
14+
'ArrowLeft',
15+
'ArrowDown',
16+
'ArrowUp',
17+
]);
718

819
const getFirstVisibleCell = (target, currentlyFocusedCell, noData) => {
920
if (
@@ -175,9 +186,13 @@ const useGetTableProps = (
175186
currentlyFocusedCell.current.dataset.rowIndex ?? currentlyFocusedCell.current.dataset.subcomponentRowIndex,
176187
10,
177188
);
189+
190+
if (NAVIGATION_KEYS.has(e.key)) {
191+
e.preventDefault();
192+
}
193+
178194
switch (e.key) {
179195
case 'End': {
180-
e.preventDefault();
181196
const visibleColumns = tableRef.current.querySelector(
182197
`div[data-component-name="AnalyticalTableHeaderRow"]`,
183198
).children;
@@ -200,15 +215,13 @@ const useGetTableProps = (
200215
break;
201216
}
202217
case 'Home': {
203-
e.preventDefault();
204218
const newElement = tableRef.current.querySelector(
205219
`div[data-visible-column-index="0"][data-row-index="${rowIndex}"]`,
206220
);
207221
setFocus(currentlyFocusedCell, newElement);
208222
break;
209223
}
210224
case 'PageDown': {
211-
e.preventDefault();
212225
if (currentlyFocusedCell.current.dataset.rowIndex === '0') {
213226
const newElement = tableRef.current.querySelector(
214227
`div[data-column-index="${columnIndex}"][data-row-index="${rowIndex + 1}"]`,
@@ -225,7 +238,6 @@ const useGetTableProps = (
225238
break;
226239
}
227240
case 'PageUp': {
228-
e.preventDefault();
229241
if (currentlyFocusedCell.current.dataset.rowIndex <= '1') {
230242
const newElement = tableRef.current.querySelector(
231243
`div[data-column-index="${columnIndex}"][data-row-index="0"]`,
@@ -240,7 +252,6 @@ const useGetTableProps = (
240252
break;
241253
}
242254
case 'ArrowRight': {
243-
e.preventDefault();
244255
if (isActiveItemInSubComponent) {
245256
navigateFromActiveSubCompItem(currentlyFocusedCell, e);
246257
return;
@@ -256,7 +267,6 @@ const useGetTableProps = (
256267
break;
257268
}
258269
case 'ArrowLeft': {
259-
e.preventDefault();
260270
if (isActiveItemInSubComponent) {
261271
navigateFromActiveSubCompItem(currentlyFocusedCell, e);
262272
return;
@@ -272,7 +282,6 @@ const useGetTableProps = (
272282
break;
273283
}
274284
case 'ArrowDown': {
275-
e.preventDefault();
276285
if (isActiveItemInSubComponent) {
277286
navigateFromActiveSubCompItem(currentlyFocusedCell, e);
278287
return;
@@ -296,7 +305,6 @@ const useGetTableProps = (
296305
break;
297306
}
298307
case 'ArrowUp': {
299-
e.preventDefault();
300308
if (isActiveItemInSubComponent) {
301309
navigateFromActiveSubCompItem(currentlyFocusedCell, e);
302310
return;
@@ -332,11 +340,25 @@ const useGetTableProps = (
332340
if (showOverlay) {
333341
return tableProps;
334342
}
343+
344+
// keyboard nav is only enabled if the table is not in edit mode
345+
const handleEditModeKeyDown: KeyboardEventHandler<HTMLDivElement> = (e) => {
346+
if (typeof tableProps.onKeyDown === 'function') {
347+
tableProps.onKeyDown(e);
348+
}
349+
if (NAVIGATION_KEYS.has(e.key)) {
350+
e.preventDefault();
351+
}
352+
};
353+
335354
return [
336355
tableProps,
337356
{
338357
onFocus: onTableFocus,
339-
onKeyDown: onKeyboardNavigation,
358+
onKeyDown:
359+
state.cellContentTabIndex === -1 || state.cellContentTabIndex == null
360+
? onKeyboardNavigation
361+
: handleEditModeKeyDown,
340362
onBlur: onTableBlur,
341363
},
342364
];

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useAnnounceEmptyCells } from './useAnnounceEmptyCells.js';
2+
import { useF2CellEdit } from './useF2CellEdit.js';
23
import { useIndeterminateRowSelection } from './useIndeterminateRowSelection.js';
34
import { useManualRowSelect } from './useManualRowSelect.js';
45
import { useOnColumnResize } from './useOnColumnResize.js';
@@ -7,6 +8,7 @@ import { useRowDisableSelection } from './useRowDisableSelection.js';
78

89
export {
910
useAnnounceEmptyCells,
11+
useF2CellEdit,
1012
useIndeterminateRowSelection,
1113
useManualRowSelect,
1214
useOnColumnResize,
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { Ui5DomRef } from '@ui5/webcomponents-react-base';
2+
import { useI18nBundle } from '@ui5/webcomponents-react-base';
3+
import type { FocusEventHandler, KeyboardEventHandler } from 'react';
4+
import { useCallback } from 'react';
5+
import { INCLUDES_X } from '../../../i18n/i18n-defaults.js';
6+
import type { ReactTableHooks, TableInstance } from '../types/index.js';
7+
8+
//todo: how to export
9+
export const useEditModeCallbackRef = (props) => {
10+
const cellContentTabIndex =
11+
props.state.cellContentTabIndex === -1 || props.state.cellContentTabIndex === undefined ? '-1' : '0';
12+
return useCallback(
13+
(node: HTMLElement) => {
14+
if (node) {
15+
if (node.tagName.startsWith('UI5')) {
16+
void (node as Ui5DomRef)
17+
.getFocusDomRefAsync()
18+
.then((focusNode) => focusNode.setAttribute('tabindex', cellContentTabIndex))
19+
.catch(() => {
20+
// fail silently
21+
});
22+
} else {
23+
node.setAttribute('tabindex', cellContentTabIndex);
24+
}
25+
}
26+
},
27+
[cellContentTabIndex],
28+
);
29+
};
30+
31+
//todo: memoize everything - getCellProps is called often
32+
export const useF2CellEdit = (hooks: ReactTableHooks) => {
33+
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
34+
35+
const setCellProps = useCallback(
36+
(props, { cell, instance }: { cell: Record<string, any>; instance: TableInstance }) => {
37+
const { dispatch } = instance;
38+
const { interactiveElementName } = cell.column;
39+
const inputName =
40+
typeof interactiveElementName === 'function' ? interactiveElementName(cell) : interactiveElementName;
41+
const ariaLabel =
42+
(interactiveElementName ? i18nBundle.getText(INCLUDES_X, inputName) : '') + ' ' + props['aria-label'];
43+
44+
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (e) => {
45+
if (e.key === 'F2') {
46+
e.preventDefault();
47+
if (e.currentTarget === e.target && interactiveElementName) {
48+
const interactiveElement = findFirstFocusableInside(e.target as HTMLElement);
49+
if (interactiveElement) {
50+
dispatch({ type: 'CELL_CONTENT_TAB_INDEX', payload: 0 });
51+
e.currentTarget.tabIndex = -1;
52+
requestAnimationFrame(() => {
53+
interactiveElement.focus();
54+
});
55+
}
56+
}
57+
if (e.currentTarget !== e.target) {
58+
dispatch({ type: 'CELL_CONTENT_TAB_INDEX', payload: -1 });
59+
e.currentTarget.tabIndex = 0;
60+
e.currentTarget.focus();
61+
}
62+
}
63+
};
64+
65+
const handleFocus: FocusEventHandler<HTMLDivElement> = (e) => {
66+
if (typeof props.onFocus === 'function') {
67+
props.onFocus(e);
68+
}
69+
70+
if (e.currentTarget !== e.target) {
71+
dispatch({ type: 'CELL_CONTENT_TAB_INDEX', payload: 0 });
72+
} else {
73+
dispatch({ type: 'CELL_CONTENT_TAB_INDEX', payload: -1 });
74+
}
75+
};
76+
77+
return [props, { onKeyDown: handleKeyDown, onFocus: handleFocus, 'aria-label': ariaLabel }];
78+
},
79+
[i18nBundle],
80+
);
81+
82+
hooks.getCellProps.push(setCellProps);
83+
hooks.stateReducers.push(stateReducer);
84+
};
85+
useF2CellEdit.pluginName = 'useF2CellEdit';
86+
87+
const stateReducer: TableInstance['stateReducer'] = (state, action, _prevState) => {
88+
const { payload, type } = action;
89+
90+
if (type === 'CELL_CONTENT_TAB_INDEX') {
91+
return { ...state, cellContentTabIndex: payload };
92+
}
93+
return state;
94+
};
95+
96+
function findFirstFocusableInside(element: HTMLElement) {
97+
if (!element) return null;
98+
99+
function recursiveFindInteractiveElement(el) {
100+
for (const child of el.children) {
101+
const style = getComputedStyle(child);
102+
if (child.disabled || style.display === 'none' || style.visibility === 'hidden') {
103+
continue;
104+
}
105+
106+
const focusableSelectors = [
107+
'a[href]',
108+
'button',
109+
'input',
110+
'textarea',
111+
'select',
112+
'[tabindex]:not([tabindex="-1"])',
113+
];
114+
115+
if (child.matches(focusableSelectors.join(','))) {
116+
return child;
117+
}
118+
119+
if (child.shadowRoot) {
120+
const shadowFocusable = recursiveFindInteractiveElement(child.shadowRoot);
121+
if (shadowFocusable) return shadowFocusable;
122+
}
123+
124+
const nestedFocusable = recursiveFindInteractiveElement(child);
125+
if (nestedFocusable) return nestedFocusable;
126+
}
127+
return null;
128+
}
129+
130+
return recursiveFindInteractiveElement(element);
131+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export interface TableInstance {
103103
disableSortBy?: boolean;
104104
dispatch?: (action: {
105105
type: string;
106-
payload?: Record<string, unknown> | AnalyticalTableState['popInColumns'] | boolean | string;
106+
payload?: Record<string, unknown> | AnalyticalTableState['popInColumns'] | boolean | string | number;
107107
clientX?: number;
108108
}) => void;
109109
expandedDepth?: number;
@@ -319,6 +319,7 @@ export interface AnalyticalTableState {
319319
interactiveRowsHavePopIn?: boolean;
320320
tableColResized?: true;
321321
triggerScroll?: TriggerScrollState;
322+
cellContentTabIndex?: number;
322323
}
323324

324325
interface Filter {

packages/main/src/i18n/messagebundle.properties

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,9 @@ HAS_DETAILS=Has Details
378378
#XACT: Message Item counter label
379379
COUNTER=Counter
380380

381-
#ACT: Screen reader announcement text for selection in the SelectDialog component when multi-selection mode is active. The placeholder represents a number.
381+
#XACT: Screen reader announcement text for selection in the SelectDialog component when multi-selection mode is active. The placeholder represents a number.
382382
SELECTED_ITEMS=Selected Items {0}
383383

384+
#XACT: Screen reader announcement for table cell that includes interactive element/s. The placeholder is the name of the element (e.g. "Input").
385+
INCLUDES_X=Includes {0}
384386

0 commit comments

Comments
 (0)