From 8b1f70fc55a05fc18a154731c96a7ea76d141a4e Mon Sep 17 00:00:00 2001 From: Michail Yasonik Date: Tue, 13 Aug 2019 17:24:23 +0300 Subject: [PATCH] Adds more complex focus control for DataGrid --- package.json | 3 +- src-docs/src/views/datagrid/datagrid.js | 40 +- src-docs/src/views/icon/icons.js | 2 +- .../__snapshots__/data_grid.test.tsx.snap | 460 +++++++++++++++--- src/components/datagrid/_data_grid.scss | 4 +- src/components/datagrid/data_grid.test.tsx | 291 +++++++++-- src/components/datagrid/data_grid.tsx | 144 +++--- src/components/datagrid/data_grid_body.tsx | 8 + src/components/datagrid/data_grid_cell.tsx | 123 ++++- .../datagrid/data_grid_data_row.tsx | 6 + .../datagrid/data_grid_header_row.tsx | 2 +- src/components/datagrid/utils.tsx | 8 + src/services/key_codes.ts | 2 + yarn.lock | 13 +- 14 files changed, 935 insertions(+), 171 deletions(-) create mode 100644 src/components/datagrid/utils.tsx diff --git a/package.json b/package.json index b834384fe05..28958bb2a1a 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "react-is": "~16.3.0", "react-virtualized": "^9.18.5", "resize-observer-polyfill": "^1.5.0", - "tabbable": "^1.1.0", + "tabbable": "^3.0.0", "uuid": "^3.1.0" }, "devDependencies": { @@ -85,6 +85,7 @@ "@types/react-is": "~16.3.0", "@types/react-virtualized": "^9.18.6", "@types/resize-observer-browser": "^0.1.1", + "@types/tabbable": "^3.1.0", "@types/uuid": "^3.4.4", "@typescript-eslint/eslint-plugin": "^1.9.0", "@typescript-eslint/parser": "^1.9.0", diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index a6594475771..88596be9d4f 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -7,7 +7,10 @@ import { EuiFormRow, EuiPopover, EuiButton, + EuiButtonIcon, + EuiLink, } from '../../../../src/components/'; +import { iconTypes } from '../../../../src-docs/src/views/icon/icons'; const columns = [ { @@ -22,6 +25,9 @@ const columns = [ { id: 'contributions', }, + { + id: 'actions', + }, ]; const data = [ @@ -304,6 +310,13 @@ export default class DataGrid extends Component { pagination: { ...pagination, pageSize }, })); + dummyIcon = () => ( + + ); + render() { const { pagination } = this.state; @@ -396,7 +409,32 @@ export default class DataGrid extends Component { rowHover: this.state.rowHoverSelected, header: this.state.headerSelected, }} - renderCellValue={({ rowIndex, columnId }) => data[rowIndex][columnId]} + renderCellValue={({ rowIndex, columnId }) => { + const value = data[rowIndex][columnId]; + + if (columnId === 'actions') { + return ( + <> + {this.dummyIcon()} + {this.dummyIcon()} + + ); + } + + if (columnId === 'url') { + return {value}; + } + + if (columnId === 'avatar_url') { + return ( +

+ Avatar: {value} +

+ ); + } + + return value; + }} pagination={{ ...pagination, pageSizeOptions: [5, 10, 25], diff --git a/src-docs/src/views/icon/icons.js b/src-docs/src/views/icon/icons.js index 79dd1154109..eb22dc91842 100644 --- a/src-docs/src/views/icon/icons.js +++ b/src-docs/src/views/icon/icons.js @@ -20,7 +20,7 @@ import { EuiCopy, } from '../../../../src/components'; -const iconTypes = [ +export const iconTypes = [ 'alert', 'apmTrace', 'apps', diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index a6815af58ad..b4e74426698 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -1,5 +1,200 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`EuiDataGrid keyboard controls allows user to enter and exit grid navigation 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`EuiDataGrid keyboard controls allows user to enter and exit grid navigation 2`] = ` +
+
+
+ +
+
+
+`; + +exports[`EuiDataGrid keyboard controls allows user to enter and exit grid navigation 3`] = ` +
+
+
+ +
+
+
+`; + +exports[`EuiDataGrid keyboard controls allows user to enter and exit grid navigation 4`] = ` +
+
+
+ +
+
+
+`; + +exports[`EuiDataGrid keyboard controls allows user to enter and exit grid navigation 5`] = ` +
+
+
+ +
+
+
+`; + exports[`EuiDataGrid pagination renders 1`] = `
+
+
+ A +
+
+
+
+ B +
+
+
+
+
+
- A + 0, A
+
+
+
+
+
- B + 0, B
+
+
+
- 0, A -
+ data-focus-guard="true" + style="width:1px;height:0px;padding:0;overflow:hidden;position:fixed;top:1px;left:1px" + tabindex="-1" + />
+
- 0, B +
+ 1, A +
+
- 1, A -
+ />
+
- 1, B +
+ 1, B +
+
+
+
+
+
- 2, A +
+ 2, A +
+
+
+
+
+
- 2, B +
+ 2, B +
+
-
, +
, + , ] `; diff --git a/src/components/datagrid/_data_grid.scss b/src/components/datagrid/_data_grid.scss index 983544e2990..39cd400b582 100644 --- a/src/components/datagrid/_data_grid.scss +++ b/src/components/datagrid/_data_grid.scss @@ -1,4 +1,4 @@ -.euiDataGrid__content { +.euiDataGrid { @include euiScrollBar; font-feature-settings: 'tnum' 1; // Tabular numbers overflow-x: auto; @@ -16,4 +16,4 @@ border-top: $euiBorderThin; border-right: $euiBorderThin; border-left: $euiBorderThin; -} \ No newline at end of file +} diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index d2af38cf2f9..36d6eff1c4a 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -9,6 +9,11 @@ import { import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; import { keyCodes } from '../../services'; import { act } from 'react-dom/test-utils'; +import cheerio from 'cheerio'; + +jest.mock('../../services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => 'htmlId', +})); function getFocusableCell(component: ReactWrapper) { return findTestSubject(component, 'dataGridRowCell').find('[tabIndex=0]'); @@ -105,6 +110,7 @@ declare global { } } } + function setColumnVisibility( datagrid: ReactWrapper, columnId: string, @@ -228,6 +234,34 @@ describe('EuiDataGrid', () => { expect(component).toMatchSnapshot(); }); + + it('renders with appropriate role structure', () => { + const component = render( + + `${rowIndex}, ${columnId}` + } + /> + ); + + // purposefully not using data-test-subj attrs to test role semantics + const grid = component.find('[role="grid"]'); + const rows = grid.children('[role="row"]'); + + // technically, this test should also allow role=rowgroup but we don't currently use rowgroups + expect(grid.children().length).toBe(rows.length); + + rows.each((i, element) => { + const $element = cheerio(element); + const allCells = $element.children( + '[role="columnheader"], [role="rowheader"], [role="gridcell"]' + ); + expect($element.children().length).toBe(allCells.length); + }); + }); }); describe('cell rendering', () => { @@ -343,7 +377,7 @@ Array [ expect(extractGridData(component)).toEqual([['Column'], ['6'], ['7']]); }); - it('pages are navigatable through page links', () => { + it('pages are navigable through page links', () => { const component = mount( { - const component = mount( - `${rowIndex}, ${columnId}`} - /> - ); + it('supports simple arrow navigation', () => { + const component = mount( + + `${rowIndex}, ${columnId}` + } + /> + ); + + let focusableCell = getFocusableCell(component); + expect(focusableCell.length).toEqual(1); + expect(focusableCell.text()).toEqual('0, A'); + + focusableCell + .simulate('focus') + .simulate('keydown', { keyCode: keyCodes.LEFT }); + + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('0, A'); // focus should not move when up against an edge + + focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); + expect(focusableCell.text()).toEqual('0, A'); // focus should not move when up against an edge + + focusableCell.simulate('keydown', { keyCode: keyCodes.DOWN }); - let focusableCell = getFocusableCell(component); - expect(focusableCell.length).toEqual(1); - expect(focusableCell.text()).toEqual('0, A'); + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, A'); - focusableCell - .simulate('focus') - .simulate('keydown', { keyCode: keyCodes.LEFT }); + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); - focusableCell = getFocusableCell(component); - expect(focusableCell.text()).toEqual('0, A'); // focus should not move when up against an edge + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, B'); - focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); - expect(focusableCell.text()).toEqual('0, A'); // focus should not move when up against an edge + focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); - focusableCell.simulate('keydown', { keyCode: keyCodes.DOWN }); + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('0, B'); - focusableCell = getFocusableCell(component); - expect(focusableCell.text()).toEqual('1, A'); + focusableCell.simulate('keydown', { keyCode: keyCodes.LEFT }); - focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('0, A'); + }); + it('does not break arrow key focus control behavior when also using a mouse', () => { + const component = mount( + + `${rowIndex}, ${columnId}` + } + /> + ); + + let focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('0, A'); + + findTestSubject(component, 'dataGridRowCell') + .at(3) + .simulate('focus'); + + focusableCell = getFocusableCell(component); + expect(focusableCell.length).toEqual(1); + expect(focusableCell.text()).toEqual('1, B'); + }); + it('supports arrow navigation through grids with different interactive cells', () => { + const component = mount( + { + if (columnId === 'A') { + return `${rowIndex}, A`; + } + + if (columnId === 'B') { + return ; + } + + if (columnId === 'C') { + return ( + <> + , + + ); + } + + if (columnId === 'D') { + return ( +
+ {rowIndex}, +
+ ); + } + + return 'error'; + }} + /> + ); - focusableCell = getFocusableCell(component); - expect(focusableCell.text()).toEqual('1, B'); + /** + * Make sure we start from a happy state + */ + let focusableCell = getFocusableCell(component); + expect(focusableCell.length).toEqual(1); + expect(focusableCell.text()).toEqual('0, A'); + focusableCell + .simulate('focus') + .simulate('keydown', { keyCode: keyCodes.DOWN }); + + /** + * On text only cells, the cell receives focus + */ + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, A'); // make sure we're on the right cell + expect(focusableCell.getDOMNode()).toBe(document.activeElement); + + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); + + /** + * On cells with 1 interactive item, the interactive item receives focus + */ + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, B'); + expect(focusableCell.find('button').getDOMNode()).toBe( + document.activeElement + ); - focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); - focusableCell = getFocusableCell(component); - expect(focusableCell.text()).toEqual('0, B'); + /** + * On cells with multiple interactive items, the cell receives focus + */ + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, C'); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); - focusableCell.simulate('keydown', { keyCode: keyCodes.LEFT }); + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); - focusableCell = getFocusableCell(component); - expect(focusableCell.text()).toEqual('0, A'); + /** + * On cells with 1 interactive item and non-interactive item(s), the cell receives focus + */ + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, D'); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); + }); + it('allows user to enter and exit grid navigation', async () => { + const component = mount( + ( + <> + , + + )} + /> + ); + + /** + * Make sure we start from a happy state + */ + let focusableCell = getFocusableCell(component); + expect(focusableCell.length).toEqual(1); + expect(focusableCell.text()).toEqual('0, A'); + focusableCell + .simulate('focus') + .simulate('keydown', { keyCode: keyCodes.DOWN }); + focusableCell = getFocusableCell(component); + + /** + * Confirm initial state is with grid navigation turn on + */ + expect(focusableCell.text()).toEqual('1, A'); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); + expect(takeMountedSnapshot(component)).toMatchSnapshot(); + + /** + * Disable grid navigation using ENTER + */ + focusableCell + .simulate('keydown', { keyCode: keyCodes.ENTER }) + .simulate('keydown', { keyCode: keyCodes.DOWN }); + + let buttons = focusableCell.find('button'); + + // grid navigation is disabled, location should not move + expect(buttons.at(0).text()).toEqual('1'); + expect(buttons.at(1).text()).toEqual('A'); + expect(buttons.at(0).getDOMNode()).toBe(document.activeElement); // focus should move to first button + expect(takeMountedSnapshot(component)).toMatchSnapshot(); // should prove focus lock is on + + /** + * Enable grid navigation ESCAPE + */ + focusableCell.simulate('keydown', { keyCode: keyCodes.ESCAPE }); + focusableCell = getFocusableCell(component); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); // focus should move back to cell + + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, B'); // grid navigation is enabled again, check that we can move + expect(takeMountedSnapshot(component)).toMatchSnapshot(); + + /** + * Disable grid navigation using F2 + */ + focusableCell = getFocusableCell(component); + focusableCell + .simulate('keydown', { keyCode: keyCodes.F2 }) + .simulate('keydown', { keyCode: keyCodes.UP }); + buttons = focusableCell.find('button'); + + // grid navigation is disabled, location should not move + expect(buttons.at(0).text()).toEqual('1'); + expect(buttons.at(1).text()).toEqual('B'); + expect(buttons.at(0).getDOMNode()).toBe(document.activeElement); // focus should move to first button + expect(takeMountedSnapshot(component)).toMatchSnapshot(); // should prove focus lock is on + + /** + * Enable grid navigation using F2 + */ + focusableCell.simulate('keydown', { keyCode: keyCodes.F2 }); + focusableCell = getFocusableCell(component); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); // focus should move back to cell + + focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('0, B'); // grid navigation is enabled again, check that we can move + expect(takeMountedSnapshot(component)).toMatchSnapshot(); + }); }); }); diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index d0acbc557e3..dc61519245c 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -17,12 +17,13 @@ import { EuiDataGridPaginationProps, } from './data_grid_types'; import { EuiDataGridCellProps } from './data_grid_cell'; -import { keyCodes } from '../../services'; +import { keyCodes, htmlIdGenerator } from '../../services'; import { EuiSpacer } from '../spacer'; import { EuiDataGridBody } from './data_grid_body'; import { useColumnSelector } from './column_selector'; // @ts-ignore-next-line import { EuiTablePagination } from '../table/table_pagination'; +import { CELL_CONTENTS_ATTR } from './utils'; // Types for styling options, passed down through the `gridStyle` prop type EuiDataGridStyleFontSizes = 's' | 'm' | 'l'; @@ -129,22 +130,20 @@ function renderPagination(props: EuiDataGridProps) { } export const EuiDataGrid: FunctionComponent = props => { + const gridRef = useRef(null); + const [interactiveCellId] = useState(htmlIdGenerator()()); const [columnWidths, setColumnWidths] = useState({}); const setColumnWidth = (columnId: string, width: number) => { setColumnWidths({ ...columnWidths, [columnId]: width }); }; - const gridRef = useRef(null); - useEffect(() => { if (gridRef.current != null) { - const gridWidth = Math.max( - gridRef.current!.clientWidth / props.columns.length, - 100 - ); + const gridWidth = gridRef.current.clientWidth; + const columnWidth = Math.max(gridWidth / props.columns.length, 100); const columnWidths = props.columns.reduce( (columnWidths: EuiDataGridColumnWidths, column) => { - columnWidths[column.id] = gridWidth; + columnWidths[column.id] = columnWidth; return columnWidths; }, {} @@ -154,44 +153,66 @@ export const EuiDataGrid: FunctionComponent = props => { }, []); const [focusedCell, setFocusedCell] = useState<[number, number]>(ORIGIN); - const onCellFocus = useCallback( - (x: number, y: number) => { - setFocusedCell([x, y]); - }, - [setFocusedCell] - ); + const [isGridNavigationEnabled, setIsGridNavigationEnabled] = useState< + boolean + >(true); - const handleKeyDown = (e: KeyboardEvent) => { + const isInteractiveCell = (element: HTMLElement) => { + if (element.getAttribute('role') !== 'gridcell') { + return false; + } + + return Boolean(element.querySelector(`[${CELL_CONTENTS_ATTR}="true"]`)); + }; + + const handleKeyDown = (event: KeyboardEvent) => { const colCount = props.columns.length - 1; const [x, y] = focusedCell; const rowCount = computeVisibleRows(props); + const { keyCode, target } = event; + + if ( + target instanceof HTMLElement && + isInteractiveCell(target) && + isGridNavigationEnabled && + (keyCode === keyCodes.ENTER || keyCode === keyCodes.F2) + ) { + setIsGridNavigationEnabled(false); + } else if ( + !isGridNavigationEnabled && + (keyCode === keyCodes.ESCAPE || keyCode === keyCodes.F2) + ) { + setIsGridNavigationEnabled(true); + } - switch (e.keyCode) { - case keyCodes.DOWN: - e.preventDefault(); - if (y < rowCount) { - setFocusedCell([x, y + 1]); - } - break; - case keyCodes.LEFT: - e.preventDefault(); - if (x > 0) { - setFocusedCell([x - 1, y]); - } - break; - case keyCodes.UP: - e.preventDefault(); - // TODO sort out when a user can arrow up into the column headers - if (y > 0) { - setFocusedCell([x, y - 1]); - } - break; - case keyCodes.RIGHT: - e.preventDefault(); - if (x < colCount) { - setFocusedCell([x + 1, y]); - } - break; + if (isGridNavigationEnabled) { + switch (keyCode) { + case keyCodes.DOWN: + if (y < rowCount) { + event.preventDefault(); + setFocusedCell([x, y + 1]); + } + break; + case keyCodes.LEFT: + if (x > 0) { + event.preventDefault(); + setFocusedCell([x - 1, y]); + } + break; + case keyCodes.UP: + // TODO sort out when a user can arrow up into the column headers + if (y > 0) { + event.preventDefault(); + setFocusedCell([x, y - 1]); + } + break; + case keyCodes.RIGHT: + if (x < colCount) { + event.preventDefault(); + setFocusedCell([x + 1, y]); + } + break; + } } }; @@ -238,28 +259,31 @@ export const EuiDataGrid: FunctionComponent = props => { role="grid" onKeyDown={handleKeyDown} ref={gridRef} - // {...label} {...rest} className={classes}> -
- - -
- - {renderPagination(props)} + +
+ + {renderPagination(props)} + ); }; diff --git a/src/components/datagrid/data_grid_body.tsx b/src/components/datagrid/data_grid_body.tsx index 7e450e7dad8..b89ab02355a 100644 --- a/src/components/datagrid/data_grid_body.tsx +++ b/src/components/datagrid/data_grid_body.tsx @@ -18,6 +18,8 @@ interface EuiDataGridBodyProps { rowCount: number; renderCellValue: EuiDataGridCellProps['renderCellValue']; pagination?: EuiDataGridPaginationProps; + isGridNavigationEnabled: EuiDataGridCellProps['isGridNavigationEnabled']; + interactiveCellId: EuiDataGridCellProps['interactiveCellId']; } export const EuiDataGridBody: FunctionComponent< @@ -31,6 +33,8 @@ export const EuiDataGridBody: FunctionComponent< rowCount, renderCellValue, pagination, + isGridNavigationEnabled, + interactiveCellId, } = props; const startRow = pagination ? pagination.pageIndex * pagination.pageSize : 0; @@ -51,6 +55,8 @@ export const EuiDataGridBody: FunctionComponent< onCellFocus={onCellFocus} renderCellValue={renderCellValue} rowIndex={i} + isGridNavigationEnabled={isGridNavigationEnabled} + interactiveCellId={interactiveCellId} /> ); } @@ -64,6 +70,8 @@ export const EuiDataGridBody: FunctionComponent< onCellFocus, renderCellValue, startRow, + isGridNavigationEnabled, + interactiveCellId, ]); return {rows}; diff --git a/src/components/datagrid/data_grid_cell.tsx b/src/components/datagrid/data_grid_cell.tsx index ee929840eb4..476a22eecd7 100644 --- a/src/components/datagrid/data_grid_cell.tsx +++ b/src/components/datagrid/data_grid_cell.tsx @@ -6,7 +6,10 @@ import React, { ReactNode, createRef, } from 'react'; +// @ts-ignore +import { EuiFocusTrap } from '../focus_trap'; import { Omit } from '../common'; +import { getTabbables, CELL_CONTENTS_ATTR } from './utils'; interface CellValueElementProps { rowIndex: number; @@ -20,6 +23,8 @@ export interface EuiDataGridCellProps { width?: number; isFocusable: boolean; onCellFocus: Function; + isGridNavigationEnabled: boolean; + interactiveCellId: string; renderCellValue: | JSXElementConstructor | ((props: CellValueElementProps) => ReactNode); @@ -29,7 +34,7 @@ interface EuiDataGridCellState {} type EuiDataGridCellValueProps = Omit< EuiDataGridCellProps, - 'width' | 'isFocusable' + 'width' | 'isFocusable' | 'isGridNavigationEnabled' | 'interactiveCellId' >; const EuiDataGridCellContent: FunctionComponent< @@ -45,36 +50,138 @@ const EuiDataGridCellContent: FunctionComponent< return ; }); +const IS_TABBABLE_ATTR = 'data-is-tabbable'; + export class EuiDataGridCell extends Component< EuiDataGridCellProps, EuiDataGridCellState > { cellRef = createRef(); + cellContentsRef = createRef(); + + isInteractiveCell() { + const cellContents = this.cellContentsRef.current; + + if (!cellContents) { + return false; + } + + const tabbables = getTabbables(cellContents); + + return ( + tabbables.length > 1 || + (tabbables.length === 1 && this.hasNotTabbables(cellContents)) + ); + } updateFocus() { - if (this.cellRef.current && this.props.isFocusable) { - this.cellRef.current.focus(); + const cell = this.cellRef.current; + const cellContents = this.cellContentsRef.current; + const { isFocusable, isGridNavigationEnabled } = this.props; + + if (cell && isFocusable && cellContents) { + const tabbables = getTabbables(cellContents); + const isASimpleInteractiveCell = + tabbables.length === 1 && !this.hasNotTabbables(cellContents); + + if ( + !isGridNavigationEnabled || + (isGridNavigationEnabled && isASimpleInteractiveCell) + ) { + (tabbables[0] as HTMLElement).focus(); + } else { + cell.focus(); + } } } - componentDidUpdate() { - this.updateFocus(); + setTabbablesTabIndex() { + const cellContents = this.cellContentsRef.current; + + if (cellContents) { + const { isFocusable, isGridNavigationEnabled } = this.props; + const areContentsFocusable = isFocusable && !isGridNavigationEnabled; + + getTabbables(cellContents).forEach(element => { + element.setAttribute('tabIndex', areContentsFocusable ? '0' : '-1'); + element.setAttribute(IS_TABBABLE_ATTR, 'true'); + }); + } + } + + hasNotTabbables(cellContents: Element) { + const clone = cellContents.cloneNode(true) as HTMLElement; + + // has to exist because we set the `IS_TABBABLE_ATTR` attribute on it + const tabbableElement = clone.querySelector(`[${IS_TABBABLE_ATTR}]`)!; + + // IE 11 doesn't support remove + if (tabbableElement.remove) { + tabbableElement.remove(); + } else { + tabbableElement.parentNode!.removeChild(tabbableElement); + } + + // textContent includes not human readable text + // but innerText causes a page reflow + // so, only force a reflow if we have a strong signal that we should + if (clone.textContent && clone.textContent.length > 0) { + // Fallback to innerText if textContent isn't available + // Only documented to fallback in tests; all officially supported browsers support innerText + if (typeof clone.innerText === 'undefined') { + return clone.textContent.length > 0; + } + + return clone.innerText.length > 0; + } + + return false; + } + + componentDidMount() { + this.setTabbablesTabIndex(); + } + + componentDidUpdate(prevProps: EuiDataGridCellProps) { + const didFocusChange = prevProps.isFocusable !== this.props.isFocusable; + const didNavigationChange = + prevProps.isGridNavigationEnabled !== this.props.isGridNavigationEnabled; + + if (didFocusChange || didNavigationChange) { + this.updateFocus(); + this.setTabbablesTabIndex(); + } } render() { - const { width, isFocusable, ...rest } = this.props; + const { + width, + isFocusable, + isGridNavigationEnabled, + interactiveCellId, + ...rest + } = this.props; const { colIndex, rowIndex, onCellFocus } = rest; + const isInteractive = this.isInteractiveCell(); + const isInteractiveCell = { + [CELL_CONTENTS_ATTR]: isInteractive, + }; return (
onCellFocus(colIndex, rowIndex)} + onFocus={() => onCellFocus([colIndex, rowIndex])} style={{ width: `${width}px` }}> - + +
+ +
+
); } diff --git a/src/components/datagrid/data_grid_data_row.tsx b/src/components/datagrid/data_grid_data_row.tsx index 121208dd88a..8bdfd8a991f 100644 --- a/src/components/datagrid/data_grid_data_row.tsx +++ b/src/components/datagrid/data_grid_data_row.tsx @@ -12,7 +12,9 @@ export type EuiDataGridDataRowProps = CommonProps & columnWidths: EuiDataGridColumnWidths; focusedCell: [number, number]; renderCellValue: EuiDataGridCellProps['renderCellValue']; + isGridNavigationEnabled: EuiDataGridCellProps['isGridNavigationEnabled']; onCellFocus: Function; + interactiveCellId: EuiDataGridCellProps['interactiveCellId']; }; const EuiDataGridDataRow: FunctionComponent< @@ -26,6 +28,8 @@ const EuiDataGridDataRow: FunctionComponent< rowIndex, focusedCell, onCellFocus, + isGridNavigationEnabled, + interactiveCellId, 'data-test-subj': _dataTestSubj, ...rest } = props; @@ -52,6 +56,8 @@ const EuiDataGridDataRow: FunctionComponent< renderCellValue={renderCellValue} onCellFocus={onCellFocus} isFocusable={isFocusable} + isGridNavigationEnabled={isGridNavigationEnabled} + interactiveCellId={interactiveCellId} /> ); })} diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index 560604de1fc..d333578d3c9 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -27,7 +27,7 @@ const EuiDataGridHeaderRow: FunctionComponent< const dataTestSubj = classnames('dataGridHeader', _dataTestSubj); return ( -
+
{columns.map(props => { const { id } = props; diff --git a/src/components/datagrid/utils.tsx b/src/components/datagrid/utils.tsx new file mode 100644 index 00000000000..7aeaecbb030 --- /dev/null +++ b/src/components/datagrid/utils.tsx @@ -0,0 +1,8 @@ +import tabbable from 'tabbable'; + +export const getTabbables = (element: Element) => [ + ...tabbable(element), + ...Array.from(element.querySelectorAll('[tabIndex="-1"]')), +]; + +export const CELL_CONTENTS_ATTR = 'data-js-cell-contents-container'; diff --git a/src/services/key_codes.ts b/src/services/key_codes.ts index 87afb804c10..6bc959170ec 100644 --- a/src/services/key_codes.ts +++ b/src/services/key_codes.ts @@ -3,6 +3,7 @@ export const SPACE = 32; export const ESCAPE = 27; export const TAB = 9; export const BACKSPACE = 8; +export const F2 = 113; // Arrow keys export const DOWN = 40; @@ -16,6 +17,7 @@ export enum keyCodes { ESCAPE = 27, TAB = 9, BACKSPACE = 8, + F2 = 113, DOWN = 40, UP = 38, diff --git a/yarn.lock b/yarn.lock index 4ea8d442f83..3d56dff8004 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1156,6 +1156,11 @@ resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.1.tgz#9b7cdae9cdc8b1a7020ca7588018dac64c770866" integrity sha512-5/bJS/uGB5kmpRrrAWXQnmyKlv+4TlPn4f+A2NBa93p+mt6Ht+YcNGkQKf8HMx28a9hox49ZXShtbGqZkk41Sw== +"@types/tabbable@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/tabbable/-/tabbable-3.1.0.tgz#540d4c2729872560badcc220e73c9412c1d2bffe" + integrity sha512-LL0q/bTlzseaXQ8j91eZ+Z8FQUzo0nwkng00B8365qULvFyiSOWylxV8m31Gmee3QuidkDqR72a9NRfR8s4qTw== + "@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" @@ -13988,10 +13993,10 @@ sync-exec@^0.6.2: resolved "https://registry.yarnpkg.com/sync-exec/-/sync-exec-0.6.2.tgz#717d22cc53f0ce1def5594362f3a89a2ebb91105" integrity sha1-cX0izFPwzh3vVZQ2LzqJouu5EQU= -tabbable@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.2.tgz#b171680aea6e0a3e9281ff23532e2e5de11c0d94" - integrity sha512-77oqsKEPrxIwgRcXUwipkj9W5ItO97L6eUT1Ar7vh+El16Zm4M6V+YU1cbipHEa6q0Yjw8O3Hoh8oRgatV5s7A== +tabbable@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-3.1.2.tgz#f2d16cccd01f400e38635c7181adfe0ad965a4a2" + integrity sha512-wjB6puVXTYO0BSFtCmWQubA/KIn7Xvajw0x0l6eJUudMG/EAiJvIUnyNX6xO4NpGrJ16lbD0eUseB9WxW0vlpQ== table@^3.7.8: version "3.8.3"