diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f422dc259..72b427dcde3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ `$euiFocusRingColor` instead of `currentColor` ([#5479](https://github.com/elastic/eui/pull/5479)) - **[Beta]** Added `optimize` build as a lighter weight option more suited to prodcution environments ([#5527](https://github.com/elastic/eui/pull/5527)) +**Bug fixes** + +- Updated the outline color in `euiCustomControlFocused` mixin to use `$euiFocusRingColor` instead of `currentColor` ([#5479](https://github.com/elastic/eui/pull/5479)) +- Fixed keyboard navigation in `EuiDataGrid` not fully scrolling cells into view ([#5515](https://github.com/elastic/eui/pull/5515)) + +**Deprecations** + +- Deprecated `data-gridcell-id` from `EuiDataGrid` in favor of 4 new and more flexible props - `data-gridcell-column-id`, `data-gridcell-column-index`, `data-gridcell-row-index`, and `data-gridcell-visible-row-index` ([#5515](https://github.com/elastic/eui/pull/5515)) + ## [`45.0.0`](https://github.com/elastic/eui/tree/v45.0.0) - Added virtulized rendering option to `EuiSelectableList` with `isVirtualized` ([#5521](https://github.com/elastic/eui/pull/5521)) diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index c4952c77221..2e397ecaa01 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -1049,6 +1049,10 @@ Array [ >
= ( visibleRowCount, ]); + /** + * Handle scrolling cells fully into view + */ + useScroll({ + gridRef, + outerGridRef, + innerGridRef, + headerRowHeight, + footerRowHeight, + visibleRowCount, + hasStickyFooter: !!(renderFooterCellValue && gridStyles.stickyFooter), + }); + /** * Row manager */ diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/data_grid_cell.tsx index 2f200cf1684..390c1d37c17 100644 --- a/src/components/datagrid/body/data_grid_cell.tsx +++ b/src/components/datagrid/body/data_grid_cell.tsx @@ -616,7 +616,12 @@ export class EuiDataGridCell extends Component< ref={this.cellRef} {...cellProps} data-test-subj="dataGridRowCell" - data-gridcell-id={`${this.props.rowIndex},${this.props.colIndex}`} + // Data attributes to help target specific cells by either data or current cell location + data-gridcell-column-id={this.props.columnId} // Static column ID name, not affected by column order + data-gridcell-column-index={this.props.colIndex} // Affected by column reordering + data-gridcell-row-index={this.props.rowIndex} // Index from data, not affected by sorting or pagination + data-gridcell-visible-row-index={this.props.visibleRowIndex} // Affected by sorting & pagination + data-gridcell-id={`${this.props.colIndex},${this.props.rowIndex}`} // TODO: Deprecate in favor of the above 4 data attrs onKeyDown={handleCellKeyDown} onFocus={this.onFocus} onMouseEnter={() => { diff --git a/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx b/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx index 908593766de..be2d9e31e33 100644 --- a/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx +++ b/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx @@ -49,6 +49,10 @@ describe('EuiDataGridHeaderCellWrapper', () => { >
{
diff --git a/src/components/datagrid/controls/display_selector.test.tsx b/src/components/datagrid/controls/display_selector.test.tsx index 0990a39bd9e..e05cc27ac8f 100644 --- a/src/components/datagrid/controls/display_selector.test.tsx +++ b/src/components/datagrid/controls/display_selector.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow, mount, ShallowWrapper, ReactWrapper } from 'enzyme'; +import { testCustomHook } from '../../../test'; import { EuiDataGridToolBarVisibilityOptions, @@ -419,27 +420,21 @@ describe('useDataGridDisplaySelector', () => { describe('gridStyles', () => { it('returns an object of grid styles with user overrides', () => { const initialStyles = { ...startingStyles, stripes: true }; - const MockComponent = () => { - const [, { onChange, ...gridStyles }] = useDataGridDisplaySelector( - true, - initialStyles, - {} - ); - return ; - }; - const component = shallow(); + const [, gridStyles] = testCustomHook(() => + useDataGridDisplaySelector(true, initialStyles, {}) + ); - expect(component).toMatchInlineSnapshot(` -
+ expect(gridStyles).toMatchInlineSnapshot(` + Object { + "border": "all", + "cellPadding": "m", + "fontSize": "m", + "footer": "overline", + "header": "shade", + "rowHover": "highlight", + "stickyFooter": true, + "stripes": true, + } `); }); }); diff --git a/src/components/datagrid/data_grid.spec.tsx b/src/components/datagrid/data_grid.spec.tsx index 729b5801001..a1b32a3e435 100644 --- a/src/components/datagrid/data_grid.spec.tsx +++ b/src/components/datagrid/data_grid.spec.tsx @@ -236,8 +236,12 @@ describe('EuiDataGrid', () => { // starts with body in focus cy.focused().should('not.exist'); - cy.get('[data-gridcell-id="1,1"]').click(); - cy.focused().should('have.attr', 'data-gridcell-id', '1,1'); + cy.get( + '[data-gridcell-column-index="1"][data-gridcell-visible-row-index="1"]' + ).click(); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '1') + .should('have.attr', 'data-gridcell-row-index', '1'); }); describe('cell keyboard interactions', () => { @@ -269,7 +273,9 @@ describe('EuiDataGrid', () => { // tab into the grid, should focus first cell after a short delay cy.focused().tab(); - cy.focused().should('have.attr', 'data-gridcell-id', '0,0'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '0') + .should('have.attr', 'data-gridcell-row-index', '0'); cy.focused().tab().should('have.id', 'final-tabbable'); }); @@ -282,15 +288,21 @@ describe('EuiDataGrid', () => { cy.get('[data-test-subj=euiDataGridBody]').focus(); // first cell is non-interactive and non-expandable = focus cell - cy.focused().should('have.attr', 'data-gridcell-id', '0,0'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '0') + .should('have.attr', 'data-gridcell-row-index', '0'); // already left-most, should be no-op cy.focused().type('{leftarrow}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,0'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '0') + .should('have.attr', 'data-gridcell-row-index', '0'); // arrow right, expandable cell with no interactive = focus cell cy.focused().type('{rightarrow}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,1'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '1') + .should('have.attr', 'data-gridcell-row-index', '0'); // arrow right, non-expandable cell with one interactive = focus interactive cy.focused().type('{rightarrow}'); @@ -298,11 +310,15 @@ describe('EuiDataGrid', () => { // arrow right, non-expandable cell with two interactives = focus cell cy.focused().type('{rightarrow}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,3'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '3') + .should('have.attr', 'data-gridcell-row-index', '0'); // arrow right, expandable cell with two interactives = focus cell cy.focused().type('{rightarrow}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,4'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '4') + .should('have.attr', 'data-gridcell-row-index', '0'); }); it('cell expansion/interaction', () => { @@ -314,10 +330,14 @@ describe('EuiDataGrid', () => { // first cell is non-interactive and non-expandable, enter should have no effect cy.focused().type('{enter}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,0'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '0') + .should('have.attr', 'data-gridcell-row-index', '0'); // second cell is expandable - cy.get('[data-gridcell-id="0,1"]').click(); + cy.get( + '[data-gridcell-column-index="1"][data-gridcell-row-index="0"]' + ).click(); cy.focused().type('{enter}'); cy.focused().should( 'have.attr', @@ -325,23 +345,35 @@ describe('EuiDataGrid', () => { 'euiDataGridExpansionPopover' ); cy.focused().type('{esc}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,1'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '1') + .should('have.attr', 'data-gridcell-row-index', '0'); // third cell is non-expandable & interactive, click should focus on the link - cy.get('[data-gridcell-id="0,2"]').click(); + cy.get( + '[data-gridcell-column-index="2"][data-gridcell-row-index="0"]' + ).click(); cy.focused().type('{enter}'); cy.focused().should('have.attr', 'data-test-subj', 'focusOnMe'); // fourth cell is non-expandable with multiple interactives, click should focus on the cell - cy.get('[data-gridcell-id="0,3"]').click(); + cy.get( + '[data-gridcell-column-index="3"][data-gridcell-row-index="0"]' + ).click(); cy.focused().type('{enter}'); cy.focused().should('have.attr', 'data-test-subj', 'focusOnMe'); // focus trap focuses the link cy.focused().type('{esc}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,3'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '3') + .should('have.attr', 'data-gridcell-row-index', '0'); // fifth cell is non-expandable & no-actions with multiple interactives, click should focus cell - cy.get('[data-gridcell-id="0,4"]').click('topLeft'); // top left to avoid clicking a button - cy.focused().should('have.attr', 'data-gridcell-id', '0,4'); + cy.get( + '[data-gridcell-column-index="4"][data-gridcell-row-index="0"]' + ).click('topLeft'); // top left to avoid clicking a button + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '4') + .should('have.attr', 'data-gridcell-row-index', '0'); // enable interactives & focus trap cy.focused().type('{enter}'); cy.focused().should('have.attr', 'data-test-subj', 'btn-yes'); @@ -350,11 +382,17 @@ describe('EuiDataGrid', () => { cy.focused().tab(); cy.focused().should('have.attr', 'data-test-subj', 'btn-yes'); cy.focused().type('{esc}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,4'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '4') + .should('have.attr', 'data-gridcell-row-index', '0'); // sixth cell is expandable cell with two interactives, click should focus on the cell - cy.get('[data-gridcell-id="0,5"]').click('topLeft', { force: true }); // top left to avoid clicking a button - cy.focused().should('have.attr', 'data-gridcell-id', '0,5'); + cy.get( + '[data-gridcell-column-index="5"][data-gridcell-row-index="0"]' + ).click('topLeft', { force: true }); // top left to avoid clicking a button + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '5') + .should('have.attr', 'data-gridcell-row-index', '0'); cy.focused().type('{enter}'); // trigger expansion popover cy.focused().should('have.attr', 'data-test-subj', 'btn-yes'); // focus trap should move focus to the first button cy.focused().parentsUntil( @@ -369,7 +407,9 @@ describe('EuiDataGrid', () => { 'euiDataGridExpansionPopover' ); cy.focused().type('{esc}'); - cy.focused().should('have.attr', 'data-gridcell-id', '0,5'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '5') + .should('have.attr', 'data-gridcell-row-index', '0'); }); }); }); @@ -377,7 +417,7 @@ describe('EuiDataGrid', () => { function getGridData() { // wait for the virtualized cells to render - cy.get('[data-gridcell-id="1,0"]'); + cy.get('[data-gridcell-column-index="0"][data-gridcell-row-index="1"]'); const rows = cy.get('[role=row]'); return rows.then((rows) => { const headers: string[] = []; diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index 4b2698d7b70..6703edbb19a 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -529,7 +529,11 @@ describe('EuiDataGrid', () => { Array [ Object { "className": "euiDataGridRowCell euiDataGridRowCell--firstColumn customClass", + "data-gridcell-column-id": "A", + "data-gridcell-column-index": 0, "data-gridcell-id": "0,0", + "data-gridcell-row-index": 0, + "data-gridcell-visible-row-index": 0, "data-test-subj": "dataGridRowCell", "onBlur": [Function], "onFocus": [Function], @@ -550,7 +554,11 @@ describe('EuiDataGrid', () => { }, Object { "className": "euiDataGridRowCell euiDataGridRowCell--lastColumn customClass", - "data-gridcell-id": "0,1", + "data-gridcell-column-id": "B", + "data-gridcell-column-index": 1, + "data-gridcell-id": "1,0", + "data-gridcell-row-index": 0, + "data-gridcell-visible-row-index": 0, "data-test-subj": "dataGridRowCell", "onBlur": [Function], "onFocus": [Function], @@ -571,7 +579,11 @@ describe('EuiDataGrid', () => { }, Object { "className": "euiDataGridRowCell euiDataGridRowCell--stripe euiDataGridRowCell--firstColumn customClass", - "data-gridcell-id": "1,0", + "data-gridcell-column-id": "A", + "data-gridcell-column-index": 0, + "data-gridcell-id": "0,1", + "data-gridcell-row-index": 1, + "data-gridcell-visible-row-index": 1, "data-test-subj": "dataGridRowCell", "onBlur": [Function], "onFocus": [Function], @@ -592,7 +604,11 @@ describe('EuiDataGrid', () => { }, Object { "className": "euiDataGridRowCell euiDataGridRowCell--stripe euiDataGridRowCell--lastColumn customClass", + "data-gridcell-column-id": "B", + "data-gridcell-column-index": 1, "data-gridcell-id": "1,1", + "data-gridcell-row-index": 1, + "data-gridcell-visible-row-index": 1, "data-test-subj": "dataGridRowCell", "onBlur": [Function], "onFocus": [Function], diff --git a/src/components/datagrid/utils/focus.ts b/src/components/datagrid/utils/focus.ts index f42af4ebf9d..910e067a70a 100644 --- a/src/components/datagrid/utils/focus.ts +++ b/src/components/datagrid/utils/focus.ts @@ -69,7 +69,7 @@ export const useFocus = ({ const setFocusedCell = useCallback((focusedCell: EuiDataGridFocusedCell) => { _setFocusedCell(focusedCell); - setIsFocusedCellInView(true); // The cell must be in view to focus it + setIsFocusedCellInView(true); // scrolling.ts ensures focused cells are fully in view }, []); const previousCell = useRef(undefined); diff --git a/src/components/datagrid/utils/scrolling.spec.tsx b/src/components/datagrid/utils/scrolling.spec.tsx new file mode 100644 index 00000000000..7b5e9b34f32 --- /dev/null +++ b/src/components/datagrid/utils/scrolling.spec.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiDataGrid, EuiDataGridProps } from '../'; + +const baseProps: EuiDataGridProps = { + 'aria-label': 'useScroll test', + height: 300, + width: 300, + columns: [ + { id: 'A' }, + { id: 'B' }, + { id: 'C' }, + { id: 'D' }, + { id: 'E' }, + { id: 'F' }, + ], + rowCount: 15, + renderCellValue: ({ rowIndex, columnId }) => `${columnId}, ${rowIndex}`, + renderFooterCellValue: ({ columnId }) => `${columnId}, footer`, + columnVisibility: { + visibleColumns: ['A', 'B', 'C', 'D', 'E', 'F'], + setVisibleColumns: () => {}, + }, +}; + +describe('useScroll', () => { + describe('on cell focus', () => { + it('fully scrolls cells into view (accounting for sticky headers, rows, and scrollbars)', () => { + cy.realMount(); + cy.repeatRealPress('Tab', 3); + cy.realPress('ArrowDown'); + + cy.realPress('End'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '5') + .should('have.attr', 'data-gridcell-visible-row-index', '0'); + + cy.realPress(['Control', 'End']); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '5') + .should('have.attr', 'data-gridcell-visible-row-index', '14'); + + cy.realPress('Home'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '0') + .should('have.attr', 'data-gridcell-visible-row-index', '14'); + + cy.realPress(['Control', 'Home']); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '0') + .should('have.attr', 'data-gridcell-visible-row-index', '0'); + }); + }); +}); diff --git a/src/components/datagrid/utils/scrolling.test.ts b/src/components/datagrid/utils/scrolling.test.ts new file mode 100644 index 00000000000..bcfd80b2cd7 --- /dev/null +++ b/src/components/datagrid/utils/scrolling.test.ts @@ -0,0 +1,341 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { testCustomHook } from '../../../test'; +import { useScrollCellIntoView } from './scrolling'; + +// see scrolling.spec.tsx for E2E useScroll tests + +describe('useScrollCellIntoView', () => { + const scrollToItem = jest.fn(); + const scrollTo = jest.fn(); + + const mockCell = { + offsetTop: 30, + offsetLeft: 0, + offsetWidth: 100, + offsetHeight: 20, + } as any; + const getCell = jest.fn(() => mockCell); + + const args = { + gridRef: { + current: { scrollTo, scrollToItem }, + } as any, + outerGridRef: { + current: { + offsetHeight: 400, + clientHeight: 380, // accounts for scrollbars + offsetWidth: 500, + clientWidth: 480, // accounts for scrollbars + scrollTop: 0, + scrollLeft: 0, + querySelector: getCell, + } as any, + }, + innerGridRef: { + current: { + offsetHeight: 800, + offsetWidth: 1000, + } as any, + }, + headerRowHeight: 0, + footerRowHeight: 0, + visibleRowCount: 100, + hasStickyFooter: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + getCell.mockReturnValue(mockCell); + }); + + it('does nothing if the grid references are unavailable', () => { + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + gridRef: { current: null }, + outerGridRef: { current: null }, + innerGridRef: { current: null }, + }) + ); + scrollCellIntoView({ rowIndex: 0, colIndex: 0 }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('does nothing if the grid does not scroll (inner and outer grid dimensions are the same)', () => { + const outerGrid = { + offsetHeight: 500, + offsetWidth: 500, + }; + const innerGrid = { + offsetHeight: 500, + offsetWidth: 500, + }; + + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { + current: { ...args.outerGridRef.current, ...outerGrid }, + }, + innerGridRef: { + current: { ...args.innerGridRef.current, ...innerGrid }, + }, + }) + ); + scrollCellIntoView({ rowIndex: 0, colIndex: 0 }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('calls scrollToItem if the specified cell is not virtualized', async () => { + getCell.mockReturnValue(null); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView(args) + ); + await scrollCellIntoView({ rowIndex: 20, colIndex: 5 }); + expect(scrollToItem).toHaveBeenCalledWith({ columnIndex: 5, rowIndex: 20 }); + }); + + it("does nothing if the current cell is in view and not outside the grid's scroll bounds", () => { + getCell.mockReturnValue({ + ...mockCell, + offsetTop: 50, + offsetLeft: 50, + }); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView(args) + ); + scrollCellIntoView({ rowIndex: 1, colIndex: 1 }); + + expect(scrollToItem).not.toHaveBeenCalled(); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + describe('right scroll adjustments', () => { + it('scrolls the grid right if the right side of the cell is out of view', () => { + const cell = { + ...mockCell, + offsetLeft: 400, + offsetWidth: 100, + }; + const grid = { + scrollLeft: 0, + clientWidth: 450, + }; + + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + }) + ); + scrollCellIntoView({ rowIndex: 1, colIndex: 5 }); + + expect(scrollTo).toHaveBeenCalledWith({ scrollLeft: 50, scrollTop: 0 }); + }); + }); + + describe('left scroll adjustments', () => { + it('scrolls the grid left if the left side of the cell is out of view', () => { + const cell = { + ...mockCell, + offsetLeft: 0, + offsetWidth: 100, + }; + const grid = { + scrollLeft: 50, + }; + + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + }) + ); + scrollCellIntoView({ rowIndex: 1, colIndex: 0 }); + + expect(scrollTo).toHaveBeenCalledWith({ scrollLeft: 0, scrollTop: 0 }); + }); + + it('scrolls to the left side over the right if the cell width is larger than the grid width', () => { + const cell = { + ...mockCell, + offsetLeft: 50, + offsetWidth: 300, + }; + const grid = { + scrollLeft: 100, + clientWidth: 250, + }; + + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + }) + ); + scrollCellIntoView({ rowIndex: 1, colIndex: 1 }); + + expect(scrollTo).toHaveBeenCalledWith({ scrollLeft: 50, scrollTop: 0 }); + }); + }); + + describe('bottom scroll adjustments', () => { + const cell = { + ...mockCell, + offsetTop: 400, + offsetHeight: 100, + }; + const grid = { + scrollTop: 0, + clientHeight: 450, + }; + + it('scrolls the grid down if the bottom side of the cell is out of view', () => { + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + }) + ); + scrollCellIntoView({ rowIndex: 5, colIndex: 0 }); + expect(scrollTo).toHaveBeenCalledWith({ scrollTop: 50, scrollLeft: 0 }); + }); + + it('accounts for the sticky bottom footer if present', () => { + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + hasStickyFooter: true, + footerRowHeight: 25, + }) + ); + scrollCellIntoView({ rowIndex: 5, colIndex: 0 }); + expect(scrollTo).toHaveBeenCalledWith({ scrollTop: 75, scrollLeft: 0 }); + }); + + it('makes no vertical adjustments if the cell is a sticky header cell', () => { + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + }) + ); + scrollCellIntoView({ rowIndex: -1, colIndex: 0 }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('makes no vertical adjustments if the cell is a sticky footer cell', () => { + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + visibleRowCount: 25, + hasStickyFooter: true, + }) + ); + scrollCellIntoView({ rowIndex: 25, colIndex: 0 }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + }); + + describe('top scroll adjustments', () => { + const cell = { + ...mockCell, + offsetTop: 50, + offsetHeight: 25, + }; + const grid = { + scrollTop: 60, + }; + + it('scrolls the grid up if the top side of the cell is out of view', () => { + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + }) + ); + scrollCellIntoView({ rowIndex: 1, colIndex: 0 }); + expect(scrollTo).toHaveBeenCalledWith({ scrollTop: 50, scrollLeft: 0 }); + }); + + it('accounts for the sticky header', () => { + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + headerRowHeight: 30, + }) + ); + scrollCellIntoView({ rowIndex: 1, colIndex: 0 }); + expect(scrollTo).toHaveBeenCalledWith({ scrollTop: 20, scrollLeft: 0 }); + }); + + it('scrolls to the top side over the bottom if the cell height is larger than the grid height', () => { + const cell = { + ...mockCell, + offsetTop: 100, + offsetHeight: 600, + }; + const grid = { + scrollTop: 200, + clientHeight: 300, + }; + + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + headerRowHeight: 50, + }) + ); + scrollCellIntoView({ rowIndex: 1, colIndex: 0 }); + + expect(scrollTo).toHaveBeenCalledWith({ scrollTop: 50, scrollLeft: 0 }); + }); + + it('makes no vertical adjustments if the cell is a sticky header cell', () => { + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + }) + ); + scrollCellIntoView({ rowIndex: -1, colIndex: 0 }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('makes no vertical adjustments if the cell is a sticky footer cell', () => { + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = testCustomHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + visibleRowCount: 25, + hasStickyFooter: true, + }) + ); + scrollCellIntoView({ rowIndex: 25, colIndex: 0 }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/datagrid/utils/scrolling.ts b/src/components/datagrid/utils/scrolling.ts new file mode 100644 index 00000000000..5a91c385f4f --- /dev/null +++ b/src/components/datagrid/utils/scrolling.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useContext, useEffect, useCallback, MutableRefObject } from 'react'; +import { VariableSizeGrid as Grid } from 'react-window'; +import { DataGridFocusContext } from './focus'; + +interface ScrollCellIntoView { + rowIndex: number; + colIndex: number; +} +interface Dependencies { + gridRef: MutableRefObject; + outerGridRef: MutableRefObject; + innerGridRef: MutableRefObject; + headerRowHeight: number; + footerRowHeight: number; + visibleRowCount: number; + hasStickyFooter: boolean; +} + +/** + * The primary goal of this scroll logic is to ensure keyboard navigation works accessibly, + * but there are other scenarios where it applies (e.g. clicking partially-visible cells) + * or is useful for (e.g. manually scrolling to cell that is currently out of viewport + * while accounting for headers/footers/scrollbars) + */ +export const useScroll = (args: Dependencies) => { + const { scrollCellIntoView } = useScrollCellIntoView(args); + + const { focusedCell } = useContext(DataGridFocusContext); + useEffect(() => { + if (focusedCell) { + scrollCellIntoView({ + rowIndex: focusedCell[1], + colIndex: focusedCell[0], + }); + } + }, [focusedCell, scrollCellIntoView]); + + return { scrollCellIntoView }; +}; + +/** + * Ensures that the passed cell is always fully in view by using cell position + * checks and scroll adjustments/workarounds. + */ +export const useScrollCellIntoView = ({ + gridRef, + outerGridRef, + innerGridRef, + headerRowHeight, + footerRowHeight, + visibleRowCount, + hasStickyFooter, +}: Dependencies) => { + const scrollCellIntoView = useCallback( + async ({ rowIndex, colIndex }: ScrollCellIntoView) => { + if (!gridRef.current || !outerGridRef.current || !innerGridRef.current) { + return; // Grid isn't rendered yet or is empty + } + + const gridDoesNotScroll = + innerGridRef.current.offsetHeight === + outerGridRef.current.offsetHeight && + innerGridRef.current.offsetWidth === outerGridRef.current.offsetWidth; + if (gridDoesNotScroll) { + return; // If it doesn't scroll, there's nothing to do here + } + + // Obtain the outermost wrapper of the current cell in view in order to + // get scroll position/height/width calculations and determine what level + // of scroll adjustment the cell needs + const getCell = () => + outerGridRef.current!.querySelector( + `[data-gridcell-column-index="${colIndex}"][data-gridcell-visible-row-index="${rowIndex}"]` + ); + let cell = getCell(); + + // If the cell is completely out of view, we need to use react-window's + // scrollToItem API to get it virtualized and rendered. + const cellIsInView = !!getCell(); + if (!cellIsInView) { + gridRef.current.scrollToItem({ rowIndex, columnIndex: colIndex }); + await new Promise(requestAnimationFrame); // The cell does not immediately render - we need to wait an async tick + cell = getCell(); + } + if (!cell) return; // If for some reason we can't find a valid cell, short-circuit + + // We now manually adjust scroll positioning around the cell to ensure it's + // fully in view on all sides. A couple of notes on this: + // 1. We're avoiding relying on react-window's scrollToItem for this because it also + // does not account for sticky items (see https://github.com/bvaughn/react-window/issues/586) + // 2. The current scroll position we're using as a base comes from either by + // `scrollToItem` or native .focus()'s automatic scroll behavior. This gets us + // halfway there, but doesn't guarantee the *full* cell in view, or account for + // sticky positioned rows or OS scrollbars, hence these workarounds + const { scrollTop, scrollLeft } = outerGridRef.current; + let adjustedScrollTop; + let adjustedScrollLeft; + + // Check if the cell's right side is outside the current scrolling bounds + const cellRightPos = cell.offsetLeft + cell.offsetWidth; + const rightScrollBound = scrollLeft + outerGridRef.current.clientWidth; // Note: We specifically want clientWidth and not offsetWidth here to account for scrollbars + const rightWidthOutOfView = cellRightPos - rightScrollBound; + if (rightWidthOutOfView > 0) { + adjustedScrollLeft = scrollLeft + rightWidthOutOfView; + } + + // Check if the cell's left side is outside the current scrolling bounds + const cellLeftPos = cell.offsetLeft; + const leftScrollBound = adjustedScrollLeft ?? scrollLeft; + const leftWidthOutOfView = leftScrollBound - cellLeftPos; + if (leftWidthOutOfView > 0) { + // Note: This overrides the right side being out of bounds, as we want to prefer + // showing the top-left corner of items if a cell is larger than the grid container + adjustedScrollLeft = cellLeftPos; + } + + // Skip top/bottom scroll adjustments for sticky headers & footers + // since they should always be in view vertically + const isStickyHeader = rowIndex === -1; + const isStickyFooter = hasStickyFooter && rowIndex === visibleRowCount; + + if (!isStickyHeader && !isStickyFooter) { + // Check if the cell's bottom side is outside the current scrolling bounds + const cellBottomPos = cell.offsetTop + cell.offsetHeight; + let bottomScrollBound = scrollTop + outerGridRef.current.clientHeight; // Note: We specifically want clientHeight and not offsetHeight here to account for scrollbars + if (hasStickyFooter) bottomScrollBound -= footerRowHeight; // Sticky footer is not always present + const bottomHeightOutOfView = cellBottomPos - bottomScrollBound; + if (bottomHeightOutOfView > 0) { + adjustedScrollTop = scrollTop + bottomHeightOutOfView; + } + + // Check if the cell's top side is outside the current scrolling bounds + const cellTopPos = cell.offsetTop; + const topScrollBound = adjustedScrollTop ?? scrollTop + headerRowHeight; // Sticky header is always present + const topHeightOutOfView = topScrollBound - cellTopPos; + if (topHeightOutOfView > 0) { + // Note: This overrides the bottom side being out of bounds, as we want to prefer + // showing the top-left corner of items if a cell is larger than the grid container + adjustedScrollTop = cellTopPos - headerRowHeight; + } + } + + // Check for undefined specifically (because 0 is a valid scroll position) + // to avoid unnecessarily calling scrollTo or hijacking scroll + if (adjustedScrollTop !== undefined || adjustedScrollLeft !== undefined) { + gridRef.current.scrollTo({ + scrollLeft: adjustedScrollLeft ?? scrollLeft, + scrollTop: adjustedScrollTop ?? scrollTop, + }); + } + }, + [ + gridRef, + outerGridRef, + innerGridRef, + headerRowHeight, + footerRowHeight, + visibleRowCount, + hasStickyFooter, + ] + ); + + return { scrollCellIntoView }; +}; diff --git a/src/test/index.ts b/src/test/index.ts index 499734984a4..b1399651a1d 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -9,6 +9,7 @@ export { requiredProps } from './required_props'; export { takeMountedSnapshot } from './take_mounted_snapshot'; export { findTestSubject } from './find_test_subject'; +export { testCustomHook } from './test_custom_hook'; export { startThrowingReactWarnings, stopThrowingReactWarnings, diff --git a/src/test/test_custom_hook.tsx b/src/test/test_custom_hook.tsx new file mode 100644 index 00000000000..bc51db29da7 --- /dev/null +++ b/src/test/test_custom_hook.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +export const HookWrapper = (props: { hook?: Function }) => { + const hook = props.hook ? props.hook() : undefined; + // @ts-ignore the actual div is irrelevant, we just need to inspect the prop for return values + return
; +}; + +export const testCustomHook = (hook?: Function): T => { + const wrapper = mount(); + const hookValues: T = wrapper.find('div').prop('hook'); + + return hookValues; +};