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`] = `
+
+
+
+
+
- 0, A
-
+ data-focus-guard="true"
+ style="width:1px;height:0px;padding:0;overflow:hidden;position:fixed;top:1px;left:1px"
+ tabindex="-1"
+ />
+
+
+
+
-
,
+ ,
+
+ Cell contains interactive content.
+
,
]
`;
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)}
+
+ Cell contains interactive content.
+ {/* TODO: if no keyboard shortcuts panel gets built, add keyboard shortcut info here */}
+
);
};
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"