diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index bc6112dc65..7651941482 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -79,11 +79,40 @@ .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : [])) .join(" "); + function getUsableWindowBounds(excludeDetailsPanel: boolean = true): DOMRect { + const windowBounds = document.documentElement.getBoundingClientRect(); + + // 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, + ); + } + } + } + + 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. // 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 @@ -109,6 +138,33 @@ 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) { + const scrollHandler = () => { + // Close menu if button is no longer visible in viewport + if (self) { + const buttonBounds = self.getBoundingClientRect(); + const windowBounds = getUsableWindowBounds(); + + // 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 if (floatingMenuContainer) { containerResizeObserver.disconnect(); @@ -125,6 +181,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 @@ -176,22 +238,19 @@ 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(); - 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"; @@ -199,6 +258,11 @@ } 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) // 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 @@ -206,68 +270,176 @@ 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"; + + // 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.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%)"; + } - // 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`; + // 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"; + + 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`; + } + + // 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; + + // 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`; + } + } + } } - 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(); - if (direction === "Top" || direction === "Bottom") { - zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top"; + 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; - // 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) { - 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 (direction === "Left" || direction === "Right") { - zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left"; + 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 (overflowingTop) { - floatingMenuContentDiv.style.top = `${windowEdgeMargin}px`; - if (windowBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top"; + if (direction === "Top" || direction === "Bottom") { + zeroedBorderVertical = direction === "Top" ? "Bottom" : "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) { - 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; + } } } }