diff --git a/frontend/src/components/floating-menus/MenuList.vue b/frontend/src/components/floating-menus/MenuList.vue index 565e5e54..4782b490 100644 --- a/frontend/src/components/floating-menus/MenuList.vue +++ b/frontend/src/components/floating-menus/MenuList.vue @@ -8,7 +8,6 @@ :escapeCloses="false" v-bind="{ direction, scrollableY: scrollableY && virtualScrollingEntryHeight === 0, minWidth }" ref="floatingMenu" - data-hover-menu-keep-open > diff --git a/frontend/src/components/layout/FloatingMenu.vue b/frontend/src/components/layout/FloatingMenu.vue index aaeb1dfd..b6670d49 100644 --- a/frontend/src/components/layout/FloatingMenu.vue +++ b/frontend/src/components/layout/FloatingMenu.vue @@ -359,22 +359,25 @@ export default defineComponent({ } }, pointerMoveHandler(e: PointerEvent) { + // This element and the element being hovered over + const self = this.$el as HTMLDivElement | undefined; const target = e.target as HTMLElement | undefined; - const pointerOverFloatingMenuKeepOpen = target?.closest("[data-hover-menu-keep-open]") as HTMLElement | undefined; - const pointerOverFloatingMenuSpawner = target?.closest("[data-hover-menu-spawner]") as HTMLElement | undefined; - const floatingMenu: HTMLDivElement | undefined = this.$el; - const pointerOverOwnFloatingMenuSpawner = floatingMenu && pointerOverFloatingMenuSpawner?.parentElement?.contains(floatingMenu); - // Swap this open floating menu with the one created by the floating menu spawner being hovered over - if (pointerOverFloatingMenuSpawner && !pointerOverOwnFloatingMenuSpawner) { - this.$emit("update:open", false); - pointerOverFloatingMenuSpawner.click(); - } + // Get the spawner element (that which is clicked to spawn this floating menu) + // Assumes the spawner is a sibling of this FloatingMenu component + const ownSpawner: HTMLElement | undefined = self?.parentElement?.querySelector(":scope > [data-floating-menu-spawner]") || undefined; + // Get the spawner element containing whatever element the user is hovering over now, if there is one + const targetSpawner: HTMLElement | undefined = target?.closest("[data-floating-menu-spawner]") || undefined; - // Close the floating menu if the pointer has strayed far enough from its bounds - if (this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE) && !pointerOverOwnFloatingMenuSpawner && !pointerOverFloatingMenuKeepOpen) { - // TODO: Extend this rectangle bounds check to all `data-hover-menu-keep-open` element bounds up the DOM tree since currently - // submenus disappear with zero stray distance if the cursor is further than the stray distance from only the top-level menu + // Hover transfer + // Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner + this.hoverTransfer(self, ownSpawner, targetSpawner); + + // Pointer stray + // Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner) + if (ownSpawner !== targetSpawner && this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE)) { + // TODO: Extend this rectangle bounds check to all submenu bounds up the DOM tree since currently submenus disappear + // TODO: with zero stray distance if the cursor is further than the stray distance from only the top-level menu this.$emit("update:open", false); } @@ -385,6 +388,72 @@ export default defineComponent({ window.removeEventListener("pointerup", this.pointerUpHandler); } }, + hoverTransfer(self: HTMLDivElement | undefined, ownSpawner: HTMLElement | undefined, targetSpawner: HTMLElement | undefined): void { + // Algorithm pseudo-code to detect and transfer to hover-transferrable floating menu spawners + // Accompanying diagram: + // + // Check our own parent for descendant spawners + // Filter out ourself and our children + // Filter out all with a different distance than our own distance from the currently-being-checked parent + // How many left? + // None -> go up a level and repeat + // Some -> is one of them the target? + // Yes -> click it and terminate + // No -> do nothing and terminate + + // Helper function that gets used below + const getDepthFromAncestor = (item: Element, ancestor: Element): number | undefined => { + let depth = 1; + + let parent = item.parentElement || undefined; + while (parent) { + if (parent === ancestor) return depth; + + parent = parent.parentElement || undefined; + depth += 1; + } + + return undefined; + }; + + // A list of all the descendant spawners: the spawner for this floating menu plus any spawners belonging to widgets inside this floating menu + const ownDescendantMenuSpawners = Array.from(self?.parentElement?.querySelectorAll("[data-floating-menu-spawner]") || []); + + // Start with the parent of the spawner for this floating menu and keep widening the search for any other valid spawners that are hover-transferrable + let currentAncestor = (targetSpawner && ownSpawner?.parentElement) || undefined; + while (currentAncestor) { + const ownSpawnerDepthFromCurrentAncestor = ownSpawner && getDepthFromAncestor(ownSpawner, currentAncestor); + const currentAncestor2 = currentAncestor; // This duplicate variable avoids an ESLint warning + + // Get the list of descendant spawners and filter out invalid possibilities for spawners that are hover-transferrable + const listOfDescendantSpawners = Array.from(currentAncestor?.querySelectorAll("[data-floating-menu-spawner]") || []); + const filteredListOfDescendantSpawners = listOfDescendantSpawners.filter((item: Element): boolean => { + // Filter away ourself and our descendants + const notOurself = !ownDescendantMenuSpawners.includes(item); + // And filter away unequal depths from the current ancestor + const notUnequalDepths = notOurself && getDepthFromAncestor(item, currentAncestor2) === ownSpawnerDepthFromCurrentAncestor; + // And filter away elements that explicitly disable hover transfer + return notUnequalDepths && !(item as HTMLElement).getAttribute?.("data-floating-menu-spawner")?.includes("no-hover-transfer"); + }); + + // If none were found, widen the search by a level and keep trying (or stop looping if the root was reached) + if (filteredListOfDescendantSpawners.length === 0) { + currentAncestor = currentAncestor?.parentElement || undefined; + } + // Stop after the first non-empty set was found + else { + const foundTarget = filteredListOfDescendantSpawners.find((item: Element): boolean => item === targetSpawner); + // If the currently hovered spawner is one of the found valid hover-transferrable spawners, swap to it by clicking on it + if (foundTarget) { + this.$emit("update:open", false); + (foundTarget as HTMLElement).click(); + } + + // In either case, we are done searching + break; + } + } + }, keyDownHandler(e: KeyboardEvent) { if (this.escapeCloses && e.key.toLowerCase() === "escape") { this.$emit("update:open", false); diff --git a/frontend/src/components/widgets/buttons/PopoverButton.vue b/frontend/src/components/widgets/buttons/PopoverButton.vue index 819acd69..2fdafeda 100644 --- a/frontend/src/components/widgets/buttons/PopoverButton.vue +++ b/frontend/src/components/widgets/buttons/PopoverButton.vue @@ -1,6 +1,6 @@