From cabf45b175023ee2217aa85271d27d27d6898230 Mon Sep 17 00:00:00 2001 From: Ayush Aggarwal Date: Sat, 20 Dec 2025 06:11:57 +0530 Subject: [PATCH 1/5] Fix dialog position shifting on button scroll --- .../src/components/layout/FloatingMenu.svelte | 122 ++++++++++++++---- 1 file changed, 98 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index bc6112dc65..c88ced3823 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -80,14 +80,15 @@ .join(" "); // Called only when `open` is changed from outside this component + // SOLUTION: Update the watchOpenChange function to NOT disable scrolling for dialogs + async function watchOpenChange(isOpen: boolean) { // Mitigate a Safari rendering bug which clips the floating menu extending beyond a scrollable container. - // The bug is possibly related to , but in our case it happens when `overflow` of a parent is `auto` rather than `hidden`. - if (browserVersion().toLowerCase().includes("safari")) { + // IMPORTANT: Don't apply this workaround to menus in scrollable containers since we want them to scroll with their buttons + const inScrollableContainer = Boolean(self?.closest("[data-scrollable-x], [data-scrollable-y]")); + if (browserVersion().toLowerCase().includes("safari") && !inScrollableContainer) { const scrollable = self?.closest("[data-scrollable-x], [data-scrollable-y]"); if (scrollable instanceof HTMLElement) { - // The issue exists when the container is set to `overflow: auto` but fine when `overflow: hidden`. So this workaround temporarily sets - // the scrollable container to `overflow: hidden`, thus removing the scrollbars and ability to scroll until the floating menu is closed. scrollable.style.overflow = isOpen ? "hidden" : ""; } } @@ -105,10 +106,14 @@ // Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target window.addEventListener("pointerup", pointerUpHandler); - // Floating menu min-width resize observer - await tick(); + // Add scroll listener for ALL menu types in scrollable containers + const scrollableParent = self?.closest("[data-scrollable-x], [data-scrollable-y]"); + if (scrollableParent) { + scrollableParent.addEventListener("scroll", positionAndStyleFloatingMenu); + } + // Start a new observation of the now-open floating menu if (floatingMenuContainer) { containerResizeObserver.disconnect(); @@ -125,6 +130,12 @@ window.removeEventListener("keydown", keyDownHandler); window.removeEventListener("pointerdown", pointerDownHandler); // The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()` + + // Clean up scroll listener for ALL menu types + const scrollableParent = self?.closest("[data-scrollable-x], [data-scrollable-y]"); + if (scrollableParent) { + scrollableParent.removeEventListener("scroll", positionAndStyleFloatingMenu); + } } // Now that we're done reading the old state, update it to the current state for next time @@ -170,6 +181,10 @@ minWidthParentWidth = entries[0].contentRect.width; } + // DEBUGGING VERSION - Replace your positionAndStyleFloatingMenu function with this: + + // COMPLETE WORKING SOLUTION - Replace positionAndStyleFloatingMenu function: + function positionAndStyleFloatingMenu() { if (type === "Cursor") return; @@ -187,9 +202,6 @@ const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= windowBounds.bottom; // TODO: Make this work for all types. This is currently limited to tooltips because they're inherently small and transient. - // TODO: But on popovers and dropdowns, it's a bit harder to do this right. First we check if it's overflowing and flip the direction to avoid the overflow. - // TODO: But once it's flipped, if the position moves and the menu would no longer be overflowing, we're still flipped and thus unable to automatically notice the need to flip back. - // TODO: So as a result, once flipped, it stays flipped forever even if the menu spawner element is moved back away from the edge of the window. if (type === "Tooltip") { // Flip direction if overflowing the edge of the window if (direction === "Top" && overflowingTop) direction = "Bottom"; @@ -199,39 +211,101 @@ } const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]")); + + // Check if spawner is inside a scrollable container + const scrollableParent = self?.closest("[data-scrollable-x], [data-scrollable-y]"); + const isInScrollableContainer = Boolean(scrollableParent); + if (!inParentFloatingMenu) { - // Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping) + // Required to correctly position content when scrolled // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever let tailOffset = 0; if (type === "Popover") tailOffset = 10; if (type === "Tooltip") tailOffset = direction === "Bottom" ? 20 : 10; - if (direction === "Bottom") floatingMenuContentDiv.style.top = `${tailOffset + floatingMenuBounds.y}px`; - if (direction === "Top") floatingMenuContentDiv.style.bottom = `${tailOffset + (windowBounds.height - floatingMenuBounds.y)}px`; - if (direction === "Right") floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.x}px`; - if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + (windowBounds.width - floatingMenuBounds.x)}px`; + // For ALL menu types in scrollable containers, update positioning dynamically + if (isInScrollableContainer) { + // Keep using fixed positioning but update the coordinates dynamically + floatingMenuContentDiv.style.position = "fixed"; + + // Clear all position properties first + floatingMenuContentDiv.style.top = ""; + floatingMenuContentDiv.style.bottom = ""; + floatingMenuContentDiv.style.left = ""; + floatingMenuContentDiv.style.right = ""; + + // Calculate center position of the button + const buttonCenterX = floatingMenuBounds.x + floatingMenuBounds.width / 2; + const buttonCenterY = floatingMenuBounds.y + floatingMenuBounds.height / 2; + + // Set new position based on current button location + if (direction === "Bottom") { + floatingMenuContentDiv.style.top = `${tailOffset + floatingMenuBounds.y}px`; + floatingMenuContentDiv.style.left = `${buttonCenterX}px`; + floatingMenuContentDiv.style.transform = "translateX(-50%)"; + } else if (direction === "Top") { + floatingMenuContentDiv.style.bottom = `${tailOffset + (windowBounds.height - floatingMenuBounds.y)}px`; + floatingMenuContentDiv.style.left = `${buttonCenterX}px`; + floatingMenuContentDiv.style.transform = "translateX(-50%)"; + } else if (direction === "Right") { + floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.x}px`; + floatingMenuContentDiv.style.top = `${buttonCenterY}px`; + floatingMenuContentDiv.style.transform = "translateY(-50%)"; + } else if (direction === "Left") { + floatingMenuContentDiv.style.right = `${tailOffset + (windowBounds.width - floatingMenuBounds.x)}px`; + floatingMenuContentDiv.style.top = `${buttonCenterY}px`; + floatingMenuContentDiv.style.transform = "translateY(-50%)"; + } + } else { + // Use fixed positioning for non-scrollable contexts + floatingMenuContentDiv.style.position = "fixed"; + + if (direction === "Bottom") floatingMenuContentDiv.style.top = `${tailOffset + floatingMenuBounds.y}px`; + if (direction === "Top") floatingMenuContentDiv.style.bottom = `${tailOffset + (windowBounds.height - floatingMenuBounds.y)}px`; + if (direction === "Right") floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.x}px`; + if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + (windowBounds.width - floatingMenuBounds.x)}px`; + } - // Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping) - // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever - if (tail && direction === "Bottom") tail.style.top = `${floatingMenuBounds.y}px`; - if (tail && direction === "Top") tail.style.bottom = `${windowBounds.height - floatingMenuBounds.y}px`; - if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.x}px`; - if (tail && direction === "Left") tail.style.right = `${windowBounds.width - floatingMenuBounds.x}px`; + // Update tail position (always update it, even in scrollable containers) + if (tail) { + // Calculate center position for the tail + const buttonCenterX = floatingMenuBounds.x + floatingMenuBounds.width / 2; + const buttonCenterY = floatingMenuBounds.y + floatingMenuBounds.height / 2; + + if (direction === "Bottom") { + tail.style.top = `${floatingMenuBounds.y}px`; + tail.style.left = `${buttonCenterX}px`; + } + if (direction === "Top") { + tail.style.bottom = `${windowBounds.height - floatingMenuBounds.y}px`; + tail.style.left = `${buttonCenterX}px`; + } + if (direction === "Right") { + tail.style.left = `${floatingMenuBounds.x}px`; + tail.style.top = `${buttonCenterY}px`; + } + if (direction === "Left") { + tail.style.right = `${windowBounds.width - floatingMenuBounds.x}px`; + tail.style.top = `${buttonCenterY}px`; + } + } } type Edge = "Top" | "Bottom" | "Left" | "Right"; let zeroedBorderVertical: Edge | undefined; let zeroedBorderHorizontal: Edge | undefined; + const skipOverflowHandling = isInScrollableContainer; + if (direction === "Top" || direction === "Bottom") { zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top"; // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever - if (overflowingLeft) { + if (overflowingLeft && !skipOverflowHandling) { floatingMenuContentDiv.style.left = `${windowEdgeMargin}px`; if (windowBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left"; } - if (overflowingRight) { + if (overflowingRight && !skipOverflowHandling) { floatingMenuContentDiv.style.right = `${windowEdgeMargin}px`; if (windowBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right"; } @@ -240,11 +314,11 @@ zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left"; // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever - if (overflowingTop) { + if (overflowingTop && !skipOverflowHandling) { floatingMenuContentDiv.style.top = `${windowEdgeMargin}px`; if (windowBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top"; } - if (overflowingBottom) { + if (overflowingBottom && !skipOverflowHandling) { floatingMenuContentDiv.style.bottom = `${windowEdgeMargin}px`; if (windowBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom"; } From 87c0ef6da0782398dc8ec064f7db836ad1072932 Mon Sep 17 00:00:00 2001 From: Ayush Aggarwal Date: Sat, 20 Dec 2025 06:55:04 +0530 Subject: [PATCH 2/5] Refine dialog positioning logic --- .../src/components/layout/FloatingMenu.svelte | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index c88ced3823..5e1d7f7826 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -80,15 +80,16 @@ .join(" "); // Called only when `open` is changed from outside this component - // SOLUTION: Update the watchOpenChange function to NOT disable scrolling for dialogs - async function watchOpenChange(isOpen: boolean) { // Mitigate a Safari rendering bug which clips the floating menu extending beyond a scrollable container. + // The bug is possibly related to , but in our case it happens when `overflow` of a parent is `auto` rather than `hidden`. // IMPORTANT: Don't apply this workaround to menus in scrollable containers since we want them to scroll with their buttons const inScrollableContainer = Boolean(self?.closest("[data-scrollable-x], [data-scrollable-y]")); if (browserVersion().toLowerCase().includes("safari") && !inScrollableContainer) { const scrollable = self?.closest("[data-scrollable-x], [data-scrollable-y]"); if (scrollable instanceof HTMLElement) { + // The issue exists when the container is set to `overflow: auto` but fine when `overflow: hidden`. So this workaround temporarily sets + // the scrollable container to `overflow: hidden`, thus removing the scrollbars and ability to scroll until the floating menu is closed. scrollable.style.overflow = isOpen ? "hidden" : ""; } } @@ -106,6 +107,8 @@ // Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target window.addEventListener("pointerup", pointerUpHandler); + // Floating menu min-width resize observer + await tick(); // Add scroll listener for ALL menu types in scrollable containers @@ -181,10 +184,6 @@ minWidthParentWidth = entries[0].contentRect.width; } - // DEBUGGING VERSION - Replace your positionAndStyleFloatingMenu function with this: - - // COMPLETE WORKING SOLUTION - Replace positionAndStyleFloatingMenu function: - function positionAndStyleFloatingMenu() { if (type === "Cursor") return; @@ -202,6 +201,9 @@ const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= windowBounds.bottom; // TODO: Make this work for all types. This is currently limited to tooltips because they're inherently small and transient. + // TODO: But on popovers and dropdowns, it's a bit harder to do this right. First we check if it's overflowing and flip the direction to avoid the overflow. + // TODO: But once it's flipped, if the position moves and the menu would no longer be overflowing, we're still flipped and thus unable to automatically notice the need to flip back. + // TODO: So as a result, once flipped, it stays flipped forever even if the menu spawner element is moved back away from the edge of the window. if (type === "Tooltip") { // Flip direction if overflowing the edge of the window if (direction === "Top" && overflowingTop) direction = "Bottom"; @@ -217,7 +219,7 @@ const isInScrollableContainer = Boolean(scrollableParent); if (!inParentFloatingMenu) { - // Required to correctly position content when scrolled + // Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping) // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever let tailOffset = 0; if (type === "Popover") tailOffset = 10; From 2fa4740d2dd9640dd3118b20b7aa6dfd5567dbd5 Mon Sep 17 00:00:00 2001 From: Ayush Aggarwal Date: Sun, 21 Dec 2025 15:26:57 +0530 Subject: [PATCH 3/5] Address PR feedback: refine dialog positioning logic --- .../src/components/layout/FloatingMenu.svelte | 172 ++++++++++++------ 1 file changed, 112 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index 5e1d7f7826..cf4c3d0953 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -111,10 +111,31 @@ await tick(); + // Add scroll listener for ALL menu types in scrollable containers // Add scroll listener for ALL menu types in scrollable containers const scrollableParent = self?.closest("[data-scrollable-x], [data-scrollable-y]"); if (scrollableParent) { - scrollableParent.addEventListener("scroll", positionAndStyleFloatingMenu); + const scrollHandler = () => { + // Close menu if button is no longer visible in viewport + if (self) { + const buttonBounds = self.getBoundingClientRect(); + const windowBounds = document.documentElement.getBoundingClientRect(); + + // Check if button is off-screen + const isOffScreen = + buttonBounds.right < windowBounds.left || buttonBounds.left > windowBounds.right || buttonBounds.bottom < windowBounds.top || buttonBounds.top > windowBounds.bottom; + + if (isOffScreen) { + dispatch("open", false); + return; + } + } + + // Otherwise, update position + positionAndStyleFloatingMenu(); + }; + + scrollableParent.addEventListener("scroll", scrollHandler); } // Start a new observation of the now-open floating menu @@ -193,19 +214,16 @@ const windowBounds = document.documentElement.getBoundingClientRect(); floatingMenuBounds = self.getBoundingClientRect(); const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect(); - floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect(); - - const overflowingLeft = floatingMenuContentBounds.left - windowEdgeMargin <= windowBounds.left; - const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= windowBounds.right; - const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= windowBounds.top; - const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= windowBounds.bottom; // TODO: Make this work for all types. This is currently limited to tooltips because they're inherently small and transient. - // TODO: But on popovers and dropdowns, it's a bit harder to do this right. First we check if it's overflowing and flip the direction to avoid the overflow. - // TODO: But once it's flipped, if the position moves and the menu would no longer be overflowing, we're still flipped and thus unable to automatically notice the need to flip back. - // TODO: So as a result, once flipped, it stays flipped forever even if the menu spawner element is moved back away from the edge of the window. if (type === "Tooltip") { // Flip direction if overflowing the edge of the window + const floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect(); + const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= windowBounds.top; + const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= windowBounds.bottom; + const overflowingLeft = floatingMenuContentBounds.left - windowEdgeMargin <= windowBounds.left; + const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= windowBounds.right; + if (direction === "Top" && overflowingTop) direction = "Bottom"; else if (direction === "Bottom" && overflowingBottom) direction = "Top"; else if (direction === "Left" && overflowingLeft) direction = "Right"; @@ -230,12 +248,6 @@ // Keep using fixed positioning but update the coordinates dynamically floatingMenuContentDiv.style.position = "fixed"; - // Clear all position properties first - floatingMenuContentDiv.style.top = ""; - floatingMenuContentDiv.style.bottom = ""; - floatingMenuContentDiv.style.left = ""; - floatingMenuContentDiv.style.right = ""; - // Calculate center position of the button const buttonCenterX = floatingMenuBounds.x + floatingMenuBounds.width / 2; const buttonCenterY = floatingMenuBounds.y + floatingMenuBounds.height / 2; @@ -244,20 +256,55 @@ if (direction === "Bottom") { floatingMenuContentDiv.style.top = `${tailOffset + floatingMenuBounds.y}px`; floatingMenuContentDiv.style.left = `${buttonCenterX}px`; + floatingMenuContentDiv.style.bottom = ""; + floatingMenuContentDiv.style.right = ""; floatingMenuContentDiv.style.transform = "translateX(-50%)"; } else if (direction === "Top") { floatingMenuContentDiv.style.bottom = `${tailOffset + (windowBounds.height - floatingMenuBounds.y)}px`; floatingMenuContentDiv.style.left = `${buttonCenterX}px`; + floatingMenuContentDiv.style.top = ""; + floatingMenuContentDiv.style.right = ""; floatingMenuContentDiv.style.transform = "translateX(-50%)"; } else if (direction === "Right") { floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.x}px`; floatingMenuContentDiv.style.top = `${buttonCenterY}px`; + floatingMenuContentDiv.style.bottom = ""; + floatingMenuContentDiv.style.right = ""; floatingMenuContentDiv.style.transform = "translateY(-50%)"; } else if (direction === "Left") { floatingMenuContentDiv.style.right = `${tailOffset + (windowBounds.width - floatingMenuBounds.x)}px`; floatingMenuContentDiv.style.top = `${buttonCenterY}px`; + floatingMenuContentDiv.style.bottom = ""; + floatingMenuContentDiv.style.left = ""; floatingMenuContentDiv.style.transform = "translateY(-50%)"; } + + // NOW recalculate bounds after positioning to check for overflow + floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect(); + + const overflowingLeft = floatingMenuContentBounds.left - windowEdgeMargin <= windowBounds.left; + const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= windowBounds.right; + const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= windowBounds.top; + const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= windowBounds.bottom; + + // Handle overflow by adjusting position + if (direction === "Bottom" || direction === "Top") { + if (overflowingLeft) { + const overflow = windowEdgeMargin - floatingMenuContentBounds.left; + floatingMenuContentDiv.style.left = `${buttonCenterX + overflow}px`; + } else if (overflowingRight) { + const overflow = floatingMenuContentBounds.right + windowEdgeMargin - windowBounds.right; + floatingMenuContentDiv.style.left = `${buttonCenterX - overflow}px`; + } + } else if (direction === "Left" || direction === "Right") { + if (overflowingTop) { + const overflow = windowEdgeMargin - floatingMenuContentBounds.top; + floatingMenuContentDiv.style.top = `${buttonCenterY + overflow}px`; + } else if (overflowingBottom) { + const overflow = floatingMenuContentBounds.bottom + windowEdgeMargin - windowBounds.bottom; + floatingMenuContentDiv.style.top = `${buttonCenterY - overflow}px`; + } + } } else { // Use fixed positioning for non-scrollable contexts floatingMenuContentDiv.style.position = "fixed"; @@ -293,57 +340,62 @@ } } - type Edge = "Top" | "Bottom" | "Left" | "Right"; - let zeroedBorderVertical: Edge | undefined; - let zeroedBorderHorizontal: Edge | undefined; + // Handle overflow for non-scrollable contexts + if (!isInScrollableContainer) { + floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect(); - const skipOverflowHandling = isInScrollableContainer; + const overflowingLeft = floatingMenuContentBounds.left - windowEdgeMargin <= windowBounds.left; + const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= windowBounds.right; + const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= windowBounds.top; + const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= windowBounds.bottom; - if (direction === "Top" || direction === "Bottom") { - zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top"; + type Edge = "Top" | "Bottom" | "Left" | "Right"; + let zeroedBorderVertical: Edge | undefined; + let zeroedBorderHorizontal: Edge | undefined; - // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever - if (overflowingLeft && !skipOverflowHandling) { - floatingMenuContentDiv.style.left = `${windowEdgeMargin}px`; - if (windowBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left"; - } - if (overflowingRight && !skipOverflowHandling) { - floatingMenuContentDiv.style.right = `${windowEdgeMargin}px`; - if (windowBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right"; - } - } - if (direction === "Left" || direction === "Right") { - zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left"; + if (direction === "Top" || direction === "Bottom") { + zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top"; - // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever - if (overflowingTop && !skipOverflowHandling) { - floatingMenuContentDiv.style.top = `${windowEdgeMargin}px`; - if (windowBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top"; + if (overflowingLeft) { + floatingMenuContentDiv.style.left = `${windowEdgeMargin}px`; + if (windowBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left"; + } + if (overflowingRight) { + floatingMenuContentDiv.style.right = `${windowEdgeMargin}px`; + if (windowBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right"; + } } - if (overflowingBottom && !skipOverflowHandling) { - floatingMenuContentDiv.style.bottom = `${windowEdgeMargin}px`; - if (windowBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom"; + if (direction === "Left" || direction === "Right") { + zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left"; + + if (overflowingTop) { + floatingMenuContentDiv.style.top = `${windowEdgeMargin}px`; + if (windowBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top"; + } + if (overflowingBottom) { + floatingMenuContentDiv.style.bottom = `${windowEdgeMargin}px`; + if (windowBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom"; + } } - } - // Remove the rounded corner from the content where the tail perfectly meets the corner - if (displayTail && windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) { - // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever - switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) { - case "TopLeft": - floatingMenuContentDiv.style.borderTopLeftRadius = "0"; - break; - case "TopRight": - floatingMenuContentDiv.style.borderTopRightRadius = "0"; - break; - case "BottomLeft": - floatingMenuContentDiv.style.borderBottomLeftRadius = "0"; - break; - case "BottomRight": - floatingMenuContentDiv.style.borderBottomRightRadius = "0"; - break; - default: - break; + // Remove the rounded corner from the content where the tail perfectly meets the corner + if (displayTail && windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) { + switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) { + case "TopLeft": + floatingMenuContentDiv.style.borderTopLeftRadius = "0"; + break; + case "TopRight": + floatingMenuContentDiv.style.borderTopRightRadius = "0"; + break; + case "BottomLeft": + floatingMenuContentDiv.style.borderBottomLeftRadius = "0"; + break; + case "BottomRight": + floatingMenuContentDiv.style.borderBottomRightRadius = "0"; + break; + default: + break; + } } } } From dd411a5db1ee9981d856bd1d07223b9df0eea64d Mon Sep 17 00:00:00 2001 From: Ayush Aggarwal Date: Sun, 21 Dec 2025 18:04:15 +0530 Subject: [PATCH 4/5] Final fix --- .../src/components/layout/FloatingMenu.svelte | 72 ++++++++++++++----- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index cf4c3d0953..ac98c8733e 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -79,6 +79,27 @@ .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : [])) .join(" "); + function getUsableWindowBounds(): DOMRect { + const windowBounds = document.documentElement.getBoundingClientRect(); + + // Check for the details panel (right sidebar) + const detailsPanel = document.querySelector('[data-subdivision-name="details"]'); + if (detailsPanel) { + const detailsBounds = detailsPanel.getBoundingClientRect(); + // If details panel is visible and on the right side, reduce usable width + if (detailsBounds.width > 0 && detailsBounds.left > windowBounds.left) { + return new DOMRect( + windowBounds.left, + windowBounds.top, + detailsBounds.left - windowBounds.left, // Usable width ends where details panel begins + windowBounds.height, + ); + } + } + + return windowBounds; + } + // Called only when `open` is changed from outside this component async function watchOpenChange(isOpen: boolean) { // Mitigate a Safari rendering bug which clips the floating menu extending beyond a scrollable container. @@ -119,7 +140,7 @@ // Close menu if button is no longer visible in viewport if (self) { const buttonBounds = self.getBoundingClientRect(); - const windowBounds = document.documentElement.getBoundingClientRect(); + const windowBounds = getUsableWindowBounds(); // Check if button is off-screen const isOffScreen = @@ -211,7 +232,7 @@ const floatingMenuContentDiv = floatingMenuContent?.div?.(); if (!self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return; - const windowBounds = document.documentElement.getBoundingClientRect(); + const windowBounds = getUsableWindowBounds(); floatingMenuBounds = self.getBoundingClientRect(); const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect(); @@ -315,27 +336,44 @@ if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + (windowBounds.width - floatingMenuBounds.x)}px`; } + // Update tail position (always update it, even in scrollable containers) // Update tail position (always update it, even in scrollable containers) if (tail) { // Calculate center position for the tail const buttonCenterX = floatingMenuBounds.x + floatingMenuBounds.width / 2; const buttonCenterY = floatingMenuBounds.y + floatingMenuBounds.height / 2; - if (direction === "Bottom") { - tail.style.top = `${floatingMenuBounds.y}px`; - tail.style.left = `${buttonCenterX}px`; - } - if (direction === "Top") { - tail.style.bottom = `${windowBounds.height - floatingMenuBounds.y}px`; - tail.style.left = `${buttonCenterX}px`; - } - if (direction === "Right") { - tail.style.left = `${floatingMenuBounds.x}px`; - tail.style.top = `${buttonCenterY}px`; - } - if (direction === "Left") { - tail.style.right = `${windowBounds.width - floatingMenuBounds.x}px`; - tail.style.top = `${buttonCenterY}px`; + // Get dialog bounds to constrain tail position + const dialogBounds = floatingMenuContentDiv.getBoundingClientRect(); + const borderRadius = 4; // From CSS: border-radius: 4px + const tailWidth = 12; // Tail is 12px wide (6px on each side from CSS) + + if (direction === "Bottom" || direction === "Top") { + // Constrain tail X position to stay within dialog bounds (minus border radius) + const minX = dialogBounds.left + borderRadius + tailWidth / 2; + const maxX = dialogBounds.right - borderRadius - tailWidth / 2; + const constrainedX = Math.max(minX, Math.min(maxX, buttonCenterX)); + + if (direction === "Bottom") { + tail.style.top = `${floatingMenuBounds.y}px`; + tail.style.left = `${constrainedX}px`; + } else { + tail.style.bottom = `${windowBounds.height - floatingMenuBounds.y}px`; + tail.style.left = `${constrainedX}px`; + } + } else if (direction === "Left" || direction === "Right") { + // Constrain tail Y position to stay within dialog bounds (minus border radius) + const minY = dialogBounds.top + borderRadius + tailWidth / 2; + const maxY = dialogBounds.bottom - borderRadius - tailWidth / 2; + const constrainedY = Math.max(minY, Math.min(maxY, buttonCenterY)); + + if (direction === "Right") { + tail.style.left = `${floatingMenuBounds.x}px`; + tail.style.top = `${constrainedY}px`; + } else { + tail.style.right = `${windowBounds.width - floatingMenuBounds.x}px`; + tail.style.top = `${constrainedY}px`; + } } } } From 1362c0bb410204806a0787d933e03b0492ceeaec Mon Sep 17 00:00:00 2001 From: Ayush Aggarwal Date: Sun, 21 Dec 2025 23:47:58 +0530 Subject: [PATCH 5/5] updated logic --- .../src/components/layout/FloatingMenu.svelte | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index ac98c8733e..7651941482 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -79,21 +79,27 @@ .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : [])) .join(" "); - function getUsableWindowBounds(): DOMRect { + function getUsableWindowBounds(excludeDetailsPanel: boolean = true): DOMRect { const windowBounds = document.documentElement.getBoundingClientRect(); - // Check for the details panel (right sidebar) - const detailsPanel = document.querySelector('[data-subdivision-name="details"]'); - if (detailsPanel) { - const detailsBounds = detailsPanel.getBoundingClientRect(); - // If details panel is visible and on the right side, reduce usable width - if (detailsBounds.width > 0 && detailsBounds.left > windowBounds.left) { - return new DOMRect( - windowBounds.left, - windowBounds.top, - detailsBounds.left - windowBounds.left, // Usable width ends where details panel begins - windowBounds.height, - ); + // Only exclude the details panel if requested AND the menu is not inside it + if (excludeDetailsPanel) { + const detailsPanel = document.querySelector('[data-subdivision-name="details"]'); + + // Check if this floating menu's spawner is inside the details panel + const isInsideDetailsPanel = self?.closest('[data-subdivision-name="details"]'); + + if (detailsPanel && !isInsideDetailsPanel) { + const detailsBounds = detailsPanel.getBoundingClientRect(); + // If details panel is visible and on the right side, reduce usable width + if (detailsBounds.width > 0 && detailsBounds.left > windowBounds.left) { + return new DOMRect( + windowBounds.left, + windowBounds.top, + detailsBounds.left - windowBounds.left, // Usable width ends where details panel begins + windowBounds.height, + ); + } } }