Desktop: custom context menus everywhere, never the browser's menu
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.
This commit is contained in:
parent
944b24fe59
commit
dece83c3e8
23 changed files with 839 additions and 28 deletions
252
apps/desktop/src/ui/ContextMenu.tsx
Normal file
252
apps/desktop/src/ui/ContextMenu.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// 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]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue