diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 0bbac402c9..e926f23e9a 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -6,6 +6,43 @@ @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: 0; + padding-inline: 0; + > .selectable-item-content { + 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 { @@ -199,6 +236,25 @@ // 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) { + @include virtual-border-padding; + margin-block-end: calc( + #{awsui.$border-item-width} - #{awsui.$border-divider-list-width} - #{$virtual-border-offset} + ); + @include virtual-highlighted-shadow; + } + + &.selected { + @include virtual-border-padding; + margin-block-end: calc( + #{awsui.$border-item-width} - #{awsui.$border-divider-list-width} - #{$virtual-border-offset} + ); + @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; @@ -253,6 +309,65 @@ &:first-of-type:not(.selected, .highlighted) { border-block-start-color: awsui.$color-border-dropdown-item-top; } + + &.parent:not(.interactiveGroups) { + border-block-start-width: awsui.$border-divider-list-width; + border-block-start-color: awsui.$color-border-dropdown-group; + padding-block: 0; + padding-inline: 0; + > .selectable-item-content { + 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; + } + + &.highlighted:not(.selected) { + @include virtual-border-padding; + @include virtual-highlighted-shadow; + } + + &.selected { + @include virtual-border-padding; + @include virtual-selected-shadow; + } + + &.selected.child > .selectable-item-content { + padding-inline-start: calc(#{awsui.$space-xxl} + #{$virtual-border-offset}); + } + + &.selected.pad-bottom > .selectable-item-content { + padding-block-end: calc(#{styles.$option-padding-vertical} + #{awsui.$space-xxxs} + #{$virtual-border-offset}); + } + + &.highlighted:not(.selected).child > .selectable-item-content { + padding-inline-start: calc(#{awsui.$space-xxl} + #{$virtual-border-offset}); + } + + &.highlighted:not(.selected).pad-bottom > .selectable-item-content { + padding-block-end: calc(#{styles.$option-padding-vertical} + #{awsui.$space-xxxs} + #{$virtual-border-offset}); + } + + &.parent.interactiveGroups.selected > .selectable-item-content, + &.parent.interactiveGroups.highlighted:not(.selected) > .selectable-item-content { + padding-block: calc(#{styles.$group-option-padding-vertical} + #{$virtual-border-offset}); + padding-inline: calc(#{styles.$control-padding-horizontal} + #{$virtual-border-offset}); + } + + &.sticky { + &.highlighted:not(.selected) { + @include virtual-border-padding; + @include virtual-highlighted-shadow; + } + + &.selected { + @include virtual-border-padding; + @include virtual-selected-shadow; + } + } } } diff --git a/src/internal/hooks/use-virtual/index.ts b/src/internal/hooks/use-virtual/index.ts index ffc3dee761..28fa4c16b7 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,45 @@ 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 - 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 applied itemOverlap. Since adjustedStart + // uses the render index (not virtualItem.index) for overlap calculation, we + // must use the maximum render index to determine the total overlap reduction. + // The last rendered item has render index (virtualItems.length - 1), so the + // total overlap applied is (virtualItems.length - 1) * itemOverlap. + + const firstItemSize = virtualItems[0]?.size ?? 0; + const maxRenderIndex = virtualItems.length - 1; + const totalOverlapReduction = maxRenderIndex >= 0 ? maxRenderIndex * itemOverlap : 0; + + let adjustedTotalSize = rowVirtualizer.totalSize - totalOverlapReduction; + adjustedTotalSize = firstItemSticky ? adjustedTotalSize - firstItemSize : 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 93b611ef9d..eb3458d131 100644 --- a/src/select/parts/virtual-list.tsx +++ b/src/select/parts/virtual-list.tsx @@ -54,6 +54,7 @@ const VirtualListOpen = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps estimateSize: useCallback(() => fallbackItemHeight, [width?.inner, filteringValue]), firstItemSticky: firstOptionSticky, + itemOverlap: 1, }); useImperativeHandle( @@ -108,12 +109,7 @@ const VirtualListOpen = forwardRef( return ( {finalOptions} -