Skip to content

Commit

Permalink
add vuu tooltip component (finos#885)
Browse files Browse the repository at this point in the history
  • Loading branch information
heswell authored and pling-scottlogic committed Oct 2, 2023
1 parent 9fa31e7 commit 58085a5
Show file tree
Hide file tree
Showing 12 changed files with 474 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const FilterBuilderMenu = ({ onMenuAction }: FilterBuilderMenuProps) => {
ref={listRef}
onSelect={handleSelect}
style={{ position: "relative" }}
width={100}
>
<ListItem data-action="apply-save" className="vuuMenuButton">
APPLY AND SAVE
Expand Down
1 change: 1 addition & 0 deletions vuu-ui/packages/vuu-popups/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./popup-menu";
export * from "./portal";
export * from "./portal-deprecated";
export * from "./prompt";
export * from "./tooltip";
32 changes: 13 additions & 19 deletions vuu-ui/packages/vuu-popups/src/portal/Portal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<HTMLElement, PortalProps>(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<HTMLElement | null>(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;
});
};
75 changes: 75 additions & 0 deletions vuu-ui/packages/vuu-popups/src/tooltip/Tooltip.css
Original file line number Diff line number Diff line change
@@ -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);
}
48 changes: 48 additions & 0 deletions vuu-ui/packages/vuu-popups/src/tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
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 (
<Portal>
<div
className={classBase}
data-align={placement}
id={id}
style={position}
>
<span
className={`${classBase}-content`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</span>
</div>
</Portal>
);
};
2 changes: 2 additions & 0 deletions vuu-ui/packages/vuu-popups/src/tooltip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./Tooltip";
export * from "./useTooltip";
67 changes: 67 additions & 0 deletions vuu-ui/packages/vuu-popups/src/tooltip/useAnchoredPosition.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
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;
};
104 changes: 104 additions & 0 deletions vuu-ui/packages/vuu-popups/src/tooltip/useTooltip.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(null);
const mouseEnterTimerRef = useRef<number | undefined>();
const mouseLeaveTimerRef = useRef<number | undefined>();
const [tooltipProps, setTooltipProps] = useState<TooltipProps | undefined>();

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,
};
};
Loading

0 comments on commit 58085a5

Please sign in to comment.