(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

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
add Tooltip component

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>;

View File

@@ -3982,6 +3982,17 @@
"@react-types/checkbox" "^3.7.1"
"@swc/helpers" "^0.5.0"
"@react-aria/toolbar@3.0.0-beta.3":
version "3.0.0-beta.3"
resolved "https://registry.yarnpkg.com/@react-aria/toolbar/-/toolbar-3.0.0-beta.3.tgz#7b4a0e1da137cb84a65b38e0a4802984759e3d9f"
integrity sha512-tPIEPRsZI/6Mb0tAW/GBTt3wBk7dfJg/eUnTloY8NHialvDa+cMUQyUVzPyLWGpErhYeBeutBmw1e2seMjmu+A==
dependencies:
"@react-aria/focus" "^3.16.2"
"@react-aria/i18n" "^3.10.2"
"@react-aria/utils" "^3.23.2"
"@react-types/shared" "^3.22.1"
"@swc/helpers" "^0.5.0"
"@react-aria/tooltip@^3.7.2":
version "3.7.2"
resolved "https://registry.yarnpkg.com/@react-aria/tooltip/-/tooltip-3.7.2.tgz#ccbcef4efcb27486cd845a734794d541696e3692"
@@ -4061,6 +4072,14 @@
"@react-types/shared" "^3.22.1"
"@swc/helpers" "^0.5.0"
"@react-stately/data@^3.11.2":
version "3.11.2"
resolved "https://registry.yarnpkg.com/@react-stately/data/-/data-3.11.2.tgz#835c9a90eaeb832dbaac4c96f1aa91008913efb8"
integrity sha512-yhK2upk2WbJeiLBRWHrh/4G2CvmmozCzoivLaRAPYu53m1J3MyzVGCLJgnZMbMZvAbNcYWZK6IzO6VqZ2y1fOw==
dependencies:
"@react-types/shared" "^3.22.1"
"@swc/helpers" "^0.5.0"
"@react-stately/datepicker@3.9.2", "@react-stately/datepicker@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@react-stately/datepicker/-/datepicker-3.9.2.tgz#a160d174c4c5a67b15e70a893071d948f5ad347d"
@@ -4328,6 +4347,13 @@
"@react-types/overlays" "^3.8.5"
"@react-types/shared" "^3.22.1"
"@react-types/form@^3.7.2":
version "3.7.2"
resolved "https://registry.yarnpkg.com/@react-types/form/-/form-3.7.2.tgz#bfa8aef2f6e1ee579ab2f9a1f40556ae24f72d32"
integrity sha512-6/isEJY4PsYoHdMaGQtqQyquXGTwB1FqCBOPKQjI/vBGWG3fL7FGfWm4Z62eTbCH4Xyv3FZuNywlT8UjPMQyKA==
dependencies:
"@react-types/shared" "^3.22.1"
"@react-types/grid@^3.2.4":
version "3.2.4"
resolved "https://registry.yarnpkg.com/@react-types/grid/-/grid-3.2.4.tgz#47b28424409b66b3bfcfcde03c92f03d6d41d1ba"
@@ -7203,6 +7229,11 @@ cli-table3@^0.6.1:
optionalDependencies:
"@colors/colors" "1.5.0"
client-only@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
cliui@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
@@ -13067,7 +13098,32 @@ raw-body@2.5.1:
iconv-lite "0.4.24"
unpipe "1.0.0"
react-aria@3.32.1:
react-aria-components@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-aria-components/-/react-aria-components-1.1.1.tgz#85e47d9321af878f9e7a183c943d7c222ab0c370"
integrity sha512-XdgqSbrlh9V1vJEvTwrnr+YGndQWYcVEAbN+Rx104o9g88cAAabclgetU2OUJ9Gbht6+gwnvnA0ksgXzVZog2Q==
dependencies:
"@internationalized/date" "^3.5.2"
"@internationalized/string" "^3.2.1"
"@react-aria/focus" "^3.16.2"
"@react-aria/interactions" "^3.21.1"
"@react-aria/menu" "^3.13.1"
"@react-aria/toolbar" "3.0.0-beta.3"
"@react-aria/utils" "^3.23.2"
"@react-stately/menu" "^3.6.1"
"@react-stately/table" "^3.11.6"
"@react-stately/utils" "^3.9.1"
"@react-types/form" "^3.7.2"
"@react-types/grid" "^3.2.4"
"@react-types/shared" "^3.22.1"
"@react-types/table" "^3.9.3"
"@swc/helpers" "^0.5.0"
client-only "^0.0.1"
react-aria "^3.32.1"
react-stately "^3.30.1"
use-sync-external-store "^1.2.0"
react-aria@3.32.1, react-aria@^3.32.1:
version "3.32.1"
resolved "https://registry.yarnpkg.com/react-aria/-/react-aria-3.32.1.tgz#e490259969b8cfbcc0fdb9cd3e041b1769b285d4"
integrity sha512-7KCJg4K5vlRqiXdGjgCT05Du8RhGBYC+2ok4GOh/Znmg8aMwOk7t0YwxaT5i1z30+fmDcJS/pk/ipUPUg28CXg==
@@ -13202,6 +13258,35 @@ react-remove-scroll@2.5.5:
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-stately@3.30.1, react-stately@^3.30.1:
version "3.30.1"
resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.30.1.tgz#7d87649c69f1bcf42c68a732f121ff23393f5abb"
integrity sha512-IEhKHMT7wijtczA5vtw/kdq9CZuOIF+ReoSimydTFiABRQxWO9ESAl/fToXOUM9qmCdhdqjGJgMAhqTnmheh8g==
dependencies:
"@react-stately/calendar" "^3.4.4"
"@react-stately/checkbox" "^3.6.3"
"@react-stately/collections" "^3.10.5"
"@react-stately/combobox" "^3.8.2"
"@react-stately/data" "^3.11.2"
"@react-stately/datepicker" "^3.9.2"
"@react-stately/dnd" "^3.2.8"
"@react-stately/form" "^3.0.1"
"@react-stately/list" "^3.10.3"
"@react-stately/menu" "^3.6.1"
"@react-stately/numberfield" "^3.9.1"
"@react-stately/overlays" "^3.6.5"
"@react-stately/radio" "^3.10.2"
"@react-stately/searchfield" "^3.5.1"
"@react-stately/select" "^3.6.2"
"@react-stately/selection" "^3.14.3"
"@react-stately/slider" "^3.5.2"
"@react-stately/table" "^3.11.6"
"@react-stately/tabs" "^3.6.4"
"@react-stately/toggle" "^3.7.2"
"@react-stately/tooltip" "^3.4.7"
"@react-stately/tree" "^3.7.6"
"@react-types/shared" "^3.22.1"
react-style-singleton@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
@@ -15136,6 +15221,11 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"