From 5e8b66e2cbe7bafd918e635329841aa8f5ba956b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 22 Jun 2025 03:35:00 -0700 Subject: [PATCH] WIP: Robust menu list arrow key navigation --- .../components/floating-menus/MenuList.svelte | 63 +++++++++++++------ .../src/components/layout/FloatingMenu.svelte | 11 +++- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/floating-menus/MenuList.svelte b/frontend/src/components/floating-menus/MenuList.svelte index 6c1f847172..9307189660 100644 --- a/frontend/src/components/floating-menus/MenuList.svelte +++ b/frontend/src/components/floating-menus/MenuList.svelte @@ -158,17 +158,23 @@ dispatch("activeEntry", menuListEntry); dispatch("selectedEntryValuePath", [...parentsValuePath, menuListEntry.value]); - // Close the containing menu - let childReference = getChildReference(menuListEntry); + // Close the child menu, which we will re-open by calling `onPointerEnter` below, in order to remove its highlighted state + let childReference = highlighted && getChildReference(highlighted); if (childReference) { childReference.open = false; entries = entries; } - dispatch("open", false); - open = false; + + // Remove the highlighted state from the current (clicked) entry + setHighlighted(undefined); + + // Re-spawn the child menu that we have clicked on, just like we hovered over it (`onPointerEnter`) + onEntryPointerEnter(menuListEntry); } function onEntryPointerEnter(menuListEntry: MenuListEntry) { + if (highlighted) return; + if (!menuListEntry.children?.length) { dispatch("hoverInEntry", menuListEntry); return; @@ -178,10 +184,14 @@ if (childReference) { childReference.open = true; entries = entries; - } else dispatch("open", true); + } else { + dispatch("open", true); + } } function onEntryPointerLeave(menuListEntry: MenuListEntry) { + if (highlighted) return; + if (!menuListEntry.children?.length) { dispatch("hoverOutEntry"); return; @@ -191,7 +201,9 @@ if (childReference) { childReference.open = false; entries = entries; - } else dispatch("open", false); + } else { + dispatch("open", false); + } } function isEntryOpen(menuListEntry: MenuListEntry): boolean { @@ -219,23 +231,21 @@ function openSubmenu(highlightedEntry: MenuListEntry): boolean { let childReference = getChildReference(highlightedEntry); - // No submenu to open + + // Exit if there is no submenu to open if (!childReference || !highlightedEntry.children?.length) return false; childReference.open = true; - // The reason we bother taking `highlightdEntry` as an argument is because, when this function is called, it can ensure `highlightedEntry` is not undefined. - // But here we still have to set `highlighted` to itself so Svelte knows to reactively update it after we set its `childReference.open` property. - highlighted = highlighted; - // Highlight first item - childReference.setHighlighted(highlightedEntry.children[0][0]); + // Ensure this item in the parent menu is highlighted + setHighlighted(highlightedEntry); // Submenu was opened return true; } /// Handles keyboard navigation for the menu. - // Returns a boolean indicating whether the entire menu stack should be dismissed. + /// Returns a boolean indicating whether the entire menu stack should be dismissed. export function keydown(e: KeyboardEvent, submenu = false): boolean { // Interactive menus should keep the active entry the same as the highlighted one if (interactive) highlighted = activeEntry; @@ -253,17 +263,24 @@ return false; } + console.log("openChild:", openChild, "highlighted:", highlighted, "menuOpen:", menuOpen); + // If a submenu is open, have it handle this instead if (menuOpen && openChild >= 0) { const childMenuListEntry = flatEntries[openChild]; const childMenu = getChildReference(childMenuListEntry); + console.log("openChild:", openChild, "childMenu:", childMenu); - // Redirect the keyboard navigation to a submenu if one is open - const shouldCloseStack = childMenu?.keydown(e, true) || false; + if (!highlighted && (e.key === "ArrowRight" || e.key === " " || e.key === "Enter")) { + openSubmenu(childMenuListEntry); + } // Highlight the menu item in the parent list that corresponds with the open submenu if (highlighted && e.key !== "Escape") setHighlighted(childMenuListEntry); + // Redirect the keyboard navigation to a submenu if one is open + const shouldCloseStack = childMenu?.keydown(e, true) || false; + // Handle the child closing the entire menu stack if (shouldCloseStack) open = false; @@ -271,6 +288,13 @@ return shouldCloseStack; } + // If this is the deepest sub-menu being activated by keyboard navigation, highlight its first entry + if (menuOpen && openChild === -1 && !highlighted && (e.key === "ArrowRight" || e.key === " " || e.key === "Enter")) { + // If no entry is highlighted, highlight the first entry + highlighted = flatEntries[0]; + setHighlighted(highlighted); + } + // Navigate to the next and previous entries with arrow keys if ((menuOpen || interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) { let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0; @@ -292,9 +316,10 @@ return false; } - // Close menu with escape key + // Close menu with escape key, or un-highlight the current entry if it's highlighted if (menuOpen && e.key === "Escape") { - open = false; + // Close the menu + if (submenu || !highlighted) open = false; // Reset active to before open setHighlighted(activeEntry); @@ -305,7 +330,7 @@ // Click on a highlighted entry with the enter key if (menuOpen && highlighted && e.key === "Enter") { - // Handle clicking on an option if enter is pressed + // Handle clicking on an option if enter is pressed, or open its submenu if it has one if (!highlighted.children?.length) onEntryClick(highlighted); else openSubmenu(highlighted); @@ -335,6 +360,8 @@ open = false; e.preventDefault(); + } else if (highlighted) { + setHighlighted(activeEntry); } // Keep the menu stack open diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index bc6112dc65..9d328aafa1 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -420,7 +420,7 @@ function pointerDownHandler(e: PointerEvent) { // Close the floating menu if the pointer clicked outside the floating menu (but within stray distance) - if (isPointerEventOutsideFloatingMenu(e)) { + if (!self?.closest("[data-floating-menu-content]") && isPointerEventOutsideFloatingMenu(e)) { dispatch("open", false); // Track if the left pointer button is now down so its later click event can be canceled @@ -448,10 +448,15 @@ } function isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean { - // Consider all child menus as well as the top-level one + // Consider all self-or-child menus as well as the top-level one const allContainedFloatingMenus = [...(self?.querySelectorAll("[data-floating-menu-content]") || [])]; - return !allContainedFloatingMenus.find((element) => !isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed)); + // If we find no self-or-child menus that this pointer event is inside, then the pointer event is outside all the floating menus + const result = !allContainedFloatingMenus.find((element) => !isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed)); + + if (result) console.log("Result is true"); + + return result; } function isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {