-
Notifications
You must be signed in to change notification settings - Fork 38
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(Tooltip): Create new tooltip component (#914)
- Loading branch information
Showing
8 changed files
with
378 additions
and
32 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
); | ||
}, | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export type { TooltipProps } from './Tooltip'; | ||
export { Tooltip } from './Tooltip'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters