Skip to content
Merged
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
14 changes: 14 additions & 0 deletions desktop/src/features/channels/ui/FocusThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ export function FocusThreadDrawer({
const drawerRef = React.useRef<HTMLDivElement>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);

React.useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopImmediatePropagation();
onClose();
Comment on lines +149 to +151

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let topmost overlays handle Escape first

Because this handler is registered on window in the capture phase, calling stopImmediatePropagation() here prevents any foreground dialog/lightbox opened from a focused thread from seeing Escape. For example, markdown image previews and composer attachment dialogs in the thread already use Escape to close themselves; with this change, pressing Escape closes/unmounts the entire focus drawer instead of dismissing the topmost preview and returning to the focused thread.

Useful? React with 👍 / 👎.

}

window.addEventListener("keydown", handleEscape, { capture: true });
return () => {
window.removeEventListener("keydown", handleEscape, { capture: true });
};
}, [onClose]);

React.useLayoutEffect(() => {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
Expand Down
25 changes: 7 additions & 18 deletions desktop/tests/e2e/thread-focus-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,29 +200,18 @@ test("focus and split preserve reading context and interaction ownership", async
body.locator(`[data-message-id="${anchorId}"]`),
).toBeInViewport();

// Escape layering: a nested control inside the drawer claims Escape first.
// With mention autocomplete open, Escape closes only the autocomplete — the
// drawer must stay.
// Focus mode owns Escape even while the rich-text composer and one of its
// nested controls has focus: one press exits the focused thread.
const threadInput = page
.getByTestId("message-thread-panel")
.getByTestId("message-input");
await threadInput.click();
await threadInput.pressSequentially("@al");
const mentionDropdown = page
.getByTestId("message-thread-panel")
.getByTestId("mention-autocomplete");
await expect(mentionDropdown).toBeVisible();
await page.keyboard.press("Escape");
await expect(mentionDropdown).toHaveCount(0);
await expect(drawer).toBeVisible();

// Known gap (pre-existing, tracked as a follow-up): while the composer's
// rich-text editor holds focus it claims Escape internally even with no
// autocomplete open, so Escape-from-composer never reaches the drawer's
// close listener. Move focus to the drawer itself before asserting
// Escape-to-close. When the editor is taught to release an idle Escape,
// this focus hop can be removed.
await drawer.focus();
await expect(
page
.getByTestId("message-thread-panel")
.getByTestId("mention-autocomplete"),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
await expect(channel).not.toHaveAttribute("inert", "");
Expand Down
Loading