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';