/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/app/src/components/Sidebar/Sidebar.tsx
342 строки
9 KB
Ken McGrady
Fix sidebar collapsing on mobile when clicking portaled elements (#13653)
28 янв 2026, 00:14
Не верифицирован
28 янв 2026, 00:14
94c5a51
Код
Авторство
О чём код?
/** * 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 { ReactElement, useCallback, useContext, useEffect, useRef, useState, } from "react" import { NumberSize, Resizable, ResizeCallback, ResizeDirection, } from "re-resizable" import { LogoComponent } from "@streamlit/app/src/components/Logo" import { shouldShowNavigation, SidebarNav, } from "@streamlit/app/src/components/Navigation" import { StreamlitEndpoints } from "@streamlit/connection" import { BaseButton, BaseButtonKind, DynamicIcon, IsSidebarContext, NavigationContext, SidebarConfigContext, useEmotionTheme, useExecuteWhenChanged, useScrollbarGutterSize, useWindowDimensionsContext, } from "@streamlit/lib" import { localStorageAvailable, notNullOrUndefined } from "@streamlit/utils" import { RESIZE_HANDLE_WIDTH, StyledCollapseSidebarButton, StyledNoLogoSpacer, StyledResizeHandle, StyledSidebar, StyledSidebarContent, StyledSidebarHeaderContainer, StyledSidebarUserContent, } from "./styled-components" import { clampSidebarWidth, DEFAULT_WIDTH } from "./utils" export interface SidebarProps { endpoints: StreamlitEndpoints children?: ReactElement hasElements: boolean isCollapsed: boolean onToggleCollapse: (collapsed: boolean, shouldPersist?: boolean) => void widgetsDisabled: boolean } function calculateMaxBreakpoint(value: string): number { // We subtract a margin of 0.02 to use as a max-width return parseInt(value, 10) - 0.02 } const Sidebar: React.FC<SidebarProps> = ({ endpoints, children, hasElements, isCollapsed, onToggleCollapse, widgetsDisabled, }): ReactElement => { const theme = useEmotionTheme() const mediumBreakpointPx = calculateMaxBreakpoint(theme.breakpoints.md) const { innerWidth } = useWindowDimensionsContext() const { appPages, navSections } = useContext(NavigationContext) const { hideSidebarNav, appLogo, initialSidebarWidth, appRootRef } = useContext(SidebarConfigContext) const scrollbarGutterSize = useScrollbarGutterSize() const sidebarRef = useRef<HTMLDivElement>(null) const cachedSidebarWidth = localStorageAvailable() ? window.localStorage.getItem("sidebarWidth") : undefined const [sidebarWidth, setSidebarWidth] = useState<string>(() => { const getCachedWidth = (): string | null => { if (cachedSidebarWidth) { const cached = Number.parseInt(cachedSidebarWidth, 10) return Number.isNaN(cached) ? null : clampSidebarWidth(cached).toString() } return null } const clampedCached = getCachedWidth() if (clampedCached) { return clampedCached } if (notNullOrUndefined(initialSidebarWidth)) { return clampSidebarWidth(initialSidebarWidth).toString() } return DEFAULT_WIDTH }) const [lastInnerWidth, setLastInnerWidth] = useState<number>( innerWidth ?? Infinity ) // When hovering sidebar header const [showSidebarCollapse, setShowSidebarCollapse] = useState<boolean>(false) const onMouseOver = useCallback(() => { setShowSidebarCollapse(true) }, []) const onMouseOut = useCallback(() => { setShowSidebarCollapse(false) }, []) const initializeSidebarWidth = useCallback((width: number): void => { const clampedWidth = clampSidebarWidth(width) const newWidth = clampedWidth.toString() setSidebarWidth(newWidth) if (localStorageAvailable()) { window.localStorage.setItem("sidebarWidth", newWidth) } }, []) const onResizeStop = useCallback<ResizeCallback>( ( _e: MouseEvent | TouchEvent, _direction: ResizeDirection, ref: HTMLElement, _d: NumberSize ) => { // Use the actual ref width, not the delta, to avoid stale delta values if (ref) { // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage const newWidth = ref.clientWidth || ref.offsetWidth initializeSidebarWidth(newWidth) } }, [initializeSidebarWidth] ) useExecuteWhenChanged(() => { // Collapse the sidebar if the window was narrowed and is now mobile-sized if ( innerWidth < lastInnerWidth && innerWidth <= mediumBreakpointPx && !isCollapsed ) { onToggleCollapse(true, false) } setLastInnerWidth(innerWidth) }, [innerWidth]) useEffect(() => { const handleClickOutside = (event: MouseEvent): void => { const sidebarElement = sidebarRef.current const appRootElement = appRootRef?.current const target = event.target as Node | null if (!sidebarElement || !target) { return } const isInsideSidebar = sidebarElement.contains(target) const isInsideApp = appRootElement?.contains(target) ?? false const isMobileViewport = innerWidth <= mediumBreakpointPx // Only collapse if click is outside the sidebar but inside the main app. // This excludes clicks on portaled elements (dropdowns, modals, etc.) // since they render outside the main app container. const shouldCollapse = !isInsideSidebar && isInsideApp && isMobileViewport && !isCollapsed if (shouldCollapse) { onToggleCollapse(true) } } document.addEventListener("mousedown", handleClickOutside) return () => { document.removeEventListener("mousedown", handleClickOutside) } }, [ mediumBreakpointPx, isCollapsed, onToggleCollapse, innerWidth, appRootRef, ]) function resetSidebarWidth(): void { // Double clicking on the resize handle resets sidebar to initial width or default const resetWidth = notNullOrUndefined(initialSidebarWidth) ? clampSidebarWidth(initialSidebarWidth).toString() : DEFAULT_WIDTH setSidebarWidth(resetWidth) if (localStorageAvailable()) { window.localStorage.setItem("sidebarWidth", resetWidth) } } const toggleCollapse = useCallback(() => { onToggleCollapse(!isCollapsed) }, [isCollapsed, onToggleCollapse]) // Render logo or spacer - using shared LogoComponent const renderLogoContent = (): ReactElement | undefined => { if (!appLogo) { return <StyledNoLogoSpacer data-testid="stLogoSpacer" /> } return ( <LogoComponent appLogo={appLogo} endpoints={endpoints} collapsed={isCollapsed} componentName="Sidebar Logo" dataTestId="stSidebarLogo" /> ) } const hasPageNavAbove = shouldShowNavigation(appPages, navSections) && !hideSidebarNav // The tabindex is required to support scrolling by arrow keys. return ( <Resizable className="stSidebar" data-testid="stSidebar" aria-expanded={!isCollapsed} enable={{ top: false, right: true, bottom: false, left: false, }} handleStyles={{ right: { width: RESIZE_HANDLE_WIDTH, right: "-6px", }, }} handleComponent={{ right: <StyledResizeHandle onDoubleClick={resetSidebarWidth} />, }} size={{ width: sidebarWidth, height: "auto", }} as={StyledSidebar} onResizeStop={onResizeStop} // Props part of StyledSidebar, but not Resizable component // @ts-expect-error isCollapsed={isCollapsed} sidebarWidth={sidebarWidth} windowInnerWidth={innerWidth} > <StyledSidebarContent data-testid="stSidebarContent" ref={sidebarRef} onMouseOver={onMouseOver} onMouseOut={onMouseOut} scrollbarGutterSize={scrollbarGutterSize} > <StyledSidebarHeaderContainer data-testid="stSidebarHeader"> {renderLogoContent()} <StyledCollapseSidebarButton showSidebarCollapse={showSidebarCollapse} data-testid="stSidebarCollapseButton" > <BaseButton kind={BaseButtonKind.HEADER_NO_PADDING} onClick={toggleCollapse} > <DynamicIcon size="xl" iconValue=":material/keyboard_double_arrow_left:" color={theme.colors.fadedText60} /> </BaseButton> </StyledCollapseSidebarButton> </StyledSidebarHeaderContainer> {hasPageNavAbove && ( <SidebarNav endpoints={endpoints} collapseSidebar={toggleCollapse} hasSidebarElements={hasElements} widgetsDisabled={widgetsDisabled} /> )} <StyledSidebarUserContent hasPageNavAbove={hasPageNavAbove} data-testid="stSidebarUserContent" > {children} </StyledSidebarUserContent> </StyledSidebarContent> </Resizable> ) } function SidebarWithProvider(props: SidebarProps): ReactElement { return ( <IsSidebarContext.Provider value={true}> <Sidebar {...props} /> </IsSidebarContext.Provider> ) } export default SidebarWithProvider