Skip to content
Draft
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
285 changes: 221 additions & 64 deletions frontend/src/components/layout/FloatingMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,27 @@
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
.join(" ");

// Generic function to get constraint bounds for positioning
// Returns the bounds of the scrollable parent if one exists, otherwise returns window bounds
function getConstraintBounds(element: HTMLElement | undefined): DOMRect {
const scrollableParent = element?.closest("[data-scrollable-x], [data-scrollable-y]");

if (scrollableParent) {
return scrollableParent.getBoundingClientRect();
}

return document.documentElement.getBoundingClientRect();
}

// Called only when `open` is changed from outside this component
async function watchOpenChange(isOpen: boolean) {
const scrollableParent = self?.closest("[data-scrollable-x], [data-scrollable-y]");
const isInScrollableContainer = Boolean(scrollableParent);

// 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")) {
// Only apply if NOT in scrollable container
if (browserVersion().toLowerCase().includes("safari") && !isInScrollableContainer) {
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 @@ -105,10 +121,38 @@
// 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 menus in scrollable containers
if (isInScrollableContainer && scrollableParent) {
const scrollHandler = () => {
// Get constraint bounds from scrollable parent
const constraintBounds = scrollableParent.getBoundingClientRect();
const buttonBounds = self?.getBoundingClientRect();

// Close menu if button is scrolled out of view
if (buttonBounds) {
const isOffScreen =
buttonBounds.right < constraintBounds.left ||
buttonBounds.left > constraintBounds.right ||
buttonBounds.bottom < constraintBounds.top ||
buttonBounds.top > constraintBounds.bottom;

if (isOffScreen) {
dispatch("open", false);
return;
}
}

// Update position
positionAndStyleFloatingMenu();
};

scrollableParent.addEventListener("scroll", scrollHandler);
}

// Floating menu min-width resize observer

// Start a new observation of the now-open floating menu
if (floatingMenuContainer) {
containerResizeObserver.disconnect();
Expand All @@ -125,6 +169,11 @@
window.removeEventListener("keydown", keyDownHandler);
window.removeEventListener("pointerdown", pointerDownHandler);
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`

// Clean up scroll listener
if (isInScrollableContainer && 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,21 +225,26 @@
const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (!self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;

const windowBounds = document.documentElement.getBoundingClientRect();
// Get constraint bounds generically
const constraintBounds = getConstraintBounds(self);
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;
// Check if in scrollable container
const scrollableParent = self?.closest("[data-scrollable-x], [data-scrollable-y]");
const isInScrollableContainer = Boolean(scrollableParent);

// 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") {
const floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect();
const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= constraintBounds.top;
const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= constraintBounds.bottom;
const overflowingLeft = floatingMenuContentBounds.left - windowEdgeMargin <= constraintBounds.left;
const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= constraintBounds.right;

// Flip direction if overflowing the edge of the window
if (direction === "Top" && overflowingTop) direction = "Bottom";
else if (direction === "Bottom" && overflowingBottom) direction = "Top";
Expand All @@ -199,75 +253,181 @@
}

const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));

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 menus in scrollable containers, position dynamically and center on button
if (isInScrollableContainer) {
floatingMenuContentDiv.style.position = "fixed";

const buttonCenterX = floatingMenuBounds.x + floatingMenuBounds.width / 2;
const buttonCenterY = floatingMenuBounds.y + floatingMenuBounds.height / 2;

// Set position based on direction
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 + (constraintBounds.height - (floatingMenuBounds.y - constraintBounds.top))}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 + (constraintBounds.width - (floatingMenuBounds.x - constraintBounds.left))}px`;
floatingMenuContentDiv.style.top = `${buttonCenterY}px`;
floatingMenuContentDiv.style.bottom = "";
floatingMenuContentDiv.style.left = "";
floatingMenuContentDiv.style.transform = "translateY(-50%)";
}

// Recalculate bounds after positioning
floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect();

const overflowingLeft = floatingMenuContentBounds.left - windowEdgeMargin <= constraintBounds.left;
const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= constraintBounds.right;
const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= constraintBounds.top;
const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= constraintBounds.bottom;

// Adjust for overflow
if (direction === "Bottom" || direction === "Top") {
if (overflowingLeft) {
const overflow = windowEdgeMargin + constraintBounds.left - floatingMenuContentBounds.left;
floatingMenuContentDiv.style.left = `${buttonCenterX + overflow}px`;
} else if (overflowingRight) {
const overflow = floatingMenuContentBounds.right + windowEdgeMargin - constraintBounds.right;
floatingMenuContentDiv.style.left = `${buttonCenterX - overflow}px`;
}
} else if (direction === "Left" || direction === "Right") {
if (overflowingTop) {
const overflow = windowEdgeMargin + constraintBounds.top - floatingMenuContentBounds.top;
floatingMenuContentDiv.style.top = `${buttonCenterY + overflow}px`;
} else if (overflowingBottom) {
const overflow = floatingMenuContentBounds.bottom + windowEdgeMargin - constraintBounds.bottom;
floatingMenuContentDiv.style.top = `${buttonCenterY - overflow}px`;
}
}
} else {
// Standard 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 + (constraintBounds.height - floatingMenuBounds.y)}px`;
if (direction === "Right") floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.x}px`;
if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + (constraintBounds.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`;
if (tail) {
const buttonCenterX = floatingMenuBounds.x + floatingMenuBounds.width / 2;
const buttonCenterY = floatingMenuBounds.y + floatingMenuBounds.height / 2;

const dialogBounds = floatingMenuContentDiv.getBoundingClientRect();
const borderRadius = 4;
const tailWidth = 12;

if (direction === "Bottom" || direction === "Top") {
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 = `${constraintBounds.height - floatingMenuBounds.y}px`;
tail.style.left = `${constrainedX}px`;
}
} else if (direction === "Left" || direction === "Right") {
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 = `${constraintBounds.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 <= constraintBounds.left;
const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= constraintBounds.right;
const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= constraintBounds.top;
const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= constraintBounds.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";

// 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 (constraintBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
}
if (overflowingRight) {
floatingMenuContentDiv.style.right = `${windowEdgeMargin}px`;
if (constraintBounds.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";

// 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 (constraintBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
}
if (overflowingBottom) {
floatingMenuContentDiv.style.bottom = `${windowEdgeMargin}px`;
if (constraintBounds.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) {
// 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;
}
}
}
}
Expand Down Expand Up @@ -354,15 +514,12 @@
// 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;
};

Expand All @@ -371,6 +528,7 @@

// 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) {
// If the current ancestor blocks hover transfer, stop searching
if (currentAncestor.hasAttribute("data-block-hover-transfer")) break;
Expand Down Expand Up @@ -453,7 +611,6 @@
function isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
// Consider all 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));
}

Expand Down