One ContextMenu in the design system (keyboard, type-ahead, submenus, check items, disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all. Menus on sidebar and portfolio apps, app hero, releases, store art, live art, reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
252 lines
14 KiB
TypeScript
252 lines
14 KiB
TypeScript
// 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()`: `<div {...menu(() => [...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<OpenFn | null>(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: `<button {...menu(() => 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<string, unknown>)[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<string, unknown>)[HANDLED] = true;
|
|
if (!open) return;
|
|
open(e.clientX, e.clientY, build(), { from: e.currentTarget as Element, keyboard: !!(e.nativeEvent as unknown as Record<string, unknown>)[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<Open | null>(null);
|
|
const open = useCallback<OpenFn>((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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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 (
|
|
<Ctx.Provider value={open}>
|
|
{children}
|
|
{state && createPortal(<MenuRoot key={`${state.x},${state.y}`} state={state} close={close} />, document.body)}
|
|
</Ctx.Provider>
|
|
);
|
|
}
|
|
|
|
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 <MenuList items={state.items} at={{ x: state.x, y: state.y }} close={close} root keyboard={state.keyboard} />;
|
|
}
|
|
|
|
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<HTMLDivElement>(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<number | undefined>(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<HTMLElement>(`[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 (
|
|
<>
|
|
<div ref={ref} className={`ds-menu${pos ? ' in' : ''}`} role="menu" tabIndex={-1} onKeyDown={onKey}
|
|
style={pos ? { left: pos.left, top: pos.top } : { left: -9999, top: -9999 }}
|
|
aria-activedescendant={active >= 0 ? `${uid}-${active}` : undefined}
|
|
onContextMenu={e => e.preventDefault()}>
|
|
{items.map((e, i) => {
|
|
if (!isItem(e)) return <div key={`s${i}`} className="ds-menu-sep" role="separator" />;
|
|
const dis = !!e.disabled;
|
|
const checkable = e.checked !== undefined;
|
|
return (
|
|
<div key={i} id={`${uid}-${i}`} data-i={i} role={checkable ? 'menuitemcheckbox' : 'menuitem'}
|
|
aria-checked={checkable ? e.checked : undefined} aria-disabled={dis || undefined}
|
|
aria-haspopup={e.submenu ? 'menu' : undefined} aria-expanded={e.submenu ? sub?.index === i : undefined}
|
|
title={typeof e.disabled === 'string' ? e.disabled : undefined}
|
|
className={`ds-menu-item${i === active ? ' active' : ''}${e.danger ? ' danger' : ''}${dis ? ' disabled' : ''}`}
|
|
onPointerMove={() => {
|
|
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)}>
|
|
<span className="ds-menu-ic">{checkable ? (e.checked ? <Icon name="check" /> : null) : e.icon ? <Icon name={e.icon} /> : null}</span>
|
|
<span className="ds-menu-l">{e.label}</span>
|
|
{e.shortcut && <span className="ds-menu-k">{e.shortcut}</span>}
|
|
{e.submenu && <Icon name="chevronRight" className="ds-menu-sub" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{sub && isItem(items[sub.index]) && (items[sub.index] as MenuItem).submenu && (
|
|
<MenuList key={sub.index} items={(items[sub.index] as MenuItem).submenu as MenuEntry[]} at={sub.anchor} close={close}
|
|
onBack={() => { 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<string, unknown>)[HANDLED] = true;
|
|
open?.(e.clientX, e.clientY, [...editMenu(el), 'sep', ...extras()], { from: el, keyboard: !!(e.nativeEvent as unknown as Record<string, unknown>)[BY_KEY] });
|
|
},
|
|
}), [open]);
|
|
}
|