Skip to content

Advanced tooltips #61

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions example/src/stories/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Tooltip } from '@solved-ac/ui-react'
import { Button, Centering, Tooltip } from '@solved-ac/ui-react'
import { Meta, StoryFn } from '@storybook/react'
import React from 'react'

Expand All @@ -16,10 +16,53 @@ export default {
description: 'Whether to use the default styles',
defaultValue: false,
},
arrow: {
control: 'boolean',
description: 'Whether to show the arrow',
defaultValue: true,
},
keepOpen: {
control: 'boolean',
description: 'Whether to keep the tooltip open',
defaultValue: false,
},
place: {
control: 'select',
options: [
'top',
'top-start',
'top-end',
'right',
'right-start',
'right-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'left-start',
'left-end',
],
description: 'The placement of the tooltip',
defaultValue: 'top',
},
interactive: {
control: 'boolean',
description:
'Whether to make the tooltip interactive - if set to true, the tooltip contents will receive pointer events',
defaultValue: false,
},
},
} as Meta<typeof Tooltip>

const Template: StoryFn<typeof Tooltip> = (args) => <Tooltip {...args} />
const Template: StoryFn<typeof Tooltip> = (args) => (
<Centering
style={{
height: 200,
}}
>
<Tooltip {...args} />
</Centering>
)

export const Default = Template.bind({})
Default.args = {
Expand Down
97 changes: 73 additions & 24 deletions src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import {
flip,
FloatingPortal,
offset,
safePolygon,
shift,
useFloating,
useHover,
useInteractions,
} from '@floating-ui/react'
import { AnimatePresence, motion } from 'framer-motion'
import { transparentize } from 'polished'
import React, { ReactNode, useRef, useState } from 'react'
import React, { CSSProperties, ReactNode, useRef, useState } from 'react'
import { SolvedTheme, solvedThemes } from '../styles'
import { Card, CardProps } from './Card'

Expand All @@ -27,7 +28,6 @@ const TooltipContainer = styled(motion(Card))`
border: ${({ theme }) => theme.styles.border()};
box-shadow: ${({ theme }) => theme.styles.shadow(undefined, 16)};
z-index: 30000;
pointer-events: none;
backdrop-filter: blur(4px);
font-size: initial;
font-weight: initial;
Expand All @@ -53,10 +53,21 @@ const renderSide = {
left: 'right',
} as const

type TooltipPlacementBasic = 'top' | 'right' | 'bottom' | 'left'
type TooltipPlacementRelative = 'start' | 'end'

export type TooltipPlacement =
| `${TooltipPlacementBasic}-${TooltipPlacementRelative}`
| TooltipPlacementBasic

export type TooltipProps = {
title?: ReactNode
theme?: SolvedTheme
children?: ReactNode
arrow?: boolean
keepOpen?: boolean
place?: TooltipPlacement
interactive?: boolean
} & (
| {
noDefaultStyles: false
Expand All @@ -66,15 +77,57 @@ export type TooltipProps = {
})
)

const resolveArrowStyles = (
arrowX: number | undefined | null,
arrowY: number | undefined | null,
arrowPosition: 'top' | 'bottom' | 'left' | 'right',
padding = 16
): CSSProperties => {
if (arrowPosition === 'bottom') {
return {
left: arrowX ?? undefined,
bottom: -padding,
transform: `scaleY(-1)`,
}
}
if (arrowPosition === 'top') {
return {
left: arrowX ?? undefined,
top: -padding,
}
}
if (arrowPosition === 'left') {
return {
top: arrowY ?? undefined,
left: -16,
transform: `rotate(-90deg)`,
}
}
if (arrowPosition === 'right') {
return {
top: arrowY ?? undefined,
right: -16,
transform: `rotate(90deg)`,
}
}
return {}
}

export const Tooltip: React.FC<TooltipProps> = (props) => {
const {
title,
theme,
noDefaultStyles: noBackground,
children,
arrow: drawArrow = true,
keepOpen = false,
place,
interactive = false,
...cardProps
} = props
const [isOpen, setIsOpen] = useState(false)
const renderTooltip = keepOpen || isOpen

const arrowRef = useRef(null)

const {
Expand All @@ -86,13 +139,14 @@ export const Tooltip: React.FC<TooltipProps> = (props) => {
placement,
middlewareData: { arrow: { x: arrowX, y: arrowY } = {} },
} = useFloating({
placement: place,
strategy: 'fixed',
open: isOpen,
onOpenChange: setIsOpen,
middleware: [
offset(8),
offset(16),
shift({ padding: 16 }),
flip(),
shift({ padding: 8 }),
arrow({ element: arrowRef }),
],
whileElementsMounted: (reference, floating, update) =>
Expand All @@ -104,6 +158,10 @@ export const Tooltip: React.FC<TooltipProps> = (props) => {
const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context, {
delay: 200,
move: true,
handleClose: safePolygon({
buffer: 1,
}),
}),
])

Expand All @@ -120,7 +178,7 @@ export const Tooltip: React.FC<TooltipProps> = (props) => {
<FloatingPortal>
<ThemeProvider theme={theme || solvedThemes.dark}>
<AnimatePresence>
{isOpen && (
{renderTooltip && (
<React.Fragment>
<RenderComponent
ref={refs.setFloating}
Expand All @@ -129,31 +187,22 @@ export const Tooltip: React.FC<TooltipProps> = (props) => {
position: strategy,
top: y || 0,
left: x || 0,
pointerEvents: interactive ? 'auto' : 'none',
},
})}
{...cardProps}
transition={{ duration: 0.2, ease: 'easeInOut' }}
initial={{ opacity: 0, y: 0, scale: 0.9 }}
animate={{ opacity: 1, y: 8, scale: 1 }}
exit={{ opacity: 0, y: 0, scale: 0.9 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
{title}
<Arrow
ref={arrowRef}
style={
arrowPosition === 'bottom'
? {
left: arrowX ?? undefined,
[arrowPosition]: -16,
transform: `scaleY(-1)`,
}
: {
top:
arrowY !== null ? (arrowY || 0) - 16 : undefined,
left: arrowX ?? undefined,
}
}
/>
{drawArrow && (
<Arrow
ref={arrowRef}
style={resolveArrowStyles(arrowX, arrowY, arrowPosition)}
/>
)}
</RenderComponent>
</React.Fragment>
)}
Expand Down