diff --git a/CHANGELOG.md b/CHANGELOG.md index fd58c908825..c61f5471f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Exported `EuiSelectOptionProps` type ([#2830](https://github.com/elastic/eui/pull/2830)) - Added `paperClip` glyph to `EuiIcon` ([#2845](https://github.com/elastic/eui/pull/2845)) - Added `banner` prop to `EuiFlyoutBody` and updated `euiOverflowShadow` mixin ([#2837](https://github.com/elastic/eui/pull/2837)) +- Added control columns to `EuiDataGrid` to support non-data columns like row selection and actions ([#2846](https://github.com/elastic/eui/pull/2846)) **Bug fixes** diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 80ff1d72b9d..41a0c6088b6 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -79,6 +79,7 @@ import { DataGridExample } from './views/datagrid/datagrid_example'; import { DataGridMemoryExample } from './views/datagrid/datagrid_memory_example'; import { DataGridSchemaExample } from './views/datagrid/datagrid_schema_example'; import { DataGridStylingExample } from './views/datagrid/datagrid_styling_example'; +import { DataGridControlColumnsExample } from './views/datagrid/datagrid_controlcolumns_example'; import { DatePickerExample } from './views/date_picker/date_picker_example'; @@ -341,6 +342,7 @@ const navigation = [ DataGridMemoryExample, DataGridSchemaExample, DataGridStylingExample, + DataGridControlColumnsExample, TableExample, ].map(example => createExample(example)), }, diff --git a/src-docs/src/views/datagrid/control_columns.js b/src-docs/src/views/datagrid/control_columns.js new file mode 100644 index 00000000000..171f08c73ca --- /dev/null +++ b/src-docs/src/views/datagrid/control_columns.js @@ -0,0 +1,377 @@ +import React, { + createContext, + useContext, + useCallback, + useReducer, + useState, + Fragment, +} from 'react'; +import { fake } from 'faker'; + +import { + EuiDataGrid, + EuiAvatar, + EuiCheckbox, + EuiButtonIcon, + EuiPopover, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPopoverTitle, + EuiSpacer, + EuiPortal, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '../../../../src/components/'; + +const columns = [ + { + id: 'avatar', + initialWidth: 38, + isExpandable: false, + isResizable: false, + headerCellRender: () => null, + }, + { + id: 'name', + }, + { + id: 'email', + }, + { + id: 'city', + }, + { + id: 'country', + }, + { + id: 'account', + }, +]; + +const data = []; + +for (let i = 1; i < 500; i++) { + data.push({ + avatar: ( + + ), + name: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + email: fake('{{internet.email}}'), + city: fake('{{address.city}}'), + country: fake('{{address.country}}'), + account: fake('{{finance.account}}'), + }); +} + +const SelectionContext = createContext(); + +const SelectionButton = () => { + const [selectedRows] = useContext(SelectionContext); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + if (selectedRows.size > 0) { + return ( + setIsPopoverOpen(!isPopoverOpen)}> + {selectedRows.size} {selectedRows.size > 1 ? 'items' : 'item'}{' '} + selected + + } + closePopover={() => setIsPopoverOpen(false)} + ownFocus={true}> + + {selectedRows.size} {selectedRows.size > 1 ? 'items' : 'item'} + +
+ + + +
+
+ ); + } else { + return null; + } +}; + +const SelectionHeaderCell = () => { + const [selectedRows, updateSelectedRows] = useContext(SelectionContext); + const isIndeterminate = + selectedRows.size > 0 && selectedRows.size < data.length; + return ( + 0} + onChange={e => { + if (isIndeterminate) { + // clear selection + updateSelectedRows({ action: 'clear' }); + } else { + if (e.target.checked) { + // select everything + updateSelectedRows({ action: 'selectAll' }); + } else { + // clear selection + updateSelectedRows({ action: 'clear' }); + } + } + }} + /> + ); +}; + +const SelectionRowCell = ({ rowIndex }) => { + const [selectedRows, updateSelectedRows] = useContext(SelectionContext); + const isChecked = selectedRows.has(rowIndex); + + return ( +
+ { + if (e.target.checked) { + updateSelectedRows({ action: 'add', rowIndex }); + } else { + updateSelectedRows({ action: 'delete', rowIndex }); + } + }} + /> +
+ ); +}; + +const FlyoutRowCell = rowIndex => { + let flyout; + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + if (isFlyoutOpen) { + const rowData = data[rowIndex.rowIndex]; + console.log(rowData); + + const details = Object.entries(rowData).map(([key, value]) => { + return ( + + {key} + {value} + + ); + }); + + flyout = ( + + setIsFlyoutOpen(!isFlyoutOpen)}> + + +

{rowData.name}

+
+
+ + {details} + +
+
+ ); + } + + return ( + + setIsFlyoutOpen(!isFlyoutOpen)} + /> + {flyout} + + ); +}; + +const leadingControlColumns = [ + { + id: 'selection', + width: 32, + headerCellRender: SelectionHeaderCell, + rowCellRender: SelectionRowCell, + }, + { + id: 'View', + width: 36, + headerCellRender: () => null, + rowCellRender: FlyoutRowCell, + }, +]; + +const trailingControlColumns = [ + { + id: 'actions', + width: 40, + headerCellRender: () => null, + rowCellRender: function RowCellRender() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + return ( +
+ setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + ownFocus={true}> + Actions +
+ + + +
+
+
+ ); + }, + }, +]; + +export default function DataGrid() { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 15, + }); + const setPageIndex = useCallback( + pageIndex => setPagination({ ...pagination, pageIndex }), + [pagination, setPagination] + ); + const setPageSize = useCallback( + pageSize => setPagination({ ...pagination, pageSize }), + [pagination, setPagination] + ); + + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); + + const rowSelection = useReducer((rowSelection, { action, rowIndex }) => { + if (action === 'add') { + const nextRowSelection = new Set(rowSelection); + nextRowSelection.add(rowIndex); + return nextRowSelection; + } else if (action === 'delete') { + const nextRowSelection = new Set(rowSelection); + nextRowSelection.delete(rowIndex); + return nextRowSelection; + } else if (action === 'clear') { + return new Set(); + } else if (action === 'selectAll') { + return new Set(data.map((_, index) => index)); + } + return rowSelection; + }, new Set()); + + const renderCellValue = useCallback( + ({ rowIndex, columnId }) => data[rowIndex][columnId], + [] + ); + + return ( + +
+ , + }} + /> +
+
+ ); +} diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index 243988fd1ca..67a438cc830 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -12,8 +12,11 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem, + EuiPopover, + EuiPopoverTitle, + EuiButtonIcon, + EuiSpacer, } from '../../../../src/components/'; -import { EuiButtonIcon } from '../../../../src/components/button/button_icon'; const columns = [ { @@ -82,6 +85,69 @@ for (let i = 1; i < 100; i++) { }); } +const trailingControlColumns = [ + { + id: 'actions', + width: 40, + headerCellRender: () => null, + rowCellRender: function RowCellRender() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + return ( +
+ setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + ownFocus={true}> + Actions +
+ + + +
+
+
+ ); + }, + }, +]; + export default () => { // ** Pagination config const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); @@ -137,6 +203,7 @@ export default () => { aria-label="Data grid demo" columns={columns} columnVisibility={{ visibleColumns, setVisibleColumns }} + trailingControlColumns={trailingControlColumns} rowCount={raw_data.length} renderCellValue={renderCellValue} inMemory={{ level: 'sorting' }} diff --git a/src-docs/src/views/datagrid/datagrid_controlcolumns_example.js b/src-docs/src/views/datagrid/datagrid_controlcolumns_example.js new file mode 100644 index 00000000000..9b77f41e279 --- /dev/null +++ b/src-docs/src/views/datagrid/datagrid_controlcolumns_example.js @@ -0,0 +1,83 @@ +import React, { Fragment } from 'react'; + +import { renderToHtml } from '../../services'; + +import { GuideSectionTypes } from '../../components'; +import { EuiDataGrid, EuiCodeBlock, EuiCode } from '../../../../src/components'; + +import DataGridControlColumns from './control_columns'; +const dataGridControlColumnsSource = require('!!raw-loader!./control_columns'); +const dataGridControlColumnsHtml = renderToHtml(DataGridControlColumns); + +import { DataGridControlColumn as EuiDataGridControlColumn } from './props'; + +const gridSnippet = ` Select a Row, + rowCellRender: () =>
, + }, + ]} + trailingControlColumns={[ + { + id: 'actions', + width: 40, + headerCellRender: () => null, + rowCellRender: MyGridActionsComponent, + }, + ]} +/> +`; + +export const DataGridControlColumnsExample = { + title: 'Data grid control columns', + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridControlColumnsSource, + }, + { + type: GuideSectionTypes.HTML, + code: dataGridControlColumnsHtml, + }, + ], + text: ( + +

+ Control columns can be used to include ancillary cells not based on + the dataset, such as row selection checkboxes or action buttons. + These columns can be placed at either side of the data grid, and + users are unable to resize, sort, or rearrange them. +

+

+ These custom columns are defined by passing an array of + EuiDataGridControlColumn objects (see Props tab below) to{' '} + leadingControlColumns and/or{' '} + trailingControlColumns. +

+

+ As with the data grid's renderCellValue, the + control columns' headerCellRender and{' '} + rowCellRender props are treated as React + components. +

+ + {gridSnippet} + +
+ ), + components: { DataGridControlColumns }, + + props: { + EuiDataGrid, + EuiDataGridControlColumn, + }, + demo: , + }, + ], +}; diff --git a/src-docs/src/views/datagrid/datagrid_example.js b/src-docs/src/views/datagrid/datagrid_example.js index 4541aa3ee29..5139383942b 100644 --- a/src-docs/src/views/datagrid/datagrid_example.js +++ b/src-docs/src/views/datagrid/datagrid_example.js @@ -29,6 +29,7 @@ import { DataGridToolbarVisibilityOptions, DataGridColumnVisibility, DataGridPopoverContent, + DataGridControlColumn, } from './props'; const gridSnippet = ` @@ -46,6 +47,22 @@ const gridSnippet = ` visibleColumns: ['A', 'C'], setVisibleColumns: () => {}, }} + leadingControlColumns={[ + { + id: 'selection', + width: 31, + headerCellRender: () => Select a Row, + rowCellRender: () =>
, + }, + ]} + trailingControlColumns={[ + { + id: 'actions', + width: 40, + headerCellRender: () => null, + rowCellRender: MyGridActionsComponent, + }, + ]} // Optional. Customize the content inside the cell. The current example outputs the row and column position. // Often used in combination with useEffect() to dynamically change the render. renderCellValue={({ rowIndex, columnId }) => @@ -161,6 +178,16 @@ const gridConcepts = [ ), }, + { + title: 'leading and trailing controlColumns', + description: ( + + An array of EuiDataGridControlColumn objects. Used to + define ancillary columns on the left side of the data grid. Useful for + adding items like checkboxes and buttons. + + ), + }, { title: 'schemaDetectors', description: ( @@ -299,6 +326,13 @@ export const DataGridExample = { can be controlled by the engineer, but augmented by user preference depending upon the features you enable. +
  • + + Control columns + {' '} + allow you to add repeatable actions and controls like checkboxes + or buttons to your grid. +
  • ), @@ -307,6 +341,7 @@ export const DataGridExample = { EuiDataGrid, EuiDataGridColumn: DataGridColumn, EuiDataGridColumnVisibility: DataGridColumnVisibility, + EuiDataGridControlColumn: DataGridControlColumn, EuiDataGridInMemory: DataGridInMemory, EuiDataGridPagination: DataGridPagination, EuiDataGridSorting: DataGridSorting, diff --git a/src-docs/src/views/datagrid/props.tsx b/src-docs/src/views/datagrid/props.tsx index 0f08f8aa051..bd828719b24 100644 --- a/src-docs/src/views/datagrid/props.tsx +++ b/src-docs/src/views/datagrid/props.tsx @@ -8,6 +8,7 @@ import { EuiDataGridToolBarVisibilityOptions, EuiDataGridColumnVisibility, EuiDataGridPopoverContentProps, + EuiDataGridControlColumn, } from '../../../../src/components/datagrid/data_grid_types'; import { EuiDataGridCellValueElementProps } from '../../../../src/components/datagrid/data_grid_cell'; import { EuiDataGridSchemaDetector } from '../../../../src/components/datagrid/data_grid_schema'; @@ -49,3 +50,7 @@ export const DataGridColumnVisibility: FunctionComponent< export const DataGridPopoverContent: FunctionComponent< EuiDataGridPopoverContentProps > = () =>
    ; + +export const DataGridControlColumn: FunctionComponent< + EuiDataGridControlColumn +> = () =>
    ; diff --git a/src-docs/src/views/tables/in_memory/in_memory_selection.js b/src-docs/src/views/tables/in_memory/in_memory_selection.js index 53d6158b88a..44c6721872e 100644 --- a/src-docs/src/views/tables/in_memory/in_memory_selection.js +++ b/src-docs/src/views/tables/in_memory/in_memory_selection.js @@ -95,7 +95,7 @@ export class Table extends Component { } renderToolsLeft() { - const selection = this.state.selection; + const selection = this.state.control_columns; if (selection.length === 0) { return; diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index ed1397c1409..a999237e25a 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -183,6 +183,9 @@ Array [ class="euiDataGrid__controls" data-test-sub="dataGridControls" > +
    - +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + leading heading + +
    +
    +
    +
    + A +
    +
    +
    +
    + B +
    +
    +
    +
    + + trailing heading + +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 1, Column: 1: + +

    +
    + 0 +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 1, Column: 2: + +

    +
    + 0, A +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 1, Column: 3: + +

    +
    + 0, B +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 1, Column: 4: + +

    +
    + 0 +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 2, Column: 1: + +

    +
    + 1 +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 2, Column: 2: + +

    +
    + 1, A +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 2, Column: 3: + +

    +
    + 1, B +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 2, Column: 4: + +

    +
    + 1 +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 3, Column: 1: + +

    +
    + 2 +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 3, Column: 2: + +

    +
    + 2, A +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 3, Column: 3: + +

    +
    + 2, B +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + + Row: 3, Column: 4: + +

    +
    + 2 +
    +
    +
    +
    +
    +
    +
    +
    + + + , +
    , +] +`; + exports[`EuiDataGrid rendering renders custom column headers 1`] = ` Array [
    { + const component = render( + {}, + }} + leadingControlColumns={[ + { + id: 'leading', + width: 50, + headerCellRender: () => leading heading, + rowCellRender: ({ rowIndex }) => rowIndex, + }, + ]} + trailingControlColumns={[ + { + id: 'trailing', + width: 50, + headerCellRender: () => trailing heading, + rowCellRender: ({ rowIndex }) => rowIndex, + }, + ]} + rowCount={3} + renderCellValue={({ rowIndex, columnId }) => + `${rowIndex}, ${columnId}` + } + toolbarVisibility={{ additionalControls: }} + /> + ); + + expect(component).toMatchSnapshot(); + }); + it('can hide the toolbar', () => { const component = mount( & { + /** + * An array of #EuiDataGridControlColumn objects. Used to define ancillary columns on the left side of the data grid. + */ + leadingControlColumns?: EuiDataGridControlColumn[]; + /** + * An array of #EuiDataGridControlColumn objects. Used to define ancillary columns on the right side of the data grid. + */ + trailingControlColumns?: EuiDataGridControlColumn[]; /** * An array of #EuiDataGridColumn objects. Lists the columns available and the schema and settings tied to it. */ @@ -211,6 +217,8 @@ function renderPagination(props: EuiDataGridProps) { function useDefaultColumnWidth( container: HTMLElement | null, + leadingControlColumns: EuiDataGridProps['leadingControlColumns'] = [], + trailingControlColumns: EuiDataGridProps['leadingControlColumns'] = [], columns: EuiDataGridProps['columns'] ): number | null { const [defaultColumnWidth, setDefaultColumnWidth] = useState( @@ -221,20 +229,32 @@ function useDefaultColumnWidth( if (container != null) { const gridWidth = container.clientWidth; + const controlColumnWidths = [ + ...leadingControlColumns, + ...trailingControlColumns, + ].reduce( + (claimedWidth, controlColumn: EuiDataGridControlColumn) => + claimedWidth + controlColumn.width, + 0 + ); + const columnsWithWidths = columns.filter< EuiDataGridColumn & { initialWidth: number } >(doesColumnHaveAnInitialWidth); - const claimedWidth = columnsWithWidths.reduce( + + const definedColumnsWidth = columnsWithWidths.reduce( (claimedWidth, column) => claimedWidth + column.initialWidth, 0 ); + const claimedWidth = controlColumnWidths + definedColumnsWidth; + const widthToFill = gridWidth - claimedWidth; const unsizedColumnCount = columns.length - columnsWithWidths.length; const columnWidth = Math.max(widthToFill / unsizedColumnCount, 100); setDefaultColumnWidth(columnWidth); } - }, [container, columns]); + }, [container, columns, leadingControlColumns, trailingControlColumns]); return defaultColumnWidth; } @@ -319,13 +339,19 @@ function useInMemoryValues( function createKeyDownHandler( props: EuiDataGridProps, visibleColumns: EuiDataGridProps['columns'], + leadingControlColumns: EuiDataGridProps['leadingControlColumns'] = [], + trailingControlColumns: EuiDataGridProps['trailingControlColumns'] = [], focusedCell: EuiDataGridFocusedCell, headerIsInteractive: boolean, setFocusedCell: (focusedCell: EuiDataGridFocusedCell) => void, updateFocus: Function ) { return (event: KeyboardEvent) => { - const colCount = visibleColumns.length - 1; + const colCount = + visibleColumns.length + + leadingControlColumns.length + + trailingControlColumns.length - + 1; const [x, y] = focusedCell; const rowCount = computeVisibleRows(props); const { keyCode, ctrlKey } = event; @@ -494,6 +520,8 @@ export const EuiDataGrid: FunctionComponent = props => { }; const { + leadingControlColumns, + trailingControlColumns, columns, columnVisibility, schemaDetectors, @@ -556,6 +584,8 @@ export const EuiDataGrid: FunctionComponent = props => { // compute the default column width from the container's clientWidth and count of visible columns const defaultColumnWidth = useDefaultColumnWidth( containerRef, + leadingControlColumns, + trailingControlColumns, orderedVisibleColumns ); @@ -618,6 +648,12 @@ export const EuiDataGrid: FunctionComponent = props => { // They can also be optionally turned off individually by using toolbarVisibility const gridControls = ( + {checkOrDefaultToolBarDiplayOptions( + toolbarVisibility, + 'additionalControls' + ) && typeof toolbarVisibility !== 'boolean' + ? toolbarVisibility.additionalControls + : null} {checkOrDefaultToolBarDiplayOptions( toolbarVisibility, 'showColumnSelector' @@ -633,12 +669,6 @@ export const EuiDataGrid: FunctionComponent = props => { {checkOrDefaultToolBarDiplayOptions(toolbarVisibility, 'showSortSelector') ? columnSorting : null} - {checkOrDefaultToolBarDiplayOptions( - toolbarVisibility, - 'additionalControls' - ) && typeof toolbarVisibility !== 'boolean' - ? toolbarVisibility.additionalControls - : null} ); @@ -752,6 +782,8 @@ export const EuiDataGrid: FunctionComponent = props => { onKeyDown={createKeyDownHandler( props, orderedVisibleColumns, + leadingControlColumns, + trailingControlColumns, realizedFocusedCell, headerIsInteractive, setFocusedCell, @@ -789,6 +821,8 @@ export const EuiDataGrid: FunctionComponent = props => { {ref => ( = props => { defaultColumnWidth={defaultColumnWidth} inMemoryValues={inMemoryValues} inMemory={inMemory} + leadingControlColumns={leadingControlColumns} + trailingControlColumns={trailingControlColumns} columns={orderedVisibleColumns} schema={mergedSchema} schemaDetectors={allSchemaDetectors} diff --git a/src/components/datagrid/data_grid_body.tsx b/src/components/datagrid/data_grid_body.tsx index 7194d2e7df1..4abf985f2e9 100644 --- a/src/components/datagrid/data_grid_body.tsx +++ b/src/components/datagrid/data_grid_body.tsx @@ -2,6 +2,7 @@ import React, { Fragment, FunctionComponent, useMemo } from 'react'; // @ts-ignore-next-line import { EuiCodeBlock } from '../code'; import { + EuiDataGridControlColumn, EuiDataGridColumn, EuiDataGridColumnWidths, EuiDataGridPopoverContents, @@ -24,6 +25,8 @@ import { export interface EuiDataGridBodyProps { columnWidths: EuiDataGridColumnWidths; defaultColumnWidth?: number | null; + leadingControlColumns?: EuiDataGridControlColumn[]; + trailingControlColumns?: EuiDataGridControlColumn[]; columns: EuiDataGridColumn[]; schema: EuiDataGridSchema; schemaDetectors: EuiDataGridSchemaDetector[]; @@ -74,6 +77,8 @@ export const EuiDataGridBody: FunctionComponent< const { columnWidths, defaultColumnWidth, + leadingControlColumns = [], + trailingControlColumns = [], columns, schema, schemaDetectors, @@ -173,6 +178,8 @@ export const EuiDataGridBody: FunctionComponent< return ( @@ -190,13 +191,18 @@ export class EuiDataGridCell extends Component< interactiveCellId, columnType, onCellFocus, + className, ...rest } = this.props; const { colIndex, rowIndex, visibleRowIndex } = rest; - const className = classNames('euiDataGridRowCell', { - [`euiDataGridRowCell--${columnType}`]: columnType, - }); + const cellClasses = classNames( + 'euiDataGridRowCell', + { + [`euiDataGridRowCell--${columnType}`]: columnType, + }, + className + ); const cellProps = { ...this.state.cellProps, @@ -204,7 +210,7 @@ export class EuiDataGridCell extends Component< 'dataGridRowCell', this.state.cellProps['data-test-subj'] ), - className: classNames(className, this.state.cellProps.className), + className: classNames(cellClasses, this.state.cellProps.className), }; const widthStyle = width != null ? { width: `${width}px` } : {}; diff --git a/src/components/datagrid/data_grid_control_header_cell.tsx b/src/components/datagrid/data_grid_control_header_cell.tsx new file mode 100644 index 00000000000..ead022c8262 --- /dev/null +++ b/src/components/datagrid/data_grid_control_header_cell.tsx @@ -0,0 +1,175 @@ +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; +import classnames from 'classnames'; +import { keyCodes } from '../../services'; +import tabbable from 'tabbable'; +import { + EuiDataGridControlColumn, + EuiDataGridFocusedCell, +} from './data_grid_types'; +import { EuiDataGridDataRowProps } from './data_grid_data_row'; + +export interface EuiDataGridControlHeaderRowProps { + index: number; + controlColumn: EuiDataGridControlColumn; + focusedCell: EuiDataGridFocusedCell; + setFocusedCell: EuiDataGridDataRowProps['onCellFocus']; + headerIsInteractive: boolean; + className?: string; +} + +export const EuiDataGridControlHeaderCell: FunctionComponent< + EuiDataGridControlHeaderRowProps +> = props => { + const { + controlColumn, + index, + focusedCell, + setFocusedCell, + headerIsInteractive, + className, + } = props; + + const { headerCellRender: HeaderCellRender, width, id } = controlColumn; + + const classes = classnames('euiDataGridHeaderCell', className); + + const headerRef = useRef(null); + const isFocused = focusedCell[0] === index && focusedCell[1] === -1; + const [isCellEntered, setIsCellEntered] = useState(false); + + useEffect(() => { + if (headerRef.current) { + function enableInteractives() { + const interactiveElements = headerRef.current!.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + for (let i = 0; i < interactiveElements.length; i++) { + interactiveElements[i].setAttribute('tabIndex', '0'); + } + } + + function disableInteractives() { + const tababbles = tabbable(headerRef.current!); + if (tababbles.length > 1) { + console.warn( + `EuiDataGridHeaderCell expects at most 1 tabbable element, ${ + tababbles.length + } found instead` + ); + } + for (let i = 0; i < tababbles.length; i++) { + const element = tababbles[i]; + element.setAttribute('data-euigrid-tab-managed', 'true'); + element.setAttribute('tabIndex', '-1'); + } + } + + if (isCellEntered) { + enableInteractives(); + const tabbables = tabbable(headerRef.current!); + if (tabbables.length > 0) { + tabbables[0].focus(); + } + } else { + disableInteractives(); + } + } + }, [isCellEntered]); + + useEffect(() => { + if (headerRef.current) { + if (isFocused) { + const interactives = headerRef.current.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + if (interactives.length === 1) { + setIsCellEntered(true); + } else { + headerRef.current.focus(); + } + } else { + setIsCellEntered(false); + } + + // focusin bubbles while focus does not, and this needs to react to children gaining focus + function onFocusIn(e: FocusEvent) { + if (headerIsInteractive === false) { + // header is not interactive, avoid focusing + requestAnimationFrame(() => headerRef.current!.blur()); + e.preventDefault(); + return false; + } else { + // take the focus + setFocusedCell([index, -1]); + } + } + + // focusout bubbles while blur does not, and this needs to react to the children losing focus + function onFocusOut() { + // wait for the next element to receive focus, then update interactives' state + requestAnimationFrame(() => { + if (headerRef.current) { + if (headerRef.current.contains(document.activeElement) === false) { + setIsCellEntered(false); + } + } + }); + } + + function onKeyUp(e: KeyboardEvent) { + switch (e.keyCode) { + case keyCodes.ENTER: { + e.preventDefault(); + setIsCellEntered(true); + break; + } + case keyCodes.ESCAPE: { + e.preventDefault(); + // move focus to cell + setIsCellEntered(false); + headerRef.current!.focus(); + break; + } + case keyCodes.F2: { + e.preventDefault(); + if (document.activeElement === headerRef.current) { + // move focus into cell's interactives + setIsCellEntered(true); + } else { + // move focus to cell + setIsCellEntered(false); + headerRef.current!.focus(); + } + break; + } + } + } + + const headerNode = headerRef.current; + // @ts-ignore-next line TS doesn't have focusin + headerNode.addEventListener('focusin', onFocusIn); + headerNode.addEventListener('focusout', onFocusOut); + headerNode.addEventListener('keyup', onKeyUp); + return () => { + // @ts-ignore-next line TS doesn't have focusin + headerNode.removeEventListener('focusin', onFocusIn); + headerNode.removeEventListener('focusout', onFocusOut); + headerNode.removeEventListener('keyup', onKeyUp); + }; + } + }, [headerIsInteractive, isFocused, setIsCellEntered, setFocusedCell, index]); + + return ( +
    +
    + +
    +
    + ); +}; diff --git a/src/components/datagrid/data_grid_data_row.tsx b/src/components/datagrid/data_grid_data_row.tsx index 3faef7c6c40..41b9d950a4d 100644 --- a/src/components/datagrid/data_grid_data_row.tsx +++ b/src/components/datagrid/data_grid_data_row.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent, HTMLAttributes, memo } from 'react'; import classnames from 'classnames'; import { + EuiDataGridControlColumn, EuiDataGridColumn, EuiDataGridColumnWidths, EuiDataGridPopoverContent, @@ -15,6 +16,8 @@ import { EuiText } from '../text'; export type EuiDataGridDataRowProps = CommonProps & HTMLAttributes & { rowIndex: number; + leadingControlColumns: EuiDataGridControlColumn[]; + trailingControlColumns: EuiDataGridControlColumn[]; columns: EuiDataGridColumn[]; schema: EuiDataGridSchema; popoverContents: EuiDataGridPopoverContents; @@ -34,6 +37,8 @@ const DefaultColumnFormatter: EuiDataGridPopoverContent = ({ children }) => { const EuiDataGridDataRow: FunctionComponent = memo( props => { const { + leadingControlColumns, + trailingControlColumns, columns, schema, popoverContents, @@ -59,6 +64,27 @@ const EuiDataGridDataRow: FunctionComponent = memo( className={classes} data-test-subj={dataTestSubj} {...rest}> + {leadingControlColumns.map((leadingColumn, i) => { + const { id, rowCellRender } = leadingColumn; + + return ( + + ); + })} {columns.map((props, i) => { const { id } = props; const columnType = schema[id] ? schema[id].columnType : null; @@ -69,25 +95,48 @@ const EuiDataGridDataRow: FunctionComponent = memo( popoverContents[columnType as string] || DefaultColumnFormatter; const width = columnWidths[id] || defaultColumnWidth; + const columnPosition = i + leadingControlColumns.length; return ( ); })} + {trailingControlColumns.map((leadingColumn, i) => { + const { id, rowCellRender } = leadingColumn; + const colIndex = i + columns.length + leadingControlColumns.length; + + return ( + + ); + })}
    ); } diff --git a/src/components/datagrid/data_grid_header_cell.tsx b/src/components/datagrid/data_grid_header_cell.tsx new file mode 100644 index 00000000000..4c1279bc9b0 --- /dev/null +++ b/src/components/datagrid/data_grid_header_cell.tsx @@ -0,0 +1,240 @@ +import React, { + AriaAttributes, + FunctionComponent, + HTMLAttributes, + useEffect, + useRef, + useState, +} from 'react'; +import { htmlIdGenerator } from '../../services/accessibility'; +import classnames from 'classnames'; +import { EuiDataGridHeaderRowPropsSpecificProps } from './data_grid_header_row'; +import { keyCodes } from '../../services'; +import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; +import { EuiScreenReaderOnly } from '../accessibility'; +import tabbable from 'tabbable'; +import { EuiDataGridColumn } from './data_grid_types'; + +export interface EuiDataGridHeaderCellProps + extends Omit< + EuiDataGridHeaderRowPropsSpecificProps, + 'columns' | 'leadingControlColumns' + > { + column: EuiDataGridColumn; + index: number; + className?: string; +} + +export const EuiDataGridHeaderCell: FunctionComponent< + EuiDataGridHeaderCellProps +> = props => { + const { + column, + index, + columnWidths, + schema, + defaultColumnWidth, + setColumnWidth, + sorting, + focusedCell, + setFocusedCell, + headerIsInteractive, + className, + } = props; + const { id, display } = column; + + const width = columnWidths[id] || defaultColumnWidth; + + const ariaProps: { + 'aria-sort'?: AriaAttributes['aria-sort']; + 'aria-describedby'?: AriaAttributes['aria-describedby']; + } = {}; + + let screenReaderId; + let sortString; + + if (sorting) { + const sortedColumnIds = new Set(sorting.columns.map(({ id }) => id)); + + if (sorting.columns.length === 1 && sortedColumnIds.has(id)) { + const sortDirection = sorting.columns[0].direction; + + let sortValue: HTMLAttributes['aria-sort'] = 'other'; + if (sortDirection === 'asc') { + sortValue = 'ascending'; + } else if (sortDirection === 'desc') { + sortValue = 'descending'; + } + + ariaProps['aria-sort'] = sortValue; + } else if (sorting.columns.length >= 2 && sortedColumnIds.has(id)) { + sortString = sorting.columns + .map(col => `Sorted by ${col.id} ${col.direction}`) + .join(' then '); + screenReaderId = htmlIdGenerator()(); + ariaProps['aria-describedby'] = screenReaderId; + } + } + + const columnType = schema[id] ? schema[id].columnType : null; + + const classes = classnames( + 'euiDataGridHeaderCell', + { + [`euiDataGridHeaderCell--${columnType}`]: columnType, + }, + className + ); + + const headerRef = useRef(null); + const isFocused = focusedCell[0] === index && focusedCell[1] === -1; + const [isCellEntered, setIsCellEntered] = useState(false); + + useEffect(() => { + if (headerRef.current) { + function enableInteractives() { + const interactiveElements = headerRef.current!.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + for (let i = 0; i < interactiveElements.length; i++) { + interactiveElements[i].setAttribute('tabIndex', '0'); + } + } + + function disableInteractives() { + const tababbles = tabbable(headerRef.current!); + if (tababbles.length > 1) { + console.warn( + `EuiDataGridHeaderCell expects at most 1 tabbable element, ${ + tababbles.length + } found instead` + ); + } + for (let i = 0; i < tababbles.length; i++) { + const element = tababbles[i]; + element.setAttribute('data-euigrid-tab-managed', 'true'); + element.setAttribute('tabIndex', '-1'); + } + } + + if (isCellEntered) { + enableInteractives(); + const tabbables = tabbable(headerRef.current!); + if (tabbables.length > 0) { + tabbables[0].focus(); + } + } else { + disableInteractives(); + } + } + }, [isCellEntered]); + + useEffect(() => { + if (headerRef.current) { + if (isFocused) { + const interactives = headerRef.current.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + if (interactives.length === 1) { + setIsCellEntered(true); + } else { + headerRef.current.focus(); + } + } else { + setIsCellEntered(false); + } + + // focusin bubbles while focus does not, and this needs to react to children gaining focus + function onFocusIn(e: FocusEvent) { + if (headerIsInteractive === false) { + // header is not interactive, avoid focusing + requestAnimationFrame(() => headerRef.current!.blur()); + e.preventDefault(); + return false; + } else { + // take the focus + setFocusedCell([index, -1]); + } + } + + // focusout bubbles while blur does not, and this needs to react to the children losing focus + function onFocusOut() { + // wait for the next element to receive focus, then update interactives' state + requestAnimationFrame(() => { + if (headerRef.current) { + if (headerRef.current.contains(document.activeElement) === false) { + setIsCellEntered(false); + } + } + }); + } + + function onKeyUp(e: KeyboardEvent) { + switch (e.keyCode) { + case keyCodes.ENTER: { + e.preventDefault(); + setIsCellEntered(true); + break; + } + case keyCodes.ESCAPE: { + e.preventDefault(); + // move focus to cell + setIsCellEntered(false); + headerRef.current!.focus(); + break; + } + case keyCodes.F2: { + e.preventDefault(); + if (document.activeElement === headerRef.current) { + // move focus into cell's interactives + setIsCellEntered(true); + } else { + // move focus to cell + setIsCellEntered(false); + headerRef.current!.focus(); + } + break; + } + } + } + + const headerNode = headerRef.current; + // @ts-ignore-next line TS doesn't have focusin + headerNode.addEventListener('focusin', onFocusIn); + headerNode.addEventListener('focusout', onFocusOut); + headerNode.addEventListener('keyup', onKeyUp); + return () => { + // @ts-ignore-next line TS doesn't have focusin + headerNode.removeEventListener('focusin', onFocusIn); + headerNode.removeEventListener('focusout', onFocusOut); + headerNode.removeEventListener('keyup', onKeyUp); + }; + } + }, [headerIsInteractive, isFocused, setIsCellEntered, setFocusedCell, index]); + + return ( +
    + {column.isResizable !== false && width != null ? ( + + ) : null} + +
    {display || id}
    + {sorting && sorting.columns.length >= 2 && ( + +
    {sortString}
    +
    + )} +
    + ); +}; diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index ad2cc51ac1f..81786a5b459 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -1,28 +1,21 @@ -import React, { - HTMLAttributes, - forwardRef, - FunctionComponent, - useRef, - useEffect, - useState, -} from 'react'; +import React, { HTMLAttributes, forwardRef } from 'react'; import classnames from 'classnames'; -import tabbable from 'tabbable'; import { EuiDataGridColumnWidths, EuiDataGridColumn, EuiDataGridSorting, EuiDataGridFocusedCell, + EuiDataGridControlColumn, } from './data_grid_types'; import { CommonProps } from '../common'; -import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; -import { htmlIdGenerator } from '../../services/accessibility'; -import { EuiScreenReaderOnly } from '../accessibility'; import { EuiDataGridSchema } from './data_grid_schema'; import { EuiDataGridDataRowProps } from './data_grid_data_row'; -import { keyCodes } from '../../services'; +import { EuiDataGridHeaderCell } from './data_grid_header_cell'; +import { EuiDataGridControlHeaderCell } from './data_grid_control_header_cell'; -interface EuiDataGridHeaderRowPropsSpecificProps { +export interface EuiDataGridHeaderRowPropsSpecificProps { + leadingControlColumns?: EuiDataGridControlColumn[]; + trailingControlColumns?: EuiDataGridControlColumn[]; columns: EuiDataGridColumn[]; columnWidths: EuiDataGridColumnWidths; schema: EuiDataGridSchema; @@ -38,226 +31,13 @@ export type EuiDataGridHeaderRowProps = CommonProps & HTMLAttributes & EuiDataGridHeaderRowPropsSpecificProps; -export interface EuiDataGridHeaderCellProps - extends Omit { - column: EuiDataGridColumn; - index: number; -} -const EuiDataGridHeaderCell: FunctionComponent< - EuiDataGridHeaderCellProps -> = props => { - const { - column, - index, - columnWidths, - schema, - defaultColumnWidth, - setColumnWidth, - sorting, - focusedCell, - setFocusedCell, - headerIsInteractive, - } = props; - const { id, display } = column; - - const width = columnWidths[id] || defaultColumnWidth; - - const ariaProps: { - 'aria-sort'?: HTMLAttributes['aria-sort']; - 'aria-describedby'?: HTMLAttributes['aria-describedby']; - } = {}; - - let screenReaderId; - let sortString; - - if (sorting) { - const sortedColumnIds = new Set(sorting.columns.map(({ id }) => id)); - - if (sorting.columns.length === 1 && sortedColumnIds.has(id)) { - const sortDirection = sorting.columns[0].direction; - - let sortValue: HTMLAttributes['aria-sort'] = 'other'; - if (sortDirection === 'asc') { - sortValue = 'ascending'; - } else if (sortDirection === 'desc') { - sortValue = 'descending'; - } - - ariaProps['aria-sort'] = sortValue; - } else if (sorting.columns.length >= 2 && sortedColumnIds.has(id)) { - sortString = sorting.columns - .map(col => `Sorted by ${col.id} ${col.direction}`) - .join(' then '); - screenReaderId = htmlIdGenerator()(); - ariaProps['aria-describedby'] = screenReaderId; - } - } - - const columnType = schema[id] ? schema[id].columnType : null; - - const classes = classnames('euiDataGridHeaderCell', { - [`euiDataGridHeaderCell--${columnType}`]: columnType, - }); - - const headerRef = useRef(null); - const isFocused = focusedCell[0] === index && focusedCell[1] === -1; - const [isCellEntered, setIsCellEntered] = useState(false); - - useEffect(() => { - if (headerRef.current) { - function enableInteractives() { - const interactiveElements = headerRef.current!.querySelectorAll( - '[data-euigrid-tab-managed]' - ); - for (let i = 0; i < interactiveElements.length; i++) { - interactiveElements[i].setAttribute('tabIndex', '0'); - } - } - - function disableInteractives() { - const tababbles = tabbable(headerRef.current!); - if (tababbles.length > 1) { - console.warn( - `EuiDataGridHeaderCell expects at most 1 tabbable element, ${ - tababbles.length - } found instead` - ); - } - for (let i = 0; i < tababbles.length; i++) { - const element = tababbles[i]; - element.setAttribute('data-euigrid-tab-managed', 'true'); - element.setAttribute('tabIndex', '-1'); - } - } - - if (isCellEntered) { - enableInteractives(); - const tabbables = tabbable(headerRef.current!); - if (tabbables.length > 0) { - tabbables[0].focus(); - } - } else { - disableInteractives(); - } - } - }, [isCellEntered]); - - useEffect(() => { - if (headerRef.current) { - if (isFocused) { - const interactives = headerRef.current.querySelectorAll( - '[data-euigrid-tab-managed]' - ); - if (interactives.length === 1) { - setIsCellEntered(true); - } else { - headerRef.current.focus(); - } - } else { - setIsCellEntered(false); - } - - // focusin bubbles while focus does not, and this needs to react to children gaining focus - function onFocusIn(e: FocusEvent) { - if (headerIsInteractive === false) { - // header is not interactive, avoid focusing - requestAnimationFrame(() => headerRef.current!.blur()); - e.preventDefault(); - return false; - } else { - // take the focus - setFocusedCell([index, -1]); - } - } - - // focusout bubbles while blur does not, and this needs to react to the children losing focus - function onFocusOut() { - // wait for the next element to receive focus, then update interactives' state - requestAnimationFrame(() => { - if (headerRef.current) { - if (headerRef.current.contains(document.activeElement) === false) { - setIsCellEntered(false); - } - } - }); - } - - function onKeyUp(e: KeyboardEvent) { - switch (e.keyCode) { - case keyCodes.ENTER: { - e.preventDefault(); - setIsCellEntered(true); - break; - } - case keyCodes.ESCAPE: { - e.preventDefault(); - // move focus to cell - setIsCellEntered(false); - headerRef.current!.focus(); - break; - } - case keyCodes.F2: { - e.preventDefault(); - if (document.activeElement === headerRef.current) { - // move focus into cell's interactives - setIsCellEntered(true); - } else { - // move focus to cell - setIsCellEntered(false); - headerRef.current!.focus(); - } - break; - } - } - } - - const headerNode = headerRef.current; - // @ts-ignore-next line TS doesn't have focusin - headerNode.addEventListener('focusin', onFocusIn); - headerNode.addEventListener('focusout', onFocusOut); - headerNode.addEventListener('keyup', onKeyUp); - return () => { - // @ts-ignore-next line TS doesn't have focusin - headerNode.removeEventListener('focusin', onFocusIn); - headerNode.removeEventListener('focusout', onFocusOut); - headerNode.removeEventListener('keyup', onKeyUp); - }; - } - }, [headerIsInteractive, isFocused, setIsCellEntered, setFocusedCell, index]); - - return ( -
    - {column.isResizable !== false && width != null ? ( - - ) : null} - -
    {display || id}
    - {sorting && sorting.columns.length >= 2 && ( - -
    {sortString}
    -
    - )} -
    - ); -}; - const EuiDataGridHeaderRow = forwardRef< HTMLDivElement, EuiDataGridHeaderRowProps >((props, ref) => { const { + leadingControlColumns = [], + trailingControlColumns = [], columns, schema, columnWidths, @@ -282,11 +62,22 @@ const EuiDataGridHeaderRow = forwardRef< className={classes} data-test-subj={dataTestSubj} {...rest}> + {leadingControlColumns.map((controlColumn, index) => ( + + ))} {columns.map((column, index) => ( ))} + {trailingControlColumns.map((controlColumn, index) => ( + + ))}
    ); }); diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index 4f9fd6e0189..7a97f44e852 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -1,4 +1,24 @@ -import { FunctionComponent, ReactNode } from 'react'; +import { ComponentType, ReactNode } from 'react'; +import { EuiDataGridCellProps } from './data_grid_cell'; + +export interface EuiDataGridControlColumn { + /** + * Used as the React `key` when rendering content + */ + id: string; + /** + * Component to render in the column header + */ + headerCellRender: ComponentType; + /** + * Component to render for each row in the column + */ + rowCellRender: EuiDataGridCellProps['renderCellValue']; + /** + * Width of the column, uses are unable to change this + */ + width: number; +} export interface EuiDataGridColumn { /** @@ -170,7 +190,7 @@ export interface EuiDataGridPopoverContentProps { */ cellContentsElement: HTMLDivElement; } -export type EuiDataGridPopoverContent = FunctionComponent< +export type EuiDataGridPopoverContent = ComponentType< EuiDataGridPopoverContentProps >; export interface EuiDataGridPopoverContents { diff --git a/src/components/datagrid/index.ts b/src/components/datagrid/index.ts index 338564da16f..662c2c4120b 100644 --- a/src/components/datagrid/index.ts +++ b/src/components/datagrid/index.ts @@ -10,10 +10,11 @@ export { } from './data_grid_cell'; export { EuiDataGridColumnResizerProps } from './data_grid_column_resizer'; export { EuiDataGridDataRowProps } from './data_grid_data_row'; +export { EuiDataGridHeaderRowProps } from './data_grid_header_row'; +export { EuiDataGridHeaderCellProps } from './data_grid_header_cell'; export { - EuiDataGridHeaderCellProps, - EuiDataGridHeaderRowProps, -} from './data_grid_header_row'; + EuiDataGridControlHeaderRowProps, +} from './data_grid_control_header_cell'; export { EuiDataGridInMemoryRendererProps, } from './data_grid_inmemory_renderer';