Skip to content
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
73 changes: 73 additions & 0 deletions src/autosuggest/__integ__/virtual-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
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));
})
);
});
21 changes: 16 additions & 5 deletions src/autosuggest/virtual-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -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;

Expand All @@ -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(
Expand Down
Loading