Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/RightPanelTabs.keyboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/components/RightPanelTabs.launcherShortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 27 additions & 11 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading