Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
db5e32f
fix: prevent list item jump on hover in virtual scrolling
mxschll Oct 16, 2025
762a8b2
fix: double borders in virtual scroll mode
mxschll Oct 20, 2025
b64be7d
fix: prevent whitespace in virtual scrolling with large lists
mxschll Nov 21, 2025
0c3ba02
feat: add test page for long lists
mxschll Nov 22, 2025
4bef83e
fix: add top divider to option group
mxschll Nov 22, 2025
61eb48a
refactor: replace variables with inline calculation
mxschll Dec 1, 2025
ab414f0
fix: calculate border offset for parent of option group
mxschll Dec 1, 2025
aded760
fix: apply border fix to sticky elements
mxschll Dec 1, 2025
c0f72aa
refactor: remove empty css rule
mxschll Dec 1, 2025
c4b87ee
feat: add generic virtual scroll page
mxschll Dec 2, 2025
d664c47
refactor: make formula more explicit
mxschll Dec 2, 2025
095595a
refactor: reuse css vars
mxschll Dec 2, 2025
17f0a2c
fix: formatting
mxschll Dec 2, 2025
1f40836
fix: adjust positioning for sticky option
mxschll Dec 2, 2025
42f1801
refactor: move calculations into into useVirtual
mxschll Dec 2, 2025
08f5f36
feat: switch between select types
mxschll Dec 4, 2025
81919bb
refactor: allow setting item overlap
mxschll Dec 4, 2025
6284fa8
chore: use virtual offset unit test
mxschll Dec 4, 2025
9661ae0
refactor: extract similar styles into mixins
mxschll Dec 4, 2025
68bbf77
chore: remove redundant comment
mxschll Dec 5, 2025
c2ba79f
chore: rewrite comment
mxschll Dec 5, 2025
8e1497b
feat: add url params to test page
mxschll Dec 5, 2025
3b628c4
refactor: move placement logic into hook
mxschll Dec 5, 2025
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
43 changes: 0 additions & 43 deletions pages/select/virtual-scroll.page.tsx

This file was deleted.

123 changes: 123 additions & 0 deletions pages/selectable-item/virtual-scroll.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this page nested under selectable-item?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're testing multiple selectables (autosuggest, select, multiselect). They all use selectable item, so it seemed like the best place to put it. What do you think?

// SPDX-License-Identifier: Apache-2.0

import React, { useContext, useState } from 'react';

import { Multiselect, MultiselectProps, SegmentedControl, SpaceBetween } from '~components';
import Autosuggest, { AutosuggestProps } from '~components/autosuggest';
import Select, { SelectProps } from '~components/select';

import AppContext, { AppContextType } from '../app/app-context';
import { SimplePage } from '../app/templates';

type DemoContext = React.Context<AppContextType<{ type?: string }>>;

const options: SelectProps.Options = Array.from({ length: 1000 }, (_, i) => ({
value: `${i}`,
label: `Option ${i + 1}`,
}));

const autosuggestOptions: AutosuggestProps.Options = Array.from({ length: 1000 }, (_, i) => ({
value: `Option ${i + 1}`,
description: `Description for option ${i + 1}`,
}));

export default function () {
const { urlParams } = useContext(AppContext as DemoContext);
const [selectedType, setSelectedType] = useState(urlParams.type || 'select');

const [selected, setSelected] = useState<SelectProps['selectedOption']>(null);
const [selectedMulti, setSelectedMulti] = useState<MultiselectProps.Options>(options.slice(0, 2));
const [selectedMultiWithSelectAll, setSelectedMultiWithSelectAll] = useState<MultiselectProps.Options>([]);
const [autosuggestValue, setAutosuggestValue] = useState('');

return (
<SimplePage title="Virtual Scroll" i18n={{}} screenshotArea={{}}>
<div
style={{
height: 500,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this height is no longer enough as there are more components now. I believe that for this to actually work as a screenshot testing page, these components probably need to be rendered one at a time, with some configuration (e.g. a segmented control above them)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The segmented control looks nice, but the selection state must use query parameters (see how it is done in other pages). This allows to avoid unnecessary steps in integration tests (instead one can open the page as e.g. /light/virtual-scroll?component=autosuggest.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, I would've clicked on the buttons. But that is way more convenient 👍

padding: 10,
// Prevents dropdown from expanding outside of the screenshot area
overflow: 'auto',
}}
>
<SpaceBetween size="m">
<SegmentedControl
selectedId={selectedType}
onChange={({ detail }) => setSelectedType(detail.selectedId)}
options={[
{ id: 'select', text: 'Select' },
{ id: 'multiselect', text: 'Multiselect' },
{ id: 'multiselect-select-all', text: 'Multiselect with Select All' },
{ id: 'autosuggest', text: 'Autosuggest' },
]}
/>

{selectedType === 'select' && (
<Select
placeholder="Select with virtual scroll"
selectedOption={selected}
options={options}
filteringType="auto"
finishedText="End of all results"
onChange={event => setSelected(event.detail.selectedOption)}
virtualScroll={true}
expandToViewport={false}
ariaLabel="select demo"
data-testid="select-demo"
/>
)}

{selectedType === 'multiselect' && (
<Multiselect
placeholder="Multiselect with virtual scroll"
selectedOptions={selectedMulti}
options={options}
filteringType="manual"
finishedText="End of all results"
errorText="verylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspacesverylongtextwithoutspaces"
recoveryText="Retry"
onLoadItems={() => {}}
onChange={event => setSelectedMulti(event.detail.selectedOptions)}
tokenLimit={2}
virtualScroll={true}
expandToViewport={false}
ariaLabel="multiselect demo"
data-testid="multiselect-demo"
/>
)}

{selectedType === 'multiselect-select-all' && (
<Multiselect
placeholder="Multiselect with virtual scroll and select all"
selectedOptions={selectedMultiWithSelectAll}
options={options}
filteringType="auto"
finishedText="End of all results"
onChange={event => setSelectedMultiWithSelectAll(event.detail.selectedOptions)}
enableSelectAll={true}
virtualScroll={true}
expandToViewport={false}
ariaLabel="multiselect with select all demo"
data-testid="multiselect-select-all-demo"
/>
)}

{selectedType === 'autosuggest' && (
<Autosuggest
value={autosuggestValue}
options={autosuggestOptions}
onChange={event => setAutosuggestValue(event.detail.value)}
enteredTextLabel={value => `Use: "${value}"`}
placeholder="Autosuggest with virtual scroll"
ariaLabel="autosuggest demo"
virtualScroll={true}
expandToViewport={false}
data-testid="autosuggest-demo"
/>
)}
</SpaceBetween>
</div>
</SimplePage>
);
}
106 changes: 105 additions & 1 deletion src/internal/components/selectable-item/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
}

Expand Down
Loading
Loading