diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index ee54135042..a2098ab6fc 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -180,6 +180,7 @@ const config = { }, Tooltip: { Tooltip: 'Tooltip/Tooltip', + RichTooltip: 'Tooltip/RichTooltip', }, TouchableRipple: { TouchableRipple: 'TouchableRipple/TouchableRipple', diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 8e0802d4a4..54c5d62193 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -6,6 +6,7 @@ import { Appbar, Avatar, Banner, + Button, Chip, FAB, IconButton, @@ -46,17 +47,28 @@ const TooltipExample = () => { header: () => ( - navigation.goBack()} /> + {(props) => ( + navigation.goBack()} + /> + )} - {}} /> + {(props) => ( + {}} /> + )} - {}} /> + {(props) => ( + {}} /> + )} - {}} /> + {(props) => ( + {}} /> + )} ), @@ -83,11 +95,14 @@ const TooltipExample = () => { enterTouchDelay={transport.enterTouchDelay} leaveTouchDelay={transport.leaveTouchDelay} > - {}} - /> + {(props) => ( + {}} + /> + )} ))} @@ -99,57 +114,106 @@ const TooltipExample = () => { onValueChange={setTextAlign} > - + {(props) => ( + + )} - + {(props) => ( + + )} - + {(props) => ( + + )} - + {(props) => } - - } - > - John Doe - + {(props) => ( + + } + > + John Doe + + )} - - ( - - )} - /> - + {(props) => ( + + ( + + )} + /> + + )} + + + ( + <> + + + + )} + > + {(props) => } + + + {(props) => ( + + )} + + + - {}} /> + {(props) => {}} />} diff --git a/jest/testSetup.js b/jest/testSetup.js index c00e611084..b787f0f32e 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -8,9 +8,15 @@ jest.mock('react-native-worklets', () => require('react-native-worklets/lib/module/mock') ); -jest.mock('react-native-reanimated', () => - require('react-native-reanimated/mock') -); +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + + // The mock doesn't ship the CSS easing helpers; stub the ones we use. + return { + ...Reanimated, + cubicBezier: (...points) => `cubic-bezier(${points.join(', ')})`, + }; +}); jest.mock('@react-native-vector-icons/material-design-icons', () => { const React = require('react'); diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx index 5319d18e36..307b16fd4d 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -44,6 +44,18 @@ export type Props = { * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute on long press. + */ + onLongPress?: (e: GestureResponderEvent) => void; + /** + * 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. */ @@ -108,6 +120,9 @@ const FAB = ({ size = 'default', visible = true, onPress, + onLongPress, + onHoverIn, + onHoverOut, containerColor, contentColor, accessibilityLabel, @@ -125,6 +140,9 @@ const FAB = ({ size={size} visible={visible} onPress={onPress} + onLongPress={onLongPress} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} containerColor={containerColor} contentColor={contentColor} accessibilityLabel={accessibilityLabel} diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 4f9b9b5da9..efd3ba0383 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -89,6 +89,18 @@ export type ShellProps = { * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute on long press. + */ + onLongPress?: (e: GestureResponderEvent) => void; + /** + * 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. */ @@ -182,6 +194,9 @@ const Shell = ({ elevation = Tokens.stateElevation.enabled, visible = true, onPress, + onLongPress, + onHoverIn, + onHoverOut, accessibilityLabel = label, accessibilityState, labelMaxFontSizeMultiplier, @@ -288,6 +303,9 @@ const Shell = ({ borderless background={background} onPress={onPress} + onLongPress={onLongPress} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} onFocus={onFocus} onBlur={onBlur} accessibilityLabel={accessibilityLabel} diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx new file mode 100644 index 0000000000..1fa73ffb2a --- /dev/null +++ b/src/components/Tooltip/RichTooltip.tsx @@ -0,0 +1,352 @@ +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 } from './hooks'; +import { Tokens } from './tokens'; +import { getTooltipPosition } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import { addEventListener } from '../../utils/addEventListener'; +import Portal from '../Portal/Portal'; +import Surface from '../Surface'; +import Text from '../Typography/Text'; + +/** + * Props passed to the `children` render function. Spread them onto the trigger + * element (and merge with your own handlers when you have them). + */ +export type TooltipRichTriggerProps = { + onPress?: () => void; + onHoverIn?: () => void; + onHoverOut?: () => void; + onFocus?: () => void; + onBlur?: () => void; +}; + +export type Props = { + /** + * Render function returning the trigger element. The provided props wire the + * tooltip's show/hide behavior and must be spread onto the returned element: + * + * ```js + * + * {(props) => } + * + * ``` + */ + children: (props: TooltipRichTriggerProps) => React.ReactElement; + /** + * Optional subhead shown above the content. + */ + title?: string; + /** + * Supporting body text. A string is rendered with the `bodyMedium` type + * style; pass an element to compose inline links or custom content. + */ + content: string | React.ReactElement; + /** + * Render function for the action buttons (and/or links) shown in a row below + * the content. Call `dismiss` from an action to hide the tooltip: + * + * ```js + * actions={({ dismiss }) => ( + * + * )} + * ``` + */ + actions?: (props: { dismiss: () => void }) => React.ReactNode; + /** + * The number of milliseconds a user must hover the element before showing + * the tooltip (web only). + */ + enterTouchDelay?: number; + /** + * The number of milliseconds after the pointer leaves both the trigger and + * the tooltip before hiding it (web only). + */ + leaveTouchDelay?: number; + /** + * Specifies the largest possible scale the title font can reach. + */ + titleMaxFontSizeMultiplier?: number; + /** + * Specifies the largest possible scale the content font can reach. + */ + contentMaxFontSizeMultiplier?: number; + /** + * @optional + */ + theme?: ThemeProp; +}; + +/** + * Rich tooltips display informative text along with an optional subhead and + * action buttons. Unlike plain tooltips they are persistent and interactive: + * tap the element to toggle the tooltip, then tap outside or an action to + * dismiss it. On web they open on hover and on keyboard focus. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Button, IconButton, Tooltip } from 'react-native-paper'; + * + * const MyComponent = () => ( + * ( + * + * )} + * > + * {(props) => } + * + * ); + * + * export default MyComponent; + * ``` + */ +const RichTooltip = ({ + children, + title, + content, + actions, + enterTouchDelay = 100, + leaveTouchDelay = 500, + titleMaxFontSizeMultiplier, + contentMaxFontSizeMultiplier, + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted + // through the exit animation and owns the measurement + opacity. + const [visible, setVisible] = React.useState(false); + const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = + useTooltipFade(theme, visible); + + const showTimer = React.useRef | null>(null); + const hideTimer = React.useRef | null>(null); + + const clearShowTimer = React.useCallback(() => { + if (showTimer.current) { + clearTimeout(showTimer.current); + showTimer.current = null; + } + }, []); + + const clearHideTimer = React.useCallback(() => { + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + }, []); + + React.useEffect(() => { + return () => { + clearShowTimer(); + clearHideTimer(); + }; + }, [clearShowTimer, clearHideTimer]); + + React.useEffect(() => { + const subscription = addEventListener(Dimensions, 'change', () => + setVisible(false) + ); + + return () => subscription.remove(); + }, []); + + const show = React.useCallback(() => { + takeSingletonSlot(() => setVisible(false)); + clearHideTimer(); + setVisible(true); + }, [clearHideTimer]); + + const hide = React.useCallback(() => { + clearShowTimer(); + setVisible(false); + }, [clearShowTimer]); + + const scheduleHide = React.useCallback(() => { + clearShowTimer(); + hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); + }, [clearShowTimer, leaveTouchDelay]); + + // Mobile: a tap toggles the tooltip. + const handlePress = React.useCallback(() => { + setVisible((v) => { + if (!v) takeSingletonSlot(() => setVisible(false)); + return !v; + }); + clearShowTimer(); + clearHideTimer(); + }, [clearShowTimer, clearHideTimer]); + + // Web: open on hover (with a short enter delay) and on keyboard focus. + const handleHoverIn = React.useCallback(() => { + clearHideTimer(); + showTimer.current = setTimeout(() => { + 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 as unknown as HTMLElement | null; + if (el) { + 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' + ? { onFocus: show, onBlur: scheduleHide } + : { onPress: handlePress }; + + // Web only: keep the tooltip open while the pointer travels from the trigger + // into the tooltip (and re-schedule the hide once it leaves the tooltip). + const tooltipHoverProps = + Platform.OS === 'web' + ? { onHoverIn: clearHideTimer, onHoverOut: scheduleHide } + : {}; + + return ( + <> + {rendered && ( + + + + + + {title ? ( + + {title} + + ) : null} + {typeof content === 'string' ? ( + + {content} + + ) : ( + content + )} + {actions ? ( + + {actions({ dismiss: hide })} + + ) : null} + + + + + )} + + {children(triggerProps)} + + + ); +}; + +RichTooltip.displayName = 'Tooltip.Rich'; + +const styles = StyleSheet.create({ + container: { + alignSelf: 'flex-start', + maxWidth: Tokens.rich.maxWidth, + }, + surface: { + paddingHorizontal: Tokens.rich.paddingHorizontal, + paddingVertical: Tokens.rich.paddingVertical, + rowGap: Tokens.rich.gap, + }, + actions: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + pressContainer: { + alignSelf: 'flex-start', + ...(Platform.OS === 'web' && { cursor: 'default' }), + } as ViewStyle, +}); + +export default RichTooltip; diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 36dc08971b..df6764ae75 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,87 @@ 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); + hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); }, [leaveTouchDelay]); - const handlePress = React.useCallback(() => { - if (touched.current) { - return null; - } - if (!isValidChild) return null; - const props = children.props as TooltipChildProps; - if (props.disabled) return null; - return props.onPress?.(); - }, [children.props, isValidChild]); - - const handleHoverIn = React.useCallback(() => { - handleTouchStart(); - if (isValidChild) { - (children.props as TooltipChildProps).onHoverIn?.(); - } - }, [children.props, handleTouchStart, isValidChild]); - - const handleHoverOut = React.useCallback(() => { - handleTouchEnd(); - if (isValidChild) { - (children.props as TooltipChildProps).onHoverOut?.(); - } - }, [children.props, handleTouchEnd, isValidChild]); - - const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - childrenWrapperRef.current?.measure( - (_x, _y, width, height, pageX, pageY) => { - setMeasurement({ - children: { pageX, pageY, height, width }, - tooltip: { ...layout }, - measured: true, - }); + // 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 as unknown as HTMLElement | null; + if (el) { + 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 +202,24 @@ const Tooltip = ({ accessibilityLiveRegion="polite" numberOfLines={1} selectable={false} - variant="labelLarge" - style={{ color: theme.colors.surface }} + variant={Tokens.plain.typescale} + style={{ color: theme.colors[Tokens.plain.content] }} maxFontSizeMultiplier={titleMaxFontSizeMultiplier} > {title} - + )} - - {React.cloneElement(children, { - ...rest, - ...(isWeb ? webPressProps : mobilePressProps), - })} - + {children(triggerProps)} + ); }; @@ -247,15 +230,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..7aeb95ab44 --- /dev/null +++ b/src/components/Tooltip/hooks.ts @@ -0,0 +1,131 @@ +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; + +export const takeSingletonSlot = (dismiss: () => void) => { + dismissCurrentTooltip?.(); + dismissCurrentTooltip = dismiss; +}; + +/** + * Drives the show/hide fade shared by both tooltip variants. + * + * Given a `visible` intent it keeps the tooltip mounted (`rendered`) through + * the exit fade so the animation can play before unmounting, holds the opacity + * at 0 until the tooltip has been measured (so it never flashes at the wrong + * position), and honors the reduce-motion preference. The fade itself is a + * Reanimated CSS transition on `opacity`; the unmount is deferred by the exit + * duration via a timer, which keeps the behavior deterministic and testable. + */ +export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { + const reduceMotion = useReduceMotion(); + const [rendered, setRendered] = React.useState(false); + const [measurement, setMeasurement] = React.useState({ + children: { pageX: 0, pageY: 0, width: 0, height: 0 }, + tooltip: { x: 0, y: 0, width: 0, height: 0 }, + measured: false, + }); + const childrenWrapperRef = React.useRef(null); + // The trigger is measured synchronously and stashed here so the tooltip's + // own layout can combine the two into the final measurement in one update. + const childrenMeasurement = React.useRef( + null + ); + + const enterDuration = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.enter.duration]; + const exitDuration = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + // Mount as soon as the tooltip is requested — derived during render rather + // than synced from an effect. + if (visible && !rendered) { + setRendered(true); + } + + // Measure the trigger synchronously once the tooltip is requested, instead + // of waiting for the tooltip's `onLayout` to do it. (The tooltip itself + // lives in a `Portal`, so its own size still comes from its layout below.) + React.useLayoutEffect(() => { + if (!rendered || !visible) { + return; + } + + childrenWrapperRef.current?.measure( + (_x, _y, width, height, pageX, pageY) => { + // 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, + }; + } + ); + }, [rendered, visible]); + + // Keep the tooltip mounted through the exit fade, then unmount. + React.useEffect(() => { + if (!rendered || visible) { + return; + } + + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ + children: { pageX: 0, pageY: 0, width: 0, height: 0 }, + tooltip: { x: 0, y: 0, width: 0, height: 0 }, + measured: false, + }); + childrenMeasurement.current = null; + }, exitDuration); + + return () => clearTimeout(id); + }, [rendered, visible, exitDuration]); + + // The tooltip reports its own size on layout; combine it with the trigger + // measurement captured above to compute the final position in one update. + const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + if (!childrenMeasurement.current) { + return; + } + + setMeasurement({ + children: childrenMeasurement.current, + tooltip: layout, + measured: true, + }); + }; + + // A Reanimated CSS transition drives the fade — no shared values. Opacity is + // held at 0 until the tooltip has been measured so it never flashes at the + // wrong position; entering decelerates in, exiting accelerates out. + const fadeStyle = { + opacity: visible && measurement.measured ? 1 : 0, + transitionProperty: 'opacity', + transitionDuration: `${visible ? enterDuration : exitDuration}ms`, + transitionTimingFunction: visible + ? cubicBezier(...theme.motion.easing[Tokens.motion.enter.easing]) + : cubicBezier(...theme.motion.easing[Tokens.motion.exit.easing]), + }; + + return { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef }; +}; diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx new file mode 100644 index 0000000000..f5fa880a6b --- /dev/null +++ b/src/components/Tooltip/index.tsx @@ -0,0 +1,6 @@ +import RichTooltip from './RichTooltip'; +import TooltipBase from './Tooltip'; + +const Tooltip = Object.assign(TooltipBase, { Rich: RichTooltip }); + +export default Tooltip; diff --git a/src/components/Tooltip/tokens.ts b/src/components/Tooltip/tokens.ts new file mode 100644 index 0000000000..eddf391669 --- /dev/null +++ b/src/components/Tooltip/tokens.ts @@ -0,0 +1,42 @@ +/** + * Plain tooltip — a single line of text on an inverse-surface container. + * https://m3.material.io/components/tooltips/specs#1e6d4d8a + */ +const plain = { + container: 'inverseSurface', + content: 'inverseOnSurface', + shape: 'extraSmall', + height: 32, + paddingHorizontal: 16, + typescale: 'bodySmall', +} as const; + +/** + * Rich tooltip — an optional subhead, supporting text and action buttons on a + * surface-container container at elevation level 2. + * https://m3.material.io/components/tooltips/specs#8e6cf915 + */ +const rich = { + container: 'surfaceContainer', + title: 'onSurface', + content: 'onSurfaceVariant', + shape: 'medium', + elevation: 2, + maxWidth: 312, + paddingHorizontal: 16, + paddingVertical: 12, + titleTypescale: 'titleSmall', + contentTypescale: 'bodyMedium', + gap: 4, +} as const; + +/** + * Fade transition on show/hide. Keys are resolved against `theme.motion` at + * runtime: enter decelerates in, exit accelerates out, per the M3 motion spec. + */ +const motion = { + enter: { duration: 'short3', easing: 'standardDecelerate' }, + exit: { duration: 'short2', easing: 'standardAccelerate' }, +} as const; + +export const Tokens = { plain, rich, motion }; diff --git a/src/components/Tooltip/utils.ts b/src/components/Tooltip/utils.ts index 43baf684fd..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__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 75b4a18cf7..6dc54cc2af 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,202 @@ describe('Tooltip', () => { }); }); }); + +describe('Tooltip.Rich', () => { + const getTrigger = ( + getByText: Awaited>['getByText'] + ) => getByText('dummy component').parent!; + + const runTimers = async (ms = 1000) => { + await act(async () => { + await jest.advanceTimersByTimeAsync(ms); + }); + }; + + let user: ReturnType; + beforeEach(() => { + user = userEvent.setup(); + }); + + const setup = async ( + propOverrides?: Partial> + ) => { + jest + .spyOn(View.prototype, 'measure') + .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + + const wrapper = await render( + + + {(props) => } + + + ); + + return { wrapper }; + }; + + it('is exposed as a compound component on Tooltip', () => { + expect(TooltipCompound.Rich).toBeDefined(); + }); + + describe('Mobile', () => { + beforeAll(() => { + Platform.OS = 'android'; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('toggles title, content and actions when the trigger is pressed', async () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = await setup({ + title: 'Heading', + actions: () => Learn more, + }); + + expect(queryByText('Body text')).toBeNull(); + + await user.press(getTrigger(getByText)); + + expect(getByText('Heading')).toBeTruthy(); + expect(getByText('Body text')).toBeTruthy(); + expect(getByText('Learn more')).toBeTruthy(); + expect(getByTestId('tooltip-rich-container')).toBeTruthy(); + + // Pressing again toggles it back off. + await user.press(getTrigger(getByText)); + await runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('renders a custom element as content', async () => { + const { + wrapper: { getByText }, + } = await setup({ content: Custom node }); + + await user.press(getTrigger(getByText)); + + expect(getByText('Custom node')).toBeTruthy(); + }); + + it('uses the surfaceContainer container with MD3 title/content roles', async () => { + const { + wrapper: { getByText, getByTestId }, + } = await setup({ title: 'Heading' }); + + await user.press(getTrigger(getByText)); + + expect(getByText('Heading')).toHaveStyle({ + color: getTheme().colors.onSurface, + }); + expect(getByText('Body text')).toHaveStyle({ + color: getTheme().colors.onSurfaceVariant, + }); + + // Surface (container) uses the surfaceContainer color. + expect(getByTestId('tooltip-rich-surface-container')).toHaveStyle({ + backgroundColor: getTheme().colors.surfaceContainer, + }); + }); + + it('dismisses when the backdrop is pressed', async () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = await setup(); + + await user.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + await user.press(getByTestId('tooltip-rich-backdrop')); + await runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('dismisses when an action calls dismiss', async () => { + const { + wrapper: { getByText, queryByText }, + } = await setup({ + actions: ({ dismiss }) => Learn more, + }); + + await user.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + await user.press(getByText('Learn more')); + await runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + }); + + describe('Web', () => { + beforeAll(() => { + Platform.OS = 'web'; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('opens on hover after the enter delay', async () => { + const { + wrapper: { 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';