Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/famous-jobs-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

Tooltip: Add delay functionality to tooltips with the options of `instant` (default), `medium`, `long`
7 changes: 7 additions & 0 deletions packages/react/src/Button/IconButton.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,10 @@ export const KeybindingHintOnDescription = () => (
)

export const KeybindingHint = () => <IconButton icon={BoldIcon} aria-label="Bold" keybindingHint="Mod+B" />

export const LongDelayedTooltip = () => (
// Ideal for cases where we don't want to show the tooltip immediately — for example, when the user is just passing over the element.
<Tooltip text="This is a tooltip with 1200ms delay" delay="long">
<IconButton icon={HeartIcon} aria-label="HeartIcon" />
</Tooltip>
)
58 changes: 58 additions & 0 deletions packages/react/src/TooltipV2/Tooltip.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,61 @@ export const DialogTrigger = () => {
</>
)
}

export const EmojiPicker = () => {
// This example demonstrates a grid of emojis/icons with tooltips that appear after a long delay.
// This pattern is used in places like emoji reactions on comments and the icon picker in the issues dashboard's saved views on GitHub.
// The delay improves UX by preventing distraction when users move their cursor across multiple emojis/icons,
// especially since these icons are generally familiar and don't require immediate explanation.

const emojis = [
{emoji: '😀', name: 'Grinning Face'},
{emoji: '😍', name: 'Heart Eyes'},
{emoji: '🎉', name: 'Party Popper'},
{emoji: '👍', name: 'Thumbs Up'},
{emoji: '❤️', name: 'Red Heart'},
{emoji: '🔥', name: 'Fire'},
{emoji: '💯', name: 'Hundred Points'},
{emoji: '🚀', name: 'Rocket'},
{emoji: '⭐', name: 'Star'},
{emoji: '🎯', name: 'Direct Hit'},
{emoji: '💡', name: 'Light Bulb'},
{emoji: '🌟', name: 'Glowing Star'},
{emoji: '🎊', name: 'Confetti Ball'},
{emoji: '✨', name: 'Sparkles'},
{emoji: '🌈', name: 'Rainbow'},
]

return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(5, 1fr)',
gap: '4px',
maxWidth: '200px',
padding: '16px',
}}
>
{emojis.map((emojiItem, index) => (
<Tooltip key={index} text={emojiItem.name} direction="n" delay="long">
<Button
aria-label={emojiItem.name}
variant="invisible"
size="small"
style={{
fontSize: '18px',
padding: '8px',
minWidth: '32px',
minHeight: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{emojiItem.emoji}
</Button>
</Tooltip>
))}
</div>
)
}
26 changes: 25 additions & 1 deletion packages/react/src/TooltipV2/Tooltip.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import {IconButton, Button, Link, ActionMenu, ActionList, VisuallyHidden} from '..'
import Octicon from '../Octicon'
import {Tooltip} from './Tooltip'
import {SearchIcon, BookIcon, CheckIcon, TriangleDownIcon, GitBranchIcon, InfoIcon} from '@primer/octicons-react'
import {
SearchIcon,
BookIcon,
CheckIcon,
TriangleDownIcon,
GitBranchIcon,
InfoIcon,
HeartIcon,
} from '@primer/octicons-react'
import classes from './Tooltip.features.stories.module.css'

export default {
Expand Down Expand Up @@ -194,3 +202,19 @@ export const KeybindingHint = () => (
</Tooltip>
</div>
)

export const WithMediumDelay = () => (
<div className={classes.PaddedContainer}>
<Tooltip text="Tooltip is delayed by 400ms" delay="medium">
<Button>With delay</Button>
</Tooltip>
</div>
)

export const WithLongDelay = () => (
<div className={classes.PaddedContainer}>
<Tooltip text="Tooltip is delayed by 1200ms" delay="long">
<IconButton icon={HeartIcon} variant="invisible" aria-label="Favorite" />
</Tooltip>
</div>
)
34 changes: 31 additions & 3 deletions packages/react/src/TooltipV2/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export type TooltipProps = React.PropsWithChildren<{
text: string
type?: 'label' | 'description'
keybindingHint?: KeybindingHintProps['keys']
/**
* Delay in milliseconds before showing the tooltip
* @default short (50ms)
* medium (400ms)
* long (1200ms)
*/
delay?: 'short' | 'medium' | 'long'
}> &
React.HTMLAttributes<HTMLElement>

Expand Down Expand Up @@ -69,6 +76,14 @@ const interactiveElements = [
'textarea',
]

// Map delay prop to actual time in ms
// For context on delay times, see https://github.com/github/primer/issues/3313#issuecomment-3336696699
const delayTimeMap = {
short: 50,
medium: 400,
long: 1200,
}

const isInteractive = (element: HTMLElement) => {
return (
interactiveElements.some(selector => element.matches(selector)) ||
Expand All @@ -79,7 +94,17 @@ export const TooltipContext = React.createContext<{tooltipId?: string}>({})

export const Tooltip = React.forwardRef(
(
{direction = 's', text, type = 'description', children, id, className, keybindingHint, ...rest}: TooltipProps,
{
direction = 's',
text,
type = 'description',
children,
id,
className,
keybindingHint,
delay = 'short',
...rest
}: TooltipProps,
forwardedRef,
) => {
const tooltipId = useId(id)
Expand Down Expand Up @@ -280,14 +305,17 @@ export const Tooltip = React.forwardRef(
child.props.onFocus?.(event)
},
onMouseOverCapture: (event: React.MouseEvent) => {
const delayTime = delayTimeMap[delay] || 50
// We use a `capture` event to ensure this is called first before
// events that might cancel the opening timeout (like `onTouchEnd`)
// show tooltip after mouse has been hovering for at least 50ms
// show tooltip after mouse has been hovering for the specified delay time
// (prevent showing tooltip when mouse is just passing through)
openTimeoutRef.current = safeSetTimeout(() => {
// if the mouse is already moved out, do not show the tooltip
if (!openTimeoutRef.current) return
openTooltip()
child.props.onMouseEnter?.(event)
}, 50)
}, delayTime)
},
onMouseLeave: (event: React.MouseEvent) => {
closeTooltip()
Expand Down
Loading