diff --git a/src/autosuggest/__integ__/virtual-scroll.test.ts b/src/autosuggest/__integ__/virtual-scroll.test.ts new file mode 100644 index 0000000000..7c3f4fd89f --- /dev/null +++ b/src/autosuggest/__integ__/virtual-scroll.test.ts @@ -0,0 +1,73 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import AutosuggestPage from './page-objects/autosuggest-page'; + +class VirtualScrollAutosuggestPage extends AutosuggestPage { + listSelector = this.wrapper.findDropdown().findOptionsContainer().toSelector(); + + async wheelOverList(deltaY: number) { + // Wheel input is separate from the pointer, so this scrolls without hovering any option. + const list = await this.browser.$(this.listSelector); + await this.browser.action('wheel').scroll({ origin: list, deltaY, duration: 100 }).perform(); + } + + async getListScrollTop() { + const { top } = await this.getElementScroll(this.listSelector); + return top; + } +} + +function setupTest(testFn: (page: VirtualScrollAutosuggestPage) => Promise) { + return useBrowser(async browser => { + const page = new VirtualScrollAutosuggestPage(browser); + await browser.url('/#/light/autosuggest/virtual-scroll'); + await page.focusInput(); + await page.assertDropdownOpen(); + await testFn(page); + }); +} + +describe('Autosuggest with virtual scroll', () => { + test( + 'keeps the scroll position after wheel scrolling a dropdown opened with the mouse', + setupTest(async page => { + await page.wheelOverList(600); + await page.waitForJsTimers(100); + expect(await page.getListScrollTop()).toBeGreaterThan(0); + + const scrollTop = await page.getListScrollTop(); + await page.wheelOverList(600); + await page.waitForJsTimers(100); + expect(await page.getListScrollTop()).toBeGreaterThan(scrollTop); + }) + ); + + test( + 'keeps the scroll position after keyboard navigation when the list is scrolled without the wheel', + setupTest(async page => { + await page.keys(['ArrowDown', 'ArrowDown']); + await page.assertHighlightedOptionContains('Option 2'); + + // Stands in for dragging the scrollbar. + await page.elementScrollTo(page.listSelector, { top: 3000 }); + await page.waitForJsTimers(100); + // The virtualizer shifts the offset slightly once it measures the newly rendered options, + // so don't expect exactly 3000. Without the fix the list snaps back to the highlighted option at the top. + expect(await page.getListScrollTop()).toBeGreaterThan(2500); + }) + ); + + test( + 'scrolls the highlighted option into view on keyboard navigation after the list was scrolled', + setupTest(async page => { + await page.elementScrollTo(page.listSelector, { top: 3000 }); + await page.waitForJsTimers(100); + + await page.keys(['ArrowDown']); + await page.assertHighlightedOptionContains('Option 1'); + await page.waitForAssertion(async () => expect(await page.getListScrollTop()).toBe(0)); + }) + ); +}); diff --git a/src/autosuggest/virtual-list.tsx b/src/autosuggest/virtual-list.tsx index a4e7671fbb..e0be769817 100644 --- a/src/autosuggest/virtual-list.tsx +++ b/src/autosuggest/virtual-list.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react'; import { useContainerQuery } from '@cloudscape-design/component-toolkit'; +import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; import OptionsList from '../internal/components/options-list'; import { useVirtual } from '../internal/hooks/use-virtual'; @@ -28,7 +29,7 @@ const VirtualList = ({ const [width, strutRef] = useContainerQuery(rect => rect.contentBoxWidth, []); useImperativeHandle(strutRef, () => scrollRef.current); - const rowVirtualizer = useVirtual({ + const { virtualItems, totalSize, scrollToIndex } = useVirtual({ items: autosuggestItemsState.items, parentRef: scrollRef, // estimateSize is a dependency of measurements memo. We update it to force full recalculation @@ -39,11 +40,21 @@ const VirtualList = ({ estimateSize: useCallback(() => 31, [width, highlightText]), }); + // Scroll when the highlight changes and moveFocus is true. The scrollToIndex identity changes + // whenever the number of items changes (e.g. when the next page of options is loaded), which + // shouldn't reset the scroll position either. + const scrollToHighlightedIndex = useStableCallback(scrollToIndex); useEffect(() => { if (autosuggestItemsState.highlightType.moveFocus) { - rowVirtualizer.scrollToIndex(autosuggestItemsState.highlightedIndex); + const index = autosuggestItemsState.highlightedIndex; + scrollToHighlightedIndex(index); + // Options are rendered with estimated sizes and measured afterwards, which can push the highlighted option out + // of view (e.g. when jumping to the last option). With React 18 the measurement happens after react-virtual's own + // retry, so retry once more after it. + const timeout = setTimeout(() => scrollToHighlightedIndex(index), 0); + return () => clearTimeout(timeout); } - }, [autosuggestItemsState.highlightType, autosuggestItemsState.highlightedIndex, rowVirtualizer]); + }, [autosuggestItemsState.highlightType, autosuggestItemsState.highlightedIndex, scrollToHighlightedIndex]); let lastGroupIndex = -1; @@ -53,9 +64,9 @@ const VirtualList = ({ aria-hidden="true" key="total-size" className={styles['layout-strut']} - style={{ height: rowVirtualizer.totalSize + (autosuggestItemsState.items.length === 1 ? 1 : 0) }} + style={{ height: totalSize + (autosuggestItemsState.items.length === 1 ? 1 : 0) }} /> - {rowVirtualizer.virtualItems.map(virtualRow => { + {virtualItems.map(virtualRow => { const { index, start, measureRef } = virtualRow; const item = autosuggestItemsState.items[index]; const optionProps = getOptionProps(