Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions e2e_playwright/st_multiselect_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Multiselect {...props} />)

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)
Expand Down
49 changes: 48 additions & 1 deletion frontend/lib/src/components/widgets/Multiselect/Multiselect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -91,6 +101,8 @@ const Multiselect: FC<Props> = props => {

const theme = useEmotionTheme()
const isInSidebar = useContext(IsSidebarContext)
const valueContainerRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef(0)
const [value, setValueWithSource] = useBasicWidgetState<
MultiselectValue,
MultiSelectProto
Expand Down Expand Up @@ -212,6 +224,40 @@ const Multiselect: FC<Props> = 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<HTMLDivElement>) => {
// 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 (
<StyledValueContainer
{...props}
ref={valueContainerRef}
onScroll={handleValueContainerScroll}
/>
)
},
[handleValueContainerScroll]
)

return (
<div className="stMultiSelect" data-testid="stMultiSelect">
<WidgetLabel
Expand Down Expand Up @@ -306,6 +352,7 @@ const Multiselect: FC<Props> = props => {
}),
},
ValueContainer: {
component: ValueContainer,
style: () => ({
overflowY: "auto",
paddingLeft: theme.spacing.sm,
Expand Down
Loading