desktop: tray menu panels and pinnable context menus

The tray's right-click menu is drawn by the app: one tree from tray.rs
(stable submenu keys), one frameless always-on-top menu-N window per
panel rendered by tray-menu.html with the design system, placed inside
the monitor's work area. A pure panel book keeps transient and pinned
panels apart; pinned panels (pin button, Ctrl+click, middle click) are
draggable, step aside from each other and are saved in state.json
(menuPins) and restored at start. tray_update pushes the tree to the
open panels, which update in place; closed panels are destroyed. The
native menu, rendered from the same tree, stays as the fallback and on
Linux. Only menu-* windows get the menu_* commands (menu.json).

In-app context menus get the same behaviour through a pure menu stack
(ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels
survive the next menu, are draggable and re-read their entries from
live sources so data changes update them in place.

Also drops a stray diff3 marker line from CHANGELOG.md.
This commit is contained in:
CRTkafa 2026-10-24 13:44:08 +08:00
parent 445ab1adbc
commit 5236f002f1
35 changed files with 2308 additions and 123 deletions

View file

@ -211,7 +211,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
const menu = useContextMenu();
const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
<div role="listitem" key={a.key}>
<button {...menu(() => appMenu(a, d, nav, { showIgnored }))} className={`nav nav-app has-line${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
<button {...menu(() => appMenu(a, d, nav, { showIgnored }), `app:${a.key}`)} className={`nav nav-app has-line${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' })}
title={`${a.name} · ${st.text}`}>
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{(st.tone === 'bad' || st.tone === 'warn') && <span className={`ds-sdot ${st.tone}`} role="img" aria-label={st.tone === 'bad' ? t('pf.blocker') : t('pf.warning')} />}</span>

View file

@ -229,6 +229,19 @@
.ds-menu-k { max-width: 150px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--faint); font-size: var(--ds-t-micro); letter-spacing: .02em; font-variant-numeric: tabular-nums; }
.ds-menu-sub { width: 12px; height: 12px; color: var(--faint); margin-left: 10px; }
.ds-menu-sep { height: 1px; margin: 5px 6px; background: var(--ds-stroke); }
/* menu header: the pin button (a normal menu or submenu), a pinned panel's title, unpin, close and drag handle */
.ds-menu-head { display: flex; align-items: center; gap: 2px; height: 24px; padding: 0 0 0 9px; margin-bottom: 1px; }
.ds-menu-head.pinned { cursor: grab; touch-action: none; }
.ds-menu-head.pinned:active { cursor: grabbing; }
.ds-menu-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ds-t-micro);
font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--faint); }
.ds-menu-btn { flex: none; width: 22px; height: 22px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px;
background: transparent; color: var(--faint); cursor: pointer; }
.ds-menu-btn svg { width: 13px; height: 13px; }
.ds-menu-btn:hover, .ds-menu-btn:focus-visible { background: var(--ds-inset); color: var(--ink); outline: none; }
.ds-menu-btn.on { color: var(--accent); }
.ds-menu.pinned { border-color: color-mix(in srgb, var(--accent) 45%, var(--ds-stroke-strong)); transition: opacity 120ms var(--ease); }
.ds-menu-gone { max-width: 240px; padding: 6px 10px 8px; color: var(--muted); font-size: var(--ds-t-micro); white-space: normal; }
@media (prefers-reduced-motion: reduce) { .ds-menu { transition: none; transform: none; } }
:root[data-motion="reduce"] .ds-menu { transition: none; transform: none; }
/* headings focused by code (dialog / wizard step titles) for screen readers: no ring */

View file

@ -2059,6 +2059,12 @@ export const en = {
// context menus (ui/ContextMenu.tsx)
'cm.cut': "Cut",
'cm.pin': "Pin: keeps this menu open",
'cm.unpin': "Unpin",
'cm.closePanel': "Close",
'cm.gone': "Gone: what this menu was for is not on screen now.",
'cm.menu': "Menu",
'cm.tearOff': "Ctrl+click or middle-click: pin this submenu",
'cm.copy': "Copy",
'cm.paste': "Paste",
'cm.selectAll': "Select all",

View file

@ -2061,6 +2061,12 @@ export const tr: Record<keyof typeof en, string> = {
// context menus (ui/ContextMenu.tsx)
'cm.cut': "Kes",
'cm.pin': "Sabitle: menü açık kalır",
'cm.unpin': "Sabitlemeyi kaldır",
'cm.closePanel': "Kapat",
'cm.gone': "Yok: bu menünün ait olduğu şey şu an ekranda değil.",
'cm.menu': "Menü",
'cm.tearOff': "Ctrl+tık veya orta tık: bu alt menüyü sabitle",
'cm.copy': "Kopyala",
'cm.paste': "Yapıştır",
'cm.selectAll': "Tümünü seç",

View file

@ -69,7 +69,7 @@ function AppControls({ app }: { app: UnifiedApp }) {
</select></label>
)}
<span className="grow" />
<button className="btn btn-sm btn-ghost" {...more(() => appMenu(app, d, nav, { showIgnored }))} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" />{app.ignored || app.ignoredStores.length ? t('nav.ignored') : null}</button>
<button className="btn btn-sm btn-ghost" {...more(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" />{app.ignored || app.ignoredStores.length ? t('nav.ignored') : null}</button>
</div>
);
}
@ -130,7 +130,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
return (
<div className="page ds-app">
<Hero name={name} icon={d.iconFor(app)} projectId={app.project && !app.project.missing ? app.project.id : null}
{...cm(() => appMenu(app, d, nav, { showIgnored }))}
{...cm(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}
badges={<>
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
{!app.project && <StoreOnlyTag />}

View file

@ -30,7 +30,7 @@ export function Ignored() {
{apps.map(a => {
const s = d.studios.find(x => x.id === a.studio) ?? null;
return (
<div className="li" key={a.key} {...cm(() => appMenu(a, d, nav, { showIgnored: true }))}>
<div className="li" key={a.key} {...cm(() => appMenu(a, d, nav, { showIgnored: true }), `app:${a.key}`)}>
<AppIcon name={a.name} src={d.iconFor(a)} />
<div className="li-main">
<span className="li-title row" style={{ gap: 8 }}>{a.name}{!a.project && <StoreOnlyTag />}</span>

View file

@ -153,7 +153,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null;
const menu = useContextMenu();
const [showIgnored] = useShowIgnored();
return (
<article className="pf-card" style={style} aria-label={name} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
<article className="pf-card" style={style} aria-label={name} {...menu(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}>
<div className={`pf-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
{shots.length ? shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} loading="lazy" />) : <>
<AppIcon name={app.name} src={icon} className="pf-hero-icon" />
@ -373,7 +373,7 @@ export function Portfolio() {
const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android);
const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? '';
return (
<tr key={app.key} className="clickable" onClick={() => open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
<tr key={app.key} className="clickable" onClick={() => open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}>
<td>
<div className="cell-app">
<AppIcon name={app.name} src={d.iconFor(app)} />
@ -456,7 +456,7 @@ export function Portfolio() {
<button className="link" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)}>{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>
{showIgnored && ignoredApps.map(a => (
<span key={a.key} className="row muted" style={{ gap: 4 }}>
<button className="link" onClick={() => open(a)} {...menu(() => appMenu(a, d, nav, { showIgnored }))}>{a.name}</button>
<button className="link" onClick={() => open(a)} {...menu(() => appMenu(a, d, nav, { showIgnored }), `app:${a.key}`)}>{a.name}</button>
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))} aria-label={t('ignored.restore')} title={t('ignored.restore')}><Icon name="eye" /></button>
</span>
))}

View file

@ -0,0 +1,166 @@
// One tray menu panel (tray-menu.html): the root menu or one submenu, drawn with the design system's menu look. The
// window around it belongs to Rust (menu_panel.rs); the panel only measures itself and reports what the owner does.
import { AppWindowIcon } from '@phosphor-icons/react/dist/csr/AppWindow';
import { ArrowsClockwiseIcon } from '@phosphor-icons/react/dist/csr/ArrowsClockwise';
import { BellSlashIcon } from '@phosphor-icons/react/dist/csr/BellSlash';
import { CaretRightIcon } from '@phosphor-icons/react/dist/csr/CaretRight';
import { DeviceMobileIcon } from '@phosphor-icons/react/dist/csr/DeviceMobile';
import { DownloadSimpleIcon } from '@phosphor-icons/react/dist/csr/DownloadSimple';
import { GearSixIcon } from '@phosphor-icons/react/dist/csr/GearSix';
import { PowerIcon } from '@phosphor-icons/react/dist/csr/Power';
import { PushPinIcon } from '@phosphor-icons/react/dist/csr/PushPin';
import { PushPinSlashIcon } from '@phosphor-icons/react/dist/csr/PushPinSlash';
import { SquaresFourIcon } from '@phosphor-icons/react/dist/csr/SquaresFour';
import { XIcon } from '@phosphor-icons/react/dist/csr/X';
import { type KeyboardEvent, type MouseEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { filterNodes, findPath, isEnabled, isTearOff, keepActive, type MenuNode, nextIndex, type Row, rows, split } from './model';
export interface MenuBridge {
ready(width: number, height: number): void;
openSub(key: string, top: number, focus: boolean): void;
pinSub(key: string, top: number): void;
closeSubs(): void;
close(refocusParent: boolean): void;
dismiss(): void;
pin(on: boolean): void;
select(id: string): void;
}
const WORDS = {
en: { title: 'storebound', pin: 'Pin (stays open)', unpin: 'Unpin', close: 'Close', gone: 'This menu is gone for now.', filter: 'Filter', none: 'Nothing matches', tearOff: 'Ctrl+click or middle-click: pin' },
tr: { title: 'storebound', pin: 'Sabitle (açık kalır)', unpin: 'Sabitlemeyi kaldır', close: 'Kapat', gone: 'Bu menü şu an yok.', filter: 'Süz', none: 'Eşleşen yok', tearOff: 'Ctrl+tık veya orta tık: sabitle' },
} satisfies Record<'en' | 'tr', Record<string, string>>;
export type MenuLang = keyof typeof WORDS;
const ICON: Record<string, ReactNode> = {
'tray-open': <AppWindowIcon />, 'tray-read': <ArrowsClockwiseIcon />, 'tray-mute': <BellSlashIcon />,
'tray-update': <DownloadSimpleIcon />, 'tray-settings': <GearSixIcon />, 'tray-quit': <PowerIcon />,
};
const iconOf = (n: Row): ReactNode => (n.kind === 'sub' ? <SquaresFourIcon /> : ICON[n.id] ?? (n.id.startsWith('tray-app:') ? <DeviceMobileIcon /> : null));
export function TrayMenu({ nodes, path, pinned, hasParent, focusFirst, bridge, lang = 'en', openKey }: {
nodes: MenuNode[]; path: string[]; pinned: boolean; hasParent: boolean; focusFirst?: boolean; bridge: MenuBridge; lang?: MenuLang;
/** The submenu shown as open (the dev demo; in the app Rust knows). */
openKey?: string;
}) {
const w = WORDS[lang];
const ref = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState('');
const [active, setActive] = useState(-1);
const [open, setOpen] = useState<string | null>(openKey ?? null);
const hover = useRef<number | undefined>(undefined);
const found = useMemo(() => findPath(nodes, path), [nodes, path]);
const items = useMemo(() => (found ? filterNodes(found.items, query) : []), [found, query]);
const list = useMemo(() => rows(items), [items]);
const prev = useRef<Row[]>(list);
// a live update keeps the highlight on the same row
useEffect(() => {
setActive(a => keepActive(prev.current, list, a));
prev.current = list;
}, [list]);
useEffect(() => {
if (focusFirst) setActive(nextIndex(-1, 1, list.map(isEnabled)));
ref.current?.focus({ preventScroll: true });
// only on the first render
}, []);
useEffect(() => () => window.clearTimeout(hover.current), []);
// the window takes the panel's size (Rust places it inside the work area)
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let last = '';
const report = () => {
const r = el.getBoundingClientRect();
const size = `${Math.ceil(r.width)}x${Math.ceil(r.height)}`;
if (size !== last) { last = size; bridge.ready(Math.ceil(r.width), Math.ceil(r.height)); }
};
report();
const ro = new ResizeObserver(report);
ro.observe(el);
return () => ro.disconnect();
}, [bridge]);
const rowTop = (i: number) => ref.current?.querySelector<HTMLElement>(`[data-i="${i}"]`)?.getBoundingClientRect().top ?? 0;
const openSub = (i: number, focus: boolean) => {
const n = list[i];
if (n?.kind !== 'sub') return;
setOpen(n.key);
bridge.openSub(n.key, rowTop(i), focus);
};
const pick = (i: number, e?: { button: number; ctrlKey: boolean; metaKey: boolean }) => {
const n = list[i];
if (!n || !isEnabled(n)) return;
if (n.kind === 'sub') {
if (e && isTearOff(e)) { bridge.pinSub(n.key, rowTop(i)); return; }
openSub(i, !e);
return;
}
bridge.select(n.id);
};
const back = () => (hasParent ? bridge.close(true) : pinned ? undefined : bridge.dismiss());
const onKey = (e: KeyboardEvent) => {
const k = e.key;
const en = list.map(isEnabled);
const stop = () => { e.preventDefault(); e.stopPropagation(); };
if (k === 'ArrowDown') { stop(); setActive(a => nextIndex(a, 1, en)); }
else if (k === 'ArrowUp') { stop(); setActive(a => nextIndex(a, -1, en)); }
else if (k === 'Home' || k === 'PageUp') { stop(); setActive(nextIndex(-1, 1, en)); }
else if (k === 'End' || k === 'PageDown') { stop(); setActive(nextIndex(-1, -1, en)); }
else if (k === 'Enter' || k === ' ') { stop(); if (active >= 0) pick(active); }
else if (k === 'ArrowRight') { stop(); if (list[active]?.kind === 'sub') openSub(active, true); }
else if (k === 'ArrowLeft') { stop(); if (hasParent) bridge.close(true); }
else if (k === 'Escape') { stop(); if (query) setQuery(''); else if (pinned) bridge.close(false); else back(); }
else if (k === 'Backspace') { stop(); setQuery(q => q.slice(0, -1)); }
else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { stop(); setQuery(q => q + k); setActive(0); }
};
// a pinned panel whose submenu is gone keeps a name: its key
const title = !found ? (path[path.length - 1] ?? w.title).replace(/-/g, ' ') : found.label == null ? w.title : split(found.label).text;
const onRowMove = (i: number, n: Row) => {
if (active !== i) setActive(isEnabled(n) ? i : -1);
window.clearTimeout(hover.current);
if (n.kind === 'sub') {
if (open !== n.key) hover.current = window.setTimeout(() => openSub(i, false), 120);
} else if (open) {
hover.current = window.setTimeout(() => { setOpen(null); bridge.closeSubs(); }, 200);
}
};
return (
<div ref={ref} className={`tm-panel${pinned ? ' pinned' : ''}`} role="menu" tabIndex={-1} onKeyDown={onKey} aria-label={title}
onContextMenu={e => e.preventDefault()}>
<div className="tm-head" data-tauri-drag-region={pinned ? '' : undefined}>
<span className="tm-title" data-tauri-drag-region={pinned ? '' : undefined}>{query ? <><span className="tm-q">{query}</span><span className="tm-caret" /></> : title}</span>
<button type="button" className={`tm-btn${pinned ? ' on' : ''}`} title={pinned ? w.unpin : w.pin} aria-label={pinned ? w.unpin : w.pin} aria-pressed={pinned}
onClick={() => bridge.pin(!pinned)}>{pinned ? <PushPinSlashIcon /> : <PushPinIcon />}</button>
{pinned && <button type="button" className="tm-btn" title={w.close} aria-label={w.close} onClick={() => bridge.close(false)}><XIcon /></button>}
</div>
{!found && <div className="tm-gone">{w.gone}</div>}
{found && !list.length && <div className="tm-gone">{query ? w.none : '—'}</div>}
{items.map((n, idx) => {
if (n.kind === 'sep') return <div key={`s${idx}`} className="ds-menu-sep" role="separator" />;
const i = list.indexOf(n);
const { text, value } = split(n.label);
const dis = !isEnabled(n);
return (
<div key={n.kind === 'sub' ? `sub:${n.key}` : n.id} data-i={i} role="menuitem" aria-disabled={dis || undefined}
aria-haspopup={n.kind === 'sub' ? 'menu' : undefined} aria-expanded={n.kind === 'sub' ? open === n.key : undefined}
title={n.kind === 'sub' ? w.tearOff : undefined}
className={`ds-menu-item tm-row${i === active ? ' active' : ''}${dis ? ' disabled' : ''}${n.kind === 'sub' && open === n.key ? ' open' : ''}`}
onPointerMove={() => onRowMove(i, n)}
onMouseDown={(e: MouseEvent) => { if (e.button === 1) e.preventDefault(); }}
onAuxClick={(e: MouseEvent) => { if (e.button === 1) pick(i, e); }}
onClick={(e: MouseEvent) => pick(i, e)}>
<span className="ds-menu-ic">{iconOf(n)}</span>
<span className="ds-menu-l">{text}</span>
{value && <span className="tm-value">{value}</span>}
{n.kind === 'sub' && <CaretRightIcon className="ds-menu-sub" />}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,55 @@
// Dev only (a plain browser, never in the release bundle): the tray menu panels on fixture data, as the tray would show
// them: the root menu with Apps open beside it and a pinned Apps panel, whose values change every few seconds the way a
// store read changes them (the panels update in place).
import { useEffect, useMemo, useState } from 'react';
import type { MenuNode } from './model';
import { type MenuBridge, type MenuLang, TrayMenu } from './TrayMenu';
const STATES = [
['App Store: In review · Play: Live', 'App Store: Ready for sale · Play: Live'],
['Play: Rolling out 20%', 'Play: Rolling out 50%', 'Play: Rolling out 100%'],
['App Store: Rejected', 'App Store: Rejected'],
['App Store: Waiting for review', 'App Store: In review'],
];
const NAMES = ['Star Dash', 'Puzzle Garden', 'Pocket Chef', 'Night Runner'];
/** The tree tray.rs builds (menu_model), with fixture apps. */
export function demoNodes(tick: number, lang: MenuLang): MenuNode[] {
const tr = lang === 'tr';
const item = (id: string, label: string, enabled = true): MenuNode => ({ kind: 'item', id, label, enabled });
return [
item('tray-open', tr ? 'storebound\'u aç' : 'Open storebound'),
item('tray-read', tr ? 'Mağazaları şimdi oku' : 'Read the stores now'),
{ kind: 'sep' },
{ kind: 'sub', key: 'apps', label: tr ? 'Uygulamalar' : 'Apps', items: NAMES.map((n, i) => item(`tray-app:k${i}`, `${n} · ${STATES[i]![tick % STATES[i]!.length]}`)) },
{ kind: 'sep' },
item('tray-mute', tr ? 'Bildirimleri 1 saat sustur' : 'Mute notifications for 1 hour'),
item('tray-update', tr ? 'Güncellemeleri denetle' : 'Check for updates'),
item('tray-settings', tr ? 'Ayarlar' : 'Settings'),
{ kind: 'sep' },
item('tray-quit', tr ? 'storebound\'dan çık' : 'Quit storebound'),
];
}
const log = (what: string) => (...a: unknown[]) => console.info('tray menu', what, ...a);
const demoBridge: MenuBridge = { ready: () => {}, openSub: log('openSub'), pinSub: log('pinSub'), closeSubs: () => {}, close: log('close'), dismiss: log('dismiss'), pin: log('pin'), select: log('select') };
export function Demo({ lang }: { lang: MenuLang }) {
const [tick, setTick] = useState(0);
const q = new URLSearchParams(location.search);
const still = q.has('still');
useEffect(() => {
if (still) return;
const t = window.setInterval(() => setTick(n => n + 1), 3000);
return () => window.clearInterval(t);
}, [still]);
const nodes = useMemo(() => demoNodes(tick, lang), [tick, lang]);
return (
<>
<div className="tm-demo-at" style={{ left: 24, top: 160 }}><TrayMenu nodes={nodes} path={[]} pinned={false} hasParent={false} bridge={demoBridge} lang={lang} openKey="apps" /></div>
<div className="tm-demo-at" style={{ left: 262, top: 262 }}><TrayMenu nodes={nodes} path={['apps']} pinned={false} hasParent focusFirst bridge={demoBridge} lang={lang} /></div>
<div className="tm-demo-at" style={{ left: 690, top: 24 }}><TrayMenu nodes={nodes} path={['apps']} pinned hasParent={false} bridge={demoBridge} lang={lang} /></div>
<div className="tm-demo-at" style={{ left: 690, top: 260 }}><TrayMenu nodes={nodes} path={['gone']} pinned hasParent={false} bridge={demoBridge} lang={lang} /></div>
</>
);
}

View file

@ -0,0 +1,70 @@
// The tray menu panel window's entry (tray-menu.html; one window per panel, made by menu_panel.rs). Theme and language
// from the same prefs as the main window; the tree and every live update from Rust (`menu-model`). In a plain dev
// browser it shows a demo with fixture data instead (demo.tsx, never in the release bundle).
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { applyPrefs, loadPrefs, resolveLang } from '../prefs';
import { bridge, inTauri, menuState, onModel, onPanel } from './menu-api';
import type { MenuNode, PanelInfo } from './model';
import { type MenuLang, TrayMenu } from './TrayMenu';
import '../styles.css';
import '../design.css';
import './traymenu.css';
const sys = () => (typeof navigator !== 'undefined' ? navigator.language : 'en');
function prefs(): MenuLang {
const p = loadPrefs();
if (import.meta.env.DEV) {
const q = new URLSearchParams(location.search);
const th = q.get('theme'), lg = q.get('lang');
if (th === 'light' || th === 'dark') p.theme = th;
if (lg === 'en' || lg === 'tr') p.lang = lg;
}
applyPrefs(p, document.documentElement, sys());
return resolveLang(p.lang, sys());
}
/** One panel window: its state from Rust, the live tree, pin changes. */
function Panel({ lang }: { lang: MenuLang }) {
const [info, setInfo] = useState<PanelInfo | null>(null);
const [nodes, setNodes] = useState<MenuNode[]>([]);
const [pinned, setPinned] = useState(false);
const [focusTick, setFocusTick] = useState(0);
useEffect(() => {
let live = true;
const offs = [
onModel(setNodes),
onPanel(word => {
if (word === 'pinned') setPinned(true);
else if (word === 'unpinned') setPinned(false);
else if (word === 'focus') setFocusTick(n => n + 1);
}),
];
menuState().then((s: PanelInfo | null) => {
if (!live || !s) return;
setInfo(s);
setNodes(s.nodes);
setPinned(s.pinned);
}, () => {});
return () => { live = false; for (const off of offs) off(); };
}, []);
useEffect(() => { if (focusTick) document.querySelector<HTMLElement>('.tm-panel')?.focus(); }, [focusTick]);
if (!info) return null;
return <TrayMenu nodes={nodes} path={info.path} pinned={pinned} hasParent={info.hasParent && !pinned} focusFirst={info.focusFirst} bridge={bridge} lang={lang} />;
}
void (async () => {
let lang = prefs();
const root = createRoot(document.getElementById('menu')!);
if (!inTauri() && import.meta.env.DEV) {
const { Demo } = await import('./demo');
document.body.classList.add('tm-demo');
root.render(<Demo lang={lang} />);
return;
}
const render = () => root.render(<Panel key={lang} lang={lang} />);
render();
// theme or language changed in the main window (same origin: shared localStorage)
window.addEventListener('storage', () => { const l = prefs(); if (l !== lang) { lang = l; render(); } });
})();

View file

@ -0,0 +1,33 @@
// The tray menu panels' host calls (the panel page's counterpart of src/api.ts, kept apart so the panel bundle stays
// small). Only `menu-*` windows may call these (capabilities/menu.json; every command re-checks the window in Rust).
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { MenuNode, PanelInfo } from './model';
import type { MenuBridge } from './TrayMenu';
export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
const call = (cmd: string, args?: Record<string, unknown>) => { invoke(cmd, args).catch(() => {}); };
function sub<T>(ev: string, fn: (p: T) => void): () => void {
const un = listen<T>(ev, e => fn(e.payload));
return () => { un.then(f => f()).catch(() => {}); };
}
/** This panel: its path into the tree, pinned or not, and the tree now. */
export const menuState = (): Promise<PanelInfo | null> => invoke<PanelInfo | null>('menu_state').catch(() => null);
/** Every change of the tree (a store read, mute, language): the panel updates in place. */
export const onModel = (fn: (nodes: MenuNode[]) => void) => sub<MenuNode[]>('menu-model', fn);
/** Rust about this panel: `pinned`, `unpinned`, `focus`. */
export const onPanel = (fn: (word: string) => void) => sub<string>('menu-panel', fn);
export const bridge: MenuBridge = {
ready: (width, height) => call('menu_ready', { width, height }),
openSub: (key, top, focus) => call('menu_open_sub', { key, top, focus }),
pinSub: (key, top) => call('menu_pin_sub', { key, top }),
closeSubs: () => call('menu_close_subs'),
close: refocusParent => call('menu_close', { refocusParent }),
dismiss: () => call('menu_dismiss'),
pin: pinned => call('menu_pin', { pinned }),
select: id => call('menu_select', { id }),
};

View file

@ -0,0 +1,75 @@
// The tray menu panels' pure half (tested in tests/tray-menu.test.ts): the tree Rust sends (menu_panel.rs `Node`),
// walking it by stable submenu keys, splitting a row into its text and live value, type-to-filter, keyboard moves and
// keeping the highlighted row on the same item when a live update arrives.
export type MenuNode =
| { kind: 'item'; id: string; label: string; enabled: boolean }
| { kind: 'sep' }
| { kind: 'sub'; key: string; label: string; items: MenuNode[] };
export type Row = Exclude<MenuNode, { kind: 'sep' }>;
/** What menu_state returns for one panel window. */
export interface PanelInfo {
label: string;
path: string[];
pinned: boolean;
hasParent: boolean;
focusFirst: boolean;
nodes: MenuNode[];
}
/** "First · App Store: In review" -> { text: 'First', value: 'App Store: In review' } (the value is right-aligned). */
export function split(label: string): { text: string; value?: string } {
const at = label.indexOf('·');
if (at <= 0) return { text: label.trim() };
const value = label.slice(at + 1).trim();
return value ? { text: label.slice(0, at).trim(), value } : { text: label.slice(0, at).trim() };
}
/** The items at `path` (submenu keys) and the submenu's label, or null when that submenu is gone. */
export function findPath(nodes: readonly MenuNode[], path: readonly string[]): { items: MenuNode[]; label: string | null } | null {
let items = nodes as MenuNode[];
let label: string | null = null;
for (const key of path) {
const next = items.find((n): n is Extract<MenuNode, { kind: 'sub' }> => n.kind === 'sub' && n.key === key);
if (!next) return null;
items = next.items;
label = next.label;
}
return { items, label };
}
/** Type-to-filter: rows whose label holds every word; separators drop out while filtering. */
export function filterNodes(items: readonly MenuNode[], query: string): MenuNode[] {
const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return items as MenuNode[];
return items.filter(n => n.kind !== 'sep' && words.every(w => n.label.toLocaleLowerCase().includes(w)));
}
export const rows = (items: readonly MenuNode[]): Row[] => items.filter((n): n is Row => n.kind !== 'sep');
export const isEnabled = (n: Row): boolean => n.kind === 'sub' || n.enabled;
export const rowId = (n: Row): string => (n.kind === 'sub' ? `sub:${n.key}` : n.id);
/** The next enabled row from `current` in `dir`, wrapping; -1 when none. `current` -1 starts before the first. */
export function nextIndex(current: number, dir: 1 | -1, enabled: readonly boolean[]): number {
const n = enabled.length;
if (!n) return -1;
const from = current < 0 ? (dir > 0 ? -1 : n) : current;
for (let step = 1; step <= n; step += 1) {
const i = (((from + dir * step) % n) + n) % n;
if (enabled[i]) return i;
}
return -1;
}
/** A live update must not move the highlight: the same id / key in the new rows, else the same place (clamped). */
export function keepActive(before: readonly Row[], after: readonly Row[], active: number): number {
if (active < 0 || !after.length) return -1;
const was = before[active];
if (!was) return Math.min(active, after.length - 1);
const found = after.findIndex(n => rowId(n) === rowId(was));
return found >= 0 ? found : Math.min(active, after.length - 1);
}
/** Ctrl / Cmd + click or a middle click on a submenu row tears it off as a pinned panel. */
export const isTearOff = (e: { button: number; ctrlKey: boolean; metaKey: boolean }): boolean => e.button === 1 || ((e.ctrlKey || e.metaKey) && e.button === 0);

View file

@ -0,0 +1,28 @@
/* The tray menu panels (tray-menu.html): the design system's menu (.ds-menu-*) in its own small window. The window is
transparent and exactly as large as the panel (menu_panel.rs sizes it from what the panel measures). */
html:has(body.tm-page), body.tm-page { margin: 0; background: transparent; overflow: hidden; }
body.tm-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
.tm-panel { box-sizing: border-box; width: max-content; min-width: 230px; max-width: 380px; max-height: 860px; overflow: auto; padding: 4px 5px 5px;
border-radius: 12px; background: var(--ds-raised); border: 1px solid var(--ds-stroke-strong);
font-size: var(--ds-t-body); color: var(--ink); outline: none; user-select: none; cursor: default; }
.tm-panel.pinned { border-color: color-mix(in srgb, var(--accent) 45%, var(--ds-stroke-strong)); }
.tm-head { display: flex; align-items: center; gap: 2px; height: 28px; padding: 0 2px 0 9px; margin-bottom: 2px; }
.tm-panel.pinned .tm-head { cursor: grab; }
.tm-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ds-t-micro);
font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.tm-q { text-transform: none; letter-spacing: 0; color: var(--ink); font-weight: 500; }
.tm-caret { display: inline-block; width: 1px; height: 11px; margin-left: 1px; vertical-align: -1px; background: var(--accent); }
.tm-btn { flex: none; width: 24px; height: 24px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px;
background: transparent; color: var(--faint); cursor: pointer; }
.tm-btn svg { width: 14px; height: 14px; }
.tm-btn:hover, .tm-btn:focus-visible { background: var(--ds-inset); color: var(--ink); outline: none; }
.tm-btn.on { color: var(--accent); }
.tm-row.open:not(.active) { background: var(--ds-inset); }
.tm-value { max-width: 190px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--muted);
font-size: var(--ds-t-micro); font-variant-numeric: tabular-nums; }
.tm-gone { padding: 8px 10px 10px; color: var(--muted); font-size: var(--ds-t-micro); }
/* the dev demo (a plain browser): several panels side by side on a desktop-like backdrop */
body.tm-page.tm-demo { overflow: auto; min-height: 100vh; background: linear-gradient(135deg, #3b4a6b, #1d2436); }
.tm-demo-at { position: absolute; }

View file

@ -1,55 +1,94 @@
// 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';
// `useContextMenu()`: `<div {...menu(() => [...entries], 'app:' + key)}>`. Keyboard: arrows, Home / End, Enter / Space,
// Esc, ArrowRight / ArrowLeft for submenus, type-ahead. Logic (placement, moves, type-ahead) in menu-logic.ts.
//
// Several menus at once (the same behaviour as the tray menu panels): a menu's pin button keeps it open as a floating
// panel; the pin button of a submenu, or Ctrl / Cmd / middle click on a submenu row, tears that submenu off as its own
// pinned panel. Pinned panels survive the next menu, can be dragged by their header, step aside so they never sit on
// each other, and update in place: they re-read their entries from the newest builder the screen registered (live
// sources, menu-stack.ts), so a read that changes a value never closes them. The panel stack is pure (menu-stack.ts).
import { createContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useContext, useEffect, useId, useLayoutEffect, useReducer, 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';
import { clampPos, closeMenu, closeUnpinned, LiveMenus, moveMenu, openMenu, pinMenu, type Pos, resolvePath, type StackEntry, tearOff, unpinMenu } from './menu-stack';
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;
interface OpenOpts { from?: Element | null; keyboard?: boolean; source?: string | null }
type OpenFn = (x: number, y: number, input: MenuInput, opts?: OpenOpts) => void;
const Ctx = createContext<OpenFn | null>(null);
/** Every screen's menu builders, newest first: what pinned panels re-read. */
const live = new LiveMenus();
/** 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 } {
/** One live source key per menu call of a component (`id` when given, else its call order in the render); the keys a
* render no longer makes are dropped after it commits, all of them when the component unmounts. */
function useLiveKeys(): (id?: string) => string {
const hid = useId();
const seen = useRef(new Set<string>());
const n = useRef(0);
seen.current = new Set();
n.current = 0;
useEffect(() => { live.retain(hid, seen.current); });
useEffect(() => () => live.retain(hid), [hid]);
return useCallback((id?: string) => {
const k = `${hid}#${id ?? n.current++}`;
seen.current.add(k);
return k;
}, [hid]);
}
/** Props that attach a menu to an element: `<button {...menu(() => entries, id)}>`. Entries are built when it opens
* (and again for a pinned copy whenever the element re-renders). `id`: what the menu is about, unique in the component
* (an app key), so a pinned panel follows that thing even when rows reorder. */
export function useContextMenu(): (build: () => MenuInput, id?: string) => { 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]);
const key = useLiveKeys();
return useCallback((build: () => MenuInput, id?: string) => {
const source = key(id);
live.set(source, build);
return {
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], source });
},
};
}, [open, key]);
}
/** Props for a "..." overflow button that opens the same menu under itself (click, Enter or Space). */
export function useMenuButton(): (build: () => MenuInput) => { onClick: (e: ReactMouseEvent) => void; 'aria-haspopup': 'menu' } {
export function useMenuButton(): (build: () => MenuInput, id?: string) => { onClick: (e: ReactMouseEvent) => void; 'aria-haspopup': 'menu' } {
const open = useContext(Ctx);
return useCallback((build: () => MenuInput) => ({
'aria-haspopup': 'menu' as const,
onClick: (e: ReactMouseEvent) => {
if (!open) return;
const r = (e.currentTarget as Element).getBoundingClientRect();
open(Math.round(r.left), Math.round(r.bottom + 4), build(), { from: e.currentTarget as Element, keyboard: e.detail === 0 });
},
}), [open]);
const key = useLiveKeys();
return useCallback((build: () => MenuInput, id?: string) => {
const source = key(id);
live.set(source, build);
return {
'aria-haspopup': 'menu' as const,
onClick: (e: ReactMouseEvent) => {
if (!open) return;
const r = (e.currentTarget as Element).getBoundingClientRect();
open(Math.round(r.left), Math.round(r.bottom + 4), build(), { from: e.currentTarget as Element, keyboard: e.detail === 0, source });
},
};
}, [open, key]);
}
/** Copies and says so in a toast-less way (the menu closes; failures are silent like the browser's own Copy). */
@ -75,19 +114,48 @@ function editMenu(el: HTMLInputElement | HTMLTextAreaElement | HTMLElement): Men
]);
}
/** What a panel was opened with (kept beside the pure stack entry). */
interface Extra { items: MenuEntry[]; from: Element | null; keyboard: boolean }
export function ContextMenuProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<Open | null>(null);
const [stack, setStack] = useState<StackEntry[]>([]);
const stackRef = useRef(stack);
stackRef.current = stack;
const extras = useRef(new Map<string, Extra>());
const seq = useRef(0);
const nextUid = () => `m${++seq.current}`;
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 uid = nextUid();
extras.current.set(uid, { items, from: opts?.from ?? (document.activeElement as Element | null), keyboard: !!opts?.keyboard });
setStack(s => openMenu(s, { uid, source: opts?.source ?? null, path: [], pinned: false, x, y }));
}, []);
const close = useCallback((restore: boolean) => {
setState(s => {
if (restore && s?.from instanceof HTMLElement && s.from.isConnected) s.from.focus({ preventScroll: true });
return null;
});
/** Every unpinned menu closes; `restore` puts the focus back where the last one was opened. */
const closeTransient = useCallback((restore: boolean) => {
const last = [...stackRef.current].reverse().find(e => !e.pinned);
if (!last) return;
const from = extras.current.get(last.uid)?.from;
if (restore && from instanceof HTMLElement && from.isConnected) from.focus({ preventScroll: true });
setStack(s => closeUnpinned(s));
}, []);
const pin = useCallback((uid: string, pos: Pos) => setStack(s => pinMenu(s, uid, pos)), []);
/** A submenu at `path` of panel `uid` becomes its own pinned panel at `pos`. */
const tear = useCallback((uid: string, path: string[], pos: Pos, closeOthers: boolean) => {
const parent = stackRef.current.find(e => e.uid === uid);
const extra = extras.current.get(uid);
if (!parent || !extra) return;
const id = nextUid();
extras.current.set(id, { items: extra.items, from: null, keyboard: false });
setStack(s => tearOff(closeOthers ? closeUnpinned(s) : s, { uid: id, source: parent.source, path: [...parent.path, ...path], pinned: true, x: pos.x, y: pos.y }, pos));
}, []);
// what a closed panel was opened with is not kept
useEffect(() => {
const ids = new Set(stack.map(e => e.uid));
for (const k of [...extras.current.keys()]) if (!ids.has(k)) extras.current.delete(k);
}, [stack]);
useEffect(() => {
// the fallback for everything no screen menu handled: never the browser's menu
@ -118,15 +186,30 @@ export function ContextMenuProvider({ children }: { children: ReactNode }) {
return () => { document.removeEventListener('contextmenu', onMenu); document.removeEventListener('keydown', onKey, true); };
}, [open]);
const hasTransient = stack.some(e => !e.pinned);
return (
<Ctx.Provider value={open}>
{children}
{state && createPortal(<MenuRoot key={`${state.x},${state.y}`} state={state} close={close} />, document.body)}
{stack.length > 0 && createPortal(<>
{hasTransient && <OutsideCloser close={closeTransient} />}
{stack.map(e => {
const extra = extras.current.get(e.uid);
if (!extra) return null;
return e.pinned
? <PinnedPanel key={e.uid} entry={e} extra={extra} closeTransient={closeTransient}
onClose={() => setStack(s => closeMenu(s, e.uid))} onUnpin={() => setStack(s => unpinMenu(s, e.uid))}
onMove={p => setStack(s => moveMenu(s, e.uid, p))} onTear={(path, p) => tear(e.uid, path, p, false)} />
: <MenuList key={e.uid} items={extra.items} at={{ x: e.x, y: e.y }} close={closeTransient} root keyboard={extra.keyboard} path={[]}
onPin={r => pin(e.uid, { x: r.left, y: r.top })} onTear={(path, p, others) => tear(e.uid, path, p, others)} />;
})}
</>, document.body)}
</Ctx.Provider>
);
}
function MenuRoot({ state, close }: { state: Open; close: (restore: boolean) => void }) {
/** While a normal menu is open: a click outside every menu, a scroll outside, blur or resize closes it (pinned panels
* stay). */
function OutsideCloser({ close }: { 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); };
@ -142,12 +225,43 @@ function MenuRoot({ state, close }: { state: Open; close: (restore: boolean) =>
window.removeEventListener('resize', blur);
};
}, [close]);
return <MenuList items={state.items} at={{ x: state.x, y: state.y }} close={close} root keyboard={state.keyboard} />;
return null;
}
type Anchor = { x: number; y: number } | { left: number; right: number; top: number };
/** A pinned panel: its entries re-read from the live source on every change of the screen that registered it. */
function PinnedPanel({ entry, extra, closeTransient, onClose, onUnpin, onMove, onTear }: {
entry: StackEntry; extra: Extra; closeTransient: (restore: boolean) => void; onClose: () => void; onUnpin: () => void;
onMove: (p: Pos) => void; onTear: (path: string[], p: Pos) => void;
}) {
const [, bump] = useReducer((n: number) => n + 1, 0);
useEffect(() => (entry.source ? live.watch(entry.source, bump) : undefined), [entry.source]);
const root = entry.source ? live.get(entry.source) ?? null : extra.items;
const items = root ? resolvePath(root, entry.path) : null;
const title = entry.path.length ? entry.path[entry.path.length - 1]! : t('cm.menu');
return (
<MenuList items={items ?? []} gone={!items} at={{ fx: entry.x, fy: entry.y }} close={() => {}} root path={[]}
pinned={{ title, onClose, onUnpin, onMove }} done={() => closeTransient(false)} onTear={(path, p) => onTear(path, p)} />
);
}
function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEntry[]; at: Anchor; close: (restore: boolean) => void; root?: boolean; keyboard?: boolean; onBack?: () => void }) {
type Anchor = { x: number; y: number } | { left: number; right: number; top: number } | { fx: number; fy: number };
interface PinnedHead { title: string; onClose: () => void; onUnpin: () => void; onMove: (p: Pos) => void }
function MenuList({ items, at, close, root, keyboard, onBack, path, onPin, onTear, pinned, gone, done }: {
items: MenuEntry[]; at: Anchor; close: (restore: boolean) => void; root?: boolean; keyboard?: boolean; onBack?: () => void;
/** Submenu labels from the panel's root to this list. */
path: string[];
/** The pin button of a normal (root) menu. */
onPin?: (rect: DOMRect) => void;
/** A submenu torn off: its path (from the panel root), where, and whether the normal menus close. */
onTear?: (path: string[], pos: Pos, closeOthers: boolean) => void;
/** This is a pinned panel's root list: header with title, unpin, close, drag. */
pinned?: PinnedHead;
gone?: boolean;
/** After a pick in a pinned panel (normal menus close; the panel stays). */
done?: () => void;
}) {
const uid = useId();
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
@ -155,19 +269,33 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
const [sub, setSub] = useState<{ index: number; anchor: Anchor; focus: boolean } | null>(null);
const typed = useRef({ q: '', at: 0 });
const hoverTimer = useRef<number | undefined>(undefined);
const shown = useRef(false);
const drag = useRef<{ dx: number; dy: number } | null>(null);
const fixed = 'fx' in at ? at : null;
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]);
if ('fx' in at) { const p = clampPos({ x: at.fx, y: at.fy }, box, view); setPos({ left: p.x, top: p.y }); }
else setPos('x' in at ? placeAt(at.x, at.y, box, view) : placeBeside(at, box, view));
// a pinned panel follows its position (drag) and stays inside the window when it shrinks
}, ['fx' in at ? at.fx : 'x' in at ? at.x : at.left, 'fx' in at ? at.fy : 'x' in at ? at.y : at.top, items.length]);
useEffect(() => {
if (!pos) return;
if (!fixed) return;
const onResize = () => { const el = ref.current; if (el) { const p = clampPos({ x: fixed.fx, y: fixed.fy }, { w: el.offsetWidth, h: el.offsetHeight }, { w: window.innerWidth, h: window.innerHeight }); setPos({ left: p.x, top: p.y }); } };
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [fixed?.fx, fixed?.fy]);
useEffect(() => {
if (!pos || shown.current) return;
shown.current = true;
// a pinned panel never takes the focus by itself (it updates while the owner works elsewhere)
if (pinned) 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]);
}, [pos, root, keyboard, items, pinned]);
useEffect(() => () => window.clearTimeout(hoverTimer.current), []);
const itemEl = (i: number) => ref.current?.querySelector<HTMLElement>(`[data-i="${i}"]`) ?? null;
@ -177,11 +305,22 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
const r = el.getBoundingClientRect();
setSub({ index: i, anchor: { left: r.left, right: r.right, top: r.top }, focus });
};
const choose = (i: number) => {
const tearAt = (i: number) => {
const e = items[i];
const el = itemEl(i);
if (!isItem(e) || !e.submenu || !el || !onTear) return;
const r = el.getBoundingClientRect();
onTear([...path, e.label], { x: Math.round(r.right + 6), y: Math.round(r.top - 30) }, false);
};
const choose = (i: number, ev?: { button: number; ctrlKey: boolean; metaKey: boolean }) => {
const e = items[i];
if (!enabled(e)) return;
if (e.submenu) { openSub(i, true); return; }
close(true);
if (e.submenu) {
if (ev && (ev.button === 1 || ev.ctrlKey || ev.metaKey)) { tearAt(i); return; }
openSub(i, true);
return;
}
if (done) { setSub(null); done(); } else close(true);
// after the menu is gone, so the action sees the restored focus (paste, select all)
window.setTimeout(() => e.onSelect?.(), 0);
};
@ -197,8 +336,8 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
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 === 'Escape') { stop(); if (onBack) onBack(); else if (pinned) ref.current?.blur(); else close(true); }
else if (k === 'Tab') { stop(); if (!pinned) close(true); }
else if (k.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
stop();
const now = Date.now();
@ -211,12 +350,44 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
useEffect(() => { if (active >= 0) itemEl(active)?.scrollIntoView({ block: 'nearest' }); }, [active]);
// a pinned panel is dragged by its header
const onHeadDown = (e: ReactPointerEvent) => {
if (!pinned || e.button !== 0 || (e.target as Element).closest('button') || !pos) return;
drag.current = { dx: e.clientX - pos.left, dy: e.clientY - pos.top };
(e.currentTarget as Element).setPointerCapture(e.pointerId);
};
const onHeadMove = (e: ReactPointerEvent) => {
if (!drag.current || !pinned) return;
pinned.onMove({ x: Math.round(e.clientX - drag.current.dx), y: Math.round(e.clientY - drag.current.dy) });
};
const onHeadUp = () => { drag.current = null; };
// the header: a pinned panel's title, unpin and close; a normal menu's (or submenu's) pin button
const pinHere = () => {
if (pinned) return pinned.onUnpin();
const r = ref.current?.getBoundingClientRect();
if (!r) return;
if (path.length) onTear?.(path, { x: Math.round(r.left), y: Math.round(r.top) }, true);
else onPin?.(r);
};
const canPin = !!pinned || (path.length ? !!onTear : !!onPin);
const head = canPin && (
<div className={`ds-menu-head${pinned ? ' pinned' : ''}`} onPointerDown={onHeadDown} onPointerMove={onHeadMove} onPointerUp={onHeadUp} onPointerCancel={onHeadUp}>
<span className="ds-menu-title">{pinned ? pinned.title : path.length ? path[path.length - 1] : ''}</span>
<button type="button" className={`ds-menu-btn${pinned ? ' on' : ''}`} title={pinned ? t('cm.unpin') : t('cm.pin')} aria-label={pinned ? t('cm.unpin') : t('cm.pin')}
aria-pressed={!!pinned} onClick={pinHere}><Icon name={pinned ? 'pinOff' : 'pin'} /></button>
{pinned && <button type="button" className="ds-menu-btn" title={t('cm.closePanel')} aria-label={t('cm.closePanel')} onClick={pinned.onClose}><Icon name="x" /></button>}
</div>
);
return (
<>
<div ref={ref} className={`ds-menu${pos ? ' in' : ''}`} role="menu" tabIndex={-1} onKeyDown={onKey}
<div ref={ref} className={`ds-menu${pos ? ' in' : ''}${pinned ? ' pinned' : ''}`} 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}
aria-label={pinned?.title} aria-activedescendant={active >= 0 ? `${uid}-${active}` : undefined}
onContextMenu={e => e.preventDefault()}>
{head}
{gone && <div className="ds-menu-gone">{t('cm.gone')}</div>}
{items.map((e, i) => {
if (!isItem(e)) return <div key={`s${i}`} className="ds-menu-sep" role="separator" />;
const dis = !!e.disabled;
@ -225,7 +396,7 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
<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}
title={typeof e.disabled === 'string' ? e.disabled : e.submenu && onTear ? t('cm.tearOff') : undefined}
className={`ds-menu-item${i === active ? ' active' : ''}${e.danger ? ' danger' : ''}${dis ? ' disabled' : ''}`}
onPointerMove={() => {
if (active !== i) setActive(dis ? -1 : i);
@ -233,7 +404,9 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
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)}>
onMouseDown={ev => { if (ev.button === 1) ev.preventDefault(); }}
onAuxClick={ev => { if (ev.button === 1) choose(i, ev); }}
onClick={ev => choose(i, ev)}>
<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>}
@ -243,7 +416,8 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
})}
</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}
<MenuList key={sub.index} items={(items[sub.index] as MenuItem).submenu as MenuEntry[]} at={sub.anchor} close={close} done={done}
path={[...path, (items[sub.index] as MenuItem).label]} onTear={onTear}
onBack={() => { setSub(null); ref.current?.focus({ preventScroll: true }); }} />
)}
</>

View file

@ -55,6 +55,8 @@ import { PauseIcon } from '@phosphor-icons/react/dist/csr/Pause';
import { PencilSimpleIcon } from '@phosphor-icons/react/dist/csr/PencilSimple';
import { PlayIcon } from '@phosphor-icons/react/dist/csr/Play';
import { PlusIcon } from '@phosphor-icons/react/dist/csr/Plus';
import { PushPinIcon } from '@phosphor-icons/react/dist/csr/PushPin';
import { PushPinSlashIcon } from '@phosphor-icons/react/dist/csr/PushPinSlash';
import { PulseIcon } from '@phosphor-icons/react/dist/csr/Pulse';
import { QuestionIcon } from '@phosphor-icons/react/dist/csr/Question';
import { RocketLaunchIcon } from '@phosphor-icons/react/dist/csr/RocketLaunch';
@ -89,7 +91,7 @@ const ICONS = {
more: DotsThreeIcon, gauge: GaugeIcon, eye: EyeIcon, eyeOff: EyeSlashIcon, link: LinkIcon, unlink: LinkBreakIcon, bell: BellIcon, filter: FunnelIcon, play: PlayIcon,
send: PaperPlaneRightIcon, edit: PencilSimpleIcon, upload: UploadSimpleIcon, sliders: SlidersHorizontalIcon,
rotate: ArrowCounterClockwiseIcon, cloud: CloudIcon, coins: CoinsIcon, chart: TrendUpIcon, flask: FlaskIcon, grid: GridFourIcon,
clipboard: ClipboardTextIcon, share: ExportIcon, lock: LockIcon, question: QuestionIcon,
clipboard: ClipboardTextIcon, share: ExportIcon, lock: LockIcon, question: QuestionIcon, pin: PushPinIcon, pinOff: PushPinSlashIcon,
} satisfies Record<string, PhosphorIcon>;
export type IconName = keyof typeof ICONS;

View file

@ -0,0 +1,140 @@
// The in-app context menus' panel stack without React or the DOM (tested in tests/menu-stack.test.ts): several menus
// open at once, a submenu chain per menu, pinned panels that survive the next menu, step aside so they never sit on
// each other and stay inside the window; plus the live sources pinned panels re-read their entries from.
import type { MenuEntry, MenuInput, MenuItem } from './menu-logic';
import { tidy } from './menu-logic';
export interface Pos { x: number; y: number }
/** One open panel: a root menu, or a submenu torn off (pinned) at `path` (submenu labels from the root). */
export interface StackEntry {
uid: string;
/** The live source of its entries (`liveKey`), or null for a menu built once (the edit menu). */
source: string | null;
path: readonly string[];
pinned: boolean;
x: number;
y: number;
}
/** Pinned panels step this far (CSS px) until no other pinned panel sits there. */
export const OFFSET_STEP = 24;
const same = (a: Pick<StackEntry, 'source' | 'path'>, b: Pick<StackEntry, 'source' | 'path'>) =>
a.source !== null && a.source === b.source && a.path.length === b.path.length && a.path.every((p, i) => p === b.path[i]);
/** Opening a menu closes only the unpinned ones. */
export function openMenu(list: readonly StackEntry[], entry: StackEntry): StackEntry[] {
return [...list.filter(e => e.pinned), entry];
}
/** Outside click, Esc, a pick, scroll, blur: every unpinned panel closes (the same list when there is none). */
export function closeUnpinned(list: readonly StackEntry[]): StackEntry[] {
return list.some(e => !e.pinned) ? list.filter(e => e.pinned) : (list as StackEntry[]);
}
export function closeMenu(list: readonly StackEntry[], uid: string): StackEntry[] {
return list.some(e => e.uid === uid) ? list.filter(e => e.uid !== uid) : (list as StackEntry[]);
}
/** A free spot: steps down-right until no taken spot is within a step of it. */
export function autoOffset(taken: readonly Pos[], pos: Pos, step = OFFSET_STEP): Pos {
let next = { ...pos };
for (let k = 0; k < 200 && taken.some(t => Math.abs(t.x - next.x) < step && Math.abs(t.y - next.y) < step); k++) next = { x: next.x + step, y: next.y + step };
return next;
}
const taken = (list: readonly StackEntry[], skip?: string): Pos[] => list.filter(e => e.pinned && e.uid !== skip).map(e => ({ x: e.x, y: e.y }));
/** Pins an open panel where it is (moved off other pinned panels). A pinned copy of the same panel is replaced. */
export function pinMenu(list: readonly StackEntry[], uid: string, pos: Pos): StackEntry[] {
const entry = list.find(e => e.uid === uid);
if (!entry || entry.pinned) return list as StackEntry[];
const rest = list.filter(e => !(e.pinned && same(e, entry)));
const at = autoOffset(taken(rest, uid), pos);
return rest.map(e => (e.uid === uid ? { ...e, pinned: true, x: at.x, y: at.y } : e));
}
/** A submenu torn off its menu (pin button on a submenu, Ctrl / middle click on its row): a new pinned panel. The menu
* it came from stays open. */
export function tearOff(list: readonly StackEntry[], entry: StackEntry, pos: Pos): StackEntry[] {
const rest = list.filter(e => !(e.pinned && same(e, entry)));
const at = autoOffset(taken(rest), pos);
return [...rest, { ...entry, pinned: true, x: at.x, y: at.y }];
}
/** Unpin: a root menu becomes a normal menu again (it closes with the next one); a torn-off submenu closes. */
export function unpinMenu(list: readonly StackEntry[], uid: string): StackEntry[] {
const entry = list.find(e => e.uid === uid);
if (!entry?.pinned) return list as StackEntry[];
if (entry.path.length > 0) return closeMenu(list, uid);
return list.map(e => (e.uid === uid ? { ...e, pinned: false } : e));
}
export function moveMenu(list: readonly StackEntry[], uid: string, pos: Pos): StackEntry[] {
return list.map(e => (e.uid === uid ? { ...e, x: pos.x, y: pos.y } : e));
}
/** Keeps a panel of `size` inside `box` (the window), `margin` from its edges. */
export function clampPos(pos: Pos, size: { w: number; h: number }, box: { w: number; h: number }, margin = 8): Pos {
return { x: Math.max(margin, Math.min(pos.x, box.w - size.w - margin)), y: Math.max(margin, Math.min(pos.y, box.h - size.h - margin)) };
}
/** The entries at `path` (submenu labels) inside a menu, or null when that submenu is gone. */
export function resolvePath(items: readonly MenuEntry[], path: readonly string[]): MenuEntry[] | null {
let cur = items as MenuEntry[];
for (const label of path) {
const next = cur.find((e): e is MenuItem => e !== 'sep' && e.label === label && !!e.submenu);
if (!next) return null;
cur = next.submenu as MenuEntry[];
}
return cur;
}
// ---- live sources ----
/**
* The menus' live sources: every `useContextMenu` / `useMenuButton` call registers the builder it got on each render,
* so a pinned panel re-reads its entries from the newest builder (fresh data, fresh actions) and updates in place. A
* source that is gone (its row unmounted) gives null: the panel says so instead of closing.
*/
export class LiveMenus {
private builders = new Map<string, () => MenuInput>();
private subs = new Map<string, Set<() => void>>();
private pending = new Set<string>();
constructor(private schedule: (fn: () => void) => void = fn => queueMicrotask(fn)) {}
/** Registers (or replaces) the builder of `key`; its watchers hear about it once per batch. */
set(key: string, build: () => MenuInput): void {
this.builders.set(key, build);
this.ping(key);
}
delete(key: string): void {
if (this.builders.delete(key)) this.ping(key);
}
/** Keeps only the keys of one hook (`prefix#...`) that its last render registered; `keep` empty drops them all. */
retain(prefix: string, keep: ReadonlySet<string> = new Set()): void {
for (const k of [...this.builders.keys()]) if (k.startsWith(`${prefix}#`) && !keep.has(k)) this.delete(k);
}
/** The entries of `key` now (tidied), or null when it is gone. */
get(key: string): MenuEntry[] | null {
const b = this.builders.get(key);
if (!b) return null;
try { return tidy(b()); } catch { return null; }
}
has(key: string): boolean { return this.builders.has(key); }
watch(key: string, fn: () => void): () => void {
let s = this.subs.get(key);
if (!s) { s = new Set(); this.subs.set(key, s); }
s.add(fn);
return () => { s.delete(fn); if (!s.size) this.subs.delete(key); };
}
private ping(key: string): void {
if (!this.subs.has(key) || this.pending.has(key)) return;
this.pending.add(key);
this.schedule(() => {
this.pending.delete(key);
for (const fn of [...(this.subs.get(key) ?? [])]) fn();
});
}
}