Skip to content

Commit b743ac0

Browse files
Preserve scroll position for st.selectbox and st.multiselect (#10073)
## Describe your changes When you click on a selectbox (or multiselect), scroll in the dropdown, then close it and click on the selectbox again, the dropdown opens at the top position. This can be annoying if the dropdown is large. With this PR, the dropdown will open at the same position where it was previously closed. Also, when the dropdown opens for the first time, it opens at the position of the selected default value. **Before:** https://github.com/user-attachments/assets/7b361fb1-0e0d-47db-b627-5391455a2228 **After:** https://github.com/user-attachments/assets/69ca577a-d78e-43fb-8a26-11ec56003638 ## GitHub Issue Link (if applicable) Closes #4901 ## Testing Plan - Added a JS unit test on the `Selectbox` component that simulates scrolling, closes the dropdown, and checks that it opens at the same position. - Did not add an e2e test because it would basically do the same thing and that feels a bit overkill. But I can add e2e test for `st.selectbox` and `st.multiselect` if we want to have them. --- **Contribution License Agreement** By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license. --------- Co-authored-by: Johannes Rieke <[email protected]>
1 parent dc758e5 commit b743ac0

File tree

4 files changed

+113
-5
lines changed

4 files changed

+113
-5
lines changed

frontend/lib/src/components/shared/Dropdown/Selectbox.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
WidgetLabel,
3636
} from "@streamlit/lib/src/components/widgets/BaseWidget"
3737
import { EmotionTheme } from "@streamlit/lib/src/theme"
38+
import { convertRemToPx } from "@streamlit/lib/src/theme/utils"
3839

3940
const NO_OPTIONS_MSG = "No options to select."
4041

@@ -91,6 +92,8 @@ const Selectbox: React.FC<Props> = ({
9192
}) => {
9293
const theme: EmotionTheme = useTheme()
9394
const [value, setValue] = useState<number | null>(propValue)
95+
const [scrollPosition, setScrollPosition] = useState(0)
96+
const [hasBeenScrolled, setHasBeenScrolled] = useState(false)
9497

9598
// Update the value whenever the value provided by the props changes
9699
// TODO: Find a better way to handle this to prevent unneeded re-renders
@@ -150,6 +153,21 @@ const Selectbox: React.FC<Props> = ({
150153
// If that's true, we show the keyboard on mobile. If not, we hide it.
151154
const showKeyboardOnMobile = options.length > 10
152155

156+
const getDropdownInitialScrollPosition = useCallback(() => {
157+
// If the dropdown has been manually scrolled before, open it at the position it
158+
// was last scrolled to.
159+
if (hasBeenScrolled) {
160+
return scrollPosition
161+
}
162+
163+
// If the dropdown has not been manually scrolled, open it at the position
164+
// of the selected default value, or at the top if the default value is not set.
165+
if (isNullOrUndefined(value)) {
166+
return 0
167+
}
168+
return value * convertRemToPx(theme.sizes.dropdownItemHeight)
169+
}, [value, hasBeenScrolled, scrollPosition, theme.sizes.dropdownItemHeight])
170+
153171
return (
154172
<div className="stSelectbox" data-testid="stSelectbox" style={{ width }}>
155173
<WidgetLabel
@@ -181,7 +199,18 @@ const Selectbox: React.FC<Props> = ({
181199
lineHeight: theme.lineHeights.inputWidget,
182200
}),
183201
},
184-
Dropdown: { component: VirtualDropdown },
202+
Dropdown: {
203+
component: VirtualDropdown,
204+
props: {
205+
$menuListProps: {
206+
initialScrollOffset: getDropdownInitialScrollPosition(),
207+
onScroll: (offset: number) => {
208+
setHasBeenScrolled(true)
209+
setScrollPosition(offset)
210+
},
211+
},
212+
},
213+
},
185214
ClearIcon: {
186215
props: {
187216
overrides: {

frontend/lib/src/components/shared/Dropdown/VirtualDropdown.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ function FixedSizeListItem(props: FixedSizeListItemProps): ReactElement {
5959
const VirtualDropdown = React.forwardRef<any, any>((props, ref) => {
6060
const theme = useTheme()
6161
const children = React.Children.toArray(props.children) as ReactElement[]
62+
const listRef = React.useRef<FixedSizeList>(null)
63+
64+
// Get initial scroll offset from props
65+
const initialScrollOffset = props.$menuListProps?.initialScrollOffset || 0
6266

6367
if (!children[0] || !children[0].props.item) {
6468
const childrenProps = children[0] ? children[0].props : {}
@@ -112,6 +116,7 @@ const VirtualDropdown = React.forwardRef<any, any>((props, ref) => {
112116
data-testid="stSelectboxVirtualDropdown"
113117
>
114118
<FixedSizeList
119+
ref={listRef}
115120
width="100%"
116121
height={height}
117122
itemCount={children.length}
@@ -120,6 +125,13 @@ const VirtualDropdown = React.forwardRef<any, any>((props, ref) => {
120125
data[index].props.item.value
121126
}
122127
itemSize={convertRemToPx(theme.sizes.dropdownItemHeight)}
128+
initialScrollOffset={initialScrollOffset}
129+
onScroll={({ scrollOffset }) => {
130+
// Pass scroll position back through props
131+
if (props.$menuListProps?.onScroll) {
132+
props.$menuListProps.onScroll(scrollOffset)
133+
}
134+
}}
123135
>
124136
{FixedSizeListItem}
125137
</FixedSizeList>

frontend/lib/src/components/widgets/Multiselect/Multiselect.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import React, { FC, memo, useCallback, useMemo } from "react"
17+
import React, { FC, memo, useCallback, useMemo, useState } from "react"
1818

1919
import { ChevronDown } from "baseui/icon"
2020
import {
@@ -44,6 +44,7 @@ import {
4444
useBasicWidgetState,
4545
ValueWithSource,
4646
} from "@streamlit/lib/src/hooks/useBasicWidgetState"
47+
import { convertRemToPx } from "@streamlit/lib/src/theme/utils"
4748

4849
export interface Props {
4950
disabled: boolean
@@ -208,6 +209,28 @@ const Multiselect: FC<Props> = props => {
208209
// If that's true, we show the keyboard on mobile. If not, we hide it.
209210
const showKeyboardOnMobile = options.length > 10
210211

212+
const [scrollPosition, setScrollPosition] = useState(0)
213+
const [hasBeenScrolled, setHasBeenScrolled] = useState(false)
214+
215+
const getInitialScrollPosition = useCallback(() => {
216+
// If the dropdown has been manually scrolled before, open it at the position it
217+
// was last scrolled to.
218+
if (hasBeenScrolled) {
219+
return scrollPosition
220+
}
221+
222+
// If the dropdown has not been manually scrolled, open it at the position
223+
// of the (last) selected default value, or at the top if the default value is not
224+
// set. Note that multiselect removes selected items from the dropdown, so this will
225+
// actually show the next item in the list.
226+
if (!value || value.length === 0) {
227+
return 0
228+
}
229+
return (
230+
value[value.length - 1] * convertRemToPx(theme.sizes.dropdownItemHeight)
231+
)
232+
}, [value, hasBeenScrolled, scrollPosition, theme.sizes.dropdownItemHeight])
233+
211234
return (
212235
<div className="stMultiSelect" data-testid="stMultiSelect" style={style}>
213236
<WidgetLabel
@@ -387,7 +410,18 @@ const Multiselect: FC<Props> = props => {
387410
: null,
388411
},
389412
},
390-
Dropdown: { component: VirtualDropdown },
413+
Dropdown: {
414+
component: VirtualDropdown,
415+
props: {
416+
$menuListProps: {
417+
initialScrollOffset: getInitialScrollPosition(),
418+
onScroll: (offset: number) => {
419+
setHasBeenScrolled(true)
420+
setScrollPosition(offset)
421+
},
422+
},
423+
},
424+
},
391425
}}
392426
/>
393427
</StyledUISelect>

frontend/lib/src/components/widgets/Selectbox/Selectbox.test.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import React from "react"
1818

1919
import { act, fireEvent, screen } from "@testing-library/react"
20+
import { userEvent } from "@testing-library/user-event"
2021

2122
import { render } from "@streamlit/lib/src/test_util"
2223
import { WidgetStateManager } from "@streamlit/lib/src/WidgetStateManager"
@@ -47,11 +48,11 @@ const getProps = (
4748
})
4849

4950
const pickOption = (selectbox: HTMLElement, value: string): void => {
50-
// TODO: Utilize user-event instead of fireEvent
51+
// TODO: Utilize userEvent instead of fireEvent. This somehow fails with userEvent.
5152
// eslint-disable-next-line testing-library/prefer-user-event
5253
fireEvent.click(selectbox)
5354
const valueElement = screen.getByText(value)
54-
// TODO: Utilize user-event instead of fireEvent
55+
// TODO: Utilize userEvent instead of fireEvent. This somehow fails with userEvent.
5556
// eslint-disable-next-line testing-library/prefer-user-event
5657
fireEvent.click(valueElement)
5758
}
@@ -153,4 +154,36 @@ describe("Selectbox widget", () => {
153154
undefined
154155
)
155156
})
157+
158+
it("maintains scroll position when reopening dropdown", async () => {
159+
const user = userEvent.setup()
160+
const props = getProps({
161+
options: Array.from({ length: 100 }, (_, i) => `Option ${i}`),
162+
})
163+
vi.spyOn(Utils, "convertRemToPx").mockImplementation(mockConvertRemToPx)
164+
165+
render(<Selectbox {...props} />)
166+
const selectbox = screen.getByRole("combobox")
167+
168+
// Open dropdown
169+
await user.click(selectbox)
170+
171+
// Get dropdown content and scroll
172+
const dropdown = screen.getByTestId("stSelectboxVirtualDropdown")
173+
act(() => {
174+
// Simulate scrolling down
175+
const scrollEvent = new Event("scroll", { bubbles: true })
176+
Object.defineProperty(dropdown, "scrollTop", { value: 500 })
177+
dropdown.dispatchEvent(scrollEvent)
178+
})
179+
180+
// Close dropdown
181+
await user.keyboard("{Escape}")
182+
183+
// Reopen dropdown
184+
await user.click(selectbox)
185+
186+
// Check if scroll position was maintained
187+
expect(dropdown.scrollTop).toBe(500)
188+
})
156189
})

0 commit comments

Comments
 (0)