WIP: Robust menu list arrow key navigation

This commit is contained in:
Keavon Chambers
2025-06-22 03:35:00 -07:00
parent 2ee8e56cef
commit 5e8b66e2cb
2 changed files with 53 additions and 21 deletions

View File

@@ -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

View File

@@ -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 {