(react) add Tooltip component

This component will allow to provide contextual information on any
DOM node.

Closes #239
This commit is contained in:
Nathan Vasse
2024-03-07 16:35:14 +01:00
committed by NathanVss
parent b4a6367bce
commit 0ef7684b12
15 changed files with 683 additions and 4 deletions

View File

@@ -56,7 +56,9 @@
"downshift": "8.4.0",
"react": "18.2.0",
"react-aria": "3.32.1",
"react-dom": "18.2.0"
"react-aria-components": "1.1.1",
"react-dom": "18.2.0",
"react-stately": "3.30.1"
},
"engines": {
"node": ">=18.0.0"

View File

@@ -0,0 +1,91 @@
.c__tooltip {
border-radius: var(--c--components--tooltip--border-radius);
background: var(--c--components--tooltip--background-color);
color: var(--c--components--tooltip--color);
font-size: var(--c--components--tooltip--font-size);
forced-color-adjust: none;
outline: none;
padding: var(--c--components--tooltip--padding);
max-width: var(--c--components--tooltip--max-width);
display: flex;
/* fixes FF gap */
transform: translate3d(0, 0, 0);
--animation-duration: 200ms;
&[data-placement=top] {
margin-bottom: 8px;
--origin: translateY(4px);
.react-aria-OverlayArrow {
left: 50%;
}
}
&[data-placement=bottom] {
margin-top: 8px;
--origin: translateY(-4px);
.react-aria-OverlayArrow {
left: 50%;
svg {
transform: rotate(180deg);
}
}
}
&[data-placement=right] {
margin-left: 8px;
--origin: translateX(-4px);
.react-aria-OverlayArrow {
top: 50%;
svg {
transform: rotate(90deg);
}
}
}
&[data-placement=left] {
margin-right: 8px;
--origin: translateX(4px);
.react-aria-OverlayArrow {
top: 50%;
svg {
transform: rotate(-90deg);
}
}
}
& .react-aria-OverlayArrow svg {
display: block;
fill: var(--c--components--tooltip--background-color);
}
&--entering {
animation: slide var(--animation-duration);
}
&--exiting {
animation: slide var(--animation-duration) reverse forwards;
}
&__content {
overflow: scroll;
}
}
@keyframes slide {
from {
transform: var(--origin);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,58 @@
import { Canvas, Meta, Story, Source, ArgTypes } from '@storybook/blocks';
import * as Stories from './index.stories';
import { Tooltip } from './index';
<Meta of={Stories}/>
# Tooltip
Cunningham provides a tooltip component for displaying any kind of additional information.
<Canvas sourceState="shown" of={Stories.Default}/>
## Content
The content of the tooltip can either be a string or any React Element.
### Plain text
<Canvas sourceState="shown" of={Stories.OverflowingText}/>
### HTML
<Canvas sourceState="shown" of={Stories.WithHtml}/>
## Trigger element
As you can see in the examples above, the tooltip can be triggered by any kind of element which
must be passed as children to the `Tooltip` component.
Here is a more complex example with a more advanced component
<Canvas sourceState="hidden" of={Stories.WithElements}/>
## Placement
The tooltip can be placed in different positions relative to the trigger element using `placement` props. The available positions are: `top`, `right`, `bottom`, `left`.
<Canvas sourceState="hidden" of={Stories.Placements}/>
## Props
These are the props of `Tooltip`.
<ArgTypes of={Tooltip} />
## Design tokens
Here a the custom design tokens defined by the Tooltip.
| Token | Description |
|--------------- |----------------------------- |
| border-radius | Border radius of the tooltip |
| background-color | Background color of the tooltip |
| color | Text color of the tooltip |
| font-size | Font size of the tooltip content |
| padding | Padding of the tooltip content |
| max-width | Max width of the tooltip |

View File

@@ -0,0 +1,149 @@
import {
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from "@testing-library/react";
import React from "react";
import userEvent from "@testing-library/user-event";
import { fireEvent } from "@testing-library/dom";
import { Tooltip } from ":/components/Tooltip/index";
import { Button } from ":/components/Button";
describe("<Tooltip />", () => {
it("appear on button hover and then disappear", async () => {
render(
<Tooltip content="Hi there" closeDelay={0}>
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>,
);
const button = screen.getByRole("button");
expect(screen.queryByText("Hi there")).not.toBeInTheDocument();
const user = userEvent.setup();
fireEvent.mouseMove(document.body);
await user.hover(button);
expect(await screen.findByText("Hi there")).toBeInTheDocument();
await user.unhover(button);
await waitForElementToBeRemoved(screen.queryByText("Hi there"));
});
it("appear on button focus and then disappear", async () => {
render(
<Tooltip content="Hi there" closeDelay={0}>
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>,
);
expect(screen.queryByText("Hi there")).not.toBeInTheDocument();
const user = userEvent.setup();
await user.tab();
expect(await screen.findByText("Hi there")).toBeInTheDocument();
await user.tab();
await waitForElementToBeRemoved(screen.queryByText("Hi there"));
});
it("sets entering and exiting class", async () => {
render(
<Tooltip content="Hi there" closeDelay={0}>
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>,
);
expect(screen.queryByText("Hi there")).not.toBeInTheDocument();
const user = userEvent.setup();
const button = screen.getByRole("button");
fireEvent.mouseMove(document.body);
await user.hover(button);
// Make sure the tooltip is entering.
await waitFor(() => {
const tooltip = document.querySelector(".c__tooltip");
expect(tooltip).toHaveClass("c__tooltip--entering");
expect(tooltip).not.toHaveClass("c__tooltip--exiting");
});
// Make sure the entering class is removed and the exiting class is not added yet.
await waitFor(() => {
const tooltip = document.querySelector(".c__tooltip");
expect(tooltip).not.toHaveClass("c__tooltip--entering");
expect(tooltip).not.toHaveClass("c__tooltip--exiting");
});
await user.unhover(button);
// Make sure the tooltip is exiting.
await waitFor(() => {
const tooltip = document.querySelector(".c__tooltip");
expect(tooltip).not.toHaveClass("c__tooltip--entering");
expect(tooltip).toHaveClass("c__tooltip--exiting");
});
// Make sure the tooltip is removed.
await waitForElementToBeRemoved(document.querySelector(".c__tooltip"));
});
it("works with HTML", async () => {
render(
<Tooltip
content={
<>
<h1>Title</h1>
<p>Description</p>
</>
}
closeDelay={0}
>
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>,
);
const button = screen.getByRole("button");
expect(screen.queryByText("Hi there")).not.toBeInTheDocument();
const user = userEvent.setup();
fireEvent.mouseMove(document.body);
await user.hover(button);
await screen.findByRole("heading", { name: "Title" });
await screen.findByText("Description");
});
it("renders with className", async () => {
render(
<Tooltip
content={
<>
<h1>Title</h1>
<p>Description</p>
</>
}
closeDelay={0}
className="my-custom-class"
>
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>,
);
const button = screen.getByRole("button");
expect(screen.queryByText("Hi there")).not.toBeInTheDocument();
const user = userEvent.setup();
fireEvent.mouseMove(document.body);
await user.hover(button);
await waitFor(() => {
const tooltip = document.querySelector(".c__tooltip");
expect(tooltip).toHaveClass("my-custom-class");
});
});
});

View File

@@ -0,0 +1,147 @@
import React from "react";
import { Meta } from "@storybook/react";
import { Tooltip } from ":/components/Tooltip";
import { Button } from ":/components/Button";
export default {
title: "Components/Tooltip",
component: Tooltip,
decorators: [
(Story) => {
return (
<div style={{ padding: "8rem", position: "relative" }}>
<div style={{ display: "flex", alignItems: "center", gap: "4rem" }}>
<Story />
<div> Hover it</div>
</div>
</div>
);
},
],
} as Meta<typeof Tooltip>;
export const Default = {
args: {
children: (
<Button
size="small"
icon={<span className="material-icons">info</span>}
color="tertiary-text"
/>
),
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
};
export const Placements = {
render: () => {
return (
<div>
<Tooltip placement="left" content="Hi there">
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>
<Tooltip placement="bottom" content="Hi there">
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>
<Tooltip placement="top" content="Hi there">
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>
<Tooltip placement="right" content="Hi there">
<Button size="nano" color="tertiary-text">
</Button>
</Tooltip>
</div>
);
},
};
const lorem =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sit amet quam sed nunc commodo consequat. Vestibulum cursus venenatis massa et tempor.";
export const OverflowingText = {
args: {
children: (
<p
style={{
width: "100px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{lorem}
</p>
),
content: lorem,
},
};
export const WithElements = {
args: {
content: lorem,
placement: "bottom",
children: (
<div
style={{
display: "inline-flex",
}}
>
<div
style={{
borderRadius: "8px",
backgroundColor: "var(--c--theme--colors--greyscale-300)",
padding: "1rem",
display: "flex",
gap: "1rem",
}}
>
<div
style={{
width: "32px",
height: "32px",
borderRadius: "100%",
backgroundColor: "var(--c--theme--colors--greyscale-600)",
}}
/>
<div
style={{
width: "150px",
height: "32px",
borderRadius: "4px",
backgroundColor: "var(--c--theme--colors--greyscale-400)",
}}
/>
</div>
</div>
),
},
};
export const WithHtml = {
args: {
children: (
<Button
size="small"
icon={<span className="material-icons">info</span>}
color="tertiary-text"
/>
),
placement: "right",
content: (
<div>
<h3>Heading</h3>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</div>
),
},
};

View File

@@ -0,0 +1,109 @@
import React, { PropsWithChildren, ReactElement, ReactNode } from "react";
import { OverlayArrow } from "react-aria-components";
import {
mergeProps,
useOverlayPosition,
useTooltip,
useTooltipTrigger,
} from "react-aria";
import { useTooltipTriggerState } from "react-stately";
import classNames from "classnames";
const ANIMATION_DURATION = 200;
export interface TooltipProps extends PropsWithChildren {
placement?: "top" | "bottom" | "left" | "right";
content: ReactNode;
closeDelay?: number;
className?: string;
}
export const Tooltip = ({
placement = "bottom",
content,
closeDelay = 150,
className,
...props
}: TooltipProps) => {
const ref = React.useRef(null);
const overlayRef = React.useRef(null);
const [isExiting, setIsExiting] = React.useState(false);
const [isEntering, setIsEntering] = React.useState(false);
const state = useTooltipTriggerState({
delay: 0,
closeDelay,
onOpenChange: (isOpen) => {
if (isOpen) {
setIsEntering(true);
setTimeout(() => {
setIsEntering(false);
}, ANIMATION_DURATION);
} else {
setIsExiting(true);
setTimeout(() => {
setIsExiting(false);
}, ANIMATION_DURATION);
}
},
});
// Get props for the trigger and its tooltip
const useTooltipTriggerRes = useTooltipTrigger({}, state, ref);
// overlayPosition.placement can be difference than placement, based on available screen space.
const overlayPosition = useOverlayPosition({
targetRef: ref,
overlayRef,
placement,
isOpen: state.isOpen,
});
const { tooltipProps: tooltipProps2 } = useTooltip(
useTooltipTriggerRes.tooltipProps,
state,
);
const arrowProps = {
placement: overlayPosition.placement,
};
const showTooltip = state.isOpen || isExiting;
return (
<>
{React.cloneElement(
React.Children.toArray(props.children)[0] as ReactElement,
{
ref,
...useTooltipTriggerRes.triggerProps,
},
)}
{showTooltip && (
<span
className={classNames(
"c__tooltip",
{
"c__tooltip--exiting": isExiting,
"c__tooltip--entering": isEntering,
},
className,
)}
data-placement={overlayPosition.placement}
ref={overlayRef}
style={{
...overlayPosition.overlayProps.style,
}}
{...mergeProps(props, tooltipProps2)}
>
<OverlayArrow {...arrowProps}>
<svg width={16} height={16} viewBox="0 0 16 16">
<path d="M0 0 L8 8 L16 0" />
</svg>
</OverlayArrow>
<span className="c__tooltip__content">{content}</span>
</span>
)}
</>
);
};

View File

@@ -0,0 +1,12 @@
import { DefaultTokens } from "@openfun/cunningham-tokens";
export const tokens = (defaults: DefaultTokens) => {
return {
"border-radius": "0.5rem",
"background-color": defaults.theme.colors["greyscale-900"],
color: defaults.theme.colors["greyscale-000"],
"font-size": defaults.theme.font.sizes.s,
padding: "1rem",
"max-width": "150px",
};
};

View File

@@ -112,6 +112,12 @@
--c--theme--breakpoints--lg: 992px;
--c--theme--breakpoints--xl: 1200px;
--c--theme--breakpoints--xxl: 1400px;
--c--components--tooltip--border-radius: 0.5rem;
--c--components--tooltip--background-color: var(--c--theme--colors--greyscale-900);
--c--components--tooltip--color: var(--c--theme--colors--greyscale-000);
--c--components--tooltip--font-size: var(--c--theme--font--sizes--s);
--c--components--tooltip--padding: 1rem;
--c--components--tooltip--max-width: 150px;
--c--components--toast--slide-in-duration: 1000ms;
--c--components--toast--slide-out-duration: 300ms;
--c--components--toast--background-color: var(--c--theme--colors--greyscale-100);

File diff suppressed because one or more lines are too long

View File

@@ -134,6 +134,14 @@ $themes: (
)
),
'components': (
'tooltip': (
'border-radius': 0.5rem,
'background-color': #0C1A2B,
'color': #FFFFFF,
'font-size': 0.6875rem,
'padding': 1rem,
'max-width': 150px
),
'toast': (
'slide-in-duration': 1000ms,
'slide-out-duration': 300ms,

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,7 @@
@use "./components/Pagination";
@use "./components/Popover";
@use "./components/Toast";
@use "./components/Tooltip";
body {
font-family: var(--c--theme--font--families--base);

View File

@@ -27,6 +27,7 @@ export * from "./components/Popover";
export * from "./components/Provider";
export * from "./components/Toast";
export * from "./components/Toast/ToastProvider";
export * from "./components/Tooltip";
export * from "./utils/VariantUtils";
export type DefaultTokens = PartialNested<typeof tokens.themes.default>;