From d17b6a5037d5a8baf1b64bd5e49b3c9e7da17c82 Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Wed, 22 Nov 2023 20:13:39 -0800
Subject: [PATCH 01/20] [organization] Move cell-related files to their own
folder
- there's enough now that it's clogging up the `body/` folder and it makes sense to
---
.../data_grid_cell.test.tsx.snap | 0
.../body/{ => cell}/data_grid_cell.test.tsx | 10 +++++-----
.../body/{ => cell}/data_grid_cell.tsx | 20 +++++++++----------
.../data_grid_cell_actions.test.tsx | 2 +-
.../{ => cell}/data_grid_cell_actions.tsx | 15 ++++++++------
.../data_grid_cell_popover.spec.tsx | 4 ++--
.../data_grid_cell_popover.test.tsx | 6 +++---
.../{ => cell}/data_grid_cell_popover.tsx | 10 +++++-----
.../data_grid_cell_wrapper.test.tsx | 4 ++--
.../{ => cell}/data_grid_cell_wrapper.tsx | 4 ++--
src/components/datagrid/body/cell/index.ts | 16 +++++++++++++++
.../datagrid/body/data_grid_body_custom.tsx | 2 +-
.../body/data_grid_body_virtualized.tsx | 2 +-
.../body/footer/data_grid_footer_row.tsx | 3 +--
src/components/datagrid/data_grid.tsx | 5 +----
src/components/datagrid/utils/scrolling.tsx | 2 +-
16 files changed, 60 insertions(+), 45 deletions(-)
rename src/components/datagrid/body/{ => cell}/__snapshots__/data_grid_cell.test.tsx.snap (100%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell.test.tsx (98%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell.tsx (97%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell_actions.test.tsx (99%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell_actions.tsx (93%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell_popover.spec.tsx (98%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell_popover.test.tsx (97%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell_popover.tsx (96%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell_wrapper.test.tsx (95%)
rename src/components/datagrid/body/{ => cell}/data_grid_cell_wrapper.tsx (98%)
create mode 100644 src/components/datagrid/body/cell/index.ts
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 100%
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
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 98%
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..58ef5dea8a4 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,11 @@
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 { 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 { EuiDataGridCell } from './data_grid_cell';
diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/cell/data_grid_cell.tsx
similarity index 97%
rename from src/components/datagrid/body/data_grid_cell.tsx
rename to src/components/datagrid/body/cell/data_grid_cell.tsx
index 61ad48987c0..a4e0a87c712 100644
--- a/src/components/datagrid/body/data_grid_cell.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell.tsx
@@ -21,14 +21,14 @@ import React, {
} 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 { 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 {
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 { IS_JEST_ENVIRONMENT } from '../../../../utils';
const EuiDataGridCellContent: FunctionComponent<
EuiDataGridCellValueProps & {
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 98%
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..cf02b7a1eee 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',
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/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_custom.tsx b/src/components/datagrid/body/data_grid_body_custom.tsx
index 881deb3f642..bc8c4147cd6 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
diff --git a/src/components/datagrid/body/data_grid_body_virtualized.tsx b/src/components/datagrid/body/data_grid_body_virtualized.tsx
index c85c7c52cc8..43370d40804 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,
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/data_grid.tsx b/src/components/datagrid/data_grid.tsx
index a98630ce39a..39aaa9924b1 100644
--- a/src/components/datagrid/data_grid.tsx
+++ b/src/components/datagrid/data_grid.tsx
@@ -41,10 +41,7 @@ import {
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 {
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';
From d670e85e1c1a3863fb53175d840d25163d58c223 Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Thu, 23 Nov 2023 10:56:49 -0800
Subject: [PATCH 02/20] Add new focus util
---
.../datagrid/body/cell/focus_utils.test.tsx | 182 ++++++++++++++++++
.../datagrid/body/cell/focus_utils.tsx | 147 ++++++++++++++
2 files changed, 329 insertions(+)
create mode 100644 src/components/datagrid/body/cell/focus_utils.test.tsx
create mode 100644 src/components/datagrid/body/cell/focus_utils.tsx
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..1dcbccacb07
--- /dev/null
+++ b/src/components/datagrid/body/cell/focus_utils.test.tsx
@@ -0,0 +1,182 @@
+/*
+ * 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', () => {
+ 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..6d80a472955
--- /dev/null
+++ b/src/components/datagrid/body/cell/focus_utils.tsx
@@ -0,0 +1,147 @@
+/*
+ * 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,
+} from 'react';
+import { tabbable } from 'tabbable';
+
+import { keys } from '../../../../services';
+import { EuiFocusTrap } from '../../../focus_trap';
+
+/**
+ * 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]);
+
+ 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;
+};
From a79faa38a374e0eadc07d284fdda25efb754a345 Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Wed, 3 Jan 2024 13:56:13 -0800
Subject: [PATCH 03/20] [EuiDataGridCell] Replace focus logic with new focus
util
- all of the removed methods are now totally unnecessary and handled as-is by `HandleInteractiveChildren`
---
.../datagrid/body/cell/data_grid_cell.tsx | 160 +++---------------
1 file changed, 20 insertions(+), 140 deletions(-)
diff --git a/src/components/datagrid/body/cell/data_grid_cell.tsx b/src/components/datagrid/body/cell/data_grid_cell.tsx
index a4e0a87c712..11e469597c2 100644
--- a/src/components/datagrid/body/cell/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,10 +19,9 @@ 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';
@@ -44,6 +42,7 @@ import {
} 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 & {
@@ -173,38 +172,18 @@ export class EuiDataGridCell extends Component<
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 });
}
};
@@ -429,62 +408,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;
@@ -659,48 +585,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;
- }
- }
- }
}
};
@@ -751,19 +635,17 @@ export class EuiDataGridCell extends Component<
>
);
- const cellContent = isExpandable ? (
-
- ) : (
- {
- this.setState({ isEntered: false }, this.preventTabbing);
- }}
- clickOutsideDisables={true}
+ const cellContent = (
+
-
-
+
+
);
const cell = (
@@ -782,14 +664,12 @@ export class EuiDataGridCell extends Component<
data-gridcell-row-index={this.props.rowIndex} // Index from data, not affected by sorting or pagination
data-gridcell-visible-row-index={this.props.visibleRowIndex} // Affected by sorting & pagination
onKeyDown={handleCellKeyDown}
- onFocus={this.onFocus}
onMouseEnter={() => {
this.setState({ enableInteractions: true });
}}
onMouseLeave={() => {
this.setState({ enableInteractions: false });
}}
- onBlur={this.onBlur}
>
{cellContent}
From 3c04c7535a3aeeb2e922a5637a719fed24d60280 Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Wed, 3 Jan 2024 13:57:34 -0800
Subject: [PATCH 04/20] [EuiDataGridCell] Continue removing now-unnecessary
focus timeouts & state
+ misc imports reorder/reorg
---
.../body/cell/data_grid_cell.test.tsx | 15 -------
.../datagrid/body/cell/data_grid_cell.tsx | 41 ++-----------------
src/components/datagrid/data_grid_types.ts | 3 --
3 files changed, 4 insertions(+), 55 deletions(-)
diff --git a/src/components/datagrid/body/cell/data_grid_cell.test.tsx b/src/components/datagrid/body/cell/data_grid_cell.test.tsx
index 58ef5dea8a4..0fc0b91632a 100644
--- a/src/components/datagrid/body/cell/data_grid_cell.test.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell.test.tsx
@@ -184,26 +184,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 });
- });
- });
});
});
diff --git a/src/components/datagrid/body/cell/data_grid_cell.tsx b/src/components/datagrid/body/cell/data_grid_cell.tsx
index 11e469597c2..092212d9c43 100644
--- a/src/components/datagrid/body/cell/data_grid_cell.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell.tsx
@@ -20,11 +20,13 @@ import React, {
} from 'react';
import { createPortal } from 'react-dom';
+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 {
@@ -41,7 +43,6 @@ import {
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<
@@ -142,17 +143,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;
@@ -160,13 +150,9 @@ 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;
@@ -284,7 +270,6 @@ export class EuiDataGridCell extends Component<
};
componentWillUnmount() {
- window.clearTimeout(this.focusTimeout);
if (this.unsubscribeCell) {
this.unsubscribeCell();
}
@@ -383,12 +368,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;
}
@@ -531,11 +511,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',
@@ -611,7 +586,7 @@ export class EuiDataGridCell extends Component<
ariaRowIndex,
};
- const cellActions = showCellActions && (
+ const cellActions = (
<>
{
- this.setState({ enableInteractions: true });
- }}
- onMouseLeave={() => {
- this.setState({ enableInteractions: false });
- }}
>
{cellContent}
diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts
index 004e0d2f2a4..f8cadd2308c 100644
--- a/src/components/datagrid/data_grid_types.ts
+++ b/src/components/datagrid/data_grid_types.ts
@@ -629,9 +629,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
}
From 21ae96c8743e1784998761f8d44781bab16806ca Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Thu, 4 Jan 2024 13:45:51 -0800
Subject: [PATCH 05/20] [downstream tests] Update tests to account for
cellActions change
- cell actions are rendered in the DOM, just not visible, which saves us from having to add a bunch of extra hover/focus state trackers
---
.../datagrid/body/cell/data_grid_cell.tsx | 6 ++---
.../body/cell/data_grid_cell_popover.spec.tsx | 24 ++++++++++++++-----
.../body/footer/data_grid_footer_row.spec.tsx | 6 ++---
src/components/datagrid/data_grid.test.tsx | 24 ++++++++-----------
4 files changed, 34 insertions(+), 26 deletions(-)
diff --git a/src/components/datagrid/body/cell/data_grid_cell.tsx b/src/components/datagrid/body/cell/data_grid_cell.tsx
index 092212d9c43..7166d75e3ee 100644
--- a/src/components/datagrid/body/cell/data_grid_cell.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell.tsx
@@ -586,7 +586,7 @@ export class EuiDataGridCell extends Component<
ariaRowIndex,
};
- const cellActions = (
+ const cellActions = isExpandable ? (
<>
>
- );
+ ) : undefined;
const cellContent = (
);
diff --git a/src/components/datagrid/body/cell/data_grid_cell_popover.spec.tsx b/src/components/datagrid/body/cell/data_grid_cell_popover.spec.tsx
index cf02b7a1eee..a1bd45cdd36 100644
--- a/src/components/datagrid/body/cell/data_grid_cell_popover.spec.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell_popover.spec.tsx
@@ -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/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/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx
index 2eb963f13dd..af7adc821b3 100644
--- a/src/components/datagrid/data_grid.test.tsx
+++ b/src/components/datagrid/data_grid.test.tsx
@@ -1329,7 +1329,7 @@ describe('EuiDataGrid', () => {
const component = mount(
{},
@@ -2177,21 +2177,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();
From 1845f5a1f7f673f6a0888c62068019fb872d761b Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Wed, 3 Jan 2024 14:17:49 -0800
Subject: [PATCH 06/20] [EuiDataGridCell] Remove Jest focus tests in favor of
Cypress ones
---
.../data_grid_cell.test.tsx.snap | 24 +++-
.../body/cell/data_grid_cell.test.tsx | 72 +---------
.../datagrid/body/cell/focus_utils.spec.tsx | 128 ++++++++++++++++++
3 files changed, 152 insertions(+), 72 deletions(-)
create mode 100644 src/components/datagrid/body/cell/focus_utils.spec.tsx
diff --git a/src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap b/src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap
index d06990806c9..c455eda3dfe 100644
--- a/src/components/datagrid/body/cell/__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`] = `
@@ -74,5 +72,27 @@ exports[`EuiDataGridCell renders 1`] = `
-
someColumn, column 1, row 1
+
+
+
+
`;
diff --git a/src/components/datagrid/body/cell/data_grid_cell.test.tsx b/src/components/datagrid/body/cell/data_grid_cell.test.tsx
index 0fc0b91632a..1e3db47cb5b 100644
--- a/src/components/datagrid/body/cell/data_grid_cell.test.tsx
+++ b/src/components/datagrid/body/cell/data_grid_cell.test.tsx
@@ -9,7 +9,6 @@
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';
@@ -636,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 };
@@ -762,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/cell/focus_utils.spec.tsx b/src/components/datagrid/body/cell/focus_utils.spec.tsx
new file mode 100644
index 00000000000..780930884be
--- /dev/null
+++ b/src/components/datagrid/body/cell/focus_utils.spec.tsx
@@ -0,0 +1,128 @@
+/*
+ * 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 = (
+ <>
+
+
+ >
+ );
+
+ describe('does not render a focus trap', () => {
+ const props = {
+ ...baseProps,
+ columns: [{ id: 'column', isExpandable: true, actions: {} }],
+ };
+
+ 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 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,
+ },
+ ]}
+ renderCellValue={() => interactiveChildren}
+ />
+
+ >
+ );
+
+ 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"]').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');
+ });
+ });
+});
From fdb932abcef39e7dab74292e3c3265e3d8058b7a Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Thu, 4 Jan 2024 11:48:22 -0800
Subject: [PATCH 07/20] [EuiDataGridHeaderCell] Replace focus logic with new
util
- all of the removed methods are now totally unnecessary and handled as-is by `HandleInteractiveChildren
---
.../header/data_grid_header_cell.test.tsx | 1 -
.../body/header/data_grid_header_cell.tsx | 5 +-
.../data_grid_header_cell_wrapper.test.tsx | 46 ++++++
.../header/data_grid_header_cell_wrapper.tsx | 136 +++---------------
4 files changed, 70 insertions(+), 118 deletions(-)
diff --git a/src/components/datagrid/body/header/data_grid_header_cell.test.tsx b/src/components/datagrid/body/header/data_grid_header_cell.test.tsx
index 109489fedc3..afe7466d882 100644
--- a/src/components/datagrid/body/header/data_grid_header_cell.test.tsx
+++ b/src/components/datagrid/body/header/data_grid_header_cell.test.tsx
@@ -255,7 +255,6 @@ describe('EuiDataGridHeaderCell', () => {
fireEvent.click(toggle);
waitForEuiPopoverOpen();
- expect(mockFocusContext.setFocusedCell).toHaveBeenCalledWith([0, -1]);
fireEvent.click(toggle);
waitForEuiPopoverClose();
diff --git a/src/components/datagrid/body/header/data_grid_header_cell.tsx b/src/components/datagrid/body/header/data_grid_header_cell.tsx
index beaf8936913..1a2ff0592c9 100644
--- a/src/components/datagrid/body/header/data_grid_header_cell.tsx
+++ b/src/components/datagrid/body/header/data_grid_header_cell.tsx
@@ -139,10 +139,7 @@ export const EuiDataGridHeaderCell: FunctionComponent<
<>
-
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.
-
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.
-
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.
-
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.
-
A, column 1, row 1
+ .
+ Press the Enter key to expand this cell.
-
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.
-
A, column 1, row 1
+ .
+ Press the Enter key to expand this cell.
-
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.
-
columnA, column 1, row 1
+ .
+ Press the Enter key to expand this cell.
-
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.
-
columnA, column 1, row 1
+ .
+ Press the Enter key to expand this cell.
-
columnB, column 2, row 1
+ .
+ Press the Enter key to expand this cell.
-
someColumn, column 1, row 1
+ .
+ Press the Enter key to expand this cell.
+ {cellActions && (
+ <>
+ {'. '}
+
+ >
+ )}
);
diff --git a/src/components/datagrid/body/cell/focus_utils.test.tsx b/src/components/datagrid/body/cell/focus_utils.test.tsx
index 1dcbccacb07..a35407d00dc 100644
--- a/src/components/datagrid/body/cell/focus_utils.test.tsx
+++ b/src/components/datagrid/body/cell/focus_utils.test.tsx
@@ -124,6 +124,23 @@ describe('HandleInteractiveChildren', () => {
});
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();
diff --git a/src/components/datagrid/body/cell/focus_utils.tsx b/src/components/datagrid/body/cell/focus_utils.tsx
index 77b655eb6cb..4d425244f83 100644
--- a/src/components/datagrid/body/cell/focus_utils.tsx
+++ b/src/components/datagrid/body/cell/focus_utils.tsx
@@ -17,6 +17,8 @@ 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.
@@ -120,6 +122,17 @@ export const FocusTrappedChildren: FunctionComponent<
clickOutsideDisables={true}
>
{children}
+
+
+
+ {' - '}
+
+
+
);
};
From d6c70f6cd0e7d7c1082d9ba888910e9533b144eb Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Fri, 5 Jan 2024 10:42:14 -0800
Subject: [PATCH 19/20] [PR feedback] Fix focus fighting bug + add E2E
regression test for behavior
---
.../data_grid_header_cell_wrapper.test.tsx | 2 ++
.../header/data_grid_header_cell_wrapper.tsx | 14 ++++++++-
src/components/datagrid/data_grid.spec.tsx | 31 ++++++++++++++++---
3 files changed, 41 insertions(+), 6 deletions(-)
diff --git a/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx b/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx
index 3d4fd903bbe..a1ed1e2a62f 100644
--- a/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx
+++ b/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx
@@ -52,6 +52,7 @@ describe('EuiDataGridHeaderCellWrapper', () => {
data-gridcell-row-index="-1"
data-gridcell-visible-row-index="-1"
data-test-subj="dataGridHeaderCell-someColumn"
+ onFocus={[Function]}
role="columnheader"
style={Object {}}
tabIndex={0}
@@ -100,6 +101,7 @@ describe('EuiDataGridHeaderCellWrapper', () => {
data-gridcell-row-index="-1"
data-gridcell-visible-row-index="-1"
data-test-subj="dataGridHeaderCell-someColumn"
+ onFocus={[Function]}
role="columnheader"
style={
Object {
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 fc969865f61..fde48ab0620 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,6 +9,7 @@
import classnames from 'classnames';
import React, {
FunctionComponent,
+ FocusEventHandler,
useContext,
useEffect,
useRef,
@@ -64,12 +65,23 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent<
}
}, [isFocused]);
+ // 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 === headerRef.current) {
+ focusActionsButton?.();
+ }
+ },
+ [hasActionsPopover, focusActionsButton]
+ );
+
return (
{
{
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 = {
@@ -436,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');
+ });
});
});
From 1d9e46faa0f1f3fcbc912e625e20574825477698 Mon Sep 17 00:00:00 2001
From: Cee Chen
Date: Fri, 5 Jan 2024 13:06:39 -0800
Subject: [PATCH 20/20] Fix flaky Cypress test
- caused by race condition with ref not updating `HandleInteractiveChildren`
---
.../data_grid_body_custom.test.tsx.snap | 4 ++++
.../data_grid_body_virtualized.test.tsx.snap | 4 ++++
.../data_grid_header_cell.test.tsx.snap | 2 ++
.../header/data_grid_header_cell_wrapper.tsx | 21 +++++++++----------
4 files changed, 20 insertions(+), 11 deletions(-)
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 f3d5e74d38e..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
@@ -28,7 +28,9 @@ exports[`EuiDataGridBodyCustomRender treats \`renderCustomGridBody\` as a render