diff --git a/vuu-ui/packages/vuu-filters/src/filter-builder-menu/FilterBuilderMenu.tsx b/vuu-ui/packages/vuu-filters/src/filter-builder-menu/FilterBuilderMenu.tsx index c0443667b..70c31e36e 100644 --- a/vuu-ui/packages/vuu-filters/src/filter-builder-menu/FilterBuilderMenu.tsx +++ b/vuu-ui/packages/vuu-filters/src/filter-builder-menu/FilterBuilderMenu.tsx @@ -46,6 +46,7 @@ export const FilterBuilderMenu = ({ onMenuAction }: FilterBuilderMenuProps) => { ref={listRef} onSelect={handleSelect} style={{ position: "relative" }} + width={100} > APPLY AND SAVE diff --git a/vuu-ui/packages/vuu-popups/src/index.ts b/vuu-ui/packages/vuu-popups/src/index.ts index 7d890adb8..453a5cf8b 100644 --- a/vuu-ui/packages/vuu-popups/src/index.ts +++ b/vuu-ui/packages/vuu-popups/src/index.ts @@ -5,3 +5,4 @@ export * from "./popup-menu"; export * from "./portal"; export * from "./portal-deprecated"; export * from "./prompt"; +export * from "./tooltip"; diff --git a/vuu-ui/packages/vuu-popups/src/portal/Portal.tsx b/vuu-ui/packages/vuu-popups/src/portal/Portal.tsx index c8ea3d4de..dea87e64b 100644 --- a/vuu-ui/packages/vuu-popups/src/portal/Portal.tsx +++ b/vuu-ui/packages/vuu-popups/src/portal/Portal.tsx @@ -1,10 +1,5 @@ -import { - forwardRef, - ReactNode, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useThemeAttributes } from "@finos/vuu-shell"; +import { ReactNode, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import "./Portal.css"; @@ -32,43 +27,42 @@ function getContainer(container: PortalProps["container"]) { return typeof container === "function" ? container() : container; } -const DEFAULT_ID = "portal-root"; +const DEFAULT_ID = "vuu-portal-root"; /** * Portals provide a first-class way to render children into a DOM node * that exists outside the DOM hierarchy of the parent component. */ -export const Portal = forwardRef(function Portal( - { children, container: containerProp = document.body, id = DEFAULT_ID }, - ref -) { +export const Portal = ({ + children, + container: containerProp = document.body, + id = DEFAULT_ID, +}: PortalProps) => { const [mounted, setMounted] = useState(false); const portalRef = useRef(null); - const container = getContainer(containerProp) ?? document.body; + const [themeClass, densityClass, dataMode] = useThemeAttributes(); useLayoutEffect(() => { const root = document.getElementById(id); - if (root) { portalRef.current = root; } else { portalRef.current = document.createElement("div"); portalRef.current.id = id; } - const el = portalRef.current; - if (!container.contains(el)) { container.appendChild(el); } - + el.classList.add(themeClass, densityClass); + el.dataset.mode = dataMode; setMounted(true); - }, [id, container]); + }, [id, container, themeClass, densityClass, dataMode]); if (mounted && portalRef.current && children) { return createPortal(children, portalRef.current); } return null; -}); +}; diff --git a/vuu-ui/packages/vuu-popups/src/tooltip/Tooltip.css b/vuu-ui/packages/vuu-popups/src/tooltip/Tooltip.css new file mode 100644 index 000000000..d085d3c91 --- /dev/null +++ b/vuu-ui/packages/vuu-popups/src/tooltip/Tooltip.css @@ -0,0 +1,75 @@ +.vuuTooltip { + --tooltip-align: flex-start; + --tooltip-justify: flex-start; + --tooltip-top: auto; + --tooltip-right: auto; + --tooltip-bottom: auto; + --tooltip-left: auto; + align-items: var(--tooltip-align); + justify-content: var(--tooltip-justify); + display: flex; + position: absolute; + } + + .vuuTooltip[data-align='right'] { + --tooltip-align: center; + --tooltip-left: 9px; + } + + .vuuTooltip[data-align='left'] { + --tooltip-align: center; + --tooltip-left: auto; + --tooltip-right: 10px; + } + + .vuuTooltip[data-align='above'] { + --tooltip-justify: center; + --tooltip-bottom: 9px; + --tooltip-left: auto; + } + .vuuTooltip[data-align='below'] { + --tooltip-justify: center; + --tooltip-top: 9px; + --tooltip-left: auto; + } + + .vuuTooltip-content { + background-color: var(--salt-color-blue-500); + border-radius: 4px; + color: white; + line-height: 24px; + padding: 2px 6px; + position: absolute; + white-space: nowrap; + top: var(--tooltip-top); + right: var(--tooltip-right); + bottom: var(--tooltip-bottom); + left: var(--tooltip-left); + } + + .vuuTooltip::before { + background-color: var(--salt-color-blue-500); + /* background-color: red; */ + content: ' '; + display: block; + position: absolute; + width: 12px; + height: 12px; + z-index: -1; +} + +.vuuTooltip[data-align='above']::before { + transform: translate(0, -18px) rotate(45deg); +} + +.vuuTooltip[data-align='below']::before { + transform: translate(0, 6px) rotate(45deg); +} + +.vuuTooltip[data-align='right']::before { + transform: translate(7px, 0px) rotate(45deg); +} + +.vuuTooltip[data-align='left']::before { + transform: translate(-19px, 0) rotate(45deg); +} \ No newline at end of file diff --git a/vuu-ui/packages/vuu-popups/src/tooltip/Tooltip.tsx b/vuu-ui/packages/vuu-popups/src/tooltip/Tooltip.tsx new file mode 100644 index 000000000..d52dbfdb6 --- /dev/null +++ b/vuu-ui/packages/vuu-popups/src/tooltip/Tooltip.tsx @@ -0,0 +1,48 @@ +import { MouseEventHandler, ReactNode, RefObject } from "react"; +import { Portal } from "../portal"; +import { TooltipPlacement, useAnchoredPosition } from "./useAnchoredPosition"; + +import "./Tooltip.css"; + +const classBase = "vuuTooltip"; + +export interface TooltipProps { + anchorElement: RefObject; + children: ReactNode; + id?: string; + onMouseEnter: MouseEventHandler; + onMouseLeave: MouseEventHandler; + placement: TooltipPlacement; +} + +export const Tooltip = ({ + anchorElement, + children, + id, + onMouseEnter, + onMouseLeave, + placement, +}: TooltipProps) => { + const position = useAnchoredPosition({ anchorElement, placement }); + if (position === undefined) { + return null; + } + return ( + +
+ + {children} + +
+
+ ); +}; diff --git a/vuu-ui/packages/vuu-popups/src/tooltip/index.ts b/vuu-ui/packages/vuu-popups/src/tooltip/index.ts new file mode 100644 index 000000000..d1f78add0 --- /dev/null +++ b/vuu-ui/packages/vuu-popups/src/tooltip/index.ts @@ -0,0 +1,2 @@ +export * from "./Tooltip"; +export * from "./useTooltip"; diff --git a/vuu-ui/packages/vuu-popups/src/tooltip/useAnchoredPosition.ts b/vuu-ui/packages/vuu-popups/src/tooltip/useAnchoredPosition.ts new file mode 100644 index 000000000..c52045c69 --- /dev/null +++ b/vuu-ui/packages/vuu-popups/src/tooltip/useAnchoredPosition.ts @@ -0,0 +1,67 @@ +// TODO merge with Popup + +import { RefObject, useLayoutEffect, useState } from "react"; + +export type TooltipPlacement = "above" | "right" | "below" | "left"; + +export interface AnchoredPositionHookProps { + anchorElement: RefObject; + offsetLeft?: number; + offsetTop?: number; + placement: TooltipPlacement; +} + +const getPositionRelativeToAnchor = ( + anchorElement: HTMLElement, + placement: TooltipPlacement, + offsetLeft: number, + offsetTop: number +): { left: number; top: number } => { + const { bottom, height, left, right, top, width } = + anchorElement.getBoundingClientRect(); + const midX = left + width / 2; + const midY = top + height / 2; + + switch (placement) { + case "above": + return { left: midX + offsetLeft, top: top + offsetTop }; + case "below": + return { left: midX + offsetLeft, top: bottom + offsetTop }; + case "right": + return { left: right + offsetLeft, top: midY + offsetLeft }; + // case "below-center": + // return { left: left + width / 2 + offsetLeft, top: bottom + offsetTop }; + case "left": + return { left: left + offsetLeft, top: midY + offsetLeft }; + default: + throw Error( + "Tooltip getPositionRelativeToAnchor only supported placement values are left, right, below and right" + ); + } +}; + +export const useAnchoredPosition = ({ + anchorElement, + offsetLeft = 0, + offsetTop = 0, + placement, +}: AnchoredPositionHookProps) => { + const [position, setPosition] = useState< + { left: number; top: number } | undefined + >(); + + // maybe better as useMemo ? + useLayoutEffect(() => { + if (anchorElement.current) { + const position = getPositionRelativeToAnchor( + anchorElement.current, + placement, + offsetLeft, + offsetTop + ); + setPosition(position); + } + }, [anchorElement, offsetLeft, offsetTop, placement]); + + return position; +}; diff --git a/vuu-ui/packages/vuu-popups/src/tooltip/useTooltip.ts b/vuu-ui/packages/vuu-popups/src/tooltip/useTooltip.ts new file mode 100644 index 000000000..5e4eb9617 --- /dev/null +++ b/vuu-ui/packages/vuu-popups/src/tooltip/useTooltip.ts @@ -0,0 +1,104 @@ +import { MouseEvent, ReactNode, useCallback, useRef, useState } from "react"; +import { TooltipProps } from "./Tooltip"; +import { TooltipPlacement } from "./useAnchoredPosition"; +import { useId } from "@finos/vuu-layout"; + +export interface TooltipHookProps { + id: string; + placement?: TooltipPlacement; + tooltipContent: ReactNode; +} + +export const useTooltip = ({ + id: idProp, + placement = "right", + tooltipContent, +}: TooltipHookProps) => { + const hideTooltipRef = useRef<() => void>(); + const anchorElementRef = useRef(null); + const mouseEnterTimerRef = useRef(); + const mouseLeaveTimerRef = useRef(); + const [tooltipProps, setTooltipProps] = useState(); + + const id = useId(idProp); + + const escapeListener = useCallback((evt: KeyboardEvent) => { + if (evt.key === "Escape") { + hideTooltipRef.current?.(); + } + }, []); + + hideTooltipRef.current = useCallback(() => { + setTooltipProps(undefined); + document.removeEventListener("keydown", escapeListener); + }, [escapeListener]); + + const handleMouseEnterTooltip = useCallback(() => { + window.clearTimeout(mouseLeaveTimerRef.current); + }, []); + + const handleMouseLeaveTooltip = useCallback(() => { + hideTooltipRef.current?.(); + }, []); + + const showTooltip = useCallback(() => { + const { current: anchorEl } = anchorElementRef; + if (anchorEl) { + setTooltipProps({ + anchorElement: anchorElementRef, + children: tooltipContent, + id: `${id}-tooltip`, + onMouseEnter: handleMouseEnterTooltip, + onMouseLeave: handleMouseLeaveTooltip, + placement: placement, + }); + // register ESC listener + document.addEventListener("keydown", escapeListener); + } + mouseEnterTimerRef.current = undefined; + }, [ + escapeListener, + handleMouseEnterTooltip, + handleMouseLeaveTooltip, + id, + placement, + tooltipContent, + ]); + + const handleMouseEnter = useCallback( + (evt: MouseEvent) => { + const el = evt.target as HTMLElement; + if (el) { + anchorElementRef.current = el; + mouseEnterTimerRef.current = window.setTimeout(showTooltip, 800); + } + }, + [showTooltip] + ); + + const handleMouseLeave = useCallback(() => { + if (anchorElementRef.current) + if (mouseEnterTimerRef.current) { + window.clearTimeout(mouseEnterTimerRef.current); + mouseEnterTimerRef.current = undefined; + } else { + if (hideTooltipRef.current) { + mouseLeaveTimerRef.current = window.setTimeout( + hideTooltipRef.current, + 200 + ); + } + } + }, []); + + const anchorProps = { + "aria-describedby": `${id}-tooltip`, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }; + + return { + anchorProps, + tooltipProps, + }; +}; diff --git a/vuu-ui/showcase/src/examples/Popups/Tooltip.examples.css b/vuu-ui/showcase/src/examples/Popups/Tooltip.examples.css new file mode 100644 index 000000000..56dd673eb --- /dev/null +++ b/vuu-ui/showcase/src/examples/Popups/Tooltip.examples.css @@ -0,0 +1,32 @@ +.box { + align-items: center; + background: rgba(0,0,0,.05); + display: flex; + height: 200px; + justify-content: center; + left: 200px; + position: absolute; + top: 200px; + width: 200px; +} + +.column { + background-color: rgba(211, 211, 211, 0.5); + position: absolute; + top: 0; + left: 0; + width: 300px; + height: 100vh; + z-index: -10; +} + +.row { + background-color: rgba(211, 211, 211, 0.5); + position: absolute; + top: 0; + left: 0; + width: 100vh; + height: 300px; + z-index: -10; +} + diff --git a/vuu-ui/showcase/src/examples/Popups/index.ts b/vuu-ui/showcase/src/examples/Popups/index.ts index 0bf93c0f6..669e268c0 100644 --- a/vuu-ui/showcase/src/examples/Popups/index.ts +++ b/vuu-ui/showcase/src/examples/Popups/index.ts @@ -1,2 +1,3 @@ export * as ContextMenu from "./ContextMenu.examples"; export * as PopupMenu from "./PopupMenu.examples"; +export * as Tooltip from "./Tooltip.examples"; diff --git a/vuu-ui/showcase/src/examples/popups/Tooltip.examples.tsx b/vuu-ui/showcase/src/examples/popups/Tooltip.examples.tsx new file mode 100644 index 000000000..2d3bee0c6 --- /dev/null +++ b/vuu-ui/showcase/src/examples/popups/Tooltip.examples.tsx @@ -0,0 +1,113 @@ +import { Button, ToggleButton, ToggleButtonGroup } from "@salt-ds/core"; +import { SyntheticEvent, useCallback, useMemo, useRef, useState } from "react"; +import { Tooltip, useTooltip } from "@finos/vuu-popups"; + +import "./Tooltip.examples.css"; + +let displaySequence = 1; + +type TooltipPlacement = "above" | "right" | "below" | "left"; + +export const DefaultTooltip = () => { + const anchorRef = useRef(null); + const [tooltipPlacement, setTooltipPlacement] = + useState("right"); + const [tooltipContent, setTooltipContent] = useState<"child" | "text">( + "text" + ); + + const handleChangePlacement = useCallback( + (evt: SyntheticEvent) => { + const { value } = evt.target as HTMLButtonElement; + setTooltipPlacement(value as TooltipPlacement); + }, + [] + ); + + const handleChangeContent = useCallback( + (evt: SyntheticEvent) => { + const { value } = evt.target as HTMLButtonElement; + setTooltipContent(value as "child" | "text"); + }, + [] + ); + + const tooltipChild = useMemo( + () => ( +
+ Custom Content +
+ ), + [] + ); + + const { anchorProps, tooltipProps } = useTooltip({ + placement: tooltipPlacement, + tooltipContent: + tooltipContent === "text" ? "This is my tooltip" : tooltipChild, + }); + + return ( +
+
+
+ + + ABOVE + RIGHT + BELOW + LEFT + + + + Text content + Chid Component + + +
+ + {tooltipProps ? : null} +
+
+ ); +}; +DefaultTooltip.displaySequence = displaySequence++; diff --git a/vuu-ui/showcase/src/examples/popups/Tooltip.tsx b/vuu-ui/showcase/src/examples/popups/Tooltip.tsx new file mode 100644 index 000000000..bd3b6bbb4 --- /dev/null +++ b/vuu-ui/showcase/src/examples/popups/Tooltip.tsx @@ -0,0 +1,17 @@ +import { HTMLAttributes, ReactNode } from "react"; + +import "./Tooltip.css"; + +// interface TooltipProps extends HTMLAttributes {} +export interface TooltipProps extends HTMLAttributes { + children: ReactNode; +} + +export const Tooltip = ({ children, ...props }: TooltipProps) => { + // TODO check that a tooltip is not already present in page + return ( +
+ {children} +
+ ); +};