diff --git a/changelogs/upcoming/7448.md b/changelogs/upcoming/7448.md
new file mode 100644
index 00000000000..ac918fe177b
--- /dev/null
+++ b/changelogs/upcoming/7448.md
@@ -0,0 +1,7 @@
+**Accessibility**
+
+- `EuiDataGrid`'s keyboard/screenreader experience has been tweaked to be more consistent for varying complex data:
+ - Headers are now always navigable by arrow key, regardless of whether the header cells contain interactive content
+ - Non-expandable cells containing any amount of interactive content now must be entered via Enter or F2 keypress
+ - Expandable cells continue to be toggled via Enter or F2 keypress
+- `EuiDataGrid` now provides a direct screen reader hint for Enter key behavior for expandable & interactive cells
diff --git a/src-docs/src/views/datagrid/cells_popovers/datagrid_cells_example.js b/src-docs/src/views/datagrid/cells_popovers/datagrid_cells_example.js
index 24a812a4237..afa8a3bc6db 100644
--- a/src-docs/src/views/datagrid/cells_popovers/datagrid_cells_example.js
+++ b/src-docs/src/views/datagrid/cells_popovers/datagrid_cells_example.js
@@ -1,4 +1,4 @@
-import React, { Fragment } from 'react';
+import React from 'react';
import { Link } from 'react-router-dom';
import { GuideSectionTypes } from '../../../components';
@@ -6,8 +6,6 @@ import {
EuiDataGrid,
EuiCode,
EuiCallOut,
- EuiBasicTable,
- EuiSpacer,
EuiText,
EuiListGroupItem,
} from '../../../../../src';
@@ -64,7 +62,7 @@ export const DataGridCellsExample = {
],
title: 'Cell actions',
text: (
-
+ <>
In addition to making a cell expandable, you can add more custom
actions by setting columns.cellActions . These
@@ -97,7 +95,7 @@ export const DataGridCellsExample = {
cellAction each, while the country column
provides 2 cellAction s.
-
+ >
),
props: {
EuiDataGrid,
@@ -157,7 +155,7 @@ export const DataGridCellsExample = {
},
],
text: (
-
+ <>
EuiDataGrid tracks and manages complicated focus
state management based upon the content of the individual inner
@@ -166,93 +164,44 @@ export const DataGridCellsExample = {
Initial focus
- When tabbing to the grid before it has received focus, the first
- cell of either the header (if it is interaction) or first content
+ When tabbing to the grid before it has received focus, the header
cell is focused.
- Datagrid does not auto-focus on mount / page load
- When removing focus from the grid and then returning, the last
- focused cell remains focused.
+ When tabbing away from the grid and then returning, the last
+ focused cell will regain focus.
+
+
+ If the last focused cell has been scrolled out of view, the first
+ header cell receives focus instead.
Click and key events
- Clicking on an interactive cell (not its content) should focus on
- the cell, or if it has only one interactive element the focus
- should shift to the element.
+ Clicking on an interactive cell or its content should focus on the
+ cell.
+
+
+ The up, down, left, and right arrow keys can be pressed to
+ navigate between cells.
+
+
+ For expandable cells, either the Enter or F2 keys can be pressed
+ interchangeably to toggle the cell popover. The Escape key will
+ close the popover.
- Clicking on an interactive element within a cell the focus should
- always remain on that element, not shift to the cell or another
- element unless a subsequent user action changes it.
+ For non-expandable cells with interactive content, either the
+ Enter or F2 keys can be pressed to enter a focus trap, allowing
+ users to Tab between the cell's content. The Escape key will exit
+ the cell trap.
- Enter or F2 can be used interchangeably to enter inner cell focus
- if the logic below allows it.
+ For non-expandable cells with no interactive content, the cell
+ alone will receive focus, with no inner content action.
-
- The content and expandability of the cells dictate the focus target
- of the cell
-
-
- The following combinations of focus are maintained to provide for a
- good balance between accessibility and ease of use while navigating
- the grid with your keyboard.
-
-
-
-
+ >
),
demo: ,
},
diff --git a/src-docs/src/views/datagrid/cells_popovers/focus.js b/src-docs/src/views/datagrid/cells_popovers/focus.js
index 1653d10e77b..5b2d85d3082 100644
--- a/src-docs/src/views/datagrid/cells_popovers/focus.js
+++ b/src-docs/src/views/datagrid/cells_popovers/focus.js
@@ -56,20 +56,7 @@ export default () => {
const columns = [
{
id: 'no-interactives not expandable',
- display: (
-
-
- {}}
- />
-
-
- 0 interactive
-
-
- ),
+ display: 0 interactive ,
isExpandable: false,
actions: false,
},
@@ -95,7 +82,16 @@ export default () => {
},
{
id: 'one-interactive not expandable',
- display: 1 interactive ,
+ display: (
+
+ {}}
+ />
+ 1 interactive
+
+ ),
isExpandable: false,
actions: false,
},
@@ -103,13 +99,6 @@ export default () => {
id: 'one-interactives is expandable',
display: (
-
- {}}
- />
-
{
},
{
id: 'two-interactives not expandable',
- display: 2 interactive ,
+ display: (
+
+ {}}
+ />
+ {}}
+ />
+ 2 interactive
+
+ ),
isExpandable: false,
actions: false,
},
diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap
index 7f67005bde8..cfbf6e00f0d 100644
--- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap
+++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap
@@ -595,7 +595,7 @@ exports[`EuiDataGrid rendering renders additional toolbar controls 1`] = `
role="row"
>
-
B, column 2, row 1
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
A, column 1, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 2, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
A, column 1, row 3
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 2, row 3
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
@@ -1041,7 +1185,7 @@ exports[`EuiDataGrid rendering renders control columns 1`] = `
-
A, column 2, row 1
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 3, row 1
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
-
- 0
-
-
- -
- trailing, column 4, row 1
-
+ 0
-
+
+ -
+ trailing, column 4, row 1
+
-
-
- 1
-
-
- -
- leading, column 1, row 2
-
+ 1
-
+
+ -
+ leading, column 1, row 2
+
-
A, column 2, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 3, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
-
- 1
-
-
- -
- trailing, column 4, row 2
-
+ 1
-
+
+ -
+ trailing, column 4, row 2
+
-
-
- 2
-
-
- -
- leading, column 1, row 3
-
+ 2
-
+
+ -
+ leading, column 1, row 3
+
-
A, column 2, row 3
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 3, row 3
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
-
- 2
-
-
- -
- trailing, column 4, row 3
-
+ 2
-
+
+ -
+ trailing, column 4, row 3
+
@@ -1724,7 +1928,7 @@ exports[`EuiDataGrid rendering renders custom column headers 1`] = `
role="row"
>
-
B, column 2, row 1
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
A, column 1, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 2, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
A, column 1, row 3
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 2, row 3
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
@@ -2150,7 +2498,7 @@ exports[`EuiDataGrid rendering renders with common and div attributes 1`] = `
role="row"
>
-
B, column 2, row 1
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
A, column 1, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 2, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
A, column 1, row 3
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
B, column 2, row 3
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
diff --git a/src/components/datagrid/body/__snapshots__/data_grid_body_custom.test.tsx.snap b/src/components/datagrid/body/__snapshots__/data_grid_body_custom.test.tsx.snap
index 2a9d732b062..73d35ea0a01 100644
--- a/src/components/datagrid/body/__snapshots__/data_grid_body_custom.test.tsx.snap
+++ b/src/components/datagrid/body/__snapshots__/data_grid_body_custom.test.tsx.snap
@@ -10,7 +10,7 @@ exports[`EuiDataGridBodyCustomRender treats \`renderCustomGridBody\` as a render
role="row"
>
-
columnB, column 2, row 1
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
columnA, column 1, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
-
columnB, column 2, row 2
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
diff --git a/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap b/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap
index c13007e730b..776c1e879b4 100644
--- a/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap
+++ b/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap
@@ -14,7 +14,7 @@ exports[`EuiDataGridBodyVirtualized renders 1`] = `
role="row"
>
-
columnB, column 2, row 1
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
diff --git a/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap b/src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap
similarity index 71%
rename from src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap
rename to src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap
index d06990806c9..075243d94b9 100644
--- a/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap
+++ b/src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap
@@ -55,13 +55,11 @@ exports[`EuiDataGridCell renders 1`] = `
hello
world
@@ -73,6 +71,30 @@ exports[`EuiDataGridCell renders 1`] = `
>
-
someColumn, column 1, row 1
+ .
+ Press the Enter key to expand this cell.
+
+
+
+
+
+
`;
diff --git a/src/components/datagrid/body/data_grid_cell.test.tsx b/src/components/datagrid/body/cell/data_grid_cell.test.tsx
similarity index 86%
rename from src/components/datagrid/body/data_grid_cell.test.tsx
rename to src/components/datagrid/body/cell/data_grid_cell.test.tsx
index da2eccd6284..1e3db47cb5b 100644
--- a/src/components/datagrid/body/data_grid_cell.test.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell.test.tsx
@@ -9,11 +9,10 @@
import React, { useEffect } from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { act } from '@testing-library/react';
-import { keys } from '../../../services';
-import { render } from '../../../test/rtl';
-import { RowHeightUtils } from '../utils/__mocks__/row_heights';
-import { mockFocusContext } from '../utils/__mocks__/focus_context';
-import { DataGridFocusContext } from '../utils/focus';
+import { render } from '../../../../test/rtl';
+import { RowHeightUtils } from '../../utils/__mocks__/row_heights';
+import { mockFocusContext } from '../../utils/__mocks__/focus_context';
+import { DataGridFocusContext } from '../../utils/focus';
import { EuiDataGridCell } from './data_grid_cell';
@@ -184,26 +183,11 @@ describe('EuiDataGridCell', () => {
component.setState({ cellProps: {} });
});
});
- it('isEntered', () => {
- act(() => {
- component.setState({ isEntered: true });
- });
- });
it('isFocused', () => {
act(() => {
component.setState({ isFocused: true });
});
});
- it('enableInteractions', () => {
- act(() => {
- component.setState({ enableInteractions: true });
- });
- });
- it('disableCellTabIndex', () => {
- act(() => {
- component.setState({ disableCellTabIndex: true });
- });
- });
});
});
@@ -651,75 +635,6 @@ describe('EuiDataGridCell', () => {
});
});
- // TODO: Test interacting/focus/tabbing in Cypress instead of Jest
- describe('interactions', () => {
- describe('keyboard events', () => {
- it('when cell is expandable', () => {
- const component = mount( );
- const preventDefault = jest.fn();
-
- component.simulate('keyDown', { preventDefault, key: keys.ENTER });
- component.simulate('keyDown', { preventDefault, key: keys.F2 });
-
- expect(mockPopoverContext.openCellPopover).toHaveBeenCalledWith({
- rowIndex: 0,
- colIndex: 0,
- });
- expect(mockPopoverContext.openCellPopover).toHaveBeenCalledTimes(2);
-
- // If the cell popover is open, the nothing should happen
- jest.clearAllMocks();
- component.setProps({
- popoverContext: { ...mockPopoverContext, popoverIsOpen: true },
- });
-
- component.simulate('keyDown', { preventDefault, key: keys.ENTER });
- component.simulate('keyDown', { preventDefault, key: keys.F2 });
-
- expect(mockPopoverContext.openCellPopover).not.toHaveBeenCalled();
- });
-
- it('when cell is not expandable', () => {
- const component = mount(
-
- );
- const preventDefault = jest.fn();
-
- component.simulate('keyDown', { preventDefault, key: keys.ENTER });
- // TODO: Assert that tabbing should be enabled
- expect(component.state('isEntered')).toEqual(true);
-
- component.simulate('keyDown', { preventDefault, key: keys.F2 });
- // TODO: Assert that tabbing should be prevented
- expect(component.state('isEntered')).toEqual(false);
-
- component.simulate('keyDown', { preventDefault, key: keys.F2 });
- // TODO: Assert that tabbing should be enabled
- expect(component.state('isEntered')).toEqual(true);
-
- component.simulate('keyDown', { preventDefault, key: keys.ENTER });
- component.simulate('keyDown', { preventDefault, key: keys.ESCAPE });
- // TODO: Assert that tabbing should be prevented
- expect(component.state('isEntered')).toEqual(false);
- });
- });
-
- it('mouse events', () => {
- const component = mount( );
- component.simulate('mouseEnter');
- expect(component.state('enableInteractions')).toEqual(true);
- component.simulate('mouseLeave');
- expect(component.state('enableInteractions')).toEqual(false);
- });
-
- it('focus/blur events', () => {
- const component = mount( );
- component.simulate('focus');
- component.simulate('blur');
- expect(component.state('disableCellTabIndex')).toEqual(false);
- });
- });
-
describe('renders certain classes/styles based on rowHeightOptions', () => {
const props = { ...requiredProps, renderCellValue: () => null };
@@ -777,4 +692,6 @@ describe('EuiDataGridCell', () => {
expect(component.find('.euiTextBlockTruncate').exists()).toBe(true);
});
});
+
+ // Note: Tests for cell interactivity (focus, tabbing, etc) are in `focus_utils.spec.tsx`
});
diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/cell/data_grid_cell.tsx
similarity index 72%
rename from src/components/datagrid/body/data_grid_cell.tsx
rename to src/components/datagrid/body/cell/data_grid_cell.tsx
index 61ad48987c0..af9041848df 100644
--- a/src/components/datagrid/body/data_grid_cell.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell.tsx
@@ -11,7 +11,6 @@ import React, {
Component,
ContextType,
createRef,
- FocusEvent,
FunctionComponent,
JSXElementConstructor,
KeyboardEvent,
@@ -20,15 +19,16 @@ import React, {
ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
-import { tabbable } from 'tabbable';
-import { keys } from '../../../services';
-import { EuiScreenReaderOnly } from '../../accessibility';
-import { EuiFocusTrap } from '../../focus_trap';
-import { EuiI18n } from '../../i18n';
-import { EuiTextBlockTruncate } from '../../text_truncate';
-import { hasResizeObserver } from '../../observer/resize_observer/resize_observer';
-import { DataGridFocusContext } from '../utils/focus';
-import { RowHeightVirtualizationUtils } from '../utils/row_heights';
+
+import { IS_JEST_ENVIRONMENT } from '../../../../utils';
+import { keys } from '../../../../services';
+import { EuiScreenReaderOnly } from '../../../accessibility';
+import { EuiI18n } from '../../../i18n';
+import { EuiTextBlockTruncate } from '../../../text_truncate';
+import { hasResizeObserver } from '../../../observer/resize_observer/resize_observer';
+
+import { DataGridFocusContext } from '../../utils/focus';
+import { RowHeightVirtualizationUtils } from '../../utils/row_heights';
import {
EuiDataGridCellProps,
EuiDataGridCellState,
@@ -37,13 +37,13 @@ import {
EuiDataGridCellValueProps,
EuiDataGridCellPopoverElementProps,
EuiDataGridRowHeightOption,
-} from '../data_grid_types';
+} from '../../data_grid_types';
import {
EuiDataGridCellActions,
EuiDataGridCellPopoverActions,
} from './data_grid_cell_actions';
import { DefaultCellPopover } from './data_grid_cell_popover';
-import { IS_JEST_ENVIRONMENT } from '../../../utils';
+import { HandleInteractiveChildren } from './focus_utils';
const EuiDataGridCellContent: FunctionComponent<
EuiDataGridCellValueProps & {
@@ -125,6 +125,15 @@ const EuiDataGridCellContent: FunctionComponent<
row: ariaRowIndex,
}}
/>
+ {cellActions && (
+ <>
+ {'. '}
+
+ >
+ )}
);
@@ -143,17 +152,6 @@ export class EuiDataGridCell extends Component<
EuiDataGridCellProps,
EuiDataGridCellState
> {
- // focus tracking is split between the entire grid & individual cells,
- // the parent grid owns which cell is focused,
- // but individual cells need to react to changes and also report that
- // they are focused in response to user actions like clicking on the cell
- // to avoid focus trap fighting, cells wait a tick after being clicked to allow
- // any existing traps to disconnect before the cell reports the new focus state to the parent grid
- // but because of this small delay, multiple cells could queue up focus and
- // create an infinite loop as the cells activate->deactivate->...
- // so we track the last timeout id and clear that request if superseded
- static activeFocusTimeoutId: number | undefined = undefined;
-
cellRef = createRef() as MutableRefObject;
contentObserver!: any; // Cell Content ResizeObserver
popoverAnchorRef = createRef() as MutableRefObject;
@@ -161,50 +159,26 @@ export class EuiDataGridCell extends Component<
state: EuiDataGridCellState = {
cellProps: {},
isFocused: false,
- isEntered: false,
- enableInteractions: false,
- disableCellTabIndex: false,
cellTextAlign: 'Left',
};
unsubscribeCell?: Function;
- focusTimeout: number | undefined;
style = null;
static contextType = DataGridFocusContext;
declare context: ContextType;
- getInteractables = () => {
- const tabbingRef = this.cellContentsRef;
-
- if (tabbingRef) {
- return tabbingRef.querySelectorAll(
- '[data-datagrid-interactable=true]'
- );
- }
-
- return [];
+ updateCellFocusContext = () => {
+ this.context.setFocusedCell([
+ this.props.colIndex,
+ this.props.visibleRowIndex,
+ ]);
};
takeFocus = (preventScroll: boolean) => {
const cell = this.cellRef.current;
-
- if (cell) {
- // only update focus if we are not already focused on something in this cell
- let element: Element | null = document.activeElement;
- while (element != null && element !== cell) {
- element = element.parentElement;
- }
- const doFocusUpdate = element !== cell;
-
- if (doFocusUpdate) {
- const interactables = this.getInteractables();
- if (this.isExpandable() === false && interactables.length === 1) {
- // Only one element can be interacted with
- interactables[0].focus({ preventScroll });
- } else {
- cell.focus({ preventScroll });
- }
- }
+ // Only focus the cell if not already focused on something in the cell
+ if (cell && !cell.contains(document.activeElement)) {
+ cell.focus({ preventScroll });
}
};
@@ -305,7 +279,6 @@ export class EuiDataGridCell extends Component<
};
componentWillUnmount() {
- window.clearTimeout(this.focusTimeout);
if (this.unsubscribeCell) {
this.unsubscribeCell();
}
@@ -404,12 +377,7 @@ export class EuiDataGridCell extends Component<
}
if (nextState.cellProps !== this.state.cellProps) return true;
- if (nextState.isEntered !== this.state.isEntered) return true;
if (nextState.isFocused !== this.state.isFocused) return true;
- if (nextState.enableInteractions !== this.state.enableInteractions)
- return true;
- if (nextState.disableCellTabIndex !== this.state.disableCellTabIndex)
- return true;
return false;
}
@@ -429,62 +397,9 @@ export class EuiDataGridCell extends Component<
} else if (this.contentObserver) {
this.contentObserver.disconnect();
}
- this.preventTabbing();
this.setCellTextAlign();
};
- onFocus = (e: FocusEvent) => {
- // only perform this logic when the event's originating element (e.target) is
- // the wrapping element with the onFocus logic
- // reasons:
- // * the outcome is only meaningful when the focus shifts to the wrapping element
- // * if the cell children include portalled content React will bubble the focus
- // event up, which can trigger the focus() call below, causing focus lock fighting
- if (this.cellRef.current === e.target) {
- const { colIndex, visibleRowIndex } = this.props;
- // focus in next tick to give potential focus capturing mechanisms time to release their traps
- // also clear any previous focus timeout that may still be queued
- if (EuiDataGridCell.activeFocusTimeoutId) {
- window.clearTimeout(EuiDataGridCell.activeFocusTimeoutId);
- }
- EuiDataGridCell.activeFocusTimeoutId = this.focusTimeout =
- window.setTimeout(() => {
- this.context.setFocusedCell([colIndex, visibleRowIndex]);
-
- const interactables = this.getInteractables();
- if (interactables.length === 1 && this.isExpandable() === false) {
- interactables[0].focus();
- this.setState({ disableCellTabIndex: true });
- }
- }, 0);
- }
- };
-
- onBlur = () => {
- this.setState({ disableCellTabIndex: false });
- };
-
- preventTabbing = () => {
- if (this.cellContentsRef) {
- const tabbables = tabbable(this.cellContentsRef);
- for (let i = 0; i < tabbables.length; i++) {
- const element = tabbables[i];
- element.setAttribute('tabIndex', '-1');
- element.setAttribute('data-datagrid-interactable', 'true');
- }
- }
- };
-
- enableTabbing = () => {
- if (this.cellContentsRef) {
- const interactables = this.getInteractables();
- for (let i = 0; i < interactables.length; i++) {
- const element = interactables[i];
- element.removeAttribute('tabIndex');
- }
- }
- };
-
setCellTextAlign = () => {
if (this.cellContentsRef) {
const { columnType } = this.props;
@@ -605,11 +520,6 @@ export class EuiDataGridCell extends Component<
const isExpandable = this.isExpandable();
const popoverIsOpen = this.isPopoverOpen();
- const showCellActions =
- this.state.isFocused ||
- this.state.isEntered ||
- this.state.enableInteractions ||
- popoverIsOpen;
const cellClasses = classNames(
'euiDataGridRowCell',
@@ -659,48 +569,6 @@ export class EuiDataGridCell extends Component<
openCellPopover({ rowIndex: visibleRowIndex, colIndex });
break;
}
- } else {
- if (
- event.key === keys.ENTER ||
- event.key === keys.F2 ||
- event.key === keys.ESCAPE
- ) {
- const interactables = this.getInteractables();
- if (interactables.length >= 2) {
- switch (event.key) {
- case keys.ENTER:
- // `Enter` only activates the trap
- if (this.state.isEntered === false) {
- this.enableTabbing();
- this.setState({ isEntered: true });
-
- // result of this keypress is focus shifts to the first interactive element
- // and then the browser fires the onClick event because that's how [Enter] works
- // so we need to prevent that default action otherwise entering the trap triggers the first element
- event.preventDefault();
- }
- break;
- case keys.F2:
- // toggle interactives' focus trap
- this.setState(({ isEntered }) => {
- if (isEntered) {
- this.preventTabbing();
- } else {
- this.enableTabbing();
- }
- return { isEntered: !isEntered };
- });
- break;
- case keys.ESCAPE:
- // `Escape` only de-activates the trap
- this.preventTabbing();
- if (this.state.isEntered === true) {
- this.setState({ isEntered: false });
- }
- break;
- }
- }
- }
}
};
@@ -727,7 +595,7 @@ export class EuiDataGridCell extends Component<
ariaRowIndex,
};
- const cellActions = showCellActions && (
+ const cellActions = isExpandable ? (
<>
>
- );
+ ) : undefined;
- const cellContent = isExpandable ? (
-
- ) : (
- {
- this.setState({ isEntered: false }, this.preventTabbing);
- }}
- clickOutsideDisables={true}
+ const cellContent = (
+
-
-
+
+
);
const cell = (
{
- this.setState({ enableInteractions: true });
- }}
- onMouseLeave={() => {
- this.setState({ enableInteractions: false });
- }}
- onBlur={this.onBlur}
>
{cellContent}
diff --git a/src/components/datagrid/body/data_grid_cell_actions.test.tsx b/src/components/datagrid/body/cell/data_grid_cell_actions.test.tsx
similarity index 99%
rename from src/components/datagrid/body/data_grid_cell_actions.test.tsx
rename to src/components/datagrid/body/cell/data_grid_cell_actions.test.tsx
index 6ab8a4f3e06..fe16e085dd9 100644
--- a/src/components/datagrid/body/data_grid_cell_actions.test.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell_actions.test.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { shallow } from 'enzyme';
-import { EuiDataGridColumnCellAction } from '../data_grid_types';
+import { EuiDataGridColumnCellAction } from '../../data_grid_types';
import {
EuiDataGridCellActions,
EuiDataGridCellPopoverActions,
diff --git a/src/components/datagrid/body/data_grid_cell_actions.tsx b/src/components/datagrid/body/cell/data_grid_cell_actions.tsx
similarity index 93%
rename from src/components/datagrid/body/data_grid_cell_actions.tsx
rename to src/components/datagrid/body/cell/data_grid_cell_actions.tsx
index 9c2f231e9e8..701b25655f2 100644
--- a/src/components/datagrid/body/data_grid_cell_actions.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell_actions.tsx
@@ -11,13 +11,16 @@ import {
EuiDataGridColumn,
EuiDataGridColumnCellAction,
EuiDataGridColumnCellActionProps,
-} from '../data_grid_types';
+} from '../../data_grid_types';
-import { EuiI18n } from '../../i18n';
-import { EuiButtonIcon, EuiButtonIconProps } from '../../button/button_icon';
-import { EuiButtonEmpty, EuiButtonEmptyProps } from '../../button/button_empty';
-import { EuiFlexGroup, EuiFlexItem } from '../../flex';
-import { EuiPopoverFooter } from '../../popover';
+import { EuiI18n } from '../../../i18n';
+import { EuiButtonIcon, EuiButtonIconProps } from '../../../button/button_icon';
+import {
+ EuiButtonEmpty,
+ EuiButtonEmptyProps,
+} from '../../../button/button_empty';
+import { EuiFlexGroup, EuiFlexItem } from '../../../flex';
+import { EuiPopoverFooter } from '../../../popover';
export const EuiDataGridCellActions = ({
onExpandClick,
diff --git a/src/components/datagrid/body/data_grid_cell_popover.spec.tsx b/src/components/datagrid/body/cell/data_grid_cell_popover.spec.tsx
similarity index 92%
rename from src/components/datagrid/body/data_grid_cell_popover.spec.tsx
rename to src/components/datagrid/body/cell/data_grid_cell_popover.spec.tsx
index 53e5e49982b..a1bd45cdd36 100644
--- a/src/components/datagrid/body/data_grid_cell_popover.spec.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell_popover.spec.tsx
@@ -8,10 +8,10 @@
///
///
-///
+///
import React, { useEffect } from 'react';
-import { EuiDataGrid, EuiDataGridProps } from '../';
+import { EuiDataGrid, EuiDataGridProps } from '../..';
const baseProps: EuiDataGridProps = {
'aria-label': 'Grid cell popover test',
@@ -53,7 +53,9 @@ describe('EuiDataGridCellPopover', () => {
'[data-gridcell-row-index="0"][data-gridcell-column-index="0"]'
).realClick();
- cy.get('[data-test-subj="euiDataGridCellExpandButton"]').click();
+ cy.get('[data-test-subj="euiDataGridCellExpandButton"]')
+ .filter(':visible')
+ .click();
cy.focused().should(
'have.attr',
'data-test-subj',
@@ -73,7 +75,9 @@ describe('EuiDataGridCellPopover', () => {
'[data-gridcell-row-index="1"][data-gridcell-column-index="1"]'
).realClick();
- cy.get('[data-test-subj="euiDataGridCellExpandButton"]').click();
+ cy.get('[data-test-subj="euiDataGridCellExpandButton"]')
+ .filter(':visible')
+ .click();
cy.focused().should(
'have.attr',
'data-test-subj',
@@ -108,7 +112,9 @@ describe('EuiDataGridCellPopover', () => {
});
it('when the cell expand action button is clicked', () => {
- cy.get('[data-test-subj="euiDataGridCellExpandButton"]').click();
+ cy.get('[data-test-subj="euiDataGridCellExpandButton"]')
+ .filter(':visible')
+ .click();
cy.get('[data-test-subj="euiDataGridExpansionPopover"]').should(
'not.exist'
);
@@ -150,8 +156,12 @@ describe('EuiDataGridCellPopover', () => {
cy.get('[data-test-subj="euiDataGridExpansionPopover"]').should('exist');
// Close and re-open the cell popover by clicking
- cy.get('[data-test-subj="euiDataGridCellExpandButton"]').click();
- cy.get('[data-test-subj="euiDataGridCellExpandButton"]').click();
+ cy.get('[data-test-subj="euiDataGridCellExpandButton"]')
+ .filter(':visible')
+ .click();
+ cy.get('[data-test-subj="euiDataGridCellExpandButton"]')
+ .filter(':visible')
+ .click();
cy.get('[data-test-subj="cellActionB"]').first().realClick();
cy.get('[data-test-subj="euiDataGridExpansionPopover"]').should('exist');
@@ -191,7 +201,9 @@ describe('EuiDataGridCellPopover', () => {
cy.get(
'[data-gridcell-row-index="0"][data-gridcell-column-index="0"]'
).realClick();
- cy.get('[data-test-subj="euiDataGridCellExpandButton"]').click();
+ cy.get('[data-test-subj="euiDataGridCellExpandButton"]')
+ .filter(':visible')
+ .click();
cy.get('.euiDataGridRowCell__popover.hello.world').should('exist');
});
diff --git a/src/components/datagrid/body/data_grid_cell_popover.test.tsx b/src/components/datagrid/body/cell/data_grid_cell_popover.test.tsx
similarity index 97%
rename from src/components/datagrid/body/data_grid_cell_popover.test.tsx
rename to src/components/datagrid/body/cell/data_grid_cell_popover.test.tsx
index 360b5e86157..8c3762c493b 100644
--- a/src/components/datagrid/body/data_grid_cell_popover.test.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell_popover.test.tsx
@@ -7,12 +7,12 @@
*/
import React from 'react';
-import { renderHook, renderHookAct } from '../../../test/rtl';
+import { renderHook, renderHookAct } from '../../../../test/rtl';
import { shallow } from 'enzyme';
-import { keys } from '../../../services';
+import { keys } from '../../../../services';
-import { DataGridCellPopoverContextShape } from '../data_grid_types';
+import { DataGridCellPopoverContextShape } from '../../data_grid_types';
import { useCellPopover, DefaultCellPopover } from './data_grid_cell_popover';
describe('useCellPopover', () => {
diff --git a/src/components/datagrid/body/data_grid_cell_popover.tsx b/src/components/datagrid/body/cell/data_grid_cell_popover.tsx
similarity index 96%
rename from src/components/datagrid/body/data_grid_cell_popover.tsx
rename to src/components/datagrid/body/cell/data_grid_cell_popover.tsx
index 66df03f09d4..4504e1e5862 100644
--- a/src/components/datagrid/body/data_grid_cell_popover.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell_popover.tsx
@@ -9,12 +9,12 @@
import React, { createContext, useState, useCallback, ReactNode } from 'react';
import classNames from 'classnames';
-import { keys } from '../../../services';
-import { EuiWrappingPopover, EuiPopoverProps } from '../../popover';
+import { keys } from '../../../../services';
+import { EuiWrappingPopover, EuiPopoverProps } from '../../../popover';
import {
DataGridCellPopoverContextShape,
EuiDataGridCellPopoverElementProps,
-} from '../data_grid_types';
+} from '../../data_grid_types';
export const DataGridCellPopoverContext =
createContext({
@@ -153,8 +153,8 @@ export const useCellPopover = (): {
/**
* Popover content renderers
*/
-import { EuiText } from '../../text';
-import { EuiCodeBlock } from '../../code';
+import { EuiText } from '../../../text';
+import { EuiCodeBlock } from '../../../code';
export const DefaultCellPopover = ({
schema,
diff --git a/src/components/datagrid/body/data_grid_cell_wrapper.test.tsx b/src/components/datagrid/body/cell/data_grid_cell_wrapper.test.tsx
similarity index 95%
rename from src/components/datagrid/body/data_grid_cell_wrapper.test.tsx
rename to src/components/datagrid/body/cell/data_grid_cell_wrapper.test.tsx
index 59ef9d70bf4..6b822ab55a8 100644
--- a/src/components/datagrid/body/data_grid_cell_wrapper.test.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell_wrapper.test.tsx
@@ -9,8 +9,8 @@
import React from 'react';
import { shallow } from 'enzyme';
-import { RowHeightUtils } from '../utils/__mocks__/row_heights';
-import { schemaDetectors } from '../utils/data_grid_schema';
+import { RowHeightUtils } from '../../utils/__mocks__/row_heights';
+import { schemaDetectors } from '../../utils/data_grid_schema';
import { Cell } from './data_grid_cell_wrapper';
diff --git a/src/components/datagrid/body/data_grid_cell_wrapper.tsx b/src/components/datagrid/body/cell/data_grid_cell_wrapper.tsx
similarity index 98%
rename from src/components/datagrid/body/data_grid_cell_wrapper.tsx
rename to src/components/datagrid/body/cell/data_grid_cell_wrapper.tsx
index 4100e98fc97..3a429494fcf 100644
--- a/src/components/datagrid/body/data_grid_cell_wrapper.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell_wrapper.tsx
@@ -14,8 +14,8 @@ import {
EuiDataGridBodyProps,
EuiDataGridHeaderRowProps,
EuiDataGridSchemaDetector,
-} from '../data_grid_types';
-import { DataGridSortingContext } from '../utils/sorting';
+} from '../../data_grid_types';
+import { DataGridSortingContext } from '../../utils/sorting';
import { DataGridCellPopoverContext } from './data_grid_cell_popover';
import { EuiDataGridCell } from './data_grid_cell';
diff --git a/src/components/datagrid/body/cell/focus_utils.spec.tsx b/src/components/datagrid/body/cell/focus_utils.spec.tsx
new file mode 100644
index 00000000000..1dcfa6d74d2
--- /dev/null
+++ b/src/components/datagrid/body/cell/focus_utils.spec.tsx
@@ -0,0 +1,225 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+///
+///
+///
+
+import React from 'react';
+import { EuiDataGrid } from '../../data_grid';
+
+describe('Cell focus utils', () => {
+ const baseProps = {
+ 'aria-label': 'Test',
+ width: 300,
+ rowCount: 1,
+ renderCellValue: () => 'cell',
+ columnVisibility: {
+ setVisibleColumns: () => {},
+ visibleColumns: ['column'],
+ },
+ };
+
+ const interactiveChildren = (
+ <>
+ A
+ B
+ >
+ );
+
+ describe('does not render a focus trap', () => {
+ const props = {
+ ...baseProps,
+ columns: [{ id: 'column', isExpandable: true, actions: {} }],
+ };
+
+ it('when header cells have actions', () => {
+ cy.mount( );
+
+ const headerCell = '[data-test-subj="dataGridHeaderCell-column"]';
+ const headerCellPopover =
+ '[data-test-subj="dataGridHeaderCellActionGroup-column"]';
+
+ // Should toggle the actions popover instead
+ cy.get(headerCell).click();
+ cy.get(headerCellPopover).should('be.visible');
+
+ // Keyboard behavior
+ cy.realPress('Escape');
+ cy.get(headerCellPopover).should('not.exist');
+ cy.realPress('Enter');
+ cy.get(headerCellPopover).should('exist');
+ });
+
+ it('when body cells are expandable', () => {
+ cy.mount( );
+
+ const cell = '[data-test-subj="dataGridRowCell"]';
+ const cellAction = '[data-test-subj="euiDataGridCellExpandButton"]';
+ const cellPopover = '[data-test-subj="euiDataGridExpansionPopover"]';
+
+ // Should toggle the cell expansion popover instead
+ cy.get(cell).click();
+ cy.get(cellAction).should('be.visible');
+ cy.get(cellAction).realClick();
+ cy.get(cellPopover).should('be.visible');
+
+ // Keyboard behavior
+ cy.realPress('Escape');
+ cy.get(cellPopover).should('not.exist');
+ cy.realPress('Enter');
+ cy.get(cellPopover).should('exist');
+ });
+ });
+
+ describe('renders a focus trap', () => {
+ it('when header cells do not have actions', () => {
+ cy.mount(
+
+ );
+ // For some reason the header click doesn't register in Cypress until the body is clicked
+ cy.get('[data-test-subj="dataGridRowCell"]').realClick();
+ cy.wait(50);
+
+ // Enter the trap
+ cy.get('[data-test-subj="dataGridHeaderCell-column"]').realClick();
+ cy.realPress('Enter');
+
+ // Should cycle through focus trap
+ cy.focused().should('have.attr', 'data-test-subj', 'interactiveChildA');
+ cy.realPress('Tab');
+ cy.focused().should('have.attr', 'data-test-subj', 'interactiveChildB');
+ cy.realPress('Tab');
+ cy.focused().should('have.attr', 'data-test-subj', 'interactiveChildA');
+
+ // Exit the trap
+ cy.realPress('Escape');
+ cy.focused().should(
+ 'have.attr',
+ 'data-test-subj',
+ 'dataGridHeaderCell-column'
+ );
+ });
+
+ it('when body cells are not expandable', () => {
+ cy.mount(
+ interactiveChildren}
+ />
+ );
+ // Enter the trap
+ cy.get('[data-test-subj="dataGridRowCell"]').realClick();
+ cy.realPress('Enter');
+
+ // Should cycle through focus trap
+ cy.focused().should('have.attr', 'data-test-subj', 'interactiveChildA');
+ cy.realPress('Tab');
+ cy.focused().should('have.attr', 'data-test-subj', 'interactiveChildB');
+ cy.realPress('Tab');
+ cy.focused().should('have.attr', 'data-test-subj', 'interactiveChildA');
+
+ // Exit the trap
+ cy.realPress('Escape');
+ cy.focused().should('have.attr', 'data-test-subj', 'dataGridRowCell');
+ });
+ });
+
+ describe('focus context', () => {
+ it('updates the cell focus context if interactive cell children are clicked', () => {
+ cy.realMount(
+ <>
+ {},
+ visibleColumns: ['A', 'B'],
+ }}
+ columns={[
+ {
+ id: 'A',
+ isExpandable: true,
+ },
+ {
+ id: 'B',
+ isExpandable: false,
+ actions: false,
+ display: interactiveChildren,
+ },
+ ]}
+ renderCellValue={() => interactiveChildren}
+ />
+ tabbable
+ >
+ );
+
+ cy.repeatRealPress('Tab', 4);
+ cy.focused()
+ .parent()
+ .should('have.attr', 'data-gridcell-row-index', '-1')
+ .should('have.attr', 'data-gridcell-column-index', '0');
+
+ cy.get('[data-test-subj="interactiveChildB"]').first().realClick();
+ cy.realPress('Tab');
+ cy.realPress(['Shift', 'Tab']);
+ cy.focused()
+ .should('have.attr', 'data-gridcell-row-index', '-1')
+ .should('have.attr', 'data-gridcell-column-index', '1');
+
+ cy.get('[data-test-subj="interactiveChildB"]').last().realClick();
+ cy.realPress('Tab');
+ cy.realPress(['Shift', 'Tab']);
+ cy.focused()
+ .should('have.attr', 'data-gridcell-row-index', '0')
+ .should('have.attr', 'data-gridcell-column-index', '1');
+ });
+ });
+
+ it('correctly toggles the header cell actions on focus when tabbing back into the datagrid', () => {
+ cy.realMount(
+
+ );
+
+ const assertFocusedHeaderActions = () => {
+ cy.focused().should(
+ 'have.attr',
+ 'data-test-subj',
+ 'dataGridHeaderCellActionButton-column'
+ );
+ cy.focused()
+ .parent()
+ .should('have.attr', 'data-gridcell-row-index', '-1')
+ .should('have.attr', 'data-gridcell-column-index', '0');
+ };
+
+ const assertCanToggleActionsPopover = () => {
+ const headerCellPopover =
+ '[data-test-subj="dataGridHeaderCellActionGroup-column"]';
+
+ cy.realPress('Enter');
+ cy.get(headerCellPopover).should('be.visible');
+ cy.realPress('Escape');
+ cy.get(headerCellPopover).should('not.exist');
+ };
+
+ cy.repeatRealPress('Tab', 4);
+ assertFocusedHeaderActions();
+ assertCanToggleActionsPopover();
+
+ cy.realPress(['Shift', 'Tab']);
+ cy.realPress('Tab');
+ assertFocusedHeaderActions();
+ assertCanToggleActionsPopover();
+ });
+});
diff --git a/src/components/datagrid/body/cell/focus_utils.test.tsx b/src/components/datagrid/body/cell/focus_utils.test.tsx
new file mode 100644
index 00000000000..a35407d00dc
--- /dev/null
+++ b/src/components/datagrid/body/cell/focus_utils.test.tsx
@@ -0,0 +1,199 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { fireEvent } from '@testing-library/react';
+import { render } from '../../../../test/rtl';
+
+import { FocusTrappedChildren, HandleInteractiveChildren } from './focus_utils';
+
+// Test util
+const getCellWithInteractiveChildren = () => {
+ const cell = document.createElement('div');
+ cell.setAttribute('tabindex', '0');
+ cell.appendChild(document.createElement('button'));
+ cell.appendChild(document.createElement('button'));
+ return cell;
+};
+
+describe('HandleInteractiveChildren', () => {
+ describe('cell with interactive children', () => {
+ it('disables tabbing on all interactive children on mount', () => {
+ const cell = getCellWithInteractiveChildren();
+ cell.querySelectorAll('button').forEach((button) => {
+ expect(button.getAttribute('tabindex')).toBeNull();
+ });
+
+ render(
+ {}}
+ />
+ );
+
+ cell.querySelectorAll('button').forEach((button) => {
+ expect(button.getAttribute('tabindex')).toEqual('-1');
+ });
+ });
+
+ it('calls `updateCellFocusContext` on child focus', () => {
+ const cell = getCellWithInteractiveChildren();
+
+ const updateCellFocusContext = jest.fn();
+ render(
+
+ );
+
+ fireEvent.focus(cell.querySelector('button')!);
+ expect(updateCellFocusContext).toHaveBeenCalled();
+ });
+
+ it('renders a focus trap if `renderFocusTrap` is true', () => {
+ const cell = getCellWithInteractiveChildren();
+
+ const { container } = render(
+ {}}
+ renderFocusTrap={true}
+ />
+ );
+
+ expect(
+ container.querySelector('[data-focus-lock-disabled]')
+ ).toBeInTheDocument();
+ });
+
+ it('does not render a focus trap if `renderFocusTrap` is falsy', () => {
+ const cell = getCellWithInteractiveChildren();
+
+ const { container } = render(
+ {}}
+ renderFocusTrap={false}
+ />
+ );
+
+ expect(
+ container.querySelector('[data-focus-lock-disabled]')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('cell without any interactive children', () => {
+ it('never renders a focus trap', () => {
+ const cell = document.createElement('div');
+
+ const { container } = render(
+ {}}
+ renderFocusTrap={true}
+ />
+ );
+
+ expect(
+ container.querySelector('[data-focus-lock-disabled]')
+ ).not.toBeInTheDocument();
+ });
+
+ it('still calls `updateCellFocusContext` if the cell itself is focused', () => {
+ const cell = document.createElement('div');
+
+ const updateCellFocusContext = jest.fn();
+ render(
+
+ );
+
+ fireEvent.focus(cell);
+ expect(updateCellFocusContext).toHaveBeenCalled();
+ });
+ });
+});
+
+describe('FocusTrappedChildren', () => {
+ it('renders a screen reader hint', () => {
+ const cell = getCellWithInteractiveChildren();
+ const { container } = render( );
+ expect(container.childNodes[1]).toMatchInlineSnapshot(`
+
+
+ -
+ Press the Enter key to interact with this cell's contents.
+
+
+ `);
+ });
+
+ describe('on enter', () => {
+ it('enables the focus trap, all interactive children, and moves focus to the first focusable child', () => {
+ const cell = getCellWithInteractiveChildren();
+
+ const { container } = render( );
+ fireEvent.keyUp(cell, { key: 'Enter' });
+
+ expect(
+ container.querySelector('[data-focus-lock-disabled]')
+ ).toHaveAttribute('data-focus-lock-disabled', 'false');
+
+ expect(cell.querySelector('button')).toHaveAttribute('tabindex', '0');
+ expect(cell.querySelector('button')).toHaveFocus();
+ });
+
+ it('allows pressing F2 to enter as well', () => {
+ const cell = getCellWithInteractiveChildren();
+
+ render( );
+ fireEvent.keyUp(cell, { key: 'F2' });
+
+ expect(cell.querySelector('button')).toHaveFocus();
+ });
+ });
+
+ describe('on exit', () => {
+ // Mock requestAnimationFrame to run immediately
+ jest
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation((cb: Function) => cb());
+
+ it('disables the focus trap, all interactive children and moves focus to the cell wrapper', () => {
+ const cell = getCellWithInteractiveChildren();
+
+ const { container } = render( );
+ fireEvent.keyUp(cell, { key: 'Enter' });
+ fireEvent.keyUp(cell, { key: 'Escape' });
+
+ expect(
+ container.querySelector('[data-focus-lock-disabled]')
+ ).toHaveAttribute('data-focus-lock-disabled', 'disabled');
+
+ expect(cell.querySelector('button')).toHaveAttribute('tabindex', '-1');
+ expect(cell).toHaveFocus();
+ });
+
+ it('does nothing if the cell is not entered', () => {
+ const cell = getCellWithInteractiveChildren();
+
+ render( );
+ fireEvent.keyUp(cell, { key: 'Escape' });
+
+ expect(cell).not.toHaveFocus();
+ });
+ });
+});
diff --git a/src/components/datagrid/body/cell/focus_utils.tsx b/src/components/datagrid/body/cell/focus_utils.tsx
new file mode 100644
index 00000000000..4d425244f83
--- /dev/null
+++ b/src/components/datagrid/body/cell/focus_utils.tsx
@@ -0,0 +1,162 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React, {
+ PropsWithChildren,
+ FunctionComponent,
+ useEffect,
+ useState,
+ useMemo,
+} from 'react';
+import { tabbable } from 'tabbable';
+
+import { keys } from '../../../../services';
+import { EuiFocusTrap } from '../../../focus_trap';
+import { EuiScreenReaderOnly } from '../../../accessibility';
+import { EuiI18n } from '../../../i18n';
+
+/**
+ * This internal utility component is used by all cells, both header and body/footer cells.
+ * It always handles:
+ * 1. Removing any interactive children from keyboard tab order on cell mount
+ * 2. Listening for focus on any interactive children and updating the cell focus context
+ *
+ * It should *only* render focus traps for:
+ * 1. Header cells that are `actions: false` but still have interactive children
+ * 2. Body cells that are `isExpandable: false` but still have interactive children
+ */
+export const HandleInteractiveChildren: FunctionComponent<
+ PropsWithChildren & {
+ cellEl?: HTMLElement | null;
+ updateCellFocusContext: Function;
+ renderFocusTrap?: boolean;
+ }
+> = ({ cellEl, children, updateCellFocusContext, renderFocusTrap }) => {
+ const [hasInteractiveChildren, setHasInteractiveChildren] = useState(false);
+
+ // On mount, disable all interactive children
+ useEffect(() => {
+ if (cellEl) {
+ const interactiveChildren = disableInteractives(cellEl);
+
+ if (renderFocusTrap) {
+ setHasInteractiveChildren(interactiveChildren!.length > 0);
+ }
+ }
+ }, [cellEl, renderFocusTrap]);
+
+ // Ensure that any interactive children that are clicked update the latest cell focus context
+ useEffect(() => {
+ if (cellEl) {
+ const onFocus = () => updateCellFocusContext();
+ cellEl.addEventListener('focus', onFocus, true); // useCapture listens for focus on children
+ return () => {
+ cellEl.removeEventListener('focus', onFocus, true);
+ };
+ }
+ }, [cellEl, updateCellFocusContext]);
+
+ const _children = useMemo(() => <>{children}>, [children]);
+ if (!cellEl) return _children; // Do nothing if cell has yet to mount or is unmounting
+ if (!renderFocusTrap) return _children; // Cells with default actions / expansion popovers do not need to trap
+ if (!hasInteractiveChildren) return _children; // No need to focus trap if no children are interactive
+
+ return (
+ {children}
+ );
+};
+
+/**
+ * Cells with interactive children but no cell popover expansion should render a
+ * focus trap that can be entered with the Enter key, which cycles keyboard tabs
+ * through the cell contents only, and exited with the Escape key
+ */
+export const FocusTrappedChildren: FunctionComponent<
+ PropsWithChildren & { cellEl: HTMLElement }
+> = ({ cellEl, children }) => {
+ const [isCellEntered, setIsCellEntered] = useState(false);
+ useEffect(() => {
+ if (isCellEntered) {
+ enableAndFocusInteractives(cellEl);
+ } else {
+ disableInteractives(cellEl);
+ }
+ }, [isCellEntered, cellEl]);
+
+ useEffect(() => {
+ const onKeyUp = (event: KeyboardEvent) => {
+ switch (event.key) {
+ case keys.ENTER:
+ case keys.F2:
+ event.preventDefault();
+ setIsCellEntered(true);
+ break;
+
+ case keys.ESCAPE:
+ event.preventDefault();
+ setIsCellEntered((isCellEntered) => {
+ if (isCellEntered === true) {
+ requestAnimationFrame(() => cellEl.focus()); // move focus to cell
+ return false;
+ }
+ return isCellEntered;
+ });
+ break;
+ }
+ };
+ cellEl.addEventListener('keyup', onKeyUp);
+ return () => {
+ cellEl.removeEventListener('keyup', onKeyUp);
+ };
+ }, [cellEl]);
+
+ return (
+ setIsCellEntered(false)}
+ clickOutsideDisables={true}
+ >
+ {children}
+
+
+
+ {' - '}
+
+
+
+
+ );
+};
+
+/**
+ * Utility fns for managing child interactive tabIndex state
+ */
+
+const disableInteractives = (cell: HTMLElement) => {
+ const interactives = tabbable(cell);
+ interactives.forEach((element) => {
+ element.setAttribute('data-euigrid-tab-managed', 'true');
+ element.setAttribute('tabIndex', '-1');
+ });
+ return interactives;
+};
+
+const enableAndFocusInteractives = (cell: HTMLElement) => {
+ const interactives = cell.querySelectorAll('[data-euigrid-tab-managed]');
+ interactives.forEach((element, i) => {
+ element.setAttribute('tabIndex', '0');
+ if (i === 0) {
+ (element as HTMLElement).focus();
+ }
+ });
+ return interactives;
+};
diff --git a/src/components/datagrid/body/cell/index.ts b/src/components/datagrid/body/cell/index.ts
new file mode 100644
index 00000000000..ed2338e8de2
--- /dev/null
+++ b/src/components/datagrid/body/cell/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export { EuiDataGridCell } from './data_grid_cell';
+
+export { Cell } from './data_grid_cell_wrapper';
+
+export {
+ DataGridCellPopoverContext,
+ useCellPopover,
+} from './data_grid_cell_popover';
diff --git a/src/components/datagrid/body/data_grid_body.test.tsx b/src/components/datagrid/body/data_grid_body.test.tsx
index 9680ee12c89..99fc8496069 100644
--- a/src/components/datagrid/body/data_grid_body.test.tsx
+++ b/src/components/datagrid/body/data_grid_body.test.tsx
@@ -16,7 +16,6 @@ import { EuiDataGridBody } from './data_grid_body';
// Body props, reused by other body unit tests
export const dataGridBodyProps = {
- headerIsInteractive: true,
rowCount: 1,
visibleRows: { startRow: 0, endRow: 1, visibleRowCount: 1 },
columnWidths: { columnA: 100 },
diff --git a/src/components/datagrid/body/data_grid_body_custom.tsx b/src/components/datagrid/body/data_grid_body_custom.tsx
index 881deb3f642..d77428bed66 100644
--- a/src/components/datagrid/body/data_grid_body_custom.tsx
+++ b/src/components/datagrid/body/data_grid_body_custom.tsx
@@ -24,7 +24,7 @@ import {
} from '../data_grid_types';
import { useDataGridHeader } from './header';
import { useDataGridFooter } from './footer';
-import { Cell } from './data_grid_cell_wrapper';
+import { Cell } from './cell';
export const EuiDataGridBodyCustomRender: FunctionComponent<
EuiDataGridBodyProps
@@ -41,8 +41,6 @@ export const EuiDataGridBodyCustomRender: FunctionComponent<
renderCellPopover,
renderFooterCellValue,
interactiveCellId,
- headerIsInteractive,
- handleHeaderMutation,
setVisibleColumns,
switchColumnPos,
onColumnResize,
@@ -92,8 +90,6 @@ export const EuiDataGridBodyCustomRender: FunctionComponent<
* Header & footer
*/
const { headerRow } = useDataGridHeader({
- headerIsInteractive,
- handleHeaderMutation,
switchColumnPos,
setVisibleColumns,
leadingControlColumns,
diff --git a/src/components/datagrid/body/data_grid_body_virtualized.tsx b/src/components/datagrid/body/data_grid_body_virtualized.tsx
index c85c7c52cc8..e49b1e9dadd 100644
--- a/src/components/datagrid/body/data_grid_body_virtualized.tsx
+++ b/src/components/datagrid/body/data_grid_body_virtualized.tsx
@@ -24,7 +24,7 @@ import {
import { useResizeObserver } from '../../observer/resize_observer';
import { useDataGridHeader } from './header';
import { useDataGridFooter } from './footer';
-import { Cell } from './data_grid_cell_wrapper';
+import { Cell } from './cell';
import {
EuiDataGridBodyProps,
DataGridWrapperRowsContentsShape,
@@ -117,8 +117,6 @@ export const EuiDataGridBodyVirtualized: FunctionComponent<
renderFooterCellValue,
interactiveCellId,
pagination,
- headerIsInteractive,
- handleHeaderMutation,
setVisibleColumns,
switchColumnPos,
onColumnResize,
@@ -177,8 +175,6 @@ export const EuiDataGridBodyVirtualized: FunctionComponent<
* Header & footer
*/
const { headerRow, headerRowHeight } = useDataGridHeader({
- headerIsInteractive,
- handleHeaderMutation,
switchColumnPos,
setVisibleColumns,
leadingControlColumns,
diff --git a/src/components/datagrid/body/footer/data_grid_footer_row.spec.tsx b/src/components/datagrid/body/footer/data_grid_footer_row.spec.tsx
index 5dc1a7b7f3f..dddd82bb296 100644
--- a/src/components/datagrid/body/footer/data_grid_footer_row.spec.tsx
+++ b/src/components/datagrid/body/footer/data_grid_footer_row.spec.tsx
@@ -65,9 +65,9 @@ describe('EuiDataGridFooterRow', () => {
cy.get(
'[data-gridcell-column-index="0"][data-gridcell-row-index="3"]'
).realClick();
- cy.get('[data-test-subj="euiDataGridCellExpandButton"]').should(
- 'not.exist'
- );
+ cy.get('[data-test-subj="euiDataGridCellExpandButton"]')
+ .filter(':visible')
+ .should('not.exist');
});
// Regression test for #5720
diff --git a/src/components/datagrid/body/footer/data_grid_footer_row.tsx b/src/components/datagrid/body/footer/data_grid_footer_row.tsx
index 085bef3dcc1..10b7e3a5b10 100644
--- a/src/components/datagrid/body/footer/data_grid_footer_row.tsx
+++ b/src/components/datagrid/body/footer/data_grid_footer_row.tsx
@@ -8,8 +8,7 @@
import classnames from 'classnames';
import React, { forwardRef, memo, useContext } from 'react';
-import { EuiDataGridCell } from '../data_grid_cell';
-import { DataGridCellPopoverContext } from '../data_grid_cell_popover';
+import { EuiDataGridCell, DataGridCellPopoverContext } from '../cell';
import { EuiDataGridFooterRowProps } from '../../data_grid_types';
const renderEmpty = () => null;
diff --git a/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap b/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap
index e3076a127c3..e0ab6fc8903 100644
--- a/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap
+++ b/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`EuiDataGridHeaderCell renders 1`] = `
`);
@@ -80,201 +101,43 @@ describe('EuiDataGridHeaderCellWrapper', () => {
data-gridcell-row-index="-1"
data-gridcell-visible-row-index="-1"
data-test-subj="dataGridHeaderCell-someColumn"
+ onFocus={[Function]}
role="columnheader"
style={
Object {
"width": "30px",
}
}
- tabIndex={-1}
+ tabIndex={0}
>
-
+
`);
});
- describe('focus behavior', () => {
- // Reusable assertions for DRYness
- const expectCellFocused = (headerCell: Element) => {
- expect(document.activeElement).toEqual(headerCell);
- expect(headerCell.getAttribute('tabIndex')).toEqual('0');
- };
- const expectCellChildrenFocused = (headerCell: Element) => {
- expect(document.activeElement).toEqual(
- headerCell.querySelector('[data-euigrid-tab-managed]')
- );
- expect(headerCell.getAttribute('tabIndex')).toEqual('-1');
- };
- const expectCellNotFocused = (headerCell: Element) => {
- expect(document.activeElement).toBeInstanceOf(HTMLBodyElement);
- expect(headerCell.getAttribute('tabIndex')).toEqual('-1');
- };
-
- // Reset focus between tests
- beforeEach(() => (document.activeElement as HTMLElement)?.blur());
-
- describe('isFocused context', () => {
- describe('when true', () => {
- it('focuses the interactive cell children when present', () => {
- const isFocused = true;
- const headerCell = mountWithContext({}, isFocused).getDOMNode();
- expectCellChildrenFocused(headerCell);
- });
-
- it('focuses the cell when no interactive children are present', () => {
- const isFocused = true;
- const headerCell = mountWithContext(
- { children:
},
- isFocused
- ).getDOMNode();
- expectCellFocused(headerCell);
- });
- });
-
- describe('when false', () => {
- it('sets isCellEntered to false and disables interactives', () => {
- const isFocused = false;
- const headerCell = mountWithContext({}, isFocused).getDOMNode();
- expectCellNotFocused(headerCell);
- });
- });
- });
-
- describe('events', () => {
- describe('keyup', () => {
- test('enter key sets isCellEntered to true (which focuses the first interactive child in the header cell)', () => {
- const headerCell = mountWithContext().getDOMNode();
- act(() => {
- headerCell.dispatchEvent(
- new KeyboardEvent('keyup', { key: keys.ENTER })
- );
- });
- expectCellChildrenFocused(headerCell);
- });
-
- test('escape key sets isCellEntered to false and focuses the header cell div', () => {
- const headerCell = mountWithContext().getDOMNode();
- act(() => {
- headerCell.dispatchEvent(
- new KeyboardEvent('keyup', { key: keys.ESCAPE })
- );
- });
- expectCellFocused(headerCell);
- });
- });
-
- describe('focus', () => {
- // Mock requestAnimationFrame
- beforeEach(() => {
- jest
- .spyOn(window, 'requestAnimationFrame')
- .mockImplementation((cb: Function) => cb());
- });
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- describe('focusin', () => {
- describe('when header is not interactive', () => {
- it('does not focus in on the cell', () => {
- const headerCell = mountWithContext(
- { headerIsInteractive: false },
- false
- ).getDOMNode();
- act(() => {
- headerCell.dispatchEvent(new FocusEvent('focusin'));
- });
- expectCellNotFocused(headerCell);
- });
- });
-
- describe('when header is interactive', () => {
- it('calls setFocusedCell when not already focused', () => {
- const headerCell = mountWithContext({}, false).getDOMNode();
- act(() => {
- headerCell.dispatchEvent(new FocusEvent('focusin'));
- });
- expect(mockFocusContext.setFocusedCell).toHaveBeenCalled();
- });
-
- it('re-enables and focuses cell interactives when already focused', () => {
- const headerCell = mountWithContext({}, true).getDOMNode();
- act(() => {
- headerCell.dispatchEvent(new FocusEvent('focusin'));
- });
- expectCellChildrenFocused(headerCell);
- });
- });
- });
-
- describe('focus out', () => {
- it('waits for the cell/children to lose focus first', () => {
- const headerCell = mountWithContext({}, true).getDOMNode();
- act(() => {
- headerCell.dispatchEvent(new FocusEvent('focusin'));
- });
- expectCellChildrenFocused(headerCell);
-
- act(() => {
- headerCell.dispatchEvent(new FocusEvent('focusout'));
- });
- // Focus hasn't moved away yet
- expectCellChildrenFocused(headerCell);
- });
-
- it('sets isCellEntered to false once the cell/children are no longer focused', () => {
- const headerCell = mountWithContext({}, false).getDOMNode();
-
- // Slightly cheating the test setup here; headerCell starts out not focused
- expectCellNotFocused(headerCell);
- act(() => {
- headerCell.dispatchEvent(new FocusEvent('focusout'));
- });
-
- // Focus is lost & isCellCentered false should set children to tabIndex -1
- expect(headerCell.querySelector('[tabIndex="-1"]')).not.toBeNull();
- expect(headerCell.querySelector('[tabIndex="0"]')).toBeNull();
- });
- });
- });
- });
-
- describe('multiple interactive children', () => {
- const children = (
-
-
-
-
-
- );
-
- let consoleWarnSpy: jest.SpyInstance;
-
- beforeEach(() => {
- consoleWarnSpy = jest
- .spyOn(console, 'warn')
- .mockImplementation(() => {}); // Silence expected warning
- });
- afterEach(() => {
- consoleWarnSpy.mockRestore();
- });
-
- it('emits a console warning', () => {
- mountWithContext({ children });
- expect(consoleWarnSpy).toHaveBeenCalledWith(
- 'EuiDataGridHeaderCell expects at most 1 tabbable element, 3 found instead'
- );
- });
-
- it('will still focus in to the first interactable element on cell enter', () => {
- const headerCell = mountWithContext({ children }).getDOMNode();
- act(() => {
- headerCell.dispatchEvent(
- new KeyboardEvent('keyup', { key: keys.ENTER })
- );
- });
- expectCellChildrenFocused(headerCell);
- });
- });
- });
+ // Focus behavior tested in `focus_utils.spec.tsx`
});
diff --git a/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx b/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx
index 1ba63c0c3f8..8bb29ce1725 100644
--- a/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx
+++ b/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx
@@ -9,15 +9,16 @@
import classnames from 'classnames';
import React, {
FunctionComponent,
+ FocusEventHandler,
useContext,
useEffect,
- useRef,
useState,
+ useCallback,
} from 'react';
-import { tabbable } from 'tabbable';
-import { keys } from '../../../../services';
-import { DataGridFocusContext } from '../../utils/focus';
+
import { EuiDataGridHeaderCellWrapperProps } from '../../data_grid_types';
+import { DataGridFocusContext } from '../../utils/focus';
+import { HandleInteractiveChildren } from '../cell/focus_utils';
/**
* This is a wrapper that handles repeated concerns between control &
@@ -29,15 +30,24 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent<
> = ({
id,
index,
- headerIsInteractive,
width,
className,
children,
+ hasActionsPopover,
+ isActionsButtonFocused,
+ focusActionsButton,
...rest
}) => {
const classes = classnames('euiDataGridHeaderCell', className);
+ // Must be a state and not a ref to trigger a HandleInteractiveChildren rerender
+ const [headerEl, setHeaderEl] = useState(null);
+
const { setFocusedCell, onFocusUpdate } = useContext(DataGridFocusContext);
+ const updateCellFocusContext = useCallback(() => {
+ setFocusedCell([index, -1]);
+ }, [index, setFocusedCell]);
+
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
onFocusUpdate([index, -1], (isFocused: boolean) => {
@@ -45,95 +55,32 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent<
});
}, [index, onFocusUpdate]);
- const headerRef = useRef(null);
- const [isCellEntered, setIsCellEntered] = useState(false);
-
useEffect(() => {
- const headerNode = headerRef.current!;
-
- if (isCellEntered) {
- enableAndFocusInteractives(headerNode);
- } else {
- disableInteractives(headerNode);
- }
- }, [isCellEntered]);
-
- useEffect(() => {
- const headerNode = headerRef.current!;
-
- if (isFocused) {
- const interactives = headerNode.querySelectorAll(
- '[data-euigrid-tab-managed]'
- );
- if (interactives.length === 1) {
- setIsCellEntered(true);
- } else {
- headerNode.focus();
+ if (isFocused && headerEl) {
+ // Only focus the cell if not already focused on something in the cell
+ if (!headerEl.contains(document.activeElement)) {
+ headerEl.focus();
}
- } else {
- setIsCellEntered(false);
}
-
- // focusin bubbles while focus does not, and this needs to react to children gaining focus
- const onFocusIn = (e: FocusEvent) => {
- if (!headerIsInteractive) {
- // header is not interactive, avoid focusing
- requestAnimationFrame(() => headerNode.blur());
- e.preventDefault();
- return false;
- } else {
- // take the focus
- if (isFocused === false) {
- setFocusedCell([index, -1]);
- } else {
- // this cell already had the grid's focus, so re-enable and focus interactives
- setIsCellEntered(true);
- }
- }
- };
-
- // focusout bubbles while blur does not, and this needs to react to the children losing focus
- const onFocusOut = () => {
- // wait for the next element to receive focus, then update interactives' state
- requestAnimationFrame(() => {
- if (!headerNode.contains(document.activeElement)) {
- setIsCellEntered(false);
- }
- });
- };
-
- const onKeyUp = (event: KeyboardEvent) => {
- switch (event.key) {
- case keys.ENTER: {
- event.preventDefault();
- setIsCellEntered(true);
- break;
- }
- case keys.ESCAPE: {
- event.preventDefault();
- // move focus to cell
- setIsCellEntered(false);
- headerNode.focus();
- break;
- }
+ }, [isFocused, headerEl]);
+
+ // For cell headers with actions, auto-focus into the button instead of the cell wrapper div
+ // The button text is significantly more useful to screen readers (e.g. contains sort order & hints)
+ const onFocus: FocusEventHandler = useCallback(
+ (e) => {
+ if (hasActionsPopover && e.target === headerEl) {
+ focusActionsButton?.();
}
- };
-
- headerNode.addEventListener('focusin', onFocusIn);
- headerNode.addEventListener('focusout', onFocusOut);
- headerNode.addEventListener('keyup', onKeyUp);
- return () => {
- headerNode.removeEventListener('focusin', onFocusIn);
- headerNode.removeEventListener('focusout', onFocusOut);
- headerNode.removeEventListener('keyup', onKeyUp);
- };
- }, [headerIsInteractive, isFocused, index, setFocusedCell]);
+ },
+ [hasActionsPopover, focusActionsButton, headerEl]
+ );
return (
- {children}
+
+ {children}
+
);
};
-
-/**
- * Utility fns for managing child interactive tabIndex state
- */
-
-const disableInteractives = (headerNode: Element) => {
- const tabbables = tabbable(headerNode);
- if (tabbables.length > 1) {
- console.warn(
- `EuiDataGridHeaderCell expects at most 1 tabbable element, ${tabbables.length} found instead`
- );
- }
- tabbables.forEach((element) => {
- element.setAttribute('data-euigrid-tab-managed', 'true');
- element.setAttribute('tabIndex', '-1');
- });
-};
-
-const enableAndFocusInteractives = (headerNode: Element) => {
- const interactiveElements = headerNode.querySelectorAll(
- '[data-euigrid-tab-managed]'
- );
- interactiveElements.forEach((element, i) => {
- element.setAttribute('tabIndex', '0');
- if (i === 0) {
- (element as HTMLElement).focus();
- }
- });
-};
diff --git a/src/components/datagrid/body/header/data_grid_header_row.test.tsx b/src/components/datagrid/body/header/data_grid_header_row.test.tsx
index d038afc93a2..1ab65f1a453 100644
--- a/src/components/datagrid/body/header/data_grid_header_row.test.tsx
+++ b/src/components/datagrid/body/header/data_grid_header_row.test.tsx
@@ -20,7 +20,6 @@ describe('EuiDataGridHeaderRow', () => {
setColumnWidth: jest.fn(),
setVisibleColumns: jest.fn(),
switchColumnPos: jest.fn(),
- headerIsInteractive: false,
};
it('renders', () => {
@@ -70,7 +69,6 @@ describe('EuiDataGridHeaderRow', () => {
]
}
defaultColumnWidth={20}
- headerIsInteractive={false}
index={0}
key="someColumn"
schema={
@@ -101,7 +99,6 @@ describe('EuiDataGridHeaderRow', () => {
width: 25,
},
]}
- headerIsInteractive={true}
/>
);
@@ -120,7 +117,6 @@ describe('EuiDataGridHeaderRow', () => {
"width": 25,
}
}
- headerIsInteractive={true}
index={0}
key="someLeadingColumn"
/>
@@ -140,7 +136,6 @@ describe('EuiDataGridHeaderRow', () => {
width: 50,
},
]}
- headerIsInteractive={true}
/>
);
@@ -159,7 +154,6 @@ describe('EuiDataGridHeaderRow', () => {
"width": 50,
}
}
- headerIsInteractive={true}
index={0}
key="someTrailingColumn"
/>
diff --git a/src/components/datagrid/body/header/data_grid_header_row.tsx b/src/components/datagrid/body/header/data_grid_header_row.tsx
index 9b5fe058909..8e3aee31e98 100644
--- a/src/components/datagrid/body/header/data_grid_header_row.tsx
+++ b/src/components/datagrid/body/header/data_grid_header_row.tsx
@@ -31,7 +31,6 @@ const EuiDataGridHeaderRow = forwardRef<
setColumnWidth,
setVisibleColumns,
switchColumnPos,
- headerIsInteractive,
'data-test-subj': _dataTestSubj,
...rest
} = props;
@@ -52,7 +51,6 @@ const EuiDataGridHeaderRow = forwardRef<
key={controlColumn.id}
index={index}
controlColumn={controlColumn}
- headerIsInteractive={headerIsInteractive}
/>
))}
{columns.map((column, index) => (
@@ -68,7 +66,6 @@ const EuiDataGridHeaderRow = forwardRef<
setVisibleColumns={setVisibleColumns}
switchColumnPos={switchColumnPos}
defaultColumnWidth={defaultColumnWidth}
- headerIsInteractive={headerIsInteractive}
/>
))}
{trailingControlColumns.map((controlColumn, index) => (
@@ -76,7 +73,6 @@ const EuiDataGridHeaderRow = forwardRef<
key={controlColumn.id}
index={index + leadingControlColumns.length + columns.length}
controlColumn={controlColumn}
- headerIsInteractive={headerIsInteractive}
/>
))}
diff --git a/src/components/datagrid/body/header/header_is_interactive.test.ts b/src/components/datagrid/body/header/header_is_interactive.test.ts
deleted file mode 100644
index f79aafb060a..00000000000
--- a/src/components/datagrid/body/header/header_is_interactive.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-import { renderHook, act } from '@testing-library/react';
-import { useHeaderIsInteractive } from './header_is_interactive';
-
-describe('useHeaderIsInteractive', () => {
- const createMockGrid = () => {
- const mockGridEl = document.createElement('div');
- const mockHeaderEl = mockGridEl.appendChild(document.createElement('div'));
- mockHeaderEl.setAttribute('data-test-subj', 'dataGridHeader');
-
- return [mockGridEl, mockHeaderEl];
- };
-
- describe('initial headerIsInteractive state', () => {
- it('returns false when there are no interactive children within the header', () => {
- const [mockGridEl] = createMockGrid();
- const { headerIsInteractive } = renderHook(() =>
- useHeaderIsInteractive(mockGridEl)
- ).result.current;
-
- expect(headerIsInteractive).toEqual(false);
- });
-
- it('returns true when there are interactive children within the header', () => {
- const [mockGridEl, mockHeaderEl] = createMockGrid();
- mockHeaderEl.appendChild(document.createElement('button')); // Interactive child
-
- const { headerIsInteractive } = renderHook(() =>
- useHeaderIsInteractive(mockGridEl)
- ).result.current;
-
- expect(headerIsInteractive).toEqual(true);
- });
- });
-
- describe('handleHeaderMutation', () => {
- it('updates headerIsInteractive state by checking if tabbable elements exist in the header cell', () => {
- const [, mockHeaderEl] = createMockGrid();
- const mockCell = document.createElement('div');
- const mockTarget = mockCell.appendChild(document.createElement('div'));
- mockTarget.setAttribute('data-euigrid-tab-managed', 'true');
- mockHeaderEl.appendChild(mockCell);
-
- const { result } = renderHook(() => useHeaderIsInteractive(null));
- expect(result.current.headerIsInteractive).toEqual(false);
-
- // @ts-ignore matching production types/data isn't necessary for this test
- act(() => result.current.handleHeaderMutation([{ target: mockTarget }]));
-
- expect(result.current.headerIsInteractive).toEqual(true);
- });
- });
-});
diff --git a/src/components/datagrid/body/header/header_is_interactive.ts b/src/components/datagrid/body/header/header_is_interactive.ts
deleted file mode 100644
index 0550aad25f5..00000000000
--- a/src/components/datagrid/body/header/header_is_interactive.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-import { useCallback, useEffect, useState } from 'react';
-import { tabbable } from 'tabbable';
-
-export const useHeaderIsInteractive = (gridElement: HTMLElement | null) => {
- const [headerIsInteractive, setHeaderIsInteractive] = useState(false);
-
- const handleHeaderChange = useCallback<(headerRow: HTMLElement) => void>(
- (headerRow) => {
- const tabbables = tabbable(headerRow);
- const managed = headerRow.querySelectorAll('[data-euigrid-tab-managed]');
- const hasInteractives = tabbables.length > 0 || managed.length > 0;
- if (hasInteractives !== headerIsInteractive) {
- setHeaderIsInteractive(hasInteractives);
- }
- },
- [headerIsInteractive]
- );
-
- // Set headerIsInteractive on data grid init/load
- useEffect(() => {
- if (gridElement) {
- const headerElement = gridElement.querySelector(
- '[data-test-subj~=dataGridHeader]'
- );
- if (headerElement) {
- handleHeaderChange(headerElement as HTMLElement);
- }
- }
- }, [gridElement, handleHeaderChange]);
-
- // Update headerIsInteractive if the header changes (e.g., columns are hidden)
- // Used in header mutation observer set in EuiDataGridBody
- const handleHeaderMutation = useCallback(
- (records) => {
- const [{ target }] = records;
-
- // find the wrapping header div
- let headerRow = target.parentElement;
- while (
- headerRow &&
- (headerRow.getAttribute('data-test-subj') || '')
- .split(/\s+/)
- .indexOf('dataGridHeader') === -1
- ) {
- headerRow = headerRow.parentElement;
- }
-
- if (headerRow) handleHeaderChange(headerRow);
- },
- [handleHeaderChange]
- );
-
- return { headerIsInteractive, handleHeaderMutation };
-};
diff --git a/src/components/datagrid/body/header/use_data_grid_header.tsx b/src/components/datagrid/body/header/use_data_grid_header.tsx
index b709fe829e3..529b059028b 100644
--- a/src/components/datagrid/body/header/use_data_grid_header.tsx
+++ b/src/components/datagrid/body/header/use_data_grid_header.tsx
@@ -8,36 +8,21 @@
import React, { useState, useMemo } from 'react';
-import { useMutationObserver } from '../../../observer/mutation_observer';
import { useResizeObserver } from '../../../observer/resize_observer';
-import { useHeaderFocusWorkaround } from '../../utils/focus';
import { EuiDataGridHeaderRowProps } from '../../data_grid_types';
import { EuiDataGridHeaderRow } from './data_grid_header_row';
-type Props = EuiDataGridHeaderRowProps & {
- handleHeaderMutation: MutationCallback;
-};
-
/**
* DRY out setting up the grid header and its refs & observers
*/
-export const useDataGridHeader = ({
- handleHeaderMutation,
- ...props
-}: Props) => {
+export const useDataGridHeader = (props: EuiDataGridHeaderRowProps) => {
const [headerRowRef, setHeaderRowRef] = useState(null);
- useMutationObserver(headerRowRef, handleHeaderMutation, {
- subtree: true,
- childList: true,
- });
const { height: headerRowHeight } = useResizeObserver(headerRowRef, 'height');
const headerRow = useMemo(() => {
return ;
}, Object.values(props)); // eslint-disable-line react-hooks/exhaustive-deps
- useHeaderFocusWorkaround(props.headerIsInteractive);
-
return { headerRow, headerRowHeight };
};
diff --git a/src/components/datagrid/data_grid.spec.tsx b/src/components/datagrid/data_grid.spec.tsx
index a424a4839d9..147f83e4681 100644
--- a/src/components/datagrid/data_grid.spec.tsx
+++ b/src/components/datagrid/data_grid.spec.tsx
@@ -79,7 +79,6 @@ describe('EuiDataGrid', () => {
);
- getGridData();
cy.get('[data-test-subj=euiDataGridBody]')
.invoke('outerHeight')
.then((firstHeight) => {
@@ -110,8 +109,6 @@ describe('EuiDataGrid', () => {
/>
);
- getGridData();
-
const virtualizedContainer = cy
.get('[data-test-subj=euiDataGridBody]')
.children()
@@ -142,29 +139,24 @@ describe('EuiDataGrid', () => {
{
id: 'no_interactive_expandable',
display: '0 interactive',
- actions: false,
},
{
id: 'one_interactive',
display: '1 interactive',
isExpandable: false,
- actions: false,
},
{
id: 'one_interactive_expandable',
display: '1 interactive',
- actions: false,
},
{
id: 'two_interactives',
display: '2 interactives',
isExpandable: false,
- actions: false,
},
{
id: 'two_interactives_expandable',
display: '2 interactives',
- actions: false,
},
];
const columnVisibility = {
@@ -240,8 +232,6 @@ describe('EuiDataGrid', () => {
it('focuses a cell when clicked', () => {
cy.mount( );
- getGridData();
-
// starts with body in focus
cy.focused().should('not.exist');
@@ -255,15 +245,13 @@ describe('EuiDataGrid', () => {
describe('cell keyboard interactions', () => {
it('tabbing to the grid the controls, then the first cell, then off', () => {
- cy.mount(
+ cy.realMount(
<>
>
);
- getGridData();
-
// starts with body in focus
cy.focused().should('not.exist');
@@ -293,21 +281,19 @@ describe('EuiDataGrid', () => {
'dataGridFullScreenButton'
);
- // tab into the grid, should focus first cell after a short delay
+ // tab into the grid, should focus first header cell
cy.realPress('Tab');
cy.focused()
.should('have.attr', 'data-gridcell-column-index', '0')
- .should('have.attr', 'data-gridcell-row-index', '0');
+ .should('have.attr', 'data-gridcell-row-index', '-1');
cy.realPress('Tab');
cy.focused().should('have.id', 'final-tabbable');
});
- it('arrow-keying focuses another cell, unless it has only one interactive element', () => {
+ it('arrow-keying focuses another cell', () => {
cy.mount( );
- getGridData();
-
// first cell is non-interactive and non-expandable = focus cell
cy.get(
'[data-gridcell-column-index="0"][data-gridcell-row-index="0"]'
@@ -328,9 +314,11 @@ describe('EuiDataGrid', () => {
.should('have.attr', 'data-gridcell-column-index', '1')
.should('have.attr', 'data-gridcell-row-index', '0');
- // arrow right, non-expandable cell with one interactive = focus interactive
+ // arrow right, non-expandable cell with one interactive = focus cell
cy.focused().type('{rightarrow}');
- cy.focused().should('have.attr', 'data-test-subj', 'focusOnMe');
+ cy.focused()
+ .should('have.attr', 'data-gridcell-column-index', '2')
+ .should('have.attr', 'data-gridcell-row-index', '0');
// arrow right, non-expandable cell with two interactives = focus cell
cy.focused().type('{rightarrow}');
@@ -348,8 +336,6 @@ describe('EuiDataGrid', () => {
it('cell expansion/interaction', () => {
cy.mount( );
- getGridData();
-
// first cell is non-interactive and non-expandable, enter should have no effect
cy.get(
'[data-gridcell-column-index="0"][data-gridcell-row-index="0"]'
@@ -384,7 +370,7 @@ describe('EuiDataGrid', () => {
// fourth cell is expandable & interactive, click should focus on the popover
cy.get(
'[data-gridcell-column-index="3"][data-gridcell-row-index="0"]'
- ).click();
+ ).realClick({ position: 'right' });
cy.focused().type('{enter}');
// focus trap focuses the popover
cy.focused().should(
@@ -445,6 +431,32 @@ describe('EuiDataGrid', () => {
.should('have.attr', 'data-gridcell-column-index', '5')
.should('have.attr', 'data-gridcell-row-index', '0');
});
+
+ it('column header cells', () => {
+ cy.realMount( );
+ cy.repeatRealPress('Tab', 5);
+ cy.realPress('{rightarrow}');
+
+ // Should auto-focus the actions button (over the cell itself)
+ cy.focused()
+ .parent()
+ .should('have.attr', 'data-gridcell-column-index', '1')
+ .should('have.attr', 'data-gridcell-row-index', '-1');
+
+ // Pressing enter should toggle the actions popover
+ cy.realPress('Enter');
+ cy.get(
+ '[data-test-subj="dataGridHeaderCellActionGroup-no_interactive_expandable"]'
+ ).should('be.visible');
+
+ // The actions popover should be fully tabbable/focus trapped with no regressions
+ cy.realPress('Tab');
+ cy.focused().should('have.text', 'Hide column');
+ cy.realPress('Tab');
+ cy.focused().should('have.text', 'Move left');
+ cy.realPress('Tab');
+ cy.focused().should('have.text', 'Move right');
+ });
});
});
diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx
index 2eb963f13dd..49120c60c4d 100644
--- a/src/components/datagrid/data_grid.test.tsx
+++ b/src/components/datagrid/data_grid.test.tsx
@@ -25,7 +25,10 @@ jest.mock('../popover', () => ({
}));
function getFocusableCell(component: ReactWrapper) {
- return findTestSubject(component, 'dataGridRowCell').find('[tabIndex=0]');
+ const headerCell = component.find('[role="columnheader"][tabIndex=0]');
+ return headerCell.length
+ ? headerCell
+ : findTestSubject(component, 'dataGridRowCell').find('[tabIndex=0]');
}
function extractGridData(datagrid: ReactWrapper) {
@@ -543,11 +546,7 @@ describe('EuiDataGrid', () => {
"data-gridcell-row-index": 0,
"data-gridcell-visible-row-index": 0,
"data-test-subj": "dataGridRowCell",
- "onBlur": [Function],
- "onFocus": [Function],
"onKeyDown": [Function],
- "onMouseEnter": [Function],
- "onMouseLeave": [Function],
"role": "gridcell",
"style": Object {
"color": "red",
@@ -569,11 +568,7 @@ describe('EuiDataGrid', () => {
"data-gridcell-row-index": 0,
"data-gridcell-visible-row-index": 0,
"data-test-subj": "dataGridRowCell",
- "onBlur": [Function],
- "onFocus": [Function],
"onKeyDown": [Function],
- "onMouseEnter": [Function],
- "onMouseLeave": [Function],
"role": "gridcell",
"style": Object {
"color": "blue",
@@ -595,11 +590,7 @@ describe('EuiDataGrid', () => {
"data-gridcell-row-index": 1,
"data-gridcell-visible-row-index": 1,
"data-test-subj": "dataGridRowCell",
- "onBlur": [Function],
- "onFocus": [Function],
"onKeyDown": [Function],
- "onMouseEnter": [Function],
- "onMouseLeave": [Function],
"role": "gridcell",
"style": Object {
"color": "red",
@@ -621,11 +612,7 @@ describe('EuiDataGrid', () => {
"data-gridcell-row-index": 1,
"data-gridcell-visible-row-index": 1,
"data-test-subj": "dataGridRowCell",
- "onBlur": [Function],
- "onFocus": [Function],
"onKeyDown": [Function],
- "onMouseEnter": [Function],
- "onMouseLeave": [Function],
"role": "gridcell",
"style": Object {
"color": "blue",
@@ -1329,7 +1316,7 @@ describe('EuiDataGrid', () => {
const component = mount(
{},
@@ -2177,21 +2164,17 @@ describe('EuiDataGrid', () => {
/>
);
- // cell buttons should not get rendered for unfocused, unhovered cell
- expect(findTestSubject(component, 'alertAction').exists()).toBe(false);
- expect(findTestSubject(component, 'happyAction').exists()).toBe(false);
-
- act(() => {
- findTestSubject(component, 'dataGridRowCell')
- .at(1)
- .prop('onMouseEnter')!({} as React.MouseEvent);
- });
-
- component.update();
+ // cell buttons should be `display: none` for unfocused, unhovered cell
+ expect(
+ findTestSubject(component, 'alertAction').last().getDOMNode()
+ ).not.toBeVisible();
+ expect(
+ findTestSubject(component, 'happyAction').last().getDOMNode()
+ ).not.toBeVisible();
- findTestSubject(component, 'alertAction').at(0).simulate('click');
+ findTestSubject(component, 'alertAction').at(1).simulate('click');
expect(alertFn).toHaveBeenCalledWith(1, 'A');
- findTestSubject(component, 'happyAction').at(0).simulate('click');
+ findTestSubject(component, 'happyAction').at(1).simulate('click');
expect(happyFn).toHaveBeenCalledWith(1, 'A');
alertFn.mockReset();
happyFn.mockReset();
@@ -2344,12 +2327,12 @@ describe('EuiDataGrid', () => {
);
component.update();
+ // focus should begin at the first header cell
let focusableCell = getFocusableCell(component);
- // focus should begin at the first cell
expect(focusableCell.length).toEqual(1);
expect(
- focusableCell.find('[data-test-subj="cell-content"]').text()
- ).toEqual('0, A');
+ focusableCell.find('.euiDataGridHeaderCell__content').text()
+ ).toEqual('A');
// focus should not move when up against the left edge
focusableCell
@@ -2357,17 +2340,18 @@ describe('EuiDataGrid', () => {
.simulate('keydown', { key: keys.ARROW_LEFT });
focusableCell = getFocusableCell(component);
expect(
- focusableCell.find('[data-test-subj="cell-content"]').text()
- ).toEqual('0, A');
+ focusableCell.find('.euiDataGridHeaderCell__content').text()
+ ).toEqual('A');
// focus should not move when up against the top edge
focusableCell.simulate('keydown', { key: keys.ARROW_UP });
expect(
- focusableCell.find('[data-test-subj="cell-content"]').text()
- ).toEqual('0, A');
+ focusableCell.find('.euiDataGridHeaderCell__content').text()
+ ).toEqual('A');
// move down
focusableCell.simulate('keydown', { key: keys.ARROW_DOWN });
+ focusableCell.simulate('keydown', { key: keys.ARROW_DOWN });
focusableCell = getFocusableCell(component);
expect(
focusableCell.find('[data-test-subj="cell-content"]').text()
diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx
index a98630ce39a..9344454f317 100644
--- a/src/components/datagrid/data_grid.tsx
+++ b/src/components/datagrid/data_grid.tsx
@@ -40,11 +40,7 @@ import {
useInMemoryValues,
EuiDataGridInMemoryRenderer,
} from './utils/in_memory';
-import { useHeaderIsInteractive } from './body/header/header_is_interactive';
-import {
- DataGridCellPopoverContext,
- useCellPopover,
-} from './body/data_grid_cell_popover';
+import { DataGridCellPopoverContext, useCellPopover } from './body/cell';
import { computeVisibleRows } from './utils/row_count';
import { EuiDataGridPaginationRenderer } from './utils/data_grid_pagination';
import {
@@ -269,12 +265,7 @@ export const EuiDataGrid = memo(
/**
* Focus
*/
- const { headerIsInteractive, handleHeaderMutation } =
- useHeaderIsInteractive(contentRef.current);
- const { focusProps: wrappingDivFocusProps, ...focusContext } = useFocus({
- headerIsInteractive,
- gridItemsRendered,
- });
+ const { focusProps: wrappingDivFocusProps, ...focusContext } = useFocus();
/**
* Cell popover
@@ -430,7 +421,6 @@ export const EuiDataGrid = memo(
rowCount,
pagination,
hasFooter: !!renderFooterCellValue,
- headerIsInteractive,
focusContext,
})}
data-test-subj="euiDataGridBody"
@@ -450,8 +440,6 @@ export const EuiDataGrid = memo(
setVisibleColumns={setVisibleColumns}
switchColumnPos={switchColumnPos}
onColumnResize={onColumnResize}
- headerIsInteractive={headerIsInteractive}
- handleHeaderMutation={handleHeaderMutation}
schemaDetectors={allSchemaDetectors}
pagination={pagination}
renderCellValue={renderCellValue}
diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts
index 004e0d2f2a4..1f2ffe67b8b 100644
--- a/src/components/datagrid/data_grid_types.ts
+++ b/src/components/datagrid/data_grid_types.ts
@@ -149,7 +149,6 @@ export interface EuiDataGridHeaderRowPropsSpecificProps {
setColumnWidth: (columnId: string, width: number) => void;
setVisibleColumns: (columnId: string[]) => void;
switchColumnPos: (colFromId: string, colToId: string) => void;
- headerIsInteractive: boolean;
}
export type EuiDataGridHeaderRowProps = CommonProps &
@@ -168,15 +167,16 @@ export interface EuiDataGridHeaderCellProps
export interface EuiDataGridControlHeaderCellProps {
index: number;
controlColumn: EuiDataGridControlColumn;
- headerIsInteractive: boolean;
}
export interface EuiDataGridHeaderCellWrapperProps extends PropsWithChildren {
id: string;
index: number;
- headerIsInteractive: boolean;
width?: number | null;
className?: string;
+ hasActionsPopover?: boolean;
+ isActionsButtonFocused?: boolean;
+ focusActionsButton?: () => void;
}
export type EuiDataGridFooterRowProps = CommonProps &
@@ -458,8 +458,6 @@ export interface EuiDataGridBodyProps {
renderCustomGridBody?: EuiDataGridProps['renderCustomGridBody'];
interactiveCellId: EuiDataGridCellProps['interactiveCellId'];
pagination?: Required;
- headerIsInteractive: boolean;
- handleHeaderMutation: MutationCallback;
setVisibleColumns: EuiDataGridHeaderRowProps['setVisibleColumns'];
switchColumnPos: EuiDataGridHeaderRowProps['switchColumnPos'];
onColumnResize?: EuiDataGridOnColumnResizeHandler;
@@ -629,9 +627,6 @@ export interface EuiDataGridCellProps {
export interface EuiDataGridCellState {
cellProps: EuiDataGridSetCellProps;
isFocused: boolean; // tracks if this cell has focus or not, used to enable tabIndex on the cell
- isEntered: boolean; // enables focus trap for non-expandable cells with multiple interactive elements
- enableInteractions: boolean; // cell got hovered at least once, so cell button and popover interactions are rendered
- disableCellTabIndex: boolean; // disables tabIndex on the wrapping cell, used for focus management of a single interactive child
cellTextAlign: 'Left' | 'Right'; // determines the cell actions and cell popover expansion position
}
diff --git a/src/components/datagrid/utils/focus.test.tsx b/src/components/datagrid/utils/focus.test.tsx
index 35f861656b1..410ed814371 100644
--- a/src/components/datagrid/utils/focus.test.tsx
+++ b/src/components/datagrid/utils/focus.test.tsx
@@ -6,29 +6,21 @@
* Side Public License, v 1.
*/
-import React from 'react';
-import { render, renderHook, renderHookAct } from '../../../test/rtl';
+import { renderHook, renderHookAct } from '../../../test/rtl';
import { keys } from '../../../services';
import {
- DataGridFocusContext,
useFocus,
notifyCellOfFocusState,
createKeyDownHandler,
preventTabbing,
getParentCellContent,
- useHeaderFocusWorkaround,
} from './focus';
describe('useFocus', () => {
- const mockArgs = {
- headerIsInteractive: true,
- gridItemsRendered: { current: null },
- };
-
describe('onFocusUpdate', () => {
it("adds a cell's onFocus callback to the internal cellsUpdateFocus map,", () => {
const onFocus = jest.fn();
- const { result } = renderHook(() => useFocus(mockArgs));
+ const { result } = renderHook(() => useFocus());
const cleanupFn = result.current.onFocusUpdate([0, 0], onFocus);
// the mapped onFocus is called with true when the cell becomes focused
@@ -48,7 +40,7 @@ describe('useFocus', () => {
describe('focusedCell / setFocusedCell', () => {
it('gets and sets the focusedCell state', () => {
- const { result } = renderHook(() => useFocus(mockArgs));
+ const { result } = renderHook(() => useFocus());
expect(result.current.focusedCell).toEqual(undefined);
renderHookAct(() => result.current.setFocusedCell([2, 2]));
@@ -56,7 +48,7 @@ describe('useFocus', () => {
});
it('does not update if setFocusedCell is called with the same cell X/Y coordinates', () => {
- const { result } = renderHook(() => useFocus(mockArgs));
+ const { result } = renderHook(() => useFocus());
renderHookAct(() => result.current.setFocusedCell([2, 2]));
const focusedCellInMemory = result.current.focusedCell;
@@ -66,54 +58,18 @@ describe('useFocus', () => {
});
describe('focusFirstVisibleInteractiveCell', () => {
- describe('when the sticky header is interactive', () => {
- it('always focuses the first header cell', () => {
- const { result } = renderHook(() =>
- useFocus({ ...mockArgs, headerIsInteractive: true })
- );
+ it('focuses the first sticky header cell', () => {
+ const { result } = renderHook(() => useFocus());
- renderHookAct(() => result.current.focusFirstVisibleInteractiveCell());
- expect(result.current.focusedCell).toEqual([0, -1]);
- });
- });
-
- describe('describe when the header is not interactive', () => {
- it('focuses the first visible data cell in the virtualized grid', () => {
- const { result } = renderHook(() =>
- useFocus({
- headerIsInteractive: false,
- gridItemsRendered: {
- current: {
- visibleColumnStartIndex: 1,
- visibleRowStartIndex: 10,
- } as any,
- },
- })
- );
-
- renderHookAct(() => result.current.focusFirstVisibleInteractiveCell());
- expect(result.current.focusedCell).toEqual([1, 10]);
- });
-
- it("does nothing if the grid isn't yet rendered", () => {
- const { result } = renderHook(() =>
- useFocus({
- headerIsInteractive: false,
- gridItemsRendered: { current: null },
- })
- );
-
- renderHookAct(() => result.current.focusFirstVisibleInteractiveCell());
- expect(result.current.focusedCell).toEqual(undefined);
- });
+ renderHookAct(() => result.current.focusFirstVisibleInteractiveCell());
+ expect(result.current.focusedCell).toEqual([0, -1]);
});
});
describe('setIsFocusedCellInView / focusProps', () => {
describe('when no focused child cell is in view', () => {
it('renders the grid with tabindex 0 and an onKeyUp event', () => {
- const { focusProps } = renderHook(() => useFocus(mockArgs)).result
- .current;
+ const { focusProps } = renderHook(() => useFocus()).result.current;
expect(focusProps).toEqual({
tabIndex: 0,
@@ -128,7 +84,7 @@ describe('useFocus', () => {
);
it('focuses into the first visible cell of the grid when the grid is directly tabbed to', () => {
- const { result } = renderHook(() => useFocus(mockArgs));
+ const { result } = renderHook(() => useFocus());
renderHookAct(() =>
result.current.focusProps.onKeyUp!({
@@ -141,7 +97,7 @@ describe('useFocus', () => {
});
it('does nothing if not a tab keyup, or if the event was not on the grid itself', () => {
- const { result } = renderHook(() => useFocus(mockArgs));
+ const { result } = renderHook(() => useFocus());
renderHookAct(() =>
result.current.focusProps.onKeyUp!({
@@ -166,7 +122,7 @@ describe('useFocus', () => {
describe('when a focused cell is in view', () => {
it('renders the grid with tabindex -1 (because the child cell will already have a tabindex 0)', () => {
- const { result } = renderHook(() => useFocus(mockArgs));
+ const { result } = renderHook(() => useFocus());
renderHookAct(() => result.current.setIsFocusedCellInView(true));
expect(result.current.focusProps).toEqual({
@@ -208,7 +164,6 @@ describe('createKeyDownHandler', () => {
rowCount: 10,
pagination: undefined,
hasFooter: false,
- headerIsInteractive: true,
focusContext,
};
const mockKeyDown = {
@@ -323,26 +278,13 @@ describe('createKeyDownHandler', () => {
expect(focusContext.setFocusedCell).toHaveBeenCalledWith([1, 0]);
});
- describe('when focus is on the top-most row', () => {
- it('does nothing', () => {
- const keyDownHandler = createKeyDownHandler({
- ...mockArgs,
- headerIsInteractive: false,
- focusContext: { ...focusContext, focusedCell: [1, 0] },
- });
- keyDownHandler({ ...mockKeyDown, key: keys.ARROW_UP });
- expect(focusContext.setFocusedCell).not.toHaveBeenCalled();
- });
-
- it('accounts for an interactive header row', () => {
- const keyDownHandler = createKeyDownHandler({
- ...mockArgs,
- headerIsInteractive: true,
- focusContext: { ...focusContext, focusedCell: [1, -1] },
- });
- keyDownHandler({ ...mockKeyDown, key: keys.ARROW_UP });
- expect(focusContext.setFocusedCell).not.toHaveBeenCalled();
+ it('does nothing when focus is on the top-most header row', () => {
+ const keyDownHandler = createKeyDownHandler({
+ ...mockArgs,
+ focusContext: { ...focusContext, focusedCell: [1, -1] },
});
+ keyDownHandler({ ...mockKeyDown, key: keys.ARROW_UP });
+ expect(focusContext.setFocusedCell).not.toHaveBeenCalled();
});
});
@@ -603,36 +545,3 @@ describe('getParentCellContent', () => {
expect(getParentCellContent(body)).toBeNull();
});
});
-
-describe('useHeaderFocusWorkaround', () => {
- const MockComponent = () => {
- useHeaderFocusWorkaround(false);
- return
;
- };
-
- it('moves focus down from the header to the first data row if the header becomes uninteractive', () => {
- const focusedCell = [2, -1];
- const setFocusedCell = jest.fn();
- render(
-
-
-
- );
- expect(setFocusedCell).toHaveBeenCalledWith([2, 0]);
- });
-
- it('does nothing if the focus was not on the header when the header became uninteractive', () => {
- const focusedCell = [2, 0];
- const setFocusedCell = jest.fn();
- render(
-
-
-
- );
- expect(setFocusedCell).not.toHaveBeenCalled();
- });
-});
diff --git a/src/components/datagrid/utils/focus.ts b/src/components/datagrid/utils/focus.ts
index 0dd226faa33..a00a6b0e51e 100644
--- a/src/components/datagrid/utils/focus.ts
+++ b/src/components/datagrid/utils/focus.ts
@@ -8,7 +8,6 @@
import {
createContext,
- useContext,
useCallback,
useEffect,
useMemo,
@@ -16,9 +15,7 @@ import {
useState,
HTMLAttributes,
KeyboardEvent,
- MutableRefObject,
} from 'react';
-import { GridOnItemsRenderedProps } from 'react-window';
import { tabbable } from 'tabbable';
import { keys } from '../../../services';
import {
@@ -40,13 +37,9 @@ type FocusProps = Pick, 'tabIndex' | 'onKeyUp'>;
/**
* Main focus context and overarching focus state management
*/
-export const useFocus = ({
- headerIsInteractive,
- gridItemsRendered,
-}: {
- headerIsInteractive: boolean;
- gridItemsRendered: MutableRefObject;
-}): DataGridFocusContextShape & { focusProps: FocusProps } => {
+export const useFocus = (): DataGridFocusContextShape & {
+ focusProps: FocusProps;
+} => {
// Maintain a map of focus cell state callbacks
const cellsUpdateFocus = useRef>(new Map());
@@ -69,19 +62,21 @@ export const useFocus = ({
const setFocusedCell = useCallback(
(nextFocusedCell: EuiDataGridFocusedCell) => {
- // If the x/y coordinates remained the same, don't update. This keeps the focusedCell
- // reference stable, and allows it to be used in places that need reference equality.
- if (
- nextFocusedCell[0] === focusedCell?.[0] &&
- nextFocusedCell[1] === focusedCell?.[1]
- ) {
- return;
- }
-
- _setFocusedCell(nextFocusedCell);
- setIsFocusedCellInView(true); // scrolling.ts ensures focused cells are fully in view
+ _setFocusedCell((prevFocusedCell) => {
+ // If the x/y coordinates remained the same, don't update. This keeps the focusedCell
+ // reference stable, and allows it to be used in places that need reference equality.
+ if (
+ nextFocusedCell[0] === prevFocusedCell?.[0] &&
+ nextFocusedCell[1] === prevFocusedCell?.[1]
+ ) {
+ return prevFocusedCell;
+ } else {
+ setIsFocusedCellInView(true); // scrolling.ts ensures focused cells are fully in view
+ return nextFocusedCell;
+ }
+ });
},
- [focusedCell]
+ []
);
const previousCell = useRef(undefined);
@@ -101,19 +96,8 @@ export const useFocus = ({
}, [cellsUpdateFocus, focusedCell]);
const focusFirstVisibleInteractiveCell = useCallback(() => {
- if (headerIsInteractive) {
- // The header (rowIndex -1) is sticky and will always be in view
- setFocusedCell([0, -1]);
- } else if (gridItemsRendered.current) {
- const { visibleColumnStartIndex, visibleRowStartIndex } =
- gridItemsRendered.current;
-
- setFocusedCell([visibleColumnStartIndex, visibleRowStartIndex]);
- } else {
- // If the header is non-interactive and there are no rendered cells,
- // there's nothing to do - we might as well leave focus on the grid body wrapper
- }
- }, [setFocusedCell, headerIsInteractive, gridItemsRendered]);
+ setFocusedCell([0, -1]);
+ }, [setFocusedCell]);
const focusProps = useMemo(
() =>
@@ -173,7 +157,6 @@ export const createKeyDownHandler = ({
rowCount,
pagination,
hasFooter,
- headerIsInteractive,
focusContext,
}: {
gridElement: HTMLDivElement | null;
@@ -183,7 +166,6 @@ export const createKeyDownHandler = ({
rowCount: EuiDataGridProps['rowCount'];
pagination: Required;
hasFooter: boolean;
- headerIsInteractive: boolean;
focusContext: DataGridFocusContextShape;
}) => {
return (event: KeyboardEvent) => {
@@ -218,8 +200,7 @@ export const createKeyDownHandler = ({
}
} else if (key === keys.ARROW_UP) {
event.preventDefault();
- const minimumIndex = headerIsInteractive ? -1 : 0;
- if (y > minimumIndex) {
+ if (y > -1) {
setFocusedCell([x, y - 1]);
}
} else if (key === keys.ARROW_RIGHT) {
@@ -312,18 +293,3 @@ export const getParentCellContent = (_element: Node | HTMLElement) => {
}
return element;
};
-
-/**
- * Focus fixes & workarounds
- */
-
-// If the focus is on the header, and the header is no longer interactive,
-// move the focus down to the first row
-export const useHeaderFocusWorkaround = (headerIsInteractive: boolean) => {
- const { focusedCell, setFocusedCell } = useContext(DataGridFocusContext);
- useEffect(() => {
- if (!headerIsInteractive && focusedCell && focusedCell[1] === -1) {
- setFocusedCell([focusedCell[0], 0]);
- }
- }, [headerIsInteractive, focusedCell, setFocusedCell]);
-};
diff --git a/src/components/datagrid/utils/scrolling.tsx b/src/components/datagrid/utils/scrolling.tsx
index 9e796d7384a..52ff7cd4831 100644
--- a/src/components/datagrid/utils/scrolling.tsx
+++ b/src/components/datagrid/utils/scrolling.tsx
@@ -16,7 +16,7 @@ import React, {
} from 'react';
import { VariableSizeGrid as Grid } from 'react-window';
-import { DataGridCellPopoverContext } from '../body/data_grid_cell_popover';
+import { DataGridCellPopoverContext } from '../body/cell';
import { EuiDataGridStyle } from '../data_grid_types';
import { DataGridFocusContext } from './focus';