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 all commits
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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

### Removed

### Fixed
## [23.0.0] - 2023-10-30

### Dependency updates
### Removed

- `Tooltip`: removed `horizontal` and `vertical` positions from the `tooltipPosition` options. Tooltips will still render to the opposite side in case there is not enough space on the chosen position. ([@lowiebenoot](https://github.com/lowiebenoot)) in [#2796](https://github.com/teamleadercrm/ui/pull/2796)`

## [22.3.5] - 2023-10-18

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@teamleader/ui",
"description": "Teamleader UI library",
"version": "22.3.5",
"version": "23.0.0",
"author": "Teamleader <development@teamleader.eu>",
"bugs": {
"url": "https://github.com/teamleadercrm/ui/issues"
Expand Down Expand Up @@ -30,6 +30,7 @@
"types": "./dist/types/index.d.ts",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@teamleader/ui-animations": "^0.0.3",
"@teamleader/ui-colors": "^2.0.0",
"@teamleader/ui-icons": "^2.1.0",
Expand Down
199 changes: 53 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 @@ -87,76 +79,11 @@ const TooltippedComponent: GenericComponent<TooltippedComponentProps> = ({
ComposedComponent,
...other
}) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
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 +104,23 @@ 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();
const handleOpenChange: RadixTooltip.TooltipProps['onOpenChange'] = (open) => {
if (open && onTooltipEntered) {
onTooltipEntered();
}
};

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

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

let childProps = {
Expand All @@ -231,47 +155,38 @@ 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>
);
}}
</Transition>,
tooltipRoot,
),
// 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={handleOpenChange}>
<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 +195,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;
2 changes: 1 addition & 1 deletion src/components/tooltip/__tests__/Tooltip.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ describe('Component - Tooltip', () => {

const screen = render(<TooltippedDiv tooltip="This is the tooltip">Hover me</TooltippedDiv>);
await user.hover(screen.getByText('Hover me'));
expect(screen.getByText('This is the tooltip')).toBeVisible();
expect(screen.getAllByText('This is the tooltip')[0]).toBeVisible();
});
});
Loading