From db5e32f60d675ac17081dd97bba7aeb89c7810f7 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Thu, 16 Oct 2025 19:08:07 +0200 Subject: [PATCH 01/23] fix: prevent list item jump on hover in virtual scrolling fix: inconsistent dropdown styling with virtual scroll refactor: deduplicate code --- .../components/selectable-item/styles.scss | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 545b4cd53b..f7a9152c0a 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -252,6 +252,60 @@ &:first-of-type:not(.selected, .highlighted) { border-block-start-color: awsui.$color-border-dropdown-item-top; } + + // 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}); + + &.highlighted:not(.selected), + &.selected { + 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}); + + &.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}); + } + } + + &.highlighted:not(.selected) { + 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; + } + } + + &.selected { + 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; + } + } + } + + &.parent:not(.interactiveGroups) { + border-block-start-color: awsui.$color-border-dropdown-group; + } } } From 762a8b280b7105e677b6bf6e62a9f0f4c0ce37ec Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Mon, 20 Oct 2025 17:50:57 +0200 Subject: [PATCH 02/23] fix: double borders in virtual scroll mode - Each item is shifted up by its index in pixels - The layout strut height is reduced by the number of items to account for the cumulative overlap --- src/select/parts/virtual-list.tsx | 6 +++++- src/select/utils/render-options.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/select/parts/virtual-list.tsx b/src/select/parts/virtual-list.tsx index a85657bf6c..e55d23c385 100644 --- a/src/select/parts/virtual-list.tsx +++ b/src/select/parts/virtual-list.tsx @@ -103,6 +103,10 @@ const VirtualListOpen = forwardRef( withScrollbar, }); + // Adjust totalSize to account for 1px overlap per item (matching the position adjustment in renderOptions) + const overlapAdjustment = filteredOptions.length; + const adjustedTotalSize = totalSize - overlapAdjustment; + return ( {finalOptions} @@ -110,7 +114,7 @@ const VirtualListOpen = forwardRef( aria-hidden="true" key="total-size" className={styles['layout-strut']} - style={{ height: totalSize - stickySize }} + style={{ height: adjustedTotalSize - stickySize }} /> {listBottom ? (
diff --git a/src/select/utils/render-options.tsx b/src/select/utils/render-options.tsx index af09210e05..27f2b07986 100644 --- a/src/select/utils/render-options.tsx +++ b/src/select/utils/render-options.tsx @@ -65,11 +65,15 @@ export const renderOptions = ({ const ListItem = useInteractiveGroups ? MultiselectItem : Item; const isSticky = firstOptionSticky && globalIndex === 0; + // Adjust virtual position to create 1px overlap between items (matching non-virtual behavior) + // Subtract globalIndex to shift each item up by 1px per item + const adjustedVirtualPosition = virtualItem ? virtualItem.start - globalIndex : undefined; + return ( Date: Fri, 21 Nov 2025 22:48:02 +0100 Subject: [PATCH 03/23] fix: prevent whitespace in virtual scrolling with large lists Use virtual window index instead of global index when calculating position offset. This prevents excessive whitespace when scrolling through large lists with virtual scrolling enabled. --- src/select/utils/render-options.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/select/utils/render-options.tsx b/src/select/utils/render-options.tsx index 27f2b07986..d1b5238ada 100644 --- a/src/select/utils/render-options.tsx +++ b/src/select/utils/render-options.tsx @@ -65,9 +65,8 @@ export const renderOptions = ({ const ListItem = useInteractiveGroups ? MultiselectItem : Item; const isSticky = firstOptionSticky && globalIndex === 0; - // Adjust virtual position to create 1px overlap between items (matching non-virtual behavior) - // Subtract globalIndex to shift each item up by 1px per item - const adjustedVirtualPosition = virtualItem ? virtualItem.start - globalIndex : undefined; + // Adjust virtual position to create 1px overlap between consecutive selected items in multiselect + const adjustedVirtualPosition = virtualItem ? virtualItem.start - index : undefined; return ( Date: Sat, 22 Nov 2025 18:54:50 +0100 Subject: [PATCH 04/23] feat: add test page for long lists --- pages/select/virtual-scroll.page.tsx | 40 +++++++++++++++------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/pages/select/virtual-scroll.page.tsx b/pages/select/virtual-scroll.page.tsx index c33dd11b7a..cca33ed037 100644 --- a/pages/select/virtual-scroll.page.tsx +++ b/pages/select/virtual-scroll.page.tsx @@ -3,9 +3,10 @@ import React, { useState } from 'react'; +import { SpaceBetween } from '~components'; import Select, { SelectProps } from '~components/select'; -import { SimplePage } from '../app/templates'; +import ScreenshotArea from '../utils/screenshot-area'; const options: SelectProps.Options = Array.from({ length: 1000 }, (_, i) => ({ value: `${i}`, @@ -16,28 +17,31 @@ export default function () { const [selected, setSelected] = useState(null); return ( - -
+

Virtual Scroll

+ + - setSelected(event.detail.selectedOption)} + virtualScroll={true} + expandToViewport={false} + ariaLabel="select demo" + data-testid="select-demo" + /> + + + ); } From 4bef83e20a6ac7c91f092afdaf658eb914488eba Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Sat, 22 Nov 2025 20:18:28 +0100 Subject: [PATCH 05/23] fix: add top divider to option group --- src/internal/components/selectable-item/styles.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index f7a9152c0a..3c721943d6 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -304,6 +304,13 @@ } &.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; } } From 61eb48a20e09473cf3f2c22b8b12dcf39ca735dc Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Mon, 1 Dec 2025 11:09:09 +0000 Subject: [PATCH 06/23] refactor: replace variables with inline calculation --- src/select/parts/virtual-list.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/select/parts/virtual-list.tsx b/src/select/parts/virtual-list.tsx index e55d23c385..0e69861d05 100644 --- a/src/select/parts/virtual-list.tsx +++ b/src/select/parts/virtual-list.tsx @@ -103,10 +103,6 @@ const VirtualListOpen = forwardRef( withScrollbar, }); - // Adjust totalSize to account for 1px overlap per item (matching the position adjustment in renderOptions) - const overlapAdjustment = filteredOptions.length; - const adjustedTotalSize = totalSize - overlapAdjustment; - return ( {finalOptions} @@ -114,7 +110,10 @@ const VirtualListOpen = forwardRef( aria-hidden="true" key="total-size" className={styles['layout-strut']} - style={{ height: adjustedTotalSize - stickySize }} + style={{ + // Adjust totalSize to account for 1px overlap per item (matching the position adjustment in renderOptions) + height: totalSize - filteredOptions.length - stickySize, + }} /> {listBottom ? (
From ab414f04a0598e77f30ce84de6104bb7aa6deb95 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Mon, 1 Dec 2025 15:12:32 +0000 Subject: [PATCH 07/23] fix: calculate border offset for parent of option group --- src/internal/components/selectable-item/styles.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 3c721943d6..1ca8f901dd 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -276,6 +276,7 @@ &.parent.interactiveGroups { padding-block: calc(#{styles.$group-option-padding-vertical} + #{$virtual-border-offset}); + padding-inline: calc(#{styles.$control-padding-horizontal} + #{$virtual-border-offset}); } } From aded760beaed9ec9c40260cd47da46f4af6ab856 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Mon, 1 Dec 2025 17:53:37 +0000 Subject: [PATCH 08/23] fix: apply border fix to sticky elements --- .../components/selectable-item/styles.scss | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 1ca8f901dd..3761dcaf3a 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -198,6 +198,49 @@ // 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) + & ~ .virtual { + // This selector exists just to enable the next rule + } + &:has(~ .virtual) { + $virtual-border-offset: calc(#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}); + + &.highlighted:not(.selected), + &.selected { + 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}); + margin-block-end: calc( + #{awsui.$border-item-width} - #{awsui.$border-divider-list-width} - #{$virtual-border-offset} + ); + } + + &.highlighted:not(.selected) { + 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; + } + } + + &.selected { + box-shadow: inset 0 0 0 $virtual-border-offset awsui.$color-border-dropdown-item-selected; + + &.highlighted { + $virtual-border-offset-double: calc(2 * #{awsui.$border-item-width} - #{awsui.$border-divider-list-width}); + 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; + } + } + } + } + &:not(.highlighted):not(.selected) { // Prevent covering the list border despite the higher z-index border-inline-start-width: awsui.$border-item-width; @@ -314,6 +357,39 @@ &.parent.interactiveGroups:not(.highlighted):not(.selected) { border-block-start-color: awsui.$color-border-dropdown-group; } + + &.sticky { + &.highlighted:not(.selected), + &.selected { + 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}); + } + + &.highlighted:not(.selected) { + 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; + } + } + + &.selected { + 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; + } + } + } + } } } From c0f72aa9904ada76278a5d53bfa1a017da920160 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Mon, 1 Dec 2025 18:02:34 +0000 Subject: [PATCH 09/23] refactor: remove empty css rule --- src/internal/components/selectable-item/styles.scss | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 3761dcaf3a..2aed89e7a6 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -199,9 +199,6 @@ z-index: 4; // Apply fixed border width to sticky items when there are virtual siblings (virtual scroll is enabled) - & ~ .virtual { - // This selector exists just to enable the next rule - } &:has(~ .virtual) { $virtual-border-offset: calc(#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}); From c4b87ee0a80e22a0814749ed4375de5256f5ddcb Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Tue, 2 Dec 2025 10:48:40 +0000 Subject: [PATCH 10/23] feat: add generic virtual scroll page --- pages/select/virtual-scroll.page.tsx | 47 --------- pages/selectable-item/virtual-scroll.page.tsx | 98 +++++++++++++++++++ 2 files changed, 98 insertions(+), 47 deletions(-) delete mode 100644 pages/select/virtual-scroll.page.tsx create mode 100644 pages/selectable-item/virtual-scroll.page.tsx diff --git a/pages/select/virtual-scroll.page.tsx b/pages/select/virtual-scroll.page.tsx deleted file mode 100644 index cca33ed037..0000000000 --- a/pages/select/virtual-scroll.page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import React, { useState } from 'react'; - -import { SpaceBetween } from '~components'; -import Select, { SelectProps } from '~components/select'; - -import ScreenshotArea from '../utils/screenshot-area'; - -const options: SelectProps.Options = Array.from({ length: 1000 }, (_, i) => ({ - value: `${i}`, - label: `Option ${i + 1}`, -})); - -export default function () { - const [selected, setSelected] = useState(null); - - return ( - <> -

Virtual Scroll

- - - - setSelected(event.detail.selectedOption)} + virtualScroll={true} + expandToViewport={false} + ariaLabel="select demo" + data-testid="select-demo" + /> + + {}} + onChange={event => setSelectedMulti(event.detail.selectedOptions)} + tokenLimit={2} + virtualScroll={true} + expandToViewport={false} + ariaLabel="multiselect demo" + data-testid="multiselect-demo" + /> + + setSelectedMultiWithSelectAll(event.detail.selectedOptions)} + enableSelectAll={true} + virtualScroll={true} + expandToViewport={false} + ariaLabel="multiselect with select all demo" + data-testid="multiselect-select-all-demo" + /> + + setAutosuggestValue(event.detail.value)} + enteredTextLabel={value => `Use: "${value}"`} + placeholder="Autosuggest with virtual scroll" + ariaLabel="autosuggest demo" + virtualScroll={true} + expandToViewport={false} + data-testid="autosuggest-demo" + /> + +
+ + ); +} From d664c47084006b33bc8d37fd2777d74ac0b894fa Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Tue, 2 Dec 2025 10:51:14 +0000 Subject: [PATCH 11/23] refactor: make formula more explicit --- src/select/utils/render-options.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/select/utils/render-options.tsx b/src/select/utils/render-options.tsx index d1b5238ada..bb103707ad 100644 --- a/src/select/utils/render-options.tsx +++ b/src/select/utils/render-options.tsx @@ -65,8 +65,8 @@ export const renderOptions = ({ const ListItem = useInteractiveGroups ? MultiselectItem : Item; const isSticky = firstOptionSticky && globalIndex === 0; - // Adjust virtual position to create 1px overlap between consecutive selected items in multiselect - const adjustedVirtualPosition = virtualItem ? virtualItem.start - index : undefined; + // Adjust virtual position to create 1px overlap between consecutive selected items + const adjustedVirtualPosition = virtualItem ? virtualItem.start - index * 1 : undefined; return ( Date: Tue, 2 Dec 2025 11:17:08 +0000 Subject: [PATCH 12/23] refactor: reuse css vars --- .../components/selectable-item/styles.scss | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 2aed89e7a6..00d8d3ad51 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -6,6 +6,13 @@ @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}); + // 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,15 +200,13 @@ 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) { - $virtual-border-offset: calc(#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}); - &.highlighted:not(.selected), &.selected { border-width: awsui.$border-divider-list-width; @@ -293,13 +298,6 @@ border-block-start-color: awsui.$color-border-dropdown-item-top; } - // 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}); - &.highlighted:not(.selected), &.selected { border-width: awsui.$border-divider-list-width; From 17f0a2c8fb02633b9aba83b50f5fbd1fb7fa6a7d Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Tue, 2 Dec 2025 11:18:09 +0000 Subject: [PATCH 13/23] fix: formatting --- src/internal/components/selectable-item/styles.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 00d8d3ad51..4858cb59cc 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -9,7 +9,6 @@ // 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}); From 1f40836410a164570a7fb330a745c3879babdd31 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Tue, 2 Dec 2025 12:08:42 +0000 Subject: [PATCH 14/23] fix: adjust positioning for sticky option --- src/select/parts/virtual-list.tsx | 6 +++++- src/select/utils/render-options.tsx | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/select/parts/virtual-list.tsx b/src/select/parts/virtual-list.tsx index 0e69861d05..e11107a660 100644 --- a/src/select/parts/virtual-list.tsx +++ b/src/select/parts/virtual-list.tsx @@ -112,7 +112,11 @@ const VirtualListOpen = forwardRef( className={styles['layout-strut']} style={{ // Adjust totalSize to account for 1px overlap per item (matching the position adjustment in renderOptions) - height: totalSize - filteredOptions.length - stickySize, + // When firstOptionSticky is enabled, the select-all is shifted down by 1 and other items are shifted up by (index + 1), + // resulting in a different total adjustment than the standard case + height: firstOptionSticky + ? totalSize - filteredOptions.length - stickySize + 2 // Compensate for the different positioning logic with sticky + : totalSize - filteredOptions.length - stickySize, }} /> {listBottom ? ( diff --git a/src/select/utils/render-options.tsx b/src/select/utils/render-options.tsx index bb103707ad..f2e66b42c8 100644 --- a/src/select/utils/render-options.tsx +++ b/src/select/utils/render-options.tsx @@ -66,7 +66,19 @@ export const renderOptions = ({ const isSticky = firstOptionSticky && globalIndex === 0; // Adjust virtual position to create 1px overlap between consecutive selected items - const adjustedVirtualPosition = virtualItem ? virtualItem.start - index * 1 : undefined; + // When firstOptionSticky is enabled (enableSelectAll), the select-all option needs to be shifted down by 1, + // and all subsequent items need to be shifted up by (index + 1) instead of just index + let adjustedVirtualPosition: number | undefined = undefined; + + if (!virtualItem) { + adjustedVirtualPosition = undefined; + } else if (!firstOptionSticky) { + adjustedVirtualPosition = virtualItem.start - index; + } else if (globalIndex === 0) { + adjustedVirtualPosition = virtualItem.start + 1; // Shift select-all down by 1 + } else { + adjustedVirtualPosition = virtualItem.start - (index - 2); // Shift other items up by (index + 2) + } return ( Date: Tue, 2 Dec 2025 12:44:11 +0000 Subject: [PATCH 15/23] refactor: move calculations into into useVirtual --- src/internal/hooks/use-virtual/index.ts | 19 +++++++++++++++++++ src/select/parts/virtual-list.tsx | 12 +++--------- src/select/utils/render-options.tsx | 9 ++++++--- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/internal/hooks/use-virtual/index.ts b/src/internal/hooks/use-virtual/index.ts index ffc3dee761..a5257dc6cb 100644 --- a/src/internal/hooks/use-virtual/index.ts +++ b/src/internal/hooks/use-virtual/index.ts @@ -16,11 +16,13 @@ interface UseVirtualProps { parentRef: React.RefObject; estimateSize: () => number; firstItemSticky?: boolean; + applyItemOffset?: boolean; } interface RowVirtualizer { virtualItems: VirtualItem[]; totalSize: number; + adjustedTotalSize: number; scrollToIndex: (index: number) => void; } @@ -41,6 +43,7 @@ export function useVirtual({ parentRef, estimateSize, firstItemSticky, + applyItemOffset = false, }: UseVirtualProps): RowVirtualizer { const rowVirtualizer = useVirtualDefault({ size: items.length, @@ -74,9 +77,25 @@ export function useVirtual({ [items, rowVirtualizer.virtualItems] ); + const adjustedTotalSize = useMemo(() => { + if (!applyItemOffset) { + return rowVirtualizer.totalSize; + } + + const stickySize = firstItemSticky && virtualItems.length > 0 ? virtualItems[0].size : 0; + + // Adjust totalSize to account for 1px overlap per item (matching the position adjustment in renderOptions: /select/utils/render-options.tsx) + // When firstItemSticky is enabled, the select-all is shifted down by 1 and other items are shifted up by (index + 1), + // resulting in a different total adjustment than the standard case + return firstItemSticky + ? rowVirtualizer.totalSize - items.length - stickySize + 2 // Compensate for the different positioning logic with sticky + : rowVirtualizer.totalSize - items.length - stickySize; + }, [applyItemOffset, firstItemSticky, virtualItems, rowVirtualizer.totalSize, items.length]); + return { virtualItems, totalSize: rowVirtualizer.totalSize, + adjustedTotalSize, scrollToIndex: rowVirtualizer.scrollToIndex, }; } diff --git a/src/select/parts/virtual-list.tsx b/src/select/parts/virtual-list.tsx index e11107a660..007c2a249c 100644 --- a/src/select/parts/virtual-list.tsx +++ b/src/select/parts/virtual-list.tsx @@ -43,7 +43,7 @@ const VirtualListOpen = forwardRef( const menuRefObject = useRef(null); const menuRef = useMergeRefs(menuMeasureRef, menuRefObject, menuProps.ref); const previousHighlightedIndex = useRef(); - const { virtualItems, totalSize, scrollToIndex } = useVirtual({ + const { virtualItems, adjustedTotalSize, scrollToIndex } = useVirtual({ items: filteredOptions, parentRef: menuRefObject, // estimateSize is a dependency of measurements memo. We update it to force full recalculation @@ -53,6 +53,7 @@ const VirtualListOpen = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps estimateSize: useCallback(() => fallbackItemHeight, [width?.inner, filteringValue]), firstItemSticky: firstOptionSticky, + applyItemOffset: true, }); useImperativeHandle( @@ -110,14 +111,7 @@ const VirtualListOpen = forwardRef( aria-hidden="true" key="total-size" className={styles['layout-strut']} - style={{ - // Adjust totalSize to account for 1px overlap per item (matching the position adjustment in renderOptions) - // When firstOptionSticky is enabled, the select-all is shifted down by 1 and other items are shifted up by (index + 1), - // resulting in a different total adjustment than the standard case - height: firstOptionSticky - ? totalSize - filteredOptions.length - stickySize + 2 // Compensate for the different positioning logic with sticky - : totalSize - filteredOptions.length - stickySize, - }} + style={{ height: adjustedTotalSize }} /> {listBottom ? (
diff --git a/src/select/utils/render-options.tsx b/src/select/utils/render-options.tsx index f2e66b42c8..5c47995866 100644 --- a/src/select/utils/render-options.tsx +++ b/src/select/utils/render-options.tsx @@ -73,11 +73,14 @@ export const renderOptions = ({ if (!virtualItem) { adjustedVirtualPosition = undefined; } else if (!firstOptionSticky) { - adjustedVirtualPosition = virtualItem.start - index; + // Shift every item up by one to create a 1px overlap + adjustedVirtualPosition = virtualItem.start - index * 1; } else if (globalIndex === 0) { - adjustedVirtualPosition = virtualItem.start + 1; // Shift select-all down by 1 + // Shift select-all down by one + adjustedVirtualPosition = virtualItem.start + 1; } else { - adjustedVirtualPosition = virtualItem.start - (index - 2); // Shift other items up by (index + 2) + // Shift items down by 2 if first item is sticky + adjustedVirtualPosition = virtualItem.start + 2 - index * 1; } return ( From 08f5f36297eb2702ce28352158e828ff618f408b Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Thu, 4 Dec 2025 11:45:11 +0100 Subject: [PATCH 16/23] feat: switch between select types --- pages/selectable-item/virtual-scroll.page.tsx | 126 ++++++++++-------- 1 file changed, 73 insertions(+), 53 deletions(-) diff --git a/pages/selectable-item/virtual-scroll.page.tsx b/pages/selectable-item/virtual-scroll.page.tsx index 6666f61a03..a8403e1986 100644 --- a/pages/selectable-item/virtual-scroll.page.tsx +++ b/pages/selectable-item/virtual-scroll.page.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; -import { Multiselect, MultiselectProps, SpaceBetween } from '~components'; +import { Multiselect, MultiselectProps, SegmentedControl, SpaceBetween } from '~components'; import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; import Select, { SelectProps } from '~components/select'; @@ -20,13 +20,14 @@ const autosuggestOptions: AutosuggestProps.Options = Array.from({ length: 1000 } })); export default function () { + const [selectedType, setSelectedType] = useState('select'); const [selected, setSelected] = useState(null); const [selectedMulti, setSelectedMulti] = useState(options.slice(0, 2)); const [selectedMultiWithSelectAll, setSelectedMultiWithSelectAll] = useState([]); const [autosuggestValue, setAutosuggestValue] = useState(''); return ( - +
- setSelected(event.detail.selectedOption)} + virtualScroll={true} + expandToViewport={false} + ariaLabel="select demo" + data-testid="select-demo" + /> + )} - setSelectedMultiWithSelectAll(event.detail.selectedOptions)} - enableSelectAll={true} - virtualScroll={true} - expandToViewport={false} - ariaLabel="multiselect with select all demo" - data-testid="multiselect-select-all-demo" - /> + {selectedType === 'multiselect' && ( + {}} + onChange={event => setSelectedMulti(event.detail.selectedOptions)} + tokenLimit={2} + virtualScroll={true} + expandToViewport={false} + ariaLabel="multiselect demo" + data-testid="multiselect-demo" + /> + )} - setAutosuggestValue(event.detail.value)} - enteredTextLabel={value => `Use: "${value}"`} - placeholder="Autosuggest with virtual scroll" - ariaLabel="autosuggest demo" - virtualScroll={true} - expandToViewport={false} - data-testid="autosuggest-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" + /> + )}
From 81919bbf4ead08a9b3b993bbf0663c1a3a1880a3 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Thu, 4 Dec 2025 14:48:33 +0100 Subject: [PATCH 17/23] refactor: allow setting item overlap --- src/internal/hooks/use-virtual/index.ts | 28 ++++++++----------------- src/select/parts/virtual-list.tsx | 11 +++------- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/src/internal/hooks/use-virtual/index.ts b/src/internal/hooks/use-virtual/index.ts index a5257dc6cb..c3d5344bd2 100644 --- a/src/internal/hooks/use-virtual/index.ts +++ b/src/internal/hooks/use-virtual/index.ts @@ -16,13 +16,12 @@ interface UseVirtualProps { parentRef: React.RefObject; estimateSize: () => number; firstItemSticky?: boolean; - applyItemOffset?: boolean; + itemOverlap?: number; } interface RowVirtualizer { virtualItems: VirtualItem[]; totalSize: number; - adjustedTotalSize: number; scrollToIndex: (index: number) => void; } @@ -43,7 +42,7 @@ export function useVirtual({ parentRef, estimateSize, firstItemSticky, - applyItemOffset = false, + itemOverlap = 0, }: UseVirtualProps): RowVirtualizer { const rowVirtualizer = useVirtualDefault({ size: items.length, @@ -77,25 +76,16 @@ export function useVirtual({ [items, rowVirtualizer.virtualItems] ); - const adjustedTotalSize = useMemo(() => { - if (!applyItemOffset) { - return rowVirtualizer.totalSize; - } - - const stickySize = firstItemSticky && virtualItems.length > 0 ? virtualItems[0].size : 0; - - // Adjust totalSize to account for 1px overlap per item (matching the position adjustment in renderOptions: /select/utils/render-options.tsx) - // When firstItemSticky is enabled, the select-all is shifted down by 1 and other items are shifted up by (index + 1), - // resulting in a different total adjustment than the standard case - return firstItemSticky - ? rowVirtualizer.totalSize - items.length - stickySize + 2 // Compensate for the different positioning logic with sticky - : rowVirtualizer.totalSize - items.length - stickySize; - }, [applyItemOffset, firstItemSticky, virtualItems, rowVirtualizer.totalSize, items.length]); + // Adjust totalSize to account for 1px overlap per item (matching the position adjustment in renderOptions: /select/utils/render-options.tsx) + // When firstItemSticky is enabled, the select-all is shifted down by 1 and other items are shifted up by (index + 1), + // resulting in a different total adjustment than the standard case + const firstItemSize = virtualItems[0]?.size ?? 0; + let adjustedTotalSize = rowVirtualizer.totalSize - items.length * itemOverlap; + adjustedTotalSize = firstItemSticky ? adjustedTotalSize - firstItemSize + 2 : adjustedTotalSize; return { virtualItems, - totalSize: rowVirtualizer.totalSize, - adjustedTotalSize, + totalSize: adjustedTotalSize, scrollToIndex: rowVirtualizer.scrollToIndex, }; } diff --git a/src/select/parts/virtual-list.tsx b/src/select/parts/virtual-list.tsx index 007c2a249c..714d81b0d6 100644 --- a/src/select/parts/virtual-list.tsx +++ b/src/select/parts/virtual-list.tsx @@ -43,7 +43,7 @@ const VirtualListOpen = forwardRef( const menuRefObject = useRef(null); const menuRef = useMergeRefs(menuMeasureRef, menuRefObject, menuProps.ref); const previousHighlightedIndex = useRef(); - const { virtualItems, adjustedTotalSize, scrollToIndex } = useVirtual({ + const { virtualItems, totalSize, scrollToIndex } = useVirtual({ items: filteredOptions, parentRef: menuRefObject, // estimateSize is a dependency of measurements memo. We update it to force full recalculation @@ -53,7 +53,7 @@ const VirtualListOpen = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps estimateSize: useCallback(() => fallbackItemHeight, [width?.inner, filteringValue]), firstItemSticky: firstOptionSticky, - applyItemOffset: true, + itemOverlap: 1, // 1px overlap }); useImperativeHandle( @@ -107,12 +107,7 @@ const VirtualListOpen = forwardRef( return ( {finalOptions} -