diff --git a/pages/select/virtual-scroll.page.tsx b/pages/select/virtual-scroll.page.tsx deleted file mode 100644 index c33dd11b7a..0000000000 --- a/pages/select/virtual-scroll.page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import React, { useState } from 'react'; - -import Select, { SelectProps } from '~components/select'; - -import { SimplePage } from '../app/templates'; - -const options: SelectProps.Options = Array.from({ length: 1000 }, (_, i) => ({ - value: `${i}`, - label: `Option ${i + 1}`, -})); - -export default function () { - const [selected, setSelected] = useState(null); - - return ( - -
- setSelected(event.detail.selectedOption)} + virtualScroll={true} + expandToViewport={false} + ariaLabel="select demo" + data-testid="select-demo" + /> + )} + + {selectedType === 'multiselect' && ( + {}} + onChange={event => setSelectedMulti(event.detail.selectedOptions)} + tokenLimit={2} + virtualScroll={true} + expandToViewport={false} + ariaLabel="multiselect demo" + data-testid="multiselect-demo" + /> + )} + + {selectedType === 'multiselect-select-all' && ( + setSelectedMultiWithSelectAll(event.detail.selectedOptions)} + enableSelectAll={true} + virtualScroll={true} + expandToViewport={false} + ariaLabel="multiselect with select all demo" + data-testid="multiselect-select-all-demo" + /> + )} + + {selectedType === 'autosuggest' && ( + setAutosuggestValue(event.detail.value)} + enteredTextLabel={value => `Use: "${value}"`} + placeholder="Autosuggest with virtual scroll" + ariaLabel="autosuggest demo" + virtualScroll={true} + expandToViewport={false} + data-testid="autosuggest-demo" + /> + )} + +
+
+ ); +} diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 545b4cd53b..d2468e8750 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -6,6 +6,39 @@ @use '../../styles' as styles; @use '../../styles/tokens' as awsui; +// When using virtual scrolling, use box-shadows to mimic changing border width +// and set the actual border to a fixed width to prevent jumping behaviour when +// hovering selectable items +$virtual-border-offset: calc(#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}); +$virtual-border-offset-double: calc(2 * #{awsui.$border-item-width} - #{awsui.$border-divider-list-width}); + +@mixin virtual-border-padding { + border-width: awsui.$border-divider-list-width; + padding-block: calc(#{styles.$option-padding-vertical} + #{$virtual-border-offset}); + padding-inline: calc(#{styles.$control-padding-horizontal} + #{$virtual-border-offset}); +} + +@mixin virtual-highlighted-shadow { + box-shadow: inset 0 0 0 $virtual-border-offset awsui.$color-border-dropdown-item-hover; + &.is-keyboard { + box-shadow: inset 0 0 0 $virtual-border-offset awsui.$color-border-dropdown-item-focused; + } +} + +@mixin virtual-selected-shadow { + box-shadow: inset 0 0 0 $virtual-border-offset awsui.$color-border-dropdown-item-selected; + &.highlighted { + box-shadow: + inset 0 0 0 $virtual-border-offset awsui.$color-border-dropdown-item-selected, + inset 0 0 0 $virtual-border-offset-double awsui.$color-border-dropdown-item-hover; + &.is-keyboard { + box-shadow: + inset 0 0 0 $virtual-border-offset awsui.$color-border-dropdown-item-selected, + inset 0 0 0 $virtual-border-offset-double awsui.$color-border-dropdown-item-focused; + } + } +} + // Outer borders of adjacent cells overlap and we want selected option border // to take precedence over the other ones, hence negative margins and z-indices .selectable-item { @@ -193,11 +226,30 @@ inset-block-start: 0; // Push the next option down to prevent its border in highlighted or selected state from being partially covered by the sticky option - margin-block-end: calc(#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}); + margin-block-end: $virtual-border-offset; // Stay on top of the other options when they are scrolled up z-index: 4; + // Apply fixed border width to sticky items when there are virtual siblings (virtual scroll is enabled) + &:has(~ .virtual) { + &.highlighted:not(.selected), + &.selected { + @include virtual-border-padding; + margin-block-end: calc( + #{awsui.$border-item-width} - #{awsui.$border-divider-list-width} - #{$virtual-border-offset} + ); + } + + &.highlighted:not(.selected) { + @include virtual-highlighted-shadow; + } + + &.selected { + @include virtual-selected-shadow; + } + } + &:not(.highlighted):not(.selected) { // Prevent covering the list border despite the higher z-index border-inline-start-width: awsui.$border-item-width; @@ -252,6 +304,58 @@ &:first-of-type:not(.selected, .highlighted) { border-block-start-color: awsui.$color-border-dropdown-item-top; } + + &.highlighted:not(.selected), + &.selected { + @include virtual-border-padding; + + &.pad-bottom { + padding-block-end: calc(#{styles.$option-padding-vertical} + #{awsui.$space-xxxs} + #{$virtual-border-offset}); + } + + &.child { + padding-inline-start: calc(#{awsui.$space-xxl} + #{$virtual-border-offset}); + } + + &.parent.interactiveGroups { + padding-block: calc(#{styles.$group-option-padding-vertical} + #{$virtual-border-offset}); + padding-inline: calc(#{styles.$control-padding-horizontal} + #{$virtual-border-offset}); + } + } + + &.highlighted:not(.selected) { + @include virtual-highlighted-shadow; + } + + &.selected { + @include virtual-selected-shadow; + } + + &.parent:not(.interactiveGroups) { + border-block-start-width: awsui.$border-divider-list-width; + border-block-start-color: awsui.$color-border-dropdown-group; + padding-block: awsui.$space-xs; + padding-inline: awsui.$space-xs; + } + + &.parent.interactiveGroups:not(.highlighted):not(.selected) { + border-block-start-color: awsui.$color-border-dropdown-group; + } + + &.sticky { + &.highlighted:not(.selected), + &.selected { + @include virtual-border-padding; + } + + &.highlighted:not(.selected) { + @include virtual-highlighted-shadow; + } + + &.selected { + @include virtual-selected-shadow; + } + } } } diff --git a/src/internal/hooks/use-virtual/__tests__/use-virtual.test.tsx b/src/internal/hooks/use-virtual/__tests__/use-virtual.test.tsx new file mode 100644 index 0000000000..320ce584eb --- /dev/null +++ b/src/internal/hooks/use-virtual/__tests__/use-virtual.test.tsx @@ -0,0 +1,221 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useRef } from 'react'; +import { render } from '@testing-library/react'; + +import { useVirtual } from '../index'; + +jest.mock('../../../vendor/react-virtual', () => ({ + useVirtual: jest.fn(), +})); + +import * as reactVirtualModule from '../../../vendor/react-virtual'; + +const mockUseVirtual = reactVirtualModule.useVirtual as jest.MockedFunction; + +interface TestItem { + id: string; +} + +const TestComponent = ({ + items, + firstItemSticky, + itemOverlap, + onResult, +}: { + items: TestItem[]; + firstItemSticky?: boolean; + itemOverlap?: number; + onResult: (result: ReturnType>) => void; +}) => { + const parentRef = useRef(null); + const result = useVirtual({ + items, + parentRef, + estimateSize: () => 50, + firstItemSticky, + itemOverlap, + }); + + onResult(result); + + return ( +
+ {result.virtualItems.map(item => ( +
+ Item {item.index} +
+ ))} +
+ ); +}; + +describe('useVirtual', () => { + const mockMeasureRef = jest.fn(); + const mockScrollToIndex = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('computes totalSize with itemOverlap=1', () => { + const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }]; + + // Mock react-virtual to return 3 items with size 50 each, total 150 + mockUseVirtual.mockReturnValue({ + virtualItems: [ + { index: 0, size: 50, start: 0, end: 50, measureRef: mockMeasureRef }, + { index: 1, size: 50, start: 50, end: 100, measureRef: mockMeasureRef }, + { index: 2, size: 50, start: 100, end: 150, measureRef: mockMeasureRef }, + ], + totalSize: 150, + scrollToIndex: mockScrollToIndex, + }); + + let capturedResult: ReturnType> | null = null; + render( (capturedResult = result)} />); + + expect(capturedResult).not.toBeNull(); + // totalSize = 150 - (3 items * 1 overlap) = 147 + expect(capturedResult!.totalSize).toBe(147); + }); + + test('computes totalSize with itemOverlap=0', () => { + const items: TestItem[] = [{ id: '1' }, { id: '2' }]; + + mockUseVirtual.mockReturnValue({ + virtualItems: [ + { index: 0, size: 50, start: 0, end: 50, measureRef: mockMeasureRef }, + { index: 1, size: 50, start: 50, end: 100, measureRef: mockMeasureRef }, + { index: 2, size: 50, start: 100, end: 150, measureRef: mockMeasureRef }, + ], + totalSize: 150, + scrollToIndex: mockScrollToIndex, + }); + + let capturedResult: ReturnType> | null = null; + render( (capturedResult = result)} />); + + // totalSize = 150 - (3 items * 0 overlap) = 150 + expect(capturedResult!.totalSize).toBe(150); + }); + + test('subtracts sticky item size from totalSize', () => { + const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }]; + + mockUseVirtual.mockReturnValue({ + virtualItems: [ + { index: 0, size: 60, start: 0, end: 60, measureRef: mockMeasureRef }, + { index: 1, size: 50, start: 60, end: 110, measureRef: mockMeasureRef }, + { index: 2, size: 50, start: 110, end: 160, measureRef: mockMeasureRef }, + ], + totalSize: 160, + scrollToIndex: mockScrollToIndex, + }); + + let capturedResult: ReturnType> | null = null; + render( + (capturedResult = result)} + /> + ); + + expect(capturedResult).not.toBeNull(); + // totalSize = 160 - (3 items * 1 overlap) - 60 (firstItemSize) + 2 = 99 + expect(capturedResult!.totalSize).toBe(99); + }); + + test('calculates item positions without itemOverlap=0', () => { + const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }]; + + mockUseVirtual.mockReturnValue({ + virtualItems: [ + { index: 0, size: 50, start: 0, end: 50, measureRef: mockMeasureRef }, + { index: 1, size: 50, start: 50, end: 100, measureRef: mockMeasureRef }, + { index: 2, size: 50, start: 100, end: 150, measureRef: mockMeasureRef }, + ], + totalSize: 150, + scrollToIndex: mockScrollToIndex, + }); + + let capturedResult: ReturnType> | null = null; + render( (capturedResult = result)} />); + + expect(capturedResult!.virtualItems[0].start).toBe(0); + expect(capturedResult!.virtualItems[1].start).toBe(50); + expect(capturedResult!.virtualItems[2].start).toBe(100); + }); + + test('calculates item positions with itemOverlap=1', () => { + const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }]; + + mockUseVirtual.mockReturnValue({ + virtualItems: [ + { index: 0, size: 50, start: 0, end: 50, measureRef: mockMeasureRef }, + { index: 1, size: 50, start: 50, end: 100, measureRef: mockMeasureRef }, + { index: 2, size: 50, start: 100, end: 150, measureRef: mockMeasureRef }, + ], + totalSize: 150, + scrollToIndex: mockScrollToIndex, + }); + + let capturedResult: ReturnType> | null = null; + render( (capturedResult = result)} />); + + expect(capturedResult!.virtualItems[0].start).toBe(0); + expect(capturedResult!.virtualItems[1].start).toBe(49); + expect(capturedResult!.virtualItems[2].start).toBe(98); + }); + + test('calculates item positions with sticky first item and itemOverlap=0', () => { + const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }]; + + mockUseVirtual.mockReturnValue({ + virtualItems: [ + { index: 0, size: 60, start: 0, end: 60, measureRef: mockMeasureRef }, + { index: 1, size: 50, start: 60, end: 110, measureRef: mockMeasureRef }, + { index: 2, size: 50, start: 110, end: 160, measureRef: mockMeasureRef }, + ], + totalSize: 160, + scrollToIndex: mockScrollToIndex, + }); + + let capturedResult: ReturnType> | null = null; + render( (capturedResult = result)} />); + + expect(capturedResult!.virtualItems[1].start).toBe(62); + expect(capturedResult!.virtualItems[2].start).toBe(112); + }); + + test('calculates item positions with sticky first item and itemOverlap=1', () => { + const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }]; + + mockUseVirtual.mockReturnValue({ + virtualItems: [ + { index: 0, size: 60, start: 0, end: 60, measureRef: mockMeasureRef }, + { index: 1, size: 50, start: 60, end: 110, measureRef: mockMeasureRef }, + { index: 2, size: 50, start: 110, end: 160, measureRef: mockMeasureRef }, + ], + totalSize: 160, + scrollToIndex: mockScrollToIndex, + }); + + let capturedResult: ReturnType> | null = null; + render( + (capturedResult = result)} + /> + ); + + expect(capturedResult!.virtualItems[0].start).toBe(1); + expect(capturedResult!.virtualItems[1].start).toBe(61); + expect(capturedResult!.virtualItems[2].start).toBe(110); + }); +}); diff --git a/src/internal/hooks/use-virtual/index.ts b/src/internal/hooks/use-virtual/index.ts index ffc3dee761..a0eeaa51e9 100644 --- a/src/internal/hooks/use-virtual/index.ts +++ b/src/internal/hooks/use-virtual/index.ts @@ -16,6 +16,7 @@ interface UseVirtualProps { parentRef: React.RefObject; estimateSize: () => number; firstItemSticky?: boolean; + itemOverlap?: number; } interface RowVirtualizer { @@ -41,6 +42,7 @@ export function useVirtual({ parentRef, estimateSize, firstItemSticky, + itemOverlap = 0, }: UseVirtualProps): RowVirtualizer { const rowVirtualizer = useVirtualDefault({ size: items.length, @@ -61,22 +63,41 @@ export function useVirtual({ const virtualItems = useMemo( () => - rowVirtualizer.virtualItems.map(virtualItem => ({ - ...virtualItem, - measureRef: (node: null | HTMLElement) => { - const mountedCount = measuresCache.current.get(items[virtualItem.index]) ?? 0; - if (mountedCount < MAX_ITEM_MOUNTS) { - virtualItem.measureRef(node); - measuresCache.current.set(items[virtualItem.index], mountedCount + 1); - } - }, - })), - [items, rowVirtualizer.virtualItems] + rowVirtualizer.virtualItems.map((virtualItem, index) => { + let adjustedStart: number; + + if (firstItemSticky && virtualItem.index === 0) { + adjustedStart = virtualItem.start + 1; + } else if (firstItemSticky) { + adjustedStart = virtualItem.start + 2 - index * itemOverlap; + } else { + adjustedStart = virtualItem.start - index * itemOverlap; + } + + return { + ...virtualItem, + start: adjustedStart, + measureRef: (node: null | HTMLElement) => { + const mountedCount = measuresCache.current.get(items[virtualItem.index]) ?? 0; + if (mountedCount < MAX_ITEM_MOUNTS) { + virtualItem.measureRef(node); + measuresCache.current.set(items[virtualItem.index], mountedCount + 1); + } + }, + }; + }), + [items, rowVirtualizer.virtualItems, firstItemSticky, itemOverlap] ); + // Adjust totalSize to account for 1px overlap per item When firstItemSticky + // is enabled, the sticky item is shifted down by 1 and other items are shifted down by (index + 2) + const firstItemSize = virtualItems[0]?.size ?? 0; + let adjustedTotalSize = rowVirtualizer.totalSize - items.length * itemOverlap; + adjustedTotalSize = firstItemSticky ? adjustedTotalSize - firstItemSize + 2 : adjustedTotalSize; + return { virtualItems, - totalSize: rowVirtualizer.totalSize, + totalSize: adjustedTotalSize, scrollToIndex: rowVirtualizer.scrollToIndex, }; } diff --git a/src/select/parts/virtual-list.tsx b/src/select/parts/virtual-list.tsx index a85657bf6c..e748517ed3 100644 --- a/src/select/parts/virtual-list.tsx +++ b/src/select/parts/virtual-list.tsx @@ -53,6 +53,7 @@ const VirtualListOpen = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps estimateSize: useCallback(() => fallbackItemHeight, [width?.inner, filteringValue]), firstItemSticky: firstOptionSticky, + itemOverlap: 1, }); useImperativeHandle( @@ -106,12 +107,7 @@ const VirtualListOpen = forwardRef( return ( {finalOptions} -