diff --git a/src/components/Tooltip/Tooltip-v2.module.css b/src/components/Tooltip/Tooltip-v2.module.css new file mode 100644 index 000000000..453d21840 --- /dev/null +++ b/src/components/Tooltip/Tooltip-v2.module.css @@ -0,0 +1,109 @@ +/*------------------------------------*\ + # TOOLTIP +\*------------------------------------*/ + +.tooltip { + border-style: solid; + border-width: var(--eds-border-width-sm); + border-radius: calc(var(--eds-theme-border-radius-surfaces-md) * 1px); + box-shadow: var(--eds-box-shadow-md); + max-width: 14rem; + + @media (prefers-reduced-motion) { + transition: none; + } + + border-color: var(--eds-theme-color-background-utility-default-high-emphasis); + color: var(--eds-theme-color-text-utility-inverse); + background-color: var(--eds-theme-color-background-utility-default-high-emphasis); + --arrow-color: var(--eds-theme-color-background-utility-default-high-emphasis); +} + +/** + * Enables opacity fade animation + */ +.tooltip[data-state='hidden'] { + opacity: 0; +} + +/* TODO-AH: consider finding a way to not use these module semantics, e.g., global: */ +.tooltip :global(.tippy-content) { + padding-left: 1rem; + padding-right: 1rem; + padding-bottom: 0.5rem; + padding-top: 0.5rem; +} + +/** + * Add arrows + */ +.tooltip :global(.tippy-arrow) { + position: absolute; + + width: 1rem; + height: 1rem; +} + +.tooltip :global(.tippy-arrow::before) { + position: absolute; + + border-style: solid; + border-color: transparent; + border-width: 0.5rem; + + content: ''; +} + +.tooltip[data-placement^='top'] :global(.tippy-arrow) { + left: 0; + + transform: translate3d(73px, 0, 0); +} + +.tooltip[data-placement^='bottom'] :global(.tippy-arrow) { + top: 0; + left: 0; + + transform: translate3d(73px, 0, 0); +} + +.tooltip[data-placement^='left'] :global(.tippy-arrow) { + top: 0; + right: 0; + + transform: translate3d(0, 19px, 0); +} + +.tooltip[data-placement^='right'] :global(.tippy-arrow) { + top: 0; + left: 0; + + transform: translate3d(0, 25px, 0); +} + +.tooltip[data-placement^='top'] :global(.tippy-arrow::before) { + left: 0; + + border-top-color: var(--arrow-color); + border-bottom-width: 0; +} + +.tooltip[data-placement^='bottom'] :global(.tippy-arrow::before) { + left: 0; + + border-bottom-color: var(--arrow-color); + border-top-width: 0; + top: -7px; +} + +.tooltip[data-placement^='left'] :global(.tippy-arrow::before) { + border-left-color: var(--arrow-color); + border-right-width: 0; + right: -7px; +} + +.tooltip[data-placement^='right'] :global(.tippy-arrow::before) { + border-right-color: var(--arrow-color); + border-left-width: 0; + left: -7px; +} diff --git a/src/components/Tooltip/Tooltip-v2.stories.tsx b/src/components/Tooltip/Tooltip-v2.stories.tsx new file mode 100644 index 000000000..28770f6ba --- /dev/null +++ b/src/components/Tooltip/Tooltip-v2.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Tooltip } from './Tooltip-v2'; + +// diminishing the threshold of this component to avoid sub-pixel jittering +// https://www.chromatic.com/docs/threshold +const diffThreshold = 0.75; +const defaultArgs = { + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a erat eu augue consequat eleifend non vel sem. Praesent efficitur mauris ac leo semper accumsan.', + children:
Target Component
, + placement: 'right', + // most stories show a visible, non-interactive tooltip. + // this turns animation off to ensure stable visual snapshots + duration: 0, + visible: true, +}; + +export default { + title: 'Components/V2/Tooltip', + component: Tooltip, + args: defaultArgs, + argTypes: { + text: { + control: { + type: 'text', + }, + }, + children: { + control: { + type: null, + }, + }, + placement: { + table: { + defaultValue: { summary: 'top' }, + }, + }, + }, + parameters: { + layout: 'centered', + badges: ['intro-1.0', 'current-2.0'], + chromatic: { + diffThreshold, + diffIncludeAntiAliasing: false, + }, + }, + decorators: [(Story) =>
{Story()}
], +} as Meta; + +type Args = React.ComponentProps; +type Story = StoryObj; + +/** + * The following stories demonstrate how `Tooltip` can be made to appear on different sides of the trigger. + * Each story name denotes a value pased to `placement`. + */ +export const LeftPlacement: Story = { + args: { + placement: 'left', + children:
Target Component
, + }, + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +export const TopPlacement: Story = { + args: { + placement: 'top', + children:
Target Component
, + }, +}; + +export const BottomPlacement: Story = { + args: { + placement: 'bottom', + children:
Target Component
, + }, +}; + +export const LongText: Story = { + args: { + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a erat eu augue consequat eleifend non vel sem. Praesent efficitur mauris ac leo semper accumsan. Donec posuere semper fermentum. Vivamus venenatis laoreet venenatis. Sed consectetur, dolor sed tristique vehicula, sapien nulla convallis odio, et tempus urna mi eu leo. Phasellus a venenatis sapien. Cras massa lectus, sollicitudin id nulla id, laoreet facilisis est.', + }, +}; + +export const LongTriggerText: Story = { + args: { + children:
Longer text to test placement
, + }, +}; + +/** + * Hover over the button to make the tooltip appear. + */ +export const Interactive: Story = { + args: { + // reset prop values defined in defaultArgs + duration: undefined, + visible: undefined, + children: , + }, +}; + +/** + * Hover over the button to make the tooltip appear. + */ +export const InteractiveDisabled: Story = { + args: { + duration: undefined, + }, + render: (args) => ( + +
Target Component
+
+ ), +}; diff --git a/src/components/Tooltip/Tooltip-v2.test.tsx b/src/components/Tooltip/Tooltip-v2.test.tsx new file mode 100644 index 000000000..ca027475d --- /dev/null +++ b/src/components/Tooltip/Tooltip-v2.test.tsx @@ -0,0 +1,42 @@ +import { generateSnapshots } from '@chanzuckerberg/story-utils'; +import { composeStories } from '@storybook/testing-react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import * as TooltipStoryFile from './Tooltip-v2.stories'; + +const { Interactive, InteractiveDisabled } = composeStories(TooltipStoryFile); + +describe(' (v2)', () => { + generateSnapshots(TooltipStoryFile, { + // Tippy renders tooltip as a child of and hence is why baseElement needs to be targetted + getElement: (wrapper) => { + return wrapper.baseElement; + }, + }); + + it('should close tooltip via escape key', async () => { + const user = userEvent.setup(); + // disable animation for test + render(); + const trigger = await screen.findByRole('button'); + expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument(); + await user.hover(trigger); + expect(screen.getByTestId('tooltip-content')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument(); + }); + + it('should close tooltip via escape key for disabled buttons', async () => { + const user = userEvent.setup(); + // disable animation for test + render(); + const trigger = await screen.findByTestId('disabled-child-tooltip-wrapper'); + expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument(); + await user.hover(trigger); + expect(screen.getByTestId('tooltip-content')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Tooltip/Tooltip-v2.tsx b/src/components/Tooltip/Tooltip-v2.tsx new file mode 100644 index 000000000..53fe911eb --- /dev/null +++ b/src/components/Tooltip/Tooltip-v2.tsx @@ -0,0 +1,164 @@ +import type { TippyProps } from '@tippyjs/react'; +import Tippy from '@tippyjs/react'; +import clsx from 'clsx'; +import * as React from 'react'; +import type { HTMLAttributes } from 'react'; + +import { Text } from '../Text/Text'; + +import styles from './Tooltip-v2.module.css'; + +// Full list of Tippy props: https://atomiks.github.io/tippyjs/v6/all-props/ +type TooltipProps = { + // Component API + /** + * The element or ref to append the tooltip to. + * Defaults to the body element. + * 'parent' is suggested if used in a modal. + */ + appendTo?: 'parent' | Element | ((ref: Element) => Element); + /** + * Behavior of the tooltip transition, defaults to an opacity "fade". + * Animation guidelines are provided in https://atomiks.github.io/tippyjs/v5/animations/. + * To disable animations, pass `duration={0}`. + */ + animation?: string; + /** + * The trigger element the tooltip appears next to. + */ + children?: React.ReactElement; + /** + * If the child being passed into the Tooltip via the `children` prop is not interactive (e.g. a disabled button or an icon). + * + * Please note that spacing and placement styling will need to be added to a wrapper around the Tooltip, + * not on the child component inside the Tooltip, because there will be a wrapper around the child. Example: + *
+ */ + childNotInteractive?: boolean; + /** + * Custom classname for additional styles. + * + * These styles will only affect the tooltip bubble. + */ + className?: string; + /** + * How long to delay the Tooltip showing and hiding, in milliseconds. + * + * If a single number is provided, it will be applied to showing and hiding. + * If an array with 2 numbers is provided, the first will apply to showing and + * the second will be applied to hiding. + * https://atomiks.github.io/tippyjs/v6/all-props/#delay + */ + delay?: number | [number | null, number | null]; + /** + * Duration of Tooltip animation, in milliseconds. Default is 200. + */ + duration?: number; + /** + * The trigger element the tooltip appears next to. + * + * Use this instead of `children` if the trigger element is being + * stored in a ref. Most cases will use `children` and not + * `reference`. + */ + reference?: React.RefObject | Element; + /** + * Whether the tooltip is always visible or always invisible. + * + * This is most often left undefined so the Tooltip component + * controls if/when the bubble appears (on hover, click, focus, etc). + */ + visible?: boolean; + // Design API + /** + * Where the tooltip should be placed in relation to the element it's attached to. + * See: https://atomiks.github.io/tippyjs/v6/all-props/#placement + * + * **Default is `"top"`**. + */ + placement?: TippyProps['placement']; + /** + * The content of the tooltip bubble. + */ + text?: string; +} & TippyProps & + HTMLAttributes; + +// @tippyjs/react does not expose tippy.js types, have to extract via props and grab element type from array type +type Plugins = NonNullable['plugins']>; +type Plugin = Plugins[number]; + +/** + * `import {Tooltip} from "@chanzuckerberg/eds";` + * + * A floating information box, attached to other components on the page. Used to display option, additional information. + * + * - https://atomiks.github.io/tippyjs/ + * - https://github.com/atomiks/tippyjs-react + */ +export const Tooltip = ({ + childNotInteractive, + className, + duration = 200, + placement = 'top', + text, + ...rest +}: TooltipProps) => { + // Hides tooltip when escape key is pressed, following: + // https://atomiks.github.io/tippyjs/v6/plugins/#hideonesc + const hideOnEsc: Plugin = { + name: 'hideOnEsc', + defaultValue: true, + fn: ({ hide }) => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + hide(); + } + } + return { + onShow() { + document.addEventListener('keydown', onKeyDown); + }, + onHide() { + document.removeEventListener('keydown', onKeyDown); + }, + }; + }, + }; + + let children = rest.children; + // Tippy only works on elements with a tabindex. If the child is disabled, we need to + // wrap it in an element with a tabindex in order for it to work. + if (childNotInteractive) { + children = ( + + {rest.children} + + ); + } + + const textContent = ( + + {text} + + ); + + return ( + + {children} + + ); +}; + +Tooltip.displayName = 'Tooltip'; diff --git a/src/components/Tooltip/Tooltip.module.css b/src/components/Tooltip/Tooltip.module.css index f6c77297f..aeb2761bb 100644 --- a/src/components/Tooltip/Tooltip.module.css +++ b/src/components/Tooltip/Tooltip.module.css @@ -20,6 +20,7 @@ border-color: var(--eds-theme-color-border-neutral-default); color: var(--eds-theme-color-text-neutral-default); + /* TODO-AH: update the neutral value used here to avoid a color change on old components */ background-color: var(--eds-theme-color-background-neutral-subtle); --arrow-color: var(--eds-theme-color-border-neutral-default); } diff --git a/src/components/Tooltip/__snapshots__/Tooltip-v2.test.tsx.snap b/src/components/Tooltip/__snapshots__/Tooltip-v2.test.tsx.snap new file mode 100644 index 000000000..55137d27b --- /dev/null +++ b/src/components/Tooltip/__snapshots__/Tooltip-v2.test.tsx.snap @@ -0,0 +1,303 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` (v2) BottomPlacement story renders snapshot 1`] = ` + +
+
+
+ Target Component +
+
+
+
+ + +`; + +exports[` (v2) Interactive story renders snapshot 1`] = ` + +
+
+ +
+
+ +`; + +exports[` (v2) InteractiveDisabled story renders snapshot 1`] = ` + +
+
+ +
+ Target Component +
+
+
+
+ +`; + +exports[` (v2) LeftPlacement story renders snapshot 1`] = ` + +
+
+
+ Target Component +
+
+
+
+ + +`; + +exports[` (v2) LongText story renders snapshot 1`] = ` + +
+
+
+ Target Component +
+
+
+
+ + +`; + +exports[` (v2) LongTriggerText story renders snapshot 1`] = ` + +
+
+
+ Longer text to test placement +
+
+
+
+ + +`; + +exports[` (v2) TopPlacement story renders snapshot 1`] = ` + +
+
+
+ Target Component +
+
+
+
+ + +`; diff --git a/src/components/Tooltip/index.ts b/src/components/Tooltip/index.ts index bb034660e..a2b0f9347 100644 --- a/src/components/Tooltip/index.ts +++ b/src/components/Tooltip/index.ts @@ -1 +1,2 @@ export { Tooltip as default } from './Tooltip'; +export { Tooltip as TooltipV2 } from './Tooltip-v2';