diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index ee54135042..a2098ab6fc 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -180,6 +180,7 @@ const config = {
},
Tooltip: {
Tooltip: 'Tooltip/Tooltip',
+ RichTooltip: 'Tooltip/RichTooltip',
},
TouchableRipple: {
TouchableRipple: 'TouchableRipple/TouchableRipple',
diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx
index 8e0802d4a4..54c5d62193 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,
@@ -46,17 +47,28 @@ const TooltipExample = () => {
header: () => (
- navigation.goBack()} />
+ {(props) => (
+ navigation.goBack()}
+ />
+ )}
- {}} />
+ {(props) => (
+ {}} />
+ )}
- {}} />
+ {(props) => (
+ {}} />
+ )}
- {}} />
+ {(props) => (
+ {}} />
+ )}
),
@@ -83,11 +95,14 @@ const TooltipExample = () => {
enterTouchDelay={transport.enterTouchDelay}
leaveTouchDelay={transport.leaveTouchDelay}
>
- {}}
- />
+ {(props) => (
+ {}}
+ />
+ )}
))}
@@ -99,57 +114,106 @@ const TooltipExample = () => {
onValueChange={setTextAlign}
>
-
+ {(props) => (
+
+ )}
-
+ {(props) => (
+
+ )}
-
+ {(props) => (
+
+ )}
-
+ {(props) => }
-
- }
- >
- John Doe
-
+ {(props) => (
+
+ }
+ >
+ John Doe
+
+ )}
-
- (
-
- )}
- />
-
+ {(props) => (
+
+ (
+
+ )}
+ />
+
+ )}
+
+
+ (
+ <>
+
+
+ >
+ )}
+ >
+ {(props) => }
+
+
+ {(props) => (
+
+ )}
+
+
+
- {}} />
+ {(props) => {}} />}
>
diff --git a/jest/testSetup.js b/jest/testSetup.js
index c00e611084..b787f0f32e 100644
--- a/jest/testSetup.js
+++ b/jest/testSetup.js
@@ -8,9 +8,15 @@ jest.mock('react-native-worklets', () =>
require('react-native-worklets/lib/module/mock')
);
-jest.mock('react-native-reanimated', () =>
- require('react-native-reanimated/mock')
-);
+jest.mock('react-native-reanimated', () => {
+ const Reanimated = require('react-native-reanimated/mock');
+
+ // The mock doesn't ship the CSS easing helpers; stub the ones we use.
+ return {
+ ...Reanimated,
+ cubicBezier: (...points) => `cubic-bezier(${points.join(', ')})`,
+ };
+});
jest.mock('@react-native-vector-icons/material-design-icons', () => {
const React = require('react');
diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx
new file mode 100644
index 0000000000..fd4422d825
--- /dev/null
+++ b/src/components/Tooltip/RichTooltip.tsx
@@ -0,0 +1,322 @@
+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 { 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 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, fadeStyle, onLayout, childrenWrapperRef } =
+ useTooltipFade(theme, visible);
+
+ const showTimer = React.useRef | null>(null);
+ const hideTimer = React.useRef | null>(null);
+
+ const clearShowTimer = React.useCallback(() => {
+ if (showTimer.current) {
+ clearTimeout(showTimer.current);
+ showTimer.current = null;
+ }
+ }, []);
+
+ const clearHideTimer = React.useCallback(() => {
+ if (hideTimer.current) {
+ clearTimeout(hideTimer.current);
+ hideTimer.current = null;
+ }
+ }, []);
+
+ React.useEffect(() => {
+ return () => {
+ clearShowTimer();
+ clearHideTimer();
+ };
+ }, [clearShowTimer, clearHideTimer]);
+
+ React.useEffect(() => {
+ const subscription = addEventListener(Dimensions, 'change', () =>
+ setVisible(false)
+ );
+
+ return () => subscription.remove();
+ }, []);
+
+ const show = React.useCallback(() => {
+ clearHideTimer();
+ setVisible(true);
+ }, [clearHideTimer]);
+
+ const hide = React.useCallback(() => {
+ clearShowTimer();
+ setVisible(false);
+ }, [clearShowTimer]);
+
+ const scheduleHide = React.useCallback(() => {
+ clearShowTimer();
+ hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay);
+ }, [clearShowTimer, leaveTouchDelay]);
+
+ // Mobile: a tap toggles the tooltip.
+ const handlePress = React.useCallback(() => {
+ setVisible((v) => !v);
+ clearShowTimer();
+ clearHideTimer();
+ }, [clearShowTimer, clearHideTimer]);
+
+ // Web: open on hover (with a short enter delay) and on keyboard focus.
+ const handleHoverIn = React.useCallback(() => {
+ clearHideTimer();
+ showTimer.current = setTimeout(() => setVisible(true), enterTouchDelay);
+ }, [clearHideTimer, enterTouchDelay]);
+
+ // Trigger props handed to the consumer's render function.
+ const triggerProps: TooltipRichTriggerProps =
+ Platform.OS === 'web'
+ ? {
+ 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 =
+ Platform.OS === 'web'
+ ? { onHoverIn: clearHideTimer, 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..73f51c6ca4 100644
--- a/src/components/Tooltip/Tooltip.tsx
+++ b/src/components/Tooltip/Tooltip.tsx
@@ -1,26 +1,42 @@
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';
import type { ThemeProp } from '../../types';
import { addEventListener } from '../../utils/addEventListener';
import Portal from '../Portal/Portal';
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 TooltipTriggerProps = {
+ onLongPress?: () => void;
+ onPressOut?: () => void;
+ delayLongPress?: number;
+ onHoverIn?: () => void;
+ onHoverOut?: () => void;
+};
+
export type Props = {
/**
- * Tooltip reference element. Needs to be able to hold a ref.
+ * 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: React.ReactElement;
+ children: (props: TooltipTriggerProps) => React.ReactElement;
/**
* The number of milliseconds a user must touch the element before showing the tooltip.
*/
@@ -48,6 +64,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';
@@ -55,7 +73,7 @@ export type Props = {
*
* const MyComponent = () => (
*
- * {}} />
+ * {(props) => {}} />}
*
* );
*
@@ -69,40 +87,21 @@ const Tooltip = ({
title,
theme: themeOverrides,
titleMaxFontSizeMultiplier,
- ...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, fadeStyle, 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(
- () => React.isValidElement(children),
- [children]
- );
+ const showTimer = React.useRef | null>(null);
+ const hideTimer = React.useRef | null>(null);
React.useEffect(() => {
return () => {
- if (showTooltipTimer.current.length) {
- showTooltipTimer.current.forEach((t) => clearTimeout(t));
- showTooltipTimer.current = [];
- }
-
- if (hideTooltipTimer.current.length) {
- hideTooltipTimer.current.forEach((t) => clearTimeout(t));
- hideTooltipTimer.current = [];
- }
+ if (showTimer.current) clearTimeout(showTimer.current);
+ if (hideTimer.current) clearTimeout(hideTimer.current);
};
}, []);
@@ -115,102 +114,49 @@ const Tooltip = ({
}, []);
const handleTouchStart = React.useCallback(() => {
- if (hideTooltipTimer.current.length) {
- hideTooltipTimer.current.forEach((t) => clearTimeout(t));
- hideTooltipTimer.current = [];
+ if (hideTimer.current) {
+ clearTimeout(hideTimer.current);
+ hideTimer.current = null;
}
- if (isWeb) {
- let id = setTimeout(() => {
- touched.current = true;
- setVisible(true);
- }, enterTouchDelay) as unknown as NodeJS.Timeout;
- showTooltipTimer.current.push(id);
+ if (Platform.OS === 'web') {
+ showTimer.current = setTimeout(() => setVisible(true), enterTouchDelay);
} else {
- touched.current = true;
setVisible(true);
}
- }, [isWeb, enterTouchDelay]);
+ }, [enterTouchDelay]);
const handleTouchEnd = React.useCallback(() => {
- touched.current = false;
- if (showTooltipTimer.current.length) {
- showTooltipTimer.current.forEach((t) => clearTimeout(t));
- showTooltipTimer.current = [];
+ if (showTimer.current) {
+ clearTimeout(showTimer.current);
+ showTimer.current = null;
}
-
- let id = setTimeout(() => {
- setVisible(false);
- setMeasurement({ children: {}, tooltip: {}, measured: false });
- }, leaveTouchDelay) as unknown as NodeJS.Timeout;
- hideTooltipTimer.current.push(id);
+ hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay);
}, [leaveTouchDelay]);
- const handlePress = React.useCallback(() => {
- if (touched.current) {
- return null;
- }
- if (!isValidChild) return null;
- const props = children.props as TooltipChildProps;
- if (props.disabled) return null;
- return props.onPress?.();
- }, [children.props, isValidChild]);
-
- const handleHoverIn = React.useCallback(() => {
- handleTouchStart();
- if (isValidChild) {
- (children.props as TooltipChildProps).onHoverIn?.();
- }
- }, [children.props, handleTouchStart, isValidChild]);
-
- const handleHoverOut = React.useCallback(() => {
- handleTouchEnd();
- if (isValidChild) {
- (children.props as TooltipChildProps).onHoverOut?.();
- }
- }, [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(),
- onPressOut: () => handleTouchEnd(),
- delayLongPress: enterTouchDelay,
- };
-
- const webPressProps = {
- onHoverIn: handleHoverIn,
- onHoverOut: handleHoverOut,
- };
+ const triggerProps: TooltipTriggerProps =
+ Platform.OS === 'web'
+ ? { onHoverIn: handleTouchStart, onHoverOut: handleTouchEnd }
+ : {
+ onLongPress: handleTouchStart,
+ onPressOut: handleTouchEnd,
+ delayLongPress: enterTouchDelay,
+ };
return (
<>
- {visible && (
+ {rendered && (
-
- ),
- borderRadius: theme.shapes.corner.extraSmall,
- ...(measurement.measured ? styles.visible : styles.hidden),
+ backgroundColor: theme.colors[Tokens.plain.container],
+ ...getTooltipPosition(measurement),
+ borderRadius: theme.shapes.corner[Tokens.plain.shape],
},
+ fadeStyle,
]}
testID="tooltip-container"
>
@@ -218,24 +164,22 @@ 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}
-
+
)}
- {React.cloneElement(children, {
- ...rest,
- ...(isWeb ? webPressProps : mobilePressProps),
- })}
+ {children(triggerProps)}
>
);
@@ -247,15 +191,9 @@ const styles = StyleSheet.create({
tooltip: {
alignSelf: 'flex-start',
justifyContent: 'center',
- paddingHorizontal: 16,
- height: 32,
- maxHeight: 32,
- },
- visible: {
- opacity: 1,
- },
- hidden: {
- opacity: 0,
+ paddingHorizontal: Tokens.plain.paddingHorizontal,
+ height: Tokens.plain.height,
+ maxHeight: Tokens.plain.height,
},
pressContainer: {
...(Platform.OS === 'web' && { cursor: 'default' }),
diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts
new file mode 100644
index 0000000000..ff696588e0
--- /dev/null
+++ b/src/components/Tooltip/hooks.ts
@@ -0,0 +1,111 @@
+import * as React from 'react';
+import { View } from 'react-native';
+import type { LayoutChangeEvent } from 'react-native';
+
+import { cubicBezier } from 'react-native-reanimated';
+
+import { Tokens } from './tokens';
+import type { Measurement } from './utils';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import type { InternalTheme } from '../../types';
+
+/**
+ * Drives the show/hide fade shared by both tooltip variants.
+ *
+ * Given a `visible` intent it keeps the tooltip mounted (`rendered`) through
+ * the exit fade so the animation can play before unmounting, holds the opacity
+ * at 0 until the tooltip has been measured (so it never flashes at the wrong
+ * position), and honors the reduce-motion preference. The fade itself is a
+ * Reanimated CSS transition on `opacity`; the unmount is deferred by the exit
+ * duration via a timer, which keeps the behavior deterministic and testable.
+ */
+export const useTooltipFade = (theme: InternalTheme, visible: boolean) => {
+ const reduceMotion = useReduceMotion();
+ const [rendered, setRendered] = React.useState(false);
+ const [measurement, setMeasurement] = React.useState({
+ children: { pageX: 0, pageY: 0, width: 0, height: 0 },
+ tooltip: { x: 0, y: 0, width: 0, height: 0 },
+ measured: false,
+ });
+ const childrenWrapperRef = React.useRef(null);
+ // The trigger is measured synchronously and stashed here so the tooltip's
+ // own layout can combine the two into the final measurement in one update.
+ const childrenMeasurement = React.useRef(
+ null
+ );
+
+ const enterDuration = reduceMotion
+ ? 0
+ : theme.motion.duration[Tokens.motion.enter.duration];
+ const exitDuration = reduceMotion
+ ? 0
+ : theme.motion.duration[Tokens.motion.exit.duration];
+
+ // Mount as soon as the tooltip is requested — derived during render rather
+ // than synced from an effect.
+ if (visible && !rendered) {
+ setRendered(true);
+ }
+
+ // Measure the trigger synchronously once the tooltip is requested, instead
+ // of waiting for the tooltip's `onLayout` to do it. (The tooltip itself
+ // lives in a `Portal`, so its own size still comes from its layout below.)
+ React.useLayoutEffect(() => {
+ if (!rendered || !visible) {
+ return;
+ }
+
+ childrenWrapperRef.current?.measure(
+ (_x, _y, width, height, pageX, pageY) => {
+ childrenMeasurement.current = { pageX, pageY, width, height };
+ }
+ );
+ }, [rendered, visible]);
+
+ // Keep the tooltip mounted through the exit fade, then unmount.
+ React.useEffect(() => {
+ if (!rendered || visible) {
+ return;
+ }
+
+ const id = setTimeout(() => {
+ setRendered(false);
+ setMeasurement({
+ children: { pageX: 0, pageY: 0, width: 0, height: 0 },
+ tooltip: { x: 0, y: 0, width: 0, height: 0 },
+ measured: false,
+ });
+ childrenMeasurement.current = null;
+ }, exitDuration);
+
+ return () => clearTimeout(id);
+ }, [rendered, visible, exitDuration]);
+
+ // The tooltip reports its own size on layout; combine it with the trigger
+ // measurement captured above to compute the final position in one update.
+ const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
+ if (!childrenMeasurement.current) {
+ return;
+ }
+
+ setMeasurement({
+ children: childrenMeasurement.current,
+ tooltip: layout,
+ measured: true,
+ });
+ };
+
+ // A Reanimated CSS transition drives the fade — no shared values. Opacity is
+ // held at 0 until the tooltip has been measured so it never flashes at the
+ // wrong position; entering decelerates in, exiting accelerates out.
+ const fadeStyle = {
+ opacity: visible && measurement.measured ? 1 : 0,
+ transitionProperty: 'opacity',
+ transitionDuration: `${visible ? enterDuration : exitDuration}ms`,
+ transitionTimingFunction: visible
+ ? cubicBezier(...theme.motion.easing[Tokens.motion.enter.easing])
+ : cubicBezier(...theme.motion.easing[Tokens.motion.exit.easing]),
+ };
+
+ return { rendered, measurement, fadeStyle, 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 75b4a18cf7..c1e91bf52a 100644
--- a/src/components/__tests__/Tooltip.test.tsx
+++ b/src/components/__tests__/Tooltip.test.tsx
@@ -6,6 +6,7 @@ import {
afterAll,
afterEach,
beforeAll,
+ beforeEach,
describe,
expect,
it,
@@ -14,8 +15,10 @@ import {
import { act, fireEvent, userEvent } from '@testing-library/react-native';
import PaperProvider from '../../core/PaperProvider';
+import { getTheme } from '../../core/theming';
import { render } from '../../test-utils';
-import Tooltip from '../Tooltip/Tooltip';
+import TooltipCompound from '../Tooltip';
+import Tooltip, { type TooltipTriggerProps } from '../Tooltip/Tooltip';
const mockedRemoveEventListener = jest.fn();
@@ -28,10 +31,11 @@ jest.mock('../../utils/addEventListener', () => ({
const DummyComponent = ({
ref,
...props
-}: ViewProps & {
- ref?: React.RefObject;
-}) => (
-
+}: ViewProps &
+ TooltipTriggerProps & {
+ ref?: React.RefObject;
+ }) => (
+
dummy component
);
@@ -51,13 +55,12 @@ describe('Tooltip', () => {
return trigger;
};
- const runTimers = async (ms?: number) => {
- await act(() => {
- if (ms === undefined) {
- jest.runOnlyPendingTimers();
- } else {
- jest.advanceTimersByTime(ms);
- }
+ // Advancing async lets the timer callbacks' state updates flush and re-render
+ // (a sync `act` doesn't under the async renderer). Default to a large step
+ // that drains every pending tooltip timer.
+ const runTimers = async (ms = 1000) => {
+ await act(async () => {
+ await jest.advanceTimersByTimeAsync(ms);
});
};
@@ -66,7 +69,7 @@ describe('Tooltip', () => {
measure = {}
) => {
const defaultProps = {
- children: ,
+ children: (props: TooltipTriggerProps) => ,
title: 'some tooltip text',
...propOverrides,
};
@@ -94,6 +97,14 @@ describe('Tooltip', () => {
return { wrapper };
};
+ // `userEvent.setup()` coordinates the press gestures with the fake timers so
+ // its `act()` scopes don't overlap the tooltip's own timer-driven updates
+ // (overlapping act() calls corrupt the renderer across tests).
+ let user: ReturnType;
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
+
describe('Mobile', () => {
beforeAll(() => {
Platform.OS = 'android';
@@ -126,7 +137,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText, unmount },
} = await setup();
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
await findByText('some tooltip text');
@@ -152,8 +163,8 @@ describe('Tooltip', () => {
} = await setup();
const trigger = getTrigger(getByText);
- await userEvent.longPress(trigger);
- await userEvent.longPress(trigger);
+ await user.longPress(trigger);
+ await user.longPress(trigger);
expect(global.clearTimeout).toHaveBeenCalledTimes(1);
});
@@ -165,16 +176,60 @@ describe('Tooltip', () => {
wrapper: { queryByText, getByText, findByText },
} = await setup({ enterTouchDelay: 50, leaveTouchDelay: 0 });
- await userEvent.longPress(getTrigger(getByText));
+ // `longPress` includes the release (pressOut), which schedules the hide.
+ await user.longPress(getTrigger(getByText));
await findByText('some tooltip text');
- await runTimers();
+ await runTimers(); // leaveTouchDelay + exit fade duration → unmounts
expect(queryByText('some tooltip text')).not.toBeOnTheScreen();
});
});
+ describe('MD3 styling', () => {
+ it('renders an inverseSurface container with inverseOnSurface text', async () => {
+ const {
+ wrapper: { getByText, getByTestId, findByText },
+ } = await setup();
+
+ await user.longPress(getTrigger(getByText));
+
+ await findByText('some tooltip text');
+
+ expect(getByTestId('tooltip-container')).toHaveStyle({
+ backgroundColor: getTheme().colors.inverseSurface,
+ });
+
+ // bodySmall (12sp) text in the inverseOnSurface role.
+ expect(getByText('some tooltip text')).toHaveStyle({
+ color: getTheme().colors.inverseOnSurface,
+ fontSize: 12,
+ });
+ });
+ });
+
+ describe('fade animation', () => {
+ it('stays mounted through the exit fade before unmounting', async () => {
+ const {
+ wrapper: { queryByText, getByText, findByText },
+ } = await setup({ leaveTouchDelay: 0 });
+
+ // `longPress` includes the release (pressOut), which schedules the hide.
+ await user.longPress(getTrigger(getByText));
+
+ await findByText('some tooltip text');
+
+ await runTimers(0); // leaveTouchDelay (0) elapses → exit fade starts
+
+ // Still mounted while fading out so the animation can play.
+ expect(getByText('some tooltip text')).toBeTruthy();
+
+ await runTimers(); // exit fade duration elapses → unmounts
+ expect(queryByText('some tooltip text')).toBeNull();
+ });
+ });
+
describe('Tooltip position', () => {
const LAYOUT_WIDTH = 360;
const LAYOUT_HEIGHT = 705;
@@ -196,7 +251,7 @@ describe('Tooltip', () => {
wrapper: { getByText, getByTestId, findByText },
} = await setup();
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
await fireEvent(await findByText('some tooltip text'), 'layout', {
nativeEvent: {
@@ -217,7 +272,7 @@ describe('Tooltip', () => {
wrapper: { getByText, getByTestId, findByText },
} = await setup({}, { pageX: 0 }); // Component starting at the starting 0 X coord
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
await fireEvent(await findByText('some tooltip text'), 'layout', {
nativeEvent: {
@@ -238,7 +293,7 @@ describe('Tooltip', () => {
wrapper: { getByText, getByTestId, findByText },
} = await setup({}, { pageX: 900, width: 150 }); // Component close to the screen limit
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
await fireEvent(await findByText('some tooltip text'), 'layout', {
nativeEvent: {
@@ -259,7 +314,7 @@ describe('Tooltip', () => {
wrapper: { getByText, getByTestId, findByText },
} = await setup({}, { pageY: 600, height: 50 });
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
await fireEvent(await findByText('some tooltip text'), 'layout', {
nativeEvent: {
@@ -363,8 +418,13 @@ describe('Tooltip', () => {
await findByText('some tooltip text');
- await fireEvent(getTrigger(getByText), 'hoverOut');
- await runTimers();
+ // Settle the hover-out in its own act() so its state update can't
+ // escape act and corrupt the renderer, then drain the fade-out timers.
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'hoverOut');
+ });
+ await runTimers(); // leaveTouchDelay → schedules the exit fade
+ await runTimers(); // exit fade duration → unmounts
expect(queryByText('some tooltip text')).not.toBeOnTheScreen();
});
@@ -475,3 +535,226 @@ describe('Tooltip', () => {
});
});
});
+
+describe('Tooltip.Rich', () => {
+ const getTrigger = (
+ getByText: Awaited>['getByText']
+ ) => getByText('dummy component').parent!;
+
+ const runTimers = async (ms = 1000) => {
+ await act(async () => {
+ await jest.advanceTimersByTimeAsync(ms);
+ });
+ };
+
+ let user: ReturnType;
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
+
+ const setup = async (
+ propOverrides?: Partial>
+ ) => {
+ jest
+ .spyOn(View.prototype, 'measure')
+ .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200));
+
+ const wrapper = await 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', async () => {
+ const {
+ wrapper: { getByText, getByTestId, queryByText },
+ } = await setup({
+ title: 'Heading',
+ actions: () => Learn more,
+ });
+
+ expect(queryByText('Body text')).toBeNull();
+
+ await user.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.
+ await user.press(getTrigger(getByText));
+ await runTimers(); // exit fade → unmount
+
+ expect(queryByText('Body text')).toBeNull();
+ });
+
+ it('renders a custom element as content', async () => {
+ const {
+ wrapper: { getByText },
+ } = await setup({ content: Custom node });
+
+ await user.press(getTrigger(getByText));
+
+ expect(getByText('Custom node')).toBeTruthy();
+ });
+
+ it('uses the surfaceContainer container with MD3 title/content roles', async () => {
+ const {
+ wrapper: { getByText, getByTestId },
+ } = await setup({ title: 'Heading' });
+
+ await user.press(getTrigger(getByText));
+
+ expect(getByText('Heading')).toHaveStyle({
+ color: getTheme().colors.onSurface,
+ });
+ expect(getByText('Body text')).toHaveStyle({
+ color: getTheme().colors.onSurfaceVariant,
+ });
+
+ // Surface (container) uses the surfaceContainer color.
+ expect(getByTestId('tooltip-rich-surface-container')).toHaveStyle({
+ backgroundColor: getTheme().colors.surfaceContainer,
+ });
+ });
+
+ it('dismisses when the backdrop is pressed', async () => {
+ const {
+ wrapper: { getByText, getByTestId, queryByText },
+ } = await setup();
+
+ await user.press(getTrigger(getByText));
+ expect(getByText('Body text')).toBeTruthy();
+
+ await user.press(getByTestId('tooltip-rich-backdrop'));
+ await runTimers(); // exit fade → unmount
+
+ expect(queryByText('Body text')).toBeNull();
+ });
+
+ it('dismisses when an action calls dismiss', async () => {
+ const {
+ wrapper: { getByText, queryByText },
+ } = await setup({
+ actions: ({ dismiss }) => Learn more,
+ });
+
+ await user.press(getTrigger(getByText));
+ expect(getByText('Body text')).toBeTruthy();
+
+ await user.press(getByText('Learn more'));
+ await 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', async () => {
+ const {
+ wrapper: { getByText, queryByText },
+ } = await setup({ enterTouchDelay: 100 });
+
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'hoverIn');
+ });
+ expect(queryByText('Body text')).toBeNull(); // still within the delay
+
+ await runTimers(100);
+
+ expect(getByText('Body text')).toBeTruthy();
+ });
+
+ it('opens on keyboard focus and hides on blur', async () => {
+ const {
+ wrapper: { getByText, queryByText },
+ } = await setup({ leaveTouchDelay: 500 });
+
+ // Focus shows the tooltip synchronously, so settle it in act() before
+ // asserting (and so its update can't escape act and corrupt the renderer).
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'focus');
+ });
+ expect(getByText('Body text')).toBeTruthy();
+
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'blur');
+ });
+ await runTimers(500); // leave delay → hide intent
+ await runTimers(); // exit fade → unmount
+
+ expect(queryByText('Body text')).toBeNull();
+ });
+
+ it('keeps the tooltip open while the pointer moves into it (gap bridge)', async () => {
+ const {
+ wrapper: { getByText, getByTestId },
+ } = await setup({ enterTouchDelay: 0, leaveTouchDelay: 500 });
+
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'hoverIn');
+ });
+ await runTimers(0);
+ expect(getByText('Body text')).toBeTruthy();
+
+ // Leaving the trigger schedules a hide...
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'hoverOut');
+ // ...but entering the tooltip cancels it.
+ await fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn');
+ });
+ await runTimers(500);
+
+ expect(getByText('Body text')).toBeTruthy();
+ });
+
+ it('opens on hover even when the trigger ignores the hover props', async () => {
+ // 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 } = await render(
+
+
+ {() => }
+
+
+ );
+
+ await fireEvent(getByTestId('tooltip-rich-trigger'), 'hoverIn');
+ expect(queryByText('Body text')).toBeNull(); // within the enter delay
+
+ await 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';