/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/app/src/components/MainMenu/MainMenu.tsx
507 строк
15 KB
Ken McGrady
Update copyright header to 2026 (#13491)
02 янв 2026, 16:21
Не верифицирован
02 янв 2026, 16:21
374540a
Код
Авторство
О чём код?
/** * 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 { forwardRef, memo, MouseEvent, ReactElement } from "react" import { MoreVert } from "@emotion-icons/material-rounded" import { StatefulMenu } from "baseui/menu" import { PLACEMENT, StatefulPopover } from "baseui/popover" import { MetricsManager } from "@streamlit/app/src/MetricsManager" import ScreenCastRecorder from "@streamlit/app/src/util/ScreenCastRecorder" import { BaseButton, BaseButtonKind, convertRemToPx, EmotionTheme, Icon, IGuestToHostMessage, IMenuItem, useEmotionTheme, } from "@streamlit/lib" import { Config, PageConfig } from "@streamlit/protobuf" import { notNullOrUndefined } from "@streamlit/utils" import { StyledCoreItem, StyledDevItem, StyledMainMenuContainer, StyledMenuContainer, StyledMenuDivider, StyledMenuItem, StyledMenuItemLabel, StyledMenuItemShortcut, StyledRecordingIndicator, } from "./styled-components" const SCREENCAST_LABEL: { [s: string]: string } = { COUNTDOWN: "Cancel screencast", RECORDING: "Stop recording", } export interface Props { /** True if we're connected to the Streamlit server. */ isServerConnected: boolean /** Rerun the current script. */ quickRerunCallback: () => void /** Clear the cache. */ clearCacheCallback: () => void /** Show the screen recording dialog. */ screencastCallback: () => void /** Show the Settings dialog. */ settingsCallback: () => void /** Show the About dialog. */ aboutCallback: () => void /** Open the Print Dialog, if the app is in iFrame first open a new tab with app URL */ printCallback: () => void screenCastState: string hostMenuItems: IMenuItem[] sendMessageToHost: (message: IGuestToHostMessage) => void menuItems?: PageConfig.IMenuItems | null developmentMode: boolean toolbarMode: Config.ToolbarMode metricsMgr: MetricsManager } const getOpenInWindowCallback = (url: string) => (): void => { window.open(url, "_blank") } export interface MenuItemProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. item: any "aria-selected": boolean onClick: (e: MouseEvent<HTMLLIElement>) => void onMouseEnter: (e: MouseEvent<HTMLLIElement>) => void $disabled: boolean $isHighlighted: boolean } export interface SubMenuProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. menuItems: any[] closeMenu: () => void isDevMenu: boolean metricsMgr: MetricsManager } // BaseWeb provides a very basic list item (or option) for its dropdown // menus. We want to customize it to our liking. We want to support: // * Shortcuts // * Red coloring for the stop recording // * Dividers (There's no special MenuListItem divider, so items have // a hasDividerAbove property to add the border properly. // Unfortunately, because we are overriding the component, we need to // implement some of the built in-features, namely: // * A11y for selected and disabled // * $disabled field (BaseWeb does not use CSS :disabled here) // * $isHighlighted field (BaseWeb does not use CSS :hover here) // * creating a forward ref to add properties to the DOM element. function buildMenuItemComponent( StyledMenuItemType: typeof StyledCoreItem, metricsMgr: MetricsManager // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. ): any { const MenuItem = forwardRef<HTMLLIElement, MenuItemProps>( ( { item, "aria-selected": ariaSelected, onClick, onMouseEnter, $disabled, $isHighlighted, }, ref ) => { const { label, shortcut, hasDividerAbove, styleProps, noHighlight, interactions, } = item const itemProps = { isDisabled: $disabled, isRecording: Boolean(item.stopRecordingIndicator), } const itemStyleProps = { isHighlighted: !noHighlight && $isHighlighted, styleProps, } const interactiveProps = interactions || ($disabled ? {} : { onClick: (e: MouseEvent<HTMLLIElement>) => { metricsMgr.enqueue("menuClick", { label, }) onClick(e) }, onMouseEnter, }) return ( <> {hasDividerAbove && ( <StyledMenuDivider data-testid="stMainMenuDivider" /> )} <StyledMenuItem ref={ref} role="option" aria-selected={ariaSelected} aria-disabled={$disabled} {...itemProps} {...interactiveProps} > <StyledMenuItemType {...itemStyleProps}> <StyledMenuItemLabel {...itemProps}>{label}</StyledMenuItemLabel> {shortcut && ( <StyledMenuItemShortcut {...itemProps}> {shortcut} </StyledMenuItemShortcut> )} </StyledMenuItemType> </StyledMenuItem> </> ) } ) MenuItem.displayName = "MenuItem" return MenuItem } const SubMenu = (props: SubMenuProps): ReactElement => { const { colors, sizes, spacing } = useEmotionTheme() const StyledMenuItemType = props.isDevMenu ? StyledDevItem : StyledCoreItem return ( <StatefulMenu items={props.menuItems} onItemSelect={({ item }) => { item.onClick() props.closeMenu() }} overrides={{ Option: buildMenuItemComponent(StyledMenuItemType, props.metricsMgr), List: { props: { "data-testid": "stMainMenuList", }, style: { backgroundColor: "inherit", borderBottomRadius: 0, borderTopRadius: 0, borderLeftRadius: 0, borderRightRadius: 0, paddingBottom: spacing.sm, paddingTop: spacing.sm, ":focus": { outline: "none", }, border: `${sizes.borderWidth} solid ${colors.borderColor}`, }, }, }} /> ) } function getDevMenuItems( theme: EmotionTheme, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. coreDevMenuItems: Record<string, any> // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. ): any[] { const devMenuItems = [] // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. const preferredDevMenuOrder: any[] = [ coreDevMenuItems.developerOptions, coreDevMenuItems.clearCache, ] let devLastMenuItem = null for (const devMenuItem of preferredDevMenuOrder) { if (devMenuItem) { if (devMenuItem !== coreDevMenuItems.DIVIDER) { if (devLastMenuItem === coreDevMenuItems.DIVIDER) { devMenuItems.push({ ...devMenuItem, hasDividerAbove: true }) } else { devMenuItems.push(devMenuItem) } } devLastMenuItem = devMenuItem } } if (notNullOrUndefined(devLastMenuItem)) { devLastMenuItem.styleProps = { margin: `0 0 -${theme.spacing.sm} 0`, padding: `${theme.spacing.twoXS} ${theme.spacing.none} ${theme.spacing.twoXS} ${theme.spacing.twoXL}`, } } return devMenuItems } function getPreferredMenuOrder( props: Props, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. hostMenuItems: any[], // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. coreMenuItems: Record<string, any> // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. ): any[] { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. let preferredMenuOrder: any[] if (props.toolbarMode == Config.ToolbarMode.MINIMAL) { // If toolbar mode == minimal then show only host menu items if any. preferredMenuOrder = [ coreMenuItems.report, coreMenuItems.community, coreMenuItems.DIVIDER, ...(hostMenuItems.length > 0 ? hostMenuItems : [coreMenuItems.DIVIDER]), coreMenuItems.about, ] preferredMenuOrder = preferredMenuOrder.filter(d => d) // If the first or last item is a divider, delete it, because // we don't want to start/end the menu with it. // TODO(sfc-gh-kbregula): We should use Array#at when supported by // browsers or transpilers. // See: https://github.com/tc39/proposal-relative-indexing-method while ( preferredMenuOrder.length > 0 && preferredMenuOrder[0] == coreMenuItems.DIVIDER ) { preferredMenuOrder.shift() } while ( preferredMenuOrder.length > 0 && preferredMenuOrder.at(preferredMenuOrder.length - 1) == coreMenuItems.DIVIDER ) { preferredMenuOrder.pop() } return preferredMenuOrder } return [ coreMenuItems.rerun, coreMenuItems.settings, coreMenuItems.DIVIDER, coreMenuItems.print, ...(ScreenCastRecorder.isSupportedBrowser() ? [coreMenuItems.recordScreencast] : []), coreMenuItems.DIVIDER, coreMenuItems.report, coreMenuItems.community, ...(hostMenuItems.length > 0 ? hostMenuItems : [coreMenuItems.DIVIDER]), coreMenuItems.about, ] } function MainMenu(props: Readonly<Props>): ReactElement { const theme = useEmotionTheme() const isServerDisconnected = !props.isServerConnected const coreMenuItems = { DIVIDER: { isDivider: true }, rerun: { disabled: isServerDisconnected, onClick: props.quickRerunCallback, label: "Rerun", shortcut: "r", }, print: { onClick: props.printCallback, label: "Print" }, recordScreencast: { onClick: props.screencastCallback, label: SCREENCAST_LABEL[props.screenCastState] || "Record a screencast", shortcut: SCREENCAST_LABEL[props.screenCastState] ? "esc" : "", stopRecordingIndicator: Boolean(SCREENCAST_LABEL[props.screenCastState]), }, saveSnapshot: { disabled: isServerDisconnected, label: "Save a snapshot", }, ...(!props.menuItems?.hideGetHelp && props.menuItems?.getHelpUrl && { community: { onClick: getOpenInWindowCallback(props.menuItems?.getHelpUrl), label: "Get help", }, }), ...(!props.menuItems?.hideReportABug && props.menuItems?.reportABugUrl && { report: { onClick: getOpenInWindowCallback(props.menuItems?.reportABugUrl), label: "Report a bug", }, }), settings: { onClick: props.settingsCallback, label: "Settings" }, ...(props.menuItems?.aboutSectionMd && { about: { onClick: props.aboutCallback, label: "About" }, }), } const coreDevMenuItems = { DIVIDER: { isDivider: true }, developerOptions: { label: "Developer options", noHighlight: true, interactions: {}, styleProps: { fontSize: convertRemToPx(theme.fontSizes.twoSm), margin: `-${theme.spacing.sm} 0 0 0`, padding: `${theme.spacing.twoXS} ${theme.spacing.none} ${theme.spacing.twoXS} ${theme.spacing.twoXL}`, pointerEvents: "none", }, }, clearCache: { disabled: isServerDisconnected, onClick: props.clearCacheCallback, label: "Clear cache", shortcut: "c", }, } const hostMenuItems = props.hostMenuItems.map(item => { if (item.type === "separator") { return coreMenuItems.DIVIDER } if (item.key === "reportBug" && props.menuItems?.hideGetHelp) { return null } if (item.key === "about" && props.menuItems?.aboutSectionMd !== "") { return null } return { onClick: () => props.sendMessageToHost({ type: "MENU_ITEM_CALLBACK", key: item.key, }), label: item.label, } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. }, [] as any[]) const preferredMenuOrder = getPreferredMenuOrder( props, hostMenuItems, coreMenuItems ) // Remove empty entries, and add dividers into menu options as needed. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. const menuItems: any[] = [] let lastMenuItem = null for (const menuItem of preferredMenuOrder) { if (menuItem) { if (menuItem !== coreMenuItems.DIVIDER) { if (lastMenuItem === coreMenuItems.DIVIDER) { menuItems.push({ ...menuItem, hasDividerAbove: true }) } else { menuItems.push(menuItem) } } lastMenuItem = menuItem } } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. const devMenuItems: any[] = props.developmentMode ? getDevMenuItems(theme, coreDevMenuItems) : [] if (menuItems.length == 0 && devMenuItems.length == 0) { // Don't show an empty menu. return <></> } return ( <StatefulPopover focusLock placement={PLACEMENT.bottomRight} content={({ close }) => ( <StyledMenuContainer> {menuItems.length != 0 && ( <SubMenu menuItems={menuItems} closeMenu={close} isDevMenu={false} metricsMgr={props.metricsMgr} /> )} {devMenuItems.length != 0 && ( <SubMenu menuItems={devMenuItems} closeMenu={close} isDevMenu={true} metricsMgr={props.metricsMgr} /> )} </StyledMenuContainer> )} overrides={{ Body: { props: { "data-testid": "stMainMenuPopover", className: "stMainMenuPopover", }, }, }} > <StyledMainMenuContainer id="MainMenu" className="stMainMenu" data-testid="stMainMenu" > <BaseButton kind={BaseButtonKind.HEADER_NO_PADDING}> <Icon content={MoreVert} size="lg" /> </BaseButton> {props.screenCastState === "RECORDING" && <StyledRecordingIndicator />} </StyledMainMenuContainer> </StatefulPopover> ) } export default memo(MainMenu)