Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EuiDataGrid] Fix cells losing focus state when scrolled out of view #5488

Merged
merged 8 commits into from
Dec 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Fixed a `EuiDataGrid` sizing bug which didn't account for a horizontal scrollbar ([#5478](https://github.com/elastic/eui/pull/5478))
- Fixed `EuiModalHeaderTitle` to conditionally wrap title strings in an H1 ([#5494](https://github.com/elastic/eui/pull/5494))
- Fixed a `EuiDataGrid` issue where a focused cell would lose focus when scrolled out of and back into view ([#5488](https://github.com/elastic/eui/pull/5488))

**Deprecations**

Expand Down
4 changes: 2 additions & 2 deletions src-docs/src/views/datagrid/datagrid_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,8 @@ export const DataGridExample = {
in memory level
</Link>{' '}
to have the grid automatically handle updating your columns.
Depending upon the level choosen you may need to manage the
content order separate from the grid.
Depending upon the level chosen, you may need to manage the
content order separately from the grid.
</li>
<li>
<Link to="/tabular-content/data-grid-schemas-and-popovers/">
Expand Down
2 changes: 1 addition & 1 deletion src-docs/src/views/datagrid/datagrid_focus_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const DataGridFocusExample = {
color="warning"
title="A caution about turning off cell expansion when the width of the column is unknown"
>
In general, you should turn <EuiCode>isExpandible</EuiCode> to false
In general, you should turn <EuiCode>isExpandable</EuiCode> to false
only when you know the exact width and number of items that a cell
will include. Control columns that contain row actions are a good
example of when to use them. In certain scenarios, allowing multiple
Expand Down
178 changes: 116 additions & 62 deletions src/components/datagrid/body/data_grid_cell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { keys } from '../../../services';
import { mockRowHeightUtils } from '../__mocks__/row_height_utils';
import { DataGridFocusContext } from '../data_grid_context';

import { EuiDataGridCell } from './data_grid_cell';

Expand All @@ -31,31 +32,24 @@ describe('EuiDataGridCell', () => {
rowHeightUtils: mockRowHeightUtils,
};

const mountEuiDataGridCellWithContext = ({ ...props } = {}) => {
const focusContext = {
setFocusedCell: jest.fn(),
onFocusUpdate: jest.fn(),
};
return mount(<EuiDataGridCell {...requiredProps} {...props} />, {
context: focusContext,
});
};

beforeEach(() => jest.clearAllMocks());

it('renders', () => {
const component = mountEuiDataGridCellWithContext();
const component = mount(<EuiDataGridCell {...requiredProps} />);
chandlerprall marked this conversation as resolved.
Show resolved Hide resolved
expect(component).toMatchSnapshot();
});

it('renders cell buttons', () => {
const component = mountEuiDataGridCellWithContext({
isExpandable: false,
column: {
id: 'someColumn',
cellActions: [() => <button />],
},
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
isExpandable={false}
column={{
id: 'someColumn',
cellActions: [() => <button />],
}}
/>
);
component.setState({ popoverIsOpen: true });

const cellButtons = component.find('EuiDataGridCellButtons');
Expand All @@ -80,7 +74,7 @@ describe('EuiDataGridCell', () => {
EuiDataGridCell.prototype,
'shouldComponentUpdate'
);
component = mountEuiDataGridCellWithContext();
component = mount(<EuiDataGridCell {...requiredProps} />);
});
afterEach(() => {
shouldComponentUpdate.mockRestore();
Expand Down Expand Up @@ -164,13 +158,49 @@ describe('EuiDataGridCell', () => {
describe('componentDidUpdate', () => {
it('resets cell props when the cell columnId changes', () => {
const setState = jest.spyOn(EuiDataGridCell.prototype, 'setState');
const component = mountEuiDataGridCellWithContext();
const component = mount(<EuiDataGridCell {...requiredProps} />);

component.setProps({ columnId: 'newColumnId' });
expect(setState).toHaveBeenCalledWith({ cellProps: {} });
});
});

describe('componentDidMount', () => {
const focusContext = {
focusedCell: undefined,
onFocusUpdate: jest.fn(),
setFocusedCell: jest.fn(),
};

it('creates an onFocusUpdate subscription', () => {
mount(
<DataGridFocusContext.Provider value={focusContext}>
<EuiDataGridCell {...requiredProps} />
</DataGridFocusContext.Provider>
);

expect(focusContext.onFocusUpdate).toHaveBeenCalled();
});

it('mounts the cell with focus state if the current cell should be focused', () => {
const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus');
const component = mount(
<DataGridFocusContext.Provider
value={{ ...focusContext, focusedCell: [3, 3] }}
>
<EuiDataGridCell
{...requiredProps}
colIndex={3}
visibleRowIndex={3}
/>
</DataGridFocusContext.Provider>
);

expect((component.instance().state as any).isFocused).toEqual(true);
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
});
});

// TODO: Test ResizeObserver logic in Cypress alongside Jest
describe('row height logic & resize observers', () => {
describe('recalculateAutoHeight', () => {
Expand All @@ -184,9 +214,12 @@ describe('EuiDataGridCell', () => {
it('sets the row height cache with cell heights on update', () => {
(mockRowHeightUtils.isAutoHeight as jest.Mock).mockReturnValue(true);

const component = mountEuiDataGridCellWithContext({
rowHeightsOptions: { defaultHeight: 'auto' },
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
rowHeightsOptions={{ defaultHeight: 'auto' }}
/>
);

triggerUpdate(component);
expect(mockRowHeightUtils.setRowHeight).toHaveBeenCalled();
Expand All @@ -195,9 +228,12 @@ describe('EuiDataGridCell', () => {
it('does not update the cache if cell height is not auto', () => {
(mockRowHeightUtils.isAutoHeight as jest.Mock).mockReturnValue(false);

const component = mountEuiDataGridCellWithContext({
rowHeightsOptions: { defaultHeight: 34 },
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
rowHeightsOptions={{ defaultHeight: 34 }}
/>
);

triggerUpdate(component);
expect(mockRowHeightUtils.setRowHeight).not.toHaveBeenCalled();
Expand All @@ -212,10 +248,13 @@ describe('EuiDataGridCell', () => {

describe('default height', () => {
it('observes the first cell for size changes and calls this.props.setRowHeight on change', () => {
const component = mountEuiDataGridCellWithContext({
rowHeightsOptions: { defaultHeight: { lineCount: 3 } },
setRowHeight,
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
rowHeightsOptions={{ defaultHeight: { lineCount: 3 } }}
setRowHeight={setRowHeight}
/>
);

callMethod(component);
expect(
Expand All @@ -227,14 +266,17 @@ describe('EuiDataGridCell', () => {

describe('row height overrides', () => {
it('uses the rowHeightUtils.setRowHeight cache instead of this.props.setRowHeight', () => {
const component = mountEuiDataGridCellWithContext({
rowHeightsOptions: {
defaultHeight: { lineCount: 3 },
rowHeights: { 10: { lineCount: 10 } },
},
rowIndex: 10,
setRowHeight,
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
rowHeightsOptions={{
defaultHeight: { lineCount: 3 },
rowHeights: { 10: { lineCount: 10 } },
}}
rowIndex={10}
setRowHeight={setRowHeight}
/>
);

callMethod(component);
expect(
Expand All @@ -246,10 +288,13 @@ describe('EuiDataGridCell', () => {
});

it('recalculates when rowHeightsOptions.defaultHeight.lineCount changes', () => {
const component = mountEuiDataGridCellWithContext({
rowHeightsOptions: { defaultHeight: { lineCount: 7 } },
setRowHeight,
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
rowHeightsOptions={{ defaultHeight: { lineCount: 7 } }}
setRowHeight={setRowHeight}
/>
);

component.setProps({
rowHeightsOptions: { defaultHeight: { lineCount: 6 } },
Expand All @@ -258,10 +303,13 @@ describe('EuiDataGridCell', () => {
});

it('calculates undefined heights as single rows with a lineCount of 1', () => {
const component = mountEuiDataGridCellWithContext({
rowHeightsOptions: { defaultHeight: undefined },
setRowHeight,
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
rowHeightsOptions={{ defaultHeight: undefined }}
setRowHeight={setRowHeight}
/>
);

callMethod(component);
expect(
Expand All @@ -271,10 +319,13 @@ describe('EuiDataGridCell', () => {
});

it('does nothing if cell height is not lineCount or undefined', () => {
const component = mountEuiDataGridCellWithContext({
rowHeightsOptions: { defaultHeight: 34 },
setRowHeight,
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
rowHeightsOptions={{ defaultHeight: 34 }}
setRowHeight={setRowHeight}
/>
);

callMethod(component);
expect(setRowHeight).not.toHaveBeenCalled();
Expand All @@ -286,7 +337,7 @@ describe('EuiDataGridCell', () => {
describe('interactions', () => {
describe('keyboard events', () => {
it('when cell is expandable', () => {
const component = mountEuiDataGridCellWithContext();
const component = mount(<EuiDataGridCell {...requiredProps} />);
const preventDefault = jest.fn();

component.simulate('keyDown', { preventDefault, key: keys.ENTER });
Expand All @@ -296,9 +347,9 @@ describe('EuiDataGridCell', () => {
});

it('when cell is not expandable', () => {
const component = mountEuiDataGridCellWithContext({
isExpandable: false,
});
const component = mount(
<EuiDataGridCell {...requiredProps} isExpandable={false} />
);
const preventDefault = jest.fn();

component.simulate('keyDown', { preventDefault, key: keys.ENTER });
Expand All @@ -321,28 +372,31 @@ describe('EuiDataGridCell', () => {
});

it('mouse events', () => {
const component = mountEuiDataGridCellWithContext();
const component = mount(<EuiDataGridCell {...requiredProps} />);
component.simulate('mouseEnter');
expect(component.state('enableInteractions')).toEqual(true);
component.simulate('mouseLeave');
expect(component.state('enableInteractions')).toEqual(false);
});

it('focus/blur events', () => {
const component = mountEuiDataGridCellWithContext();
const component = mount(<EuiDataGridCell {...requiredProps} />);
component.simulate('focus');
component.simulate('blur');
expect(component.state('disableCellTabIndex')).toEqual(false);
});
});

it('renders certain classes/styles if rowHeightOptions is passed', () => {
const component = mountEuiDataGridCellWithContext({
rowHeightsOptions: {
defaultHeight: 20,
rowHeights: { 0: 10 },
},
});
const component = mount(
<EuiDataGridCell
{...requiredProps}
rowHeightsOptions={{
defaultHeight: 20,
rowHeights: { 0: 10 },
}}
/>
);
component.setState({ popoverIsOpen: true });

expect(
Expand Down
25 changes: 19 additions & 6 deletions src/components/datagrid/body/data_grid_cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export class EuiDataGridCell extends Component<
return [];
};

takeFocus = () => {
takeFocus = (preventScroll: boolean) => {
const cell = this.cellRef.current;

if (cell) {
Expand All @@ -157,9 +157,9 @@ export class EuiDataGridCell extends Component<
const interactables = this.getInteractables();
if (this.props.isExpandable === false && interactables.length === 1) {
// Only one element can be interacted with
interactables[0].focus();
interactables[0].focus({ preventScroll });
} else {
cell.focus();
cell.focus({ preventScroll });
}
}
}
Expand Down Expand Up @@ -225,16 +225,29 @@ export class EuiDataGridCell extends Component<
};

componentDidMount() {
const { colIndex, visibleRowIndex } = this.props;

this.unsubscribeCell = this.context.onFocusUpdate(
[this.props.colIndex, this.props.visibleRowIndex],
[colIndex, visibleRowIndex],
this.onFocusUpdate
);

// Account for virtualization - when a cell unmounts when scrolled out of view
// and then remounts when scrolled back into view, it should retain focus state
if (
this.context.focusedCell?.[0] === colIndex &&
this.context.focusedCell?.[1] === visibleRowIndex
) {
// The second flag sets preventScroll: true as a focus option, which prevents
// hijacking the user's scroll behavior when the cell re-mounts on scroll
this.onFocusUpdate(true, true);
}
}

onFocusUpdate = (isFocused: boolean) => {
onFocusUpdate = (isFocused: boolean, preventScroll = false) => {
this.setState({ isFocused }, () => {
if (isFocused) {
this.takeFocus();
this.takeFocus(preventScroll);
}
});
};
Expand Down
3 changes: 2 additions & 1 deletion src/components/datagrid/data_grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -748,10 +748,11 @@ export const EuiDataGrid: FunctionComponent<EuiDataGridProps> = (props) => {
);
const datagridFocusContext = useMemo<DataGridFocusContextShape>(() => {
return {
focusedCell,
setFocusedCell,
onFocusUpdate,
};
}, [setFocusedCell, onFocusUpdate]);
}, [focusedCell, setFocusedCell, onFocusUpdate]);

const gridId = useGeneratedHtmlId();
const ariaLabelledById = useGeneratedHtmlId();
Expand Down
Loading