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...@@ -44,6 +44,12 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
44 continue;44 continue;
45 }45 }
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
47 const normalizedItem: ContextMenuItem = {53 const normalizedItem: ContextMenuItem = {
48 id: sourceItem.id,54 id: sourceItem.id,
49 label: sourceItem.label,55 label: sourceItem.label,
apps/web/src/components/CommandPalette.logic.ts+2
...@@ -95,6 +95,7 @@ export function buildProjectActionItems(input: {...@@ -95,6 +95,7 @@ export function buildProjectActionItems(input: {
95 valuePrefix: string;95 valuePrefix: string;
96 icon: (project: Project) => ReactNode;96 icon: (project: Project) => ReactNode;
97 runProject: (project: Project) => Promise<void>;97 runProject: (project: Project) => Promise<void>;
98 shortcutCommand?: KeybindingCommand;
98}): CommandPaletteActionItem[] {99}): CommandPaletteActionItem[] {
99 return input.projects.map((project) => ({100 return input.projects.map((project) => ({
100 kind: "action",101 kind: "action",
...@@ -103,6 +104,7 @@ export function buildProjectActionItems(input: {...@@ -103,6 +104,7 @@ export function buildProjectActionItems(input: {
103 title: project.name,104 title: project.name,
104 description: project.cwd,105 description: project.cwd,
105 icon: input.icon(project),106 icon: input.icon(project),
107 ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
106 run: async () => {108 run: async () => {
107 await input.runProject(project);109 await input.runProject(project);
108 },110 },
apps/web/src/components/CommandPalette.tsx+1
...@@ -647,6 +647,7 @@ function OpenCommandPaletteDialog() {...@@ -647,6 +647,7 @@ function OpenCommandPaletteDialog() {
647 buildProjectActionItems({647 buildProjectActionItems({
648 projects,648 projects,
649 valuePrefix: "new-thread-in",649 valuePrefix: "new-thread-in",
650 shortcutCommand: "chat.new",
650 icon: (project) => (651 icon: (project) => (
651 <ProjectFavicon652 <ProjectFavicon
652 environmentId={project.environmentId}653 environmentId={project.environmentId}
apps/web/src/components/Sidebar.tsx+20-16
...@@ -217,6 +217,8 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> =...@@ -217,6 +217,8 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> =
217 repository_path: "Group by repository path",217 repository_path: "Group by repository path",
218 separate: "Keep separate",218 separate: "Keep separate",
219};219};
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
221function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {223function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {
222 return Math.min(224 return Math.min(
...@@ -635,7 +637,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -635,7 +637,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
635 data-thread-selection-safe637 data-thread-selection-safe
636 data-testid={`thread-archive-confirm-${thread.id}`}638 data-testid={`thread-archive-confirm-${thread.id}`}
637 aria-label={`Confirm archive ${thread.title}`}639 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"
639 onPointerDown={stopPropagationOnPointerDown}641 onPointerDown={stopPropagationOnPointerDown}
640 onClick={handleConfirmArchiveClick}642 onClick={handleConfirmArchiveClick}
641 >643 >
...@@ -643,13 +645,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -643,13 +645,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
643 </button>645 </button>
644 ) : !isThreadRunning ? (646 ) : !isThreadRunning ? (
645 appSettingsConfirmThreadArchive ? (647 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">
647 <button649 <button
648 type="button"650 type="button"
649 data-thread-selection-safe651 data-thread-selection-safe
650 data-testid={`thread-archive-${thread.id}`}652 data-testid={`thread-archive-${thread.id}`}
651 aria-label={`Archive ${thread.title}`}653 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}
653 onPointerDown={stopPropagationOnPointerDown}655 onPointerDown={stopPropagationOnPointerDown}
654 onClick={handleStartArchiveConfirmation}656 onClick={handleStartArchiveConfirmation}
655 >657 >
...@@ -660,13 +662,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -660,13 +662,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
660 <Tooltip>662 <Tooltip>
661 <TooltipTrigger663 <TooltipTrigger
662 render={664 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">
664 <button666 <button
665 type="button"667 type="button"
666 data-thread-selection-safe668 data-thread-selection-safe
667 data-testid={`thread-archive-${thread.id}`}669 data-testid={`thread-archive-${thread.id}`}
668 aria-label={`Archive ${thread.title}`}670 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}
670 onPointerDown={stopPropagationOnPointerDown}672 onPointerDown={stopPropagationOnPointerDown}
671 onClick={handleArchiveImmediateClick}673 onClick={handleArchiveImmediateClick}
672 >674 >
...@@ -712,7 +714,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -712,7 +714,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
712 </Tooltip>714 </Tooltip>
713 ) : (715 ) : (
714 <span716 <span
715 className={`text-[10px] ${717 className={`text-[10px] tabular-nums ${
716 isHighlighted718 isHighlighted
717 ? "text-foreground/72 dark:text-foreground/82"719 ? "text-foreground/72 dark:text-foreground/82"
718 : "text-muted-foreground/40"720 : "text-muted-foreground/40"
...@@ -1495,12 +1497,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1495,12 +1497,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1495 ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}),1497 ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}),
1496 }),1498 }),
1497 label,1499 label,
1500 ...(action === "delete" ? { icon: "trash" } : {}),
1498 };1501 };
1499 }1502 }
15001503
1501 return {1504 return {
1502 id: `${action}:submenu`,1505 id: `${action}:submenu`,
1503 label,1506 label,
1507 ...(action === "delete" ? { icon: "trash" } : {}),
1504 children: project.memberProjects.map((member) =>1508 children: project.memberProjects.map((member) =>
1505 makeLeaf(action, member, {1509 makeLeaf(action, member, {
1506 ...(options?.destructive ? { destructive: true } : {}),1510 ...(options?.destructive ? { destructive: true } : {}),
...@@ -1512,10 +1516,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1512,10 +1516,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15121516
1513 const clicked = await api.contextMenu.show(1517 const clicked = await api.contextMenu.show(
1514 [1518 [
1515 buildTargetedItem("rename", "Rename project"),1519 buildTargetedItem("rename", "Rename"),
1516 buildTargetedItem("grouping", "Project grouping…"),1520 buildTargetedItem("grouping", "Group into..."),
1517 buildTargetedItem("copy-path", "Copy Project Path"),1521 buildTargetedItem("copy-path", "Copy Path"),
1518 buildTargetedItem("delete", "Remove project", {1522 buildTargetedItem("delete", "Remove", {
1519 destructive: true,1523 destructive: true,
1520 }),1524 }),
1521 ],1525 ],
...@@ -1931,7 +1935,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1931,7 +1935,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1931 { id: "mark-unread", label: "Mark unread" },1935 { id: "mark-unread", label: "Mark unread" },
1932 { id: "copy-path", label: "Copy Path" },1936 { id: "copy-path", label: "Copy Path" },
1933 { id: "copy-thread-id", label: "Copy Thread ID" },1937 { id: "copy-thread-id", label: "Copy Thread ID" },
1934 { id: "delete", label: "Delete", destructive: true },1938 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
1935 ],1939 ],
1936 position,1940 position,
1937 );1941 );
...@@ -2073,12 +2077,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2073,12 +2077,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2073 <Tooltip>2077 <Tooltip>
2074 <TooltipTrigger2078 <TooltipTrigger
2075 render={2079 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">
2077 <button2081 <button
2078 type="button"2082 type="button"
2079 aria-label={`Create new thread in ${project.displayName}`}2083 aria-label={`Create new thread in ${project.displayName}`}
2080 data-testid="new-thread-button"2084 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}
2082 onClick={handleCreateThreadClick}2086 onClick={handleCreateThreadClick}
2083 >2087 >
2084 <SquarePenIcon className="size-3.5" />2088 <SquarePenIcon className="size-3.5" />
...@@ -2316,7 +2320,7 @@ function ProjectSortMenu({...@@ -2316,7 +2320,7 @@ function ProjectSortMenu({
2316 <Tooltip>2320 <Tooltip>
2317 <TooltipTrigger2321 <TooltipTrigger
2318 render={2322 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" />
2320 }2324 }
2321 >2325 >
2322 <ArrowUpDownIcon className="size-3.5" />2326 <ArrowUpDownIcon className="size-3.5" />
...@@ -2654,7 +2658,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2654,7 +2658,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2654 />2658 />
2655 }2659 }
2656 >2660 >
2657 <SearchIcon className="size-3.5" />2661 <SearchIcon className="size-3.5 text-muted-foreground/70" />
2658 <span className="flex-1 truncate text-left text-xs">Search</span>2662 <span className="flex-1 truncate text-left text-xs">Search</span>
2659 {commandPaletteShortcutLabel ? (2663 {commandPaletteShortcutLabel ? (
2660 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">2664 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">
...@@ -2711,7 +2715,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2711,7 +2715,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2711 type="button"2715 type="button"
2712 aria-label="Add project"2716 aria-label="Add project"
2713 data-testid="sidebar-add-project-trigger"2717 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"
2715 onClick={openAddProject}2719 onClick={openAddProject}
2716 />2720 />
2717 }2721 }
apps/web/src/contextMenuFallback.ts+178-15
...@@ -1,5 +1,74 @@...@@ -1,5 +1,74 @@
1import type { ContextMenuItem } from "@t3tools/contracts";1import 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
3function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) {72function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) {
4 const rect = menu.getBoundingClientRect();73 const rect = menu.getBoundingClientRect();
5 const left = Math.min(74 const left = Math.min(
...@@ -14,6 +83,24 @@ function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferre...@@ -14,6 +83,24 @@ function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferre
14 menu.style.top = `${top}px`;83 menu.style.top = `${top}px`;
15}84}
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
17/**104/**
18 * Imperative DOM-based context menu for non-Electron environments.105 * Imperative DOM-based context menu for non-Electron environments.
19 * Supports nested submenus and resolves with the clicked leaf item id.106 * Supports nested submenus and resolves with the clicked leaf item id.
...@@ -23,14 +110,18 @@ export function showContextMenuFallback<T extends string>(...@@ -23,14 +110,18 @@ export function showContextMenuFallback<T extends string>(
23 position?: { x: number; y: number },110 position?: { x: number; y: number },
24): Promise<T | null> {111): Promise<T | null> {
25 return new Promise<T | null>((resolve) => {112 return new Promise<T | null>((resolve) => {
26 const overlay = document.createElement("div");
27 overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
28
29 const menuStack: HTMLDivElement[] = [];113 const menuStack: HTMLDivElement[] = [];
114 let isDisposed = false;
115 let canDismissFromPointer = false;
30116
31 const cleanup = (result: T | null) => {117 const cleanup = (result: T | null) => {
118 if (isDisposed) {
119 return;
120 }
121 isDisposed = true;
32 document.removeEventListener("keydown", onKeyDown);122 document.removeEventListener("keydown", onKeyDown);
33 overlay.remove();123 document.removeEventListener("pointerdown", onPointerDown, true);
124 document.removeEventListener("contextmenu", onContextMenu, true);
34 for (const menu of menuStack) {125 for (const menu of menuStack) {
35 menu.remove();126 menu.remove();
36 }127 }
...@@ -44,6 +135,21 @@ export function showContextMenuFallback<T extends string>(...@@ -44,6 +135,21 @@ export function showContextMenuFallback<T extends string>(
44 }135 }
45 };136 };
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
47 const closeMenusFromLevel = (level: number) => {153 const closeMenusFromLevel = (level: number) => {
48 while (menuStack.length > level) {154 while (menuStack.length > level) {
49 menuStack.pop()?.remove();155 menuStack.pop()?.remove();
...@@ -60,24 +166,60 @@ export function showContextMenuFallback<T extends string>(...@@ -60,24 +166,60 @@ export function showContextMenuFallback<T extends string>(
60166
61 const menu = document.createElement("div");167 const menu = document.createElement("div");
62 menu.className =168 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;";
64 menu.style.left = `${preferredLeft}px`;172 menu.style.left = `${preferredLeft}px`;
65 menu.style.top = `${preferredTop}px`;173 menu.style.top = `${preferredTop}px`;
66 menu.dataset.level = String(level);174 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
68 for (const item of entries) {182 for (const item of entries) {
69 const button = document.createElement("button");183 if (item.header === true) {
70 button.type = "button";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
71 const hasChildren = Array.isArray(item.children) && item.children.length > 0;191 const hasChildren = Array.isArray(item.children) && item.children.length > 0;
72 const isLeafDestructive =192 const isLeafDestructive =
73 !hasChildren && (item.destructive === true || item.id === ("delete" as T));193 !hasChildren && (item.destructive === true || item.id === ("delete" as T));
194
195 const button = document.createElement("button");
196 button.type = "button";
74 const isDisabled = item.disabled === true;197 const isDisabled = item.disabled === true;
75 button.disabled = isDisabled;198 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";
76 button.className = isDisabled201 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`
78 : isLeafDestructive203 : 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"204 ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground`
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";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
82 const label = document.createElement("span");224 const label = document.createElement("span");
83 label.className = "min-w-0 flex-1 truncate";225 label.className = "min-w-0 flex-1 truncate";
...@@ -86,12 +228,27 @@ export function showContextMenuFallback<T extends string>(...@@ -86,12 +228,27 @@ export function showContextMenuFallback<T extends string>(
86228
87 if (hasChildren) {229 if (hasChildren) {
88 const chevron = document.createElement("span");230 const chevron = document.createElement("span");
89 chevron.className = "shrink-0 text-muted-foreground/70";231 chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none";
90 chevron.textContent = "›";232 chevron.textContent = ">";
91 button.appendChild(chevron);233 button.appendChild(chevron);
92 }234 }
93235
94 if (!isDisabled) {236 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
95 if (hasChildren) {252 if (hasChildren) {
96 button.addEventListener("mouseenter", () => {253 button.addEventListener("mouseenter", () => {
97 const rect = button.getBoundingClientRect();254 const rect = button.getBoundingClientRect();
...@@ -119,9 +276,11 @@ export function showContextMenuFallback<T extends string>(...@@ -119,9 +276,11 @@ export function showContextMenuFallback<T extends string>(
119 }276 }
120 }277 }
121278
122 menu.appendChild(button);279 inner.appendChild(button);
123 }280 }
124281
282 menu.appendChild(inner);
283
125 menu.addEventListener("mouseenter", () => {284 menu.addEventListener("mouseenter", () => {
126 closeMenusFromLevel(level + 1);285 closeMenusFromLevel(level + 1);
127 });286 });
...@@ -134,9 +293,13 @@ export function showContextMenuFallback<T extends string>(...@@ -134,9 +293,13 @@ export function showContextMenuFallback<T extends string>(
134 });293 });
135 };294 };
136295
137 overlay.addEventListener("mousedown", () => cleanup(null));
138 document.addEventListener("keydown", onKeyDown);296 document.addEventListener("keydown", onKeyDown);
139 document.body.appendChild(overlay);297 document.addEventListener("pointerdown", onPointerDown, true);
298 document.addEventListener("contextmenu", onContextMenu, true);
140 openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);299 openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);
300
301 requestAnimationFrame(() => {
302 canDismissFromPointer = true;
303 });
141 });304 });
142}305}
packages/contracts/src/ipc.ts+8
...@@ -86,6 +86,10 @@ export interface ContextMenuItem<T extends string = string> {...@@ -86,6 +86,10 @@ export interface ContextMenuItem<T extends string = string> {
86 label: string;86 label: string;
87 destructive?: boolean;87 destructive?: boolean;
88 disabled?: boolean;88 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;
89 children?: readonly ContextMenuItem<T>[];93 children?: readonly ContextMenuItem<T>[];
90}94}
9195
...@@ -94,6 +98,8 @@ export interface ContextMenuItemSchemaType {...@@ -94,6 +98,8 @@ export interface ContextMenuItemSchemaType {
94 readonly label: string;98 readonly label: string;
95 readonly destructive?: boolean;99 readonly destructive?: boolean;
96 readonly disabled?: boolean;100 readonly disabled?: boolean;
101 readonly header?: boolean;
102 readonly icon?: string;
97 readonly children?: readonly ContextMenuItemSchemaType[];103 readonly children?: readonly ContextMenuItemSchemaType[];
98}104}
99105
...@@ -102,6 +108,8 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc...@@ -102,6 +108,8 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
102 label: Schema.String,108 label: Schema.String,
103 destructive: Schema.optionalKey(Schema.Boolean),109 destructive: Schema.optionalKey(Schema.Boolean),
104 disabled: Schema.optionalKey(Schema.Boolean),110 disabled: Schema.optionalKey(Schema.Boolean),
111 header: Schema.optionalKey(Schema.Boolean),
112 icon: Schema.optionalKey(Schema.String),
105 children: Schema.optionalKey(113 children: Schema.optionalKey(
106 Schema.Array(114 Schema.Array(
107 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),115 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),