From 2fbfa7a17729fcd47d8c6686659515435a6687f1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:57:32 +0100 Subject: [PATCH] fix: give the focus back to the folder or message checkbox after closing a menu opened with Shift+F10 --- .../__tests__/use-context-menu-focus.test.tsx | 66 +++++++++++++++++++ hooks/use-context-menu.ts | 15 ++++- 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 hooks/__tests__/use-context-menu-focus.test.tsx diff --git a/hooks/__tests__/use-context-menu-focus.test.tsx b/hooks/__tests__/use-context-menu-focus.test.tsx new file mode 100644 index 000000000..03c88c73d --- /dev/null +++ b/hooks/__tests__/use-context-menu-focus.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu'; +import { useContextMenu } from '@/hooks/use-context-menu'; + +// A list row whose menu handler sits on the row, while the keyboard focus +// is on a control inside it (a folder's button, a message's checkbox). +function Row() { + const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); + return ( + <> +
openContextMenu(e, 'row')}> + +
+ + + + + ); +} + +async function openFromKeyboard() { + render(); + const control = screen.getByText('Inbox'); + control.focus(); + // Shift+F10 fires contextmenu at 0,0 on the focused control; it bubbles to the row. + fireEvent.contextMenu(control, { clientX: 0, clientY: 0 }); + await waitFor(() => expect(document.activeElement).toBe(screen.getByText('Rename').closest('button'))); + return control; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('useContextMenu focus return', () => { + it('gives the focus back to the focused control inside the row on Escape', async () => { + const control = await openFromKeyboard(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + expect(document.activeElement).toBe(control); + }); + + it('gives the focus back to the focused control inside the row on Tab', async () => { + const control = await openFromKeyboard(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Tab' }); + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + expect(document.activeElement).toBe(control); + }); + + it('still anchors the menu to the row', async () => { + const rect = (left: number, top: number, width: number, height: number) => + ({ left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) }) as DOMRect; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'menu') return rect(0, 0, 200, 120); + if (this.dataset.testid === 'row') return rect(500, 400, 300, 40); + return rect(0, 0, 0, 0); + }); + render(); + screen.getByText('Inbox').focus(); + fireEvent.contextMenu(screen.getByText('Inbox'), { clientX: 0, clientY: 0 }); + await waitFor(() => expect(screen.getByRole('menu').style.visibility).toBe('visible')); + expect(screen.getByRole('menu').style.left).toBe('500px'); + expect(screen.getByRole('menu').style.top).toBe('444px'); + }); +}); diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index e42eb6672..dfdc02348 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -40,6 +40,16 @@ function openedByKeyboard(e: React.MouseEvent): boolean { return false; } +/** + * Where the focus goes back to when the menu closes: the control inside the + * opener that had it (a folder row's button, a message row's checkbox, where + * Shift+F10 was pressed), else the opener itself. + */ +function focusReturnTarget(opener: HTMLElement): HTMLElement { + const active = typeof document === "undefined" ? null : document.activeElement; + return active instanceof HTMLElement && opener.contains(active) ? active : opener; +} + /** The position that opens a menu under an element. */ export function anchorPosition(element: HTMLElement, align: "start" | "end" = "start"): ContextMenuPosition { const rect = element.getBoundingClientRect(); @@ -89,9 +99,10 @@ export function useContextMenu(): UseContextMenuReturn { e.stopPropagation(); const target = e.currentTarget instanceof HTMLElement ? e.currentTarget : null; + const trigger = target ? focusReturnTarget(target) : null; const position = target && openedByKeyboard(e) - ? anchorPosition(target) - : { ...calculatePosition(e.clientX, e.clientY), trigger: target }; + ? { ...anchorPosition(target), trigger } + : { ...calculatePosition(e.clientX, e.clientY), trigger }; setContextMenu({ isOpen: true,