diff --git a/.changeset/virtuallist-focus-echo-resnap.md b/.changeset/virtuallist-focus-echo-resnap.md new file mode 100644 index 0000000..71d25aa --- /dev/null +++ b/.changeset/virtuallist-focus-echo-resnap.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +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. diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.spec.tsx b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.spec.tsx new file mode 100644 index 0000000..64b65e0 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.spec.tsx @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { LightningElement } from '@plextv/react-lightning'; + +import { createTestRenderer } from './createTestRenderer'; +import type { LayoutManager } from './LayoutManager'; +import { type UseScrollHandlerResult, useScrollHandler } from './useScrollHandler'; + +const ROW = 100; +const COUNT = 20; +const VIEWPORT = 300; + +function setup() { + const { render } = createTestRenderer(); + const sizes = Array.from({ length: COUNT }, () => ROW); + const offsetOf = (index: number) => sizes.slice(0, index).reduce((sum, size) => sum + size, 0); + const totalSize = () => offsetOf(COUNT); + const layoutManager = { + getLayout: (index: number) => ({ + offset: offsetOf(index), + size: sizes[index], + column: 0, + crossOffset: 0, + crossSize: 500, + }), + get totalSize() { + return totalSize(); + }, + } as unknown as LayoutManager; + let current: UseScrollHandlerResult | undefined; + + function Probe({ total }: { total: number }) { + current = useScrollHandler({ + layoutManager, + horizontal: false, + viewportSize: VIEWPORT, + itemAreaOffset: 0, + totalContentSize: total, + viewportCrossSize: 500, + totalCrossSize: 500, + animationDuration: 300, + snapToAlignment: 'start', + onEndReachedThreshold: null, + paddingStart: 0, + paddingEnd: 0, + itemCount: COUNT, + keepFocusedItemSnapped: true, + }); + + return null; + } + + const rerender = () => render(); + + rerender(); + + // oxlint-disable-next-line typescript/no-non-null-assertion -- set by the first render + const handler = () => current!; + + handler().contentRef.current = { node: { x: 0, y: 0 } } as unknown as LightningElement; + + const focus = (index: number) => { + const child = { + getRelativePosition: () => ({ x: 0, y: offsetOf(index) }), + node: { w: 500, h: sizes[index] }, + props: {}, + children: [], + } as unknown as LightningElement; + + handler().handleChildFocused(child, index); + rerender(); + }; + + const resize = (index: number, size: number) => { + sizes[index] = size; + rerender(); + }; + + return { handler, focus, resize, rerender }; +} + +describe('useScrollHandler', () => { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps the focused row snapped when a scrollToIndex echoes the focus scroll', () => { + const { handler, focus, resize } = setup(); + + focus(10); + handler().scrollToIndex(10, true); + resize(5, 76); + + expect(handler().scrollOffsetRef.current).toBe(976); + }); + + it('lets a scroll to another row release the focused row', () => { + const { handler, focus, resize } = setup(); + + focus(10); + handler().scrollToIndex(12, true); + resize(5, 76); + + expect(handler().scrollOffsetRef.current).toBe(1200); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index dcdd751..8151b92 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -361,7 +361,14 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan } function scrollToOffset(offset: number, animated = true): void { - focusAnchorRef.current = null; + const anchor = focusAnchorRef.current; + + // A request for the spot the focused item already snaps to (a focus handler + // echoing the list's own scroll) keeps the item snapped as rows above resize. + if (!anchor || Math.abs(clamp(offset) - clamp(resolveFocusTarget(anchor))) >= 1) { + focusAnchorRef.current = null; + } + indexIntentRef.current = null; scrollTo(offset, animated, true); } @@ -388,7 +395,11 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan } scrollToOffset(target, animated); - indexIntentRef.current = target; + + // A kept focus anchor already follows the item; a fixed intent would pull it back. + if (!focusAnchorRef.current) { + indexIntentRef.current = target; + } } function scrollToEnd(animated = true): void {