From 4aa9d47e9f35bf6d69be73e4fb6120421074540f Mon Sep 17 00:00:00 2001 From: hazrid93 Date: Mon, 20 Jul 2026 10:28:12 +0000 Subject: [PATCH] feat: add rich tooltip mode to Tooltip Closes #4074. Extends Tooltip with a mode that selects between the current plain behaviour and a new rich behaviour. Rich mode renders an optional heading above multi line supporting text and up to two action buttons. Each action button is individually pressable, exposes a button accessibility role and label, invokes its own callback, and dismisses the tooltip. A rich tooltip is persistent, staying open after the trigger touch ends, and dismisses on an action press or an outside tap, firing a dismissal callback on close. Plain mode keeps the current fixed 32px container height and single line label, and rich only props have no effect in plain mode. The new mode, action, and dismiss types are exported from the public surface. --- src/components/Tooltip/Tooltip.tsx | 146 ++++++++++++++++++++++++++++- src/index.tsx | 1 + 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 8d237d6991..87f876c0a7 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -16,6 +16,20 @@ import { addEventListener } from '../../utils/addEventListener'; import Portal from '../Portal/Portal'; import Text from '../Typography/Text'; +export type TooltipMode = 'plain' | 'rich'; + +export type TooltipAction = { + /** + * Label shown on the action button. + */ + label: string; + /** + * Called when the action button is pressed. The tooltip is dismissed + * immediately afterwards. + */ + onPress?: () => void; +}; + export type Props = { /** * Tooltip reference element. Needs to be able to hold a ref. @@ -30,9 +44,36 @@ export type Props = { */ leaveTouchDelay?: number; /** - * Tooltip title + * Tooltip title. In a `rich` tooltip this is the supporting/descriptive text. */ title: string; + /** + * Tooltip variant. `plain` shows a single-line text label (the default + * behaviour). `rich` renders a larger container that can hold an optional + * heading and up to two action buttons. + * + * @default 'plain' + */ + mode?: TooltipMode; + /** + * Optional heading shown above the supporting text in a `rich` tooltip. + * Ignored in `plain` mode. + */ + subhead?: string; + /** + * Primary action button for a `rich` tooltip. Ignored in `plain` mode. + */ + action?: TooltipAction; + /** + * Optional secondary action button for a `rich` tooltip, rendered after the + * primary action. Ignored in `plain` mode. + */ + secondaryAction?: TooltipAction; + /** + * Called when a `rich` tooltip is dismissed, either by pressing an action or + * by tapping outside of it. Ignored in `plain` mode. + */ + onDismiss?: () => void; /** * Specifies the largest possible scale a title font can reach. */ @@ -67,11 +108,17 @@ const Tooltip = ({ enterTouchDelay = 500, leaveTouchDelay = 1500, title, + mode = 'plain', + subhead, + action, + secondaryAction, + onDismiss, theme: themeOverrides, titleMaxFontSizeMultiplier, ...rest }: Props) => { const isWeb = Platform.OS === 'web'; + const isRich = mode === 'rich'; const theme = useInternalTheme(themeOverrides); const [visible, setVisible] = React.useState(false); @@ -133,6 +180,12 @@ const Tooltip = ({ }, [isWeb, enterTouchDelay]); const handleTouchEnd = React.useCallback(() => { + if (isRich) { + // Rich tooltips are persistent: they stay open so the user can read the + // heading and interact with the action buttons, and are dismissed by + // pressing an action rather than on touch end. + return; + } touched.current = false; if (showTooltipTimer.current.length) { showTooltipTimer.current.forEach((t) => clearTimeout(t)); @@ -144,7 +197,7 @@ const Tooltip = ({ setMeasurement({ children: {}, tooltip: {}, measured: false }); }, leaveTouchDelay) as unknown as NodeJS.Timeout; hideTooltipTimer.current.push(id); - }, [leaveTouchDelay]); + }, [isRich, leaveTouchDelay]); const handlePress = React.useCallback(() => { if (touched.current) { @@ -182,6 +235,16 @@ const Tooltip = ({ ); }; + const dismiss = React.useCallback(() => { + setVisible(false); + setMeasurement({ children: {}, tooltip: {}, measured: false }); + onDismiss?.(); + }, [onDismiss]); + + const richActions = ( + isRich ? [action, secondaryAction].filter(Boolean) : [] + ) as TooltipAction[]; + const mobilePressProps = { onPress: handlePress, onLongPress: () => handleTouchStart(), @@ -198,10 +261,20 @@ const Tooltip = ({ <> {visible && ( + {isRich ? ( + + ) : null} + {isRich && subhead ? ( + + {subhead} + + ) : null} {title} + {isRich && richActions.length > 0 ? ( + + {richActions.map((richAction, index) => ( + { + richAction.onPress?.(); + dismiss(); + }} + accessibilityRole="button" + accessibilityLabel={richAction.label} + style={styles.richActionButton} + > + + {richAction.label} + + + ))} + + ) : null} )} @@ -251,6 +365,30 @@ const styles = StyleSheet.create({ height: 32, maxHeight: 32, }, + richTooltip: { + height: undefined, + maxHeight: undefined, + minHeight: 56, + maxWidth: 320, + justifyContent: 'flex-start', + alignItems: 'flex-start', + flexDirection: 'column', + paddingVertical: 12, + paddingHorizontal: 16, + } as ViewStyle, + richSubhead: { + marginBottom: 4, + } as ViewStyle, + richActions: { + flexDirection: 'row', + alignSelf: 'flex-start', + marginTop: 12, + } as ViewStyle, + richActionButton: { + paddingVertical: 4, + paddingHorizontal: 8, + marginRight: 8, + } as ViewStyle, visible: { opacity: 1, }, diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..6a3d76fcce 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -146,5 +146,6 @@ export type { Props as TextProps } from './components/Typography/Text'; export type { Props as SegmentedButtonsProps } from './components/SegmentedButtons/SegmentedButtons'; export type { Props as ListImageProps } from './components/List/ListImage'; export type { Props as TooltipProps } from './components/Tooltip/Tooltip'; +export type { TooltipMode, TooltipAction } from './components/Tooltip/Tooltip'; export { type TypescaleKey, type Theme, type Elevation } from './types';