diff --git a/.changeset/famous-jobs-applaud.md b/.changeset/famous-jobs-applaud.md new file mode 100644 index 00000000000..5b3ea4feaf7 --- /dev/null +++ b/.changeset/famous-jobs-applaud.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Tooltip: Add delay functionality to tooltips with the options of `instant` (default), `medium`, `long` diff --git a/packages/react/src/Button/IconButton.features.stories.tsx b/packages/react/src/Button/IconButton.features.stories.tsx index 6c98391d520..674a3d7df2b 100644 --- a/packages/react/src/Button/IconButton.features.stories.tsx +++ b/packages/react/src/Button/IconButton.features.stories.tsx @@ -95,3 +95,10 @@ export const KeybindingHintOnDescription = () => ( ) export const KeybindingHint = () => + +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. + + + +) diff --git a/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx b/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx index 37f0e359a1f..e6e55e43804 100644 --- a/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx +++ b/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx @@ -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 ( +
+ {emojis.map((emojiItem, index) => ( + + + + ))} +
+ ) +} diff --git a/packages/react/src/TooltipV2/Tooltip.features.stories.tsx b/packages/react/src/TooltipV2/Tooltip.features.stories.tsx index d72956c19eb..a8f1123a6e6 100644 --- a/packages/react/src/TooltipV2/Tooltip.features.stories.tsx +++ b/packages/react/src/TooltipV2/Tooltip.features.stories.tsx @@ -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 { @@ -194,3 +202,19 @@ export const KeybindingHint = () => ( ) + +export const WithMediumDelay = () => ( +
+ + + +
+) + +export const WithLongDelay = () => ( +
+ + + +
+) diff --git a/packages/react/src/TooltipV2/Tooltip.tsx b/packages/react/src/TooltipV2/Tooltip.tsx index a0479eb8f5c..a291ecc9e10 100644 --- a/packages/react/src/TooltipV2/Tooltip.tsx +++ b/packages/react/src/TooltipV2/Tooltip.tsx @@ -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 @@ -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)) || @@ -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) @@ -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()