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

Commit

Permalink
Merge pull request #2796 from teamleadercrm/radix-tooltip
Browse files Browse the repository at this point in the history
`Tooltip`: implement with Radix UI
  • Loading branch information
lowiebenoot authored Oct 31, 2023
2 parents ca285ac + a038c63 commit e577e91
Show file tree
Hide file tree
Showing 7 changed files with 342 additions and 316 deletions.
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 <[email protected]>",
"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

0 comments on commit e577e91

Please sign in to comment.