feat: add centralized ContextMenuRenderer with submenu and mobile support

This commit is contained in:
Jannis Braun
2026-03-20 18:28:10 +01:00
parent 6fb8801960
commit 9e93734719
@@ -0,0 +1,552 @@
import React, { useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import {
useContextMenuStore,
filterMenuItems,
type ContextMenuItem,
type ContextMenuLeafItem,
type ContextMenuSubmenu,
} from '../../stores/contextMenuStore';
import { useUIStore } from '../../stores/uiStore';
// ── Desktop item button ──────────────────────────────────────────────────────
const ITEM_CLASS =
'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white';
const ITEM_DANGER_CLASS =
'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-danger hover:bg-accent-rose hover:text-white';
const ITEM_DISABLED_CLASS =
'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary opacity-50 cursor-default';
const ITEM_STYLE: React.CSSProperties = { width: 'calc(100% - 12px)' };
// ── Mobile item button ───────────────────────────────────────────────────────
const MOBILE_ITEM_CLASS = 'w-full text-left px-5 py-3 text-sm flex items-center gap-3';
// ── Checkbox indicator ───────────────────────────────────────────────────────
function CheckboxIndicator({ checked }: { checked: boolean }) {
return (
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
checked ? 'bg-accent-primary border-accent-primary' : 'border-txt-tertiary'
}`}
>
{checked && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="white">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</div>
);
}
// ── Desktop submenu flyout ───────────────────────────────────────────────────
interface SubmenuFlyoutProps {
submenu: ContextMenuSubmenu;
triggerRef: React.RefObject<HTMLButtonElement | null>;
onMouseEnter: () => void;
onMouseLeave: () => void;
close: () => void;
}
function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close }: SubmenuFlyoutProps) {
const flyoutRef = useRef<HTMLDivElement>(null);
const filteredChildren = filterMenuItems(submenu.children);
useLayoutEffect(() => {
const flyout = flyoutRef.current;
const trigger = triggerRef.current;
if (!flyout || !trigger) return;
const tRect = trigger.getBoundingClientRect();
const fRect = flyout.getBoundingClientRect();
const gap = 4;
let left = tRect.right + gap;
if (left + fRect.width > window.innerWidth) {
left = tRect.left - fRect.width - gap;
}
if (left < 8) left = 8;
let top = tRect.top;
if (top + fRect.height > window.innerHeight - 8) {
top = window.innerHeight - fRect.height - 8;
}
if (top < 8) top = 8;
flyout.style.left = `${left}px`;
flyout.style.top = `${top}px`;
}, [triggerRef]);
if (filteredChildren.length === 0) return null;
return createPortal(
<div
ref={flyoutRef}
className="fixed z-[210] glass rounded-md py-1.5 min-w-[160px] overflow-y-auto scrollbar-thin animate-fade-in"
style={{ left: -9999, top: -9999 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={(e) => e.stopPropagation()}
>
{filteredChildren.map((child) => (
<DesktopLeafItem key={child.key} item={child} close={close} />
))}
</div>,
document.body,
);
}
// ── Desktop leaf item ────────────────────────────────────────────────────────
interface DesktopLeafItemProps {
item: ContextMenuLeafItem;
close: () => void;
}
function DesktopLeafItem({ item, close }: DesktopLeafItemProps) {
switch (item.type) {
case 'separator':
return <div className="h-px bg-white/[0.06] my-1 mx-1.5" />;
case 'custom':
return <div>{item.render()}</div>;
case 'checkbox':
return (
<button
className={ITEM_CLASS}
style={ITEM_STYLE}
onClick={(e) => {
e.stopPropagation();
item.onChange(!item.checked);
}}
>
<span className="flex-1">{item.label}</span>
<CheckboxIndicator checked={item.checked} />
</button>
);
case 'action': {
const className = item.disabled
? ITEM_DISABLED_CLASS
: item.danger
? ITEM_DANGER_CLASS
: ITEM_CLASS;
return (
<button
className={className}
style={ITEM_STYLE}
disabled={item.disabled}
onClick={(e) => {
e.stopPropagation();
if (item.disabled) return;
item.onClick();
close();
}}
>
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
{item.label}
</button>
);
}
}
}
// ── Desktop submenu trigger item ─────────────────────────────────────────────
interface DesktopSubmenuItemProps {
item: ContextMenuSubmenu;
close: () => void;
}
function DesktopSubmenuItem({ item, close }: DesktopSubmenuItemProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const openSubmenuKey = useContextMenuStore((s) => s.openSubmenuKey);
const setOpenSubmenu = useContextMenuStore((s) => s.setOpenSubmenu);
const isOpen = openSubmenuKey === item.key;
const cancelTimers = useCallback(() => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
if (openTimer.current) {
clearTimeout(openTimer.current);
openTimer.current = null;
}
}, []);
const handleTriggerEnter = useCallback(() => {
cancelTimers();
openTimer.current = setTimeout(() => {
setOpenSubmenu(item.key);
}, 150);
}, [cancelTimers, setOpenSubmenu, item.key]);
const handleTriggerLeave = useCallback(() => {
cancelTimers();
closeTimer.current = setTimeout(() => {
setOpenSubmenu(null);
}, 150);
}, [cancelTimers, setOpenSubmenu]);
const handleFlyoutEnter = useCallback(() => {
cancelTimers();
}, [cancelTimers]);
const handleFlyoutLeave = useCallback(() => {
cancelTimers();
closeTimer.current = setTimeout(() => {
setOpenSubmenu(null);
}, 150);
}, [cancelTimers, setOpenSubmenu]);
useEffect(() => {
return () => {
cancelTimers();
};
}, [cancelTimers]);
return (
<>
<button
ref={triggerRef}
className={ITEM_CLASS}
style={ITEM_STYLE}
onMouseEnter={handleTriggerEnter}
onMouseLeave={handleTriggerLeave}
>
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
<span className="flex-1">{item.label}</span>
<span className="text-txt-tertiary text-xs ml-auto">{'\u203A'}</span>
</button>
{isOpen && (
<SubmenuFlyout
submenu={item}
triggerRef={triggerRef}
onMouseEnter={handleFlyoutEnter}
onMouseLeave={handleFlyoutLeave}
close={close}
/>
)}
</>
);
}
// ── Desktop menu item dispatcher ─────────────────────────────────────────────
interface DesktopMenuItemProps {
item: ContextMenuItem;
close: () => void;
}
function DesktopMenuItem({ item, close }: DesktopMenuItemProps) {
if (item.type === 'submenu') {
return <DesktopSubmenuItem item={item} close={close} />;
}
return <DesktopLeafItem item={item} close={close} />;
}
// ── Desktop menu panel ───────────────────────────────────────────────────────
interface DesktopMenuProps {
items: ContextMenuItem[];
position: { x: number; y: number };
close: () => void;
closeGuard: boolean;
}
function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
// Viewport-aware positioning via direct DOM mutation
useLayoutEffect(() => {
const el = menuRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let x = position.x;
let y = position.y;
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8;
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8;
if (x < 8) x = 8;
if (y < 8) y = 8;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
}, [position]);
// Auto-focus on mount for keyboard nav
useEffect(() => {
menuRef.current?.focus();
}, []);
// Dismiss on scroll/resize
useEffect(() => {
const dismiss = () => close();
window.addEventListener('scroll', dismiss, true);
window.addEventListener('resize', dismiss);
return () => {
window.removeEventListener('scroll', dismiss, true);
window.removeEventListener('resize', dismiss);
};
}, [close]);
// Keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const container = menuRef.current;
if (!container) return;
const focusableSelector = 'button:not([disabled])';
if (e.key === 'Escape') {
e.preventDefault();
// Close submenu first if open, otherwise close the whole menu
const openSubmenuKey = useContextMenuStore.getState().openSubmenuKey;
if (openSubmenuKey) {
useContextMenuStore.getState().setOpenSubmenu(null);
} else {
close();
}
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const buttons = Array.from(container.querySelectorAll<HTMLElement>(focusableSelector));
if (buttons.length === 0) return;
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement);
let nextIndex: number;
if (e.key === 'ArrowDown') {
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
} else {
nextIndex = currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1;
}
buttons[nextIndex]?.focus();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const focused = document.activeElement;
if (focused instanceof HTMLButtonElement && container.contains(focused)) {
focused.click();
}
return;
}
if (e.key === 'ArrowRight') {
// Open submenu if focused item is a submenu trigger
e.preventDefault();
const focused = document.activeElement;
if (focused instanceof HTMLButtonElement) {
// Simulate mouse enter to open the submenu
focused.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
}
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
const openSubmenuKey = useContextMenuStore.getState().openSubmenuKey;
if (openSubmenuKey) {
useContextMenuStore.getState().setOpenSubmenu(null);
}
return;
}
},
[close],
);
return createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[199]"
onMouseDown={(e) => {
if (!closeGuard) {
close();
}
}}
onContextMenu={(e) => {
// Prevent browser context menu but do NOT stopPropagation.
// This allows right-clicks to reach underlying elements that may
// open a new context menu via their own onContextMenu handler.
e.preventDefault();
}}
/>
{/* Menu panel */}
<div
ref={menuRef}
tabIndex={-1}
className="fixed z-[200] min-w-[180px] py-1.5 glass rounded-md animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin outline-none"
style={{ left: position.x, top: position.y }}
onKeyDown={handleKeyDown}
onMouseDown={(e) => e.stopPropagation()}
>
{items.map((item) => (
<DesktopMenuItem key={item.key} item={item} close={close} />
))}
</div>
</>,
document.body,
);
}
// ── Mobile leaf item ─────────────────────────────────────────────────────────
interface MobileLeafItemProps {
item: ContextMenuLeafItem;
close: () => void;
}
function MobileLeafItem({ item, close }: MobileLeafItemProps) {
switch (item.type) {
case 'separator':
return <div className="h-px bg-white/[0.06] my-1 mx-1.5" />;
case 'custom':
return <div>{item.render()}</div>;
case 'checkbox':
return (
<button
className={`${MOBILE_ITEM_CLASS} text-txt-primary`}
onClick={(e) => {
e.stopPropagation();
item.onChange(!item.checked);
}}
>
<span className="flex-1">{item.label}</span>
<CheckboxIndicator checked={item.checked} />
</button>
);
case 'action': {
const colorClass = item.danger ? 'text-txt-danger' : 'text-txt-primary';
return (
<button
className={`${MOBILE_ITEM_CLASS} ${colorClass} ${item.disabled ? 'opacity-50' : ''}`}
disabled={item.disabled}
onClick={(e) => {
e.stopPropagation();
if (item.disabled) return;
item.onClick();
close();
}}
>
{item.icon && <span className="w-5 h-5">{item.icon}</span>}
{item.label}
</button>
);
}
}
}
// ── Mobile bottom sheet menu ─────────────────────────────────────────────────
interface MobileMenuProps {
items: ContextMenuItem[];
close: () => void;
}
function MobileMenu({ items, close }: MobileMenuProps) {
const [submenuStack, setSubmenuStack] = useState<ContextMenuSubmenu | null>(null);
// Dismiss on scroll/resize
useEffect(() => {
const dismiss = () => close();
window.addEventListener('scroll', dismiss, true);
window.addEventListener('resize', dismiss);
return () => {
window.removeEventListener('scroll', dismiss, true);
window.removeEventListener('resize', dismiss);
};
}, [close]);
// Determine what items to render: root or submenu
const currentItems: ReadonlyArray<ContextMenuLeafItem | ContextMenuItem> = submenuStack
? filterMenuItems(submenuStack.children)
: items;
return createPortal(
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[300] bg-black/50" onClick={close} />
{/* Bottom sheet */}
<div
className="fixed bottom-0 left-0 right-0 z-[301] rounded-t-2xl glass-bubble animate-slide-up-sheet"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1" />
{/* Back button for submenu */}
{submenuStack && (
<button
className={`${MOBILE_ITEM_CLASS} text-txt-secondary`}
onClick={() => setSubmenuStack(null)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
<span>Back</span>
</button>
)}
<div className="py-1">
{currentItems.map((item) => {
if (item.type === 'submenu') {
// Render submenu trigger as a button that switches sheet content
const colorClass = 'text-txt-primary';
return (
<button
key={item.key}
className={`${MOBILE_ITEM_CLASS} ${colorClass}`}
onClick={(e) => {
e.stopPropagation();
setSubmenuStack(item);
}}
>
{item.icon && <span className="w-5 h-5">{item.icon}</span>}
<span className="flex-1">{item.label}</span>
<span className="text-txt-tertiary text-sm">{'\u203A'}</span>
</button>
);
}
return <MobileLeafItem key={item.key} item={item as ContextMenuLeafItem} close={close} />;
})}
</div>
</div>
</>,
document.body,
);
}
// ── Main renderer ────────────────────────────────────────────────────────────
export function ContextMenuRenderer() {
const menu = useContextMenuStore((s) => s.menu);
const close = useContextMenuStore((s) => s.close);
const closeGuard = useContextMenuStore((s) => s.closeGuard);
const isMobile = useUIStore((s) => s.isMobile);
if (!menu) return null;
const filteredItems = filterMenuItems(menu.items);
if (filteredItems.length === 0) return null;
if (isMobile) {
return <MobileMenu items={filteredItems} close={close} />;
}
return (
<DesktopMenu
items={filteredItems}
position={menu.position}
close={close}
closeGuard={closeGuard}
/>
);
}