| 1 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 2 | |
| 3 | const SVG_NS = "http://www.w3.org/2000/svg"; |
| 4 | |
| 5 | // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). |
| 6 | const 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 | |
| 139 | function 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 | |
| 168 | function 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 | |
| 182 | function 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. |
| 203 | let activeContextMenuDismiss: (() => void) | null = null; |
| 204 | |
| 205 | export 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 | */ |
| 214 | export 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 | */ |
| 223 | export 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 | } |