From 59461c7e068bbd6b61fa85d8479516fbd9070a82 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 11 Jan 2022 13:30:32 -0800 Subject: [PATCH 1/9] [EuiDataGrid] Set up `ref` that exposes focus/popover internal APIs (#5499) * Set up types * Set up forwardRef * Add setFocusedCell API to returned grid ref obj * Add colIndex prop to cell actions - so that cell actions that trigger modals or flyouts can re-focus into the correct cell using the new ref API * Add documentation + example + props * Add changelog * [PR feedback] Types Co-authored-by: Chandler Prall * [PR feedback] Clean up unit test * [Rebase] Tweak useImperativeHandle location - Moving it below fullscreen logic, as we're oging to expose setIsFullScreen as an API shortly Co-authored-by: Chandler Prall --- CHANGELOG.md | 2 + src-docs/src/routes.js | 2 + .../src/views/datagrid/datagrid_example.js | 17 + .../views/datagrid/datagrid_ref_example.js | 67 ++ src-docs/src/views/datagrid/ref.js | 196 +++++ .../datagrid/body/data_grid_cell.tsx | 4 +- .../body/data_grid_cell_buttons.test.tsx | 2 + .../datagrid/body/data_grid_cell_buttons.tsx | 5 +- .../body/data_grid_cell_popover.test.tsx | 3 +- .../datagrid/body/data_grid_cell_popover.tsx | 2 + src/components/datagrid/data_grid.test.tsx | 27 +- src/components/datagrid/data_grid.tsx | 746 +++++++++--------- src/components/datagrid/data_grid_types.ts | 18 + 13 files changed, 725 insertions(+), 366 deletions(-) create mode 100644 src-docs/src/views/datagrid/datagrid_ref_example.js create mode 100644 src-docs/src/views/datagrid/ref.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6728405d0..4b13ae8a11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [`main`](https://github.com/elastic/eui/tree/main) +- Added the ability to access certain `EuiDataGrid` internal methods via the `ref` prop ([#5499](https://github.com/elastic/eui/pull/5499)) + **Breaking changes** - Removed `data-test-subj="dataGridWrapper"` from `EuiDataGrid` in favor of `data-test-subj="euiDataGridBody"` ([#5506](https://github.com/elastic/eui/pull/5506)) diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 11d3ff15f0a..23c7d8366e5 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -89,6 +89,7 @@ import { DataGridControlColumnsExample } from './views/datagrid/datagrid_control import { DataGridFooterRowExample } from './views/datagrid/datagrid_footer_row_example'; import { DataGridVirtualizationExample } from './views/datagrid/datagrid_virtualization_example'; import { DataGridRowHeightOptionsExample } from './views/datagrid/datagrid_height_options_example'; +import { DataGridRefExample } from './views/datagrid/datagrid_ref_example'; import { DatePickerExample } from './views/date_picker/date_picker_example'; @@ -489,6 +490,7 @@ const navigation = [ DataGridFooterRowExample, DataGridVirtualizationExample, DataGridRowHeightOptionsExample, + DataGridRefExample, TableExample, TableInMemoryExample, ].map((example) => createExample(example)), diff --git a/src-docs/src/views/datagrid/datagrid_example.js b/src-docs/src/views/datagrid/datagrid_example.js index 47a606c471c..3ecd3c20e5b 100644 --- a/src-docs/src/views/datagrid/datagrid_example.js +++ b/src-docs/src/views/datagrid/datagrid_example.js @@ -33,6 +33,7 @@ import { EuiDataGridRowHeightsOptions, EuiDataGridCellValueElementProps, EuiDataGridSchemaDetector, + EuiDataGridRefProps, } from '!!prop-loader!../../../../src/components/datagrid/data_grid_types'; const gridSnippet = ` @@ -164,6 +165,8 @@ const gridSnippet = ` ); }, }} + // Optional. For advanced control of internal data grid popover/focus state, passes back an object of API methods + ref={dataGridRef} /> `; @@ -323,6 +326,19 @@ const gridConcepts = [ ), }, + { + title: 'ref', + description: ( + + Passes back an object of internal EuiDataGridRefProps{' '} + methods for advanced control of data grid popover/focus state. See{' '} + + Data grid ref methods + {' '} + for more details and examples. + + ), + }, ]; export const DataGridExample = { @@ -414,6 +430,7 @@ export const DataGridExample = { EuiDataGridToolBarAdditionalControlsLeftOptions, EuiDataGridPopoverContentProps, EuiDataGridRowHeightsOptions, + EuiDataGridRefProps, }, demo: ( diff --git a/src-docs/src/views/datagrid/datagrid_ref_example.js b/src-docs/src/views/datagrid/datagrid_ref_example.js new file mode 100644 index 00000000000..9345f4d75c0 --- /dev/null +++ b/src-docs/src/views/datagrid/datagrid_ref_example.js @@ -0,0 +1,67 @@ +import React from 'react'; + +import { GuideSectionTypes } from '../../components'; +import { + EuiCode, + EuiCodeBlock, + EuiSpacer, + EuiCallOut, +} from '../../../../src/components'; + +import { EuiDataGridRefProps } from '!!prop-loader!../../../../src/components/datagrid/data_grid_types'; +import DataGridRef from './ref'; +const dataGridRefSource = require('!!raw-loader!./ref'); +const dataGridRefSnippet = `const dataGridRef = useRef(); + + +// Mnaually focus a specific cell within the data grid +dataGridRef.current.setFocusedCell({ rowIndex, colIndex }); +`; + +export const DataGridRefExample = { + title: 'Data grid ref methods', + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridRefSource, + }, + ], + text: ( + <> +

+ For advanced use cases, and particularly for data grids that manage + associated modals/flyouts and need to manually control their grid + cell popovers & focus states, we expose certain internal methods via + the ref prop of EuiDataGrid. These methods are: +

+
    +
  • + setFocusedCell({'{ rowIndex, colIndex }'}) - + focuses the specified cell in the grid. + + + Your modal or flyout should restore focus into the grid on close + to prevent keyboard or screen reader users from being stranded. + +
  • +
+ {dataGridRefSnippet} +

+ The below example shows how to use the internal APIs for a data grid + that opens a modal via cell actions. +

+ + ), + components: { DataGridRef }, + demo: , + snippet: dataGridRefSnippet, + props: { EuiDataGridRefProps }, + }, + ], +}; diff --git a/src-docs/src/views/datagrid/ref.js b/src-docs/src/views/datagrid/ref.js new file mode 100644 index 00000000000..e043abcedc9 --- /dev/null +++ b/src-docs/src/views/datagrid/ref.js @@ -0,0 +1,196 @@ +import React, { useCallback, useMemo, useState, useRef } from 'react'; +import { fake } from 'faker'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiFormRow, + EuiFieldNumber, + EuiButton, + EuiDataGrid, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, +} from '../../../../src/components/'; + +const raw_data = []; +for (let i = 1; i < 100; i++) { + raw_data.push({ + name: fake('{{name.lastName}}, {{name.firstName}}'), + email: fake('{{internet.email}}'), + location: fake('{{address.city}}, {{address.country}}'), + account: fake('{{finance.account}}'), + date: fake('{{date.past}}'), + }); +} + +export default () => { + const dataGridRef = useRef(); + + // Modal + const [isModalVisible, setIsModalVisible] = useState(false); + const [lastFocusedCell, setLastFocusedCell] = useState({}); + + const closeModal = useCallback(() => { + setIsModalVisible(false); + dataGridRef.current.setFocusedCell(lastFocusedCell); // Set the data grid focus back to the cell that opened the modal + }, [lastFocusedCell]); + + const showModal = useCallback(({ rowIndex, colIndex }) => { + setIsModalVisible(true); + setLastFocusedCell({ rowIndex, colIndex }); // Store the cell that opened this modal + }, []); + + const openModalAction = useCallback( + ({ Component, rowIndex, colIndex }) => { + return ( + showModal({ rowIndex, colIndex })} + iconType="faceHappy" + aria-label="Open modal" + > + Open modal + + ); + }, + [showModal] + ); + + // Columns + const columns = useMemo( + () => [ + { + id: 'name', + displayAsText: 'Name', + cellActions: [openModalAction], + }, + { + id: 'email', + displayAsText: 'Email address', + initialWidth: 130, + cellActions: [openModalAction], + }, + { + id: 'location', + displayAsText: 'Location', + cellActions: [openModalAction], + }, + { + id: 'account', + displayAsText: 'Account', + cellActions: [openModalAction], + }, + { + id: 'date', + displayAsText: 'Date', + cellActions: [openModalAction], + }, + ], + [openModalAction] + ); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); + + // Pagination + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + const onChangePage = useCallback( + (pageIndex) => + setPagination((pagination) => ({ ...pagination, pageIndex })), + [] + ); + + // Manual cell focus + const [rowIndexAction, setRowIndexAction] = useState(0); + const [colIndexAction, setColIndexAction] = useState(0); + + return ( + <> + + + + setRowIndexAction(Number(e.target.value))} + compressed + /> + + + + + setColIndexAction(Number(e.target.value))} + compressed + /> + + + + + dataGridRef.current.setFocusedCell({ + rowIndex: rowIndexAction, + colIndex: colIndexAction, + }) + } + > + Set cell focus + + + + + + + raw_data[rowIndex][columnId] + } + pagination={{ + ...pagination, + pageSizeOptions: [25], + onChangePage: onChangePage, + }} + height={400} + ref={dataGridRef} + /> + {isModalVisible && ( + + + +

Example modal

+
+
+ + + +

+ When closed, this modal should re-focus into the cell that + toggled it. +

+
+
+ + + + Close + + +
+ )} + + ); +}; diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/data_grid_cell.tsx index 26ab518a0a4..60e78a26479 100644 --- a/src/components/datagrid/body/data_grid_cell.tsx +++ b/src/components/datagrid/body/data_grid_cell.tsx @@ -405,7 +405,7 @@ export class EuiDataGridCell extends Component< rowManager, ...rest } = this.props; - const { rowIndex } = rest; + const { rowIndex, colIndex } = rest; const showCellButtons = this.state.isFocused || @@ -546,6 +546,7 @@ export class EuiDataGridCell extends Component< (this.popoverPanelRef.current = ref)} popoverIsOpen={this.state.popoverIsOpen} rowIndex={rowIndex} + colIndex={colIndex} renderCellValue={rest.renderCellValue} popoverContent={PopoverContent} /> diff --git a/src/components/datagrid/body/data_grid_cell_buttons.test.tsx b/src/components/datagrid/body/data_grid_cell_buttons.test.tsx index 5f6de44b079..ed26a36cb56 100644 --- a/src/components/datagrid/body/data_grid_cell_buttons.test.tsx +++ b/src/components/datagrid/body/data_grid_cell_buttons.test.tsx @@ -17,6 +17,7 @@ describe('EuiDataGridCellButtons', () => { closePopover: jest.fn(), onExpandClick: jest.fn(), rowIndex: 0, + colIndex: 0, }; it('renders an expand button', () => { @@ -66,6 +67,7 @@ describe('EuiDataGridCellButtons', () => { void; onExpandClick: () => void; column?: EuiDataGridColumn; rowIndex: number; + colIndex: number; }) => { const buttonIconClasses = classNames('euiDataGridRowCell__expandButtonIcon', { 'euiDataGridRowCell__expandButtonIcon-isActive': popoverIsOpen, @@ -74,6 +76,7 @@ export const EuiDataGridCellButtons = ({ {[...additionalButtons, expandButton]} diff --git a/src/components/datagrid/body/data_grid_cell_popover.test.tsx b/src/components/datagrid/body/data_grid_cell_popover.test.tsx index 8402eff4c01..0e87a303c8e 100644 --- a/src/components/datagrid/body/data_grid_cell_popover.test.tsx +++ b/src/components/datagrid/body/data_grid_cell_popover.test.tsx @@ -14,8 +14,8 @@ import { EuiDataGridCellPopover } from './data_grid_cell_popover'; describe('EuiDataGridCellPopover', () => { const requiredProps = { - // column, rowIndex: 0, + colIndex: 0, cellContentProps: { rowIndex: 0, columnId: 'someId', @@ -123,6 +123,7 @@ describe('EuiDataGridCellPopover', () => { ( diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index 4b2698d7b70..a4200aab0ad 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, createRef } from 'react'; import { mount, ReactWrapper, render } from 'enzyme'; -import { EuiDataGrid, EuiDataGridProps } from './'; +import { EuiDataGrid } from './'; +import { EuiDataGridProps, EuiDataGridRefProps } from './data_grid_types'; import { findTestSubject, requiredProps, @@ -2724,4 +2725,26 @@ describe('EuiDataGrid', () => { expect(takeMountedSnapshot(component)).toMatchSnapshot(); }); }); + + it('returns a ref which exposes internal imperative APIs', () => { + const gridRef = createRef(); + + mount( + {}, + }} + rowCount={1} + renderCellValue={() => 'value'} + ref={gridRef} + /> + ); + + expect(gridRef.current).toEqual({ + setFocusedCell: expect.any(Function), + }); + }); }); diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index 46096ad9aaa..f5688219b83 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -8,11 +8,12 @@ import classNames from 'classnames'; import React, { - FunctionComponent, + forwardRef, KeyboardEvent, useMemo, useRef, useState, + useImperativeHandle, } from 'react'; import { VariableSizeGrid as Grid } from 'react-window'; import { useGeneratedHtmlId, keys } from '../../services'; @@ -51,6 +52,7 @@ import { import { EuiDataGridColumn, EuiDataGridProps, + EuiDataGridRefProps, EuiDataGridStyleBorders, EuiDataGridStyleCellPaddings, EuiDataGridStyleFontSizes, @@ -98,372 +100,394 @@ const cellPaddingsToClassMap: { l: 'euiDataGrid--paddingLarge', }; -export const EuiDataGrid: FunctionComponent = (props) => { - const { - leadingControlColumns = [], - trailingControlColumns = [], - columns, - columnVisibility, - schemaDetectors, - rowCount, - renderCellValue, - renderFooterCellValue, - className, - gridStyle, - toolbarVisibility = true, - pagination, - sorting, - inMemory, - popoverContents, - onColumnResize, - minSizeForControls, - height, - width, - rowHeightsOptions: _rowHeightsOptions, - virtualizationOptions, - ...rest - } = props; - - /** - * Merge consumer settings with defaults - */ - const gridStyleWithDefaults = { ...startingStyles, ...gridStyle }; - - const mergedPopoverContents = useMemo( - () => ({ - ...providedPopoverContents, - ...popoverContents, - }), - [popoverContents] - ); - - const [inMemoryValues, onCellRender] = useInMemoryValues(inMemory, rowCount); - - const allSchemaDetectors = useMemo( - () => [...providedSchemaDetectors, ...(schemaDetectors || [])], - [schemaDetectors] - ); - - const mergedSchema = useMergedSchema({ - columns, - inMemory, - inMemoryValues, - schemaDetectors: allSchemaDetectors, - autoDetectSchema: inMemory != null, - }); - - /** - * Grid refs & observers - */ - // Outermost wrapper div - const resizeRef = useRef(null); - const { width: gridWidth } = useResizeObserver(resizeRef.current, 'width'); - - // Wrapper div around EuiDataGridBody - const contentRef = useRef(null); - useMutationObserver(contentRef.current, preventTabbing, { - subtree: true, - childList: true, - }); - - // Imperative handler passed back by react-window - we're setting this at - // the top datagrid level to make passing it to other children & utils easier - const gridRef = useRef(null); - - /** - * Display - */ - const displayValues: { [key: string]: string } = useMemo(() => { - return columns.reduce( - (acc: { [key: string]: string }, column: EuiDataGridColumn) => ({ - ...acc, - [column.id]: column.displayAsText || column.id, +export const EuiDataGrid = forwardRef( + (props, ref) => { + const { + leadingControlColumns = [], + trailingControlColumns = [], + columns, + columnVisibility, + schemaDetectors, + rowCount, + renderCellValue, + renderFooterCellValue, + className, + gridStyle, + toolbarVisibility = true, + pagination, + sorting, + inMemory, + popoverContents, + onColumnResize, + minSizeForControls, + height, + width, + rowHeightsOptions: _rowHeightsOptions, + virtualizationOptions, + ...rest + } = props; + + /** + * Merge consumer settings with defaults + */ + const gridStyleWithDefaults = { ...startingStyles, ...gridStyle }; + + const mergedPopoverContents = useMemo( + () => ({ + ...providedPopoverContents, + ...popoverContents, }), - {} + [popoverContents] ); - }, [columns]); - - const [ - displaySelector, - gridStyles, - rowHeightsOptions, - ] = useDataGridDisplaySelector( - checkOrDefaultToolBarDisplayOptions( - toolbarVisibility, - 'showDisplaySelector' - ), - gridStyleWithDefaults, - _rowHeightsOptions - ); - - /** - * Column order & visibility - */ - const [ - columnSelector, - orderedVisibleColumns, - setVisibleColumns, - switchColumnPos, - ] = useDataGridColumnSelector( - columns, - columnVisibility, - checkOrDefaultToolBarDisplayOptions( - toolbarVisibility, - 'showColumnSelector' - ), - displayValues - ); - - const visibleColCount = useMemo(() => { - return ( - orderedVisibleColumns.length + - leadingControlColumns.length + - trailingControlColumns.length + + const [inMemoryValues, onCellRender] = useInMemoryValues( + inMemory, + rowCount ); - }, [orderedVisibleColumns, leadingControlColumns, trailingControlColumns]); - - const visibleRows = useMemo( - () => computeVisibleRows({ pagination, rowCount }), - [pagination, rowCount] - ); - const { visibleRowCount } = visibleRows; - - /** - * Sorting - */ - const columnSorting = useDataGridColumnSorting( - orderedVisibleColumns, - sorting, - mergedSchema, - allSchemaDetectors, - displayValues - ); - - const sortingContext = useSorting({ - sorting, - inMemory, - inMemoryValues, - schema: mergedSchema, - schemaDetectors: allSchemaDetectors, - startRow: visibleRows.startRow, - }); - - /** - * Focus - */ - const { headerIsInteractive, handleHeaderMutation } = useHeaderIsInteractive( - contentRef.current - ); - const { focusProps: wrappingDivFocusProps, ...focusContext } = useFocus( - headerIsInteractive - ); - - /** - * Toolbar & full-screen - */ - const showToolbar = !!toolbarVisibility; - const [toolbarRef, setToolbarRef] = useState(null); - const { height: toolbarHeight } = useResizeObserver(toolbarRef, 'height'); - - const [isFullScreen, setIsFullScreen] = useState(false); - const handleGridKeyDown = (event: KeyboardEvent) => { - switch (event.key) { - case keys.ESCAPE: - if (isFullScreen) { - event.preventDefault(); - setIsFullScreen(false); - } - break; - } - }; - - /** - * Classes - */ - const classes = classNames( - 'euiDataGrid', - fontSizesToClassMap[gridStyles.fontSize!], - bordersToClassMap[gridStyles.border!], - headerToClassMap[gridStyles.header!], - footerToClassMap[gridStyles.footer!], - rowHoverToClassMap[gridStyles.rowHover!], - cellPaddingsToClassMap[gridStyles.cellPadding!], - { - 'euiDataGrid--stripes': gridStyles.stripes!, - }, - { - 'euiDataGrid--stickyFooter': gridStyles.footer && gridStyles.stickyFooter, - }, - { - 'euiDataGrid--fullScreen': isFullScreen, - }, - { - 'euiDataGrid--noControls': !toolbarVisibility, - }, - className - ); - - const controlBtnClasses = classNames('euiDataGrid__controlBtn', { - 'euiDataGrid__controlBtn--active': isFullScreen, - }); - - /** - * Accessibility - */ - const gridId = useGeneratedHtmlId(); - const interactiveCellId = useGeneratedHtmlId(); - const ariaLabelledById = useGeneratedHtmlId(); - - const ariaLabel = useEuiI18n( - 'euiDataGrid.ariaLabel', - '{label}; Page {page} of {pageCount}.', - { - label: rest['aria-label'], - page: pagination ? pagination.pageIndex + 1 : 0, - pageCount: pagination ? Math.ceil(rowCount / pagination.pageSize) : 0, + + const allSchemaDetectors = useMemo( + () => [...providedSchemaDetectors, ...(schemaDetectors || [])], + [schemaDetectors] + ); + + const mergedSchema = useMergedSchema({ + columns, + inMemory, + inMemoryValues, + schemaDetectors: allSchemaDetectors, + autoDetectSchema: inMemory != null, + }); + + /** + * Grid refs & observers + */ + // Outermost wrapper div + const resizeRef = useRef(null); + const { width: gridWidth } = useResizeObserver(resizeRef.current, 'width'); + + // Wrapper div around EuiDataGridBody + const contentRef = useRef(null); + useMutationObserver(contentRef.current, preventTabbing, { + subtree: true, + childList: true, + }); + + // Imperative handler passed back by react-window - we're setting this at + // the top datagrid level to make passing it to other children & utils easier + const gridRef = useRef(null); + + /** + * Display + */ + const displayValues: { [key: string]: string } = useMemo(() => { + return columns.reduce( + (acc: { [key: string]: string }, column: EuiDataGridColumn) => ({ + ...acc, + [column.id]: column.displayAsText || column.id, + }), + {} + ); + }, [columns]); + + const [ + displaySelector, + gridStyles, + rowHeightsOptions, + ] = useDataGridDisplaySelector( + checkOrDefaultToolBarDisplayOptions( + toolbarVisibility, + 'showDisplaySelector' + ), + gridStyleWithDefaults, + _rowHeightsOptions + ); + + /** + * Column order & visibility + */ + const [ + columnSelector, + orderedVisibleColumns, + setVisibleColumns, + switchColumnPos, + ] = useDataGridColumnSelector( + columns, + columnVisibility, + checkOrDefaultToolBarDisplayOptions( + toolbarVisibility, + 'showColumnSelector' + ), + displayValues + ); + + const visibleColCount = useMemo(() => { + return ( + orderedVisibleColumns.length + + leadingControlColumns.length + + trailingControlColumns.length + ); + }, [orderedVisibleColumns, leadingControlColumns, trailingControlColumns]); + + const visibleRows = useMemo( + () => computeVisibleRows({ pagination, rowCount }), + [pagination, rowCount] + ); + const { visibleRowCount } = visibleRows; + + /** + * Sorting + */ + const columnSorting = useDataGridColumnSorting( + orderedVisibleColumns, + sorting, + mergedSchema, + allSchemaDetectors, + displayValues + ); + + const sortingContext = useSorting({ + sorting, + inMemory, + inMemoryValues, + schema: mergedSchema, + schemaDetectors: allSchemaDetectors, + startRow: visibleRows.startRow, + }); + + /** + * Focus + */ + const { + headerIsInteractive, + handleHeaderMutation, + } = useHeaderIsInteractive(contentRef.current); + const { focusProps: wrappingDivFocusProps, ...focusContext } = useFocus( + headerIsInteractive + ); + + /** + * Toolbar & full-screen + */ + const showToolbar = !!toolbarVisibility; + const [toolbarRef, setToolbarRef] = useState(null); + const { height: toolbarHeight } = useResizeObserver(toolbarRef, 'height'); + + const [isFullScreen, setIsFullScreen] = useState(false); + const handleGridKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case keys.ESCAPE: + if (isFullScreen) { + event.preventDefault(); + setIsFullScreen(false); + } + break; + } + }; + + /** + * Expose internal APIs as ref to consumer + */ + useImperativeHandle( + ref, + () => ({ + setFocusedCell: ({ rowIndex, colIndex }) => { + focusContext.setFocusedCell([colIndex, rowIndex]); + }, + }), + [focusContext] + ); + + /** + * Classes + */ + const classes = classNames( + 'euiDataGrid', + fontSizesToClassMap[gridStyles.fontSize!], + bordersToClassMap[gridStyles.border!], + headerToClassMap[gridStyles.header!], + footerToClassMap[gridStyles.footer!], + rowHoverToClassMap[gridStyles.rowHover!], + cellPaddingsToClassMap[gridStyles.cellPadding!], + { + 'euiDataGrid--stripes': gridStyles.stripes!, + }, + { + 'euiDataGrid--stickyFooter': + gridStyles.footer && gridStyles.stickyFooter, + }, + { + 'euiDataGrid--fullScreen': isFullScreen, + }, + { + 'euiDataGrid--noControls': !toolbarVisibility, + }, + className + ); + + const controlBtnClasses = classNames('euiDataGrid__controlBtn', { + 'euiDataGrid__controlBtn--active': isFullScreen, + }); + + /** + * Accessibility + */ + const gridId = useGeneratedHtmlId(); + const interactiveCellId = useGeneratedHtmlId(); + const ariaLabelledById = useGeneratedHtmlId(); + + const ariaLabel = useEuiI18n( + 'euiDataGrid.ariaLabel', + '{label}; Page {page} of {pageCount}.', + { + label: rest['aria-label'], + page: pagination ? pagination.pageIndex + 1 : 0, + pageCount: pagination ? Math.ceil(rowCount / pagination.pageSize) : 0, + } + ); + + const ariaLabelledBy = useEuiI18n( + 'euiDataGrid.ariaLabelledBy', + 'Page {page} of {pageCount}.', + { + page: pagination ? pagination.pageIndex + 1 : 0, + pageCount: pagination ? Math.ceil(rowCount / pagination.pageSize) : 0, + } + ); + + // extract aria-label and/or aria-labelledby from `rest` + const gridAriaProps: { + 'aria-label'?: string; + 'aria-labelledby'?: string; + } = {}; + if ('aria-label' in rest) { + gridAriaProps['aria-label'] = pagination ? ariaLabel : rest['aria-label']; + delete rest['aria-label']; } - ); - - const ariaLabelledBy = useEuiI18n( - 'euiDataGrid.ariaLabelledBy', - 'Page {page} of {pageCount}.', - { - page: pagination ? pagination.pageIndex + 1 : 0, - pageCount: pagination ? Math.ceil(rowCount / pagination.pageSize) : 0, + if ('aria-labelledby' in rest) { + gridAriaProps['aria-labelledby'] = `${rest['aria-labelledby']} ${ + pagination ? ariaLabelledById : '' + }`; + delete rest['aria-labelledby']; } - ); - - // extract aria-label and/or aria-labelledby from `rest` - const gridAriaProps: { - 'aria-label'?: string; - 'aria-labelledby'?: string; - } = {}; - if ('aria-label' in rest) { - gridAriaProps['aria-label'] = pagination ? ariaLabel : rest['aria-label']; - delete rest['aria-label']; - } - if ('aria-labelledby' in rest) { - gridAriaProps['aria-labelledby'] = `${rest['aria-labelledby']} ${ - pagination ? ariaLabelledById : '' - }`; - delete rest['aria-labelledby']; - } - return ( - - - -
+ + - {showToolbar && ( - - )} - {inMemory ? ( - - ) : null} -
- -
- {pagination && props['aria-labelledby'] && ( -
+ +
+ {pagination && props['aria-labelledby'] && ( + + )} + {pagination && ( + + )} + - )} - {pagination && ( - - )} - -
-
-
-
- ); -}; + + + + + ); + } +); + +EuiDataGrid.displayName = 'EuiDataGrid'; diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index 4e06beba1c0..ba23040dba9 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -283,6 +283,19 @@ export type EuiDataGridProps = OneOf< 'aria-label' | 'aria-labelledby' >; +export interface EuiDataGridRefProps { + /** + * Allows manually focusing the specified cell in the grid. + * + * Using this method is an accessibility requirement if your EuiDataGrid + * toggles a modal or flyout - focus must be restored to the grid on close + * to prevent keyboard or screen reader users from being stranded. + */ + setFocusedCell(targetCell: EuiDataGridCellLocation): void; +} + +export type EuiDataGridCellLocation = { rowIndex: number; colIndex: number }; + export interface EuiDataGridColumnResizerProps { columnId: string; columnWidth: number; @@ -307,6 +320,7 @@ export interface EuiDataGridCellPopoverProps { | JSXElementConstructor | ((props: EuiDataGridCellValueElementProps) => ReactNode); rowIndex: number; + colIndex: number; } export interface EuiDataGridColumnSortingDraggableProps { id: string; @@ -516,6 +530,10 @@ export interface EuiDataGridColumnCellActionProps { * The index of the row that contains cell's data */ rowIndex: number; + /** + * The index of the column that contains cell's data + */ + colIndex: number; /** * The id of the column that contains the cell's data */ From e5def1c73045810f0022be1bfeb48472ec07d2d6 Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 12 Jan 2022 09:00:59 -0800 Subject: [PATCH 2/9] [EuiDataGrid] Add `setIsFullScreen` to ref API (#5531) * Expose `setIsFullScreen` to ref API * Update documentation/examples --- src-docs/src/views/datagrid/datagrid_ref_example.js | 10 ++++++++++ src-docs/src/views/datagrid/ref.js | 8 ++++++++ src/components/datagrid/data_grid.test.tsx | 1 + src/components/datagrid/data_grid.tsx | 1 + src/components/datagrid/data_grid_types.ts | 4 ++++ 5 files changed, 24 insertions(+) diff --git a/src-docs/src/views/datagrid/datagrid_ref_example.js b/src-docs/src/views/datagrid/datagrid_ref_example.js index 9345f4d75c0..d3ab45bf471 100644 --- a/src-docs/src/views/datagrid/datagrid_ref_example.js +++ b/src-docs/src/views/datagrid/datagrid_ref_example.js @@ -14,6 +14,9 @@ const dataGridRefSource = require('!!raw-loader!./ref'); const dataGridRefSnippet = `const dataGridRef = useRef(); +// Mnaually toggle the data grid's full screen state +dataGridRef.current.setIsFullScreen(true); + // Mnaually focus a specific cell within the data grid dataGridRef.current.setFocusedCell({ rowIndex, colIndex }); `; @@ -37,6 +40,13 @@ export const DataGridRefExample = { the ref prop of EuiDataGrid. These methods are:

    +
  • +

    + setIsFullScreen(isFullScreen) - controls the + full screen state of the data grid. Accepts a true/false boolean + flag. +

    +
  • setFocusedCell({'{ rowIndex, colIndex }'}) - focuses the specified cell in the grid. diff --git a/src-docs/src/views/datagrid/ref.js b/src-docs/src/views/datagrid/ref.js index e043abcedc9..62a0f64b807 100644 --- a/src-docs/src/views/datagrid/ref.js +++ b/src-docs/src/views/datagrid/ref.js @@ -148,6 +148,14 @@ export default () => { Set cell focus + + dataGridRef.current.setIsFullScreen(true)} + > + Set grid to full screen + + diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index a4200aab0ad..66bd8cba928 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -2744,6 +2744,7 @@ describe('EuiDataGrid', () => { ); expect(gridRef.current).toEqual({ + setIsFullScreen: expect.any(Function), setFocusedCell: expect.any(Function), }); }); diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index f5688219b83..d350c50c919 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -290,6 +290,7 @@ export const EuiDataGrid = forwardRef( useImperativeHandle( ref, () => ({ + setIsFullScreen, setFocusedCell: ({ rowIndex, colIndex }) => { focusContext.setFocusedCell([colIndex, rowIndex]); }, diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index ba23040dba9..cf788d29b88 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -284,6 +284,10 @@ export type EuiDataGridProps = OneOf< >; export interface EuiDataGridRefProps { + /** + * Allows manually controlling the full-screen state of the grid. + */ + setIsFullScreen: (isFullScreen: boolean) => void; /** * Allows manually focusing the specified cell in the grid. * From f8a25bca3ee27eee7eed541d97543b363f0bed96 Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 26 Jan 2022 15:46:23 -0800 Subject: [PATCH 3/9] [EuiDataGrid] Add `openCellPopover` and `closeCellPopover` to ref APIs (#5550) * [setup] Update testCustomHook to expose fn that allows accessing most recent state/value - without this callback, the initial returned hook values will be stale/not properly return most recent values - see next commit for example usage within useCellPopover * Set up cell popover context - set up initial open/location state, + open/close popover APIs returned to consumers - improve auto props documentation - remove EuiDataGridCellLocation in favor of specifying rowIndex and colIndex (it's less DRY but it's easier for devs to not have to look up EuiDataGridCellLocation from our docs) * Pass down popoverContext to cells as a prop - I'm not using context here because we're already using this.context for focus, and unfortunately class components can only initialize one context at time using `static contextType` (see https://reactjs.org/docs/context.html#classcontexttype) * Remove internal cell popoverIsOpen state - This should now be handled by the overarching context state, and the cell should simply react to it or update it (similar to how focusContext works) + add new var for hasCellButtons + add unit tests for isFocusedCell alongside isPopoverOpen (since both methods perform similar functions) * Update cell popovers to set the popover anchor & content - content is TODO, will likely be easier to compare when cleaning it up/moving it all at once * Refactor EuiDataGridCellPopover - It should no longer exist as a reusable component that belongs to every single cell, but instead as a single popover that exists at the top grid level and moves from cell to cell - I cleaned and split up the JSX for the popover (e.g. moving popover actions to data_grid_cell_buttons, where it feels like it belongs more) and think it's significantly more DRY now - note the entire `anchorContent` branch removed from EuiDataGridCell that is no longer necessary - Note that due to this change, we now have to mock EuiWrappingPopover in EuiDataGrid tests, as we see failures otherwise * [bugfix] Handle cells with open popover being scrolled out of view - this is the same behavior as in prod - causes weird DOM issues if we don't close the cell popover automatically * [bugfix] Workaround for popover DOM stuttering issues * [enhancement] Account for openCellPopover being called on cells out of view + write bonus Cypress test for useScroll's focus effect now that we have access to the imperative ref * Update documentation example + remove code snippet - it was starting to get redundant with the API bullet points, and is already available as tab if needed + fix control button widths * [PR feedback] Be more specific about useImperativeHandle dependencies + add a few explanatory comments * [PR feedback] Rename openCellLocation to cellLocation - to make it sound less like a verb/method * [PR feedback] Ignore edge case of `openCellPopover` being called on an `isExpandable: false` cell --- .../views/datagrid/datagrid_ref_example.js | 27 +- src-docs/src/views/datagrid/ref.js | 24 +- .../data_grid_cell.test.tsx.snap | 47 +++ .../datagrid/body/data_grid_body.tsx | 5 +- .../datagrid/body/data_grid_cell.test.tsx | 202 ++++++++++- .../datagrid/body/data_grid_cell.tsx | 161 ++++++--- .../body/data_grid_cell_buttons.test.tsx | 63 +++- .../datagrid/body/data_grid_cell_buttons.tsx | 44 +++ .../body/data_grid_cell_popover.test.tsx | 322 ++++++++++-------- .../datagrid/body/data_grid_cell_popover.tsx | 139 ++++---- .../body/data_grid_footer_row.test.tsx | 52 +++ .../datagrid/body/data_grid_footer_row.tsx | 5 +- .../controls/display_selector.test.tsx | 4 +- src/components/datagrid/data_grid.test.tsx | 10 + src/components/datagrid/data_grid.tsx | 247 +++++++------- src/components/datagrid/data_grid_types.ts | 46 +-- .../datagrid/utils/scrolling.spec.tsx | 35 +- .../datagrid/utils/scrolling.test.ts | 68 ++-- src/components/datagrid/utils/scrolling.ts | 14 + src/test/test_custom_hook.tsx | 15 +- 20 files changed, 1069 insertions(+), 461 deletions(-) diff --git a/src-docs/src/views/datagrid/datagrid_ref_example.js b/src-docs/src/views/datagrid/datagrid_ref_example.js index d3ab45bf471..ee550b19479 100644 --- a/src-docs/src/views/datagrid/datagrid_ref_example.js +++ b/src-docs/src/views/datagrid/datagrid_ref_example.js @@ -1,12 +1,7 @@ import React from 'react'; import { GuideSectionTypes } from '../../components'; -import { - EuiCode, - EuiCodeBlock, - EuiSpacer, - EuiCallOut, -} from '../../../../src/components'; +import { EuiCode, EuiSpacer, EuiCallOut } from '../../../../src/components'; import { EuiDataGridRefProps } from '!!prop-loader!../../../../src/components/datagrid/data_grid_types'; import DataGridRef from './ref'; @@ -19,6 +14,12 @@ dataGridRef.current.setIsFullScreen(true); // Mnaually focus a specific cell within the data grid dataGridRef.current.setFocusedCell({ rowIndex, colIndex }); + +// Manually opens the popover of a specified cell within the data grid +dataGridRef.current.openCellPopover({ rowIndex, colIndex }); + +// Close any open cell popover +dataGridRef.current.closeCellPopover(); `; export const DataGridRefExample = { @@ -59,9 +60,21 @@ export const DataGridRefExample = { Your modal or flyout should restore focus into the grid on close to prevent keyboard or screen reader users from being stranded. + +
  • +
  • +

    + openCellPopover({'{ rowIndex, colIndex }'}) - + opens the specified cell's popover contents. +

    +
  • +
  • +

    + closeCellPopover() - closes any currently + open cell popover. +

- {dataGridRefSnippet}

The below example shows how to use the internal APIs for a data grid that opens a modal via cell actions. diff --git a/src-docs/src/views/datagrid/ref.js b/src-docs/src/views/datagrid/ref.js index 62a0f64b807..2ce537c6884 100644 --- a/src-docs/src/views/datagrid/ref.js +++ b/src-docs/src/views/datagrid/ref.js @@ -42,6 +42,7 @@ export default () => { const showModal = useCallback(({ rowIndex, colIndex }) => { setIsModalVisible(true); + dataGridRef.current.closeCellPopover(); // Close any open cell popovers setLastFocusedCell({ rowIndex, colIndex }); // Store the cell that opened this modal }, []); @@ -112,8 +113,8 @@ export default () => { return ( <> - - + + { /> - + { /> - + @@ -148,7 +149,20 @@ export default () => { Set cell focus - + + + dataGridRef.current.openCellPopover({ + rowIndex: rowIndexAction, + colIndex: colIndexAction, + }) + } + > + Open cell popover + + + dataGridRef.current.setIsFullScreen(true)} diff --git a/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap b/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap index 03fc9306cda..371eb036f0f 100644 --- a/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap +++ b/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap @@ -7,6 +7,19 @@ exports[`EuiDataGridCell renders 1`] = ` interactiveCellId="someId" isExpandable={true} popoverContent={[Function]} + popoverContext={ + Object { + "cellLocation": Object { + "colIndex": 0, + "rowIndex": 0, + }, + "closeCellPopover": [MockFunction], + "openCellPopover": [MockFunction], + "popoverIsOpen": false, + "setPopoverAnchor": [MockFunction], + "setPopoverContent": [MockFunction], + } + } renderCellValue={[Function]} rowHeightUtils={ Object { @@ -154,3 +167,37 @@ exports[`EuiDataGridCell renders 1`] = ` `; + +exports[`EuiDataGridCell componentDidUpdate handles the cell popover by forwarding the cell's DOM node and contents to the parent popover context 1`] = ` +Array [ +

+
+ + +
+
, +
+
+
+
+
+
, +] +`; diff --git a/src/components/datagrid/body/data_grid_body.tsx b/src/components/datagrid/body/data_grid_body.tsx index e6912ae358e..6c10315fd05 100644 --- a/src/components/datagrid/body/data_grid_body.tsx +++ b/src/components/datagrid/body/data_grid_body.tsx @@ -28,6 +28,7 @@ import { EuiDataGridCell } from './data_grid_cell'; import { EuiDataGridFooterRow } from './data_grid_footer_row'; import { EuiDataGridHeaderRow } from './header'; import { DefaultColumnFormatter } from './popover_utils'; +import { DataGridCellPopoverContext } from './data_grid_cell_popover'; import { EuiDataGridBodyProps, EuiDataGridRowManager, @@ -70,6 +71,7 @@ export const Cell: FunctionComponent = ({ rowHeightUtils, rowManager, } = data; + const popoverContext = useContext(DataGridCellPopoverContext); const { headerRowHeight } = useContext(DataGridWrapperRowsContext); const { getCorrectRowIndex } = useContext(DataGridSortingContext); @@ -118,7 +120,8 @@ export const Cell: FunctionComponent = ({ rowHeightsOptions, rowHeightUtils, setRowHeight: isFirstColumn ? setRowHeight : undefined, - rowManager: rowManager, + rowManager, + popoverContext, }; if (isLeadingControlColumn) { diff --git a/src/components/datagrid/body/data_grid_cell.test.tsx b/src/components/datagrid/body/data_grid_cell.test.tsx index e74c9efd830..07e6bb5474c 100644 --- a/src/components/datagrid/body/data_grid_cell.test.tsx +++ b/src/components/datagrid/body/data_grid_cell.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; +import { mount, render, ReactWrapper } from 'enzyme'; import { keys } from '../../../services'; import { mockRowHeightUtils } from '../utils/__mocks__/row_heights'; import { mockFocusContext } from '../utils/__mocks__/focus_context'; @@ -16,6 +16,14 @@ import { DataGridFocusContext } from '../utils/focus'; import { EuiDataGridCell } from './data_grid_cell'; describe('EuiDataGridCell', () => { + const mockPopoverContext = { + popoverIsOpen: false, + cellLocation: { rowIndex: 0, colIndex: 0 }, + closeCellPopover: jest.fn(), + openCellPopover: jest.fn(), + setPopoverAnchor: jest.fn(), + setPopoverContent: jest.fn(), + }; const requiredProps = { rowIndex: 0, visibleRowIndex: 0, @@ -29,7 +37,10 @@ describe('EuiDataGridCell', () => { ), - popoverContent: () =>
popover
, + popoverContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + popoverContext: mockPopoverContext, rowHeightUtils: mockRowHeightUtils, }; @@ -51,19 +62,24 @@ describe('EuiDataGridCell', () => { }} /> ); - component.setState({ popoverIsOpen: true }); - - const cellButtons = component.find('EuiDataGridCellButtons'); - expect(component.find('EuiDataGridCellButtons')).toHaveLength(1); + component.setState({ enableInteractions: true }); - // Should handle re-closing the popover correctly + const getCellButtons = () => component.find('EuiDataGridCellButtons'); + expect(getCellButtons()).toHaveLength(1); - (cellButtons.prop('onExpandClick') as Function)(); - expect(component.state('popoverIsOpen')).toEqual(false); + // Should handle opening the popover + (getCellButtons().prop('onExpandClick') as Function)(); + expect(mockPopoverContext.openCellPopover).toHaveBeenCalled(); - component.setState({ popoverIsOpen: true }); - (cellButtons.prop('closePopover') as Function)(); - expect(component.state('popoverIsOpen')).toEqual(false); + // Should handle closing the popover + component.setProps({ + isExpandable: true, + popoverContext: { ...mockPopoverContext, popoverIsOpen: true }, + }); + (getCellButtons().prop('onExpandClick') as Function)(); + expect(mockPopoverContext.closeCellPopover).toHaveBeenCalledTimes(1); + (getCellButtons().prop('closePopover') as Function)(); + expect(mockPopoverContext.closeCellPopover).toHaveBeenCalledTimes(2); }); describe('shouldComponentUpdate', () => { @@ -117,6 +133,19 @@ describe('EuiDataGridCell', () => { it('popoverContent', () => { component.setProps({ popoverContent: () =>
test
}); }); + it('popoverContext.popoverIsOpen', () => { + component.setProps({ + popoverContext: { ...mockPopoverContext, popoverIsOpen: true }, + }); + }); + it('popoverContext.cellLocation', () => { + component.setProps({ + popoverContext: { + ...mockPopoverContext, + cellLocation: { rowIndex: 5, colIndex: 5 }, + }, + }); + }); it('style', () => { component.setProps({ style: {} }); component.setProps({ style: { top: 0 } }); @@ -132,9 +161,6 @@ describe('EuiDataGridCell', () => { it('cellProps', () => { component.setState({ cellProps: {} }); }); - it('popoverIsOpen', () => { - component.setState({ popoverIsOpen: true }); - }); it('isEntered', () => { component.setState({ isEntered: true }); }); @@ -164,6 +190,29 @@ describe('EuiDataGridCell', () => { component.setProps({ columnId: 'newColumnId' }); expect(setState).toHaveBeenCalledWith({ cellProps: {} }); }); + + it("handles the cell popover by forwarding the cell's DOM node and contents to the parent popover context", () => { + const component = mount( +