Menu: Support nested items (#6522)

By @kotevcode
This commit is contained in:
Alexander Zinchuk 2026-01-13 01:14:23 +01:00
parent e112d3d9ed
commit 8d5858bab4
10 changed files with 320 additions and 63 deletions

View File

@ -1446,6 +1446,7 @@
"MenuArchivedChats" = "Archived Chats"; "MenuArchivedChats" = "Archived Chats";
"MenuContacts" = "Contacts"; "MenuContacts" = "Contacts";
"MenuSettings" = "Settings"; "MenuSettings" = "Settings";
"MenuMore" = "More";
"MenuNightMode" = "Night Mode"; "MenuNightMode" = "Night Mode";
"AriaMenuEnableNightMode" = "Enable night mode"; "AriaMenuEnableNightMode" = "Enable night mode";
"AriaMenuDisableNightMode" = "Disable night mode"; "AriaMenuDisableNightMode" = "Disable night mode";

View File

@ -57,7 +57,6 @@ const LeftSideMenuDropdown = ({
return ( return (
<DropdownMenu <DropdownMenu
trigger={trigger} trigger={trigger}
footer={`${APP_NAME} ${versionString}`}
className={buildClassName( className={buildClassName(
'main-menu', 'main-menu',
lang.isRtl && 'rtl', lang.isRtl && 'rtl',
@ -77,6 +76,7 @@ const LeftSideMenuDropdown = ({
onSelectSettings={handleSelectSettings} onSelectSettings={handleSelectSettings}
onBotMenuOpened={markBotMenuOpen} onBotMenuOpened={markBotMenuOpen}
onBotMenuClosed={unmarkBotMenuOpen} onBotMenuClosed={unmarkBotMenuOpen}
footer={`${APP_NAME} ${versionString}`}
/> />
</DropdownMenu> </DropdownMenu>
); );

View File

@ -37,6 +37,7 @@ import useLastCallback from '../../../hooks/useLastCallback';
import AttachBotItem from '../../middle/composer/AttachBotItem'; import AttachBotItem from '../../middle/composer/AttachBotItem';
import MenuItem from '../../ui/MenuItem'; import MenuItem from '../../ui/MenuItem';
import MenuSeparator from '../../ui/MenuSeparator'; import MenuSeparator from '../../ui/MenuSeparator';
import NestedMenuItem from '../../ui/NestedMenuItem';
import Switcher from '../../ui/Switcher'; import Switcher from '../../ui/Switcher';
import Toggle from '../../ui/Toggle'; import Toggle from '../../ui/Toggle';
import AccountMenuItems from './AccountMenuItems'; import AccountMenuItems from './AccountMenuItems';
@ -47,6 +48,7 @@ type OwnProps = {
onSelectArchived: NoneToVoidFunction; onSelectArchived: NoneToVoidFunction;
onBotMenuOpened: NoneToVoidFunction; onBotMenuOpened: NoneToVoidFunction;
onBotMenuClosed: NoneToVoidFunction; onBotMenuClosed: NoneToVoidFunction;
footer?: string;
}; };
type StateProps = { type StateProps = {
@ -72,6 +74,7 @@ const LeftSideMenuItems = ({
onSelectSettings, onSelectSettings,
onBotMenuOpened, onBotMenuOpened,
onBotMenuClosed, onBotMenuClosed,
footer,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
openChat, openChat,
@ -196,63 +199,74 @@ const LeftSideMenuItems = ({
> >
{lang('MenuSettings')} {lang('MenuSettings')}
</MenuItem> </MenuItem>
<MenuItem <NestedMenuItem
icon="darkmode" icon="more"
onClick={handleDarkModeToggle} footer={footer}
submenu={(
<>
<MenuItem
icon="darkmode"
onClick={handleDarkModeToggle}
>
<span className="menu-item-name">{lang('MenuNightMode')}</span>
<Switcher
id="darkmode"
label={lang(theme === 'dark' ? 'AriaMenuDisableNightMode' : 'AriaMenuEnableNightMode')}
checked={theme === 'dark'}
noAnimation
/>
</MenuItem>
<MenuItem
icon="animations"
onClick={handleAnimationLevelChange}
>
<span className="menu-item-name capitalize">{lang('MenuAnimationsSwitch')}</span>
<Toggle value={animationLevelValue} />
</MenuItem>
<MenuSeparator />
<MenuItem
icon="help"
onClick={handleOpenTipsChat}
>
{lang('MenuTelegramFeatures')}
</MenuItem>
<MenuItem
icon="bug"
onClick={handleBugReportClick}
>
{lang('MenuReportBug')}
</MenuItem>
{IS_BETA && (
<MenuItem
icon="permissions"
onClick={handleChangelogClick}
>
{lang('MenuBetaChangelog')}
</MenuItem>
)}
{withOtherVersions && (
<MenuItem
icon="K"
isCharIcon
href={`${WEB_VERSION_BASE}k`}
onClick={handleSwitchToWebK}
>
{lang('MenuSwitchToK')}
</MenuItem>
)}
{canInstall && (
<MenuItem
icon="install"
onClick={getPromptInstall()}
>
{lang('MenuInstallApp')}
</MenuItem>
)}
</>
)}
> >
<span className="menu-item-name">{lang('MenuNightMode')}</span> {lang('MenuMore')}
<Switcher </NestedMenuItem>
id="darkmode"
label={lang(theme === 'dark' ? 'AriaMenuDisableNightMode' : 'AriaMenuEnableNightMode')}
checked={theme === 'dark'}
noAnimation
/>
</MenuItem>
<MenuItem
icon="animations"
onClick={handleAnimationLevelChange}
>
<span className="menu-item-name capitalize">{lang('MenuAnimationsSwitch')}</span>
<Toggle value={animationLevelValue} />
</MenuItem>
<MenuItem
icon="help"
onClick={handleOpenTipsChat}
>
{lang('MenuTelegramFeatures')}
</MenuItem>
<MenuItem
icon="bug"
onClick={handleBugReportClick}
>
{lang('MenuReportBug')}
</MenuItem>
{IS_BETA && (
<MenuItem
icon="permissions"
onClick={handleChangelogClick}
>
{lang('MenuBetaChangelog')}
</MenuItem>
)}
{withOtherVersions && (
<MenuItem
icon="K"
isCharIcon
href={`${WEB_VERSION_BASE}k`}
onClick={handleSwitchToWebK}
>
{lang('MenuSwitchToK')}
</MenuItem>
)}
{canInstall && (
<MenuItem
icon="install"
onClick={getPromptInstall()}
>
{lang('MenuInstallApp')}
</MenuItem>
)}
</> </>
); );
}; };

View File

@ -87,9 +87,10 @@
} }
.footer { .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); color: var(--color-text-secondary);
text-align: center; text-align: center;

View File

@ -49,6 +49,7 @@ type OwnProps =
onMouseEnterBackdrop?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onMouseEnterBackdrop?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseLeave?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onMouseLeave?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
withPortal?: boolean; withPortal?: boolean;
nested?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
} }
& MenuPositionOptions; & MenuPositionOptions;
@ -75,6 +76,7 @@ const Menu: FC<OwnProps> = ({
onMouseLeave, onMouseLeave,
withPortal, withPortal,
onMouseEnterBackdrop, onMouseEnterBackdrop,
nested,
...positionOptions ...positionOptions
}) => { }) => {
const { isTouchScreen } = useAppLayout(); const { isTouchScreen } = useAppLayout();
@ -108,12 +110,16 @@ const Menu: FC<OwnProps> = ({
const handleKeyDown = useKeyboardListNavigation(bubbleRef, isOpen, autoClose ? onClose : undefined, undefined, true); const handleKeyDown = useKeyboardListNavigation(bubbleRef, isOpen, autoClose ? onClose : undefined, undefined, true);
const fullExcludedSelector = backdropExcludedSelector
? `${backdropExcludedSelector}, .submenu`
: '.submenu';
useVirtualBackdrop( useVirtualBackdrop(
isOpen, isOpen,
containerRef, containerRef,
noCloseOnBackdrop ? undefined : onClose, noCloseOnBackdrop ? undefined : onClose,
undefined, undefined,
backdropExcludedSelector, fullExcludedSelector,
); );
const bubbleFullClassName = buildClassName( const bubbleFullClassName = buildClassName(
@ -147,7 +153,7 @@ const Menu: FC<OwnProps> = ({
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={isOpen ? onMouseLeave : undefined} onMouseLeave={isOpen ? onMouseLeave : undefined}
> >
{isOpen && ( {isOpen && !nested && (
// This only prevents click events triggering on underlying elements // This only prevents click events triggering on underlying elements
<div <div
className="backdrop" className="backdrop"

View File

@ -113,6 +113,10 @@
} }
} }
.submenu-icon {
margin-inline: auto 0 !important;
}
&.compact { &.compact {
will-change: transform; will-change: transform;

View File

@ -0,0 +1,225 @@
import {
useEffect, useRef, useState, useUnmountCleanup,
} from '@teact';
import type { IAnchorPosition } from '../../types';
import type { IconName } from '../../types/icons';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import buildClassName from '../../util/buildClassName';
import { REM } from '../common/helpers/mediaDimensions';
import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useUniqueId from '../../hooks/useUniqueId';
import useWindowSize from '../../hooks/window/useWindowSize';
import Icon from '../common/icons/Icon';
import Menu from './Menu';
import MenuItem from './MenuItem';
const OPEN_TIMEOUT = 150;
const CLOSE_TIMEOUT = 150;
type OwnProps = {
icon?: IconName;
customIcon?: React.ReactNode;
submenuIcon?: IconName;
className?: string;
children: React.ReactNode;
submenu: React.ReactNode;
submenuClassName?: string;
disabled?: boolean;
destructive?: boolean;
ariaLabel?: string;
footer?: string;
};
const NestedMenuItem = ({
icon,
customIcon,
submenuIcon,
className,
children,
submenu,
submenuClassName,
disabled,
destructive,
ariaLabel,
footer,
}: OwnProps) => {
const lang = useLang();
const itemRef = useRef<HTMLDivElement>();
const closeTimeoutRef = useRef<number>();
const openTimeoutRef = useRef<number>();
const submenuId = useUniqueId();
const isClosingRef = useRef(false);
const [isSubmenuOpen, openSubmenu, closeSubmenu] = useFlag(false);
useUnmountCleanup(() => {
clearTimeout(closeTimeoutRef.current);
clearTimeout(openTimeoutRef.current);
});
const [submenuAnchor, setSubmenuAnchor] = useState<IAnchorPosition>();
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<HTMLDivElement | HTMLAnchorElement>) => {
e.stopPropagation();
if (disabled || isSubmenuOpen) return;
openSubmenu();
});
return (
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={scheduleClose}
ref={itemRef}
>
<MenuItem
icon={icon}
customIcon={customIcon}
className={buildClassName(className, 'submenu')}
disabled={disabled}
destructive={destructive}
ariaLabel={ariaLabel}
onClick={handleClick}
>
{children}
<Icon name={submenuIcon || (lang.isRtl ? 'previous' : 'next')} className="submenu-icon" />
{submenuAnchor && (
<Menu
id={submenuId}
isOpen={isSubmenuOpen}
className={buildClassName('submenu', submenuClassName)}
anchor={submenuAnchor}
positionX={lang.isRtl ? 'left' : 'right'}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
autoClose
nested
withPortal
footer={footer}
onClose={handleSubmenuClose}
onMouseEnter={handleSubmenuMouseEnter}
onMouseLeave={handleSubmenuMouseLeave}
>
{submenu}
</Menu>
)}
</MenuItem>
</div>
);
};
export default NestedMenuItem;

View File

@ -132,6 +132,8 @@ function processDynamically(
let { x, y } = anchor; let { x, y } = anchor;
const anchorX = x; const anchorX = x;
const anchorY = y; const anchorY = y;
const anchorWidth = anchor.width || 0;
const anchorHeight = anchor.height || 0;
const menuEl = getMenuElement(); const menuEl = getMenuElement();
const rootEl = getRootElement(); const rootEl = getRootElement();
@ -161,9 +163,9 @@ function processDynamically(
if (isDense || (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left)) { if (isDense || (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left)) {
x += 3; x += 3;
positionX = 'left'; positionX = 'left';
} else if (x - menuRect.width - rootRect.left > 0) { } else if (x - anchorWidth - menuRect.width - rootRect.left > 0) {
positionX = 'right'; positionX = 'right';
x -= 3; x = x - anchorWidth - 3;
} else { } else {
positionX = 'left'; positionX = 'left';
x = 16; x = 16;
@ -178,6 +180,7 @@ function processDynamically(
y = yWithTopShift; y = yWithTopShift;
} else { } else {
positionY = 'bottom'; positionY = 'bottom';
y = y + anchorHeight;
if (y - menuRect.height < rootRect.top + extraTopPadding) { if (y - menuRect.height < rootRect.top + extraTopPadding) {
y = rootRect.top + rootRect.height; y = rootRect.top + rootRect.height;

View File

@ -163,6 +163,8 @@ export interface AccountSettings {
export type IAnchorPosition = { export type IAnchorPosition = {
x: number; x: number;
y: number; y: number;
width?: number;
height?: number;
}; };
export interface ShippingOption { export interface ShippingOption {

View File

@ -1235,6 +1235,7 @@ export interface LangPair {
'MenuArchivedChats': undefined; 'MenuArchivedChats': undefined;
'MenuContacts': undefined; 'MenuContacts': undefined;
'MenuSettings': undefined; 'MenuSettings': undefined;
'MenuMore': undefined;
'MenuNightMode': undefined; 'MenuNightMode': undefined;
'AriaMenuEnableNightMode': undefined; 'AriaMenuEnableNightMode': undefined;
'AriaMenuDisableNightMode': undefined; 'AriaMenuDisableNightMode': undefined;