/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/components/widgets/Multiselect/Multiselect.tsx
455 строк
15 KB
kagawa0710
fix(multiselect): preserve scroll position when removing items (#13384)
09 янв 2026, 21:46
Не верифицирован
09 янв 2026, 21:46
0d285cf
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 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" import { without } from "lodash-es" import { MultiSelect as MultiSelectProto } from "@streamlit/protobuf" import IsSidebarContext from "~lib/components/core/IsSidebarContext" import { getBorderColor } from "~lib/components/shared/Base/styled-components" import { VirtualDropdown } from "~lib/components/shared/Dropdown" import { WidgetLabel, WidgetLabelHelpIcon, } from "~lib/components/widgets/BaseWidget" import { StyledUISelect } from "~lib/components/widgets/Multiselect/styled-components" import { useBasicWidgetState, ValueWithSource, } from "~lib/hooks/useBasicWidgetState" import { useEmotionTheme } from "~lib/hooks/useEmotionTheme" import { useSelectCommon } from "~lib/hooks/useSelectCommon" import { labelVisibilityProtoValueToEnum } from "~lib/util/utils" import { WidgetStateManager } from "~lib/WidgetStateManager" export interface Props { disabled: boolean element: MultiSelectProto widgetMgr: WidgetStateManager fragmentId?: string } type MultiselectValue = string[] const getStateFromWidgetMgr = ( widgetMgr: WidgetStateManager, element: MultiSelectProto ): MultiselectValue | undefined => { return widgetMgr.getStringArrayValue(element) } const getDefaultStateFromProto = ( element: MultiSelectProto ): MultiselectValue => { return element.default.map(i => element.options[i]) ?? null } const getCurrStateFromProto = ( element: MultiSelectProto ): MultiselectValue => { return element.rawValues ?? null } const updateWidgetMgrState = ( element: MultiSelectProto, widgetMgr: WidgetStateManager, valueWithSource: ValueWithSource<MultiselectValue>, fragmentId?: string ): void => { widgetMgr.setStringArrayValue( element, valueWithSource.value, { fromUi: valueWithSource.fromUi }, fragmentId ) } const Multiselect: FC<Props> = props => { const { element, widgetMgr, fragmentId } = props const theme = useEmotionTheme() const isInSidebar = useContext(IsSidebarContext) const valueContainerRef = useRef<HTMLDivElement>(null) const scrollTopRef = useRef(0) const [value, setValueWithSource] = useBasicWidgetState< MultiselectValue, MultiSelectProto >({ getStateFromWidgetMgr, getDefaultStateFromProto, getCurrStateFromProto, updateWidgetMgrState, element, widgetMgr, fragmentId, }) const overMaxSelections = element.maxSelections > 0 && value.length >= element.maxSelections const getNoResultsMsg = useMemo(() => { if (element.maxSelections === 0) { return "No results" } else if (value.length === element.maxSelections) { const option = element.maxSelections !== 1 ? "options" : "option" return `You can only select up to ${element.maxSelections} ${option}. Remove an option first.` } return "No results" }, [element.maxSelections, value.length]) const generateNewState = useCallback( (data: OnChangeParams): MultiselectValue => { switch (data.type) { case "remove": { return without(value, data.option?.value) } case "clear": { return [] } case "select": { return value.concat([data.option?.value]) } default: { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`State transition is unknown: ${data.type}`) } } }, [value] ) /** * This is the onChange handler for the baseweb Select component. * It is called whenever the user selects an option or removes an option. * When the user starts to modify an option by typing in the input field and * pressing backspace, a single `type="remove"` event is fired with the value set * to the option that is being removed. The same type of event is fired when the * user removes an option by clicking the X icon. * * If we wanted to prevent an immediate rerun when starting to delete characters, * we would need to introduce two new states, e.g. `localValue` and `aboutToDelete`, * and commit that state to the backend upon an onBlur event. * To keep it simple, we just accept the rerun happening for now. */ const onChange = useCallback( (params: OnChangeParams) => { if ( element.maxSelections && params.type === "select" && value.length >= element.maxSelections ) { return } setValueWithSource({ value: generateNewState(params), fromUi: true, }) }, [element.maxSelections, generateNewState, setValueWithSource, value.length] ) const { options } = element const { placeholder, disabled: shouldDisable, selectOptions, inputReadOnly, valuesToUiMulti, createFilterOptions, } = useSelectCommon({ options, isMulti: true, acceptNewOptions: element.acceptNewOptions ?? false, placeholderInput: element.placeholder, }) const filterOptions = useCallback( (options: readonly Option[], filterValue: string): readonly Option[] => { if (overMaxSelections) { return [] } return createFilterOptions(value)(options, filterValue) }, [createFilterOptions, overMaxSelections, value] ) const disabled = props.disabled || shouldDisable const valueFromState = useMemo( () => valuesToUiMulti(value), [valuesToUiMulti, value] ) // Calculate the max height of the selectbox based on the baseFontSize // to better support advanced theming const maxHeight = useMemo(() => { // Option height = lineHeight (1.6 * baseFontSize) + margin/padding (14px total) const optionHeight = theme.fontSizes.baseFontSize * 1.6 + 14 // Allow up to 4 options tall before scrolling + show small portion // of the next row so its clear the user can scroll const pxMaxHeight = Math.round(optionHeight * 4.25) // Return value in px 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 label={element.label} disabled={disabled} labelVisibility={labelVisibilityProtoValueToEnum( element.labelVisibility?.value )} > {element.help && ( <WidgetLabelHelpIcon content={element.help} label={element.label} /> )} </WidgetLabel> <StyledUISelect> <UISelect creatable={element.acceptNewOptions ?? false} options={selectOptions} labelKey="label" valueKey="value" aria-label={element.label} placeholder={placeholder} type={TYPE.select} multi onChange={onChange} value={valueFromState} disabled={disabled} size={"compact"} noResultsMsg={getNoResultsMsg} filterOptions={filterOptions} closeOnSelect={false} ignoreCase={false} overrides={{ Popover: { props: { ignoreBoundary: isInSidebar, overrides: { Body: { style: () => ({ marginTop: theme.spacing.px, }), }, }, }, }, SelectArrow: { component: ChevronDown, props: { style: { cursor: "pointer", }, overrides: { Svg: { style: () => ({ width: theme.iconSizes.xl, height: theme.iconSizes.xl, }), }, }, }, }, IconsContainer: { style: () => ({ paddingRight: theme.spacing.sm, }), }, ControlContainer: { style: ({ $isFocused }: { $isFocused: boolean }) => { const borderColor = getBorderColor(theme.colors, $isFocused) return { maxHeight: maxHeight, minHeight: theme.sizes.minElementHeight, // Baseweb requires long-hand props, short-hand leads to weird bugs & warnings. borderLeftWidth: theme.sizes.borderWidth, borderRightWidth: theme.sizes.borderWidth, borderTopWidth: theme.sizes.borderWidth, borderBottomWidth: theme.sizes.borderWidth, borderTopColor: borderColor, borderRightColor: borderColor, borderBottomColor: borderColor, borderLeftColor: borderColor, } }, }, Placeholder: { style: () => ({ flex: "inherit", color: disabled ? theme.colors.fadedText40 : theme.colors.fadedText60, }), }, ValueContainer: { component: ValueContainer, style: () => ({ overflowY: "auto", paddingLeft: theme.spacing.sm, paddingTop: theme.spacing.none, paddingBottom: theme.spacing.none, paddingRight: theme.spacing.none, }), }, ClearIcon: { props: { overrides: { Svg: { style: { color: theme.colors.grayTextColor, // setting this width and height makes the clear-icon align with dropdown arrows of other input fields padding: theme.spacing.threeXS, height: theme.sizes.clearIconSize, width: theme.sizes.clearIconSize, cursor: "pointer", ":hover": { fill: theme.colors.bodyText, }, }, }, }, }, }, SearchIcon: { style: { color: theme.colors.grayTextColor, }, }, Tag: { props: { overrides: { Root: { style: { fontWeight: theme.fontWeights.normal, borderTopLeftRadius: theme.radii.md, borderTopRightRadius: theme.radii.md, borderBottomRightRadius: theme.radii.md, borderBottomLeftRadius: theme.radii.md, fontSize: theme.fontSizes.md, paddingLeft: theme.spacing.sm, marginLeft: theme.spacing.none, marginRight: theme.spacing.sm, // The tag height is derived from the minElementHeight // minus a top and bottom padding (2 * spacing.xs) // to nicely fit into the input field. height: `calc(${theme.sizes.minElementHeight} - 2 * ${theme.spacing.xs})`, maxWidth: `calc(100% - ${theme.spacing.lg})`, // Using !important because the alternative would be // uglier: we'd have to put it under a selector like // "&[role="button"]:not(:disabled)" in order to win in // the order of the precedence. cursor: "default !important", }, }, Action: { style: { paddingLeft: 0, }, }, ActionIcon: { props: { overrides: { Svg: { style: { // The action icon should be around 0.625% of the parent font size. width: "0.625em", height: "0.625em", }, }, }, }, }, }, }, }, MultiValue: { props: { overrides: { Root: { style: { fontSize: theme.fontSizes.sm, }, }, }, }, }, Input: { props: { readOnly: inputReadOnly } }, Dropdown: { component: VirtualDropdown }, }} /> </StyledUISelect> </div> ) } export default memo(Multiselect)