From 8d5858bab4de4279387c47e977285deb452e2848 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 13 Jan 2026 01:14:23 +0100 Subject: [PATCH] Menu: Support nested items (#6522) By @kotevcode --- src/assets/localization/fallback.strings | 1 + src/components/common/MainMenuDropdown.tsx | 2 +- .../left/main/LeftSideMenuItems.tsx | 126 +++++----- src/components/ui/Menu.scss | 5 +- src/components/ui/Menu.tsx | 10 +- src/components/ui/MenuItem.scss | 4 + src/components/ui/NestedMenuItem.tsx | 225 ++++++++++++++++++ src/hooks/useMenuPosition.ts | 7 +- src/types/index.ts | 2 + src/types/language.d.ts | 1 + 10 files changed, 320 insertions(+), 63 deletions(-) create mode 100644 src/components/ui/NestedMenuItem.tsx diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 741f16f8c..76b0e8996 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1446,6 +1446,7 @@ "MenuArchivedChats" = "Archived Chats"; "MenuContacts" = "Contacts"; "MenuSettings" = "Settings"; +"MenuMore" = "More"; "MenuNightMode" = "Night Mode"; "AriaMenuEnableNightMode" = "Enable night mode"; "AriaMenuDisableNightMode" = "Disable night mode"; diff --git a/src/components/common/MainMenuDropdown.tsx b/src/components/common/MainMenuDropdown.tsx index 68a4fc35d..cc5088ab5 100644 --- a/src/components/common/MainMenuDropdown.tsx +++ b/src/components/common/MainMenuDropdown.tsx @@ -57,7 +57,6 @@ const LeftSideMenuDropdown = ({ return ( ); diff --git a/src/components/left/main/LeftSideMenuItems.tsx b/src/components/left/main/LeftSideMenuItems.tsx index 61fcc467f..c56734843 100644 --- a/src/components/left/main/LeftSideMenuItems.tsx +++ b/src/components/left/main/LeftSideMenuItems.tsx @@ -37,6 +37,7 @@ import useLastCallback from '../../../hooks/useLastCallback'; import AttachBotItem from '../../middle/composer/AttachBotItem'; import MenuItem from '../../ui/MenuItem'; import MenuSeparator from '../../ui/MenuSeparator'; +import NestedMenuItem from '../../ui/NestedMenuItem'; import Switcher from '../../ui/Switcher'; import Toggle from '../../ui/Toggle'; import AccountMenuItems from './AccountMenuItems'; @@ -47,6 +48,7 @@ type OwnProps = { onSelectArchived: NoneToVoidFunction; onBotMenuOpened: NoneToVoidFunction; onBotMenuClosed: NoneToVoidFunction; + footer?: string; }; type StateProps = { @@ -72,6 +74,7 @@ const LeftSideMenuItems = ({ onSelectSettings, onBotMenuOpened, onBotMenuClosed, + footer, }: OwnProps & StateProps) => { const { openChat, @@ -196,63 +199,74 @@ const LeftSideMenuItems = ({ > {lang('MenuSettings')} - + + {lang('MenuNightMode')} + + + + {lang('MenuAnimationsSwitch')} + + + + + {lang('MenuTelegramFeatures')} + + + {lang('MenuReportBug')} + + {IS_BETA && ( + + {lang('MenuBetaChangelog')} + + )} + {withOtherVersions && ( + + {lang('MenuSwitchToK')} + + )} + {canInstall && ( + + {lang('MenuInstallApp')} + + )} + + )} > - {lang('MenuNightMode')} - - - - {lang('MenuAnimationsSwitch')} - - - - {lang('MenuTelegramFeatures')} - - - {lang('MenuReportBug')} - - {IS_BETA && ( - - {lang('MenuBetaChangelog')} - - )} - {withOtherVersions && ( - - {lang('MenuSwitchToK')} - - )} - {canInstall && ( - - {lang('MenuInstallApp')} - - )} + {lang('MenuMore')} + ); }; diff --git a/src/components/ui/Menu.scss b/src/components/ui/Menu.scss index e9411d5cd..d82cd2c2f 100644 --- a/src/components/ui/Menu.scss +++ b/src/components/ui/Menu.scss @@ -87,9 +87,10 @@ } .footer { - padding: 0.5rem 0; + padding: 0.25rem 0.5rem; - font-size: 0.8125rem; + font-size: 0.75rem; + line-height: 1.25; color: var(--color-text-secondary); text-align: center; diff --git a/src/components/ui/Menu.tsx b/src/components/ui/Menu.tsx index e186bea2a..0b850f319 100644 --- a/src/components/ui/Menu.tsx +++ b/src/components/ui/Menu.tsx @@ -49,6 +49,7 @@ type OwnProps = onMouseEnterBackdrop?: (e: React.MouseEvent) => void; onMouseLeave?: (e: React.MouseEvent) => void; withPortal?: boolean; + nested?: boolean; children?: React.ReactNode; } & MenuPositionOptions; @@ -75,6 +76,7 @@ const Menu: FC = ({ onMouseLeave, withPortal, onMouseEnterBackdrop, + nested, ...positionOptions }) => { const { isTouchScreen } = useAppLayout(); @@ -108,12 +110,16 @@ const Menu: FC = ({ const handleKeyDown = useKeyboardListNavigation(bubbleRef, isOpen, autoClose ? onClose : undefined, undefined, true); + const fullExcludedSelector = backdropExcludedSelector + ? `${backdropExcludedSelector}, .submenu` + : '.submenu'; + useVirtualBackdrop( isOpen, containerRef, noCloseOnBackdrop ? undefined : onClose, undefined, - backdropExcludedSelector, + fullExcludedSelector, ); const bubbleFullClassName = buildClassName( @@ -147,7 +153,7 @@ const Menu: FC = ({ onMouseEnter={onMouseEnter} onMouseLeave={isOpen ? onMouseLeave : undefined} > - {isOpen && ( + {isOpen && !nested && ( // This only prevents click events triggering on underlying elements
{ + const lang = useLang(); + + const itemRef = useRef(); + const closeTimeoutRef = useRef(); + const openTimeoutRef = useRef(); + const submenuId = useUniqueId(); + const isClosingRef = useRef(false); + + const [isSubmenuOpen, openSubmenu, closeSubmenu] = useFlag(false); + + useUnmountCleanup(() => { + clearTimeout(closeTimeoutRef.current); + clearTimeout(openTimeoutRef.current); + }); + + const [submenuAnchor, setSubmenuAnchor] = useState(); + const { isResizing } = useWindowSize(); + + const updateAnchor = useLastCallback(() => { + requestMeasure(() => { + if (!itemRef.current) return; + + const rect = itemRef.current.getBoundingClientRect(); + const overlap = REM; + + setSubmenuAnchor({ + x: lang.isRtl ? rect.left + overlap : rect.right - overlap, + y: rect.top, + width: rect.width - overlap * 2, + height: rect.height, + }); + }); + }); + + useEffect(() => { + if (isSubmenuOpen && !isResizing) { + updateAnchor(); + } + }, [isSubmenuOpen, lang.isRtl, updateAnchor, isResizing]); + + const cancelOpen = useLastCallback(() => { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = undefined; + }); + + const cancelClose = useLastCallback(() => { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = undefined; + }); + + const scheduleOpen = useLastCallback(() => { + cancelClose(); + cancelOpen(); + openTimeoutRef.current = window.setTimeout(() => { + openTimeoutRef.current = undefined; + // Don't open if the parent menu is closing + const parentBubble = itemRef.current?.closest('.bubble'); + if (parentBubble?.classList.contains('closing')) return; + openSubmenu(); + }, OPEN_TIMEOUT); + }); + + const scheduleClose = useLastCallback(() => { + cancelOpen(); + cancelClose(); + closeTimeoutRef.current = window.setTimeout(() => { + closeSubmenu(); + closeTimeoutRef.current = undefined; + }, CLOSE_TIMEOUT); + }); + + const handleMouseEnter = useLastCallback(() => { + if (disabled) return; + scheduleOpen(); + }); + + const handleSubmenuMouseEnter = useLastCallback(() => { + cancelOpen(); + cancelClose(); + }); + + const handleSubmenuMouseLeave = useLastCallback(() => { + scheduleClose(); + }); + + const closeParentMenu = useLastCallback(() => { + const parentMenu = itemRef.current?.closest('.Menu'); + if (parentMenu) { + const backdrop = parentMenu.querySelector('.backdrop') as HTMLElement; + if (backdrop) { + const event = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + }); + backdrop.dispatchEvent(event); + } + } + }); + + const handleSubmenuClose = useLastCallback(() => { + if (isClosingRef.current) return; + isClosingRef.current = true; + + cancelOpen(); + cancelClose(); + closeSubmenu(); + + closeParentMenu(); + + // Reset after a short delay + setTimeout(() => { + isClosingRef.current = false; + }, 100); + }); + + const getTriggerElement = useLastCallback(() => itemRef.current); + const getRootElement = useLastCallback(() => document.body); + const getMenuElement = useLastCallback( + () => document.getElementById(submenuId)?.querySelector('.bubble') as HTMLElement | undefined, + ); + const getLayout = useLastCallback(() => ({ withPortal: true })); + + const handleClick = useLastCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + if (disabled || isSubmenuOpen) return; + openSubmenu(); + }); + + return ( +
+ + {children} + + {submenuAnchor && ( + + {submenu} + + )} + +
+ ); +}; + +export default NestedMenuItem; diff --git a/src/hooks/useMenuPosition.ts b/src/hooks/useMenuPosition.ts index 6f8a1fdbb..ea89cb03a 100644 --- a/src/hooks/useMenuPosition.ts +++ b/src/hooks/useMenuPosition.ts @@ -132,6 +132,8 @@ function processDynamically( let { x, y } = anchor; const anchorX = x; const anchorY = y; + const anchorWidth = anchor.width || 0; + const anchorHeight = anchor.height || 0; const menuEl = getMenuElement(); const rootEl = getRootElement(); @@ -161,9 +163,9 @@ function processDynamically( if (isDense || (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left)) { x += 3; positionX = 'left'; - } else if (x - menuRect.width - rootRect.left > 0) { + } else if (x - anchorWidth - menuRect.width - rootRect.left > 0) { positionX = 'right'; - x -= 3; + x = x - anchorWidth - 3; } else { positionX = 'left'; x = 16; @@ -178,6 +180,7 @@ function processDynamically( y = yWithTopShift; } else { positionY = 'bottom'; + y = y + anchorHeight; if (y - menuRect.height < rootRect.top + extraTopPadding) { y = rootRect.top + rootRect.height; diff --git a/src/types/index.ts b/src/types/index.ts index 7c485d217..e40d23600 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -163,6 +163,8 @@ export interface AccountSettings { export type IAnchorPosition = { x: number; y: number; + width?: number; + height?: number; }; export interface ShippingOption { diff --git a/src/types/language.d.ts b/src/types/language.d.ts index f13accbe1..5c43801b8 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1235,6 +1235,7 @@ export interface LangPair { 'MenuArchivedChats': undefined; 'MenuContacts': undefined; 'MenuSettings': undefined; + 'MenuMore': undefined; 'MenuNightMode': undefined; 'AriaMenuEnableNightMode': undefined; 'AriaMenuDisableNightMode': undefined;