authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-10 00:29:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-10 07:29:21+00:00
log3efabdcd3a7a6f0ed56443197a65a3a474cddb6c
tree8668cc68fb0692ac69450ed87c3e5a3512e8d26f
parentc5f7cd40ba00c015373fdb95818916642917e000
signature Signed by PGP key B5690EEEBB952194

Polish web context menu fallback and sidebar icon actions (#3025)

Co-authored-by: ss <ss@barekey.dev> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

6 files changed, 215 insertions(+), 31 deletions(-)

apps/desktop/src/electron/ElectronMenu.ts+6
......@@ -44,6 +44,12 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
4444 continue;
4545 }
4646
47 // Header items are decorative section labels for the web fallback only —
48 // Electron's native menu has no equivalent affordance, so we skip them.
49 if (sourceItem.header === true) {
50 continue;
51 }
52
4753 const normalizedItem: ContextMenuItem = {
4854 id: sourceItem.id,
4955 label: sourceItem.label,
apps/web/src/components/CommandPalette.logic.ts+2
......@@ -95,6 +95,7 @@ export function buildProjectActionItems(input: {
9595 valuePrefix: string;
9696 icon: (project: Project) => ReactNode;
9797 runProject: (project: Project) => Promise<void>;
98 shortcutCommand?: KeybindingCommand;
9899}): CommandPaletteActionItem[] {
99100 return input.projects.map((project) => ({
100101 kind: "action",
......@@ -103,6 +104,7 @@ export function buildProjectActionItems(input: {
103104 title: project.name,
104105 description: project.cwd,
105106 icon: input.icon(project),
107 ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
106108 run: async () => {
107109 await input.runProject(project);
108110 },
apps/web/src/components/CommandPalette.tsx+1
......@@ -647,6 +647,7 @@ function OpenCommandPaletteDialog() {
647647 buildProjectActionItems({
648648 projects,
649649 valuePrefix: "new-thread-in",
650 shortcutCommand: "chat.new",
650651 icon: (project) => (
651652 <ProjectFavicon
652653 environmentId={project.environmentId}
apps/web/src/components/Sidebar.tsx+20-16
......@@ -217,6 +217,8 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> =
217217 repository_path: "Group by repository path",
218218 separate: "Keep separate",
219219};
220const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
221 "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring";
220222
221223function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {
222224 return Math.min(
......@@ -635,7 +637,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
635637 data-thread-selection-safe
636638 data-testid={`thread-archive-confirm-${thread.id}`}
637639 aria-label={`Confirm archive ${thread.title}`}
638 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
640 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-md bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
639641 onPointerDown={stopPropagationOnPointerDown}
640642 onClick={handleConfirmArchiveClick}
641643 >
......@@ -643,13 +645,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
643645 </button>
644646 ) : !isThreadRunning ? (
645647 appSettingsConfirmThreadArchive ? (
646 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
648 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
647649 <button
648650 type="button"
649651 data-thread-selection-safe
650652 data-testid={`thread-archive-${thread.id}`}
651653 aria-label={`Archive ${thread.title}`}
652 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
654 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
653655 onPointerDown={stopPropagationOnPointerDown}
654656 onClick={handleStartArchiveConfirmation}
655657 >
......@@ -660,13 +662,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
660662 <Tooltip>
661663 <TooltipTrigger
662664 render={
663 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
665 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
664666 <button
665667 type="button"
666668 data-thread-selection-safe
667669 data-testid={`thread-archive-${thread.id}`}
668670 aria-label={`Archive ${thread.title}`}
669 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
671 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
670672 onPointerDown={stopPropagationOnPointerDown}
671673 onClick={handleArchiveImmediateClick}
672674 >
......@@ -712,7 +714,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
712714 </Tooltip>
713715 ) : (
714716 <span
715 className={`text-[10px] ${
717 className={`text-[10px] tabular-nums ${
716718 isHighlighted
717719 ? "text-foreground/72 dark:text-foreground/82"
718720 : "text-muted-foreground/40"
......@@ -1495,12 +1497,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14951497 ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}),
14961498 }),
14971499 label,
1500 ...(action === "delete" ? { icon: "trash" } : {}),
14981501 };
14991502 }
15001503
15011504 return {
15021505 id: `${action}:submenu`,
15031506 label,
1507 ...(action === "delete" ? { icon: "trash" } : {}),
15041508 children: project.memberProjects.map((member) =>
15051509 makeLeaf(action, member, {
15061510 ...(options?.destructive ? { destructive: true } : {}),
......@@ -1512,10 +1516,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15121516
15131517 const clicked = await api.contextMenu.show(
15141518 [
1515 buildTargetedItem("rename", "Rename project"),
1516 buildTargetedItem("grouping", "Project grouping…"),
1517 buildTargetedItem("copy-path", "Copy Project Path"),
1518 buildTargetedItem("delete", "Remove project", {
1519 buildTargetedItem("rename", "Rename"),
1520 buildTargetedItem("grouping", "Group into..."),
1521 buildTargetedItem("copy-path", "Copy Path"),
1522 buildTargetedItem("delete", "Remove", {
15191523 destructive: true,
15201524 }),
15211525 ],
......@@ -1931,7 +1935,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19311935 { id: "mark-unread", label: "Mark unread" },
19321936 { id: "copy-path", label: "Copy Path" },
19331937 { id: "copy-thread-id", label: "Copy Thread ID" },
1934 { id: "delete", label: "Delete", destructive: true },
1938 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
19351939 ],
19361940 position,
19371941 );
......@@ -2073,12 +2077,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
20732077 <Tooltip>
20742078 <TooltipTrigger
20752079 render={
2076 <div className="pointer-events-none absolute top-1 right-1.5 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
2080 <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
20772081 <button
20782082 type="button"
20792083 aria-label={`Create new thread in ${project.displayName}`}
20802084 data-testid="new-thread-button"
2081 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 hover:bg-secondary hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
2085 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
20822086 onClick={handleCreateThreadClick}
20832087 >
20842088 <SquarePenIcon className="size-3.5" />
......@@ -2316,7 +2320,7 @@ function ProjectSortMenu({
23162320 <Tooltip>
23172321 <TooltipTrigger
23182322 render={
2319 <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />
2323 <MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />
23202324 }
23212325 >
23222326 <ArrowUpDownIcon className="size-3.5" />
......@@ -2654,7 +2658,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
26542658 />
26552659 }
26562660 >
2657 <SearchIcon className="size-3.5" />
2661 <SearchIcon className="size-3.5 text-muted-foreground/70" />
26582662 <span className="flex-1 truncate text-left text-xs">Search</span>
26592663 {commandPaletteShortcutLabel ? (
26602664 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">
......@@ -2711,7 +2715,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
27112715 type="button"
27122716 aria-label="Add project"
27132717 data-testid="sidebar-add-project-trigger"
2714 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
2718 className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
27152719 onClick={openAddProject}
27162720 />
27172721 }
apps/web/src/contextMenuFallback.ts+178-15
......@@ -1,5 +1,74 @@
11import type { ContextMenuItem } from "@t3tools/contracts";
22
3const SVG_NS = "http://www.w3.org/2000/svg";
4
5// Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2).
6const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = {
7 pencil: [
8 {
9 tag: "path",
10 attrs: {
11 d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",
12 },
13 },
14 { tag: "path", attrs: { d: "m15 5 4 4" } },
15 ],
16 copy: [
17 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },
18 { tag: "path", attrs: { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" } },
19 ],
20 "folder-tree": [
21 {
22 tag: "path",
23 attrs: {
24 d: "M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z",
25 },
26 },
27 {
28 tag: "path",
29 attrs: {
30 d: "M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z",
31 },
32 },
33 { tag: "path", attrs: { d: "M3 5a2 2 0 0 0 2 2h3" } },
34 { tag: "path", attrs: { d: "M3 3v13a2 2 0 0 0 2 2h3" } },
35 ],
36 trash: [
37 { tag: "path", attrs: { d: "M3 6h18" } },
38 { tag: "path", attrs: { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" } },
39 { tag: "path", attrs: { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" } },
40 { tag: "line", attrs: { x1: "10", x2: "10", y1: "11", y2: "17" } },
41 { tag: "line", attrs: { x1: "14", x2: "14", y1: "11", y2: "17" } },
42 ],
43};
44
45function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
46 const paths = ICON_PATHS[name];
47 if (!paths) {
48 return null;
49 }
50 const svg = document.createElementNS(SVG_NS, "svg");
51 svg.setAttribute("xmlns", SVG_NS);
52 svg.setAttribute("viewBox", "0 0 24 24");
53 svg.setAttribute("fill", "none");
54 svg.setAttribute("stroke", "currentColor");
55 svg.setAttribute("stroke-width", "2");
56 svg.setAttribute("stroke-linecap", "round");
57 svg.setAttribute("stroke-linejoin", "round");
58 svg.setAttribute(
59 "class",
60 tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground",
61 );
62 for (const node of paths) {
63 const child = document.createElementNS(SVG_NS, node.tag);
64 for (const [key, value] of Object.entries(node.attrs)) {
65 child.setAttribute(key, value);
66 }
67 svg.appendChild(child);
68 }
69 return svg;
70}
71
372function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) {
473 const rect = menu.getBoundingClientRect();
574 const left = Math.min(
......@@ -14,6 +83,24 @@ function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferre
1483 menu.style.top = `${top}px`;
1584}
1685
86function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly HTMLDivElement[]) {
87 if (typeof Node !== "undefined" && target instanceof Node) {
88 return menuStack.some((menu) => menu.contains(target));
89 }
90 if (!target || typeof target !== "object") {
91 return false;
92 }
93
94 let current: unknown = target;
95 while (current && typeof current === "object") {
96 if (menuStack.includes(current as HTMLDivElement)) {
97 return true;
98 }
99 current = (current as { parent?: unknown }).parent;
100 }
101 return false;
102}
103
17104/**
18105 * Imperative DOM-based context menu for non-Electron environments.
19106 * Supports nested submenus and resolves with the clicked leaf item id.
......@@ -23,14 +110,18 @@ export function showContextMenuFallback<T extends string>(
23110 position?: { x: number; y: number },
24111): Promise<T | null> {
25112 return new Promise<T | null>((resolve) => {
26 const overlay = document.createElement("div");
27 overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
28
29113 const menuStack: HTMLDivElement[] = [];
114 let isDisposed = false;
115 let canDismissFromPointer = false;
30116
31117 const cleanup = (result: T | null) => {
118 if (isDisposed) {
119 return;
120 }
121 isDisposed = true;
32122 document.removeEventListener("keydown", onKeyDown);
33 overlay.remove();
123 document.removeEventListener("pointerdown", onPointerDown, true);
124 document.removeEventListener("contextmenu", onContextMenu, true);
34125 for (const menu of menuStack) {
35126 menu.remove();
36127 }
......@@ -44,6 +135,21 @@ export function showContextMenuFallback<T extends string>(
44135 }
45136 };
46137
138 const onPointerDown = (event: PointerEvent) => {
139 if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) {
140 return;
141 }
142 cleanup(null);
143 };
144
145 const onContextMenu = (event: MouseEvent) => {
146 if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) {
147 return;
148 }
149 event.preventDefault();
150 cleanup(null);
151 };
152
47153 const closeMenusFromLevel = (level: number) => {
48154 while (menuStack.length > level) {
49155 menuStack.pop()?.remove();
......@@ -60,24 +166,60 @@ export function showContextMenuFallback<T extends string>(
60166
61167 const menu = document.createElement("div");
62168 menu.className =
63 "fixed z-[10000] min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95";
169 "fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg border border-border bg-popover bg-clip-padding text-popover-foreground shadow-lg/5 outline-none";
170 menu.style.cssText =
171 "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);border:1px solid var(--border);background:var(--popover);background-clip:padding-box;color:var(--popover-foreground);box-shadow:0 10px 15px -3px rgb(0 0 0 / 0.05),0 4px 6px -4px rgb(0 0 0 / 0.05);outline:none;pointer-events:auto;";
64172 menu.style.left = `${preferredLeft}px`;
65173 menu.style.top = `${preferredTop}px`;
66174 menu.dataset.level = String(level);
67175
176 const inner = document.createElement("div");
177 inner.className =
178 "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1";
179 inner.style.cssText =
180 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
181
68182 for (const item of entries) {
69 const button = document.createElement("button");
70 button.type = "button";
183 if (item.header === true) {
184 const header = document.createElement("div");
185 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
186 header.textContent = item.label;
187 inner.appendChild(header);
188 continue;
189 }
190
71191 const hasChildren = Array.isArray(item.children) && item.children.length > 0;
72192 const isLeafDestructive =
73193 !hasChildren && (item.destructive === true || item.id === ("delete" as T));
194
195 const button = document.createElement("button");
196 button.type = "button";
74197 const isDisabled = item.disabled === true;
75198 button.disabled = isDisabled;
199 const rowBase =
200 "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base";
76201 button.className = isDisabled
77 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed"
202 ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64`
78203 : isLeafDestructive
79 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"
80 : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default";
204 ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground`
205 : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`;
206 button.style.cssText =
207 "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;";
208 if (isLeafDestructive) {
209 button.style.color = "var(--destructive-foreground)";
210 }
211 if (isDisabled) {
212 button.style.color = "var(--muted-foreground)";
213 button.style.opacity = "0.64";
214 button.style.pointerEvents = "none";
215 }
216
217 if (typeof item.icon === "string") {
218 const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral");
219 if (icon) {
220 button.appendChild(icon);
221 }
222 }
81223
82224 const label = document.createElement("span");
83225 label.className = "min-w-0 flex-1 truncate";
......@@ -86,12 +228,27 @@ export function showContextMenuFallback<T extends string>(
86228
87229 if (hasChildren) {
88230 const chevron = document.createElement("span");
89 chevron.className = "shrink-0 text-muted-foreground/70";
90 chevron.textContent = "›";
231 chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none";
232 chevron.textContent = ">";
91233 button.appendChild(chevron);
92234 }
93235
94236 if (!isDisabled) {
237 button.addEventListener("mouseenter", () => {
238 button.style.background = isLeafDestructive
239 ? "color-mix(in srgb, var(--destructive) 10%, transparent)"
240 : "var(--accent)";
241 button.style.color = isLeafDestructive
242 ? "var(--destructive-foreground)"
243 : "var(--accent-foreground)";
244 });
245 button.addEventListener("mouseleave", () => {
246 button.style.background = "transparent";
247 button.style.color = isLeafDestructive
248 ? "var(--destructive-foreground)"
249 : "var(--foreground)";
250 });
251
95252 if (hasChildren) {
96253 button.addEventListener("mouseenter", () => {
97254 const rect = button.getBoundingClientRect();
......@@ -119,9 +276,11 @@ export function showContextMenuFallback<T extends string>(
119276 }
120277 }
121278
122 menu.appendChild(button);
279 inner.appendChild(button);
123280 }
124281
282 menu.appendChild(inner);
283
125284 menu.addEventListener("mouseenter", () => {
126285 closeMenusFromLevel(level + 1);
127286 });
......@@ -134,9 +293,13 @@ export function showContextMenuFallback<T extends string>(
134293 });
135294 };
136295
137 overlay.addEventListener("mousedown", () => cleanup(null));
138296 document.addEventListener("keydown", onKeyDown);
139 document.body.appendChild(overlay);
297 document.addEventListener("pointerdown", onPointerDown, true);
298 document.addEventListener("contextmenu", onContextMenu, true);
140299 openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);
300
301 requestAnimationFrame(() => {
302 canDismissFromPointer = true;
303 });
141304 });
142305}
packages/contracts/src/ipc.ts+8
......@@ -86,6 +86,10 @@ export interface ContextMenuItem<T extends string = string> {
8686 label: string;
8787 destructive?: boolean;
8888 disabled?: boolean;
89 /** Renders as a non-interactive section header label. Web fallback only — stripped on desktop native menus. */
90 header?: boolean;
91 /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */
92 icon?: string;
8993 children?: readonly ContextMenuItem<T>[];
9094}
9195
......@@ -94,6 +98,8 @@ export interface ContextMenuItemSchemaType {
9498 readonly label: string;
9599 readonly destructive?: boolean;
96100 readonly disabled?: boolean;
101 readonly header?: boolean;
102 readonly icon?: string;
97103 readonly children?: readonly ContextMenuItemSchemaType[];
98104}
99105
......@@ -102,6 +108,8 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
102108 label: Schema.String,
103109 destructive: Schema.optionalKey(Schema.Boolean),
104110 disabled: Schema.optionalKey(Schema.Boolean),
111 header: Schema.optionalKey(Schema.Boolean),
112 icon: Schema.optionalKey(Schema.String),
105113 children: Schema.optionalKey(
106114 Schema.Array(
107115 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),