1import type { ContextMenuItem } from "@t3tools/contracts";
2
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 archive: [
8 { tag: "rect", attrs: { width: "20", height: "5", x: "2", y: "3", rx: "1" } },
9 { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } },
10 { tag: "path", attrs: { d: "M10 12h4" } },
11 ],
12 check: [{ tag: "path", attrs: { d: "M20 6 9 17l-5-5" } }],
13 timer: [
14 { tag: "line", attrs: { x1: "10", x2: "14", y1: "2", y2: "2" } },
15 { tag: "line", attrs: { x1: "12", x2: "15", y1: "14", y2: "11" } },
16 { tag: "circle", attrs: { cx: "12", cy: "14", r: "8" } },
17 ],
18 "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }],
19 "circle-check": [
20 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
21 { tag: "path", attrs: { d: "m9 12 2 2 4-4" } },
22 ],
23 clock: [
24 { tag: "path", attrs: { d: "M12 6v6l4 2" } },
25 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
26 ],
27 pencil: [
28 {
29 tag: "path",
30 attrs: {
31 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",
32 },
33 },
34 { tag: "path", attrs: { d: "m15 5 4 4" } },
35 ],
36 copy: [
37 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },
38 { 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" } },
39 ],
40 folder: [
41 {
42 tag: "path",
43 attrs: {
44 d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",
45 },
46 },
47 ],
48 "git-branch": [
49 { tag: "line", attrs: { x1: "6", x2: "6", y1: "3", y2: "15" } },
50 { tag: "circle", attrs: { cx: "18", cy: "6", r: "3" } },
51 { tag: "circle", attrs: { cx: "6", cy: "18", r: "3" } },
52 { tag: "path", attrs: { d: "M18 9a9 9 0 0 1-9 9" } },
53 ],
54 hash: [
55 { tag: "line", attrs: { x1: "4", x2: "20", y1: "9", y2: "9" } },
56 { tag: "line", attrs: { x1: "4", x2: "20", y1: "15", y2: "15" } },
57 { tag: "line", attrs: { x1: "10", x2: "8", y1: "3", y2: "21" } },
58 { tag: "line", attrs: { x1: "16", x2: "14", y1: "3", y2: "21" } },
59 ],
60 "mail-open": [
61 {
62 tag: "path",
63 attrs: {
64 d: "M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z",
65 },
66 },
67 { tag: "path", attrs: { d: "m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" } },
68 ],
69 "message-square-plus": [
70 {
71 tag: "path",
72 attrs: {
73 d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z",
74 },
75 },
76 { tag: "path", attrs: { d: "M12 8v6" } },
77 { tag: "path", attrs: { d: "M9 11h6" } },
78 ],
79 pin: [
80 { tag: "path", attrs: { d: "M12 17v5" } },
81 {
82 tag: "path",
83 attrs: {
84 d: "M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z",
85 },
86 },
87 ],
88 "pin-off": [
89 { tag: "path", attrs: { d: "M12 17v5" } },
90 { tag: "path", attrs: { d: "M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" } },
91 { tag: "path", attrs: { d: "m2 2 20 20" } },
92 {
93 tag: "path",
94 attrs: {
95 d: "M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11",
96 },
97 },
98 ],
99 "refresh-cw": [
100 { tag: "path", attrs: { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" } },
101 { tag: "path", attrs: { d: "M21 3v5h-5" } },
102 { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } },
103 { tag: "path", attrs: { d: "M8 16H3v5" } },
104 ],
105 settings: [
106 {
107 tag: "path",
108 attrs: {
109 d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z",
110 },
111 },
112 { tag: "circle", attrs: { cx: "12", cy: "12", r: "3" } },
113 ],
114 "folder-tree": [
115 {
116 tag: "path",
117 attrs: {
118 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",
119 },
120 },
121 {
122 tag: "path",
123 attrs: {
124 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",
125 },
126 },
127 { tag: "path", attrs: { d: "M3 5a2 2 0 0 0 2 2h3" } },
128 { tag: "path", attrs: { d: "M3 3v13a2 2 0 0 0 2 2h3" } },
129 ],
130 trash: [
131 { tag: "path", attrs: { d: "M3 6h18" } },
132 { tag: "path", attrs: { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" } },
133 { tag: "path", attrs: { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" } },
134 { tag: "line", attrs: { x1: "10", x2: "10", y1: "11", y2: "17" } },
135 { tag: "line", attrs: { x1: "14", x2: "14", y1: "11", y2: "17" } },
136 ],
137};
138
139function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
140 const paths = ICON_PATHS[name];
141 if (!paths || typeof document.createElementNS !== "function") {
142 return null;
143 }
144 const svg = document.createElementNS(SVG_NS, "svg");
145 svg.setAttribute("xmlns", SVG_NS);
146 svg.setAttribute("viewBox", "0 0 24 24");
147 svg.setAttribute("fill", "none");
148 svg.setAttribute("stroke", "currentColor");
149 svg.setAttribute("stroke-width", "2");
150 svg.setAttribute("stroke-linecap", "round");
151 svg.setAttribute("stroke-linejoin", "round");
152 svg.setAttribute(
153 "class",
154 tone === "destructive"
155 ? "size-4.5 shrink-0 sm:size-4"
156 : "size-4.5 shrink-0 text-muted-foreground sm:size-4",
157 );
158 for (const node of paths) {
159 const child = document.createElementNS(SVG_NS, node.tag);
160 for (const [key, value] of Object.entries(node.attrs)) {
161 child.setAttribute(key, value);
162 }
163 svg.appendChild(child);
164 }
165 return svg;
166}
167
168function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) {
169 const rect = menu.getBoundingClientRect();
170 const left = Math.min(
171 Math.max(4, preferredLeft),
172 Math.max(4, window.innerWidth - rect.width - 4),
173 );
174 const top = Math.min(
175 Math.max(4, preferredTop),
176 Math.max(4, window.innerHeight - rect.height - 4),
177 );
178 menu.style.left = `${left}px`;
179 menu.style.top = `${top}px`;
180}
181
182function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly HTMLDivElement[]) {
183 if (typeof Node !== "undefined" && target instanceof Node) {
184 return menuStack.some((menu) => menu.contains(target));
185 }
186 if (!target || typeof target !== "object") {
187 return false;
188 }
189
190 let current: unknown = target;
191 while (current && typeof current === "object") {
192 if (menuStack.includes(current as HTMLDivElement)) {
193 return true;
194 }
195 current = (current as { parent?: unknown }).parent;
196 }
197 return false;
198}
199
200// Only one fallback menu exists at a time in the renderer; the active one is
201// tracked so a state change (for example a terminal selection clearing) can
202// dismiss it with the same result as an outside click or Escape.
203let activeContextMenuDismiss: (() => void) | null = null;
204
205export function isContextMenuOpen(): boolean {
206 return activeContextMenuDismiss !== null;
207}
208
209/**
210 * Closes the currently open fallback context menu, resolving its show() with
211 * null (the same result as dismissing by outside click or Escape). No-op when
212 * no fallback menu is open.
213 */
214export function dismissContextMenu(): void {
215 activeContextMenuDismiss?.();
216 activeContextMenuDismiss = null;
217}
218
219/**
220 * Imperative DOM-based context menu for non-Electron environments.
221 * Supports nested submenus and resolves with the clicked leaf item id.
222 */
223export function showContextMenuFallback<T extends string>(
224 items: readonly ContextMenuItem<T>[],
225 position?: { x: number; y: number },
226): Promise<T | null> {
227 return new Promise<T | null>((resolve) => {
228 const previouslyFocusedElement =
229 document.activeElement instanceof HTMLElement ? document.activeElement : null;
230 const menuStack: HTMLDivElement[] = [];
231 const submenuTriggerStack: Array<HTMLButtonElement | undefined> = [];
232 let isDisposed = false;
233 let canDismissFromPointer = false;
234
235 const dismiss = () => cleanup(null);
236
237 const cleanup = (result: T | null) => {
238 if (isDisposed) {
239 return;
240 }
241 isDisposed = true;
242 if (activeContextMenuDismiss === dismiss) {
243 activeContextMenuDismiss = null;
244 }
245 document.removeEventListener("keydown", onKeyDown);
246 document.removeEventListener("pointerdown", onPointerDown, true);
247 document.removeEventListener("contextmenu", onContextMenu, true);
248 const shouldRestoreFocus = isNodeWithinMenuStack(document.activeElement, menuStack);
249 for (const menu of menuStack) {
250 menu.remove();
251 }
252 if (shouldRestoreFocus && previouslyFocusedElement?.isConnected) {
253 previouslyFocusedElement.focus({ preventScroll: true });
254 }
255 resolve(result);
256 };
257
258 const onKeyDown = (event: KeyboardEvent) => {
259 if (event.key === "Escape") {
260 event.preventDefault();
261 cleanup(null);
262 }
263 };
264
265 const onPointerDown = (event: PointerEvent) => {
266 if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) {
267 return;
268 }
269 cleanup(null);
270 };
271
272 const onContextMenu = (event: MouseEvent) => {
273 if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) {
274 return;
275 }
276 event.preventDefault();
277 cleanup(null);
278 };
279
280 const closeMenusFromLevel = (level: number) => {
281 while (menuStack.length > level) {
282 submenuTriggerStack.pop()?.setAttribute("aria-expanded", "false");
283 menuStack.pop()?.remove();
284 }
285 };
286
287 const openMenu = (
288 entries: readonly ContextMenuItem<T>[],
289 preferredLeft: number,
290 preferredTop: number,
291 level: number,
292 parentTrigger?: HTMLButtonElement,
293 ) => {
294 closeMenusFromLevel(level);
295
296 const menu = document.createElement("div");
297 menu.className =
298 "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none";
299 menu.style.cssText =
300 "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--contrast-popover-foreground);outline:none;pointer-events:auto;";
301 menu.style.left = `${preferredLeft}px`;
302 menu.style.top = `${preferredTop}px`;
303 menu.dataset.level = String(level);
304
305 const inner = document.createElement("div");
306 // Fit the viewport (minus clampMenuPosition's 4px margins) so menus only
307 // scroll when the window is shorter than the menu itself.
308 inner.className =
309 "max-h-[calc(100vh-0.5rem)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1";
310 inner.style.cssText =
311 "max-height:calc(100vh - 0.5rem);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
312
313 for (const item of entries) {
314 if (item.separatorBefore === true && inner.children.length > 0) {
315 const separator = document.createElement("div");
316 separator.className = "mx-2 my-1 h-px bg-border";
317 separator.style.cssText =
318 "height:1px;margin:0.25rem 0.5rem;background:var(--contrast-border);";
319 separator.dataset.contextMenuSeparator = "true";
320 separator.setAttribute("role", "separator");
321 inner.appendChild(separator);
322 }
323
324 if (item.header === true) {
325 const header = document.createElement("div");
326 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
327 header.textContent = item.label;
328 inner.appendChild(header);
329 continue;
330 }
331
332 const hasChildren = Array.isArray(item.children) && item.children.length > 0;
333 const isLeafDestructive =
334 !hasChildren && (item.destructive === true || item.id === ("delete" as T));
335
336 const button = document.createElement("button");
337 button.type = "button";
338 const isDisabled = item.disabled === true;
339 button.disabled = isDisabled;
340 const rowBase =
341 "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";
342 button.className = isDisabled
343 ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64`
344 : isLeafDestructive
345 ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground`
346 : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`;
347 button.style.cssText =
348 "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(--contrast-foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;";
349 if (isLeafDestructive) {
350 button.style.color = "var(--destructive-foreground)";
351 }
352 if (isDisabled) {
353 button.style.color = "var(--contrast-muted-foreground)";
354 button.style.opacity = "0.64";
355 button.style.pointerEvents = "none";
356 }
357
358 if (typeof item.checked === "boolean") {
359 // Option rows use the icon slot for the check so labels line up
360 // with icon rows. The unselected option keeps the slot empty.
361 button.setAttribute("role", "menuitemradio");
362 button.setAttribute("aria-checked", item.checked ? "true" : "false");
363 const check = item.checked ? createIconElement("check", "neutral") : null;
364 if (check) {
365 button.appendChild(check);
366 } else {
367 const spacer = document.createElement("span");
368 spacer.className = "size-4.5 shrink-0 sm:size-4";
369 spacer.style.cssText = "display:inline-block;width:1rem;height:1rem;flex-shrink:0;";
370 spacer.setAttribute("aria-hidden", "true");
371 button.appendChild(spacer);
372 }
373 } else if (typeof item.icon === "string") {
374 const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral");
375 if (icon) {
376 button.appendChild(icon);
377 }
378 }
379
380 const label = document.createElement("span");
381 label.className = "min-w-0 flex-1 truncate";
382 label.textContent = item.label;
383 button.appendChild(label);
384
385 if (hasChildren) {
386 button.setAttribute("aria-haspopup", "menu");
387 button.setAttribute("aria-expanded", "false");
388 const chevron = createIconElement("chevron-right", "neutral");
389 if (chevron) {
390 chevron.setAttribute(
391 "class",
392 "-me-0.5 ms-auto size-4.5 shrink-0 text-muted-foreground opacity-80 sm:size-4",
393 );
394 chevron.setAttribute("aria-hidden", "true");
395 chevron.dataset.contextMenuChevron = "true";
396 button.appendChild(chevron);
397 }
398 }
399
400 if (!isDisabled) {
401 let isHovered = false;
402 let isFocused = false;
403 const updateHighlight = () => {
404 const isHighlighted = isHovered || isFocused;
405 button.style.background = isHighlighted
406 ? isLeafDestructive
407 ? "color-mix(in srgb, var(--destructive) 10%, transparent)"
408 : "var(--accent)"
409 : "transparent";
410 button.style.color = isHighlighted
411 ? isLeafDestructive
412 ? "var(--destructive-foreground)"
413 : "var(--contrast-accent-foreground)"
414 : isLeafDestructive
415 ? "var(--destructive-foreground)"
416 : "var(--contrast-foreground)";
417 };
418 button.addEventListener("mouseenter", () => {
419 button.focus({ preventScroll: true });
420 isHovered = true;
421 updateHighlight();
422 });
423 button.addEventListener("mouseleave", () => {
424 isHovered = false;
425 updateHighlight();
426 });
427 button.addEventListener("focus", () => {
428 isFocused = true;
429 updateHighlight();
430 });
431 button.addEventListener("blur", () => {
432 isFocused = false;
433 updateHighlight();
434 });
435
436 if (hasChildren) {
437 const openSubmenu = (focusFirstItem = false) => {
438 const rect = button.getBoundingClientRect();
439 const nextLeft = rect.right + 4;
440 const nextTop = rect.top;
441 openMenu(item.children!, nextLeft, nextTop, level + 1, button);
442 button.setAttribute("aria-expanded", "true");
443
444 const childMenu = menuStack[level + 1];
445 if (!childMenu) {
446 return;
447 }
448 const childRect = childMenu.getBoundingClientRect();
449 if (childRect.right > window.innerWidth) {
450 clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top);
451 }
452 if (focusFirstItem) {
453 [...childMenu.querySelectorAll<HTMLButtonElement>("button")]
454 .find((childButton) => !childButton.disabled)
455 ?.focus();
456 }
457 };
458 button.addEventListener("mouseenter", () => {
459 openSubmenu();
460 });
461 button.addEventListener("click", (event) => {
462 event.preventDefault();
463 openSubmenu(true);
464 });
465 } else {
466 button.addEventListener("mouseenter", () => {
467 closeMenusFromLevel(level + 1);
468 });
469 button.addEventListener("click", () => {
470 if (canDismissFromPointer) cleanup(item.id);
471 });
472 }
473 }
474
475 inner.appendChild(button);
476 }
477
478 menu.appendChild(inner);
479
480 menu.addEventListener("mouseenter", () => {
481 closeMenusFromLevel(level + 1);
482 });
483
484 document.body.appendChild(menu);
485 menuStack[level] = menu;
486 submenuTriggerStack[level] = parentTrigger;
487
488 requestAnimationFrame(() => {
489 clampMenuPosition(menu, preferredLeft, preferredTop);
490 });
491 };
492
493 document.addEventListener("keydown", onKeyDown);
494 document.addEventListener("pointerdown", onPointerDown, true);
495 document.addEventListener("contextmenu", onContextMenu, true);
496 openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);
497 // Only one fallback menu can be open at a time: a new show must dismiss
498 // any prior one, or its DOM and listeners leak and close() can only ever
499 // reach the newest menu.
500 if (activeContextMenuDismiss) {
501 activeContextMenuDismiss();
502 }
503 activeContextMenuDismiss = dismiss;
504
505 requestAnimationFrame(() => {
506 canDismissFromPointer = true;
507 });
508 });
509}