feat(maestro): port kill dialog, help overlay, context menu, drawer to React
Closes #250: kill confirmation/force-kill (dialogs/KillDialog), the ? shortcuts overlay (dialogs/HelpOverlay), the general-purpose right-click menu (common/ContextMenu, closes on scroll/resize unlike the sidebar's own), the mobile drawer with swipe support (layout/Drawer), and the visual-viewport hook (hooks/useViewport). Each is a standalone, tested port of its vanilla-DOM source; wiring into the shell is a later issue.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// React port of ../../contextMenu.ts (#125), the general-purpose version —
|
||||
// unlike the sidebar's own menu, this one also closes on scroll/resize, since
|
||||
// a menu anchored to coordinates goes stale the moment the page moves under it.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
onSelect(): void;
|
||||
/** Renders the entry in the destructive style. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
|
||||
// Nudge back inside the viewport once the menu has a measurable size.
|
||||
useLayoutEffect(() => {
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const maxX = window.innerWidth - rect.width;
|
||||
const maxY = window.innerHeight - rect.height;
|
||||
setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) });
|
||||
}, [x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
const onPointerDown = (e: Event) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
window.addEventListener("scroll", onClose, true);
|
||||
window.addEventListener("resize", onClose);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
window.removeEventListener("pointerdown", onPointerDown, true);
|
||||
window.removeEventListener("scroll", onClose, true);
|
||||
window.removeEventListener("resize", onClose);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
(ref.current?.firstElementChild as HTMLElement | null)?.focus();
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`context-menu-item block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
|
||||
item.danger ? "is-danger text-danger" : "text-ink"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user