@ui5/webcomponents-react
Version:
React Wrapper for UI5 Web Components and additional components
285 lines (280 loc) • 10.1 kB
JavaScript
import { useI18nBundle } from '@ui5/webcomponents-react-base';
import { useCallback, useEffect, useRef } from 'react';
import { INCLUDES_X, MOVE_TO_CONTENT_F2 } from '../../../i18n/i18n-defaults.js';
import { NAVIGATION_KEYS } from '../util/index.js';
const NON_STANDARD_INTERACTIVE_ELEMENTS = ['[ui5-checkbox]', '[ui5-switch]', '[ui5-radio-button]', '[ui5-rating-indicator]', '[ui5-segmented-button]', '[ui5-select]', '[ui5-slider]', '[ui5-split-button]', '[ui5-icon][mode="Interactive"]'];
/**
* A plugin hook that enables F2-based cell editing for interactive elements inside a cell.
*
* To __ensure the hook works correctly__, make sure that:
*
* - Each column containing interactive elements has the `interactiveElementName` property set. __Note:__ This property is also used to describe the cell's content for screen readers.
* - The callback Ref returned by `useF2CellEdit.useCallbackRef` is attached to every interactive element within the cell.
*
* 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.
*
* @example
* ```tsx
* import type {
* AnalyticalTableCellInstance,
* AnalyticalTableColumnDefinition,
* InputDomRef,
* AnalyticalTablePropTypes,
* } from '@ui5/webcomponents-react';
* import { AnalyticalTableHooks, AnalyticalTable, Input } from '@ui5/webcomponents-react';
*
* const columns: AnalyticalTableColumnDefinition[] = [
* {
* Header: 'Input',
* id: 'input',
* Cell: (props: AnalyticalTableCellInstance) => {
* const callbackRef = AnalyticalTableHooks.useF2CellEdit.useCallbackRef<InputDomRef>(props);
* return <Input ref={callbackRef} />;
* },
* interactiveElementName: 'Input',
* },
* ];
*
* const tableHooks: AnalyticalTablePropTypes['tableHooks'] = [AnalyticalTableHooks.useF2CellEdit];
*
* function TableWithInput() {
* return <AnalyticalTable data={data} columns={columns} tableHooks={tableHooks} />;
* }
* ```
*
* @since 2.14.0
*/
export const useF2CellEdit = hooks => {
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
const lastFocusedBodyRowRef = useRef(null);
const setCellProps = useCallback((props, {
cell,
instance
}) => {
const {
dispatch,
state,
webComponentsReactProperties
} = instance;
const {
tableRef
} = webComponentsReactProperties;
const {
interactiveElementName
} = cell.column;
const inputName = typeof interactiveElementName === 'function' ? interactiveElementName(cell) : interactiveElementName;
const ariaLabel = (interactiveElementName ? i18nBundle.getText(INCLUDES_X, inputName) : '') + ' ' + props['aria-label'];
const handleKeyDown = e => {
if (state.cellContentTabIndex === 0 && NAVIGATION_KEYS.has(e.key) && !e.key.includes('Arrow')) {
e.preventDefault();
}
if (e.key === 'F2') {
if (e.currentTarget === e.target && interactiveElementName) {
const interactiveElement = findFirstFocusableInside(e.target);
if (interactiveElement) {
dispatch({
type: 'CELL_CONTENT_TAB_INDEX',
payload: 0
});
e.currentTarget.tabIndex = -1;
requestAnimationFrame(() => {
interactiveElement.focus();
});
}
}
if (e.currentTarget !== e.target) {
dispatch({
type: 'CELL_CONTENT_TAB_INDEX',
payload: -1
});
e.currentTarget.tabIndex = 0;
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 = 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 = e => {
if (typeof props.onFocus === 'function') {
props.onFocus(e);
}
if (e.currentTarget !== e.target) {
dispatch({
type: 'CELL_CONTENT_TAB_INDEX',
payload: 0
});
} 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
}];
}, [i18nBundle]);
const setHeaderProps = useCallback((headerProps, {
instance
}) => {
const {
webComponentsReactProperties
} = instance;
const {
tableRef
} = webComponentsReactProperties;
// Tab on header cell -> focus same column body cell
const handleKeyDown = 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 = 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);
};
useF2CellEdit.pluginName = 'useF2CellEdit';
/**
* Returns a callback ref for a cell's interactive element, setting its `tabindex` based on the cell state.
*
* **Must be attached to every interactive element inside the cell!**
*
* @param props - The table cell props containing state.
*
* @example
* ```tsx
* Cell: (props: AnalyticalTableCellInstance) => {
* const callbackRef = useF2CellEdit.useCallbackRef(props);
* return <Input ref={callbackRef} />;
* },
* ```
*/
useF2CellEdit.useCallbackRef = props => {
const cellContentTabIndex = props.state.cellContentTabIndex === -1 ? '-1' : '0';
// eslint-disable-next-line react-hooks/rules-of-hooks
return useCallback(node => {
if (node) {
const setTabIndex = el => {
if (typeof el.getFocusDomRefAsync === 'function') {
void el.getFocusDomRefAsync().then(resolved => {
if (resolved && resolved !== el) {
setTabIndex(resolved);
} else {
el.setAttribute('tabindex', cellContentTabIndex);
}
}).catch(() => {
// fail silently
});
} else {
el.setAttribute('tabindex', cellContentTabIndex);
}
};
setTabIndex(node);
}
}, [cellContentTabIndex]);
};
const stateReducer = (state, action, _prevState) => {
const {
payload,
type
} = action;
if (type === 'CELL_CONTENT_TAB_INDEX') {
return {
...state,
cellContentTabIndex: payload
};
}
return state;
};
function findFirstFocusableInside(element) {
if (!element) return null;
function recursiveFindInteractiveElement(el) {
for (const child of el.children) {
const style = getComputedStyle(child);
if (child.disabled || style.display === 'none' || style.visibility === 'hidden') {
continue;
}
const focusableSelectors = ['a[href]', 'button', 'input', 'textarea', 'select', '[tabindex]:not([tabindex="-1"])', ...NON_STANDARD_INTERACTIVE_ELEMENTS];
if (child.matches(focusableSelectors.join(','))) {
return child;
}
if (child.shadowRoot) {
const shadowFocusable = recursiveFindInteractiveElement(child.shadowRoot);
if (shadowFocusable) return shadowFocusable;
}
const nestedFocusable = recursiveFindInteractiveElement(child);
if (nestedFocusable) return nestedFocusable;
}
return null;
}
return recursiveFindInteractiveElement(element);
}
/**
* Init `cellContentTabIndex` if the plugin hook is used.
*/
function useInstanceBeforeDimensions(instance) {
const {
dispatch
} = instance;
useEffect(() => {
dispatch({
type: 'CELL_CONTENT_TAB_INDEX',
payload: -1
});
}, [dispatch]);
}