Skip to content

Commit fbe68d4

Browse files
committed
fix(virtual-list): keep the focus snap when a scroll request echoes it
1 parent 213cffb commit fbe68d4

3 files changed

Lines changed: 129 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@plextv/react-lightning-components': patch
3+
---
4+
5+
virtual list keeps the focused row snapped when a scrollToIndex or scrollToOffset asks for the spot focus already scrolled to, so rows above it resizing afterwards no longer leave it off its rest position.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import type { LightningElement } from '@plextv/react-lightning';
4+
5+
import { createTestRenderer } from './createTestRenderer';
6+
import type { LayoutManager } from './LayoutManager';
7+
import { type UseScrollHandlerResult, useScrollHandler } from './useScrollHandler';
8+
9+
const ROW = 100;
10+
const COUNT = 20;
11+
const VIEWPORT = 300;
12+
13+
function setup() {
14+
const { render } = createTestRenderer();
15+
const sizes = Array.from({ length: COUNT }, () => ROW);
16+
const offsetOf = (index: number) => sizes.slice(0, index).reduce((sum, size) => sum + size, 0);
17+
const totalSize = () => offsetOf(COUNT);
18+
const layoutManager = {
19+
getLayout: (index: number) => ({
20+
offset: offsetOf(index),
21+
size: sizes[index],
22+
column: 0,
23+
crossOffset: 0,
24+
crossSize: 500,
25+
}),
26+
get totalSize() {
27+
return totalSize();
28+
},
29+
} as unknown as LayoutManager<unknown>;
30+
let current: UseScrollHandlerResult | undefined;
31+
32+
function Probe({ total }: { total: number }) {
33+
current = useScrollHandler({
34+
layoutManager,
35+
horizontal: false,
36+
viewportSize: VIEWPORT,
37+
itemAreaOffset: 0,
38+
totalContentSize: total,
39+
viewportCrossSize: 500,
40+
totalCrossSize: 500,
41+
animationDuration: 300,
42+
snapToAlignment: 'start',
43+
onEndReachedThreshold: null,
44+
paddingStart: 0,
45+
paddingEnd: 0,
46+
itemCount: COUNT,
47+
keepFocusedItemSnapped: true,
48+
});
49+
50+
return null;
51+
}
52+
53+
const rerender = () => render(<Probe total={totalSize()} />);
54+
55+
rerender();
56+
57+
// oxlint-disable-next-line typescript/no-non-null-assertion -- set by the first render
58+
const handler = () => current!;
59+
60+
handler().contentRef.current = { node: { x: 0, y: 0 } } as unknown as LightningElement;
61+
62+
const focus = (index: number) => {
63+
const child = {
64+
getRelativePosition: () => ({ x: 0, y: offsetOf(index) }),
65+
node: { w: 500, h: sizes[index] },
66+
props: {},
67+
children: [],
68+
} as unknown as LightningElement;
69+
70+
handler().handleChildFocused(child, index);
71+
rerender();
72+
};
73+
74+
const resize = (index: number, size: number) => {
75+
sizes[index] = size;
76+
rerender();
77+
};
78+
79+
return { handler, focus, resize, rerender };
80+
}
81+
82+
describe('useScrollHandler', () => {
83+
beforeEach(() => {
84+
vi.stubGlobal('requestAnimationFrame', () => 1);
85+
vi.stubGlobal('cancelAnimationFrame', () => {});
86+
});
87+
88+
afterEach(() => {
89+
vi.unstubAllGlobals();
90+
});
91+
92+
it('keeps the focused row snapped when a scrollToIndex echoes the focus scroll', () => {
93+
const { handler, focus, resize } = setup();
94+
95+
focus(10);
96+
handler().scrollToIndex(10, true);
97+
resize(5, 76);
98+
99+
expect(handler().scrollOffsetRef.current).toBe(976);
100+
});
101+
102+
it('lets a scroll to another row release the focused row', () => {
103+
const { handler, focus, resize } = setup();
104+
105+
focus(10);
106+
handler().scrollToIndex(12, true);
107+
resize(5, 76);
108+
109+
expect(handler().scrollOffsetRef.current).toBe(1200);
110+
});
111+
});

‎packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,14 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan
361361
}
362362

363363
function scrollToOffset(offset: number, animated = true): void {
364-
focusAnchorRef.current = null;
364+
const anchor = focusAnchorRef.current;
365+
366+
// A request for the spot the focused item already snaps to (a focus handler
367+
// echoing the list's own scroll) keeps the item snapped as rows above resize.
368+
if (!anchor || Math.abs(clamp(offset) - clamp(resolveFocusTarget(anchor))) >= 1) {
369+
focusAnchorRef.current = null;
370+
}
371+
365372
indexIntentRef.current = null;
366373
scrollTo(offset, animated, true);
367374
}
@@ -388,7 +395,11 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan
388395
}
389396

390397
scrollToOffset(target, animated);
391-
indexIntentRef.current = target;
398+
399+
// A kept focus anchor already follows the item; a fixed intent would pull it back.
400+
if (!focusAnchorRef.current) {
401+
indexIntentRef.current = target;
402+
}
392403
}
393404

394405
function scrollToEnd(animated = true): void {

0 commit comments

Comments
 (0)