Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 236 additions & 64 deletions frontend/src/components/layout/FloatingMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://bugs.webkit.org/show_bug.cgi?id=160953>, 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
Expand All @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -176,98 +238,208 @@
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";
else if (direction === "Right" && overflowingRight) direction = "Left";
}

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
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";

// 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;
}
}
}
}
Expand Down