diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx
index 56bd19f194..201c15de11 100644
--- a/docs/src/components/BannerExample.tsx
+++ b/docs/src/components/BannerExample.tsx
@@ -6,8 +6,8 @@ import { BrowserOnly } from '@rspress/core/runtime';
import {
Avatar,
Button,
- DarkTheme,
FAB,
+ DarkTheme,
LightTheme,
ProgressBar,
Provider,
diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx
index 09ce39aa14..04b8d2d140 100644
--- a/example/src/Examples/FABExample.tsx
+++ b/example/src/Examples/FABExample.tsx
@@ -1,17 +1,24 @@
import * as React from 'react';
-import { FlatList, ScrollView, StyleSheet, View } from 'react-native';
-import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
+import {
+ FlatList,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ ScrollView,
+ StyleSheet,
+ View,
+} from 'react-native';
import {
Chip,
Divider,
FAB,
+ FABSize,
+ FABVariant,
List,
Switch,
Text,
useTheme,
} from 'react-native-paper';
-import type { FABSize, FABVariant } from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type FabType = 'icon' | 'extended' | 'extendedTransforming' | 'menu';
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/package.json b/package.json
index 3a0296b5f5..c3111e4f76 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"@release-it/conventional-changelog": "^1.1.0",
"@testing-library/react-native": "^14.0.0",
"@types/color": "^3.0.0",
+ "@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/react": "^19.2.7",
"all-contributors-cli": "^6.24.0",
diff --git a/src/components/FAB/Content.tsx b/src/components/FAB/Content.tsx
index 0e5728c2e3..8c5b1fa7c2 100644
--- a/src/components/FAB/Content.tsx
+++ b/src/components/FAB/Content.tsx
@@ -1,12 +1,15 @@
-import { StyleSheet, View } from 'react-native';
-import type { ColorValue, StyleProp, ViewStyle } from 'react-native';
+import {
+ ColorValue,
+ StyleProp,
+ StyleSheet,
+ View,
+ ViewStyle,
+} from 'react-native';
-import Reanimated from 'react-native-reanimated';
-import type { AnimatedStyle } from 'react-native-reanimated';
+import Reanimated, { AnimatedStyle } from 'react-native-reanimated';
import type { TypescaleKey } from '../../theme/types';
-import Icon from '../Icon';
-import type { IconSource } from '../Icon';
+import Icon, { IconSource } from '../Icon';
import AnimatedText from '../Typography/AnimatedText';
export type ContentProps = {
diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx
index 06a67cab1f..897fad77bc 100644
--- a/src/components/FAB/Extended.tsx
+++ b/src/components/FAB/Extended.tsx
@@ -1,10 +1,11 @@
import * as React from 'react';
-import { StyleSheet, View } from 'react-native';
-import type {
+import {
ColorValue,
GestureResponderEvent,
PressableAndroidRippleConfig,
StyleProp,
+ StyleSheet,
+ View,
ViewStyle,
} from 'react-native';
@@ -18,7 +19,7 @@ import Reanimated, {
import { scheduleOnUI } from 'react-native-worklets';
import Shell from './Shell';
-import type { Size, Variant } from './tokens';
+import { Size, Variant } from './tokens';
import { getDimensions } from './utils';
import { useInternalTheme } from '../../core/theming';
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
@@ -110,7 +111,10 @@ export type Props = {
* @optional
*/
theme?: ThemeProp;
- ref?: React.Ref;
+ /**
+ * @optional
+ */
+ ref?: React.RefObject;
};
/**
diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx
index fa4de1288c..b425662c2a 100644
--- a/src/components/FAB/FAB.tsx
+++ b/src/components/FAB/FAB.tsx
@@ -1,15 +1,15 @@
import * as React from 'react';
-import { View } from 'react-native';
-import type {
+import {
ColorValue,
GestureResponderEvent,
PressableAndroidRippleConfig,
StyleProp,
+ View,
ViewStyle,
} from 'react-native';
import Shell from './Shell';
-import type { Size, Variant } from './tokens';
+import { Size, Variant } from './tokens';
import type { ThemeProp } from '../../types';
import type { IconSource } from '../Icon';
@@ -43,6 +43,26 @@ export type Props = {
* Function to execute on press.
*/
onPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute on long press.
+ */
+ onLongPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute when a touch is released.
+ */
+ onPressOut?: (e: GestureResponderEvent) => void;
+ /**
+ * The number of milliseconds a user must touch the element before executing `onLongPress`.
+ */
+ delayLongPress?: number;
+ /**
+ * Called when the pointer enters the element (web only).
+ */
+ onHoverIn?: () => void;
+ /**
+ * Called when the pointer leaves the element (web only).
+ */
+ onHoverOut?: () => void;
/**
* Accessibility label. Falls back to nothing if unset.
*/
@@ -82,7 +102,7 @@ export type Props = {
* @optional
*/
theme?: ThemeProp;
- ref?: React.Ref;
+ ref?: React.RefObject;
};
/**
@@ -120,6 +140,11 @@ const FAB = ({
size = 'default',
visible = true,
onPress,
+ onLongPress,
+ onPressOut,
+ delayLongPress,
+ onHoverIn,
+ onHoverOut,
containerColor,
contentColor,
'aria-label': ariaLabel,
@@ -140,6 +165,11 @@ const FAB = ({
size={size}
visible={visible}
onPress={onPress}
+ onLongPress={onLongPress}
+ onPressOut={onPressOut}
+ delayLongPress={delayLongPress}
+ onHoverIn={onHoverIn}
+ onHoverOut={onHoverOut}
containerColor={containerColor}
contentColor={contentColor}
aria-label={ariaLabel}
diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx
index 58a75291cf..cbbe30ea50 100644
--- a/src/components/FAB/Menu.tsx
+++ b/src/components/FAB/Menu.tsx
@@ -1,6 +1,11 @@
import * as React from 'react';
-import { Platform, StyleSheet, View } from 'react-native';
-import type { ColorValue, GestureResponderEvent } from 'react-native';
+import {
+ ColorValue,
+ GestureResponderEvent,
+ Platform,
+ StyleSheet,
+ View,
+} from 'react-native';
import Animated, {
interpolate,
@@ -16,12 +21,13 @@ import Content from './Content';
import Shell from './Shell';
import {
MenuTokens,
+ Size,
Tokens,
+ Variant,
FOCUS_RING_INSET,
FOCUS_RING_THICKNESS,
webNoOutline,
} from './tokens';
-import type { Size, Variant } from './tokens';
import { useFocusRing } from './useFocusRing';
import { resolveColors } from './utils';
import { useLocale } from '../../core/locale';
@@ -30,8 +36,7 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import { toRawSpring } from '../../theme/tokens/sys/motion';
import { resolveCornerRadius } from '../../theme/utils/shape';
import type { InternalTheme, ThemeProp } from '../../types';
-import Icon from '../Icon';
-import type { IconSource } from '../Icon';
+import Icon, { IconSource } from '../Icon';
import TouchableRipple from '../TouchableRipple/TouchableRipple';
export type MenuItemProps = {
@@ -67,9 +72,6 @@ export type MenuTriggerProps = {
contentColor?: ColorValue;
visible?: boolean;
onPress?: (e: GestureResponderEvent) => void;
- /**
- * Accessibility label for the trigger FAB.
- */
'aria-label'?: string;
testID?: string;
};
@@ -222,9 +224,6 @@ type ItemProps = {
variant: Variant;
theme: InternalTheme;
onPress: (e: GestureResponderEvent) => void;
- /**
- * Accessibility label. Falls back to `label`.
- */
'aria-label'?: string;
testID?: string;
};
@@ -315,9 +314,6 @@ type MorphingTriggerProps = {
visible: boolean;
alignment: 'start' | 'center' | 'end';
onPress?: (e: GestureResponderEvent) => void;
- /**
- * Accessibility label for the trigger button.
- */
'aria-label'?: string;
theme: InternalTheme;
testID?: string;
diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx
index 2d2d60e4cd..d996331795 100644
--- a/src/components/FAB/Shell.tsx
+++ b/src/components/FAB/Shell.tsx
@@ -1,28 +1,31 @@
import * as React from 'react';
-import { Platform, StyleSheet, View } from 'react-native';
-import type {
+import {
ColorValue,
GestureResponderEvent,
+ Platform,
PressableAndroidRippleConfig,
StyleProp,
+ StyleSheet,
+ View,
ViewStyle,
} from 'react-native';
import Reanimated, {
+ AnimatedStyle,
useAnimatedStyle,
useSharedValue,
+ type SharedValue,
} from 'react-native-reanimated';
-import type { SharedValue } from 'react-native-reanimated';
-import type { AnimatedStyle } from 'react-native-reanimated';
import Content from './Content';
import {
+ Size,
Tokens,
+ Variant,
FOCUS_RING_INSET,
FOCUS_RING_THICKNESS,
webNoOutline,
} from './tokens';
-import type { Size, Variant } from './tokens';
import { useFocusRing } from './useFocusRing';
import { useVisibility } from './useVisibility';
import { getDimensions, resolveColors } from './utils';
@@ -88,6 +91,26 @@ export type ShellProps = {
* Function to execute on press.
*/
onPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute on long press.
+ */
+ onLongPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute when a touch is released.
+ */
+ onPressOut?: (e: GestureResponderEvent) => void;
+ /**
+ * The number of milliseconds a user must touch the element before executing `onLongPress`.
+ */
+ delayLongPress?: number;
+ /**
+ * Called when the pointer enters the element (web only).
+ */
+ onHoverIn?: () => void;
+ /**
+ * Called when the pointer leaves the element (web only).
+ */
+ onHoverOut?: () => void;
/**
* Accessibility label. Falls back to `label` if unset.
*/
@@ -194,6 +217,11 @@ const Shell = ({
elevation = Tokens.stateElevation.enabled,
visible = true,
onPress,
+ onLongPress,
+ onPressOut,
+ delayLongPress,
+ onHoverIn,
+ onHoverOut,
'aria-label': ariaLabel = label,
'aria-checked': ariaChecked,
'aria-selected': ariaSelected,
@@ -303,6 +331,11 @@ const Shell = ({
borderless
background={background}
onPress={onPress}
+ onLongPress={onLongPress}
+ onPressOut={onPressOut}
+ delayLongPress={delayLongPress}
+ onHoverIn={onHoverIn}
+ onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}
aria-label={ariaLabel}
diff --git a/src/components/FAB/utils.ts b/src/components/FAB/utils.ts
index 75b7ddb940..8e7c83eddb 100644
--- a/src/components/FAB/utils.ts
+++ b/src/components/FAB/utils.ts
@@ -1,11 +1,9 @@
-import type { ColorValue } from 'react-native';
+import { ColorValue } from 'react-native';
-import { Tokens } from './tokens';
-import type { Size, Variant } from './tokens';
+import { Size, Tokens, Variant } from './tokens';
import type { TypescaleKey } from '../../theme/types';
import { contentColorFor } from '../../theme/utils/color';
-import { resolveCornerRadius } from '../../theme/utils/shape';
-import type { ShapeToken } from '../../theme/utils/shape';
+import { resolveCornerRadius, ShapeToken } from '../../theme/utils/shape';
import type { InternalTheme } from '../../types';
export type ResolvedColors = {
diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx
new file mode 100644
index 0000000000..93b532933c
--- /dev/null
+++ b/src/components/Tooltip/RichTooltip.tsx
@@ -0,0 +1,435 @@
+import * as React from 'react';
+import {
+ Dimensions,
+ View,
+ StyleSheet,
+ Platform,
+ Pressable,
+} from 'react-native';
+import type { PointerEvent, ViewStyle } from 'react-native';
+
+import Animated from 'react-native-reanimated';
+
+import {
+ takeSingletonSlot,
+ useTooltipFade,
+ registerRichTrigger,
+ unregisterRichTrigger,
+ forwardPressToTriggerAt,
+ subscribeToTriggerRefresh,
+} 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,
+ enterDuration,
+ } = useTooltipFade(theme, visible);
+
+ // Android: elevation shadows don't participate in opacity compositing.
+ // Keep elevation at 0 during the enter fade so there's no grey-border
+ // artifact, then add it exactly when the content reaches full opacity.
+ const [elevationReady, setElevationReady] = React.useState(false);
+ React.useEffect(() => {
+ if (Platform.OS !== 'android') {
+ setElevationReady(true);
+ return;
+ }
+ if (visible && measurement.measured) {
+ const id = setTimeout(() => setElevationReady(true), enterDuration);
+ return () => clearTimeout(id);
+ }
+ setElevationReady(false);
+ return undefined;
+ }, [visible, measurement.measured, enterDuration]);
+
+ 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(() => {
+ takeSingletonSlot(() => setVisible(false));
+ clearHideTimer();
+ setVisible(true);
+ }, [clearHideTimer]);
+
+ const hide = React.useCallback(() => {
+ clearShowTimer();
+ setVisible(false);
+ }, [clearShowTimer]);
+
+ const scheduleHide = React.useCallback(() => {
+ clearShowTimer();
+ if (hideTimer.current) {
+ clearTimeout(hideTimer.current);
+ }
+ hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay);
+ }, [clearShowTimer, leaveTouchDelay]);
+
+ // Mobile: a tap toggles the tooltip.
+ // takeSingletonSlot must be called outside the setVisible updater — calling
+ // setVisible inside an updater queues it for a later render, so the dismiss
+ // of the stale slot would undo the show on the same cycle.
+ const handlePress = React.useCallback(() => {
+ if (!visible) {
+ takeSingletonSlot(() => setVisible(false));
+ setVisible(true);
+ } else {
+ setVisible(false);
+ }
+ clearShowTimer();
+ clearHideTimer();
+ }, [visible, clearShowTimer, clearHideTimer]);
+
+ // Web: open on hover (with a short enter delay) and on keyboard focus.
+ const handleHoverIn = React.useCallback(() => {
+ clearHideTimer();
+ showTimer.current = setTimeout(() => {
+ takeSingletonSlot(() => setVisible(false));
+ setVisible(true);
+ }, enterTouchDelay);
+ }, [clearHideTimer, enterTouchDelay]);
+
+ // On web, pointer events on the wrapper View handle hover without going
+ // through RNW's Pressable lock/unlock mechanism that caused flicker.
+ // Guard against spurious pointerleave: only schedule hide when cursor has
+ // actually left wrapper bounds (same reasoning as Tooltip.tsx).
+ const handlePointerLeave = React.useCallback(
+ (e?: PointerEvent) => {
+ if (Platform.OS === 'web' && e?.nativeEvent) {
+ const el = childrenWrapperRef.current;
+ if (el?.getBoundingClientRect) {
+ const { clientX, clientY } = e.nativeEvent;
+ const rect = el.getBoundingClientRect();
+ if (
+ (rect.width > 0 || rect.height > 0) &&
+ clientX >= rect.left &&
+ clientX <= rect.right &&
+ clientY >= rect.top &&
+ clientY <= rect.bottom
+ ) {
+ return;
+ }
+ }
+ }
+ scheduleHide();
+ },
+ [scheduleHide, childrenWrapperRef]
+ );
+
+ const wrapperPointerProps =
+ Platform.OS === 'web'
+ ? { onPointerEnter: handleHoverIn, onPointerLeave: handlePointerLeave }
+ : {};
+
+ const triggerProps: TooltipRichTriggerProps =
+ Platform.OS === 'web'
+ ? { onPress: handlePress, 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 }
+ : {};
+
+ // Mobile: stable id + ref for the latest handlePress, used by the global
+ // trigger registry so the backdrop can forward taps to another trigger.
+ const triggerId = React.useRef(Symbol()).current;
+ const handlePressRef = React.useRef(handlePress);
+ React.useEffect(() => {
+ handlePressRef.current = handlePress;
+ }, [handlePress]);
+
+ const updateTriggerRegistration = React.useCallback(() => {
+ if (Platform.OS === 'web') return;
+ childrenWrapperRef.current?.measureInWindow?.((x, y, width, height) => {
+ registerRichTrigger(triggerId, {
+ pageX: x,
+ pageY: y,
+ width,
+ height,
+ onPress: () => handlePressRef.current(),
+ });
+ });
+ }, [childrenWrapperRef, triggerId]);
+
+ React.useEffect(() => {
+ return () => {
+ unregisterRichTrigger(triggerId);
+ };
+ }, [triggerId]);
+
+ // Re-measure this trigger whenever any tooltip opens so scroll-invalidated
+ // coordinates are refreshed before the backdrop needs to hit-test against them.
+ React.useEffect(() => {
+ return subscribeToTriggerRefresh(updateTriggerRegistration);
+ }, [updateTriggerRegistration]);
+
+ const handleBackdropPress = React.useCallback(
+ (e: { nativeEvent: { pageX?: number; pageY?: number } }) => {
+ const pageX = e?.nativeEvent?.pageX ?? -1;
+ const pageY = e?.nativeEvent?.pageY ?? -1;
+ if (!forwardPressToTriggerAt(pageX, pageY)) {
+ hide();
+ }
+ },
+ [hide]
+ );
+
+ 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 8d237d6991..32c1b2341b 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, View } from 'react-native';
+import type { PointerEvent, ViewStyle } from 'react-native';
+import Animated from 'react-native-reanimated';
+
+import { takeSingletonSlot, 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,90 @@ 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;
+ if (Platform.OS === 'web') {
+ showTimer.current = setTimeout(() => {
+ takeSingletonSlot(() => setVisible(false));
setVisible(true);
- }, enterTouchDelay) as unknown as NodeJS.Timeout;
- showTooltipTimer.current.push(id);
+ }, enterTouchDelay);
} else {
- touched.current = true;
+ takeSingletonSlot(() => setVisible(false));
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);
- }, [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?.();
+ if (hideTimer.current) {
+ clearTimeout(hideTimer.current);
}
- }, [children.props, handleTouchStart, isValidChild]);
-
- const handleHoverOut = React.useCallback(() => {
- handleTouchEnd();
- if (isValidChild) {
- (children.props as TooltipChildProps).onHoverOut?.();
- }
- }, [children.props, handleTouchEnd, isValidChild]);
+ hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay);
+ }, [leaveTouchDelay]);
- const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
- childrenWrapperRef.current?.measure(
- (_x, _y, width, height, pageX, pageY) => {
- setMeasurement({
- children: { pageX, pageY, height, width },
- tooltip: { ...layout },
- measured: true,
- });
+ // On web, pointer events on the wrapper View handle hover without going
+ // through RNW's Pressable lock/unlock mechanism that caused flicker.
+ // Long-press in triggerProps lets touchscreen-web users still trigger it.
+ //
+ // Guard against spurious pointerleave events (e.g. fired when the tooltip
+ // renders in the Portal): only start the hide timer when the cursor has
+ // actually left the wrapper bounds. In JSDOM getBoundingClientRect() returns
+ // a zero-size rect, so the check is skipped and tests continue to pass.
+ const handlePointerLeave = React.useCallback(
+ (e?: PointerEvent) => {
+ if (Platform.OS === 'web' && e?.nativeEvent) {
+ const el = childrenWrapperRef.current;
+ if (el?.getBoundingClientRect) {
+ const { clientX, clientY } = e.nativeEvent;
+ const rect = el.getBoundingClientRect();
+ if (
+ (rect.width > 0 || rect.height > 0) &&
+ clientX >= rect.left &&
+ clientX <= rect.right &&
+ clientY >= rect.top &&
+ clientY <= rect.bottom
+ ) {
+ return;
+ }
+ }
}
- );
- };
+ handleTouchEnd();
+ },
+ [handleTouchEnd, childrenWrapperRef]
+ );
- const mobilePressProps = {
- onPress: handlePress,
- onLongPress: () => handleTouchStart(),
- onPressOut: () => handleTouchEnd(),
- delayLongPress: enterTouchDelay,
- };
+ const wrapperPointerProps =
+ Platform.OS === 'web'
+ ? { onPointerEnter: handleTouchStart, onPointerLeave: handlePointerLeave }
+ : {};
- const webPressProps = {
- onHoverIn: handleHoverIn,
- onHoverOut: handleHoverOut,
+ const triggerProps: TooltipTriggerProps = {
+ 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,25 +205,24 @@ const Tooltip = ({
aria-live="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 +233,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..8c85599479
--- /dev/null
+++ b/src/components/Tooltip/hooks.ts
@@ -0,0 +1,215 @@
+import * as React from 'react';
+import { Platform, 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';
+
+// Ensures only one tooltip is visible at a time. When a tooltip calls
+// takeSingletonSlot it immediately hides the previous one.
+let dismissCurrentTooltip: (() => void) | null = null;
+
+// When any tooltip opens, trigger re-measurement of all registered triggers so
+// scroll-invalidated coordinates are refreshed before the backdrop is used.
+type RefreshCallback = () => void;
+const triggerRefreshCallbacks = new Set();
+
+export const subscribeToTriggerRefresh = (
+ cb: RefreshCallback
+): (() => void) => {
+ triggerRefreshCallbacks.add(cb);
+ return () => triggerRefreshCallbacks.delete(cb);
+};
+
+const refreshAllTriggers = () => {
+ triggerRefreshCallbacks.forEach((cb) => cb());
+};
+
+export const takeSingletonSlot = (dismiss: () => void) => {
+ dismissCurrentTooltip?.();
+ dismissCurrentTooltip = dismiss;
+ refreshAllTriggers();
+};
+
+// Mobile backdrop hit-forwarding: each RichTooltip registers its trigger's
+// screen rect so the backdrop can forward presses that land on another trigger
+// rather than consuming them. This allows one-tap switching between tooltips.
+type RichTriggerEntry = {
+ pageX: number;
+ pageY: number;
+ width: number;
+ height: number;
+ onPress: () => void;
+};
+const richTriggerRegistry = new Map();
+
+export const registerRichTrigger = (
+ id: symbol,
+ entry: RichTriggerEntry
+): void => {
+ richTriggerRegistry.set(id, entry);
+};
+
+export const unregisterRichTrigger = (id: symbol): void => {
+ richTriggerRegistry.delete(id);
+};
+
+// Returns true if a registered trigger was hit and pressed (caller should NOT
+// also call hide() in this case — the singleton dismisses the current tooltip).
+export const forwardPressToTriggerAt = (
+ pageX: number,
+ pageY: number
+): boolean => {
+ for (const entry of richTriggerRegistry.values()) {
+ if (
+ pageX >= entry.pageX &&
+ pageX <= entry.pageX + entry.width &&
+ pageY >= entry.pageY &&
+ pageY <= entry.pageY + entry.height
+ ) {
+ entry.onPress();
+ return true;
+ }
+ }
+ return false;
+};
+
+/**
+ * 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<
+ View & { getBoundingClientRect?(): DOMRect }
+ >(null);
+ // The trigger measurement and tooltip layout are each obtained via async
+ // native calls that can complete in either order. Both refs are written by
+ // whichever side fires first; the second side checks whether the other is
+ // already available and, if so, completes the combined measurement.
+ const childrenMeasurement = React.useRef(
+ null
+ );
+ const tooltipLayout = 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) => {
+ // On web, measure() returns viewport-relative coords but the Portal
+ // container is positioned at the document origin — add scroll offset.
+ const scrollX =
+ Platform.OS === 'web' ? ((window as Window).scrollX ?? 0) : 0;
+ const scrollY =
+ Platform.OS === 'web' ? ((window as Window).scrollY ?? 0) : 0;
+ childrenMeasurement.current = {
+ pageX: pageX + scrollX,
+ pageY: pageY + scrollY,
+ width,
+ height,
+ };
+ // If onLayout already fired before this callback, use the stashed
+ // tooltip layout to complete the measurement now.
+ if (tooltipLayout.current) {
+ setMeasurement({
+ children: childrenMeasurement.current,
+ tooltip: tooltipLayout.current,
+ measured: true,
+ });
+ }
+ }
+ );
+ }, [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;
+ tooltipLayout.current = null;
+ }, exitDuration);
+
+ return () => clearTimeout(id);
+ }, [rendered, visible, exitDuration]);
+
+ // The tooltip reports its own size on layout; combine it with the trigger
+ // measurement to compute the final position. Either side can arrive first:
+ // stash the layout and let the measure callback pick it up if it's late.
+ const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
+ tooltipLayout.current = layout;
+ 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,
+ enterDuration,
+ };
+};
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..e6aa4b8610 100644
--- a/src/components/Tooltip/utils.ts
+++ b/src/components/Tooltip/utils.ts
@@ -24,12 +24,14 @@ export type TooltipChildProps = {
onHoverOut?: () => void;
};
+const EDGE_MARGIN = 8;
+
/**
* Return true when the tooltip center x-coordinate relative to the wrapped element is negative.
* The tooltip will be placed at the starting x-coordinate from the wrapped element.
*/
const overflowLeft = (center: number): boolean => {
- return center < 0;
+ return center < EDGE_MARGIN;
};
/**
@@ -39,7 +41,7 @@ const overflowLeft = (center: number): boolean => {
const overflowRight = (center: number, tooltipWidth: number): boolean => {
const { width: layoutWidth } = Dimensions.get('window');
- return center + tooltipWidth > layoutWidth;
+ return center + tooltipWidth > layoutWidth - EDGE_MARGIN;
};
/**
@@ -67,10 +69,15 @@ const getTooltipXPosition = (
? childrenX + (childrenWidth - tooltipWidth) / 2
: childrenX;
- if (overflowLeft(center)) return childrenX;
+ if (overflowLeft(center)) return Math.max(EDGE_MARGIN, childrenX);
- if (overflowRight(center, tooltipWidth))
- return childrenX + childrenWidth - tooltipWidth;
+ if (overflowRight(center, tooltipWidth)) {
+ const { width: layoutWidth } = Dimensions.get('window');
+ return Math.min(
+ childrenX + childrenWidth - tooltipWidth,
+ layoutWidth - tooltipWidth - EDGE_MARGIN
+ );
+ }
return center;
};
@@ -119,14 +126,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__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx
index eedcb3e0f3..ffebf66ea5 100644
--- a/src/components/__tests__/FAB.test.tsx
+++ b/src/components/__tests__/FAB.test.tsx
@@ -1,7 +1,7 @@
import { expect, it, jest } from '@jest/globals';
-import { fireEvent, userEvent } from '@testing-library/react-native';
+import { fireEvent, screen, userEvent } from '@testing-library/react-native';
-import { render, screen } from '../../test-utils';
+import { render } from '../../test-utils';
import FAB from '../FAB';
it('renders FAB with default props', async () => {
@@ -38,13 +38,6 @@ it('renders FAB with tonalTertiary variant', async () => {
expect(tree).toMatchSnapshot();
});
-it('renders FAB with aria-label', async () => {
- const tree = (
- await render()
- ).toJSON();
- expect(tree).toMatchSnapshot();
-});
-
it('renders FAB medium size', async () => {
const tree = (await render()).toJSON();
expect(tree).toMatchSnapshot();
@@ -71,6 +64,13 @@ it('renders FAB with containerColor and contentColor overrides', async () => {
expect(tree).toMatchSnapshot();
});
+it('renders FAB with aria-label', async () => {
+ const tree = (
+ await render()
+ ).toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
it('renders FAB transitioning to not visible', async () => {
const { rerender, toJSON } = await render();
await rerender();
diff --git a/src/components/__tests__/FABExtended.test.tsx b/src/components/__tests__/FABExtended.test.tsx
index fd9a21cec9..fd88091282 100644
--- a/src/components/__tests__/FABExtended.test.tsx
+++ b/src/components/__tests__/FABExtended.test.tsx
@@ -1,7 +1,7 @@
import { expect, it, jest } from '@jest/globals';
-import { fireEvent, userEvent } from '@testing-library/react-native';
+import { fireEvent, screen, userEvent } from '@testing-library/react-native';
-import { render, screen } from '../../test-utils';
+import { render } from '../../test-utils';
import FAB from '../FAB';
it('renders extended FAB expanded', async () => {
@@ -58,16 +58,8 @@ it('renders extended FAB transitioning to collapsed', async () => {
});
it('uses label as default aria-label', async () => {
- await render(
-
- );
-
- expect(screen.getByLabelText('New message')).toBeOnTheScreen();
+ await render();
+ expect(screen.getByRole('button', { name: 'New message' })).toBeTruthy();
});
it('respects explicit aria-label', async () => {
@@ -77,24 +69,18 @@ it('respects explicit aria-label', async () => {
label="New message"
expanded
aria-label="Create new message"
- testID="extended-fab"
/>
);
-
- expect(screen.getByLabelText('Create new message')).toBeOnTheScreen();
+ expect(
+ screen.getByRole('button', { name: 'Create new message' })
+ ).toBeTruthy();
});
it('calls onPress when pressed', async () => {
const user = userEvent.setup();
const onPress = jest.fn();
await render(
-
+
);
await user.press(screen.getByRole('button', { name: 'New message' }));
expect(onPress).toHaveBeenCalledTimes(1);
@@ -103,13 +89,7 @@ it('calls onPress when pressed', async () => {
it('forwards event object to onPress', async () => {
const onPress = jest.fn();
await render(
-
+
);
await fireEvent(
screen.getByRole('button', { name: 'New message' }),
diff --git a/src/components/__tests__/FABMenu.test.tsx b/src/components/__tests__/FABMenu.test.tsx
index 50135634d7..91a621c0be 100644
--- a/src/components/__tests__/FABMenu.test.tsx
+++ b/src/components/__tests__/FABMenu.test.tsx
@@ -1,13 +1,15 @@
import { expect, it, jest } from '@jest/globals';
-import { fireEvent, userEvent } from '@testing-library/react-native';
+import { fireEvent, screen, userEvent } from '@testing-library/react-native';
-import { render, screen } from '../../test-utils';
+import { render } from '../../test-utils';
import FAB from '../FAB';
-import type { MenuItemProps } from '../FAB/Menu';
const makeItems = (
- onItemPress = jest.fn<(event?: unknown) => void>()
-): [MenuItemProps, MenuItemProps] => [
+ onItemPress = jest.fn()
+): [
+ { label: string; onPress: jest.Mock; testID: string },
+ { label: string; onPress: jest.Mock; testID: string },
+] => [
{ label: 'Send email', onPress: onItemPress, testID: 'item-0' },
{ label: 'Set reminder', onPress: onItemPress, testID: 'item-1' },
];
@@ -124,6 +126,7 @@ it('renders FAB.Menu not expanded when trigger is not visible', async () => {
});
it('calls item onPress when menu item is pressed', async () => {
+ const user = userEvent.setup();
const onItemPress = jest.fn();
await render(
{
items={makeItems(onItemPress)}
/>
);
- await userEvent.press(screen.getByTestId('item-0'));
+ await user.press(screen.getByTestId('item-0'));
expect(onItemPress).toHaveBeenCalledTimes(1);
});
@@ -152,6 +155,7 @@ it('forwards event object to item onPress', async () => {
});
it('calls onDismiss when menu item is pressed', async () => {
+ const user = userEvent.setup();
const onDismiss = jest.fn();
await render(
{
items={makeItems()}
/>
);
- await userEvent.press(screen.getByTestId('item-0'));
+ await user.press(screen.getByTestId('item-0'));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('calls trigger onPress when menu is closed', async () => {
+ const user = userEvent.setup();
const onTriggerPress = jest.fn();
await render(
{
/>
);
// Shell's TouchableRipple uses the default testID 'fab-shell'
- await userEvent.press(screen.getByTestId('fab-shell'));
+ await user.press(screen.getByTestId('fab-shell'));
expect(onTriggerPress).toHaveBeenCalledTimes(1);
});
it('calls onDismiss when trigger is pressed while menu is open', async () => {
+ const user = userEvent.setup();
const onDismiss = jest.fn();
await render(
{
items={makeItems()}
/>
);
- await userEvent.press(screen.getByTestId('fab-shell'));
+ await user.press(screen.getByTestId('fab-shell'));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx
index 75b4a18cf7..92d9c63d85 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: {
@@ -226,7 +281,7 @@ describe('Tooltip', () => {
});
expect(getByTestId('tooltip-container')).toHaveStyle({
- left: 0, // Tooltip renders starting from children's x coord
+ left: 8, // Math.max(EDGE_MARGIN=8, pageX=0)
top: 250,
});
});
@@ -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: {
@@ -247,7 +302,7 @@ describe('Tooltip', () => {
});
expect(getByTestId('tooltip-container')).toHaveStyle({
- left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
+ left: 252, // Math.min(950, LAYOUT_WIDTH(360) - TOOLTIP_WIDTH(100) - EDGE_MARGIN(8))
top: 250,
});
});
@@ -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: {
@@ -280,6 +335,12 @@ describe('Tooltip', () => {
beforeAll(() => {
Platform.OS = 'web';
});
+
+ // Hover is handled by onPointerEnter/onPointerLeave on the wrapper View.
+ const getWrapperTrigger = (
+ getByTestId: Awaited>['getByTestId']
+ ) => getByTestId('tooltip-trigger');
+
describe('Unmount', () => {
beforeAll(() => {
jest.spyOn(global, 'clearTimeout');
@@ -290,10 +351,10 @@ describe('Tooltip', () => {
it('removes showTooltipTimer when the component unmounts', async () => {
const {
- wrapper: { getByText, unmount },
+ wrapper: { getByTestId, unmount },
} = await setup({ enterTouchDelay: 5000 });
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter');
await unmount();
@@ -302,10 +363,10 @@ describe('Tooltip', () => {
it('removes hideTooltipTimer when the component unmounts', async () => {
const {
- wrapper: { getByText, unmount },
+ wrapper: { getByTestId, unmount },
} = await setup({ enterTouchDelay: 5000 });
- await fireEvent(getTrigger(getByText), 'hoverOut');
+ await fireEvent(getWrapperTrigger(getByTestId), 'pointerLeave');
await unmount();
@@ -314,10 +375,10 @@ describe('Tooltip', () => {
it('removes Dimensions listener when the component unmount', async () => {
const {
- wrapper: { getByText, findByText, unmount },
+ wrapper: { getByTestId, findByText, unmount },
} = await setup();
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter');
await runTimers(500);
await findByText('some tooltip text');
@@ -340,13 +401,13 @@ describe('Tooltip', () => {
jest.spyOn(global, 'clearTimeout');
const {
- wrapper: { getByText },
+ wrapper: { getByTestId },
} = await setup();
- const trigger = getTrigger(getByText);
- await fireEvent(trigger, 'hoverIn');
- await fireEvent(trigger, 'hoverOut');
- await fireEvent(trigger, 'hoverIn');
+ const trigger = getWrapperTrigger(getByTestId);
+ await fireEvent(trigger, 'pointerEnter');
+ await fireEvent(trigger, 'pointerLeave');
+ await fireEvent(trigger, 'pointerEnter');
expect(global.clearTimeout).toHaveBeenCalledTimes(2);
});
@@ -355,16 +416,21 @@ describe('Tooltip', () => {
describe('hoverOut', () => {
it('hides the tooltip when the user stops hovering the component', async () => {
const {
- wrapper: { queryByText, getByText, findByText },
+ wrapper: { queryByText, getByTestId, findByText },
} = await setup({ enterTouchDelay: 50, leaveTouchDelay: 0 });
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter');
await runTimers(50);
await findByText('some tooltip text');
- await fireEvent(getTrigger(getByText), 'hoverOut');
- await runTimers();
+ // Settle the pointer-leave 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(getWrapperTrigger(getByTestId), 'pointerLeave');
+ });
+ await runTimers(); // leaveTouchDelay → schedules the exit fade
+ await runTimers(); // exit fade duration → unmounts
expect(queryByText('some tooltip text')).not.toBeOnTheScreen();
});
@@ -388,10 +454,10 @@ describe('Tooltip', () => {
describe('When it does not overflow', () => {
it('centers the tooltip in the middle of the children component', async () => {
const {
- wrapper: { getByText, getByTestId, findByText },
+ wrapper: { getByTestId, findByText },
} = await setup();
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter');
await runTimers(500);
await fireEvent(await findByText('some tooltip text'), 'layout', {
@@ -410,10 +476,10 @@ describe('Tooltip', () => {
describe('When it overflows to left', () => {
it('renders the tooltip with the right placement', async () => {
const {
- wrapper: { getByText, getByTestId, findByText },
+ wrapper: { getByTestId, findByText },
} = await setup({}, { pageX: 0 }); // Component starting at the starting 0 X coord
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter');
await runTimers(500);
await fireEvent(await findByText('some tooltip text'), 'layout', {
@@ -423,7 +489,7 @@ describe('Tooltip', () => {
});
expect(getByTestId('tooltip-container')).toHaveStyle({
- left: 0, // Tooltip renders starting from children's x coord
+ left: 8, // Math.max(EDGE_MARGIN=8, pageX=0)
top: 250,
});
});
@@ -432,10 +498,10 @@ describe('Tooltip', () => {
describe('When it overflows to right', () => {
it('renders the tooltip with the right placement', async () => {
const {
- wrapper: { getByText, getByTestId, findByText },
+ wrapper: { getByTestId, findByText },
} = await setup({}, { pageX: 900, width: 150 }); // Component close to the screen limit
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter');
await runTimers(500);
await fireEvent(await findByText('some tooltip text'), 'layout', {
@@ -445,7 +511,7 @@ describe('Tooltip', () => {
});
expect(getByTestId('tooltip-container')).toHaveStyle({
- left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
+ left: 252, // Math.min(950, LAYOUT_WIDTH(360) - TOOLTIP_WIDTH(100) - EDGE_MARGIN(8))
top: 250,
});
});
@@ -454,10 +520,10 @@ describe('Tooltip', () => {
describe('When it overflows to bottom', () => {
it('renders the tooltip with the right placement', async () => {
const {
- wrapper: { getByText, getByTestId, findByText },
+ wrapper: { getByTestId, findByText },
} = await setup({}, { pageY: 600, height: 50 });
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter');
await runTimers(500);
await fireEvent(await findByText('some tooltip text'), 'layout', {
@@ -475,3 +541,238 @@ 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));
+ jest
+ .spyOn(View.prototype, 'measureInWindow')
+ .mockImplementation((cb) => cb(0, 0, 0, 0));
+
+ 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 the open tooltip when another tooltip trigger is pressed', async () => {
+ jest
+ .spyOn(View.prototype, 'measure')
+ .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200));
+ jest
+ .spyOn(View.prototype, 'measureInWindow')
+ .mockImplementation((cb) => cb(0, 0, 0, 0));
+
+ const { getAllByText, getByText, queryByText } = await render(
+
+
+ {(props) => }
+
+
+ {(props) => }
+
+
+ );
+
+ const [textA, textB] = getAllByText('dummy component');
+ const triggerA = textA.parent!;
+ const triggerB = textB.parent!;
+
+ await user.press(triggerA);
+ expect(getByText('First tooltip')).toBeTruthy();
+
+ await user.press(triggerB);
+ await runTimers(); // exit fade → unmount
+
+ expect(queryByText('First tooltip')).toBeNull();
+ expect(getByText('Second tooltip')).toBeTruthy();
+ });
+
+ 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: { getByTestId, getByText, queryByText },
+ } = await setup({ enterTouchDelay: 100 });
+
+ await act(async () => {
+ await fireEvent(getByTestId('tooltip-rich-trigger'), 'pointerEnter');
+ });
+ 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(getByTestId('tooltip-rich-trigger'), 'pointerEnter');
+ });
+ await runTimers(0);
+ expect(getByText('Body text')).toBeTruthy();
+
+ // Leaving the trigger schedules a hide...
+ await act(async () => {
+ await fireEvent(getByTestId('tooltip-rich-trigger'), 'pointerLeave');
+ // ...but entering the tooltip cancels it.
+ await fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn');
+ });
+ await runTimers(500);
+
+ 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';
diff --git a/tsconfig.json b/tsconfig.json
index 8178b5189f..74a64a0668 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -28,8 +28,7 @@
"skipLibCheck": true,
"strict": true,
"target": "esnext",
- "types": ["node"],
- "verbatimModuleSyntax": true
+ "types": ["jest", "node"]
},
"files": [],
"references": [
diff --git a/yarn.lock b/yarn.lock
index 313bbc5130..295d4e6c2c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3726,6 +3726,15 @@ __metadata:
languageName: node
linkType: hard
+"@jest/expect-utils@npm:30.4.1":
+ version: 30.4.1
+ resolution: "@jest/expect-utils@npm:30.4.1"
+ dependencies:
+ "@jest/get-type": "npm:30.1.0"
+ checksum: 10c0/6dea9e11ebcc7be68fea5950ae5a1b7ff9fd1490101ee8af0aede336b9934ab24a28bcafe2f1171dac0f95982406386c609ca2659b9132e1a9d419e8d69b9cd4
+ languageName: node
+ linkType: hard
+
"@jest/expect-utils@npm:^29.7.0":
version: 29.7.0
resolution: "@jest/expect-utils@npm:29.7.0"
@@ -3778,6 +3787,16 @@ __metadata:
languageName: node
linkType: hard
+"@jest/pattern@npm:30.4.0":
+ version: 30.4.0
+ resolution: "@jest/pattern@npm:30.4.0"
+ dependencies:
+ "@types/node": "npm:*"
+ jest-regex-util: "npm:30.4.0"
+ checksum: 10c0/05bc0799f84f3750bbbff0f9a546979efd0dbcee86c1be98b9e2811a68885809ec7b5cca39b8dda1497cb7cf17b7be936019fba8dfbcd9c53b181e03e67f4f82
+ languageName: node
+ linkType: hard
+
"@jest/reporters@npm:^29.7.0":
version: 29.7.0
resolution: "@jest/reporters@npm:29.7.0"
@@ -3891,6 +3910,21 @@ __metadata:
languageName: node
linkType: hard
+"@jest/types@npm:30.4.1":
+ version: 30.4.1
+ resolution: "@jest/types@npm:30.4.1"
+ dependencies:
+ "@jest/pattern": "npm:30.4.0"
+ "@jest/schemas": "npm:30.4.1"
+ "@types/istanbul-lib-coverage": "npm:^2.0.6"
+ "@types/istanbul-reports": "npm:^3.0.4"
+ "@types/node": "npm:*"
+ "@types/yargs": "npm:^17.0.33"
+ chalk: "npm:^4.1.2"
+ checksum: 10c0/4c79f6dbdb1c7eaab5da255fc696c7cae744759d4020e42da8aa63b37fe55ce594be73075fe1ee5407dd59d7e47975be9f674bfc81e91bae2c89c62d27ba55a1
+ languageName: node
+ linkType: hard
+
"@jest/types@npm:^24.9.0":
version: 24.9.0
resolution: "@jest/types@npm:24.9.0"
@@ -5320,7 +5354,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1":
+"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6":
version: 2.0.6
resolution: "@types/istanbul-lib-coverage@npm:2.0.6"
checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7
@@ -5346,7 +5380,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/istanbul-reports@npm:^3.0.0":
+"@types/istanbul-reports@npm:^3.0.0, @types/istanbul-reports@npm:^3.0.4":
version: 3.0.4
resolution: "@types/istanbul-reports@npm:3.0.4"
dependencies:
@@ -5355,6 +5389,16 @@ __metadata:
languageName: node
linkType: hard
+"@types/jest@npm:^30.0.0":
+ version: 30.0.0
+ resolution: "@types/jest@npm:30.0.0"
+ dependencies:
+ expect: "npm:^30.0.0"
+ pretty-format: "npm:^30.0.0"
+ checksum: 10c0/20c6ce574154bc16f8dd6a97afacca4b8c4921a819496a3970382031c509ebe87a1b37b152a1b8475089b82d8ca951a9e95beb4b9bf78fbf579b1536f0b65969
+ languageName: node
+ linkType: hard
+
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
@@ -5528,7 +5572,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/stack-utils@npm:^2.0.0":
+"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3":
version: 2.0.3
resolution: "@types/stack-utils@npm:2.0.3"
checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c
@@ -5586,7 +5630,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/yargs@npm:^17.0.8":
+"@types/yargs@npm:^17.0.33, @types/yargs@npm:^17.0.8":
version: 17.0.35
resolution: "@types/yargs@npm:17.0.35"
dependencies:
@@ -7687,6 +7731,13 @@ __metadata:
languageName: node
linkType: hard
+"ci-info@npm:^4.2.0":
+ version: 4.4.0
+ resolution: "ci-info@npm:4.4.0"
+ checksum: 10c0/44156201545b8dde01aa8a09ee2fe9fc7a73b1bef9adbd4606c9f61c8caeeb73fb7a575c88b0443f7b4edb5ee45debaa59ed54ba5f99698339393ca01349eb3a
+ languageName: node
+ linkType: hard
+
"cjs-module-lexer@npm:^1.0.0":
version: 1.4.3
resolution: "cjs-module-lexer@npm:1.4.3"
@@ -10210,6 +10261,20 @@ __metadata:
languageName: node
linkType: hard
+"expect@npm:^30.0.0":
+ version: 30.4.1
+ resolution: "expect@npm:30.4.1"
+ dependencies:
+ "@jest/expect-utils": "npm:30.4.1"
+ "@jest/get-type": "npm:30.1.0"
+ jest-matcher-utils: "npm:30.4.1"
+ jest-message-util: "npm:30.4.1"
+ jest-mock: "npm:30.4.1"
+ jest-util: "npm:30.4.1"
+ checksum: 10c0/ad04fbdffac5a2bae186478938a60f737e3aac823db9a80c87f3f390f9f458bddcc454dc3a3997d715706747c6aff928923e6a71db3a221adb89a51cc1582e72
+ languageName: node
+ linkType: hard
+
"expo-asset@npm:~56.0.12":
version: 56.0.12
resolution: "expo-asset@npm:56.0.12"
@@ -13508,6 +13573,18 @@ __metadata:
languageName: node
linkType: hard
+"jest-matcher-utils@npm:30.4.1, jest-matcher-utils@npm:^30.4.1":
+ version: 30.4.1
+ resolution: "jest-matcher-utils@npm:30.4.1"
+ dependencies:
+ "@jest/get-type": "npm:30.1.0"
+ chalk: "npm:^4.1.2"
+ jest-diff: "npm:30.4.1"
+ pretty-format: "npm:30.4.1"
+ checksum: 10c0/ddbb0c7075def27ba30160883c327cb3fd13f561f5789d00a1edca1b48b0651f8ea23a1c51bcfcb6413a68c47d658bcf47a34701b8a39ce135dd28d87a3117af
+ languageName: node
+ linkType: hard
+
"jest-matcher-utils@npm:^29.7.0":
version: 29.7.0
resolution: "jest-matcher-utils@npm:29.7.0"
@@ -13520,15 +13597,21 @@ __metadata:
languageName: node
linkType: hard
-"jest-matcher-utils@npm:^30.4.1":
+"jest-message-util@npm:30.4.1":
version: 30.4.1
- resolution: "jest-matcher-utils@npm:30.4.1"
+ resolution: "jest-message-util@npm:30.4.1"
dependencies:
- "@jest/get-type": "npm:30.1.0"
+ "@babel/code-frame": "npm:^7.27.1"
+ "@jest/types": "npm:30.4.1"
+ "@types/stack-utils": "npm:^2.0.3"
chalk: "npm:^4.1.2"
- jest-diff: "npm:30.4.1"
+ graceful-fs: "npm:^4.2.11"
+ jest-util: "npm:30.4.1"
+ picomatch: "npm:^4.0.3"
pretty-format: "npm:30.4.1"
- checksum: 10c0/ddbb0c7075def27ba30160883c327cb3fd13f561f5789d00a1edca1b48b0651f8ea23a1c51bcfcb6413a68c47d658bcf47a34701b8a39ce135dd28d87a3117af
+ slash: "npm:^3.0.0"
+ stack-utils: "npm:^2.0.6"
+ checksum: 10c0/ae7427544e042bc1c14abf3c0dbe8b83d0dbec22a9a5efefaca5b8ccb6b9bf391abe732e6f2117ca995c6889bfe1be35c78cec75e5ea0a50e28cffe1ba6f9fdf
languageName: node
linkType: hard
@@ -13549,6 +13632,17 @@ __metadata:
languageName: node
linkType: hard
+"jest-mock@npm:30.4.1":
+ version: 30.4.1
+ resolution: "jest-mock@npm:30.4.1"
+ dependencies:
+ "@jest/types": "npm:30.4.1"
+ "@types/node": "npm:*"
+ jest-util: "npm:30.4.1"
+ checksum: 10c0/5185a41255285c1634c5d85dda037afaaadfc12793b3293c9e253a30bb67449f8df968447f830abb9cf7a52e63694e6734680130e8085ce119056280890bf6fc
+ languageName: node
+ linkType: hard
+
"jest-mock@npm:^29.7.0":
version: 29.7.0
resolution: "jest-mock@npm:29.7.0"
@@ -13572,6 +13666,13 @@ __metadata:
languageName: node
linkType: hard
+"jest-regex-util@npm:30.4.0":
+ version: 30.4.0
+ resolution: "jest-regex-util@npm:30.4.0"
+ checksum: 10c0/fe7426f67b54d38bed8e9d6e6a099d63d72f41f5bf65b922d9d03fedcb55c614b45657207632f6ee22d0a59d8d11327891f258d23f68a58912fcdb0f7db48435
+ languageName: node
+ linkType: hard
+
"jest-regex-util@npm:^29.6.3":
version: 29.6.3
resolution: "jest-regex-util@npm:29.6.3"
@@ -13693,6 +13794,20 @@ __metadata:
languageName: node
linkType: hard
+"jest-util@npm:30.4.1":
+ version: 30.4.1
+ resolution: "jest-util@npm:30.4.1"
+ dependencies:
+ "@jest/types": "npm:30.4.1"
+ "@types/node": "npm:*"
+ chalk: "npm:^4.1.2"
+ ci-info: "npm:^4.2.0"
+ graceful-fs: "npm:^4.2.11"
+ picomatch: "npm:^4.0.3"
+ checksum: 10c0/3efe1f25e5a172d04c6af8612d82867ab603b7c1bd8cb89073ff834679b44eba178793cf3af162cf5e25be13aa736ebd23a7826683acc85bddc5873f305b1f6e
+ languageName: node
+ linkType: hard
+
"jest-util@npm:^29.7.0":
version: 29.7.0
resolution: "jest-util@npm:29.7.0"
@@ -17604,7 +17719,7 @@ __metadata:
languageName: node
linkType: hard
-"pretty-format@npm:30.4.1, pretty-format@npm:^30.4.1":
+"pretty-format@npm:30.4.1, pretty-format@npm:^30.0.0, pretty-format@npm:^30.4.1":
version: 30.4.1
resolution: "pretty-format@npm:30.4.1"
dependencies:
@@ -18225,6 +18340,7 @@ __metadata:
"@release-it/conventional-changelog": "npm:^1.1.0"
"@testing-library/react-native": "npm:^14.0.0"
"@types/color": "npm:^3.0.0"
+ "@types/jest": "npm:^30.0.0"
"@types/node": "npm:^24.0.0"
"@types/react": "npm:^19.2.7"
all-contributors-cli: "npm:^6.24.0"
@@ -20238,7 +20354,7 @@ __metadata:
languageName: node
linkType: hard
-"stack-utils@npm:^2.0.3":
+"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6":
version: 2.0.6
resolution: "stack-utils@npm:2.0.6"
dependencies: