From dd1297deed17f63085571b1dbb7b1b79906c0112 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 10:18:39 +0200 Subject: [PATCH 1/5] feat(tooltip): align plain tooltip colors and typography with MD3 Container now uses inverseSurface and text uses inverseOnSurface per the MD3 tooltip spec (previously onSurface/surface). Text variant changes from labelLarge to bodySmall (12sp). Tokens are extracted into a new tokens.ts following the FAB pattern; the rich and motion token sets land here too and are consumed in later commits. --- src/components/Tooltip/Tooltip.tsx | 15 ++--- src/components/Tooltip/tokens.ts | 72 +++++++++++++++++++++++ src/components/__tests__/Tooltip.test.tsx | 28 ++++++++- 3 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 src/components/Tooltip/tokens.ts diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 86e2e3cff5..99307a803d 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -9,6 +9,7 @@ import { ViewStyle, } from 'react-native'; +import { Tokens } from './tokens'; import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -203,12 +204,12 @@ const Tooltip = ({ style={[ styles.tooltip, { - backgroundColor: theme.colors.onSurface, + backgroundColor: theme.colors[Tokens.plain.container], ...getTooltipPosition( measurement as Measurement, children as React.ReactElement ), - borderRadius: theme.shapes.corner.extraSmall, + borderRadius: theme.shapes.corner[Tokens.plain.shape], ...(measurement.measured ? styles.visible : styles.hidden), }, ]} @@ -218,8 +219,8 @@ const Tooltip = ({ accessibilityLiveRegion="polite" numberOfLines={1} selectable={false} - variant="labelLarge" - style={{ color: theme.colors.surface }} + variant={Tokens.plain.typescale} + style={{ color: theme.colors[Tokens.plain.content] }} maxFontSizeMultiplier={titleMaxFontSizeMultiplier} > {title} @@ -247,9 +248,9 @@ const styles = StyleSheet.create({ tooltip: { alignSelf: 'flex-start', justifyContent: 'center', - paddingHorizontal: 16, - height: 32, - maxHeight: 32, + paddingHorizontal: Tokens.plain.paddingHorizontal, + height: Tokens.plain.height, + maxHeight: Tokens.plain.height, }, visible: { opacity: 1, diff --git a/src/components/Tooltip/tokens.ts b/src/components/Tooltip/tokens.ts new file mode 100644 index 0000000000..a4e863431a --- /dev/null +++ b/src/components/Tooltip/tokens.ts @@ -0,0 +1,72 @@ +import type { + ColorRole, + Elevation, + ThemeShapeCorners, + TypescaleKey, +} from '../../theme/types'; + +type ShapeKey = keyof ThemeShapeCorners; + +/** + * Plain tooltip — a single line of text on an inverse-surface container. + * https://m3.material.io/components/tooltips/specs#1e6d4d8a + */ +const plain = { + container: 'inverseSurface', + content: 'inverseOnSurface', + shape: 'extraSmall', + height: 32, + paddingHorizontal: 16, + typescale: 'bodySmall', +} as const satisfies { + container: ColorRole; + content: ColorRole; + shape: ShapeKey; + height: number; + paddingHorizontal: number; + typescale: TypescaleKey; +}; + +/** + * Rich tooltip — an optional subhead, supporting text and action buttons on a + * surface-container container at elevation level 2. + * https://m3.material.io/components/tooltips/specs#8e6cf915 + */ +const rich = { + container: 'surfaceContainer', + title: 'onSurface', + content: 'onSurfaceVariant', + action: 'primary', + shape: 'medium', + elevation: 2, + maxWidth: 312, + paddingHorizontal: 16, + paddingVertical: 12, + titleTypescale: 'titleSmall', + contentTypescale: 'bodyMedium', + gap: 4, +} as const satisfies { + container: ColorRole; + title: ColorRole; + content: ColorRole; + action: ColorRole; + shape: ShapeKey; + elevation: Elevation; + maxWidth: number; + paddingHorizontal: number; + paddingVertical: number; + titleTypescale: TypescaleKey; + contentTypescale: TypescaleKey; + gap: number; +}; + +/** + * Fade transition on show/hide. Keys are resolved against `theme.motion` at + * runtime: enter decelerates in, exit accelerates out, per the M3 motion spec. + */ +const motion = { + enter: { duration: 'short3', easing: 'standardDecelerate' }, + exit: { duration: 'short2', easing: 'standardAccelerate' }, +} as const; + +export const Tokens = { plain, rich, motion }; diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 6c59eaa60b..8d3ed8318e 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -1,10 +1,11 @@ import React, { RefObject } from 'react'; -import { Dimensions, Text, View, Platform } from 'react-native'; +import { Dimensions, StyleSheet, Text, View, Platform } from 'react-native'; import { act, fireEvent } from '@testing-library/react-native'; import type { ReactTestInstance } from 'react-test-renderer'; import PaperProvider from '../../core/PaperProvider'; +import { getTheme } from '../../core/theming'; import { render } from '../../test-utils'; import Tooltip from '../Tooltip/Tooltip'; @@ -145,6 +146,31 @@ describe('Tooltip', () => { }); }); + describe('MD3 styling', () => { + it('renders an inverseSurface container with inverseOnSurface text', async () => { + const { + wrapper: { getByText, getByTestId, findByText }, + } = setup(); + + fireEvent(getTrigger(getByText), 'longPress'); + + await findByText('some tooltip text'); + + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { backgroundColor: getTheme().colors.inverseSurface }, + ]); + + // bodySmall (12sp) text in the inverseOnSurface role. + expect( + StyleSheet.flatten(getByText('some tooltip text').props.style) + ).toMatchObject({ + color: getTheme().colors.inverseOnSurface, + fontSize: 12, + }); + }); + }); + describe('Tooltip position', () => { const LAYOUT_WIDTH = 360; const LAYOUT_HEIGHT = 705; From 9079c4990ae5428b7c288f56d0412e0b964fe456 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 10:52:04 +0200 Subject: [PATCH 2/5] feat(tooltip): add fade enter/exit animation The plain tooltip now fades in on show and out on hide using Reanimated, per the MD3 motion spec (enter short3/standardDecelerate, exit short2/standardAccelerate). Show/hide intent (visible) is split from mount state (rendered) so the tooltip stays mounted through the exit fade before unmounting. Honors reduce-motion via useReduceMotion. --- src/components/Tooltip/Tooltip.tsx | 95 ++++++++++++++++++++--- src/components/__tests__/Tooltip.test.tsx | 36 ++++++++- 2 files changed, 118 insertions(+), 13 deletions(-) diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 99307a803d..59d6809149 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -9,9 +9,18 @@ import { ViewStyle, } from 'react-native'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + import { Tokens } from './tokens'; import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; import Portal from '../Portal/Portal'; @@ -75,7 +84,11 @@ const Tooltip = ({ const isWeb = Platform.OS === 'web'; const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + // `visible` is the show/hide intent; `rendered` keeps the tooltip mounted + // through the exit fade so it can animate out before unmounting. const [visible, setVisible] = React.useState(false); + const [rendered, setRendered] = React.useState(false); const [measurement, setMeasurement] = React.useState({ children: {}, @@ -88,6 +101,36 @@ const Tooltip = ({ const childrenWrapperRef = React.useRef(null); const touched = React.useRef(false); + const opacity = useSharedValue(0); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const enterConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.enter.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.exit.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + // The visual fade-out is handled by Reanimated; the actual unmount is + // deferred by this same duration so the fade can play. Reduce-motion skips + // the wait entirely. + const exitDurationMs = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + const isValidChild = React.useMemo( () => React.isValidElement(children), [children] @@ -107,6 +150,43 @@ const Tooltip = ({ }; }, []); + // Mount as soon as the tooltip is requested. + React.useEffect(() => { + if (visible) { + setRendered(true); + } + }, [visible]); + + // Drive the fade and defer unmount until the exit animation has played. + React.useEffect(() => { + if (!rendered) { + return; + } + + if (visible) { + // Hold at 0 until measured so the tooltip never flashes at the wrong + // position, then fade in. + opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; + return; + } + + opacity.value = withTiming(0, exitConfig); + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ children: {}, tooltip: {}, measured: false }); + }, exitDurationMs) as unknown as NodeJS.Timeout; + + return () => clearTimeout(id); + }, [ + visible, + rendered, + measurement.measured, + opacity, + enterConfig, + exitConfig, + exitDurationMs, + ]); + React.useEffect(() => { const subscription = addEventListener(Dimensions, 'change', () => setVisible(false) @@ -142,7 +222,6 @@ const Tooltip = ({ let id = setTimeout(() => { setVisible(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); }, leaveTouchDelay) as unknown as NodeJS.Timeout; hideTooltipTimer.current.push(id); }, [leaveTouchDelay]); @@ -197,9 +276,9 @@ const Tooltip = ({ return ( <> - {visible && ( + {rendered && ( - ), borderRadius: theme.shapes.corner[Tokens.plain.shape], - ...(measurement.measured ? styles.visible : styles.hidden), }, + animatedStyle, ]} testID="tooltip-container" > @@ -225,7 +304,7 @@ const Tooltip = ({ > {title} - + )} { await findByText('some tooltip text'); fireEvent(getTrigger(getByText), 'pressOut'); - runTimers(); + runTimers(); // leaveTouchDelay → starts the fade-out + runTimers(); // exit fade duration → unmounts expect(queryByText('some tooltip text')).toBeNull(); }); @@ -159,6 +160,7 @@ describe('Tooltip', () => { expect(getByTestId('tooltip-container').props.style).toMatchObject([ {}, { backgroundColor: getTheme().colors.inverseSurface }, + {}, ]); // bodySmall (12sp) text in the inverseOnSurface role. @@ -171,6 +173,27 @@ describe('Tooltip', () => { }); }); + describe('fade animation', () => { + it('stays mounted through the exit fade before unmounting', async () => { + const { + wrapper: { queryByText, getByText, findByText }, + } = setup({ leaveTouchDelay: 0 }); + + fireEvent(getTrigger(getByText), 'longPress'); + + await findByText('some tooltip text'); + + fireEvent(getTrigger(getByText), 'pressOut'); + runTimers(); // leaveTouchDelay elapses → exit fade starts + + // Still mounted while fading out so the animation can play. + expect(queryByText('some tooltip text')).not.toBeNull(); + + runTimers(); // exit fade duration elapses → unmounts + expect(queryByText('some tooltip text')).toBeNull(); + }); + }); + describe('Tooltip position', () => { const LAYOUT_WIDTH = 360; const LAYOUT_HEIGHT = 705; @@ -206,6 +229,7 @@ describe('Tooltip', () => { left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 top: 250, // pageY (200) + height (50) }, + {}, ]); }); }); @@ -230,6 +254,7 @@ describe('Tooltip', () => { left: 0, // Tooltip renders starting from children's x coord top: 250, }, + {}, ]); }); }); @@ -254,6 +279,7 @@ describe('Tooltip', () => { left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen top: 250, }, + {}, ]); }); }); @@ -278,6 +304,7 @@ describe('Tooltip', () => { left: 210, top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, }, + {}, ]); }); }); @@ -365,7 +392,8 @@ describe('Tooltip', () => { await findByText('some tooltip text'); fireEvent(getTrigger(getByText), 'hoverOut'); - runTimers(); + runTimers(); // leaveTouchDelay → starts the fade-out + runTimers(); // exit fade duration → unmounts expect(queryByText('some tooltip text')).toBeNull(); }); @@ -407,6 +435,7 @@ describe('Tooltip', () => { left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 top: 250, // pageY (200) + height (50) }, + {}, ]); }); }); @@ -432,6 +461,7 @@ describe('Tooltip', () => { left: 0, // Tooltip renders starting from children's x coord top: 250, }, + {}, ]); }); }); @@ -457,6 +487,7 @@ describe('Tooltip', () => { left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen top: 250, }, + {}, ]); }); }); @@ -482,6 +513,7 @@ describe('Tooltip', () => { left: 210, top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, }, + {}, ]); }); }); From 793d397ae2e86ab3c0922513535ab7a7b293af0b Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 13:37:01 +0200 Subject: [PATCH 3/5] feat(tooltip): add rich tooltip variant Adds Tooltip.Rich, a persistent, interactive rich tooltip per the MD3 spec: an optional subhead title, supporting body text (string or element) and a row of action buttons on a surfaceContainer surface at elevation level 2 with a 12dp corner. Exposed as a compound component (Object.assign) so the plain Tooltip stays untouched. Uncontrolled tap-to-toggle: tapping the trigger toggles it, tapping the Portal backdrop or selecting an action dismisses it. On web it opens on hover and bridges the trigger-to-tooltip gap before hiding. Reuses the plain tooltip's Reanimated fade and reduce-motion handling. --- src/components/Tooltip/RichTooltip.tsx | 408 ++++++++++++++++++++++ src/components/Tooltip/index.tsx | 6 + src/components/__tests__/Tooltip.test.tsx | 170 +++++++++ src/index.tsx | 3 +- 4 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 src/components/Tooltip/RichTooltip.tsx create mode 100644 src/components/Tooltip/index.tsx diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx new file mode 100644 index 0000000000..ce169ca720 --- /dev/null +++ b/src/components/Tooltip/RichTooltip.tsx @@ -0,0 +1,408 @@ +import * as React from 'react'; +import { + Dimensions, + View, + LayoutChangeEvent, + StyleSheet, + Platform, + Pressable, + ViewStyle, +} from 'react-native'; + +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { Tokens } from './tokens'; +import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import type { ThemeProp } from '../../types'; +import { addEventListener } from '../../utils/addEventListener'; +import Portal from '../Portal/Portal'; +import Surface from '../Surface'; +import Text from '../Typography/Text'; + +export type Props = { + /** + * Tooltip reference element. Needs to be able to hold a ref. + */ + children: React.ReactElement; + /** + * Optional subhead shown above the content. + */ + title?: string; + /** + * Supporting body text. A string is rendered with the `bodyMedium` type + * style; pass an element to compose inline links or custom content. + */ + content: string | React.ReactElement; + /** + * Action buttons (and/or links) rendered in a row below the content. + * Pressing one dismisses the tooltip. + */ + actions?: React.ReactNode; + /** + * The number of milliseconds a user must hover the element before showing + * the tooltip (web only). + */ + enterTouchDelay?: number; + /** + * The number of milliseconds after the pointer leaves both the trigger and + * the tooltip before hiding it (web only). + */ + leaveTouchDelay?: number; + /** + * Specifies the largest possible scale the title font can reach. + */ + titleMaxFontSizeMultiplier?: number; + /** + * Specifies the largest possible scale the content font can reach. + */ + contentMaxFontSizeMultiplier?: number; + /** + * @optional + */ + theme?: ThemeProp; +}; + +/** + * Rich tooltips display informative text along with an optional subhead and + * action buttons. Unlike plain tooltips they are persistent and interactive: + * tap the element to toggle the tooltip, then tap outside or an action to + * dismiss it. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Button, IconButton, Tooltip } from 'react-native-paper'; + * + * const MyComponent = () => ( + * Learn more} + * > + * {}} /> + * + * ); + * + * export default MyComponent; + * ``` + */ +const RichTooltip = ({ + children, + title, + content, + actions, + enterTouchDelay = 100, + leaveTouchDelay = 500, + titleMaxFontSizeMultiplier, + contentMaxFontSizeMultiplier, + theme: themeOverrides, + ...rest +}: Props) => { + const isWeb = Platform.OS === 'web'; + + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + // `visible` is the show/hide intent; `rendered` keeps the tooltip mounted + // through the exit fade so it can animate out before unmounting. + const [visible, setVisible] = React.useState(false); + const [rendered, setRendered] = React.useState(false); + + const [measurement, setMeasurement] = React.useState({ + children: {}, + tooltip: {}, + measured: false, + }); + const showTooltipTimer = React.useRef([]); + const hideTooltipTimer = React.useRef([]); + + const childrenWrapperRef = React.useRef(null); + + const opacity = useSharedValue(0); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const enterConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.enter.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.exit.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitDurationMs = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + const isValidChild = React.useMemo( + () => React.isValidElement(children), + [children] + ); + + const clearShowTimers = React.useCallback(() => { + showTooltipTimer.current.forEach((t) => clearTimeout(t)); + showTooltipTimer.current = []; + }, []); + + const clearHideTimers = React.useCallback(() => { + hideTooltipTimer.current.forEach((t) => clearTimeout(t)); + hideTooltipTimer.current = []; + }, []); + + React.useEffect(() => { + return () => { + clearShowTimers(); + clearHideTimers(); + }; + }, [clearShowTimers, clearHideTimers]); + + // Mount as soon as the tooltip is requested. + React.useEffect(() => { + if (visible) { + setRendered(true); + } + }, [visible]); + + // Drive the fade and defer unmount until the exit animation has played. + React.useEffect(() => { + if (!rendered) { + return; + } + + if (visible) { + opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; + return; + } + + opacity.value = withTiming(0, exitConfig); + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ children: {}, tooltip: {}, measured: false }); + }, exitDurationMs) as unknown as NodeJS.Timeout; + + return () => clearTimeout(id); + }, [ + visible, + rendered, + measurement.measured, + opacity, + enterConfig, + exitConfig, + exitDurationMs, + ]); + + React.useEffect(() => { + const subscription = addEventListener(Dimensions, 'change', () => + setVisible(false) + ); + + return () => subscription.remove(); + }, []); + + const show = React.useCallback(() => { + clearHideTimers(); + setVisible(true); + }, [clearHideTimers]); + + const hide = React.useCallback(() => { + clearShowTimers(); + setVisible(false); + }, [clearShowTimers]); + + const scheduleHide = React.useCallback(() => { + clearShowTimers(); + const id = setTimeout( + () => setVisible(false), + leaveTouchDelay + ) as unknown as NodeJS.Timeout; + hideTooltipTimer.current.push(id); + }, [clearShowTimers, leaveTouchDelay]); + + // Mobile: a tap toggles the tooltip and still forwards the child's onPress. + const handlePress = React.useCallback(() => { + if (visible) { + hide(); + } else { + show(); + } + if (isValidChild) { + (children.props as TooltipChildProps).onPress?.(); + } + }, [visible, hide, show, isValidChild, children.props]); + + // Web: open on hover, with a short enter delay. + const handleHoverIn = React.useCallback(() => { + clearHideTimers(); + const id = setTimeout( + () => setVisible(true), + enterTouchDelay + ) as unknown as NodeJS.Timeout; + showTooltipTimer.current.push(id); + if (isValidChild) { + (children.props as TooltipChildProps).onHoverIn?.(); + } + }, [clearHideTimers, enterTouchDelay, isValidChild, children.props]); + + const handleHoverOut = React.useCallback(() => { + scheduleHide(); + if (isValidChild) { + (children.props as TooltipChildProps).onHoverOut?.(); + } + }, [scheduleHide, isValidChild, children.props]); + + const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + childrenWrapperRef.current?.measure( + (_x, _y, width, height, pageX, pageY) => { + setMeasurement({ + children: { pageX, pageY, height, width }, + tooltip: { ...layout }, + measured: true, + }); + } + ); + }; + + const mobilePressProps = { + onPress: handlePress, + }; + + const webPressProps = { + onHoverIn: handleHoverIn, + onHoverOut: handleHoverOut, + }; + + // Web only: keep the tooltip open while the pointer travels from the trigger + // into the tooltip (and re-schedule the hide once it leaves the tooltip). + const tooltipHoverProps = isWeb + ? { onHoverIn: clearHideTimers, onHoverOut: scheduleHide } + : {}; + + return ( + <> + {rendered && ( + + + + ), + animatedStyle, + ]} + testID="tooltip-rich-container" + > + + + {title ? ( + + {title} + + ) : null} + {typeof content === 'string' ? ( + + {content} + + ) : ( + content + )} + {actions ? ( + // `onTouchEnd` bubbles from the pressed action up to this + // wrapper, so selecting any action dismisses the tooltip. + + {actions} + + ) : null} + + + + + )} + + {React.cloneElement(children, { + ...rest, + ...(isWeb ? webPressProps : mobilePressProps), + })} + + + ); +}; + +RichTooltip.displayName = 'Tooltip.Rich'; + +const styles = StyleSheet.create({ + container: { + alignSelf: 'flex-start', + maxWidth: Tokens.rich.maxWidth, + }, + surface: { + paddingHorizontal: Tokens.rich.paddingHorizontal, + paddingVertical: Tokens.rich.paddingVertical, + rowGap: Tokens.rich.gap, + }, + actions: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + pressContainer: { + ...(Platform.OS === 'web' && { cursor: 'default' }), + } as ViewStyle, +}); + +export default RichTooltip; diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx new file mode 100644 index 0000000000..f5fa880a6b --- /dev/null +++ b/src/components/Tooltip/index.tsx @@ -0,0 +1,6 @@ +import RichTooltip from './RichTooltip'; +import TooltipBase from './Tooltip'; + +const Tooltip = Object.assign(TooltipBase, { Rich: RichTooltip }); + +export default Tooltip; diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index a5e8975acf..794e08a138 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -7,6 +7,7 @@ import type { ReactTestInstance } from 'react-test-renderer'; import PaperProvider from '../../core/PaperProvider'; import { getTheme } from '../../core/theming'; import { render } from '../../test-utils'; +import TooltipCompound from '../Tooltip'; import Tooltip from '../Tooltip/Tooltip'; const mockedRemoveEventListener = jest.fn(); @@ -520,3 +521,172 @@ describe('Tooltip', () => { }); }); }); + +describe('Tooltip.Rich', () => { + const getTrigger = (getByText: (text: string) => ReactTestInstance) => + getByText('dummy component').parent as ReactTestInstance; + + const runTimers = (ms?: number) => { + act(() => { + if (ms === undefined) { + jest.runOnlyPendingTimers(); + } else { + jest.advanceTimersByTime(ms); + } + }); + }; + + const setup = ( + propOverrides?: Partial> + ) => { + jest + .spyOn(View.prototype, 'measure') + .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + + const wrapper = render( + + + + + + ); + + return { wrapper }; + }; + + it('is exposed as a compound component on Tooltip', () => { + expect(TooltipCompound.Rich).toBeDefined(); + }); + + describe('Mobile', () => { + beforeAll(() => { + Platform.OS = 'android'; + }); + afterEach(() => jest.clearAllMocks()); + + it('toggles title, content and actions when the trigger is pressed', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup({ title: 'Heading', actions: Learn more }); + + expect(queryByText('Body text')).toBeNull(); + + fireEvent.press(getTrigger(getByText)); + + expect(getByText('Heading')).toBeTruthy(); + expect(getByText('Body text')).toBeTruthy(); + expect(getByText('Learn more')).toBeTruthy(); + expect(getByTestId('tooltip-rich-container')).toBeTruthy(); + + // Pressing again toggles it back off. + fireEvent.press(getTrigger(getByText)); + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('renders a custom element as content', () => { + const { + wrapper: { getByText }, + } = setup({ content: Custom node }); + + fireEvent.press(getTrigger(getByText)); + + expect(getByText('Custom node')).toBeTruthy(); + }); + + it('uses the surfaceContainer container with MD3 title/content roles', () => { + const { + wrapper: { getByText, getByTestId }, + } = setup({ title: 'Heading' }); + + fireEvent.press(getTrigger(getByText)); + + expect( + StyleSheet.flatten(getByText('Heading').props.style) + ).toMatchObject({ + color: getTheme().colors.onSurface, + }); + expect( + StyleSheet.flatten(getByText('Body text').props.style) + ).toMatchObject({ + color: getTheme().colors.onSurfaceVariant, + }); + + // Surface (container) uses the surfaceContainer color. + expect( + StyleSheet.flatten( + getByTestId('tooltip-rich-surface-container').props.style + ) + ).toMatchObject({ + backgroundColor: getTheme().colors.surfaceContainer, + }); + }); + + it('dismisses when the backdrop is pressed', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup(); + + fireEvent.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + fireEvent.press(getByTestId('tooltip-rich-backdrop')); + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('dismisses when an action is selected', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup({ actions: Learn more }); + + fireEvent.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + fireEvent(getByTestId('tooltip-rich-actions'), 'touchEnd'); + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + }); + + describe('Web', () => { + beforeAll(() => { + Platform.OS = 'web'; + }); + afterEach(() => jest.clearAllMocks()); + + it('opens on hover after the enter delay', () => { + const { + wrapper: { getByText, queryByText }, + } = setup({ enterTouchDelay: 100 }); + + fireEvent(getTrigger(getByText), 'hoverIn'); + expect(queryByText('Body text')).toBeNull(); // still within the delay + + runTimers(100); + + expect(getByText('Body text')).toBeTruthy(); + }); + + it('keeps the tooltip open while the pointer moves into it (gap bridge)', () => { + const { + wrapper: { getByText, getByTestId }, + } = setup({ enterTouchDelay: 0, leaveTouchDelay: 500 }); + + fireEvent(getTrigger(getByText), 'hoverIn'); + runTimers(0); + expect(getByText('Body text')).toBeTruthy(); + + // Leaving the trigger schedules a hide... + fireEvent(getTrigger(getByText), 'hoverOut'); + // ...but entering the tooltip cancels it. + fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn'); + runTimers(500); + + expect(getByText('Body text')).toBeTruthy(); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..0f783a0d47 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -49,7 +49,7 @@ export { default as TouchableRipple } from './components/TouchableRipple/Touchab export { default as TextInput } from './components/TextInput'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; -export { default as Tooltip } from './components/Tooltip/Tooltip'; +export { default as Tooltip } from './components/Tooltip'; export { default as Text, customText } from './components/Typography/Text'; @@ -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 { Props as TooltipRichProps } from './components/Tooltip/RichTooltip'; export { type TypescaleKey, type Theme, type Elevation } from './types'; From d52cbcc60be499b3b49028c5979ca2c4e2bd0676 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 13:57:47 +0200 Subject: [PATCH 4/5] docs(tooltip): showcase rich tooltip and document both variants Adds a 'Rich tooltips' section to the example app (full title/content/actions variant plus a body-only one), registers the RichTooltip page in the docs component map so the generated docs cover Tooltip.Rich, and cross-references the rich variant from the plain Tooltip JSDoc. --- docs/docusaurus.config.js | 1 + example/src/Examples/TooltipExample.tsx | 24 ++++++++++++++++++++++++ src/components/Tooltip/Tooltip.tsx | 2 ++ 3 files changed, 27 insertions(+) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a4b672640c..3aee5acc26 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -175,6 +175,7 @@ const config = { }, Tooltip: { Tooltip: 'Tooltip/Tooltip', + TooltipRich: 'Tooltip/RichTooltip', }, TouchableRipple: { TouchableRipple: 'TouchableRipple/TouchableRipple', diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 8e0802d4a4..59a3b6bbef 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -6,6 +6,7 @@ import { Appbar, Avatar, Banner, + Button, Chip, FAB, IconButton, @@ -146,6 +147,29 @@ const TooltipExample = () => { + + + + + + + } + > + {}} /> + + + {}} /> + + + diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 59d6809149..a6ba168284 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -58,6 +58,8 @@ export type Props = { * * Plain tooltips, when activated, display a text label identifying an element, such as a description of its function. Tooltips should include only short, descriptive text and avoid restating visible UI text. * + * For tooltips with a title, supporting text and action buttons, see `Tooltip.Rich`. + * * ## Usage * ```js * import * as React from 'react'; From bdcf0e50f73fae1719636efd2cff6a0018ef623d Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 14:13:25 +0200 Subject: [PATCH 5/5] refactor(tooltip): extract shared useTooltipFade hook The fade lifecycle (mount-through-exit, opacity, measurement, motion configs, reduce-motion) was duplicated between Tooltip and Tooltip.Rich. Extract it into a useTooltipFade hook so both variants share one implementation. No behavior change. --- src/components/Tooltip/RichTooltip.tsx | 102 ++--------------------- src/components/Tooltip/Tooltip.tsx | 107 ++---------------------- src/components/Tooltip/hooks.ts | 111 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 195 deletions(-) create mode 100644 src/components/Tooltip/hooks.ts diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index ce169ca720..9e97ba7bbe 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -2,25 +2,18 @@ import * as React from 'react'; import { Dimensions, View, - LayoutChangeEvent, StyleSheet, Platform, Pressable, ViewStyle, } from 'react-native'; -import Animated, { - Easing, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; +import { useTooltipFade } from './hooks'; import { Tokens } from './tokens'; import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; -import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; import Portal from '../Portal/Portal'; @@ -109,49 +102,15 @@ const RichTooltip = ({ const isWeb = Platform.OS === 'web'; const theme = useInternalTheme(themeOverrides); - const reduceMotion = useReduceMotion(); - // `visible` is the show/hide intent; `rendered` keeps the tooltip mounted - // through the exit fade so it can animate out before unmounting. + // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted + // through the exit animation and owns the measurement + opacity. const [visible, setVisible] = React.useState(false); - const [rendered, setRendered] = React.useState(false); + const { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef } = + useTooltipFade(theme, visible); - const [measurement, setMeasurement] = React.useState({ - children: {}, - tooltip: {}, - measured: false, - }); const showTooltipTimer = React.useRef([]); const hideTooltipTimer = React.useRef([]); - const childrenWrapperRef = React.useRef(null); - - const opacity = useSharedValue(0); - const reanimatedReduceMotion = reduceMotion - ? ReduceMotion.Always - : ReduceMotion.Never; - - const enterConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.enter.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - const exitConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.exit.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - const exitDurationMs = reduceMotion - ? 0 - : theme.motion.duration[Tokens.motion.exit.duration]; - - const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - const isValidChild = React.useMemo( () => React.isValidElement(children), [children] @@ -174,41 +133,6 @@ const RichTooltip = ({ }; }, [clearShowTimers, clearHideTimers]); - // Mount as soon as the tooltip is requested. - React.useEffect(() => { - if (visible) { - setRendered(true); - } - }, [visible]); - - // Drive the fade and defer unmount until the exit animation has played. - React.useEffect(() => { - if (!rendered) { - return; - } - - if (visible) { - opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; - return; - } - - opacity.value = withTiming(0, exitConfig); - const id = setTimeout(() => { - setRendered(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); - }, exitDurationMs) as unknown as NodeJS.Timeout; - - return () => clearTimeout(id); - }, [ - visible, - rendered, - measurement.measured, - opacity, - enterConfig, - exitConfig, - exitDurationMs, - ]); - React.useEffect(() => { const subscription = addEventListener(Dimensions, 'change', () => setVisible(false) @@ -268,18 +192,6 @@ const RichTooltip = ({ } }, [scheduleHide, isValidChild, children.props]); - const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - childrenWrapperRef.current?.measure( - (_x, _y, width, height, pageX, pageY) => { - setMeasurement({ - children: { pageX, pageY, height, width }, - tooltip: { ...layout }, - measured: true, - }); - } - ); - }; - const mobilePressProps = { onPress: handlePress, }; @@ -307,7 +219,7 @@ const RichTooltip = ({ testID="tooltip-rich-backdrop" /> ([]); const hideTooltipTimer = React.useRef([]); - const childrenWrapperRef = React.useRef(null); const touched = React.useRef(false); - const opacity = useSharedValue(0); - const reanimatedReduceMotion = reduceMotion - ? ReduceMotion.Always - : ReduceMotion.Never; - - const enterConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.enter.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - const exitConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.exit.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - // The visual fade-out is handled by Reanimated; the actual unmount is - // deferred by this same duration so the fade can play. Reduce-motion skips - // the wait entirely. - const exitDurationMs = reduceMotion - ? 0 - : theme.motion.duration[Tokens.motion.exit.duration]; - - const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - const isValidChild = React.useMemo( () => React.isValidElement(children), [children] @@ -152,43 +108,6 @@ const Tooltip = ({ }; }, []); - // Mount as soon as the tooltip is requested. - React.useEffect(() => { - if (visible) { - setRendered(true); - } - }, [visible]); - - // Drive the fade and defer unmount until the exit animation has played. - React.useEffect(() => { - if (!rendered) { - return; - } - - if (visible) { - // Hold at 0 until measured so the tooltip never flashes at the wrong - // position, then fade in. - opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; - return; - } - - opacity.value = withTiming(0, exitConfig); - const id = setTimeout(() => { - setRendered(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); - }, exitDurationMs) as unknown as NodeJS.Timeout; - - return () => clearTimeout(id); - }, [ - visible, - rendered, - measurement.measured, - opacity, - enterConfig, - exitConfig, - exitDurationMs, - ]); - React.useEffect(() => { const subscription = addEventListener(Dimensions, 'change', () => setVisible(false) @@ -252,18 +171,6 @@ const Tooltip = ({ } }, [children.props, handleTouchEnd, isValidChild]); - const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - childrenWrapperRef.current?.measure( - (_x, _y, width, height, pageX, pageY) => { - setMeasurement({ - children: { pageX, pageY, height, width }, - tooltip: { ...layout }, - measured: true, - }); - } - ); - }; - const mobilePressProps = { onPress: handlePress, onLongPress: () => handleTouchStart(), @@ -281,7 +188,7 @@ const Tooltip = ({ {rendered && ( { + const reduceMotion = useReduceMotion(); + const [rendered, setRendered] = React.useState(false); + const [measurement, setMeasurement] = React.useState({ + children: {}, + tooltip: {}, + measured: false, + }); + const childrenWrapperRef = React.useRef(null); + + const opacity = useSharedValue(0); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const enterConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.enter.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.exit.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitDurationMs = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + // Mount as soon as the tooltip is requested. + React.useEffect(() => { + if (visible) { + setRendered(true); + } + }, [visible]); + + // Drive the fade and defer unmount until the exit animation has played. + React.useEffect(() => { + if (!rendered) { + return; + } + + if (visible) { + opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; + return; + } + + opacity.value = withTiming(0, exitConfig); + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ children: {}, tooltip: {}, measured: false }); + }, exitDurationMs) as unknown as NodeJS.Timeout; + + return () => clearTimeout(id); + }, [ + visible, + rendered, + measurement.measured, + opacity, + enterConfig, + exitConfig, + exitDurationMs, + ]); + + const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + childrenWrapperRef.current?.measure( + (_x, _y, width, height, pageX, pageY) => { + setMeasurement({ + children: { pageX, pageY, height, width }, + tooltip: { ...layout }, + measured: true, + }); + } + ); + }; + + return { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef }; +};