✨(react) add Tooltip component
This component will allow to provide contextual information on any DOM node. Closes #239
This commit is contained in:
@@ -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"
|
||||
|
||||
91
packages/react/src/components/Tooltip/_index.scss
Normal file
91
packages/react/src/components/Tooltip/_index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
58
packages/react/src/components/Tooltip/index.mdx
Normal file
58
packages/react/src/components/Tooltip/index.mdx
Normal 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 |
|
||||
149
packages/react/src/components/Tooltip/index.spec.tsx
Normal file
149
packages/react/src/components/Tooltip/index.spec.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/react/src/components/Tooltip/index.stories.tsx
Normal file
147
packages/react/src/components/Tooltip/index.stories.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
109
packages/react/src/components/Tooltip/index.tsx
Normal file
109
packages/react/src/components/Tooltip/index.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
12
packages/react/src/components/Tooltip/tokens.ts
Normal file
12
packages/react/src/components/Tooltip/tokens.ts
Normal 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",
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user