Menu
Menu renders a raised Windows 98 command menu. Use MenuItem for actions and MenuSeparator for grouped commands. For nested commands, wrap a trigger and content pair in MenuSub to get a side-expanding submenu.
Props
| Export | Key props | Description |
|---|---|---|
Menu | HTML menu props | Root command list with role="menu". |
MenuItem | icon, disabled, selected, reserveIconSpace | Interactive or disabled menu row. |
MenuSeparator | HTML li props | Horizontal divider between command groups. |
MenuSub | open, onOpenChange, hoverOpenDelay, hoverCloseDelay | Owns submenu open state and hover timers. |
MenuSubTrigger | icon, disabled, reserveIconSpace | Menu row that opens a submenu, with a built-in right-side chevron. |
MenuSubContent | sideOffset, plus Menu props | Portal-rendered submenu positioned to the right of the trigger, flipping to the left at the viewport edge. |
Examples
Command menu
import { Menu, MenuItem, MenuSeparator } from '@murasaki/react98'
export function MenuBasicDemo(): React.ReactElement {
return (
<Menu className="w-52">
<MenuItem icon={<span aria-hidden="true">*</span>}>New Window</MenuItem>
<MenuItem reserveIconSpace>Open</MenuItem>
<MenuItem reserveIconSpace selected>Save</MenuItem>
<MenuSeparator />
<MenuItem reserveIconSpace disabled>Delete</MenuItem>
<MenuItem reserveIconSpace>Properties</MenuItem>
</Menu>
)
}Submenus
import {
Menu,
MenuItem,
MenuSeparator,
MenuSub,
MenuSubContent,
MenuSubTrigger,
} from '@murasaki/react98'
export function MenuSubmenuDemo(): React.ReactElement {
return (
<Menu className="w-56">
<MenuItem reserveIconSpace>New</MenuItem>
<MenuItem reserveIconSpace>Open</MenuItem>
<MenuSeparator />
<MenuSub>
<MenuSubTrigger reserveIconSpace>Send To</MenuSubTrigger>
<MenuSubContent>
<MenuItem reserveIconSpace>Desktop</MenuItem>
<MenuItem reserveIconSpace>Mail Recipient</MenuItem>
<MenuItem reserveIconSpace>My Documents</MenuItem>
</MenuSubContent>
</MenuSub>
<MenuSub>
<MenuSubTrigger reserveIconSpace>Recent</MenuSubTrigger>
<MenuSubContent>
<MenuItem reserveIconSpace>Readme.txt</MenuItem>
<MenuItem reserveIconSpace>Notes.txt</MenuItem>
<MenuItem reserveIconSpace disabled>(Nothing else)</MenuItem>
</MenuSubContent>
</MenuSub>
<MenuSeparator />
<MenuItem reserveIconSpace>Exit</MenuItem>
</Menu>
)
}Scroll-arrow steppers
import { Menu, MenuItem, MenuSeparator } from '@murasaki/react98'
import { Fragment } from 'react'
const ITEMS = [
'New',
'Open',
'Save',
'Save As...',
'Close',
'Print',
'Print Preview',
'Page Setup',
'Properties',
'Send To',
'Recent Files',
'Workspace',
'Import',
'Export',
'Convert',
'Compress',
'Share',
'Backup',
'Restore',
'Exit',
]
export function MenuScrollArrowsDemo(): React.ReactElement {
return (
<Menu className="w-52" maxHeight={140}>
{ITEMS.map((label, index) => (
<Fragment key={label}>
<MenuItem reserveIconSpace>{label}</MenuItem>
{index === 4 && <MenuSeparator />}
</Fragment>
))}
</Menu>
)
}Viewport edge detection
import {
Menu,
MenuItem,
MenuSeparator,
MenuSub,
MenuSubContent,
MenuSubTrigger,
} from '@murasaki/react98'
import { Fragment, useRef } from 'react'
const ACCESSORIES = [
'Notepad',
'Calculator',
'Paint',
'WordPad',
'Accessibility',
'Address Book',
'Backup',
'CD Player',
'Character Map',
'Clipboard Viewer',
'Command Prompt',
'Disk Cleanup',
'HyperTerminal',
'Magnifier',
'Media Player',
'NetMeeting',
'Phone Dialer',
'Sound Recorder',
'System Information',
'Volume Control',
]
const EDGE_MENUS = [
{ id: 'top-left', label: 'Top left', style: { left: 12, top: 12 } },
{ id: 'top-right', label: 'Top right', style: { right: 12, top: 12 } },
{ id: 'bottom-left', label: 'Bottom left', style: { bottom: 12, left: 12 } },
{ id: 'bottom-right', label: 'Bottom right', style: { bottom: 12, right: 12 } },
] as const
function EdgeMenu({
boundaryRef,
label,
}: {
boundaryRef: React.RefObject<HTMLDivElement | null>
label: string
}): React.ReactElement {
return (
<Menu style={{ width: 152 }}>
<MenuItem reserveIconSpace disabled>{label}</MenuItem>
<MenuSeparator />
<MenuSub hoverOpenDelay={0} hoverCloseDelay={120}>
<MenuSubTrigger reserveIconSpace>Accessories</MenuSubTrigger>
<MenuSubContent boundaryRef={boundaryRef} estimatedHeight={560} estimatedWidth={176}>
{ACCESSORIES.map((item, index) => (
<Fragment key={item}>
<MenuItem reserveIconSpace>{item}</MenuItem>
{index === 3 && <MenuSeparator />}
</Fragment>
))}
</MenuSubContent>
</MenuSub>
<MenuSub hoverOpenDelay={0} hoverCloseDelay={120}>
<MenuSubTrigger reserveIconSpace>Recent</MenuSubTrigger>
<MenuSubContent boundaryRef={boundaryRef} estimatedWidth={176}>
<MenuItem reserveIconSpace>Readme.txt</MenuItem>
<MenuItem reserveIconSpace>Notes.txt</MenuItem>
<MenuItem reserveIconSpace>Budget.xls</MenuItem>
</MenuSubContent>
</MenuSub>
<MenuSeparator />
<MenuItem reserveIconSpace>Exit</MenuItem>
</Menu>
)
}
export function MenuEdgeTestDemo(): React.ReactElement {
const boundaryRef = useRef<HTMLDivElement>(null)
return (
<div
ref={boundaryRef}
style={{
background: 'var(--window)',
boxShadow: 'var(--shadow-border-field)',
maxWidth: 720,
minHeight: 460,
overflow: 'hidden',
padding: 12,
position: 'relative',
width: '100%',
}}
>
{EDGE_MENUS.map(menu => (
<div key={menu.id} style={{ ...menu.style, position: 'absolute' }}>
<EdgeMenu boundaryRef={boundaryRef} label={menu.label} />
</div>
))}
<div
style={{
color: 'var(--gray-text)',
fontSize: 11,
left: '50%',
position: 'absolute',
textAlign: 'center',
top: '50%',
transform: 'translate(-50%, -50%)',
width: 176,
}}
>
Hover Accessories
</div>
</div>
)
}ARIA
Menu uses role="menu"; each item uses role="menuitem", and disabled items expose aria-disabled. MenuSubTrigger adds aria-haspopup="menu" and toggles aria-expanded as the submenu opens. Enabled menu items are focusable and skip disabled rows during roving navigation.
Keyboard
ArrowUp and ArrowDown move focus between enabled menu items and wrap at the ends. Home and End move to the first and last enabled item. Typing printable characters moves focus to the next enabled item whose text starts with the typed buffer. Enter and Space activate the focused menu item. On a MenuSubTrigger, ArrowRight (or Enter / Space) opens the submenu and focuses the first enabled item; inside MenuSubContent, ArrowLeft closes the submenu and returns focus to the trigger. Escape dismisses the innermost open submenu.
SSR
Keep selected, disabled, and icon content stable between server and client so focusable menu items hydrate with the same order and labels. MenuSubContent portals to document.body on mount, so it renders client-side only.