diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 72aee6d00f..3c83f9e2b6 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -285,6 +285,35 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`:
```
+### Tooltip
+
+The `Tooltip` trigger is now a render function. Spread the supplied props onto
+the trigger element so the tooltip can attach its interactions without cloning
+the element.
+
+```tsx
+// Before (v5)
+
+
+
+
+// After (v6)
+
+ {(props) => (
+
+ )}
+
+```
+
+`Tooltip.Rich` is new in Paper 6.x and follows the same render-function pattern
+for its trigger:
+
+```tsx
+
+ {(props) => }
+
+```
+
### Searchbar
The misspelled `traileringIcon` props have been renamed:
diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts
index 2cf106849c..dd67e4159d 100644
--- a/docs/component-docs.config.ts
+++ b/docs/component-docs.config.ts
@@ -151,6 +151,10 @@ const pages = {
},
Tooltip: {
Tooltip: 'Tooltip/Tooltip',
+ TooltipRich: {
+ source: 'Tooltip/RichTooltip',
+ title: 'Tooltip.Rich',
+ },
},
TouchableRipple: {
TouchableRipple: 'TouchableRipple/TouchableRipple',
diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx
index 09ce39aa14..1d753397db 100644
--- a/example/src/Examples/FABExample.tsx
+++ b/example/src/Examples/FABExample.tsx
@@ -6,12 +6,13 @@ import {
Chip,
Divider,
FAB,
+ type FABSize,
+ type 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 4ecbdfe544..694c862a57 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,
@@ -44,17 +45,28 @@ const TooltipExample = () => {
header: () => (
- navigation.goBack()} />
+ {(props) => (
+ navigation.goBack()}
+ />
+ )}
- {}} />
+ {(props) => (
+ {}} />
+ )}
- {}} />
+ {(props) => (
+ {}} />
+ )}
- {}} />
+ {(props) => (
+ {}} />
+ )}
),
@@ -81,11 +93,14 @@ const TooltipExample = () => {
enterTouchDelay={transport.enterTouchDelay}
leaveTouchDelay={transport.leaveTouchDelay}
>
- {}}
- />
+ {(props) => (
+ {}}
+ />
+ )}
))}
@@ -93,73 +108,112 @@ const TooltipExample = () => {
- setTextAlign('left')}
- />
+ {(props) => (
+ setTextAlign('left')}
+ />
+ )}
- setTextAlign('center')}
- />
+ {(props) => (
+ setTextAlign('center')}
+ />
+ )}
- setTextAlign('right')}
- />
+ {(props) => (
+ setTextAlign('right')}
+ />
+ )}
-
+ {(props) => }
-
- }
- >
- John Doe
-
+ {(props) => (
+
+ }
+ >
+ John Doe
+
+ )}
-
- (
-
- )}
- />
-
+ {(props) => (
+
+ (
+
+ )}
+ />
+
+ )}
+
+
+ (
+ <>
+
+
+ >
+ )}
+ >
+ {(props) => }
+
+
+ {(props) => (
+
+ )}
+
+
+
- {}} />
+ {(props) => {}} />}
>
diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx
index 17da09bd56..d010773a43 100644
--- a/src/components/FAB/Extended.tsx
+++ b/src/components/FAB/Extended.tsx
@@ -1,10 +1,11 @@
import * as React from 'react';
-import { Platform, StyleSheet, View } from 'react-native';
+import { Platform, StyleSheet } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
PressableAndroidRippleConfig,
StyleProp,
+ View,
ViewStyle,
} from 'react-native';
@@ -111,6 +112,9 @@ export type Props = {
* @optional
*/
theme?: ThemeProp;
+ /**
+ * @optional
+ */
ref?: React.Ref;
};
diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx
index fb30396939..b46ae89e7f 100644
--- a/src/components/FAB/FAB.tsx
+++ b/src/components/FAB/FAB.tsx
@@ -1,10 +1,10 @@
import * as React from 'react';
-import { View } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
PressableAndroidRippleConfig,
StyleProp,
+ View,
ViewStyle,
} from 'react-native';
@@ -45,6 +45,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.
*/
@@ -122,6 +142,11 @@ const FAB = ({
size = 'default',
visible = true,
onPress,
+ onLongPress,
+ onPressOut,
+ delayLongPress,
+ onHoverIn,
+ onHoverOut,
containerColor,
contentColor,
'aria-label': ariaLabel,
@@ -142,6 +167,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 e4884647d6..e681a34339 100644
--- a/src/components/FAB/Menu.tsx
+++ b/src/components/FAB/Menu.tsx
@@ -16,12 +16,13 @@ import Content from './Content';
import Shell from './Shell';
import {
MenuTokens,
+ type Size,
Tokens,
+ type 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';
@@ -67,9 +68,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 +220,6 @@ type ItemProps = {
variant: Variant;
theme: InternalTheme;
onPress: (e: GestureResponderEvent) => void;
- /**
- * Accessibility label. Falls back to `label`.
- */
'aria-label'?: string;
testID?: string;
};
@@ -314,9 +309,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 83a71f72ed..33aa587ede 100644
--- a/src/components/FAB/Shell.tsx
+++ b/src/components/FAB/Shell.tsx
@@ -1,10 +1,11 @@
import * as React from 'react';
-import { Platform, StyleSheet, View } from 'react-native';
+import { Platform, StyleSheet } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
PressableAndroidRippleConfig,
StyleProp,
+ View,
ViewStyle,
} from 'react-native';
@@ -12,18 +13,19 @@ import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
+ type AnimatedStyle,
+ 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 {
+ type Size,
Tokens,
+ type Variant,
FOCUS_RING_INSET,
FOCUS_RING_THICKNESS,
webNoOutline,
} from './tokens';
-import type { Size, Variant } from './tokens';
import { useFocusRing } from './useFocusRing';
import { getDimensions, resolveColors } from './utils';
import { useInternalTheme } from '../../core/theming';
@@ -91,6 +93,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.
*/
@@ -197,6 +219,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,
@@ -327,6 +354,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/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx
new file mode 100644
index 0000000000..477711f6a8
--- /dev/null
+++ b/src/components/Tooltip/RichTooltip.tsx
@@ -0,0 +1,532 @@
+import * as React from 'react';
+import {
+ Dimensions,
+ View,
+ StyleSheet,
+ Platform,
+ Pressable,
+} from 'react-native';
+import type { PointerEvent, ViewStyle } from 'react-native';
+
+import Reanimated from 'react-native-reanimated';
+
+import {
+ takeSingletonSlot,
+ useTooltipFade,
+ registerRichTrigger,
+ unregisterRichTrigger,
+ forwardPressToTriggerAt,
+ subscribeToTriggerRefresh,
+} from './hooks';
+import type { TooltipHostElement } from './hooks';
+import { Tokens } from './tokens';
+import { getTooltipPosition } from './utils';
+import { useLocale } from '../../core/locale';
+import { useInternalTheme } from '../../core/theming';
+import type { ThemeProp } from '../../theme/types';
+import { addEventListener } from '../../utils/addEventListener';
+import Portal from '../Portal/Portal';
+import Surface from '../Surface';
+import Text from '../Typography/Text';
+
+const AnimatedSurface = Reanimated.createAnimatedComponent(Surface);
+
+// Tooltip renders in a portal at the end of document, so keyboard
+// users reach its actions by moving focus into 1st focusable element
+const FOCUSABLE_SELECTOR =
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+
+/**
+ * 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;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ onKeyDown?: (event: { key?: string }) => void;
+ 'aria-label'?: string;
+ 'aria-describedby'?: string;
+};
+
+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);
+ const { direction } = useLocale();
+ // `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 tooltipId = React.useId();
+ // Web only: a non-touch pointer sits on the trigger
+ const hoveringRef = React.useRef(false);
+ const tooltipRef = React.useRef(null);
+ // Web only: set while dismiss is returning focus to the trigger
+ const refocusAfterDismissRef = React.useRef(false);
+
+ 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]);
+
+ // Web only: tooltip opens on trigger focus, so it has to ignore the focus
+ // that Escape itself causes; later focus (or any blur) re-arms it
+ const handleFocus = React.useCallback(() => {
+ if (refocusAfterDismissRef.current) {
+ refocusAfterDismissRef.current = false;
+ return;
+ }
+ show();
+ }, [show]);
+
+ const handleBlur = React.useCallback(() => {
+ refocusAfterDismissRef.current = false;
+ scheduleHide();
+ }, [scheduleHide]);
+
+ // 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 (Platform.OS === 'web' && visible && hoveringRef.current) {
+ clearShowTimer();
+ clearHideTimer();
+ return;
+ }
+
+ 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. Touch
+ // pointer is skipped - tap both enters & presses and only the press half
+ // should act so taps keep toggling the tooltip
+ const handlePointerEnter = React.useCallback(
+ (e?: PointerEvent) => {
+ if (e?.nativeEvent?.pointerType === 'touch') {
+ return;
+ }
+
+ hoveringRef.current = true;
+ 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;
+ }
+ }
+ }
+
+ hoveringRef.current = false;
+ scheduleHide();
+ },
+ [scheduleHide, childrenWrapperRef]
+ );
+
+ const wrapperPointerProps =
+ Platform.OS === 'web'
+ ? {
+ onPointerEnter: handlePointerEnter,
+ onPointerLeave: handlePointerLeave,
+ }
+ : {};
+
+ // Web only: `Tab` walks the document, and the tooltip renders in a portal at
+ // its end, so the trigger needs a key that moves focus into the actions.
+ const focusTooltipContent = React.useCallback(() => {
+ // The tooltip mounts through the portal on the render that opens it, so
+ // wait for that commit: from this one the node isn't reachable yet and
+ // focus requested while it's missing is simply lost
+ setTimeout(() => {
+ tooltipRef.current?.querySelector?.(FOCUSABLE_SELECTOR)?.focus?.();
+ }, 0);
+ }, []);
+
+ const focusTrigger = React.useCallback(() => {
+ const wrapper = childrenWrapperRef.current;
+ (wrapper?.querySelector?.(FOCUSABLE_SELECTOR) ?? wrapper)?.focus?.();
+ }, [childrenWrapperRef]);
+
+ const handleKeyDown = React.useCallback(
+ (event: { key?: string }) => {
+ if (event.key === 'ArrowDown') {
+ show();
+ focusTooltipContent();
+ }
+ },
+ [show, focusTooltipContent]
+ );
+
+ // Web: Escape dismisses the tooltip the way the backdrop does on touch and
+ // puts focus back on the trigger so it's not lost
+ const handleKeypress = React.useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ refocusAfterDismissRef.current = true;
+ hide();
+ focusTrigger();
+ }
+ },
+ [hide, focusTrigger]
+ );
+
+ React.useEffect(() => {
+ if (Platform.OS !== 'web' || !visible || !('document' in global)) {
+ return;
+ }
+
+ document.addEventListener('keyup', handleKeypress);
+
+ return () => document.removeEventListener('keyup', handleKeypress);
+ }, [visible, handleKeypress]);
+
+ // accessible name for trigger without its own label
+ const description =
+ typeof content === 'string'
+ ? [title, content].filter(Boolean).join('. ')
+ : title;
+
+ const triggerProps: TooltipRichTriggerProps =
+ Platform.OS === 'web'
+ ? {
+ onPress: handlePress,
+ onFocus: handleFocus,
+ onBlur: handleBlur,
+ onKeyDown: handleKeyDown,
+ 'aria-describedby': tooltipId,
+ }
+ : { onPress: handlePress, 'aria-label': description };
+
+ // Web only: keep the tooltip open while the pointer or the focus travels from
+ // the trigger into the tooltip, and re-schedule the hide once it leaves. A
+ // plain `View` carries these — a `Pressable` would be focusable and merge its
+ // children into one accessibility element on iOS, so the title, the content
+ // and the actions would stop being reachable on their own.
+ const tooltipInteractionProps =
+ Platform.OS === 'web'
+ ? {
+ onHoverIn: clearHideTimer,
+ onHoverOut: scheduleHide,
+ onFocus: clearHideTimer,
+ onBlur: 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?.measure(
+ (_x, _y, width, height, pageX, pageY) => {
+ registerRichTrigger(triggerId, {
+ pageX,
+ pageY,
+ 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,
+ paddingTop: Tokens.rich.paddingTop,
+ paddingBottom: Tokens.rich.paddingBottom,
+ rowGap: Tokens.rich.gap,
+ },
+ actions: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ },
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ 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 d0dc180115..f412168d21 100644
--- a/src/components/Tooltip/Tooltip.tsx
+++ b/src/components/Tooltip/Tooltip.tsx
@@ -1,26 +1,49 @@
import * as React from 'react';
import {
Dimensions,
- View,
- StyleSheet,
Platform,
Pressable,
+ StyleSheet,
+ View,
} from 'react-native';
-import type { LayoutChangeEvent, ViewStyle } 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 { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../theme/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;
+ 'aria-label'?: string;
+ 'aria-describedby'?: string;
+};
+
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 +71,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 +80,7 @@ export type Props = {
*
* const MyComponent = () => (
*
- * {}} />
+ * {(props) => {}} />}
*
* );
*
@@ -69,38 +94,23 @@ const Tooltip = ({
title,
theme: themeOverrides,
titleMaxFontSizeMultiplier,
- ...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
+ const { direction } = useLocale();
+ // `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 tooltipId = React.useId();
- 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);
};
}, []);
@@ -113,133 +123,132 @@ 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 (Platform.OS === 'web') {
- const id = setTimeout(() => {
- touched.current = true;
+ showTimer.current = setTimeout(() => {
+ takeSingletonSlot(() => setVisible(false));
setVisible(true);
}, enterTouchDelay);
- showTooltipTimer.current.push(id);
} else {
- touched.current = true;
+ takeSingletonSlot(() => setVisible(false));
setVisible(true);
}
}, [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;
}
-
- const id = setTimeout(() => {
- setVisible(false);
- setMeasurement({ children: {}, tooltip: {}, measured: false });
- }, leaveTouchDelay);
- hideTooltipTimer.current.push(id);
- }, [leaveTouchDelay]);
-
- const handlePress = React.useCallback(() => {
- if (touched.current) {
- return null;
- }
- if (!isValidChild) return null;
- const props =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- children.props as TooltipChildProps;
- if (props.disabled) return null;
- return props.onPress?.();
- }, [children.props, isValidChild]);
-
- const handleHoverIn = React.useCallback(() => {
- handleTouchStart();
- if (isValidChild) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- (children.props as TooltipChildProps).onHoverIn?.();
+ if (hideTimer.current) {
+ clearTimeout(hideTimer.current);
}
- }, [children.props, handleTouchStart, isValidChild]);
-
- const handleHoverOut = React.useCallback(() => {
- handleTouchEnd();
- if (isValidChild) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- (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,
+ ...(Platform.OS === 'web'
+ ? { 'aria-describedby': tooltipId }
+ : { 'aria-label': title }),
};
return (
<>
- {visible && (
+ {rendered && (
-
- ),
- borderRadius: theme.shapes.corner.extraSmall,
- ...(measurement.measured ? styles.visible : styles.hidden),
+ backgroundColor: theme.colors[Tokens.plain.container],
+ ...getTooltipPosition(measurement, direction),
+ borderRadius: theme.shapes.corner[Tokens.plain.shape],
},
+ fadeStyle,
]}
>
{title}
-
+
)}
-
- {React.cloneElement(children, {
- ...rest,
- ...(Platform.OS === 'web' ? webPressProps : mobilePressProps),
- })}
-
+ {Platform.OS === 'web' ? (
+
+ {children(triggerProps)}
+
+ ) : (
+
+ {children(triggerProps)}
+
+ )}
>
);
};
@@ -250,15 +259,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,
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pressContainer: {
diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts
new file mode 100644
index 0000000000..e8ee1a258a
--- /dev/null
+++ b/src/components/Tooltip/hooks.ts
@@ -0,0 +1,229 @@
+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 '../../theme/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;
+};
+
+export type TooltipHostElement = View & {
+ getBoundingClientRect?(): DOMRect;
+ querySelector?(selectors: string): TooltipHostElement | null;
+ focus?(): void;
+};
+
+/**
+ * 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 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.scrollX ?? 0) : 0;
+ const scrollY = Platform.OS === 'web' ? (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) => {
+ // Positioning the tooltip causes another onLayout with different x/y but
+ // the same dimensions. Only its size affects our position calculation, so
+ // ignore that second event instead of rebuilding the animated native layer
+ // while it is fading in (which can move it by one physical pixel on
+ // Android).
+ if (
+ tooltipLayout.current?.width === layout.width &&
+ tooltipLayout.current?.height === layout.height
+ ) {
+ return;
+ }
+
+ const nextLayout = { ...layout, x: 0, y: 0 };
+ tooltipLayout.current = nextLayout;
+ if (!childrenMeasurement.current) {
+ return;
+ }
+
+ setMeasurement({
+ children: childrenMeasurement.current,
+ tooltip: nextLayout,
+ 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..97cba80a9f
--- /dev/null
+++ b/src/components/Tooltip/tokens.ts
@@ -0,0 +1,43 @@
+/**
+ * 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: 'onSurfaceVariant',
+ content: 'onSurfaceVariant',
+ shape: 'medium',
+ elevation: 2,
+ maxWidth: 312,
+ paddingHorizontal: 16,
+ paddingTop: 12,
+ paddingBottom: 8,
+ 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..0b55a82511 100644
--- a/src/components/Tooltip/utils.ts
+++ b/src/components/Tooltip/utils.ts
@@ -1,5 +1,7 @@
-import { Dimensions, StyleSheet } from 'react-native';
-import type { LayoutRectangle, StyleProp, ViewStyle } from 'react-native';
+import { Dimensions, PixelRatio, Platform } from 'react-native';
+import type { LayoutRectangle } from 'react-native';
+
+import type { Direction } from '../../core/locale';
type ChildrenMeasurement = {
width: number;
@@ -16,20 +18,14 @@ export type Measurement = {
measured: boolean;
};
-export type TooltipChildProps = {
- style: StyleProp;
- disabled?: boolean;
- onPress?: () => void;
- onHoverIn?: () => void;
- 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 +35,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 +63,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;
};
@@ -85,52 +86,25 @@ const getTooltipYPosition = (
return childrenY + childrenHeight;
};
-const getChildrenMeasures = (
- style: StyleProp,
- measures: ChildrenMeasurement
-): ChildrenMeasurement => {
- const { position, top, bottom, left, right } = StyleSheet.flatten(style);
-
- if (position === 'absolute') {
- let pageX = 0;
- let pageY = measures.pageY;
- let height = 0;
- let width = 0;
- if (typeof left === 'number') {
- pageX = left;
- width = 0;
- }
- if (typeof right === 'number') {
- pageX = measures.width - right;
- width = 0;
- }
- if (typeof top === 'number') {
- pageY = pageY + top;
- }
- if (typeof bottom === 'number') {
- pageY = pageY - bottom;
- }
-
- return { pageX, pageY, width, height };
- }
-
- return measures;
-};
-
export const getTooltipPosition = (
{ children, tooltip, measured }: Measurement,
- component: React.ReactElement<{
- style: StyleProp;
- }>
+ direction: Direction = 'ltr'
): {} | { left: number; top: number } => {
if (!measured) return {};
- let measures = children;
- if (component.props.style) {
- measures = getChildrenMeasures(component.props.style, children);
+
+ let left = getTooltipXPosition(children, tooltip);
+ const top = getTooltipYPosition(children, tooltip);
+
+ if (direction === 'rtl') {
+ const trailingOffset =
+ Dimensions.get('window').width - tooltip.width - left;
+ left = Platform.OS === 'web' ? -trailingOffset : trailingOffset;
}
- return {
- left: getTooltipXPosition(measures, tooltip),
- top: getTooltipYPosition(measures, tooltip),
- };
+ return Platform.OS === 'web'
+ ? { left, top }
+ : {
+ left: PixelRatio.roundToNearestPixel(left),
+ top: PixelRatio.roundToNearestPixel(top),
+ };
};
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 e16ca3854b..df21b1ccc8 100644
--- a/src/components/__tests__/FABExtended.test.tsx
+++ b/src/components/__tests__/FABExtended.test.tsx
@@ -2,10 +2,10 @@ import * as React from 'react';
import { Platform, View } from 'react-native';
import { afterEach, expect, it, jest } from '@jest/globals';
-import { fireEvent, userEvent } from '@testing-library/react-native';
+import { fireEvent, screen, userEvent } from '@testing-library/react-native';
import * as Reanimated from 'react-native-reanimated';
-import { render, screen } from '../../test-utils';
+import { render } from '../../test-utils';
import FAB from '../FAB';
jest.mock('react-native-reanimated', () => {
@@ -112,16 +112,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 () => {
@@ -131,24 +123,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);
@@ -157,13 +143,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 0e03ff6823..e0cf31a19b 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(
{
items={makeItems()}
/>
);
- 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 2888803613..e93b8c01d1 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,
@@ -15,7 +16,9 @@ import { act, fireEvent, userEvent } from '@testing-library/react-native';
import PaperProvider from '../../core/PaperProvider';
import { render } from '../../test-utils';
-import Tooltip from '../Tooltip/Tooltip';
+import { LightTheme } from '../../theme/schemes';
+import TooltipCompound from '../Tooltip';
+import Tooltip, { type TooltipTriggerProps } from '../Tooltip/Tooltip';
const mockedRemoveEventListener = jest.fn();
@@ -28,9 +31,10 @@ jest.mock('../../utils/addEventListener', () => ({
const DummyComponent = ({
ref,
...props
-}: ViewProps & {
- ref?: React.RefObject;
-}) => (
+}: ViewProps &
+ TooltipTriggerProps & {
+ ref?: React.RefObject;
+ }) => (
dummy component
@@ -51,13 +55,24 @@ describe('Tooltip', () => {
return trigger;
};
- const runTimers = async (ms?: number) => {
- await act(() => {
- if (ms === undefined) {
- jest.runOnlyPendingTimers();
- } else {
- jest.advanceTimersByTime(ms);
- }
+ const getTooltipContainer = (
+ getByText: Awaited>['getByText']
+ ) => {
+ const container = getByText('some tooltip text').parent;
+
+ if (!container) {
+ throw new Error('Expected Tooltip container');
+ }
+
+ return container;
+ };
+
+ // 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 +81,7 @@ describe('Tooltip', () => {
measure = {}
) => {
const defaultProps = {
- children: ,
+ children: (props: TooltipTriggerProps) => ,
title: 'some tooltip text',
...propOverrides,
};
@@ -94,6 +109,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 +149,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,29 +175,126 @@ 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);
});
});
+ describe('anchor that ignores the trigger props', () => {
+ it('shows the tooltip on long press as the wrapper handles it', async () => {
+ jest
+ .spyOn(View.prototype, 'measure')
+ .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200));
+
+ // An anchor like `Avatar.Text` never receives the handlers, so the
+ // wrapper has to keep handling the long press for it.
+ const { getByText, findByText } = await render(
+
+
+ {() => (
+
+ dummy component
+
+ )}
+
+
+ );
+
+ await user.longPress(getTrigger(getByText));
+
+ expect(await findByText('some tooltip text')).toBeTruthy();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('names the trigger with the title so it is announceable', async () => {
+ const {
+ wrapper: { getByText },
+ } = await setup();
+
+ expect(getTrigger(getByText)).toHaveAccessibleName('some tooltip text');
+
+ await user.longPress(getTrigger(getByText));
+
+ expect(getTooltipContainer(getByText)).toHaveProp('role', 'tooltip');
+ });
+
+ it('keeps the label the consumer writes after the spread', async () => {
+ const {
+ wrapper: { getByText },
+ } = await setup({
+ children: (props: TooltipTriggerProps) => (
+
+ ),
+ });
+
+ expect(getTrigger(getByText)).toHaveAccessibleName('Own label');
+ });
+ });
+
describe('pressOut', () => {
it('hides the tooltip when the user stop pressing the component', async () => {
const {
wrapper: { queryByText, getByText, findByText },
} = await setup({ enterTouchDelay: 50, leaveTouchDelay: 100 });
- 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 → schedules the exit fade
+ await runTimers(); // 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, findByText },
+ } = await setup();
+
+ await user.longPress(getTrigger(getByText));
+
+ await findByText('some tooltip text');
+
+ expect(getTooltipContainer(getByText)).toHaveStyle({
+ backgroundColor: LightTheme.colors.inverseSurface,
+ });
+
+ // bodySmall (12sp) text in the inverseOnSurface role.
+ expect(getByText('some tooltip text')).toHaveStyle({
+ color: LightTheme.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 +316,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText },
} = await setup();
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
const tooltip = await findByText('some tooltip text');
await fireEvent(tooltip, 'layout', {
@@ -205,20 +325,53 @@ describe('Tooltip', () => {
},
});
- expect(tooltip.parent).toHaveStyle({
+ expect(getTooltipContainer(getByText)).toHaveStyle({
left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210
top: 250, // pageY (200) + height (50)
});
});
});
+ describe('When the locale is RTL', () => {
+ it('mirrors the position to the trailing edge', async () => {
+ jest
+ .spyOn(View.prototype, 'measure')
+ .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200));
+
+ const { getByText, findByText } = await render(
+
+
+ {(props: TooltipTriggerProps) => }
+
+
+ );
+
+ await user.longPress(getTrigger(getByText));
+
+ const tooltip = await findByText('some tooltip text');
+ await fireEvent(tooltip, 'layout', {
+ nativeEvent: {
+ layout: { width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT },
+ },
+ });
+
+ // `measure` reports the physical left edge, while native resolves
+ // `left` from the leading (right) edge in RTL, so what the tooltip
+ // needs is its distance to the trailing edge.
+ expect(getTooltipContainer(getByText)).toHaveStyle({
+ left: 50, // LAYOUT_WIDTH (360) - TOOLTIP_WIDTH (100) - left (210)
+ top: 250,
+ });
+ });
+ });
+
describe('When it overflows to left', () => {
it('renders the tooltip with the right placement', async () => {
const {
wrapper: { getByText, findByText },
} = await setup({}, { pageX: 0 }); // Component starting at the starting 0 X coord
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
const tooltip = await findByText('some tooltip text');
await fireEvent(tooltip, 'layout', {
@@ -227,8 +380,8 @@ describe('Tooltip', () => {
},
});
- expect(tooltip.parent).toHaveStyle({
- left: 0, // Tooltip renders starting from children's x coord
+ expect(getTooltipContainer(getByText)).toHaveStyle({
+ left: 8, // Math.max(EDGE_MARGIN=8, pageX=0)
top: 250,
});
});
@@ -240,7 +393,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText },
} = await setup({}, { pageX: 900, width: 150 }); // Component close to the screen limit
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
const tooltip = await findByText('some tooltip text');
await fireEvent(tooltip, 'layout', {
@@ -249,8 +402,8 @@ describe('Tooltip', () => {
},
});
- expect(tooltip.parent).toHaveStyle({
- left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
+ expect(getTooltipContainer(getByText)).toHaveStyle({
+ left: 252, // Math.min(950, LAYOUT_WIDTH(360) - TOOLTIP_WIDTH(100) - EDGE_MARGIN(8))
top: 250,
});
});
@@ -262,7 +415,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText },
} = await setup({}, { pageY: 600, height: 50 });
- await userEvent.longPress(getTrigger(getByText));
+ await user.longPress(getTrigger(getByText));
const tooltip = await findByText('some tooltip text');
await fireEvent(tooltip, 'layout', {
@@ -271,11 +424,33 @@ describe('Tooltip', () => {
},
});
- expect(tooltip.parent).toHaveStyle({
+ expect(getTooltipContainer(getByText)).toHaveStyle({
left: 210,
top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component,
});
});
+
+ it('snaps the tooltip position to the physical pixel grid', async () => {
+ const {
+ wrapper: { getByText, findByText },
+ } = await setup(
+ {},
+ { pageX: 220.2, pageY: 600.2, width: 80, height: 50 }
+ );
+
+ await user.longPress(getTrigger(getByText));
+
+ await fireEvent(await findByText('some tooltip text'), 'layout', {
+ nativeEvent: {
+ layout: { width: 100.1, height: 100.1 },
+ },
+ });
+
+ expect(getTooltipContainer(getByText)).toHaveStyle({
+ left: 210,
+ top: 500,
+ });
+ });
});
});
});
@@ -284,6 +459,7 @@ describe('Tooltip', () => {
beforeAll(() => {
Platform.OS = 'web';
});
+
describe('Unmount', () => {
beforeAll(() => {
jest.spyOn(global, 'clearTimeout');
@@ -297,7 +473,7 @@ describe('Tooltip', () => {
wrapper: { getByText, unmount },
} = await setup({ enterTouchDelay: 5000 });
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
await unmount();
@@ -309,7 +485,7 @@ describe('Tooltip', () => {
wrapper: { getByText, unmount },
} = await setup({ enterTouchDelay: 5000 });
- await fireEvent(getTrigger(getByText), 'hoverOut');
+ await fireEvent(getTrigger(getByText), 'pointerLeave');
await unmount();
@@ -321,7 +497,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText, unmount },
} = await setup();
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
await runTimers(500);
await findByText('some tooltip text');
@@ -348,9 +524,9 @@ describe('Tooltip', () => {
} = await setup();
const trigger = getTrigger(getByText);
- await fireEvent(trigger, 'hoverIn');
- await fireEvent(trigger, 'hoverOut');
- await fireEvent(trigger, 'hoverIn');
+ await fireEvent(trigger, 'pointerEnter');
+ await fireEvent(trigger, 'pointerLeave');
+ await fireEvent(trigger, 'pointerEnter');
expect(global.clearTimeout).toHaveBeenCalledTimes(2);
});
@@ -362,18 +538,46 @@ describe('Tooltip', () => {
wrapper: { queryByText, getByText, findByText },
} = await setup({ enterTouchDelay: 50, leaveTouchDelay: 0 });
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getTrigger(getByText), '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(getTrigger(getByText), 'pointerLeave');
+ });
+ await runTimers(); // leaveTouchDelay → schedules the exit fade
+ await runTimers(); // exit fade duration → unmounts
expect(queryByText('some tooltip text')).not.toBeOnTheScreen();
});
});
+ describe('accessibility', () => {
+ it('describes the trigger with the tooltip so it is announced', async () => {
+ const {
+ wrapper: { getByText },
+ } = await setup({ enterTouchDelay: 0 });
+
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
+ });
+ await runTimers(0);
+
+ // The id comes from `useId`, so the link is checked by reading it back.
+ // eslint-disable-next-line no-restricted-syntax -- cross-check the link
+ const describedBy = getTrigger(getByText).props['aria-describedby'];
+
+ expect(describedBy).toEqual(expect.any(String));
+ expect(getTooltipContainer(getByText)).toHaveProp(
+ 'nativeID',
+ describedBy
+ );
+ });
+ });
+
describe('Tooltip position', () => {
const LAYOUT_WIDTH = 360;
const LAYOUT_HEIGHT = 705;
@@ -395,7 +599,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText },
} = await setup();
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
await runTimers(500);
const tooltip = await findByText('some tooltip text');
@@ -405,7 +609,7 @@ describe('Tooltip', () => {
},
});
- expect(tooltip.parent).toHaveStyle({
+ expect(getTooltipContainer(getByText)).toHaveStyle({
left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210
top: 250, // pageY (200) + height (50)
});
@@ -418,7 +622,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText },
} = await setup({}, { pageX: 0 }); // Component starting at the starting 0 X coord
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
await runTimers(500);
const tooltip = await findByText('some tooltip text');
@@ -428,8 +632,8 @@ describe('Tooltip', () => {
},
});
- expect(tooltip.parent).toHaveStyle({
- left: 0, // Tooltip renders starting from children's x coord
+ expect(getTooltipContainer(getByText)).toHaveStyle({
+ left: 8, // Math.max(EDGE_MARGIN=8, pageX=0)
top: 250,
});
});
@@ -441,7 +645,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText },
} = await setup({}, { pageX: 900, width: 150 }); // Component close to the screen limit
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
await runTimers(500);
const tooltip = await findByText('some tooltip text');
@@ -451,8 +655,8 @@ describe('Tooltip', () => {
},
});
- expect(tooltip.parent).toHaveStyle({
- left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
+ expect(getTooltipContainer(getByText)).toHaveStyle({
+ left: 252, // Math.min(950, LAYOUT_WIDTH(360) - TOOLTIP_WIDTH(100) - EDGE_MARGIN(8))
top: 250,
});
});
@@ -464,7 +668,7 @@ describe('Tooltip', () => {
wrapper: { getByText, findByText },
} = await setup({}, { pageY: 600, height: 50 });
- await fireEvent(getTrigger(getByText), 'hoverIn');
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
await runTimers(500);
const tooltip = await findByText('some tooltip text');
@@ -474,7 +678,7 @@ describe('Tooltip', () => {
},
});
- expect(tooltip.parent).toHaveStyle({
+ expect(getTooltipContainer(getByText)).toHaveStyle({
left: 210,
top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component,
});
@@ -483,3 +687,417 @@ describe('Tooltip', () => {
});
});
});
+
+describe('Tooltip.Rich', () => {
+ const getTrigger = (
+ getByText: Awaited>['getByText']
+ ) => {
+ const trigger = getByText('dummy component').parent;
+
+ if (!trigger) {
+ throw new Error('Expected Tooltip.Rich trigger');
+ }
+
+ return trigger;
+ };
+
+ const getSurface = (
+ getByText: Awaited>['getByText']
+ ) => {
+ const surface = getByText('Body text').parent;
+
+ if (!surface) {
+ throw new Error('Expected Tooltip.Rich surface');
+ }
+
+ return surface;
+ };
+
+ const getTooltipElement = (
+ getByText: Awaited>['getByText']
+ ) => getSurface(getByText).parent?.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, 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();
+ // 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 },
+ } = await setup({ title: 'Heading' });
+
+ await user.press(getTrigger(getByText));
+
+ expect(getByText('Heading')).toHaveStyle({
+ color: LightTheme.colors.onSurfaceVariant,
+ });
+ expect(getByText('Body text')).toHaveStyle({
+ color: LightTheme.colors.onSurfaceVariant,
+ });
+
+ // Surface (container) uses the surfaceContainer color.
+ expect(getSurface(getByText)).toHaveStyle({
+ backgroundColor: LightTheme.colors.surfaceContainer,
+ });
+ });
+
+ it('fades the Android shadow with the elevated surface', async () => {
+ const {
+ wrapper: { getByText },
+ } = await setup();
+
+ await user.press(getTrigger(getByText));
+ await fireEvent(getByText('Body text'), 'layout', {
+ nativeEvent: {
+ layout: { width: 100, height: 100 },
+ },
+ });
+
+ expect(getSurface(getByText)).toHaveStyle({
+ elevation: 3,
+ opacity: 1,
+ });
+ });
+
+ it('dismisses when the backdrop is pressed', async () => {
+ const {
+ wrapper: { getByText, getByRole, queryByText },
+ } = await setup();
+
+ await user.press(getTrigger(getByText));
+ expect(getByText('Body text')).toBeTruthy();
+
+ await user.press(getByRole('button', { name: 'Close' }));
+ await runTimers(); // exit fade → unmount
+
+ expect(queryByText('Body text')).toBeNull();
+ });
+
+ it('names the trigger with the title and content', async () => {
+ const {
+ wrapper: { getByText },
+ } = await setup({ title: 'Heading' });
+
+ expect(getTrigger(getByText)).toHaveAccessibleName('Heading. Body text');
+ });
+
+ it('registers the trigger with page coordinates', async () => {
+ const {
+ wrapper: { getByText },
+ } = await setup();
+
+ const measureInWindow = jest.spyOn(View.prototype, 'measureInWindow');
+
+ await user.press(getTrigger(getByText));
+
+ expect(measureInWindow).not.toHaveBeenCalled();
+ });
+
+ 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;
+
+ if (!triggerA || !triggerB) {
+ throw new Error('Expected both Tooltip.Rich triggers');
+ }
+
+ 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: { getByText, queryByText },
+ } = await setup({ enterTouchDelay: 100 });
+
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), '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 },
+ } = await setup({ enterTouchDelay: 0, leaveTouchDelay: 500 });
+
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
+ });
+ await runTimers(0);
+ expect(getByText('Body text')).toBeTruthy();
+
+ // Leaving the trigger schedules a hide...
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'pointerLeave');
+ // ...but entering the tooltip cancels it.
+ await fireEvent(getByText('Body text'), 'hoverIn');
+ });
+ await runTimers(500);
+
+ expect(getByText('Body text')).toBeTruthy();
+ });
+
+ it('keeps the tooltip open when the hovered trigger is pressed', async () => {
+ const {
+ wrapper: { getByText, queryByText },
+ } = await setup({ enterTouchDelay: 0, leaveTouchDelay: 500 });
+
+ const trigger = getTrigger(getByText);
+ await act(async () => {
+ await fireEvent(trigger, 'pointerEnter');
+ });
+ await runTimers(0);
+ expect(getByText('Body text')).toBeTruthy();
+
+ // The pointer never left the trigger, so the click must not close the
+ // tooltip: nothing would bring it back until the pointer leaves first
+ await user.press(trigger);
+ await runTimers(); // exit fade -> would have unmounted a closed tooltip
+
+ expect(getByText('Body text')).toBeTruthy();
+
+ await act(async () => {
+ await fireEvent(trigger, 'pointerLeave');
+ });
+ await runTimers(500); // leave delay -> hide intent
+ await runTimers(); // exit fade -> unmount
+
+ expect(queryByText('Body text')).toBeNull();
+ });
+
+ it('describes the trigger with the tooltip so it is announced', async () => {
+ const {
+ wrapper: { getByText },
+ } = await setup({ enterTouchDelay: 0 });
+
+ await act(async () => {
+ await fireEvent(getTrigger(getByText), 'pointerEnter');
+ });
+ await runTimers(0);
+
+ // The tooltip element is what a screen reader reads and the trigger
+ // points at it so it is announced on focus
+ expect(getTrigger(getByText)).toHaveProp(
+ 'aria-describedby',
+ expect.any(String)
+ );
+ expect(getTooltipElement(getByText)).toHaveProp('role', 'tooltip');
+ expect(getTooltipElement(getByText)).toHaveProp(
+ 'nativeID',
+ expect.any(String)
+ );
+ });
+
+ it('closes on Escape without reopening from the focus it returns', async () => {
+ // The tooltip listens for Escape on the document, which the native test
+ // renderer has none of: stand in just enough of it to reach the listener
+ const listeners: Array<(event: { key?: string }) => void> = [];
+ Reflect.defineProperty(globalThis, 'document', {
+ value: {
+ addEventListener: (
+ type: string,
+ listener: (event: { key?: string }) => void
+ ) => {
+ if (type === 'keyup') listeners.push(listener);
+ },
+ removeEventListener: (type: string) => {
+ if (type === 'keyup') listeners.length = 0;
+ },
+ },
+ configurable: true,
+ writable: true,
+ });
+
+ try {
+ const {
+ wrapper: { getByText, queryByText, unmount },
+ } = await setup({ leaveTouchDelay: 500 });
+ const trigger = getTrigger(getByText);
+
+ await act(async () => {
+ await fireEvent(trigger, 'focus');
+ });
+ expect(getByText('Body text')).toBeTruthy();
+
+ await act(() => {
+ listeners.forEach((listener) => listener({ key: 'Escape' }));
+ });
+ await runTimers(); // hide intent
+ await runTimers(); // exit fade -> unmount
+
+ expect(queryByText('Body text')).toBeNull();
+
+ // Dismissing puts focus back on the trigger, whose `onFocus` opens the
+ // tooltip again unless that one focus is ignored.
+ await act(async () => {
+ await fireEvent(trigger, 'focus');
+ });
+ await runTimers();
+
+ expect(queryByText('Body text')).toBeNull();
+
+ // A focus after the pointer or focus has moved elsewhere works again.
+ await act(async () => {
+ await fireEvent(trigger, 'blur');
+ await fireEvent(trigger, 'focus');
+ });
+
+ expect(getByText('Body text')).toBeTruthy();
+
+ // Unmount while the stand-in is still in place: the effect cleanup
+ // removes the Escape listener through it.
+ await unmount();
+ } finally {
+ Reflect.deleteProperty(globalThis, 'document');
+ }
+ });
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index 1807d2f249..1c2cfe3f8d 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -48,7 +48,7 @@ export { default as Appbar } from './components/Appbar';
export { default as TouchableRipple } from './components/TouchableRipple/TouchableRipple';
export { default as TextInput } from './components/TextInput';
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';
@@ -141,6 +141,13 @@ export type { Props as TouchableRippleProps } from './components/TouchableRipple
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 TooltipProps,
+ TooltipTriggerProps,
+} from './components/Tooltip/Tooltip';
+export type {
+ Props as TooltipRichProps,
+ TooltipRichTriggerProps,
+} from './components/Tooltip/RichTooltip';
export { type TypescaleKey, type Theme, type Elevation } from './theme/types';