Skip to content
This repository has been archived by the owner on Feb 1, 2024. It is now read-only.

Tooltip: implement with Radix UI #2796

Merged
merged 8 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 54 additions & 146 deletions src/components/tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,23 @@
import uiUtilities from '@teamleader/ui-utilities';
import cx from 'classnames';
import * as RadixTooltip from '@radix-ui/react-tooltip';
import omit from 'lodash.omit';
import React, { MouseEventHandler, ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import Transition from 'react-transition-group/Transition';
import React, { MouseEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
import { GenericComponent } from '../../@types/types';
import { COLORS, SIZES } from '../../constants';
import Box from '../box';
import { BoxProps } from '../box/Box';
import DocumentObjectProvider, { Context as DocumentObjectContext } from '../hoc/DocumentObjectProvider';
import { getViewport } from '../utils/utils';
import theme from './theme.css';

type Position = 'bottom' | 'horizontal' | 'left' | 'right' | 'top' | 'vertical';
type Position = 'bottom' | 'left' | 'right' | 'top';

export const POSITIONS: Record<string, Position> = {
BOTTOM: 'bottom',
HORIZONTAL: 'horizontal',
LEFT: 'left',
RIGHT: 'right',
TOP: 'top',
VERTICAL: 'vertical',
};

interface PositionState {
position: Position;
top: number | string;
left: number | string;
}
export type AllowedColor = Exclude<(typeof COLORS)[number], 'teal'> | 'white' | 'inverse';
export type AllowedSize = Exclude<(typeof SIZES)[number], 'tiny' | 'fullscreen' | 'smallest' | 'hero'>;
const SIZE_MAP: Record<AllowedSize, BoxProps> = {
Expand Down Expand Up @@ -57,19 +47,21 @@ interface TooltippedComponentProps {
tooltipPosition?: Position;
tooltipShowOnClick?: boolean;
tooltipSize?: AllowedSize;
documentObject: Document;
tooltipShowDelay?: number;
/** The z-index of the Tooltip */
zIndex?: number;
tooltipActive?: boolean;
ComposedComponent: React.ElementType;
}
export interface TooltipProps extends Omit<TooltippedComponentProps, 'ComposedComponent' | 'documentObject'> {}
export interface TooltipProps extends Omit<TooltippedComponentProps, 'ComposedComponent'> {}

const Arrow = () => {
return <div className={theme['arrow']} />;
};

const TooltippedComponent: GenericComponent<TooltippedComponentProps> = ({
children,
className,
documentObject,
tooltip,
tooltipColor = 'white',
onTooltipEntered,
Expand All @@ -88,75 +80,11 @@ const TooltippedComponent: GenericComponent<TooltippedComponentProps> = ({
...other
}) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
lowiebenoot marked this conversation as resolved.
Show resolved Hide resolved
const tooltipRoot = useMemo(() => documentObject.createElement('div'), []);
const ref = useRef(null);
const [active, setActive] = useState(false);
const [position, setPosition] = useState<PositionState>({ position: tooltipPosition, top: 'auto', left: 'auto' });

const activate = (position: PositionState) => {
documentObject.body.appendChild(tooltipRoot);
setActive(true);
setPosition({ position: position.position, top: position.top, left: position.left });
};

const getPosition = (element: Element) => {
if (tooltipPosition === POSITIONS.HORIZONTAL) {
const origin = element.getBoundingClientRect();
const { width: windowWidth } = getViewport();
const toRight = origin.left < windowWidth / 2 - origin.width / 2;

return toRight ? POSITIONS.RIGHT : POSITIONS.LEFT;
} else if (tooltipPosition === POSITIONS.VERTICAL) {
const origin = element.getBoundingClientRect();
const { height: windowHeight } = getViewport();
const toBottom = origin.top < windowHeight / 2 - origin.height / 2;

return toBottom ? POSITIONS.BOTTOM : POSITIONS.TOP;
}

return tooltipPosition;
};

const calculatePosition = (element: Element | null) => {
if (typeof element?.getBoundingClientRect !== 'function') {
return { top: 0, left: 0, position: tooltipPosition };
}

const { top, left, height, width } = element.getBoundingClientRect();
const position = getPosition(element);
const xOffset = window.scrollX || window.pageXOffset;
const yOffset = window.scrollY || window.pageYOffset;

if (position === POSITIONS.BOTTOM) {
return {
top: top + height + yOffset,
left: left + width / 2 + xOffset,
position,
};
} else if (position === POSITIONS.TOP) {
return {
top: top + yOffset,
left: left + width / 2 + xOffset,
position,
};
} else if (position === POSITIONS.LEFT) {
return {
top: top + height / 2 + yOffset,
left: left + xOffset,
position,
};
} else if (position === POSITIONS.RIGHT) {
return {
top: top + height / 2 + yOffset,
left: left + width + xOffset,
position,
};
}
return { top: 0, left: 0, position: tooltipPosition };
};

const handleMouseEnter: MouseEventHandler = (event) => {
activate(calculatePosition(event.currentTarget));
setActive(true);

if (onMouseEnter) {
onMouseEnter(event);
Expand All @@ -177,25 +105,17 @@ const TooltippedComponent: GenericComponent<TooltippedComponentProps> = ({
}

if (tooltipShowOnClick && !active) {
activate(calculatePosition(event.currentTarget));
setActive(true);
}

if (onClick) {
onClick(event);
}
};

const handleTransitionExited = () => {
documentObject.body.removeChild(tooltipRoot);
};

const handleTransitionEntered = () => {
onTooltipEntered && onTooltipEntered();
};

useEffect(() => {
if (tooltipActive && !active) {
activate(calculatePosition(ref.current));
setActive(true);
}

if (!tooltipActive && active) {
Expand All @@ -210,7 +130,6 @@ const TooltippedComponent: GenericComponent<TooltippedComponentProps> = ({
'tooltipPosition',
'tooltipShowOnClick',
'tooltipShowDelay',
'documentObject',
]);

let childProps = {
Expand All @@ -231,47 +150,44 @@ const TooltippedComponent: GenericComponent<TooltippedComponentProps> = ({
};
}

return React.createElement(
ComposedComponent,
childProps,
children,
createPortal(
<Transition
in={active}
onExited={handleTransitionExited}
onEntered={handleTransitionEntered}
timeout={{ enter: tooltipShowDelay, exit: 1000 }}
>
{(state) => {
const classNames = cx(
uiUtilities['box-shadow-200'],
theme['tooltip'],
theme[tooltipColor],
theme[tooltipSize],
{
[theme['is-entering']]: state === 'entering',
[theme['is-entered']]: state === 'entered',
[theme['is-exiting']]: state === 'exiting',
[theme[position.position]]: theme[position.position],
},
);

return (
<div
className={classNames}
data-teamleader-ui="tooltip"
style={{ top: position.top, left: position.left, zIndex }}
>
<Box className={theme['inner']} {...SIZE_MAP[tooltipSize]}>
{tooltipIcon && <div className={theme['icon']}>{tooltipIcon}</div>}
<div className={theme['text']}>{tooltip}</div>
</Box>
</div>
);
// Using the radix tooltip component, but we only use it for rendering the tooltip
// we still manually implement the trigger with mouseover/leave/click and keep that in state.
// With a pure radix implementation we couldn't support our `tooltipHideOnClick` prop.
return (
<RadixTooltip.Provider delayDuration={tooltipShowDelay}>
<RadixTooltip.Root
onOpenChange={(open) => {
if (open && onTooltipEntered) {
onTooltipEntered();
}
lowiebenoot marked this conversation as resolved.
Show resolved Hide resolved
}}
</Transition>,
tooltipRoot,
),
>
<RadixTooltip.Trigger asChild>
<ComposedComponent {...childProps}>{children}</ComposedComponent>
</RadixTooltip.Trigger>
<RadixTooltip.Portal forceMount={active || undefined}>
<RadixTooltip.Content
className={cx(
uiUtilities['box-shadow-200'],
theme['tooltip-content'],
theme[tooltipColor],
theme[tooltipSize],
)}
sideOffset={8}
side={tooltipPosition}
style={{ zIndex }}
>
<Box className={theme['inner']} {...SIZE_MAP[tooltipSize]}>
{tooltipIcon && <div className={theme['icon']}>{tooltipIcon}</div>}
<div className={theme['text']}>{tooltip}</div>
</Box>
<RadixTooltip.Arrow asChild>
<Arrow />
</RadixTooltip.Arrow>
</RadixTooltip.Content>
</RadixTooltip.Portal>
</RadixTooltip.Root>
</RadixTooltip.Provider>
);
};

Expand All @@ -280,22 +196,14 @@ function Tooltip<E extends keyof JSX.IntrinsicElements>(
): React.ComponentType<JSX.IntrinsicElements[E] & TooltipProps>;
function Tooltip<P>(ComposedComponent: React.ElementType<P>): React.ComponentType<P & TooltipProps>;
function Tooltip(ComposedComponent: TooltippedComponentProps['ComposedComponent']) {
return DocumentObjectProvider<TooltipProps>((props) => {
const WrappedComponent = (props: TooltipProps) => {
return (
<DocumentObjectContext.Consumer>
{(documentObject) => (
<TooltippedComponent
{...props}
tooltip={props.tooltip}
documentObject={documentObject as Document}
ComposedComponent={ComposedComponent}
>
{props.children}
</TooltippedComponent>
)}
</DocumentObjectContext.Consumer>
<TooltippedComponent {...props} tooltip={props.tooltip} ComposedComponent={ComposedComponent}>
{props.children}
</TooltippedComponent>
);
});
};
return WrappedComponent;
}

export default Tooltip;
Loading