// Context menus (design system): the browser's own menu never shows. ContextMenuProvider (main.tsx) prevents every
// native `contextmenu`, opens a small Cut / Copy / Paste / Select all menu on text fields and a Copy menu on selected
// text, and turns Shift+F10 / the Menu key into a context menu on the focused element. Screens attach menus with
// `useContextMenu()`: `
[...entries])}>`. Keyboard: arrows, Home / End, Enter / Space, Esc,
// ArrowRight / ArrowLeft for submenus, type-ahead. Logic (placement, moves, type-ahead) in menu-logic.ts.
import { createContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { t } from '../i18n';
import { copyText } from '../lib/crash';
import { Icon } from './icons';
import { MOD } from './kit';
import { enabled, first, isEditable, isItem, last, type MenuEntry, type MenuInput, type MenuItem, move, placeAt, placeBeside, tidy, typeahead } from './menu-logic';
export type { MenuEntry, MenuInput, MenuItem } from './menu-logic';
interface Open { x: number; y: number; items: MenuEntry[]; from: Element | null; keyboard: boolean }
type OpenFn = (x: number, y: number, input: MenuInput, opts?: { from?: Element | null; keyboard?: boolean }) => void;
const Ctx = createContext
(null);
/** Marks a native event a menu handled, so the global fallback leaves it alone. */
const HANDLED = '__sbMenu';
/** Marks the contextmenu event Shift+F10 / the Menu key sent (the menu starts on its first item). */
const BY_KEY = '__sbKey';
/** Props that attach a menu to an element: ` entries)}>`. Entries are built when it opens. */
export function useContextMenu(): (build: () => MenuInput) => { onContextMenu: (e: ReactMouseEvent) => void } {
const open = useContext(Ctx);
return useCallback((build: () => MenuInput) => ({
onContextMenu: (e: ReactMouseEvent) => {
if ((e.nativeEvent as unknown as Record)[HANDLED]) return;
// a text field or a selection inside keeps the edit menu
const target = e.target as HTMLElement;
if (isEditable(target as HTMLInputElement) || (window.getSelection()?.toString() ?? '').trim()) return;
e.preventDefault();
(e.nativeEvent as unknown as Record)[HANDLED] = true;
if (!open) return;
open(e.clientX, e.clientY, build(), { from: e.currentTarget as Element, keyboard: !!(e.nativeEvent as unknown as Record)[BY_KEY] });
},
}), [open]);
}
/** Copies and says so in a toast-less way (the menu closes; failures are silent like the browser's own Copy). */
export const copyEntry = (label: string, text: string | null | undefined, icon: MenuItem['icon'] = 'copy'): MenuItem | null =>
text ? { label, icon, onSelect: () => { void copyText(text); } } : null;
function editMenu(el: HTMLInputElement | HTMLTextAreaElement | HTMLElement): MenuEntry[] {
const field = 'selectionStart' in el ? el as HTMLInputElement : null;
const ro = !!field && (field.readOnly || field.disabled);
const secret = field?.type === 'password';
const start = field?.selectionStart ?? 0, end = field?.selectionEnd ?? 0;
const sel = field ? (field.value ?? '').slice(start, end) : (window.getSelection()?.toString() ?? '');
const refocus = () => { el.focus(); if (field && field.type !== 'number' && field.type !== 'email') field.setSelectionRange(start, end); };
return tidy([
{ label: t('cm.cut'), icon: 'edit', shortcut: `${MOD} X`, disabled: ro || secret || !sel, onSelect: () => { refocus(); void copyText(sel).then(() => { refocus(); document.execCommand('delete'); }); } },
{ label: t('cm.copy'), icon: 'copy', shortcut: `${MOD} C`, disabled: secret || !sel, onSelect: () => { void copyText(sel); refocus(); } },
{ label: t('cm.paste'), icon: 'clipboard', shortcut: `${MOD} V`, disabled: ro, onSelect: () => {
refocus();
navigator.clipboard?.readText().then(s => { refocus(); document.execCommand('insertText', false, s); }, () => {});
} },
'sep',
{ label: t('cm.selectAll'), icon: 'grid', shortcut: `${MOD} A`, onSelect: () => { el.focus(); if (field) field.select(); else document.execCommand('selectAll'); } },
]);
}
export function ContextMenuProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState(null);
const open = useCallback((x, y, input, opts) => {
const items = tidy(input);
if (!items.length) return;
setState({ x, y, items, from: opts?.from ?? (document.activeElement as Element | null), keyboard: !!opts?.keyboard });
}, []);
const close = useCallback((restore: boolean) => {
setState(s => {
if (restore && s?.from instanceof HTMLElement && s.from.isConnected) s.from.focus({ preventScroll: true });
return null;
});
}, []);
useEffect(() => {
// the fallback for everything no screen menu handled: never the browser's menu
const onMenu = (e: MouseEvent) => {
e.preventDefault();
if ((e as unknown as Record)[HANDLED]) return;
const target = e.target as HTMLElement | null;
if (target?.closest?.('.ds-menu')) return;
const editable = target && isEditable(target as HTMLInputElement) ? target : null;
const keyboard = !!(e as unknown as Record)[BY_KEY];
if (editable) { open(e.clientX, e.clientY, editMenu(editable), { from: editable, keyboard }); return; }
const sel = window.getSelection()?.toString() ?? '';
if (sel.trim()) open(e.clientX, e.clientY, [{ label: t('cm.copy'), icon: 'copy', shortcut: `${MOD} C`, onSelect: () => { void copyText(sel); } }], { keyboard });
};
// Shift+F10 and the Menu key: a context menu on the focused element, at its corner
const onKey = (e: KeyboardEvent) => {
if (!((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu')) return;
const el = document.activeElement as HTMLElement | null;
if (!el || el === document.body || el.closest('.ds-menu')) return;
e.preventDefault();
const r = el.getBoundingClientRect();
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: Math.round(r.left + Math.min(24, r.width / 2)), clientY: Math.round(r.bottom - 2), button: 2 });
(ev as unknown as Record)[BY_KEY] = true;
el.dispatchEvent(ev);
};
document.addEventListener('contextmenu', onMenu);
document.addEventListener('keydown', onKey, true);
return () => { document.removeEventListener('contextmenu', onMenu); document.removeEventListener('keydown', onKey, true); };
}, [open]);
return (
{children}
{state && createPortal( , document.body)}
);
}
function MenuRoot({ state, close }: { state: Open; close: (restore: boolean) => void }) {
useEffect(() => {
const outside = (e: Event) => { if (!(e.target as Element | null)?.closest?.('.ds-menu')) close(false); };
const scroll = (e: Event) => { if (!(e.target instanceof Element && e.target.closest('.ds-menu'))) close(false); };
const blur = () => close(false);
document.addEventListener('pointerdown', outside, true);
document.addEventListener('scroll', scroll, true);
window.addEventListener('blur', blur);
window.addEventListener('resize', blur);
return () => {
document.removeEventListener('pointerdown', outside, true);
document.removeEventListener('scroll', scroll, true);
window.removeEventListener('blur', blur);
window.removeEventListener('resize', blur);
};
}, [close]);
return ;
}
type Anchor = { x: number; y: number } | { left: number; right: number; top: number };
function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEntry[]; at: Anchor; close: (restore: boolean) => void; root?: boolean; keyboard?: boolean; onBack?: () => void }) {
const uid = useId();
const ref = useRef(null);
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const [active, setActive] = useState(-1);
const [sub, setSub] = useState<{ index: number; anchor: Anchor; focus: boolean } | null>(null);
const typed = useRef({ q: '', at: 0 });
const hoverTimer = useRef(undefined);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const box = { w: el.offsetWidth, h: el.offsetHeight }, view = { w: window.innerWidth, h: window.innerHeight };
setPos('x' in at ? placeAt(at.x, at.y, box, view) : placeBeside(at, box, view));
}, [at]);
useEffect(() => {
if (!pos) return;
ref.current?.focus({ preventScroll: true });
// a submenu, or a menu opened from the keyboard, starts on its first item; a mouse-opened menu waits for a key
if (!root || keyboard) setActive(first(items));
}, [pos, root, keyboard, items]);
useEffect(() => () => window.clearTimeout(hoverTimer.current), []);
const itemEl = (i: number) => ref.current?.querySelector(`[data-i="${i}"]`) ?? null;
const openSub = (i: number, focus: boolean) => {
const el = itemEl(i);
if (!el) return;
const r = el.getBoundingClientRect();
setSub({ index: i, anchor: { left: r.left, right: r.right, top: r.top }, focus });
};
const choose = (i: number) => {
const e = items[i];
if (!enabled(e)) return;
if (e.submenu) { openSub(i, true); return; }
close(true);
// after the menu is gone, so the action sees the restored focus (paste, select all)
window.setTimeout(() => e.onSelect?.(), 0);
};
const onKey = (ev: ReactKeyboardEvent) => {
if (sub?.focus) return; // the submenu has the keys
const k = ev.key;
const stop = () => { ev.preventDefault(); ev.stopPropagation(); };
if (k === 'ArrowDown') { stop(); setActive(a => move(items, a, 1)); }
else if (k === 'ArrowUp') { stop(); setActive(a => move(items, a, -1)); }
else if (k === 'Home' || k === 'PageUp') { stop(); setActive(first(items)); }
else if (k === 'End' || k === 'PageDown') { stop(); setActive(last(items)); }
else if (k === 'Enter' || k === ' ') { stop(); if (active >= 0) choose(active); }
else if (k === 'ArrowRight') { stop(); const e = items[active]; if (enabled(e) && e.submenu) openSub(active, true); }
else if (k === 'ArrowLeft') { stop(); onBack?.(); }
else if (k === 'Escape') { stop(); if (onBack) onBack(); else close(true); }
else if (k === 'Tab') { stop(); close(true); }
else if (k.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
stop();
const now = Date.now();
const q = (now - typed.current.at > 700 ? '' : typed.current.q) + k;
typed.current = { q, at: now };
const i = typeahead(items, active, q);
if (i >= 0) setActive(i);
}
};
useEffect(() => { if (active >= 0) itemEl(active)?.scrollIntoView({ block: 'nearest' }); }, [active]);
return (
<>
= 0 ? `${uid}-${active}` : undefined}
onContextMenu={e => e.preventDefault()}>
{items.map((e, i) => {
if (!isItem(e)) return
;
const dis = !!e.disabled;
const checkable = e.checked !== undefined;
return (
{
if (active !== i) setActive(dis ? -1 : i);
window.clearTimeout(hoverTimer.current);
if (!dis && e.submenu) { if (sub?.index !== i) hoverTimer.current = window.setTimeout(() => openSub(i, false), 120); }
else if (sub) hoverTimer.current = window.setTimeout(() => setSub(null), 200);
}}
onClick={() => choose(i)}>
{checkable ? (e.checked ? : null) : e.icon ? : null}
{e.label}
{e.shortcut && {e.shortcut} }
{e.submenu && }
);
})}
{sub && isItem(items[sub.index]) && (items[sub.index] as MenuItem).submenu && (
{ setSub(null); ref.current?.focus({ preventScroll: true }); }} />
)}
>
);
}
/** A text field's menu: Cut / Copy / Paste / Select all plus the field's own entries (copy value, reset). */
export function useFieldMenu(): (extras: () => MenuInput) => { onContextMenu: (e: ReactMouseEvent) => void } {
const open = useContext(Ctx);
return useCallback((extras: () => MenuInput) => ({
onContextMenu: (e: ReactMouseEvent) => {
const el = e.currentTarget as HTMLElement;
e.preventDefault();
(e.nativeEvent as unknown as Record)[HANDLED] = true;
open?.(e.clientX, e.clientY, [...editMenu(el), 'sep', ...extras()], { from: el, keyboard: !!(e.nativeEvent as unknown as Record)[BY_KEY] });
},
}), [open]);
}