Skip to content

Commit

Permalink
feat(Tooltip): Create new tooltip component (#914)
Browse files Browse the repository at this point in the history
  • Loading branch information
Barsnes authored Oct 11, 2023
1 parent c0fd9f5 commit eccc36d
Show file tree
Hide file tree
Showing 8 changed files with 378 additions and 32 deletions.
64 changes: 32 additions & 32 deletions packages/Overview.mdx

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions packages/react/src/components/Tooltip/Tooltip.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Meta, Canvas, Story, Controls, Primary } from '@storybook/blocks';

import * as TooltipStories from './Tooltip.stories.tsx';

<Meta of={TooltipStories} />

# Tooltip

<Primary />
<Controls />

## Plassering

<Canvas of={TooltipStories.Placement} />

## Åpen som standard

<Canvas of={TooltipStories.DefaultOpen} />

## Avansert bruk

<Canvas of={TooltipStories.Complex} />
7 changes: 7 additions & 0 deletions packages/react/src/components/Tooltip/Tooltip.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.wrapper {
background: var(--fds-semantic-border-neutral-strong);
padding: var(--fds-spacing-1) var(--fds-spacing-2);
color: white;
border-radius: var(--fds-border_radius-medium);
font: var(--fds-typography-paragraph-xsmall);
}
62 changes: 62 additions & 0 deletions packages/react/src/components/Tooltip/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import React from 'react';

import { Paragraph, Button } from '../..';

import { Tooltip } from '.';

type Story = StoryObj<typeof Tooltip>;

const defaultChildren = <Button>My trigger</Button>;
const decorators = [
(Story: StoryFn) => (
<div style={{ margin: '2rem' }}>
<Story />
</div>
),
];

export default {
title: 'Felles/Tooltip',
component: Tooltip,
decorators,
} as Meta;

export const Preview: Story = {
args: {
content: 'Tooltip text',
children: defaultChildren,
},
};

export const Placement: Story = {
args: {
content: 'Tooltip text',
placement: 'bottom',
children: defaultChildren,
},
};

export const DefaultOpen: Story = {
args: {
content: 'Tooltip text',
defaultOpen: true,
children: defaultChildren,
},
};

export const Complex: StoryFn<typeof Tooltip> = () => {
return (
<Paragraph>
Du kan ha{' '}
<Tooltip content='Kan gi bra brukeropplevelse'>
<abbr
style={{ fontWeight: 'bold', textDecoration: 'underline dotted' }}
>
tooltip
</abbr>
</Tooltip>{' '}
inne i tekst også
</Paragraph>
);
};
96 changes: 96 additions & 0 deletions packages/react/src/components/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { act, render as renderRtl, screen } from '@testing-library/react';

import type { TooltipProps } from './Tooltip';
import { Tooltip } from './Tooltip';

const render = async (props: Partial<TooltipProps> = {}) => {
const allProps: TooltipProps = {
children: <button>My button</button>,
content: 'Tooltip text',
...props,
};
/* Flush microtasks */
await act(async () => {});
const user = userEvent.setup();

return {
user,
...renderRtl(<Tooltip {...allProps} />),
};
};

describe('Tooltip', () => {
describe('should render children', () => {
it('should render child', async () => {
await render();
const tooltipTrigger = screen.getByRole('button', { name: 'My button' });

expect(tooltipTrigger).toBeInTheDocument();
});
});

describe('should render tooltip', () => {
it('should render tooltip on hover', async () => {
const { user } = await render();
const tooltipTrigger = screen.getByRole('button', { name: 'My button' });

expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
await act(async () => await user.hover(tooltipTrigger));
const tooltip = await screen.findByText('Tooltip text');
expect(tooltip).toBeInTheDocument();
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
});

it('should render tooltip on focus', async () => {
await render();
const tooltipTrigger = screen.getByRole('button', { name: 'My button' });

expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument();
act(() => tooltipTrigger.focus());
const tooltip = await screen.findByText('Tooltip text');
expect(tooltip).toBeInTheDocument();
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
});

it('should close tooltip on escape', async () => {
const { user } = await render();
const tooltipTrigger = screen.getByRole('button', { name: 'My button' });

expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument();
await act(async () => {
await user.hover(tooltipTrigger);
});
const tooltip = await screen.findByText('Tooltip text');
expect(tooltip).toBeInTheDocument();
await act(async () => {
await user.keyboard('[Escape]');
});
expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument();
});
});

it('should render open when we pass open prop', async () => {
await render({ open: true });
const tooltipTrigger = screen.getByRole('button', { name: 'My button' });

expect(screen.getByRole('tooltip')).toBeInTheDocument();
expect(tooltipTrigger).toHaveAttribute('aria-describedby');
});

it('delay', async () => {
const user = userEvent.setup();

await render({ delay: 300 });

await act(async () => {
await user.hover(screen.getByRole('button'));
await new Promise((r) => setTimeout(r, 250));
expect(screen.queryByRole('tooltip')).toBeNull();
await new Promise((r) => setTimeout(r, 600));
});

expect(screen.getByRole('tooltip')).toBeVisible();
});
});
156 changes: 156 additions & 0 deletions packages/react/src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { HTMLAttributes } from 'react';
import React, { cloneElement, forwardRef, useState } from 'react';
import cn from 'classnames';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
arrow,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
useTransitionStyles,
useMergeRefs,
FloatingArrow,
FloatingPortal,
} from '@floating-ui/react';

import styles from './Tooltip.module.css';

const ARROW_HEIGHT = 7;
const ARROW_GAP = 4;

export type TooltipProps = {
/**
* The element that triggers the tooltip.
* @note Needs to be a single ReactElement and not: <React.Fragment/> | <></>
*/
children: React.ReactElement & React.RefAttributes<HTMLElement>;
/** Content of the tooltip */
content: string;
/**
* Placement of the tooltip on the trigger.
* @default 'top'
*/
placement?: 'top' | 'right' | 'bottom' | 'left';
/** Delay in milliseconds before opening.
* @default 150
*/
delay?: number;
/** Whether the tooltip is open or not.
* This overrides the internal state of the tooltip.
*/
open?: boolean;
/** Whether the tooltip is open by default or not. */
defaultOpen?: boolean;
} & HTMLAttributes<HTMLDivElement>;

export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
(
{
children,
content,
placement = 'top',
delay = 150,
open: userOpen,
defaultOpen = false,
className,
...restHTMLProps
},
ref,
) => {
const [isOpen, setIsOpen] = useState(defaultOpen);

const arrowRef = React.useRef<SVGSVGElement>(null);
const internalOpen = userOpen ?? isOpen;

const { refs, floatingStyles, context } = useFloating({
open: internalOpen,
onOpenChange: setIsOpen,
placement,
whileElementsMounted: autoUpdate,
middleware: [
offset(ARROW_HEIGHT + ARROW_GAP),
flip({
fallbackAxisSideDirection: 'start',
}),
shift(),
arrow({
element: arrowRef,
}),
],
});

const { styles: animationStyles } = useTransitionStyles(context, {
initial: {
opacity: 0,
},
});

const { getReferenceProps, getFloatingProps } = useInteractions([
// Event listeners to change the open state
useHover(context, { move: false, delay }),
useFocus(context),
useDismiss(context),
useRole(context, { role: 'tooltip' }),
]);

const mergedRef = useMergeRefs([ref, refs.setFloating]);

const childMergedRef = useMergeRefs([
(children as React.ReactElement & React.RefAttributes<HTMLElement>)
.ref as React.MutableRefObject<HTMLElement>,
refs.setReference,
]);

if (
!children ||
children?.type === React.Fragment ||
(children as unknown) === React.Fragment
) {
console.error(
'<Tooltip> children needs to be a single ReactElement and not: <React.Fragment/> | <></>',
);
return null;
}

return (
<>
{cloneElement(
children,
getReferenceProps({
ref: childMergedRef,
}),
)}
<FloatingPortal>
{internalOpen && (
<>
<div
ref={refs.setFloating}
style={{ ...floatingStyles, ...animationStyles }}
{...getFloatingProps({
...restHTMLProps,
className: cn(styles.wrapper, className),
ref: mergedRef,
})}
role='tooltip'
>
{content}
<FloatingArrow
ref={arrowRef}
context={context}
fill='var(--fds-semantic-border-neutral-strong)'
height={ARROW_HEIGHT}
/>
</div>
</>
)}
</FloatingPortal>
</>
);
},
);
2 changes: 2 additions & 0 deletions packages/react/src/components/Tooltip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { TooltipProps } from './Tooltip';
export { Tooltip } from './Tooltip';
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export * from './Alert';
export * from './Tag';
export * from './Chip';
export * from './Pagination';
export * from './Tooltip';
export * from './form/Checkbox';
export * from './form/Radio';
export * from './form/Fieldset';
Expand Down

0 comments on commit eccc36d

Please sign in to comment.