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 cellActions.

-
+ ), 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

Click and key events

-

- 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" >
- 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.

+
+ +
+
@@ -1041,7 +1185,7 @@ exports[`EuiDataGrid rendering renders control columns 1`] = `
-
-
- 0 -
- + 0
-
+
- 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 -
- + 0
-
+
-
-
- 1 -
- + 1
-
+
- 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 -
- + 1
-
+
-
-
- 2 -
- + 2
-
+
- 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 -
- + 2
-
+
@@ -1724,7 +1928,7 @@ exports[`EuiDataGrid rendering renders custom column headers 1`] = ` role="row" >
- 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.

+
+ +
+
@@ -2150,7 +2498,7 @@ exports[`EuiDataGrid rendering renders with common and div attributes 1`] = ` role="row" >
- 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.

+
+ +
+
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" >
- 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.

+
+ +
+
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" >
- 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.

+
+ +
+
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`] = `
@@ -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 = ( + <> + + + + ); + + 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} + /> + + + ); + + 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`] = `
[data-focus-lock-disabled] { + width: 100%; + } + + &:focus, + &--hasColumnActions:focus-within, + &--isActionsPopoverOpen { @include euiDataGridCellFocus; - border-top: none; } // We only truncate if the cell is not a control column. &:not(.euiDataGridHeaderCell--controlColumn) { - &:focus-within { - @include euiDataGridCellFocus; - border-top: none; - } - .euiDataGridHeaderCell__button { position: relative; display: flex; diff --git a/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx b/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx index 2d7a825bae0..2d5accb778b 100644 --- a/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx +++ b/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx @@ -20,7 +20,6 @@ describe('EuiDataGridControlHeaderCell', () => { rowCellRender: () =>
, width: 50, }, - headerIsInteractive: true, }; it('renders', () => { @@ -28,7 +27,6 @@ describe('EuiDataGridControlHeaderCell', () => { expect(component).toMatchInlineSnapshot(` = ({ controlColumn, index, headerIsInteractive }) => { +> = ({ controlColumn, index }) => { const { headerCellRender: HeaderCellRender, headerCellProps, @@ -32,7 +32,6 @@ export const EuiDataGridControlHeaderCell: FunctionComponent< id={id} index={index} width={width} - headerIsInteractive={headerIsInteractive} >
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..7d9f5be4709 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 @@ -38,7 +38,6 @@ describe('EuiDataGridHeaderCell', () => { setColumnWidth: jest.fn(), setVisibleColumns: jest.fn(), switchColumnPos: jest.fn(), - headerIsInteractive: false, }; it('renders', () => { @@ -255,7 +254,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..6e67ce1ac5e 100644 --- a/src/components/datagrid/body/header/data_grid_header_cell.tsx +++ b/src/components/datagrid/body/header/data_grid_header_cell.tsx @@ -48,16 +48,10 @@ export const EuiDataGridHeaderCell: FunctionComponent< setColumnWidth, setVisibleColumns, switchColumnPos, - headerIsInteractive, }) => { const { id, display, displayAsText, displayHeaderCellProps } = column; const width = columnWidths[id] || defaultColumnWidth; - const columnType = schema[id] ? schema[id].columnType : null; - const classes = classnames( - { [`euiDataGridHeaderCell--${columnType}`]: columnType }, - displayHeaderCellProps?.className - ); const { setFocusedCell, focusFirstVisibleInteractiveCell } = useContext(DataGridFocusContext); @@ -81,6 +75,10 @@ export const EuiDataGridHeaderCell: FunctionComponent< const showColumnActions = columnActions && columnActions.length > 0; const actionsButtonRef = useRef(null); + const focusActionsButton = useCallback(() => { + actionsButtonRef.current?.focus(); + }, []); + const [isActionsButtonFocused, setIsActionsButtonFocused] = useState(false); const { sortingArrow, ariaSort, sortingScreenReaderText } = useSortingUtils({ sorting, @@ -108,6 +106,15 @@ export const EuiDataGridHeaderCell: FunctionComponent< ); + const classes = classnames( + { + [`euiDataGridHeaderCell--${columnType}`]: columnType, + 'euiDataGridHeaderCell--hasColumnActions': showColumnActions, + 'euiDataGridHeaderCell--isActionsPopoverOpen': isPopoverOpen, + }, + displayHeaderCellProps?.className + ); + return ( {column.isResizable !== false && width != null ? (
+ } + renderFocusTrap={false} + updateCellFocusContext={[Function]} + > +
`); @@ -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} > -
+ } + renderFocusTrap={false} + updateCellFocusContext={[Function]} + > +
`); }); - 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';