From 8807d3347b67f7327039f5640d854b13f0987704 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 | 47 +++++++- src-docs/src/views/icon/icons.js | 2 +- src/components/datagrid/data_grid.tsx | 104 +++++++++++------- src/components/datagrid/data_grid_body.tsx | 4 + src/components/datagrid/data_grid_cell.tsx | 63 +++++++++-- .../datagrid/data_grid_data_row.tsx | 3 + src/components/datagrid/utils.tsx | 8 ++ src/services/key_codes.ts | 2 + yarn.lock | 13 ++- 10 files changed, 195 insertions(+), 54 deletions(-) create mode 100644 src/components/datagrid/utils.tsx diff --git a/package.json b/package.json index b834384fe05d..67b92e8f3aab 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": "^4.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 a6594475771a..026a83078b77 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,12 @@ const columns = [ { id: 'contributions', }, + { + id: 'actions', + }, + { + id: 'a bug', + }, ]; const data = [ @@ -304,6 +313,13 @@ export default class DataGrid extends Component { pagination: { ...pagination, pageSize }, })); + dummyIcon = () => ( + + ); + render() { const { pagination } = this.state; @@ -396,7 +412,36 @@ 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} + + ); + } + + if (columnId === 'a bug') { + return

check it: {this.dummyIcon()}

; + } + + 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 79dd11541096..eb22dc918429 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/data_grid.tsx b/src/components/datagrid/data_grid.tsx index d0acbc557e32..cfac180c577d 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -23,6 +23,7 @@ import { EuiDataGridBody } from './data_grid_body'; import { useColumnSelector } from './column_selector'; // @ts-ignore-next-line import { EuiTablePagination } from '../table/table_pagination'; +import { getTabbables, CELL_CONTENTS_ATTR } from './utils'; // Types for styling options, passed down through the `gridStyle` prop type EuiDataGridStyleFontSizes = 's' | 'm' | 'l'; @@ -138,13 +139,11 @@ export const EuiDataGrid: FunctionComponent = props => { 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,71 @@ 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 isInteractiveCell = (element: HTMLElement) => { + if (element.getAttribute('role') !== 'gridcell') { + return false; + } + + const cellContents = element.querySelector(`[${CELL_CONTENTS_ATTR}]`)!; + const tabbables = getTabbables(cellContents); + const nodeCount = cellContents.childNodes.length; + + // TODO fix the bug column (should check if when removing all tabbables from cell if anything is left) + return tabbables.length > 1 || (tabbables.length === 1 && nodeCount > 1); + }; const handleKeyDown = (e: KeyboardEvent) => { const colCount = props.columns.length - 1; const [x, y] = focusedCell; const rowCount = computeVisibleRows(props); + const key = e.keyCode; + + if ( + // @ts-ignore // TODO why do I need this ignore? + isInteractiveCell(e.target) && + (key === keyCodes.ENTER || key === keyCodes.F2) + ) { + e.preventDefault(); + setIsGridNavigationEnabled(false); + } + + if (key === keyCodes.ESCAPE || key === keyCodes.F2) { + e.preventDefault(); + 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 (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; + } } }; @@ -238,7 +264,6 @@ export const EuiDataGrid: FunctionComponent = props => { role="grid" onKeyDown={handleKeyDown} ref={gridRef} - // {...label} {...rest} className={classes}>
@@ -251,10 +276,11 @@ export const EuiDataGrid: FunctionComponent = props => { columnWidths={columnWidths} columns={visibleColumns} focusedCell={focusedCell} - onCellFocus={onCellFocus} + onCellFocus={useCallback(setFocusedCell, [setFocusedCell])} pagination={pagination} renderCellValue={renderCellValue} rowCount={rowCount} + isGridNavigationEnabled={isGridNavigationEnabled} />
diff --git a/src/components/datagrid/data_grid_body.tsx b/src/components/datagrid/data_grid_body.tsx index 7e450e7dad81..9dc9fc12c5b8 100644 --- a/src/components/datagrid/data_grid_body.tsx +++ b/src/components/datagrid/data_grid_body.tsx @@ -18,6 +18,7 @@ interface EuiDataGridBodyProps { rowCount: number; renderCellValue: EuiDataGridCellProps['renderCellValue']; pagination?: EuiDataGridPaginationProps; + isGridNavigationEnabled: EuiDataGridCellProps['isGridNavigationEnabled']; } export const EuiDataGridBody: FunctionComponent< @@ -31,6 +32,7 @@ export const EuiDataGridBody: FunctionComponent< rowCount, renderCellValue, pagination, + isGridNavigationEnabled, } = props; const startRow = pagination ? pagination.pageIndex * pagination.pageSize : 0; @@ -51,6 +53,7 @@ export const EuiDataGridBody: FunctionComponent< onCellFocus={onCellFocus} renderCellValue={renderCellValue} rowIndex={i} + isGridNavigationEnabled={isGridNavigationEnabled} /> ); } @@ -64,6 +67,7 @@ export const EuiDataGridBody: FunctionComponent< onCellFocus, renderCellValue, startRow, + isGridNavigationEnabled, ]); return {rows}; diff --git a/src/components/datagrid/data_grid_cell.tsx b/src/components/datagrid/data_grid_cell.tsx index ee929840eb44..78f7b8b9cb2d 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,7 @@ export interface EuiDataGridCellProps { width?: number; isFocusable: boolean; onCellFocus: Function; + isGridNavigationEnabled: boolean; renderCellValue: | JSXElementConstructor | ((props: CellValueElementProps) => ReactNode); @@ -29,7 +33,7 @@ interface EuiDataGridCellState {} type EuiDataGridCellValueProps = Omit< EuiDataGridCellProps, - 'width' | 'isFocusable' + 'width' | 'isFocusable' | 'isGridNavigationEnabled' >; const EuiDataGridCellContent: FunctionComponent< @@ -50,19 +54,58 @@ export class EuiDataGridCell extends Component< EuiDataGridCellState > { cellRef = createRef(); + cellContentsRef = createRef(); updateFocus() { - if (this.cellRef.current && this.props.isFocusable) { - this.cellRef.current.focus(); + const cell = this.cellRef.current; + const cellContents = this.cellContentsRef.current; + + if (cell && cellContents && this.props.isFocusable) { + const tabbables = getTabbables(cellContents); + const nodeCount = cellContents.childNodes.length; + + if (this.props.isGridNavigationEnabled) { + if (tabbables.length === 1 && nodeCount === 1) { + // @ts-ignore // TODO why do I need this ignore? + tabbables[0].focus(); + } else { + cell.focus(); + } + } else { + // @ts-ignore // TODO why do I need this ignore? + tabbables[0].focus(); + } + } + } + + setTabbablesTabIndex() { + const { isFocusable, isGridNavigationEnabled } = this.props; + const areContentsFocusable = isFocusable && !isGridNavigationEnabled; + + if (this.cellContentsRef.current) { + getTabbables(this.cellContentsRef.current).forEach(element => { + element.setAttribute('tabIndex', areContentsFocusable ? '0' : '-1'); + }); } } - componentDidUpdate() { - this.updateFocus(); + 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, ...rest } = this.props; const { colIndex, rowIndex, onCellFocus } = rest; return ( @@ -72,9 +115,13 @@ export class EuiDataGridCell extends Component< ref={this.cellRef} className="euiDataGridRowCell" data-test-subj="dataGridRowCell" - onFocus={() => 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 121208dd88a0..9338ad32e9b9 100644 --- a/src/components/datagrid/data_grid_data_row.tsx +++ b/src/components/datagrid/data_grid_data_row.tsx @@ -12,6 +12,7 @@ export type EuiDataGridDataRowProps = CommonProps & columnWidths: EuiDataGridColumnWidths; focusedCell: [number, number]; renderCellValue: EuiDataGridCellProps['renderCellValue']; + isGridNavigationEnabled: EuiDataGridCellProps['isGridNavigationEnabled']; onCellFocus: Function; }; @@ -26,6 +27,7 @@ const EuiDataGridDataRow: FunctionComponent< rowIndex, focusedCell, onCellFocus, + isGridNavigationEnabled, 'data-test-subj': _dataTestSubj, ...rest } = props; @@ -52,6 +54,7 @@ const EuiDataGridDataRow: FunctionComponent< renderCellValue={renderCellValue} onCellFocus={onCellFocus} isFocusable={isFocusable} + isGridNavigationEnabled={isGridNavigationEnabled} /> ); })} diff --git a/src/components/datagrid/utils.tsx b/src/components/datagrid/utils.tsx new file mode 100644 index 000000000000..7aeaecbb0309 --- /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 87afb804c106..6bc959170ecb 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 4ea8d442f83d..25908800c783 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@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261" + integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ== table@^3.7.8: version "3.8.3"