diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index e7a952ba43c5..dc1de792e91c 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -194,6 +194,7 @@ describe("right panel new-tab shortcut", () => { expect((await press("t", { metaKey: true, isComposing: true })).defaultPrevented).toBe(false); const dialog = document.createElement("div"); dialog.dataset.slot = "dialog-popup"; + dialog.dataset.open = ""; container.append(dialog); expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); expect(document.querySelector('[role="menu"]')).toBeNull(); diff --git a/apps/web/src/components/RightPanelTabs.launcherShortcuts.test.ts b/apps/web/src/components/RightPanelTabs.launcherShortcuts.test.ts new file mode 100644 index 000000000000..c4e77c931c36 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.launcherShortcuts.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vite-plus/test"; + +import { launcherShortcutBlockedByOverlay } from "./RightPanelTabs"; + +const POPUP_SLOTS = [ + "dialog-popup", + "alert-dialog-popup", + "command-dialog-popup", + "menu-popup", + "select-popup", + "popover-popup", + "combobox-popup", + "autocomplete-popup", +] as const; + +/** Mount a popup slot in the document in a closed, open, or closing state. */ +function mountPopup(slot: string, state: "closed" | "open" | "ending"): HTMLElement { + const popup = document.createElement("div"); + popup.dataset.slot = slot; + if (state === "closed") popup.dataset.closed = ""; + if (state === "open") popup.dataset.open = ""; + if (state === "ending") popup.dataset.endingStyle = ""; + document.body.append(popup); + return popup; +} + +describe("launcher shortcut overlays", () => { + it("ignores a closed popup that stays mounted", () => { + for (const slot of POPUP_SLOTS) { + document.body.replaceChildren(); + mountPopup(slot, "closed"); + expect(launcherShortcutBlockedByOverlay(), slot).toBe(false); + } + }); + + it("blocks while a popup is open or animating closed", () => { + for (const slot of POPUP_SLOTS) { + for (const state of ["open", "ending"] as const) { + document.body.replaceChildren(); + mountPopup(slot, state); + expect(launcherShortcutBlockedByOverlay(), `${slot} ${state}`).toBe(true); + } + } + }); + + it("keeps blocking when a closed mounted menu sits beside an open one", () => { + document.body.replaceChildren(); + mountPopup("menu-popup", "closed"); + mountPopup("menu-popup", "open"); + expect(launcherShortcutBlockedByOverlay()).toBe(true); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 2c7e3e3da68c..f5b30a479c97 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -165,18 +165,34 @@ const SURFACE_DISABLED_REASONS = { device: "Devices are only available from a thread.", } as const; -/** Overlays that must win over the launcher's letter shortcuts. */ +/** + * Overlays that must win over the launcher's letter shortcuts. + * Each popup matches only while open or animating closed. A closed popup + * can stay mounted (`keepMounted`); matching it by slot alone made the + * chat header actions menu swallow every launcher letter. + */ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ - '[data-slot="dialog-popup"]', - '[data-slot="alert-dialog-popup"]', - '[data-slot="command-dialog-popup"]', - '[data-slot="menu-popup"]', - '[data-slot="select-popup"]', - '[data-slot="popover-popup"]', - '[data-slot="combobox-popup"]', - '[data-slot="autocomplete-popup"]', + '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="menu-popup"]:is([data-open],[data-ending-style])', + '[data-slot="select-popup"]:is([data-open],[data-ending-style])', + '[data-slot="popover-popup"]:is([data-open],[data-ending-style])', + '[data-slot="combobox-popup"]:is([data-open],[data-ending-style])', + '[data-slot="autocomplete-popup"]:is([data-open],[data-ending-style])', ].join(","); +/** + * Whether a launcher letter should be ignored because an overlay is open + * or still animating closed. + * + * Closed popups that stay mounted do not count. The chat header actions + * menu used to, and every launcher letter was dead on every thread. + */ +export function launcherShortcutBlockedByOverlay(root: ParentNode = document): boolean { + return root.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS) !== null; +} + /** One-line unavailability hints for the empty-state rows. */ const SURFACE_UNAVAILABLE_HINTS = { browser: "Only available in the desktop app.", @@ -414,7 +430,7 @@ function RightPanelEmptyState(props: { const handler = (event: KeyboardEvent) => { const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); if (!action) return; - if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + if (launcherShortcutBlockedByOverlay()) return; // The composed path starts at the real target, which may sit inside a shadow root. const target = event.composedPath()[0] ?? event.target; if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; @@ -818,7 +834,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }) !== "rightPanel.new" ) return; - if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + if (!addSurfaceMenuOpen && launcherShortcutBlockedByOverlay()) return; event.preventDefault(); event.stopPropagation(); if (!event.repeat) {