diff --git a/test/integration/thumbnail_view_spec.mjs b/test/integration/thumbnail_view_spec.mjs index f44dd5808..bc4e64120 100644 --- a/test/integration/thumbnail_view_spec.mjs +++ b/test/integration/thumbnail_view_spec.mjs @@ -355,4 +355,73 @@ describe("PDF Thumbnail View", () => { ); }); }); + + describe("Menu keyboard navigation with multi-character keys (bug 2016212)", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number_and_link.pdf", + "#viewsManagerSelectorButton", + null, + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("must navigate menus with ArrowDown and Tab keys", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.click("#viewsManagerToggleButton"); + await waitForThumbnailVisible(page, 1); + + // Focus the views manager selector button + await page.waitForSelector("#viewsManagerSelectorButton", { + visible: true, + }); + await page.focus("#viewsManagerSelectorButton"); + + // Open menu with Enter key + await page.keyboard.press("Enter"); + + // Wait for menu to be expanded + await waitForMenu(page, "#viewsManagerSelectorButton"); + + // Check that focus moved to the first menu button (pages) + await page.waitForSelector("#thumbnailsViewMenu:focus", { + visible: true, + }); + + // Press ArrowDown to navigate to second item + await page.keyboard.press("ArrowDown"); + + // Should now be on outlines button + await page.waitForSelector("#outlinesViewMenu:focus", { + visible: true, + }); + + // Press Tab to move to the manage button (should close views menu) + await page.keyboard.press("Tab"); + + // Wait for views manager menu to be collapsed + await waitForMenu(page, "#viewsManagerSelectorButton", false); + + // Focus should be on manage button + await page.waitForSelector("#viewsManagerStatusActionButton:focus", { + visible: true, + }); + + // Open manage menu with Space key + await page.keyboard.press(" "); + + // Wait for manage menu to be expanded + await waitForMenu(page, "#viewsManagerStatusActionButton"); + }) + ); + }); + }); }); diff --git a/web/menu.js b/web/menu.js index de917c6ec..9861c7377 100644 --- a/web/menu.js +++ b/web/menu.js @@ -28,6 +28,8 @@ class Menu { #lastIndex = -1; + #onFocusOutBound = this.#onFocusOut.bind(this); + /** * Create a menu for the given button. * @param {HTMLElement} menuContainer @@ -97,7 +99,18 @@ class Menu { }, { signal } ); - window.addEventListener("blur", this.#closeMenu.bind(this), { signal }); + const closeMenu = this.#closeMenu.bind(this); + window.addEventListener("blur", closeMenu, { signal }); + menu.addEventListener("focusout", this.#onFocusOutBound, { signal }); + } + + #onFocusOut({ relatedTarget }) { + if ( + !this.#triggeringButton.contains(relatedTarget) && + !this.#menu.contains(relatedTarget) + ) { + this.#closeMenu(); + } } /** @@ -112,6 +125,7 @@ class Menu { this.#openMenu(); }); + this.#triggeringButton.addEventListener("focusout", this.#onFocusOutBound); const { signal } = this.#menuAC; @@ -148,7 +162,12 @@ class Menu { stopEvent(e); break; default: - const char = e.key.toLocaleLowerCase(); + const { key } = e; + if (!/^\p{L}$/u.test(key)) { + // It isn't a single letter, so ignore it. + break; + } + const char = key.toLocaleLowerCase(); this.#goToNextItem(e.target, true, item => item.textContent.trim().toLowerCase().startsWith(char) );