diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab64898482..45fa9f3c975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [`master`](https://github.com/elastic/eui/tree/master) - Added `badge` prop and new styles `EuiHeaderAlert` ([#2506](https://github.com/elastic/eui/pull/2506)) +- Added new keyboard shortcuts for the data grid component: `Home` (same row, first column), `End` (same row, last column), `Ctrl+Home` (first row, first column), `Ctrl+End` (last row, last column), `Page Up` (next page) and `Page Down` (previous page) ([#2519](https://github.com/elastic/eui/pull/2519)) - Added `disabled` prop to the `EuiCheckboxGroup` definition ([#2545](https://github.com/elastic/eui/pull/2545)) ## [`16.0.1`](https://github.com/elastic/eui/tree/v16.0.1) diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index 7665ad560c4..8d3dd57e46d 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -1441,69 +1441,206 @@ Array [ describe('keyboard controls', () => { it('supports simple arrow navigation', () => { + let pagination = { + pageIndex: 0, + pageSize: 3, + pageSizeOptions: [3, 6, 10], + onChangePage: (pageIndex: number) => { + pagination = { + ...pagination, + pageIndex, + }; + component.setProps({ pagination }); + }, + onChangeItemsPerPage: () => {}, + }; + const component = mount( {}, }} - rowCount={3} + rowCount={8} renderCellValue={({ rowIndex, columnId }) => `${rowIndex}, ${columnId}` } + pagination={pagination} /> ); 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'); + // focus should not move when up against the left edge focusableCell .simulate('focus') .simulate('keydown', { keyCode: keyCodes.LEFT }); - focusableCell = getFocusableCell(component); expect( focusableCell.find('[data-test-subj="cell-content"]').text() - ).toEqual('0, A'); // focus should not move when up against an edge + ).toEqual('0, A'); + // focus should not move when up against the top edge focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); expect( focusableCell.find('[data-test-subj="cell-content"]').text() - ).toEqual('0, A'); // focus should not move when up against an edge + ).toEqual('0, A'); + // move down focusableCell.simulate('keydown', { keyCode: keyCodes.DOWN }); - focusableCell = getFocusableCell(component); expect( focusableCell.find('[data-test-subj="cell-content"]').text() ).toEqual('1, A'); + // move right focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); - focusableCell = getFocusableCell(component); expect( focusableCell.find('[data-test-subj="cell-content"]').text() ).toEqual('1, B'); + // move up focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); - focusableCell = getFocusableCell(component); expect( focusableCell.find('[data-test-subj="cell-content"]').text() ).toEqual('0, B'); + // move left focusableCell.simulate('keydown', { keyCode: keyCodes.LEFT }); + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); + + // move down and to the end of the row + focusableCell + .simulate('keydown', { keyCode: keyCodes.DOWN }) + .simulate('keydown', { keyCode: keyCodes.END }); + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('1, C'); + + // move up and to the beginning of the row + focusableCell + .simulate('keydown', { keyCode: keyCodes.UP }) + .simulate('keydown', { keyCode: keyCodes.HOME }); + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); + + // jump to the last cell + focusableCell.simulate('keydown', { + ctrlKey: true, + keyCode: keyCodes.END, + }); + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('2, C'); + + // jump to the first cell + focusableCell.simulate('keydown', { + ctrlKey: true, + keyCode: keyCodes.HOME, + }); + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); + + // page should not change when moving before the first entry + focusableCell.simulate('keydown', { + keyCode: keyCodes.PAGE_UP, + }); + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); + + // advance to the next page + focusableCell.simulate('keydown', { + keyCode: keyCodes.PAGE_DOWN, + }); + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('3, A'); + + // move over one column and advance one more page + focusableCell + .simulate('keydown', { keyCode: keyCodes.RIGHT }) // 3, B + .simulate('keydown', { + keyCode: keyCodes.PAGE_DOWN, + }); // 6, B + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('6, B'); + + // does not advance beyond the last page + focusableCell.simulate('keydown', { + keyCode: keyCodes.PAGE_DOWN, + }); + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('6, B'); + + // move left one column, return to the previous page + focusableCell + .simulate('keydown', { keyCode: keyCodes.LEFT }) // 6, A + .simulate('keydown', { + keyCode: keyCodes.PAGE_UP, + }); // 3, A + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('3, A'); + // return to the previous (first) page + focusableCell.simulate('keydown', { + keyCode: keyCodes.PAGE_UP, + }); focusableCell = getFocusableCell(component); expect( focusableCell.find('[data-test-subj="cell-content"]').text() ).toEqual('0, A'); + + // move to the last cell of the page then advance one page + focusableCell + .simulate('keydown', { + ctrlKey: true, + keyCode: keyCodes.END, + }) // 2, C (last cell of the first page) + .simulate('keydown', { + keyCode: keyCodes.PAGE_DOWN, + }); // 5, C (last cell of the second page, same cell position as previous page) + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('5, C'); + + // advance to the final page, but there is 1 row less on page 3 so focus should retreat a row but retain the column + focusableCell.simulate('keydown', { + keyCode: keyCodes.PAGE_DOWN, + }); // 7, C + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('7, C'); }); + it('does not break arrow key focus control behavior when also using a mouse', () => { const component = mount( +) { const { pagination, rowCount } = props; const startRow = pagination ? pagination.pageIndex * pagination.pageSize : 0; @@ -286,41 +289,81 @@ function createKeyDownHandler( visibleColumns: EuiDataGridProps['columns'], focusedCell: [number, number], headerIsInteractive: boolean, - setFocusedCell: (focusedCell: [number, number]) => void + setFocusedCell: (focusedCell: [number, number]) => void, + updateFocus: (focusedCell: [number, number]) => void ) { return (event: KeyboardEvent) => { const colCount = visibleColumns.length - 1; const [x, y] = focusedCell; const rowCount = computeVisibleRows(props); - const { keyCode } = event; + const { keyCode, ctrlKey } = event; - switch (keyCode) { - case keyCodes.DOWN: - event.preventDefault(); - if (y < rowCount - 1) { - setFocusedCell([x, y + 1]); - } - break; - case keyCodes.LEFT: - event.preventDefault(); - if (x > 0) { - setFocusedCell([x - 1, y]); - } - break; - case keyCodes.UP: + if (keyCode === keyCodes.DOWN) { + event.preventDefault(); + if (y < rowCount - 1) { + setFocusedCell([x, y + 1]); + } + } else if (keyCode === keyCodes.LEFT) { + event.preventDefault(); + if (x > 0) { + setFocusedCell([x - 1, y]); + } + } else if (keyCode === keyCodes.UP) { + event.preventDefault(); + const minimumIndex = headerIsInteractive ? -1 : 0; + if (y > minimumIndex) { + setFocusedCell([x, y - 1]); + } + } else if (keyCode === keyCodes.RIGHT) { + event.preventDefault(); + if (x < colCount) { + setFocusedCell([x + 1, y]); + } + } else if (keyCode === keyCodes.PAGE_DOWN) { + if (props.pagination) { event.preventDefault(); - // TODO sort out when a user can arrow up into the column headers - const minimumIndex = headerIsInteractive ? -1 : 0; - if (y > minimumIndex) { - setFocusedCell([x, y - 1]); + const rowCount = props.rowCount; + const pageIndex = props.pagination.pageIndex; + const pageSize = props.pagination.pageSize; + const pageCount = Math.ceil(rowCount / pageSize); + if (pageIndex < pageCount - 1) { + props.pagination.onChangePage(pageIndex + 1); + const newPageRowCount = computeVisibleRows({ + rowCount, + pagination: { + ...props.pagination, + pageIndex: pageIndex + 1, + }, + }); + const rowIndex = + focusedCell[1] < newPageRowCount + ? focusedCell[1] + : newPageRowCount - 1; + setFocusedCell([focusedCell[0], rowIndex]); + updateFocus([focusedCell[0], rowIndex]); } - break; - case keyCodes.RIGHT: + } + } else if (keyCode === keyCodes.PAGE_UP) { + if (props.pagination) { event.preventDefault(); - if (x < colCount) { - setFocusedCell([x + 1, y]); + const pageIndex = props.pagination.pageIndex; + if (pageIndex > 0) { + props.pagination.onChangePage(pageIndex - 1); + updateFocus(focusedCell); } - break; + } + } else if (keyCode === (ctrlKey && keyCodes.END)) { + event.preventDefault(); + setFocusedCell([colCount, rowCount - 1]); + } else if (keyCode === (ctrlKey && keyCodes.HOME)) { + event.preventDefault(); + setFocusedCell([0, 0]); + } else if (keyCode === keyCodes.END) { + event.preventDefault(); + setFocusedCell([colCount, y]); + } else if (keyCode === keyCodes.HOME) { + event.preventDefault(); + setFocusedCell([0, y]); } }; } @@ -555,111 +598,142 @@ export const EuiDataGrid: FunctionComponent = props => { ); + const [cellsUpdateFocus] = useState>(new Map()); + + const updateFocus = (focusedCell: [number, number]) => { + const key = `${focusedCell[0]}-${focusedCell[1]}`; + if (cellsUpdateFocus.has(key)) { + requestAnimationFrame(() => { + cellsUpdateFocus.get(key)!(); + }); + } + }; + + const datagridContext = { + onFocusUpdate: (cell: [number, number], updateFocus: Function) => { + if (pagination) { + const key = `${cell[0]}-${cell[1]}`; + + // this intentionally and purposefully mutates the existing `cellsUpdateFocus` object as the + // value/state of `cellsUpdateFocus` must be up-to-date when `updateFocus`'s requestAnimationFrame fires + // there is likely a better pattern to use, but this is fine for now as the scope is known & limited + cellsUpdateFocus.set(key, updateFocus); + + return () => { + cellsUpdateFocus.delete(key); + }; + } + }, + }; + return ( - -
- {showToolbar ? ( -
- {hasRoomForGridControls ? gridControls : null} - {checkOrDefaultToolBarDiplayOptions( - toolbarVisibility, - 'showFullScreenSelector' - ) - ? fullScreenSelector - : null} -
- ) : null} - - {resizeRef => ( + + +
+ {showToolbar ? (
-
- {inMemory ? ( - - ) : null} -
- - {ref => ( - - )} - - + className="euiDataGrid__controls" + data-test-sub="dataGridControls"> + {hasRoomForGridControls ? gridControls : null} + {checkOrDefaultToolBarDiplayOptions( + toolbarVisibility, + 'showFullScreenSelector' + ) + ? fullScreenSelector + : null} +
+ ) : null} + + {resizeRef => ( +
+
+ {inMemory ? ( + + ) : null} +
+ + {ref => ( + + )} + + +
-
- )} - - - {renderPagination(props)} - -
- + )} + + + {renderPagination(props)} + +
+
+
); }; diff --git a/src/components/datagrid/data_grid_body.tsx b/src/components/datagrid/data_grid_body.tsx index 2db4592c50f..a7ad04662cb 100644 --- a/src/components/datagrid/data_grid_body.tsx +++ b/src/components/datagrid/data_grid_body.tsx @@ -1,9 +1,4 @@ -import React, { - Fragment, - FunctionComponent, - useCallback, - useMemo, -} from 'react'; +import React, { Fragment, FunctionComponent, useMemo } from 'react'; // @ts-ignore-next-line import { EuiCodeBlock } from '../code'; import { @@ -163,30 +158,6 @@ export const EuiDataGridBody: FunctionComponent< return rowMap; }, [sorting, inMemory, inMemoryValues, schema, schemaDetectors]); - const setCellFocus = useCallback( - ([colIndex, rowIndex]) => { - // If the rows in the grid have been mapped in some way (e.g. sorting) - // then this callback must unmap the reported rowIndex - const mappedRowIndicies = Object.keys(rowMap); - let reverseMappedIndex = rowIndex; - for (let i = 0; i < mappedRowIndicies.length; i++) { - const mappedRowIndex = mappedRowIndicies[i]; - const rowMappedToIndex = rowMap[(mappedRowIndex as any) as number]; - if (`${rowMappedToIndex}` === `${rowIndex}`) { - reverseMappedIndex = parseInt(mappedRowIndex); - break; - } - } - - // map the row into the visible rows - if (pagination) { - reverseMappedIndex -= pagination.pageIndex * pagination.pageSize; - } - onCellFocus([colIndex, reverseMappedIndex]); - }, - [onCellFocus, rowMap, pagination] - ); - const rows = useMemo(() => { const rows = []; for (let i = 0; i < visibleRowIndices.length; i++) { @@ -209,7 +180,7 @@ export const EuiDataGridBody: FunctionComponent< columnWidths={columnWidths} defaultColumnWidth={defaultColumnWidth} focusedCell={focusedCell} - onCellFocus={setCellFocus} + onCellFocus={onCellFocus} renderCellValue={renderCellValue} rowIndex={rowIndex} visibleRowIndex={i} diff --git a/src/components/datagrid/data_grid_cell.tsx b/src/components/datagrid/data_grid_cell.tsx index 3d157d5fe03..c5d6e31fcce 100644 --- a/src/components/datagrid/data_grid_cell.tsx +++ b/src/components/datagrid/data_grid_cell.tsx @@ -19,6 +19,7 @@ import { EuiButtonIcon } from '../button'; import { keyCodes } from '../../services'; import { EuiDataGridPopoverContent } from './data_grid_types'; import { EuiMutationObserver } from '../observer/mutation_observer'; +import { DataGridContext } from './data_grid_context'; export interface EuiDataGridCellValueElementProps { /** @@ -52,6 +53,7 @@ export interface EuiDataGridCellValueElementProps { export interface EuiDataGridCellProps { rowIndex: number; + visibleRowIndex: number; colIndex: number; columnId: string; columnType?: string | null; @@ -105,6 +107,9 @@ export class EuiDataGridCell extends Component< cellProps: {}, popoverIsOpen: false, }; + unsubscribeCell?: Function = () => {}; + + static contextType = DataGridContext; updateFocus = () => { const cell = this.cellRef.current; @@ -115,6 +120,19 @@ export class EuiDataGridCell extends Component< } }; + componentDidMount() { + this.unsubscribeCell = this.context.onFocusUpdate( + [this.props.colIndex, this.props.visibleRowIndex], + this.updateFocus + ); + } + + componentWillUnmount() { + if (this.unsubscribeCell) { + this.unsubscribeCell(); + } + } + componentDidUpdate(prevProps: EuiDataGridCellProps) { const didFocusChange = prevProps.isFocused !== this.props.isFocused; @@ -128,6 +146,7 @@ export class EuiDataGridCell extends Component< nextState: EuiDataGridCellState ) { if (nextProps.rowIndex !== this.props.rowIndex) return true; + if (nextProps.visibleRowIndex !== this.props.visibleRowIndex) return true; if (nextProps.colIndex !== this.props.colIndex) return true; if (nextProps.columnId !== this.props.columnId) return true; if (nextProps.width !== this.props.width) return true; @@ -173,7 +192,7 @@ export class EuiDataGridCell extends Component< onCellFocus, ...rest } = this.props; - const { colIndex, rowIndex } = rest; + const { colIndex, rowIndex, visibleRowIndex } = rest; const className = classNames('euiDataGridRowCell', { [`euiDataGridRowCell--${columnType}`]: columnType, @@ -361,7 +380,7 @@ export class EuiDataGridCell extends Component< {...cellProps} data-test-subj="dataGridRowCell" onKeyDown={handleCellKeyDown} - onFocus={() => onCellFocus([colIndex, rowIndex])}> + onFocus={() => onCellFocus([colIndex, visibleRowIndex])}> {innerContent}
); diff --git a/src/components/datagrid/data_grid_context.tsx b/src/components/datagrid/data_grid_context.tsx new file mode 100644 index 00000000000..d89c9487a9a --- /dev/null +++ b/src/components/datagrid/data_grid_context.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export const DataGridContext = React.createContext({ + onFocusUpdate: (_cell: [number, number], _updateFocus: Function) => {}, +}); diff --git a/src/components/datagrid/data_grid_data_row.tsx b/src/components/datagrid/data_grid_data_row.tsx index abe7aed8ec2..1660ac2c767 100644 --- a/src/components/datagrid/data_grid_data_row.tsx +++ b/src/components/datagrid/data_grid_data_row.tsx @@ -74,6 +74,7 @@ const EuiDataGridDataRow: FunctionComponent<