From b7c8545a70a1ed6d47528e67695081bd5733003f Mon Sep 17 00:00:00 2001 From: Daniel Karski Date: Thu, 3 Oct 2024 11:00:07 +0200 Subject: [PATCH] [CP-3146] Implemented Tooltip Flipping to Maintain Viewport Visibility (#2104) --- .../models/src/lib/feature/extra-config.ts | 9 +- .../interactive/tooltip/tooltip-helpers.tsx | 29 ++++++ .../src/lib/interactive/tooltip/tooltip.tsx | 96 +++++++++++++++---- 3 files changed, 111 insertions(+), 23 deletions(-) create mode 100644 libs/generic-view/ui/src/lib/interactive/tooltip/tooltip-helpers.tsx diff --git a/libs/device/models/src/lib/feature/extra-config.ts b/libs/device/models/src/lib/feature/extra-config.ts index eb15cef410..0b601efe0b 100644 --- a/libs/device/models/src/lib/feature/extra-config.ts +++ b/libs/device/models/src/lib/feature/extra-config.ts @@ -5,9 +5,14 @@ import { z } from "zod" -const TooltipPlacementEnum = z.enum(["bottom-right", "bottom-left"]); +const TooltipPlacementEnum = z.enum([ + "bottom-right", + "bottom-left", + "top-right", + "top-left", +]) -export type TooltipPlacement = z.infer; +export type TooltipPlacement = z.infer const tooltipSchema = z.object({ contentText: z.string().optional(), diff --git a/libs/generic-view/ui/src/lib/interactive/tooltip/tooltip-helpers.tsx b/libs/generic-view/ui/src/lib/interactive/tooltip/tooltip-helpers.tsx new file mode 100644 index 0000000000..1fcdf5cf3a --- /dev/null +++ b/libs/generic-view/ui/src/lib/interactive/tooltip/tooltip-helpers.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { TooltipPlacement } from "device/models" + +export const flipVertical = (placement: TooltipPlacement): TooltipPlacement => { + return placement.startsWith("bottom") + ? (`top-${placement.split("-")[1]}` as TooltipPlacement) + : (`bottom-${placement.split("-")[1]}` as TooltipPlacement) +} + +export const flipHorizontal = ( + placement: TooltipPlacement +): TooltipPlacement => { + return placement.endsWith("right") + ? (`-${placement.replace("right", "left")}` as TooltipPlacement) + : (`-${placement.replace("left", "right")}` as TooltipPlacement) +} + +export const flipTooltipPlacement = ( + placement: TooltipPlacement +): TooltipPlacement => { + const [vertical, horizontal] = placement.split("-") + const flippedVertical = vertical === "bottom" ? "top" : "bottom" + const flippedHorizontal = horizontal === "right" ? "left" : "right" + return `${flippedVertical}-${flippedHorizontal}` as TooltipPlacement +} diff --git a/libs/generic-view/ui/src/lib/interactive/tooltip/tooltip.tsx b/libs/generic-view/ui/src/lib/interactive/tooltip/tooltip.tsx index 5b3688d206..10d9e88e6a 100644 --- a/libs/generic-view/ui/src/lib/interactive/tooltip/tooltip.tsx +++ b/libs/generic-view/ui/src/lib/interactive/tooltip/tooltip.tsx @@ -15,6 +15,16 @@ import React, { import styled, { css } from "styled-components" import { BaseGenericComponent } from "generic-view/utils" import { TooltipPlacement } from "device/models" +import { + flipHorizontal, + flipTooltipPlacement, + flipVertical, +} from "./tooltip-helpers" + +interface Position { + left: number + top: number +} export const Tooltip: BaseGenericComponent< undefined, @@ -24,33 +34,75 @@ export const Tooltip: BaseGenericComponent< Anchor: typeof TooltipAnchor Content: typeof TooltipContent } = ({ children, placement = "bottom-right" }) => { - const [anchorPosition, setAnchorPosition] = useState<{ - top?: number - left?: number - }>({}) + const [contentPosition, setContentPosition] = useState>({}) const contentRef = useRef(null) const handleAnchorHover = useCallback( (event: MouseEvent) => { const anchorRect = event.currentTarget.getBoundingClientRect() - const contentReact = contentRef.current?.getBoundingClientRect() - - if (contentReact === undefined) { - return + const contentRect = contentRef.current?.getBoundingClientRect() + + if (!contentRect) return + + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + + const placements: TooltipPlacement[] = [ + placement, + flipVertical(placement), + flipHorizontal(placement), + flipTooltipPlacement(placement), + ] + + const calculatePosition = (placment: TooltipPlacement): Position => { + let top = 0 + let left = 0 + + switch (placment) { + case "bottom-right": + top = anchorRect.top + anchorRect.height + left = anchorRect.left + break + case "bottom-left": + top = anchorRect.top + anchorRect.height + left = anchorRect.left - contentRect.width + anchorRect.width + break + case "top-right": + top = anchorRect.top - contentRect.height - anchorRect.height + left = anchorRect.left + break + case "top-left": + top = anchorRect.top - contentRect.height - anchorRect.height + left = anchorRect.left - contentRect.width + anchorRect.width + break + default: + top = anchorRect.top + anchorRect.height + left = anchorRect.left + } + + return { top, left } } - if (placement === "bottom-right") { - const top = anchorRect.top + anchorRect.height - const left = anchorRect.left - - setAnchorPosition({ left, top }) - } else if (placement === "bottom-left") { - const top = anchorRect.top + anchorRect.height - const left = anchorRect.left - contentReact.width + anchorRect.width + const isWithinViewport = (position: Position): boolean => { + return ( + position.left >= 0 && + position.top >= 0 && + position.left + contentRect.width <= viewportWidth && + position.top + contentRect.height <= viewportHeight + ) + } - setAnchorPosition({ left, top }) + for (const placement of placements) { + const position = calculatePosition(placement) + if (isWithinViewport(position)) { + setContentPosition(position) + return + } } + + const position = calculatePosition(placement) + setContentPosition(position) }, [placement] ) @@ -82,13 +134,13 @@ export const Tooltip: BaseGenericComponent< } return React.cloneElement(child as ReactElement, { ...child.props, - $top: anchorPosition.top, - $left: anchorPosition.left, + $top: contentPosition.top, + $left: contentPosition.left, ref: contentRef, $placement: placement, }) }) - }, [children, anchorPosition.top, anchorPosition.left, placement]) + }, [children, contentPosition.top, contentPosition.left, placement]) return ( @@ -171,7 +223,9 @@ const Content = styled.div<{ width: 100%; color: ${theme.color.black}; white-space: pre-wrap; - text-align: ${$placement === "bottom-left" ? "right" : "left"}; + text-align: ${$placement === "bottom-left" || $placement === "top-left" + ? "right" + : "left"}; } `} `