graphite2 / frontend /src /components /layout /FloatingMenu.svelte
openfree's picture
Deploy from GitHub repository
2409829 verified
<script lang="ts" context="module">
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
/// Prevents the escape key from closing the parent floating menu of the given element.
/// This works by momentarily setting the `data-escape-does-not-close` attribute on the parent floating menu element.
/// After checking for the Escape key, it checks (in one `setTimeout`) for the attribute and ignores the key if it's present.
/// Then after two calls of `setTimeout`, we can safely remove the attribute here.
export function preventEscapeClosingParentFloatingMenu(element: HTMLElement) {
const floatingMenuParent = element.closest("[data-floating-menu-content]") || undefined;
if (floatingMenuParent instanceof HTMLElement) {
floatingMenuParent.setAttribute("data-escape-does-not-close", "");
setTimeout(() => {
setTimeout(() => {
floatingMenuParent.removeAttribute("data-escape-does-not-close");
}, 0);
}, 0);
}
}
</script>
<script lang="ts">
import { onMount, afterUpdate, createEventDispatcher, tick } from "svelte";
import type { MenuDirection } from "@graphite/messages";
import { browserVersion } from "@graphite/utility-functions/platform";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
const BUTTON_LEFT = 0;
const POINTER_STRAY_DISTANCE = 100;
const dispatch = createEventDispatcher<{ open: boolean; naturalWidth: number }>();
let className = "";
export { className as class };
export let classes: Record<string, boolean> = {};
let styleName = "";
export { styleName as style };
export let styles: Record<string, string | number | undefined> = {};
export let open: boolean;
export let type: MenuType;
export let direction: MenuDirection = "Bottom";
export let windowEdgeMargin = 6;
export let scrollableY = false;
export let minWidth = 0;
export let escapeCloses = true;
export let strayCloses = true;
let tail: HTMLDivElement | undefined;
let self: HTMLDivElement | undefined;
let floatingMenuContainer: HTMLDivElement | undefined;
let floatingMenuContent: LayoutCol | undefined;
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
// This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the
// spawner widget to optionally set its min-size to the floating menu's natural width.
let containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
resizeObserverCallback(entries);
});
let wasOpen = open;
let measuringOngoing = false;
let measuringOngoingGuard = false;
let minWidthParentWidth = 0;
let pointerStillDown = false;
let workspaceBounds = new DOMRect();
let floatingMenuBounds = new DOMRect();
let floatingMenuContentBounds = new DOMRect();
$: watchOpenChange(open);
$: minWidthStyleValue = measuringOngoing ? "0" : `${Math.max(minWidth, minWidthParentWidth)}px`;
$: displayTail = open && type === "Popover";
$: displayContainer = open || measuringOngoing;
$: extraClasses = Object.entries(classes)
.flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" ");
$: extraStyles = Object.entries(styles)
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
.join(" ");
// 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")) {
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" : "";
}
}
// Switching from closed to open
if (isOpen && !wasOpen) {
// TODO: Close any other floating menus that may already be open, which can happen using tab navigation and Enter/Space Bar to open
// Close floating menu if pointer strays far enough away
window.addEventListener("pointermove", pointerMoveHandler);
// Close floating menu if esc is pressed
window.addEventListener("keydown", keyDownHandler);
// Close floating menu if pointer is outside (but within stray distance)
window.addEventListener("pointerdown", pointerDownHandler);
// 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();
// Start a new observation of the now-open floating menu
if (floatingMenuContainer) {
containerResizeObserver.disconnect();
containerResizeObserver.observe(floatingMenuContainer);
}
}
// Switching from open to closed
if (!isOpen && wasOpen) {
// Clean up observation of the now-closed floating menu
containerResizeObserver.disconnect();
window.removeEventListener("pointermove", pointerMoveHandler);
window.removeEventListener("keydown", keyDownHandler);
window.removeEventListener("pointerdown", pointerDownHandler);
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
}
// Now that we're done reading the old state, update it to the current state for next time
wasOpen = isOpen;
}
onMount(() => {
// Measure the content and round up its width and height to the nearest even integer.
// This solves antialiasing issues when the content isn't cleanly divisible by 2 and gets translated by (-50%, -50%) causing all its content to be blurry.
const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (type === "Dialog" && floatingMenuContentDiv) {
// TODO: Also use https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver to detect any changes which may affect the size of the content.
// TODO: The current method only notices when the dialog size increases but can't detect when it decreases.
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
let { width, height } = entry.contentRect;
width = Math.ceil(width);
if (width % 2 === 1) width += 1;
height = Math.ceil(height);
if (height % 2 === 1) height += 1;
// We have to set the style properties directly because attempting to do it through a Svelte bound property results in `afterUpdate()` being triggered
floatingMenuContentDiv.style.setProperty("min-width", width === 0 ? "unset" : `${width}px`);
floatingMenuContentDiv.style.setProperty("min-height", height === 0 ? "unset" : `${height}px`);
});
});
resizeObserver.observe(floatingMenuContentDiv);
}
});
afterUpdate(() => {
// Remove the size constraint after the content updates so the resize observer can measure the content and reapply a newly calculated one
const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (type === "Dialog" && floatingMenuContentDiv) {
// We have to set the style properties directly because attempting to do it through a Svelte bound property results in `afterUpdate()` being triggered
floatingMenuContentDiv.style.setProperty("min-width", "unset");
floatingMenuContentDiv.style.setProperty("min-height", "unset");
}
// Gets the client bounds of the elements and apply relevant styles to them.
// TODO: Use DOM attribute bindings more whilst not causing recursive updates. Turning measuring on and off both causes the component to change,
// TODO: which causes the `afterUpdate()` Svelte event to fire extraneous times (hurting performance and sometimes causing an infinite loop).
if (!measuringOngoingGuard) positionAndStyleFloatingMenu();
});
function resizeObserverCallback(entries: ResizeObserverEntry[]) {
minWidthParentWidth = entries[0].contentRect.width;
}
function positionAndStyleFloatingMenu() {
if (type === "Cursor") return;
const workspace = document.querySelector("[data-workspace]");
const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;
const viewportBounds = document.documentElement.getBoundingClientRect();
workspaceBounds = workspace.getBoundingClientRect();
floatingMenuBounds = self.getBoundingClientRect();
const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect();
floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect();
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
const tailOffset = type === "Popover" ? 10 : 0;
if (direction === "Bottom") floatingMenuContentDiv.style.top = `${tailOffset + floatingMenuBounds.y}px`;
if (direction === "Top") floatingMenuContentDiv.style.bottom = `${tailOffset + (viewportBounds.height - floatingMenuBounds.y)}px`;
if (direction === "Right") floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.x}px`;
if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + (viewportBounds.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 = `${viewportBounds.height - floatingMenuBounds.y}px`;
if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.x}px`;
if (tail && direction === "Left") tail.style.right = `${viewportBounds.width - floatingMenuBounds.x}px`;
}
type Edge = "Top" | "Bottom" | "Left" | "Right";
let zeroedBorderVertical: Edge | undefined;
let zeroedBorderHorizontal: Edge | undefined;
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 (floatingMenuContentBounds.left - windowEdgeMargin <= workspaceBounds.left) {
floatingMenuContentDiv.style.left = `${windowEdgeMargin}px`;
if (workspaceBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
}
if (floatingMenuContentBounds.right + windowEdgeMargin >= workspaceBounds.right) {
floatingMenuContentDiv.style.right = `${windowEdgeMargin}px`;
if (workspaceBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right";
}
}
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 (floatingMenuContentBounds.top - windowEdgeMargin <= workspaceBounds.top) {
floatingMenuContentDiv.style.top = `${windowEdgeMargin}px`;
if (workspaceBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
}
if (floatingMenuContentBounds.bottom + windowEdgeMargin >= workspaceBounds.bottom) {
floatingMenuContentDiv.style.bottom = `${windowEdgeMargin}px`;
if (workspaceBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom";
}
}
// Remove the rounded corner from the content where the tail perfectly meets the corner
if (type === "Popover" && 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;
}
}
}
export function div(): HTMLDivElement | undefined {
return self;
}
// To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
export async function measureAndEmitNaturalWidth() {
if (!measuringOngoingGuard) return;
// Wait for the changed content which fired the `afterUpdate()` Svelte event to be put into the DOM
await tick();
// Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate
await document.fonts.ready;
// Make the component show itself with 0 min-width so it can be measured, and wait until the values have been updated to the DOM
measuringOngoing = true;
measuringOngoingGuard = true;
await tick();
// Measure the width of the floating menu content element, if it's currently visible
// The result will be `undefined` if the menu is invisible, perhaps because an ancestor component is hidden with a falsy Svelte template if condition
const naturalWidth: number | undefined = floatingMenuContent?.div?.()?.clientWidth;
// Turn off measuring mode for the component, which triggers another call to the `afterUpdate()` Svelte event, so we can turn off the protection after that has happened
measuringOngoing = false;
await tick();
measuringOngoingGuard = false;
// Notify the parent about the measured natural width
if (naturalWidth !== undefined && naturalWidth >= 0) {
dispatch("naturalWidth", naturalWidth);
}
}
function pointerMoveHandler(e: PointerEvent) {
// This element and the element being hovered over
const target = e.target as HTMLElement | undefined;
// 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;
// HOVER TRANSFER
// Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner
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)
const notHoveringOverOwnSpawner = ownSpawner !== targetSpawner;
if (strayCloses && notHoveringOverOwnSpawner && 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
dispatch("open", false);
}
// Clean up any messes from lost pointerup events
const BUTTONS_LEFT = 0b0000_0001;
const eventIncludesLmb = Boolean(e.buttons & BUTTONS_LEFT);
if (!open && !eventIncludesLmb) {
pointerStillDown = false;
window.removeEventListener("pointerup", pointerUpHandler);
}
}
function hoverTransfer(self: HTMLDivElement | undefined, ownSpawner: HTMLElement | undefined, targetSpawner: HTMLElement | undefined) {
// Algorithm pseudo-code to detect and transfer to hover-transferrable floating menu spawners
// Accompanying diagram: <https://files.keavon.com/-/SpringgreenKnownXantus/capture.png>
//
// 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) {
dispatch("open", false);
(foundTarget as HTMLElement).click();
}
// In either case, we are done searching
break;
}
}
}
function keyDownHandler(e: KeyboardEvent) {
if (escapeCloses && e.key === "Escape") {
setTimeout(() => {
if (!floatingMenuContainer?.querySelector("[data-floating-menu-content][data-escape-does-not-close]")) {
dispatch("open", false);
}
}, 0);
// Find the parent floating menu and prevent it from also closing with the escape key when this floating menu does
if (self) preventEscapeClosingParentFloatingMenu(self);
}
}
function pointerDownHandler(e: PointerEvent) {
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
if (isPointerEventOutsideFloatingMenu(e)) {
dispatch("open", false);
// Track if the left pointer button is now down so its later click event can be canceled
const eventIsForLmb = e.button === BUTTON_LEFT;
if (eventIsForLmb) pointerStillDown = true;
}
}
function pointerUpHandler(e: PointerEvent) {
const eventIsForLmb = e.button === BUTTON_LEFT;
if (pointerStillDown && eventIsForLmb) {
// Clean up self
pointerStillDown = false;
window.removeEventListener("pointerup", pointerUpHandler);
// Prevent the click event from firing, which would normally occur right after this pointerup event
window.addEventListener("click", clickHandlerCapture, true);
}
}
function clickHandlerCapture(e: MouseEvent) {
// Stop the click event from reopening this floating menu if the click event targets the floating menu's button
e.stopPropagation();
// Clean up self
window.removeEventListener("click", clickHandlerCapture, true);
}
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));
}
function isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {
const floatingMenuBounds = element.getBoundingClientRect();
if (floatingMenuBounds.left - e.clientX >= extraDistanceAllowed) return true;
if (e.clientX - floatingMenuBounds.right >= extraDistanceAllowed) return true;
if (floatingMenuBounds.top - e.clientY >= extraDistanceAllowed) return true;
if (e.clientY - floatingMenuBounds.bottom >= extraDistanceAllowed) return true;
return false;
}
</script>
<div
class={`floating-menu ${direction.toLowerCase()} ${type.toLowerCase()} ${className} ${extraClasses}`.trim()}
style={`${styleName} ${extraStyles}`.trim() || undefined}
bind:this={self}
{...$$restProps}
>
{#if displayTail}
<div class="tail" bind:this={tail} />
{/if}
{#if displayContainer}
<div class="floating-menu-container" bind:this={floatingMenuContainer}>
<LayoutCol class="floating-menu-content" styles={{ "min-width": minWidthStyleValue }} {scrollableY} bind:this={floatingMenuContent} data-floating-menu-content>
<slot />
</LayoutCol>
</div>
{/if}
</div>
<style lang="scss" global>
.floating-menu {
position: absolute;
width: 0;
height: 0;
display: flex;
// Floating menus begin at a z-index of 1000
z-index: 1000;
--floating-menu-content-offset: 0;
.tail {
width: 0;
height: 0;
border-style: solid;
// Put the tail above the floating menu's shadow
z-index: 10;
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
position: fixed;
}
.floating-menu-container {
display: flex;
.floating-menu-content {
background: var(--color-2-mildblack);
box-shadow: rgba(var(--color-0-black-rgb), 0.5) 0 2px 4px;
border-radius: 4px;
color: var(--color-e-nearwhite);
font-size: inherit;
padding: 8px;
z-index: 0;
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
position: fixed;
}
}
&.dropdown {
&.top {
width: 100%;
left: 0;
top: 0;
}
&.bottom {
width: 100%;
left: 0;
bottom: 0;
}
&.left {
height: 100%;
top: 0;
left: 0;
}
&.right {
height: 100%;
top: 0;
right: 0;
}
&.topleft {
top: 0;
left: 0;
margin-top: -4px;
}
&.topright {
top: 0;
right: 0;
margin-top: -4px;
}
&.topleft {
bottom: 0;
left: 0;
margin-bottom: -4px;
}
&.topright {
bottom: 0;
right: 0;
margin-bottom: -4px;
}
}
&.top.dropdown .floating-menu-container,
&.bottom.dropdown .floating-menu-container {
justify-content: left;
}
&.popover {
--floating-menu-content-offset: 10px;
}
&.cursor .floating-menu-container .floating-menu-content {
background: none;
box-shadow: none;
border-radius: 0;
padding: 0;
}
&.center {
justify-content: center;
align-items: center;
> .floating-menu-container > .floating-menu-content {
transform: translate(-50%, -50%);
}
}
&.top,
&.bottom {
flex-direction: column;
}
&.top .tail {
border-width: 8px 6px 0 6px;
border-color: var(--color-2-mildblack) transparent transparent transparent;
margin-left: -6px;
margin-bottom: 2px;
}
&.bottom .tail {
border-width: 0 6px 8px 6px;
border-color: transparent transparent var(--color-2-mildblack) transparent;
margin-left: -6px;
margin-top: 2px;
}
&.left .tail {
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent var(--color-2-mildblack);
margin-top: -6px;
margin-right: 2px;
}
&.right .tail {
border-width: 6px 8px 6px 0;
border-color: transparent var(--color-2-mildblack) transparent transparent;
margin-top: -6px;
margin-left: 2px;
}
&.top .floating-menu-container {
justify-content: center;
margin-bottom: var(--floating-menu-content-offset);
}
&.bottom .floating-menu-container {
justify-content: center;
margin-top: var(--floating-menu-content-offset);
}
&.left .floating-menu-container {
align-items: center;
margin-right: var(--floating-menu-content-offset);
}
&.right .floating-menu-container {
align-items: center;
margin-left: var(--floating-menu-content-offset);
}
}
</style>