Skip to content
Source
Sign inSign up

contextMenuFallback.ts

626 lines23.9 KB

1import type { ContextMenuItem } from "@t3tools/contracts";
2​
3const SVG_NS = "http://www.w3.org/2000/svg";
4​
5const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = {
6 archive: [
7 { tag: "rect", attrs: { width: "20", height: "5", x: "2", y: "3", rx: "1" } },
8 { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } },
9 { tag: "path", attrs: { d: "M10 12h4" } },
10 ],
11 check: [{ tag: "path", attrs: { d: "M20 6 9 17l-5-5" } }],
12 timer: [
13 { tag: "line", attrs: { x1: "10", x2: "14", y1: "2", y2: "2" } },
14 { tag: "line", attrs: { x1: "12", x2: "15", y1: "14", y2: "11" } },
15 { tag: "circle", attrs: { cx: "12", cy: "14", r: "8" } },
16 ],
17 "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }],
18 "circle-check": [
19 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
20 { tag: "path", attrs: { d: "m9 12 2 2 4-4" } },
21 ],
22 clock: [
23 { tag: "path", attrs: { d: "M12 6v6l4 2" } },
24 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
25 ],
26 pencil: [
27 {
28 tag: "path",
29 attrs: {
30 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",
31 },
32 },
33 { tag: "path", attrs: { d: "m15 5 4 4" } },
34 ],
35 copy: [
36 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },
37 { 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" } },
38 ],
39 folder: [
40 {
41 tag: "path",
42 attrs: {
43 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",
44 },
45 },
46 ],
47 "git-branch": [
48 { tag: "line", attrs: { x1: "6", x2: "6", y1: "3", y2: "15" } },
49 { tag: "circle", attrs: { cx: "18", cy: "6", r: "3" } },
50 { tag: "circle", attrs: { cx: "6", cy: "18", r: "3" } },
51 { tag: "path", attrs: { d: "M18 9a9 9 0 0 1-9 9" } },
52 ],
53 hash: [
54 { tag: "line", attrs: { x1: "4", x2: "20", y1: "9", y2: "9" } },
55 { tag: "line", attrs: { x1: "4", x2: "20", y1: "15", y2: "15" } },
56 { tag: "line", attrs: { x1: "10", x2: "8", y1: "3", y2: "21" } },
57 { tag: "line", attrs: { x1: "16", x2: "14", y1: "3", y2: "21" } },
58 ],
59 "mail-open": [
60 {
61 tag: "path",
62 attrs: {
63 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",
64 },
65 },
66 { tag: "path", attrs: { d: "m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" } },
67 ],
68 "message-square": [
69 {
70 tag: "path",
71 attrs: {
72 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",
73 },
74 },
75 ],
76 "message-square-plus": [
77 {
78 tag: "path",
79 attrs: {
80 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",
81 },
82 },
83 { tag: "path", attrs: { d: "M12 8v6" } },
84 { tag: "path", attrs: { d: "M9 11h6" } },
85 ],
86 pin: [
87 { tag: "path", attrs: { d: "M12 17v5" } },
88 {
89 tag: "path",
90 attrs: {
91 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",
92 },
93 },
94 ],
95 "pin-off": [
96 { tag: "path", attrs: { d: "M12 17v5" } },
97 { tag: "path", attrs: { d: "M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" } },
98 { tag: "path", attrs: { d: "m2 2 20 20" } },
99 {
100 tag: "path",
101 attrs: {
102 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",
103 },
104 },
105 ],
106 "refresh-cw": [
107 { tag: "path", attrs: { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" } },
108 { tag: "path", attrs: { d: "M21 3v5h-5" } },
109 { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } },
110 { tag: "path", attrs: { d: "M8 16H3v5" } },
111 ],
112 settings: [
113 {
114 tag: "path",
115 attrs: {
116 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",
117 },
118 },
119 { tag: "circle", attrs: { cx: "12", cy: "12", r: "3" } },
120 ],
121 "user-plus": [
122 { tag: "path", attrs: { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" } },
123 { tag: "circle", attrs: { cx: "9", cy: "7", r: "4" } },
124 { tag: "line", attrs: { x1: "19", x2: "19", y1: "8", y2: "14" } },
125 { tag: "line", attrs: { x1: "22", x2: "16", y1: "11", y2: "11" } },
126 ],
127 "folder-plus": [
128 { tag: "path", attrs: { d: "M12 10v6" } },
129 { tag: "path", attrs: { d: "M9 13h6" } },
130 {
131 tag: "path",
132 attrs: {
133 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",
134 },
135 },
136 ],
137 "folder-tree": [
138 {
139 tag: "path",
140 attrs: {
141 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",
142 },
143 },
144 {
145 tag: "path",
146 attrs: {
147 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",
148 },
149 },
150 { tag: "path", attrs: { d: "M3 5a2 2 0 0 0 2 2h3" } },
151 { tag: "path", attrs: { d: "M3 3v13a2 2 0 0 0 2 2h3" } },
152 ],
153 "text-search": [
154 { tag: "path", attrs: { d: "M21 5H3" } },
155 { tag: "path", attrs: { d: "M10 12H3" } },
156 { tag: "path", attrs: { d: "M10 19H3" } },
157 { tag: "circle", attrs: { cx: "17", cy: "15", r: "3" } },
158 { tag: "path", attrs: { d: "m21 19-1.9-1.9" } },
159 ],
160 trash: [
161 { tag: "path", attrs: { d: "M3 6h18" } },
162 { tag: "path", attrs: { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" } },
163 { tag: "path", attrs: { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" } },
164 { tag: "line", attrs: { x1: "10", x2: "10", y1: "11", y2: "17" } },
165 { tag: "line", attrs: { x1: "14", x2: "14", y1: "11", y2: "17" } },
166 ],
167};
168​
169function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
170 const paths = ICON_PATHS[name];
171 if (!paths || typeof document.createElementNS !== "function") {
172 return null;
173 }
174 const svg = document.createElementNS(SVG_NS, "svg");
175 svg.setAttribute("aria-hidden", "true");
176 svg.setAttribute("xmlns", SVG_NS);
177 svg.setAttribute("viewBox", "0 0 24 24");
178 svg.setAttribute("fill", "none");
179 svg.setAttribute("stroke", "currentColor");
180 svg.setAttribute("stroke-width", "2");
181 svg.setAttribute("stroke-linecap", "round");
182 svg.setAttribute("stroke-linejoin", "round");
183 svg.setAttribute(
184 "class",
185 tone === "destructive"
186 ? "size-4.5 shrink-0 sm:size-4"
187 : "size-4.5 shrink-0 text-muted-foreground sm:size-4",
188 );
189 for (const node of paths) {
190 const child = document.createElementNS(SVG_NS, node.tag);
191 for (const [key, value] of Object.entries(node.attrs)) {
192 child.setAttribute(key, value);
193 }
194 svg.appendChild(child);
195 }
196 return svg;
198​
199function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) {
200 const rect = menu.getBoundingClientRect();
201 const left = Math.min(
202 Math.max(4, preferredLeft),
203 Math.max(4, window.innerWidth - rect.width - 4),
204 );
205 const top = Math.min(
206 Math.max(4, preferredTop),
207 Math.max(4, window.innerHeight - rect.height - 4),
208 );
209 menu.style.left = `${left}px`;
210 menu.style.top = `${top}px`;
212​
213function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly HTMLDivElement[]) {
214 if (typeof Node !== "undefined" && target instanceof Node) {
215 return menuStack.some((menu) => menu.contains(target));
216 }
217 if (!target || typeof target !== "object") {
218 return false;
219 }
220​
221 let current: unknown = target;
222 while (current && typeof current === "object") {
223 if (menuStack.includes(current as HTMLDivElement)) {
224 return true;
225 }
226 current = (current as { parent?: unknown }).parent;
227 }
228 return false;
230​
231let activeContextMenuDismiss: ((restoreFocus?: boolean) => void) | null = null;
232​
233export function isContextMenuOpen(): boolean {
234 return activeContextMenuDismiss !== null;
236​
237export function dismissContextMenu(restoreFocus = true): void {
238 activeContextMenuDismiss?.(restoreFocus);
239 activeContextMenuDismiss = null;
241​
242export function showContextMenuFallback<T extends string>(
243 items: readonly ContextMenuItem<T>[],
244 position?: { x: number; y: number },
245): Promise<T | null> {
246 dismissContextMenu();
247 return new Promise<T | null>((resolve) => {
248 let focusedElement = document.activeElement;
249 while (focusedElement?.shadowRoot?.activeElement) {
250 focusedElement = focusedElement.shadowRoot.activeElement;
251 }
252 const previouslyFocusedElement = focusedElement instanceof HTMLElement ? focusedElement : null;
253 const menuStack: HTMLDivElement[] = [];
254 const submenuTriggerStack: Array<HTMLButtonElement | undefined> = [];
255 let isDisposed = false;
256 let canDismissFromPointer = false;
257​
258 const dismiss = (restoreFocus = true) => cleanup(null, restoreFocus);
259​
260 const cleanup = (result: T | null, restoreFocus = true) => {
261 if (isDisposed) {
262 return;
263 }
264 isDisposed = true;
265 if (activeContextMenuDismiss === dismiss) {
266 activeContextMenuDismiss = null;
267 }
268 document.removeEventListener("keydown", onKeyDown);
269 document.removeEventListener("pointerdown", onPointerDown, true);
270 document.removeEventListener("contextmenu", onContextMenu, true);
271 document.removeEventListener("focusin", onFocusIn);
272 window.removeEventListener("blur", onWindowBlur);
273 const shouldRestoreFocus =
274 restoreFocus && isNodeWithinMenuStack(document.activeElement, menuStack);
275 for (const menu of menuStack) {
276 menu.remove();
277 }
278 if (shouldRestoreFocus && previouslyFocusedElement?.isConnected) {
279 previouslyFocusedElement.focus({ preventScroll: true });
280 }
281 resolve(result);
282 };
283​
284 const onKeyDown = (event: KeyboardEvent) => {
285 if (
286 event.defaultPrevented ||
287 event.isComposing ||
288 event.altKey ||
289 event.ctrlKey ||
290 event.metaKey
291 )
292 return;
293 const level = menuStack.findIndex((menu) =>
294 isNodeWithinMenuStack(document.activeElement, [menu]),
295 );
296 if (level < 0) return;
297 const menu = menuStack[level]!;
298 const buttons = [...menu.querySelectorAll<HTMLButtonElement>("button")].filter(
299 (button) => !button.disabled,
300 );
301 const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
302 let target: HTMLButtonElement | undefined;
303 switch (event.key) {
304 case "ArrowDown":
305 target = buttons[(index + 1) % buttons.length];
306 break;
307 case "ArrowUp":
308 target =
309 buttons[(index < 0 ? buttons.length - 1 : index - 1 + buttons.length) % buttons.length];
310 break;
311 case "Home":
312 target = buttons[0];
313 break;
314 case "End":
315 target = buttons.at(-1);
316 break;
317 case "ArrowRight":
318 if (buttons[index]?.getAttribute("aria-haspopup") !== "menu") return;
319 buttons[index]?.click();
320 break;
321 case " ":
322 case "Enter":
323 // Keyboard activation must not depend on the opening pointer gesture's frame.
324 canDismissFromPointer = true;
325 buttons[index]?.click();
326 break;
327 case "ArrowLeft":
328 if (level === 0) return;
329 // Fall through to cancel this submenu only.
330 case "Escape": {
331 const parent = submenuTriggerStack[level];
332 if (parent) {
333 parent.focus({ preventScroll: true });
334 closeMenusFromLevel(level);
335 } else cleanup(null);
336 break;
337 }
338 case "Tab":
339 // Tab closes the menu and continues from the element that opened it.
340 cleanup(null);
341 return;
342 default:
343 return;
344 }
345 event.preventDefault();
346 event.stopPropagation();
347 if (target && target !== submenuTriggerStack[level + 1]) closeMenusFromLevel(level + 1);
348 target?.focus({ preventScroll: true });
349 target?.scrollIntoView({ block: "nearest" });
350 };
351​
352 const onFocusIn = (event: FocusEvent) => {
353 if (!isNodeWithinMenuStack(event.target, menuStack)) cleanup(null, false);
354 };
355 const onWindowBlur = () => cleanup(null, false);
356​
357 const onPointerDown = (event: PointerEvent) => {
358 if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) {
359 return;
360 }
361 cleanup(null);
362 };
363​
364 const onContextMenu = (event: MouseEvent) => {
365 if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) {
366 return;
367 }
368 event.preventDefault();
369 cleanup(null);
370 };
371​
372 const closeMenusFromLevel = (level: number) => {
373 while (menuStack.length > level) {
374 submenuTriggerStack.pop()?.setAttribute("aria-expanded", "false");
375 menuStack.pop()?.remove();
376 }
377 };
378​
379 const openMenu = (
380 entries: readonly ContextMenuItem<T>[],
381 preferredLeft: number,
382 preferredTop: number,
383 level: number,
384 parentTrigger?: HTMLButtonElement,
385 ) => {
386 closeMenusFromLevel(level);
387​
388 const menu = document.createElement("div");
389 menu.className =
390 "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none";
391 menu.style.cssText =
392 "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;";
393 menu.style.left = `${preferredLeft}px`;
394 menu.style.top = `${preferredTop}px`;
395 menu.dataset.level = String(level);
396 menu.dataset.contextMenu = "true";
397 menu.setAttribute("role", "menu");
398 menu.setAttribute("aria-label", parentTrigger?.textContent ?? "Context menu");
399 menu.tabIndex = -1;
400​
401 const inner = document.createElement("div");
402 inner.className =
403 "max-h-[calc(100vh-0.5rem)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1";
404 inner.style.cssText =
405 "max-height:calc(100vh - 0.5rem);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
406​
407 for (const item of entries) {
408 if (item.separatorBefore === true && inner.children.length > 0) {
409 const separator = document.createElement("div");
410 separator.className = "mx-2 my-1 h-px bg-border";
411 separator.style.cssText =
412 "height:1px;margin:0.25rem 0.5rem;background:var(--contrast-border);";
413 separator.dataset.contextMenuSeparator = "true";
414 separator.setAttribute("role", "separator");
415 inner.appendChild(separator);
416 }
417​
418 if (item.header === true) {
419 const header = document.createElement("div");
420 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
421 header.textContent = item.label;
422 inner.appendChild(header);
423 continue;
424 }
425​
426 const hasChildren = Array.isArray(item.children) && item.children.length > 0;
427 const isLeafDestructive =
428 !hasChildren && (item.destructive === true || item.id === ("delete" as T));
429​
430 const button = document.createElement("button");
431 button.type = "button";
432 const isDisabled = item.disabled === true;
433 button.disabled = isDisabled;
434 button.tabIndex = -1;
435 button.setAttribute("role", "menuitem");
436 button.setAttribute("aria-disabled", String(isDisabled));
437 const rowBase =
438 "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";
439 button.className = isDisabled
440 ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64`
441 : isLeafDestructive
442 ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground`
443 : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`;
444 button.style.cssText =
445 "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;";
446 if (isLeafDestructive) {
447 button.style.color = "var(--destructive-foreground)";
448 }
449 if (isDisabled) {
450 button.style.color = "var(--contrast-muted-foreground)";
451 button.style.opacity = "0.64";
452 button.style.pointerEvents = "none";
453 }
454​
455 if (typeof item.checked === "boolean") {
456 button.setAttribute("role", "menuitemradio");
457 button.setAttribute("aria-checked", item.checked ? "true" : "false");
458 const check = item.checked ? createIconElement("check", "neutral") : null;
459 if (check) {
460 button.appendChild(check);
461 } else {
462 const spacer = document.createElement("span");
463 spacer.className = "size-4.5 shrink-0 sm:size-4";
464 spacer.style.cssText = "display:inline-block;width:1rem;height:1rem;flex-shrink:0;";
465 spacer.setAttribute("aria-hidden", "true");
466 button.appendChild(spacer);
467 }
468 } else if (typeof item.icon === "string") {
469 const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral");
470 if (icon) {
471 button.appendChild(icon);
472 }
473 }
474​
475 const label = document.createElement("span");
476 label.className = "min-w-0 flex-1 truncate";
477 label.textContent = item.label;
478 button.appendChild(label);
479​
480 if (item.detail) {
481 // The comma gives screen readers a pause between label and detail.
482 button.setAttribute("aria-label", `${item.label}, ${item.detail}`);
483 const detail = document.createElement("span");
484 detail.className = "ms-auto shrink-0 ps-4 text-muted-foreground text-xs";
485 detail.style.cssText =
486 "margin-inline-start:auto;flex-shrink:0;padding-inline-start:1rem;color:var(--contrast-muted-foreground);font-size:0.75rem;";
487 detail.textContent = item.detail;
488 button.appendChild(detail);
489 }
490​
491 if (hasChildren) {
492 button.setAttribute("aria-haspopup", "menu");
493 button.setAttribute("aria-expanded", "false");
494 const chevron = createIconElement("chevron-right", "neutral");
495 if (chevron) {
496 chevron.setAttribute(
497 "class",
498 "-me-0.5 ms-auto size-4.5 shrink-0 text-muted-foreground opacity-80 sm:size-4",
499 );
500 chevron.setAttribute("aria-hidden", "true");
501 chevron.dataset.contextMenuChevron = "true";
502 button.appendChild(chevron);
503 }
504 }
505​
506 if (!isDisabled) {
507 let isHovered = false;
508 let isFocused = false;
509 const updateHighlight = () => {
510 const isHighlighted = isHovered || isFocused;
511 button.style.background = isHighlighted
512 ? isLeafDestructive
513 ? "color-mix(in srgb, var(--destructive) 10%, transparent)"
514 : "var(--accent)"
515 : "transparent";
516 button.style.color = isHighlighted
517 ? isLeafDestructive
518 ? "var(--destructive-foreground)"
519 : "var(--contrast-accent-foreground)"
520 : isLeafDestructive
521 ? "var(--destructive-foreground)"
522 : "var(--contrast-foreground)";
523 };
524 button.addEventListener("mouseenter", () => {
525 button.focus({ preventScroll: true });
526 isHovered = true;
527 updateHighlight();
528 });
529 button.addEventListener("mouseleave", () => {
530 isHovered = false;
531 updateHighlight();
532 });
533 button.addEventListener("focus", () => {
534 isFocused = true;
535 updateHighlight();
536 });
537 button.addEventListener("blur", () => {
538 isFocused = false;
539 updateHighlight();
540 });
541​
542 if (hasChildren) {
543 const openSubmenu = (focusFirstItem = false) => {
544 const rect = button.getBoundingClientRect();
545 const nextLeft = rect.right + 4;
546 const nextTop = rect.top;
547 openMenu(item.children!, nextLeft, nextTop, level + 1, button);
548 button.setAttribute("aria-expanded", "true");
549​
550 const childMenu = menuStack[level + 1];
551 if (!childMenu) {
552 return;
553 }
554 const childRect = childMenu.getBoundingClientRect();
555 if (childRect.right > window.innerWidth) {
556 clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top);
557 }
558 if (focusFirstItem) {
559 [...childMenu.querySelectorAll<HTMLButtonElement>("button")]
560 .find((childButton) => !childButton.disabled)
561 ?.focus();
562 }
563 };
564 button.addEventListener("mouseenter", () => {
565 openSubmenu();
566 });
567 button.addEventListener("click", (event) => {
568 event.preventDefault();
569 openSubmenu(true);
570 });
571 } else {
572 button.addEventListener("mouseenter", () => {
573 closeMenusFromLevel(level + 1);
574 });
575 button.addEventListener("click", () => {
576 if (canDismissFromPointer) cleanup(item.id);
577 });
578 }
579 }
580​
581 inner.appendChild(button);
582 }
583​
584 menu.appendChild(inner);
585​
586 menu.addEventListener("mouseenter", () => {
587 closeMenusFromLevel(level + 1);
588 });
589 // Like a native menu, take thumb Back/Forward presses instead of letting Chromium
590 // run the app router's history underneath.
591 const swallowThumbButton = (event: MouseEvent) => {
592 if (event.button === 3 || event.button === 4) event.preventDefault();
593 };
594 menu.addEventListener("mousedown", swallowThumbButton);
595 menu.addEventListener("mouseup", swallowThumbButton);
596​
597 document.body.appendChild(menu);
598 menuStack[level] = menu;
599 submenuTriggerStack[level] = parentTrigger;
600​
601 requestAnimationFrame(() => {
602 clampMenuPosition(menu, preferredLeft, preferredTop);
603 });
604 };
605​
606 document.addEventListener("keydown", onKeyDown);
607 document.addEventListener("pointerdown", onPointerDown, true);
608 document.addEventListener("contextmenu", onContextMenu, true);
609 openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);
610 activeContextMenuDismiss = dismiss;
611 // A keyboard-opened menu starts on its first action, a pointer-opened one on the menu itself.
612 // Text fields always match :focus-visible, so they say nothing about how the menu opened.
613 const firstAction =
614 previouslyFocusedElement?.matches(":focus-visible") &&
615 !previouslyFocusedElement.matches('input, textarea, select, [contenteditable="true"]')
616 ? menuStack[0]?.querySelector<HTMLButtonElement>("button:not(:disabled)")
617 : null;
618 (firstAction ?? menuStack[0])?.focus({ preventScroll: true });
619 document.addEventListener("focusin", onFocusIn);
620 window.addEventListener("blur", onWindowBlur);
621​
622 requestAnimationFrame(() => {
623 canDismissFromPointer = true;
624 });
625 });