From 0ef7684b12514b47bde48790fef7e5ce76eb28a4 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 7 Mar 2024 16:35:14 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(react)=20add=20Tooltip=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This component will allow to provide contextual information on any DOM node. Closes #239 --- .changeset/shiny-days-notice.md | 5 + packages/react/package.json | 4 +- .../react/src/components/Tooltip/_index.scss | 91 +++++++++++ .../react/src/components/Tooltip/index.mdx | 58 +++++++ .../src/components/Tooltip/index.spec.tsx | 149 ++++++++++++++++++ .../src/components/Tooltip/index.stories.tsx | 147 +++++++++++++++++ .../react/src/components/Tooltip/index.tsx | 109 +++++++++++++ .../react/src/components/Tooltip/tokens.ts | 12 ++ packages/react/src/cunningham-tokens.css | 6 + packages/react/src/cunningham-tokens.js | 2 +- packages/react/src/cunningham-tokens.scss | 8 + packages/react/src/cunningham-tokens.ts | 2 +- packages/react/src/index.scss | 1 + packages/react/src/index.ts | 1 + yarn.lock | 92 ++++++++++- 15 files changed, 683 insertions(+), 4 deletions(-) create mode 100644 .changeset/shiny-days-notice.md create mode 100644 packages/react/src/components/Tooltip/_index.scss create mode 100644 packages/react/src/components/Tooltip/index.mdx create mode 100644 packages/react/src/components/Tooltip/index.spec.tsx create mode 100644 packages/react/src/components/Tooltip/index.stories.tsx create mode 100644 packages/react/src/components/Tooltip/index.tsx create mode 100644 packages/react/src/components/Tooltip/tokens.ts diff --git a/.changeset/shiny-days-notice.md b/.changeset/shiny-days-notice.md new file mode 100644 index 0000000..d70dfdd --- /dev/null +++ b/.changeset/shiny-days-notice.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add Tooltip component diff --git a/packages/react/package.json b/packages/react/package.json index f21dc50..2e8ede8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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" diff --git a/packages/react/src/components/Tooltip/_index.scss b/packages/react/src/components/Tooltip/_index.scss new file mode 100644 index 0000000..a185bab --- /dev/null +++ b/packages/react/src/components/Tooltip/_index.scss @@ -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; + } +} diff --git a/packages/react/src/components/Tooltip/index.mdx b/packages/react/src/components/Tooltip/index.mdx new file mode 100644 index 0000000..6229924 --- /dev/null +++ b/packages/react/src/components/Tooltip/index.mdx @@ -0,0 +1,58 @@ +import { Canvas, Meta, Story, Source, ArgTypes } from '@storybook/blocks'; +import * as Stories from './index.stories'; +import { Tooltip } from './index'; + + + +# Tooltip + +Cunningham provides a tooltip component for displaying any kind of additional information. + + + + +## Content + +The content of the tooltip can either be a string or any React Element. + +### Plain text + + + +### HTML + + + +## 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 + + + +## 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`. + + + +## Props + +These are the props 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 | diff --git a/packages/react/src/components/Tooltip/index.spec.tsx b/packages/react/src/components/Tooltip/index.spec.tsx new file mode 100644 index 0000000..324575c --- /dev/null +++ b/packages/react/src/components/Tooltip/index.spec.tsx @@ -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("", () => { + it("appear on button hover and then disappear", async () => { + render( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + +

Title

+

Description

+ + } + closeDelay={0} + > + +
, + ); + + 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( + +

Title

+

Description

+ + } + closeDelay={0} + className="my-custom-class" + > + +
, + ); + + 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"); + }); + }); +}); diff --git a/packages/react/src/components/Tooltip/index.stories.tsx b/packages/react/src/components/Tooltip/index.stories.tsx new file mode 100644 index 0000000..64cd41b --- /dev/null +++ b/packages/react/src/components/Tooltip/index.stories.tsx @@ -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 ( +
+
+ +
⬅️ Hover it
+
+
+ ); + }, + ], +} as Meta; + +export const Default = { + args: { + children: ( + +
+ + + + + + + + + + + ); + }, +}; + +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: ( +

+ {lorem} +

+ ), + content: lorem, + }, +}; + +export const WithElements = { + args: { + content: lorem, + placement: "bottom", + children: ( +
+
+
+
+
+
+ ), + }, +}; + +export const WithHtml = { + args: { + children: ( +