Skip to content

Commit 5f6630b

Browse files
committed
fix(ListBox): auto scroll
1 parent 12b2617 commit 5f6630b

File tree

5 files changed

+46
-60
lines changed

5 files changed

+46
-60
lines changed

.changeset/nice-maps-dress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": patch
3+
---
4+
5+
Fix auto-scroll in ListBox with sections.

src/components/actions/CommandMenu/styled.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ export const StyledSearchInput = tasty({
5151
userSelect: 'auto',
5252
height: '($size + 1x)',
5353
padding: {
54-
'': '.5x $inline-padding',
55-
prefix: '0 $inline-padding 0 .5x',
54+
'': '.5x 1.5x',
55+
prefix: '0 1.5x 0 .5x',
5656
},
5757

5858
$size: {
@@ -61,10 +61,6 @@ export const StyledSearchInput = tasty({
6161
'[data-size="medium"]': '$size-md',
6262
'[data-size="large"]': '$size-lg',
6363
},
64-
'$inline-padding':
65-
'max($min-inline-padding, (($size - 1lh) / 2 + $inline-compensation))',
66-
'$inline-compensation': '1x',
67-
'$min-inline-padding': '1x',
6864
},
6965
});
7066

src/components/actions/Menu/styled.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const StyledHeader = tasty(Space, {
7575
placeContent: 'space-between',
7676
placeItems: 'center',
7777
whiteSpace: 'nowrap',
78-
padding: '.5x $inline-padding',
78+
padding: '.5x 1.5x',
7979
height: 'min $size',
8080
boxSizing: 'border-box',
8181
border: 'bottom',
@@ -87,10 +87,6 @@ export const StyledHeader = tasty(Space, {
8787
'[data-size="medium"]': '$size-md',
8888
'[data-size="large"]': '$size-lg',
8989
},
90-
'$inline-padding':
91-
'max($min-inline-padding, (($size - 1lh) / 2 + $inline-compensation))',
92-
'$inline-compensation': '1x',
93-
'$min-inline-padding': '1x',
9490
},
9591
});
9692

@@ -216,8 +212,8 @@ export const StyledSectionHeading = tasty(Space, {
216212
placeContent: 'center space-between',
217213
align: 'start',
218214
padding: {
219-
'': '.5x $inline-padding',
220-
prefix: '0 $inline-padding 0 .5x',
215+
'': '.5x .75x',
216+
prefix: '0 .75x 0 .5x',
221217
},
222218

223219
$size: {
@@ -226,9 +222,5 @@ export const StyledSectionHeading = tasty(Space, {
226222
'[data-size="medium"]': '$size-md',
227223
'[data-size="large"]': '$size-lg',
228224
},
229-
'$inline-padding':
230-
'max($min-inline-padding, (($size - 1lh) / 2 + $inline-compensation - 1bw))',
231-
'$inline-compensation': '.5x',
232-
'$min-inline-padding': '1x',
233225
},
234226
});

src/components/fields/FilterListBox/FilterListBox.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,9 @@ const SearchInputElement = tasty({
9090
...DEFAULT_INPUT_STYLES,
9191
fill: '#clear',
9292
padding: {
93-
'': '.5x $inline-padding',
94-
prefix: '0 $inline-padding 0 .5x',
93+
'': '.5x 1.5x',
94+
prefix: '0 1.5x 0 .5x',
9595
},
96-
'$inline-padding':
97-
'max($min-inline-padding, (($size - 1lh) / 2 + $inline-compensation))',
98-
'$inline-compensation': '1x',
99-
'$min-inline-padding': '1x',
10096
},
10197
});
10298

src/components/fields/ListBox/ListBox.tsx

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -789,46 +789,42 @@ export const ListBox = forwardRef(function ListBox<T extends object>(
789789
}
790790
}, [shouldVirtualize, itemsArray, rowVirtualizer]);
791791

792-
// Keep focused item visible when virtualizing, but only for keyboard navigation
793-
useEffect(() => {
794-
if (!shouldVirtualize) return;
792+
// Keep focused item visible, but only for keyboard navigation
793+
useLayoutEffect(() => {
795794
const focusedKey = listState.selectionManager.focusedKey;
796-
if (focusedKey != null) {
797-
const idx = itemsArrayRef.current.findIndex(
798-
(it) => it.key === focusedKey,
799-
);
800-
if (idx !== -1) {
801-
// Check if the focused item is actually visible in the current viewport
802-
// (not just rendered due to overscan)
803-
const scrollElement = scrollRef.current;
804-
if (scrollElement) {
805-
const scrollTop = scrollElement.scrollTop;
806-
const viewportHeight = scrollElement.clientHeight;
807-
const viewportBottom = scrollTop + viewportHeight;
808-
809-
// Find the virtual item for this index
810-
const virtualItems = rowVirtualizer.getVirtualItems();
811-
const virtualItem = virtualItems.find((item) => item.index === idx);
812-
813-
let isAlreadyVisible = false;
814-
if (virtualItem) {
815-
const itemTop = virtualItem.start;
816-
const itemBottom = virtualItem.start + virtualItem.size;
817-
818-
// Check if the item is fully visible in the viewport
819-
// We should scroll if the item is partially hidden
820-
isAlreadyVisible =
821-
itemTop >= scrollTop && itemBottom <= viewportBottom;
822-
}
795+
if (focusedKey == null) return;
823796

824-
// Only scroll if the item is not already visible AND the focus change was due to keyboard navigation
825-
if (!isAlreadyVisible && lastFocusSourceRef.current === 'keyboard') {
826-
rowVirtualizer.scrollToIndex(idx, { align: 'auto' });
827-
}
828-
}
829-
}
797+
// Only scroll on keyboard navigation
798+
if (lastFocusSourceRef.current !== 'keyboard') return;
799+
800+
const scrollElement = scrollRef.current;
801+
if (!scrollElement) return;
802+
803+
const itemElement = scrollElement.querySelector(
804+
`[data-key="${CSS.escape(String(focusedKey))}"]`,
805+
) as HTMLElement;
806+
if (!itemElement) return;
807+
808+
const scrollTop = scrollElement.scrollTop;
809+
const viewportHeight = scrollElement.clientHeight;
810+
const viewportBottom = scrollTop + viewportHeight;
811+
812+
const itemRect = itemElement.getBoundingClientRect();
813+
const scrollRect = scrollElement.getBoundingClientRect();
814+
815+
// Calculate item position relative to scroll container
816+
const itemTop = itemRect.top - scrollRect.top + scrollTop;
817+
const itemBottom = itemTop + itemRect.height;
818+
819+
// Check if the item is fully visible in the viewport
820+
const isAlreadyVisible =
821+
itemTop >= scrollTop && itemBottom <= viewportBottom;
822+
823+
if (!isAlreadyVisible) {
824+
// Use scrollIntoView with block: 'nearest' to minimize scroll jumps
825+
itemElement.scrollIntoView({ block: 'nearest', behavior: 'auto' });
830826
}
831-
}, [shouldVirtualize, listState.selectionManager.focusedKey, itemsArray]);
827+
}, [listState.selectionManager.focusedKey, itemsArray]);
832828

833829
// Merge React Aria listbox props with custom keyboard props so both sets of
834830
// event handlers (e.g. Arrow navigation *and* our Escape handler) are
@@ -1205,6 +1201,7 @@ function Option({
12051201
ref={combinedRef}
12061202
qa={item.props?.qa}
12071203
id={`ListBoxItem-${String(item.key)}`}
1204+
data-key={String(item.key)}
12081205
{...mergeProps(filteredOptionProps, hoverProps, {
12091206
onClick: handleOptionClick,
12101207
onKeyDown,

0 commit comments

Comments
 (0)