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..0e86faed5e 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,31 @@ const TooltipExample = () => {
+
+
+ (
+ <>
+
+
+ >
+ )}
+ >
+ {(props) => }
+
+
+ {(props) => (
+
+ )}
+
+
+
diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx
new file mode 100644
index 0000000000..9efd7d8134
--- /dev/null
+++ b/src/components/Tooltip/RichTooltip.tsx
@@ -0,0 +1,326 @@
+import * as React from 'react';
+import {
+ Dimensions,
+ View,
+ StyleSheet,
+ Platform,
+ Pressable,
+} from 'react-native';
+import type { ViewStyle } from 'react-native';
+
+import Animated from 'react-native-reanimated';
+
+import { useTooltipFade } from './hooks';
+import { Tokens } from './tokens';
+import { getTooltipPosition } from './utils';
+import type { Measurement } 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';
+
+/**
+ * Props passed to the `children` render function. Spread them onto the trigger
+ * element (and merge with your own handlers when you have them).
+ */
+export type TooltipRichTriggerProps = {
+ onPress?: () => void;
+ onHoverIn?: () => void;
+ onHoverOut?: () => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+};
+
+export type Props = {
+ /**
+ * Render function returning the trigger element. The provided props wire the
+ * tooltip's show/hide behavior and must be spread onto the returned element:
+ *
+ * ```js
+ *
+ * {(props) => }
+ *
+ * ```
+ */
+ children: (props: TooltipRichTriggerProps) => 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;
+ /**
+ * Render function for the action buttons (and/or links) shown in a row below
+ * the content. Call `dismiss` from an action to hide the tooltip:
+ *
+ * ```js
+ * actions={({ dismiss }) => (
+ *
+ * )}
+ * ```
+ */
+ actions?: (props: { dismiss: () => void }) => 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. On web they open on hover and on keyboard focus.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { Button, IconButton, Tooltip } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ * (
+ *
+ * )}
+ * >
+ * {(props) => }
+ *
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+const RichTooltip = ({
+ children,
+ title,
+ content,
+ actions,
+ enterTouchDelay = 100,
+ leaveTouchDelay = 500,
+ titleMaxFontSizeMultiplier,
+ contentMaxFontSizeMultiplier,
+ theme: themeOverrides,
+}: 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 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.
+ const handlePress = React.useCallback(() => {
+ setVisible((v) => !v);
+ clearShowTimers();
+ clearHideTimers();
+ }, [clearShowTimers, clearHideTimers]);
+
+ // Web: open on hover (with a short enter delay) and on keyboard focus.
+ const handleHoverIn = React.useCallback(() => {
+ clearHideTimers();
+ const id = setTimeout(
+ () => setVisible(true),
+ enterTouchDelay
+ ) as unknown as NodeJS.Timeout;
+ showTooltipTimer.current.push(id);
+ }, [clearHideTimers, enterTouchDelay]);
+
+ // Trigger props handed to the consumer's render function.
+ const triggerProps: TooltipRichTriggerProps = isWeb
+ ? {
+ onHoverIn: handleHoverIn,
+ onHoverOut: scheduleHide,
+ onFocus: show,
+ onBlur: scheduleHide,
+ }
+ : { onPress: handlePress };
+
+ // 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 && (
+
+
+
+
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {typeof content === 'string' ? (
+
+ {content}
+
+ ) : (
+ content
+ )}
+ {actions ? (
+
+ {actions({ dismiss: hide })}
+
+ ) : null}
+
+
+
+
+ )}
+
+ {children(triggerProps)}
+
+ >
+ );
+};
+
+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: {
+ alignSelf: 'flex-start',
+ ...(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 36dc08971b..9c2cad0b7d 100644
--- a/src/components/Tooltip/Tooltip.tsx
+++ b/src/components/Tooltip/Tooltip.tsx
@@ -1,13 +1,11 @@
import * as React from 'react';
-import {
- Dimensions,
- View,
- StyleSheet,
- Platform,
- Pressable,
-} from 'react-native';
-import type { LayoutChangeEvent, ViewStyle } from 'react-native';
+import { Dimensions, StyleSheet, Platform, Pressable } from 'react-native';
+import type { ViewStyle } from 'react-native';
+import Animated from 'react-native-reanimated';
+
+import { useTooltipFade } from './hooks';
+import { Tokens } from './tokens';
import { getTooltipPosition } from './utils';
import type { Measurement, TooltipChildProps } from './utils';
import { useInternalTheme } from '../../core/theming';
@@ -48,6 +46,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 +74,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 +139,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 +167,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 +181,22 @@ const Tooltip = ({
return (
<>
- {visible && (
+ {rendered && (
-
+ (children as React.ReactElement).props
+ .style
),
- borderRadius: theme.shapes.corner.extraSmall,
- ...(measurement.measured ? styles.visible : styles.hidden),
+ borderRadius: theme.shapes.corner[Tokens.plain.shape],
},
+ animatedStyle,
]}
testID="tooltip-container"
>
@@ -218,13 +204,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..eddf391669
--- /dev/null
+++ b/src/components/Tooltip/tokens.ts
@@ -0,0 +1,42 @@
+/**
+ * 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;
+
+/**
+ * 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',
+ shape: 'medium',
+ elevation: 2,
+ maxWidth: 312,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ titleTypescale: 'titleSmall',
+ contentTypescale: 'bodyMedium',
+ gap: 4,
+} as const;
+
+/**
+ * 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/Tooltip/utils.ts b/src/components/Tooltip/utils.ts
index 43baf684fd..190a99b77b 100644
--- a/src/components/Tooltip/utils.ts
+++ b/src/components/Tooltip/utils.ts
@@ -119,14 +119,12 @@ const getChildrenMeasures = (
export const getTooltipPosition = (
{ children, tooltip, measured }: Measurement,
- component: React.ReactElement<{
- style: StyleProp;
- }>
+ childStyle?: StyleProp
): {} | { left: number; top: number } => {
if (!measured) return {};
let measures = children;
- if (component.props.style) {
- measures = getChildrenMeasures(component.props.style, children);
+ if (childStyle) {
+ measures = getChildrenMeasures(childStyle, children);
}
return {
diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx
index 62666e9d7c..44171be25f 100644
--- a/src/components/__tests__/Tooltip.test.tsx
+++ b/src/components/__tests__/Tooltip.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Dimensions, Text, View, Platform } from 'react-native';
+import { Dimensions, StyleSheet, Text, View, Platform } from 'react-native';
import type { ViewProps } from 'react-native';
import {
@@ -15,7 +15,9 @@ 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();
@@ -162,12 +164,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;
@@ -203,6 +253,7 @@ describe('Tooltip', () => {
left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210
top: 250, // pageY (200) + height (50)
},
+ {},
]);
});
});
@@ -227,6 +278,7 @@ describe('Tooltip', () => {
left: 0, // Tooltip renders starting from children's x coord
top: 250,
},
+ {},
]);
});
});
@@ -251,6 +303,7 @@ describe('Tooltip', () => {
left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
top: 250,
},
+ {},
]);
});
});
@@ -275,6 +328,7 @@ describe('Tooltip', () => {
left: 210,
top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component,
},
+ {},
]);
});
});
@@ -370,7 +424,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();
});
@@ -412,6 +467,7 @@ describe('Tooltip', () => {
left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210
top: 250, // pageY (200) + height (50)
},
+ {},
]);
});
});
@@ -437,6 +493,7 @@ describe('Tooltip', () => {
left: 0, // Tooltip renders starting from children's x coord
top: 250,
},
+ {},
]);
});
});
@@ -462,6 +519,7 @@ describe('Tooltip', () => {
left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
top: 250,
},
+ {},
]);
});
});
@@ -487,9 +545,224 @@ 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(
+
+
+ {(props) => }
+
+
+ );
+
+ 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 calls dismiss', () => {
+ const {
+ wrapper: { getByText, queryByText },
+ } = setup({
+ actions: ({ dismiss }) => Learn more,
+ });
+
+ fireEvent.press(getTrigger(getByText));
+ expect(getByText('Body text')).toBeTruthy();
+
+ fireEvent.press(getByText('Learn more'));
+ 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('opens on keyboard focus and hides on blur', () => {
+ const {
+ wrapper: { getByText, queryByText },
+ } = setup({ leaveTouchDelay: 500 });
+
+ fireEvent(getTrigger(getByText), 'focus');
+ expect(getByText('Body text')).toBeTruthy();
+
+ fireEvent(getTrigger(getByText), 'blur');
+ runTimers(500); // leave delay → hide intent
+ runTimers(); // exit fade → unmount
+
+ expect(queryByText('Body text')).toBeNull();
+ });
+
+ 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();
+ });
+
+ it('opens on hover even when the trigger ignores the hover props', () => {
+ // Some triggers (e.g. `IconButton`) don't forward `onHoverIn` on web,
+ // so the wrapper must carry the handlers itself. Here the trigger
+ // deliberately drops the provided props.
+ jest
+ .spyOn(View.prototype, 'measure')
+ .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200));
+
+ const { getByTestId, getByText, queryByText } = render(
+
+
+ {() => }
+
+
+ );
+
+ fireEvent(getByTestId('tooltip-rich-trigger'), 'hoverIn');
+ expect(queryByText('Body text')).toBeNull(); // within the enter delay
+
+ runTimers(100);
+
+ expect(getByText('Body text')).toBeTruthy();
+ });
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..da86483501 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,9 @@ 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,
+ TooltipRichTriggerProps,
+} from './components/Tooltip/RichTooltip';
export { type TypescaleKey, type Theme, type Elevation } from './types';