Skip to content

Commit 0e37bab

Browse files
[EuiDataGrid] Fix scrolling on focus return (#9453)
Co-authored-by: Weronika Olejniczak <weronika.olejniczak@elastic.co>
1 parent 429fbed commit 0e37bab

File tree

6 files changed

+102
-71
lines changed

6 files changed

+102
-71
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
**Bug fixes**
2+
3+
- Fixed `EuiDataGrid` scroll bouncing back to the focused element in certain cases

packages/eui/src/components/datagrid/utils/scrolling.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import React, {
1111
useEffect,
1212
useCallback,
1313
useMemo,
14+
useRef,
1415
MutableRefObject,
1516
ReactNode,
1617
} from 'react';
1718
import { VariableSizeGrid as Grid } from 'react-window';
1819

19-
import { useEuiMemoizedStyles, useIsPointerDown } from '../../../services';
20+
import { useEuiMemoizedStyles } from '../../../services';
21+
import { useIsPointerDown } from '../../../services/hooks';
2022
import { logicalStyles } from '../../../global_styling';
2123
import { DataGridCellPopoverContext } from '../body/cell';
2224
import { EuiDataGridStyle } from '../data_grid_types';
@@ -50,21 +52,48 @@ export const useScroll = (args: Dependencies) => {
5052
const { scrollCellIntoView } = useScrollCellIntoView(args);
5153

5254
const { focusedCell } = useContext(DataGridFocusContext);
53-
const isPointerDown = useIsPointerDown(args.outerGridRef);
55+
const isPointerDownRef = useIsPointerDown(args.outerGridRef);
56+
57+
/**
58+
* Set when `focusedCell` changes while the pointer is held down (e.g. clicking a cell).
59+
* Allows the `pointerup` listener below to scroll on release without
60+
* causing snap-back when the user scrolls the grid without changing focus.
61+
*/
62+
const pendingScrollRef = useRef(false);
5463

5564
useEffect(() => {
56-
if (focusedCell) {
57-
// do not scroll if text is being selected
58-
if (isPointerDown || window?.getSelection()?.type === 'Range') {
59-
return;
60-
}
65+
if (!focusedCell) return;
66+
if (isPointerDownRef.current) {
67+
// Pointer is down - defer scroll decision to the pointerup listener
68+
pendingScrollRef.current = true;
69+
return;
70+
}
71+
72+
scrollCellIntoView({ rowIndex: focusedCell[1], colIndex: focusedCell[0] });
73+
}, [focusedCell, scrollCellIntoView, isPointerDownRef]);
74+
75+
useEffect(() => {
76+
const handlePointerUp = () => {
77+
if (!pendingScrollRef.current || !focusedCell) return;
78+
79+
pendingScrollRef.current = false;
80+
81+
// Skip if the interaction resulted in text being selected
82+
if (window?.getSelection()?.type === 'Range') return;
6183

6284
scrollCellIntoView({
6385
rowIndex: focusedCell[1],
6486
colIndex: focusedCell[0],
6587
});
66-
}
67-
}, [focusedCell, isPointerDown, scrollCellIntoView]);
88+
};
89+
90+
document.addEventListener('pointerup', handlePointerUp, { capture: true });
91+
92+
return () =>
93+
document.removeEventListener('pointerup', handlePointerUp, {
94+
capture: true,
95+
});
96+
}, [focusedCell, scrollCellIntoView]);
6897

6998
const { popoverIsOpen, cellLocation } = useContext(
7099
DataGridCellPopoverContext

packages/eui/src/services/hooks/index.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
* Side Public License, v 1.
77
*/
88

9-
export * from './useDependentState';
10-
export * from './useCombinedRefs';
11-
export * from './useForceRender';
12-
export * from './useLatest';
13-
export * from './useDeepEqual';
14-
export * from './useMouseMove';
15-
export * from './useIsPointerDown';
16-
export * from './useUpdateEffect';
9+
export { useDependentState } from './useDependentState';
10+
export { useCombinedRefs, setMultipleRefs } from './useCombinedRefs';
11+
export { useForceRender } from './useForceRender';
12+
export { useLatest } from './useLatest';
13+
export { useDeepEqual } from './useDeepEqual';
14+
export { isMouseEvent, useMouseMove } from './useMouseMove';
15+
export { useIsPointerDown } from './useIsPointerDown';
16+
export { useUpdateEffect } from './useUpdateEffect';
1717
export {
1818
type EuiDisabledProps,
1919
useEuiDisabledElement,

packages/eui/src/services/hooks/useIsPointerDown.test.tsx

Lines changed: 34 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
* Side Public License, v 1.
77
*/
88

9-
import React, { useRef } from 'react';
9+
import React, { type MutableRefObject, useRef } from 'react';
1010
import { act } from '@testing-library/react';
1111

12-
import { render } from '../../test/rtl';
12+
import { render, renderHook, renderHookAct } from '../../test/rtl';
1313

1414
import { useIsPointerDown } from './useIsPointerDown';
1515

@@ -26,39 +26,36 @@ global.PointerEvent = MockPointerEvent;
2626
describe('useIsPointerDown', () => {
2727
describe('without container', () => {
2828
it('returns true when pointer is down and false when pointer is up', () => {
29-
let isPointerDown: boolean;
29+
const {
30+
result: { current: ref },
31+
} = renderHook(useIsPointerDown);
3032

31-
const TestComponent = () => {
32-
isPointerDown = useIsPointerDown();
33-
return null;
34-
};
35-
36-
render(<TestComponent />);
37-
expect(isPointerDown!).toBe(false);
33+
expect(ref.current).toBe(false);
3834

39-
act(() => {
35+
renderHookAct(() => {
4036
document.dispatchEvent(
4137
new PointerEvent('pointerdown', { bubbles: true })
4238
);
4339
});
44-
expect(isPointerDown!).toBe(true);
4540

46-
act(() => {
41+
expect(ref.current).toBe(true);
42+
43+
renderHookAct(() => {
4744
document.dispatchEvent(
4845
new PointerEvent('pointerup', { bubbles: true })
4946
);
5047
});
51-
expect(isPointerDown!).toBe(false);
48+
expect(ref.current).toBe(false);
5249
});
5350
});
5451

5552
describe('with container', () => {
5653
it('returns true when pointer is down inside the container', () => {
57-
let isPointerDown: boolean;
54+
let isPointerDownRef: MutableRefObject<boolean> = { current: false };
5855

5956
const TestComponent = () => {
6057
const containerRef = useRef<HTMLDivElement | null>(null);
61-
isPointerDown = useIsPointerDown(containerRef);
58+
isPointerDownRef = useIsPointerDown(containerRef);
6259
return (
6360
<div>
6461
<div data-test-subj="outside" />
@@ -68,30 +65,30 @@ describe('useIsPointerDown', () => {
6865
};
6966

7067
const { getByTestSubject } = render(<TestComponent />);
71-
expect(isPointerDown!).toBe(false);
68+
expect(isPointerDownRef.current).toBe(false);
7269

7370
act(() => {
7471
const container = getByTestSubject('container');
7572
container.dispatchEvent(
7673
new PointerEvent('pointerdown', { bubbles: true })
7774
);
7875
});
79-
expect(isPointerDown!).toBe(true);
76+
expect(isPointerDownRef.current).toBe(true);
8077

8178
act(() => {
8279
document.dispatchEvent(
8380
new PointerEvent('pointerup', { bubbles: true })
8481
);
8582
});
86-
expect(isPointerDown!).toBe(false);
83+
expect(isPointerDownRef.current).toBe(false);
8784
});
8885

8986
it('returns false when pointer is down outside the container', () => {
90-
let isPointerDown: boolean;
87+
let isPointerDownRef: MutableRefObject<boolean> = { current: false };
9188

9289
const TestComponent = () => {
9390
const containerRef = useRef<HTMLDivElement | null>(null);
94-
isPointerDown = useIsPointerDown(containerRef);
91+
isPointerDownRef = useIsPointerDown(containerRef);
9592
return (
9693
<div>
9794
<div data-test-subj="outside" />
@@ -101,69 +98,59 @@ describe('useIsPointerDown', () => {
10198
};
10299

103100
const { getByTestSubject } = render(<TestComponent />);
104-
expect(isPointerDown!).toBe(false);
101+
expect(isPointerDownRef.current).toBe(false);
105102

106103
act(() => {
107104
const outside = getByTestSubject('outside');
108105
outside.dispatchEvent(
109106
new PointerEvent('pointerdown', { bubbles: true })
110107
);
111108
});
112-
expect(isPointerDown!).toBe(false);
109+
expect(isPointerDownRef.current).toBe(false);
113110
});
114111
});
115112

116113
describe('pointercancel and visibilitychange events', () => {
117114
it('resets to false on pointercancel', () => {
118-
let isPointerDown: boolean;
119-
120-
const TestComponent = () => {
121-
isPointerDown = useIsPointerDown();
122-
return null;
123-
};
115+
const {
116+
result: { current: ref },
117+
} = renderHook(useIsPointerDown);
124118

125-
render(<TestComponent />);
126-
127-
act(() => {
119+
renderHookAct(() => {
128120
document.dispatchEvent(
129121
new PointerEvent('pointerdown', { bubbles: true })
130122
);
131123
});
132-
expect(isPointerDown!).toBe(true);
124+
expect(ref.current).toBe(true);
133125

134-
act(() => {
126+
renderHookAct(() => {
135127
document.dispatchEvent(
136128
new PointerEvent('pointercancel', { bubbles: true })
137129
);
138130
});
139-
expect(isPointerDown!).toBe(false);
131+
expect(ref.current).toBe(false);
140132
});
141133

142134
it('resets to false when document becomes hidden', () => {
143-
let isPointerDown: boolean;
135+
const {
136+
result: { current: ref },
137+
} = renderHook(useIsPointerDown);
144138

145-
const TestComponent = () => {
146-
isPointerDown = useIsPointerDown();
147-
return null;
148-
};
149-
150-
render(<TestComponent />);
151-
152-
act(() => {
139+
renderHookAct(() => {
153140
document.dispatchEvent(
154141
new PointerEvent('pointerdown', { bubbles: true })
155142
);
156143
});
157-
expect(isPointerDown!).toBe(true);
144+
expect(ref.current).toBe(true);
158145

159-
act(() => {
146+
renderHookAct(() => {
160147
Object.defineProperty(document, 'visibilityState', {
161148
value: 'hidden',
162149
configurable: true,
163150
});
164151
document.dispatchEvent(new Event('visibilitychange'));
165152
});
166-
expect(isPointerDown!).toBe(false);
153+
expect(ref.current).toBe(false);
167154

168155
// reset
169156
Object.defineProperty(document, 'visibilityState', {

packages/eui/src/services/hooks/useIsPointerDown.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Side Public License, v 1.
77
*/
88

9-
import { useState, useEffect, MutableRefObject } from 'react';
9+
import { useRef, useEffect, type MutableRefObject } from 'react';
1010

1111
/**
1212
* A hook that tracks whether the pointer is currently down/pressed.
@@ -15,7 +15,7 @@ import { useState, useEffect, MutableRefObject } from 'react';
1515
export function useIsPointerDown(
1616
container?: MutableRefObject<HTMLElement | null>
1717
) {
18-
const [isPointerDown, setIsPointerDown] = useState(false);
18+
const isPointerDownRef = useRef(false);
1919

2020
useEffect(() => {
2121
const handlePointerDown = (event: PointerEvent) => {
@@ -25,16 +25,16 @@ export function useIsPointerDown(
2525
) {
2626
return;
2727
}
28-
setIsPointerDown(true);
28+
isPointerDownRef.current = true;
2929
};
3030

3131
const handlePointerUpOrCancel = () => {
32-
setIsPointerDown(false);
32+
isPointerDownRef.current = false;
3333
};
3434

3535
const handleVisibilityChange = () => {
3636
if (document.visibilityState === 'hidden') {
37-
setIsPointerDown(false);
37+
isPointerDownRef.current = false;
3838
}
3939
};
4040

@@ -57,5 +57,5 @@ export function useIsPointerDown(
5757
};
5858
}, [container]);
5959

60-
return isPointerDown;
60+
return isPointerDownRef;
6161
}

packages/eui/src/services/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,19 @@ export {
8585
formatNumber,
8686
formatText,
8787
} from './format';
88-
export * from './hooks';
88+
export {
89+
useDependentState,
90+
useCombinedRefs,
91+
setMultipleRefs,
92+
useForceRender,
93+
useLatest,
94+
useDeepEqual,
95+
isMouseEvent,
96+
useMouseMove,
97+
useUpdateEffect,
98+
useEuiDisabledElement,
99+
type EuiDisabledProps,
100+
} from './hooks';
89101
export { isEvenlyDivisibleBy, isWithinRange } from './number';
90102
export { Pager } from './paging';
91103
export { calculatePopoverPosition, findPopoverPosition } from './popover';

0 commit comments

Comments
 (0)