119 lines
4.2 KiB
React
119 lines
4.2 KiB
React
import React, { useRef, useState } from 'react';
|
||
import Box from '@mui/material/Box';
|
||
import Button from '@mui/material/Button';
|
||
import Menu from '@mui/material/Menu';
|
||
import MenuItem from '@mui/material/MenuItem';
|
||
import Divider from '@mui/material/Divider';
|
||
import Typography from '@mui/material/Typography';
|
||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||
|
||
/* ============================================================
|
||
* MenuBar:VSCode 风格菜单栏(MUI Menu,配置数组驱动)
|
||
*
|
||
* menus = [{ label, items: [{ label, Icon?, action?, shortcut?, divider? }] }]
|
||
* - role=menubar / menuitem,顶层 roving tabindex + 方向键导航:
|
||
* ← → / Home / End 移动焦点,↓ / Enter / 空格 打开菜单
|
||
* - 菜单内部导航由 MUI Menu 自带(方向键 + Enter/Esc)
|
||
* ============================================================ */
|
||
|
||
export default function MenuBar({ menus = [] }) {
|
||
const [openIdx, setOpenIdx] = useState(null);
|
||
const [anchorEl, setAnchorEl] = useState(null);
|
||
const [focusIdx, setFocusIdx] = useState(0); // roving tabindex 停靠点
|
||
const barRef = useRef(null);
|
||
|
||
const btnEls = () => Array.from(barRef.current?.querySelectorAll('[data-menu-btn]') || []);
|
||
const focusBtn = (i) => {
|
||
const n = menus.length;
|
||
if (n === 0) return;
|
||
const j = ((i % n) + n) % n;
|
||
setFocusIdx(j);
|
||
btnEls()[j]?.focus();
|
||
};
|
||
const openMenu = (i) => {
|
||
setFocusIdx(i);
|
||
setAnchorEl(btnEls()[i]);
|
||
setOpenIdx(i);
|
||
};
|
||
const close = () => { setOpenIdx(null); setAnchorEl(null); };
|
||
|
||
const handleTopKeyDown = (e, i) => {
|
||
switch (e.key) {
|
||
case 'ArrowRight': e.preventDefault(); focusBtn(i + 1); break;
|
||
case 'ArrowLeft': e.preventDefault(); focusBtn(i - 1); break;
|
||
case 'Home': e.preventDefault(); focusBtn(0); break;
|
||
case 'End': e.preventDefault(); focusBtn(menus.length - 1); break;
|
||
case 'ArrowDown':
|
||
case 'Enter':
|
||
case ' ':
|
||
e.preventDefault();
|
||
openMenu(i); // MUI Menu 打开后自动聚焦首个菜单项
|
||
break;
|
||
default: break;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Box
|
||
ref={barRef}
|
||
component="nav"
|
||
role="menubar"
|
||
aria-label="主菜单"
|
||
sx={{ display: 'flex', alignItems: 'center' }}
|
||
>
|
||
{menus.map((menu, i) => (
|
||
<React.Fragment key={menu.label}>
|
||
<Button
|
||
data-menu-btn
|
||
role="menuitem"
|
||
aria-haspopup="true"
|
||
aria-expanded={openIdx === i}
|
||
tabIndex={focusIdx === i ? 0 : -1}
|
||
size="small"
|
||
onClick={(e) => {
|
||
setFocusIdx(i);
|
||
if (openIdx === i) { close(); return; }
|
||
setAnchorEl(e.currentTarget);
|
||
setOpenIdx(i);
|
||
}}
|
||
onKeyDown={(e) => handleTopKeyDown(e, i)}
|
||
sx={{
|
||
minWidth: 0, px: 1, height: 30, borderRadius: 1,
|
||
fontSize: 13, color: openIdx === i ? 'primary.main' : 'text.primary',
|
||
'&:hover': { bgcolor: openIdx === i ? 'action.selected' : 'action.hover' },
|
||
}}
|
||
>
|
||
{menu.label}
|
||
<KeyboardArrowDownIcon sx={{ fontSize: 14, ml: 0.25, color: 'text.disabled' }} />
|
||
</Button>
|
||
<Menu
|
||
open={openIdx === i}
|
||
anchorEl={anchorEl}
|
||
onClose={close}
|
||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||
MenuListProps={{ sx: { py: 0.5, minWidth: 200 } }}
|
||
>
|
||
{menu.items.map((item, j) => (
|
||
item.divider ? (
|
||
<Divider key={`d-${j}`} sx={{ my: 0.5 }} />
|
||
) : (
|
||
<MenuItem
|
||
key={item.label + j}
|
||
dense
|
||
onClick={() => { close(); item.action?.(); }}
|
||
>
|
||
{item.Icon && <item.Icon sx={{ fontSize: 16, mr: 1.25, color: 'text.secondary' }} />}
|
||
<span style={{ flex: 1 }}>{item.label}</span>
|
||
{item.shortcut && (
|
||
<Typography variant="caption" sx={{ ml: 3, color: 'text.disabled' }}>{item.shortcut}</Typography>
|
||
)}
|
||
</MenuItem>
|
||
)
|
||
))}
|
||
</Menu>
|
||
</React.Fragment>
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|