diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 87636bdcc8c..229beaafb99 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -4517,26 +4517,71 @@ describe('AnalyticalTable', () => { cy.focused().should('have.attr', 'data-column-index', '0'); cy.realPress('Tab'); - cy.focused().should('have.text', 'After'); + cy.log('Cell 1-0'); + cy.focused().should('have.attr', 'data-row-index', '1'); + cy.focused().should('have.attr', 'data-column-index', '0'); cy.realPress(['Shift', 'Tab']); - cy.log('Cell 0-0'); + cy.log('Header 0-0'); cy.focused().should('have.attr', 'data-row-index', '0'); cy.focused().should('have.attr', 'data-column-index', '0'); + cy.realPress('Tab'); cy.log('Cell 1-0'); - cy.realPress('ArrowDown'); cy.focused().should('have.attr', 'data-row-index', '1'); cy.focused().should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); + cy.realPress('ArrowRight'); + cy.realPress('ArrowRight'); + cy.log('Cell 3-2'); + cy.focused().should('have.attr', 'data-row-index', '3'); + cy.focused().should('have.attr', 'data-column-index', '2'); + + cy.realPress(['Shift', 'Tab']); + cy.log('Header 0-2'); + cy.focused().should('have.attr', 'data-row-index', '0'); + cy.focused().should('have.attr', 'data-column-index', '2'); + cy.realPress('Tab'); - cy.focused().should('have.text', 'After'); + cy.log('Cell 3-2'); + cy.focused().should('have.attr', 'data-row-index', '3'); + cy.focused().should('have.attr', 'data-column-index', '2'); + + cy.realPress('Home'); + cy.log('Cell 3-0'); + cy.focused().should('have.attr', 'data-row-index', '3'); + cy.focused().should('have.attr', 'data-column-index', '0'); cy.realPress(['Shift', 'Tab']); - cy.log('Cell 1-0'); - cy.focused().should('have.attr', 'data-row-index', '1'); + cy.log('Header 0-0'); + cy.focused().should('have.attr', 'data-row-index', '0'); cy.focused().should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowRight'); + cy.realPress('ArrowRight'); + cy.realPress('ArrowRight'); + cy.log('Header 0-3'); + cy.focused().should('have.attr', 'data-row-index', '0'); + cy.focused().should('have.attr', 'data-column-index', '3'); + + cy.realPress('Tab'); + cy.log('Cell 3-3'); + cy.focused().should('have.attr', 'data-row-index', '3'); + cy.focused().should('have.attr', 'data-column-index', '3'); + + cy.realPress('Tab'); + cy.focused().should('have.text', 'After'); + + cy.realPress(['Shift', 'Tab']); + cy.log('Cell 3-3'); + cy.focused().should('have.attr', 'data-row-index', '3'); + cy.focused().should('have.attr', 'data-column-index', '3'); + + cy.realPress('Home'); + cy.realPress('PageUp'); + cy.log('Cell 1-0'); cy.realPress('F2'); cy.log('Input 1-0'); cy.focused().should('have.attr', 'type', 'text'); @@ -4613,6 +4658,9 @@ describe('AnalyticalTable', () => { cy.focused().should('have.attr', 'data-row-index', '0'); cy.focused().should('have.attr', 'data-column-index', '0'); cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-row-index', '1'); + cy.focused().should('have.attr', 'data-column-index', '0'); + cy.realPress('Tab'); cy.focused().should('have.text', 'After'); cy.realPress(['Shift', 'Tab']); cy.realPress('ArrowDown'); diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx index 798fd102510..9ff8f84391a 100644 --- a/packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx +++ b/packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx @@ -21,6 +21,8 @@ To **ensure the hook works correctly**, make sure that: The hook manages focus, keyboard navigation, and `tabindex` for cells with interactive content: - Pressing `F2` moves focus between the cell container and its first interactive element. +- 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). +- Pressing `Shift+Tab` on a focused body cell moves focus back to the header cell of the same column. - Updates the cell's `aria-label` with the interactive element's name for accessibility. - Prevents standard navigation keys from interfering when editing a cell. diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index 8e9e86b4073..4c48440ffd5 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -118,19 +118,20 @@ const measureElement = (el: HTMLElement) => { * 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. * It also provides several possibilities for working with the data, including sorting, filtering, grouping and aggregation. * - * __Note:__ The `AnalyticalTable` has some limitations and includes features that do not have a defined design specification. - * To follow UXC guidelines, please refer to the table below: + * __Note:__ Some features listed below have technical limitations, lack a defined design specification, or should be enabled by default to align with UXC guidelines: * *| Function / Feature | Reason | *|---------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| - *| No sticky columns/rows | Not supported due to technical limitations. | + *| `useF2CellEdit` plugin hook | To mimic the `sap.ui.table` edit-mode and tabbing behavior, use the `useF2CellEdit` plugin hook. | + *| No sticky columns/rows | Not supported due to technical limitations. | *| 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. | *| `visibleRowCountMode: "Auto"` | `"AutoWithEmptyRows"` is preferred. `"Auto"` mode can lead to inconsistent table heights depending on the container. | *| `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). | *| `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`. | - *| `renderRowSubComponent` | There is no design/UX concept for this functionality. | - *| `useIndeterminateRowSelection` | There is no design/UX concept for this functionality. | + *| `renderRowSubComponent` | There is no design/UX concept for this functionality. | + *| `useIndeterminateRowSelection` | There is no design/UX concept for this functionality. | *| `useRowDisableSelection` (deprecated) | Table rows should not be disabled. | + * */ const AnalyticalTable = forwardRef((props, ref) => { const { diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts b/packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts index 6116700facf..ad566b3a877 100644 --- a/packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts +++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts @@ -1,9 +1,9 @@ import { useI18nBundle } from '@ui5/webcomponents-react-base'; import type { Ui5DomRef } from '@ui5/webcomponents-react-base'; import type { FocusEventHandler, KeyboardEventHandler } from 'react'; -import { useCallback, useEffect } from 'react'; -import { INCLUDES_X } from '../../../i18n/i18n-defaults.js'; -import type { CellInstance, CellType, ReactTableHooks, TableInstance } from '../types/index.js'; +import { useCallback, useEffect, useRef } from 'react'; +import { INCLUDES_X, MOVE_TO_CONTENT_F2 } from '../../../i18n/i18n-defaults.js'; +import type { CellInstance, CellType, ColumnType, ReactTableHooks, TableInstance } from '../types/index.js'; import { NAVIGATION_KEYS } from '../util/index.js'; const NON_STANDARD_INTERACTIVE_ELEMENTS = [ @@ -28,6 +28,8 @@ const NON_STANDARD_INTERACTIVE_ELEMENTS = [ * * It manages focus, keyboard navigation, and `tabindex` for cells with interactive content: * - Pressing `F2` moves focus between the cell container and its first interactive element. + * - 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). + * - Pressing `Shift+Tab` on a focused body cell moves focus back to the header cell of the same column. * - Updates the cell's `aria-label` with the interactive element's name for accessibility. * - Prevents standard navigation keys from interfering when editing a cell. * @@ -64,10 +66,12 @@ const NON_STANDARD_INTERACTIVE_ELEMENTS = [ */ export const useF2CellEdit = (hooks: ReactTableHooks) => { const i18nBundle = useI18nBundle('@ui5/webcomponents-react'); + const lastFocusedBodyRowRef = useRef(null); const setCellProps = useCallback( (props, { cell, instance }: { cell: CellType; instance: TableInstance }) => { - const { dispatch, state } = instance; + const { dispatch, state, webComponentsReactProperties } = instance; + const { tableRef } = webComponentsReactProperties; const { interactiveElementName } = cell.column; const inputName = typeof interactiveElementName === 'function' ? interactiveElementName(cell) : interactiveElementName; @@ -96,6 +100,25 @@ export const useF2CellEdit = (hooks: ReactTableHooks) => { e.currentTarget.focus(); } } + + // Shift+Tab on body cell -> focus same column header cell + if (e.key === 'Tab' && e.shiftKey && e.currentTarget === e.target) { + const rowIndex = parseInt(e.currentTarget.dataset.rowIndex, 10); + const columnIndex = parseInt(e.currentTarget.dataset.columnIndex, 10); + + if (rowIndex > 0) { + lastFocusedBodyRowRef.current = rowIndex; + const headerCell: HTMLElement | null = tableRef.current.querySelector( + `div[data-column-index="${columnIndex}"][data-row-index="0"]`, + ); + if (headerCell) { + e.preventDefault(); + e.currentTarget.tabIndex = -1; + headerCell.tabIndex = 0; + headerCell.focus(); + } + } + } }; const handleFocus: FocusEventHandler = (e) => { @@ -108,6 +131,11 @@ export const useF2CellEdit = (hooks: ReactTableHooks) => { } else { dispatch({ type: 'CELL_CONTENT_TAB_INDEX', payload: -1 }); } + + const rowIndex = parseInt(e.currentTarget.dataset.rowIndex, 10); + if (rowIndex > 0) { + lastFocusedBodyRowRef.current = rowIndex; + } }; return [props, { onKeyDown: handleKeyDown, onFocus: handleFocus, 'aria-label': ariaLabel }]; @@ -115,7 +143,54 @@ export const useF2CellEdit = (hooks: ReactTableHooks) => { [i18nBundle], ); + const setHeaderProps = useCallback((headerProps, { instance }: { instance: TableInstance; column: ColumnType }) => { + const { webComponentsReactProperties } = instance; + const { tableRef } = webComponentsReactProperties; + + // Tab on header cell -> focus same column body cell + const handleKeyDown: KeyboardEventHandler = (e) => { + if (typeof headerProps.onKeyDown === 'function') { + headerProps.onKeyDown(e); + } + + if (e.key === 'Tab' && !e.shiftKey && e.currentTarget === e.target) { + const columnIndex = parseInt(e.currentTarget.dataset.columnIndex, 10); + const targetRowIndex = lastFocusedBodyRowRef.current ?? 1; + let targetCell: HTMLElement | null = tableRef.current.querySelector( + `div[data-column-index="${columnIndex}"][data-row-index="${targetRowIndex}"]`, + ); + if (!targetCell) { + targetCell = tableRef.current.querySelector( + `div[data-column-index="${columnIndex}"][data-visible-row-index="1"]`, + ); + } + if (targetCell) { + e.preventDefault(); + e.currentTarget.tabIndex = -1; + targetCell.tabIndex = 0; + targetCell.focus(); + targetCell.scrollIntoView({ block: 'nearest' }); + } + } + }; + + return [headerProps, { onKeyDown: handleKeyDown }]; + }, []); + + const setTableProps = useCallback( + (tableProps) => { + const f2Description = i18nBundle.getText(MOVE_TO_CONTENT_F2); + const existingDescription = tableProps['aria-description']; + const ariaDescription = existingDescription ? `${existingDescription} ${f2Description}` : f2Description; + + return [tableProps, { 'aria-description': ariaDescription }]; + }, + [i18nBundle], + ); + + hooks.getTableProps.push(setTableProps); hooks.getCellProps.push(setCellProps); + hooks.getHeaderProps.push(setHeaderProps); hooks.stateReducers.push(stateReducer); hooks.useInstanceBeforeDimensions.push(useInstanceBeforeDimensions); };