| <script lang="ts" context="module"> |
| export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor"; |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| |
| 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" : ""; |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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()` |
| } |
| |
| |
| 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; |
| |
| |
| 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"); |
| } |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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; |
| }; |
| |
| |
| const ownDescendantMenuSpawners = Array.from(self?.parentElement?.querySelectorAll("[data-floating-menu-spawner]") || []); |
| |
| |
| 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 (filteredListOfDescendantSpawners.length === 0) { |
| currentAncestor = currentAncestor?.parentElement || undefined; |
| } |
| |
| 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(); |
| } |
| |
| |
| 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); |
| |
| |
| 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> |
| |