diff --git a/e2e_playwright/st_multiselect_test.py b/e2e_playwright/st_multiselect_test.py index 6a8fc68692b..8d9c9de5e4c 100644 --- a/e2e_playwright/st_multiselect_test.py +++ b/e2e_playwright/st_multiselect_test.py @@ -476,3 +476,28 @@ def test_multiselect_empty_options_disabled_when_no_accept_new(app: Page): # Verify the widget value remains empty expect_text(app, "value 3: []") + + +def test_multiselect_preserves_scroll_position_on_remove(app: Page): + """Should preserve scroll position when removing an item from the multiselect.""" + multiselect_elem = get_multiselect(app, "multiselect 17 - show maxHeight") + + # Get the value container (scrollable area) + value_container = multiselect_elem.locator( + '[data-baseweb="select"] > div > div:first-child' + ) + + # Scroll to middle of the value container (not bottom, to avoid clamping issues + # when items are removed and scrollHeight decreases) + value_container.evaluate("el => { el.scrollTop = el.scrollHeight / 2; }") + + # Get initial scroll position (should be > 0 since there are many items) + initial_scroll = value_container.evaluate("el => el.scrollTop") + assert initial_scroll > 0 + + # Remove an item by clicking its delete button + del_from_multiselect(app, "multiselect 17 - show maxHeight", "fifteen") + + # Verify scroll position is preserved + final_scroll = value_container.evaluate("el => el.scrollTop") + assert final_scroll == initial_scroll diff --git a/frontend/lib/src/components/widgets/Multiselect/Multiselect.test.tsx b/frontend/lib/src/components/widgets/Multiselect/Multiselect.test.tsx index caf996a828e..739f7024d10 100644 --- a/frontend/lib/src/components/widgets/Multiselect/Multiselect.test.tsx +++ b/frontend/lib/src/components/widgets/Multiselect/Multiselect.test.tsx @@ -508,6 +508,40 @@ describe("Multiselect widget", () => { expect(options[2]).toHaveTextContent("aA") }) + describe("scroll position preservation", () => { + it("preserves scroll position when removing an item", async () => { + const user = userEvent.setup() + const options = Array.from({ length: 20 }, (_, i) => `Option ${i + 1}`) + const props = getProps({ + default: options.map((_, i) => i), + options, + }) + render() + + const multiselect = screen.getByTestId("stMultiSelect") + const valueContainer = multiselect.querySelector( + '[data-baseweb="select"] > div > div:first-child' + ) + + expect(valueContainer).not.toBeNull() + if (valueContainer === null) { + return + } + + Object.defineProperty(valueContainer, "scrollTop", { + writable: true, + configurable: true, + value: 100, + }) + valueContainer.dispatchEvent(new Event("scroll", { bubbles: true })) + + const deleteButtons = screen.getAllByTitle("Delete") + await user.click(deleteButtons[5]) + + expect(valueContainer.scrollTop).toBe(100) + }) + }) + describe("on mobile", () => { beforeEach(() => { vi.spyOn(MobileUtil, "isMobile").mockReturnValue(true) diff --git a/frontend/lib/src/components/widgets/Multiselect/Multiselect.tsx b/frontend/lib/src/components/widgets/Multiselect/Multiselect.tsx index 4d9968f734a..f005a3c5a5b 100644 --- a/frontend/lib/src/components/widgets/Multiselect/Multiselect.tsx +++ b/frontend/lib/src/components/widgets/Multiselect/Multiselect.tsx @@ -14,12 +14,22 @@ * limitations under the License. */ -import { FC, memo, useCallback, useContext, useMemo } from "react" +import { + FC, + memo, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, +} from "react" import { ChevronDown } from "baseui/icon" import { type OnChangeParams, type Option, + type SharedStylePropsArg, + StyledValueContainer, TYPE, Select as UISelect, } from "baseui/select" @@ -91,6 +101,8 @@ const Multiselect: FC = props => { const theme = useEmotionTheme() const isInSidebar = useContext(IsSidebarContext) + const valueContainerRef = useRef(null) + const scrollTopRef = useRef(0) const [value, setValueWithSource] = useBasicWidgetState< MultiselectValue, MultiSelectProto @@ -212,6 +224,40 @@ const Multiselect: FC = props => { return `${pxMaxHeight}px` }, [theme.fontSizes.baseFontSize]) + // Runs every render to capture BaseWeb's internal DOM updates that can reset scroll position. + // Performance is acceptable since this is a leaf component with no children to re-render. + useLayoutEffect(() => { + if (valueContainerRef.current) { + valueContainerRef.current.scrollTop = scrollTopRef.current + } + }) + + const handleValueContainerScroll = useCallback( + (e: React.UIEvent) => { + // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Safe: layout already computed during scroll event + scrollTopRef.current = e.currentTarget.scrollTop + }, + [] + ) + + // Memoized to prevent BaseWeb from remounting on every render + const ValueContainer = useMemo( + () => + // eslint-disable-next-line @eslint-react/no-nested-component-definitions -- Required for baseweb component override with refs + function ValueContainer( + props: SharedStylePropsArg & { children: React.ReactNode } + ): React.ReactElement { + return ( + + ) + }, + [handleValueContainerScroll] + ) + return (
= props => { }), }, ValueContainer: { + component: ValueContainer, style: () => ({ overflowY: "auto", paddingLeft: theme.spacing.sm,