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/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx
new file mode 100644
index 0000000000..9e97ba7bbe
--- /dev/null
+++ b/src/components/Tooltip/RichTooltip.tsx
@@ -0,0 +1,320 @@
+import * as React from 'react';
+import {
+ Dimensions,
+ View,
+ StyleSheet,
+ Platform,
+ Pressable,
+ ViewStyle,
+} from 'react-native';
+
+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 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);
+ // `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, measurement, animatedStyle, onLayout, childrenWrapperRef } =
+ useTooltipFade(theme, visible);
+
+ const showTooltipTimer = React.useRef([]);
+ const hideTooltipTimer = React.useRef([]);
+
+ 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]);
+
+ 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 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/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx
index 86e2e3cff5..0989f1a24e 100644
--- a/src/components/Tooltip/Tooltip.tsx
+++ b/src/components/Tooltip/Tooltip.tsx
@@ -1,14 +1,16 @@
import * as React from 'react';
import {
Dimensions,
- View,
- LayoutChangeEvent,
StyleSheet,
Platform,
Pressable,
ViewStyle,
} from 'react-native';
+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 type { ThemeProp } from '../../types';
@@ -48,6 +50,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';
@@ -74,17 +78,15 @@ const Tooltip = ({
const isWeb = Platform.OS === 'web';
const theme = useInternalTheme(themeOverrides);
+ // `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, 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 touched = React.useRef(false);
const isValidChild = React.useMemo(
@@ -141,7 +143,6 @@ const Tooltip = ({
let id = setTimeout(() => {
setVisible(false);
- setMeasurement({ children: {}, tooltip: {}, measured: false });
}, leaveTouchDelay) as unknown as NodeJS.Timeout;
hideTooltipTimer.current.push(id);
}, [leaveTouchDelay]);
@@ -170,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(),
@@ -196,21 +185,21 @@ const Tooltip = ({
return (
<>
- {visible && (
+ {rendered && (
-
),
- borderRadius: theme.shapes.corner.extraSmall,
- ...(measurement.measured ? styles.visible : styles.hidden),
+ borderRadius: theme.shapes.corner[Tokens.plain.shape],
},
+ animatedStyle,
]}
testID="tooltip-container"
>
@@ -218,13 +207,13 @@ 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}
-
+
)}
{
+ 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 };
+};
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/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..794e08a138 100644
--- a/src/components/__tests__/Tooltip.test.tsx
+++ b/src/components/__tests__/Tooltip.test.tsx
@@ -1,11 +1,13 @@
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 TooltipCompound from '../Tooltip';
import Tooltip from '../Tooltip/Tooltip';
const mockedRemoveEventListener = jest.fn();
@@ -139,12 +141,60 @@ describe('Tooltip', () => {
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();
});
});
+ 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('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;
@@ -180,6 +230,7 @@ describe('Tooltip', () => {
left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210
top: 250, // pageY (200) + height (50)
},
+ {},
]);
});
});
@@ -204,6 +255,7 @@ describe('Tooltip', () => {
left: 0, // Tooltip renders starting from children's x coord
top: 250,
},
+ {},
]);
});
});
@@ -228,6 +280,7 @@ describe('Tooltip', () => {
left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
top: 250,
},
+ {},
]);
});
});
@@ -252,6 +305,7 @@ describe('Tooltip', () => {
left: 210,
top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component,
},
+ {},
]);
});
});
@@ -339,7 +393,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();
});
@@ -381,6 +436,7 @@ describe('Tooltip', () => {
left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210
top: 250, // pageY (200) + height (50)
},
+ {},
]);
});
});
@@ -406,6 +462,7 @@ describe('Tooltip', () => {
left: 0, // Tooltip renders starting from children's x coord
top: 250,
},
+ {},
]);
});
});
@@ -431,6 +488,7 @@ describe('Tooltip', () => {
left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
top: 250,
},
+ {},
]);
});
});
@@ -456,9 +514,179 @@ describe('Tooltip', () => {
left: 210,
top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component,
},
+ {},
]);
});
});
});
});
});
+
+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';