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.
87 lines
2.8 KiB
TypeScript
87 lines
2.8 KiB
TypeScript
// 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,
|
|
);
|
|
}
|