You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React递归菜单:实现点击关闭其他子菜单及外部点击关闭功能

Material-UI递归菜单交互优化需求

已基于Material-UI在React中实现了支持子菜单的递归菜单,现需补充两个交互功能:

  1. 点击带子菜单的菜单项时,无论其他子菜单当前状态如何,关闭所有其他子菜单,仅展开当前点击的菜单
  2. 点击递归菜单(包括主菜单和所有子菜单)外部区域时,关闭所有已打开的子菜单及主菜单

原实现代码

import React, { useState } from 'react';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import Checkbox from '@mui/material/Checkbox';

const RecursiveMenu = ({ menuItems }) => {
  const [anchorEl, setAnchorEl] = useState(null);

  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <IconButton
        aria-controls="simple-menu"
        aria-haspopup="true"
        onClick={handleClick}
      >
        <MenuIcon />
      </IconButton>
      <Menu
        id="simple-menu"
        anchorEl={anchorEl}
        open={Boolean(anchorEl)}
        onClose={handleClose}
      >
        {Object.values(menuItems)
          .filter((menuItem) => menuItem.showing !== false)
          .map((menuItem) => (
            <RecursiveMenuItem key={menuItem.id} menuItem={menuItem} />
          ))}
      </Menu>
    </div>
  );
};

const RecursiveMenuItem = ({ menuItem }) => {
  const [subMenuAnchorEl, setSubMenuAnchorEl] = useState(null);
  const [showingState, setShowingState] = useState(menuItem.showing);

  const handleClick = (event) => {
    if (menuItem.options) {
      setSubMenuAnchorEl(event.currentTarget);
    } else {
      // Toggle between 0, 1, and 2 for checked, indeterminate, and unchecked
      setShowingState((prevState) => (prevState + 1) % 3);
    }
  };

  const handleClose = () => {
    setSubMenuAnchorEl(null);
  };

  return (
    <>
      <MenuItem style={{ display: 'flex', justifyContent: 'space-between' }} onClick={handleClick}>
        <span>{menuItem.name}</span>
        {!menuItem.options && (
          <Checkbox
            checked={showingState === 0}
            indeterminate={showingState === 1}
            onChange={handleClick}
            inputProps={{ 'aria-label': 'indeterminate-checkbox' }}
          />
        )}
      </MenuItem>
      {menuItem.options && (
        <Menu
          anchorEl={subMenuAnchorEl}
          open={Boolean(subMenuAnchorEl)}
          onClose={handleClose}
        >
          {Object.values(menuItem.options)
            .filter((subMenuItem) => subMenuItem.showing !== false)
            .map((subMenuItem) => (
              <RecursiveMenuItem key={subMenuItem.id} menuItem={subMenuItem} />
            ))}
        </Menu>
      )}
    </>
  );
};

const App = () => {
  const menuItems = {
    A: { id: "A", name: "A", enabled: false, showing: 0 },
    B: { id: "B", name: "B", enabled: false, showing: 2 },
    C: {
      id: "c",
      name: "C",
      options: {
        C1: { id: "C1", name: "C1", enabled: false, showing: 1 },
        C2: {
          id: "C2", name: "C2", options:
          {
            C21: { id: "C21", name: "C2.1", enabled: false, showing: 0 },
            C22: { id: "C22", name: "C2.2", enabled: false, showing: 2 },
          }
        },
      },
    },
    Shaun: { id: "D", name: "D", enabled: false, showing: 2 },
  };

  return <RecursiveMenu menuItems={menuItems} />;
};

export default App;

解决方案

核心思路是将所有子菜单的展开状态提升到顶层组件统一管理,这样才能实现全局的状态控制:

1. 顶层菜单状态改造

在RecursiveMenu中新增状态记录当前展开的子菜单ID,以及控制所有菜单关闭的函数:

  • openSubMenus:用对象记录每个子菜单的展开状态(键为菜单ID,值为对应的anchor元素)
  • handleSubMenuOpen:点击子菜单时,先清空其他所有子菜单的状态,再设置当前菜单为展开
  • handleSubMenuClose:关闭指定ID的子菜单
  • handleCloseAll:关闭主菜单及所有子菜单

2. 递归菜单项接收控制函数

将顶层的状态控制函数传递给RecursiveMenuItem,替换原有的局部状态管理逻辑,让顶层统一控制所有子菜单的展开/关闭。

3. 外部点击处理

利用Material-UI Menu组件的onClose事件,当点击外部时触发handleCloseAll,关闭所有菜单。

修改后的完整代码

import React, { useState } from 'react';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import Checkbox from '@mui/material/Checkbox';

const RecursiveMenu = ({ menuItems }) => {
  const [anchorEl, setAnchorEl] = useState(null);
  // 统一管理所有子菜单的展开状态:key为菜单ID,value为对应的anchor元素
  const [openSubMenus, setOpenSubMenus] = useState({});

  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };

  // 关闭主菜单及所有子菜单
  const handleCloseAll = () => {
    setAnchorEl(null);
    setOpenSubMenus({});
  };

  // 打开指定ID的子菜单,同时关闭其他所有子菜单
  const handleSubMenuOpen = (menuId, anchorEl) => {
    setOpenSubMenus({
      [menuId]: anchorEl,
    });
  };

  // 关闭指定ID的子菜单
  const handleSubMenuClose = (menuId) => {
    setOpenSubMenus(prev => {
      const newState = {...prev};
      delete newState[menuId];
      return newState;
    });
  };

  return (
    <div>
      <IconButton
        aria-controls="simple-menu"
        aria-haspopup="true"
        onClick={handleClick}
      >
        <MenuIcon />
      </IconButton>
      <Menu
        id="simple-menu"
        anchorEl={anchorEl}
        open={Boolean(anchorEl)}
        onClose={handleCloseAll} // 点击主菜单外部时关闭所有
      >
        {Object.values(menuItems)
          .filter((menuItem) => menuItem.showing !== false)
          .map((menuItem) => (
            <RecursiveMenuItem
              key={menuItem.id}
              menuItem={menuItem}
              openSubMenus={openSubMenus}
              onSubMenuOpen={handleSubMenuOpen}
              onSubMenuClose={handleSubMenuClose}
              onCloseAll={handleCloseAll}
            />
          ))}
      </Menu>
    </div>
  );
};

const RecursiveMenuItem = ({ menuItem, openSubMenus, onSubMenuOpen, onSubMenuClose, onCloseAll }) => {
  const [showingState, setShowingState] = useState(menuItem.showing);

  const handleClick = (event) => {
    if (menuItem.options) {
      // 点击带子菜单的项:关闭其他所有子菜单,展开当前
      onSubMenuOpen(menuItem.id, event.currentTarget);
    } else {
      // 切换复选框状态逻辑保持不变
      setShowingState((prevState) => (prevState + 1) % 3);
    }
  };

  return (
    <>
      <MenuItem style={{ display: 'flex', justifyContent: 'space-between' }} onClick={handleClick}>
        <span>{menuItem.name}</span>
        {!menuItem.options && (
          <Checkbox
            checked={showingState === 0}
            indeterminate={showingState === 1}
            onChange={handleClick}
            inputProps={{ 'aria-label': 'indeterminate-checkbox' }}
          />
        )}
      </MenuItem>
      {menuItem.options && (
        <Menu
          anchorEl={openSubMenus[menuItem.id]}
          open={Boolean(openSubMenus[menuItem.id])}
          onClose={() => onSubMenuClose(menuItem.id)}
          // 点击子菜单外部时关闭所有菜单
          onClickOutside={onCloseAll}
        >
          {Object.values(menuItem.options)
            .filter((subMenuItem) => subMenuItem.showing !== false)
            .map((subMenuItem) => (
              <RecursiveMenuItem
                key={subMenuItem.id}
                menuItem={subMenuItem}
                openSubMenus={openSubMenus}
                onSubMenuOpen={onSubMenuOpen}
                onSubMenuClose={onSubMenuClose}
                onCloseAll={onCloseAll}
              />
            ))}
        </Menu>
      )}
    </>
  );
};

const App = () => {
  const menuItems = {
    A: { id: "A", name: "A", enabled: false, showing: 0 },
    B: { id: "B", name: "B", enabled: false, showing: 2 },
    C: {
      id: "c",
      name: "C",
      options: {
        C1: { id: "C1", name: "C1", enabled: false, showing: 1 },
        C2: {
          id: "C2", name: "C2", options:
          {
            C21: { id: "C21", name: "C2.1", enabled: false, showing: 0 },
            C22: { id: "C22", name: "C2.2", enabled: false, showing: 2 },
          }
        },
      },
    },
    Shaun: { id: "D", name: "D", enabled: false, showing: 2 },
  };

  return <RecursiveMenu menuItems={menuItems} />;
};

export default App;

关键修改说明

  • 状态提升:所有子菜单的展开状态由顶层RecursiveMenu的openSubMenus统一管理,避免了局部状态无法同步的问题
  • 排他展开:点击子菜单时,handleSubMenuOpen会重置openSubMenus只保留当前菜单的状态,实现关闭其他子菜单的效果
  • 外部点击处理:主菜单的onClose和子菜单的onClickOutside都绑定handleCloseAll,点击外部时直接清空所有菜单状态

内容的提问来源于stack exchange,提问作者OmriHalifa

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 15:15:55