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";
"MenuContacts" = "Contacts";
"MenuSettings" = "Settings";
"MenuMore" = "More";
"MenuNightMode" = "Night Mode";
"AriaMenuEnableNightMode" = "Enable night mode";
"AriaMenuDisableNightMode" = "Disable night mode";

View File

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

View File

@ -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')}
</MenuItem>
<MenuItem
icon="darkmode"
onClick={handleDarkModeToggle}
<NestedMenuItem
icon="more"
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>
<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>
<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>
)}
{lang('MenuMore')}
</NestedMenuItem>
</>
);
};

View File

@ -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;

View File

@ -49,6 +49,7 @@ type OwnProps =
onMouseEnterBackdrop?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseLeave?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
withPortal?: boolean;
nested?: boolean;
children?: React.ReactNode;
}
& MenuPositionOptions;
@ -75,6 +76,7 @@ const Menu: FC<OwnProps> = ({
onMouseLeave,
withPortal,
onMouseEnterBackdrop,
nested,
...positionOptions
}) => {
const { isTouchScreen } = useAppLayout();
@ -108,12 +110,16 @@ const Menu: FC<OwnProps> = ({
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<OwnProps> = ({
onMouseEnter={onMouseEnter}
onMouseLeave={isOpen ? onMouseLeave : undefined}
>
{isOpen && (
{isOpen && !nested && (
// This only prevents click events triggering on underlying elements
<div
className="backdrop"

View File

@ -113,6 +113,10 @@
}
}
.submenu-icon {
margin-inline: auto 0 !important;
}
&.compact {
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;
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;

View File

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

View File

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