diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index ee54135042..a2098ab6fc 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -180,6 +180,7 @@ const config = { }, Tooltip: { Tooltip: 'Tooltip/Tooltip', + RichTooltip: 'Tooltip/RichTooltip', }, TouchableRipple: { TouchableRipple: 'TouchableRipple/TouchableRipple', diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 8e0802d4a4..54c5d62193 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -6,6 +6,7 @@ import { Appbar, Avatar, Banner, + Button, Chip, FAB, IconButton, @@ -46,17 +47,28 @@ const TooltipExample = () => { header: () => ( - navigation.goBack()} /> + {(props) => ( + navigation.goBack()} + /> + )} - {}} /> + {(props) => ( + {}} /> + )} - {}} /> + {(props) => ( + {}} /> + )} - {}} /> + {(props) => ( + {}} /> + )} ), @@ -83,11 +95,14 @@ const TooltipExample = () => { enterTouchDelay={transport.enterTouchDelay} leaveTouchDelay={transport.leaveTouchDelay} > - {}} - /> + {(props) => ( + {}} + /> + )} ))} @@ -99,57 +114,106 @@ const TooltipExample = () => { onValueChange={setTextAlign} > - + {(props) => ( + + )} - + {(props) => ( + + )} - + {(props) => ( + + )} - + {(props) => } - - } - > - John Doe - + {(props) => ( + + } + > + John Doe + + )} - - ( - - )} - /> - + {(props) => ( + + ( + + )} + /> + + )} + + + ( + <> + + + + )} + > + {(props) => } + + + {(props) => ( + + )} + + + - {}} /> + {(props) => {}} />} diff --git a/jest/testSetup.js b/jest/testSetup.js index c00e611084..b787f0f32e 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -8,9 +8,15 @@ jest.mock('react-native-worklets', () => require('react-native-worklets/lib/module/mock') ); -jest.mock('react-native-reanimated', () => - require('react-native-reanimated/mock') -); +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + + // The mock doesn't ship the CSS easing helpers; stub the ones we use. + return { + ...Reanimated, + cubicBezier: (...points) => `cubic-bezier(${points.join(', ')})`, + }; +}); jest.mock('@react-native-vector-icons/material-design-icons', () => { const React = require('react'); diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx new file mode 100644 index 0000000000..fd4422d825 --- /dev/null +++ b/src/components/Tooltip/RichTooltip.tsx @@ -0,0 +1,322 @@ +import * as React from 'react'; +import { + Dimensions, + View, + StyleSheet, + Platform, + Pressable, +} from 'react-native'; +import type { ViewStyle } from 'react-native'; + +import Animated from 'react-native-reanimated'; + +import { useTooltipFade } from './hooks'; +import { Tokens } from './tokens'; +import { getTooltipPosition } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import { addEventListener } from '../../utils/addEventListener'; +import Portal from '../Portal/Portal'; +import Surface from '../Surface'; +import Text from '../Typography/Text'; + +/** + * Props passed to the `children` render function. Spread them onto the trigger + * element (and merge with your own handlers when you have them). + */ +export type TooltipRichTriggerProps = { + onPress?: () => void; + onHoverIn?: () => void; + onHoverOut?: () => void; + onFocus?: () => void; + onBlur?: () => void; +}; + +export type Props = { + /** + * Render function returning the trigger element. The provided props wire the + * tooltip's show/hide behavior and must be spread onto the returned element: + * + * ```js + * + * {(props) => } + * + * ``` + */ + children: (props: TooltipRichTriggerProps) => React.ReactElement; + /** + * Optional subhead shown above the content. + */ + title?: string; + /** + * Supporting body text. A string is rendered with the `bodyMedium` type + * style; pass an element to compose inline links or custom content. + */ + content: string | React.ReactElement; + /** + * Render function for the action buttons (and/or links) shown in a row below + * the content. Call `dismiss` from an action to hide the tooltip: + * + * ```js + * actions={({ dismiss }) => ( + * + * )} + * ``` + */ + actions?: (props: { dismiss: () => void }) => React.ReactNode; + /** + * The number of milliseconds a user must hover the element before showing + * the tooltip (web only). + */ + enterTouchDelay?: number; + /** + * The number of milliseconds after the pointer leaves both the trigger and + * the tooltip before hiding it (web only). + */ + leaveTouchDelay?: number; + /** + * Specifies the largest possible scale the title font can reach. + */ + titleMaxFontSizeMultiplier?: number; + /** + * Specifies the largest possible scale the content font can reach. + */ + contentMaxFontSizeMultiplier?: number; + /** + * @optional + */ + theme?: ThemeProp; +}; + +/** + * Rich tooltips display informative text along with an optional subhead and + * action buttons. Unlike plain tooltips they are persistent and interactive: + * tap the element to toggle the tooltip, then tap outside or an action to + * dismiss it. On web they open on hover and on keyboard focus. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Button, IconButton, Tooltip } from 'react-native-paper'; + * + * const MyComponent = () => ( + * ( + * + * )} + * > + * {(props) => } + * + * ); + * + * export default MyComponent; + * ``` + */ +const RichTooltip = ({ + children, + title, + content, + actions, + enterTouchDelay = 100, + leaveTouchDelay = 500, + titleMaxFontSizeMultiplier, + contentMaxFontSizeMultiplier, + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted + // through the exit animation and owns the measurement + opacity. + const [visible, setVisible] = React.useState(false); + const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = + useTooltipFade(theme, visible); + + const showTimer = React.useRef | null>(null); + const hideTimer = React.useRef | null>(null); + + const clearShowTimer = React.useCallback(() => { + if (showTimer.current) { + clearTimeout(showTimer.current); + showTimer.current = null; + } + }, []); + + const clearHideTimer = React.useCallback(() => { + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + }, []); + + React.useEffect(() => { + return () => { + clearShowTimer(); + clearHideTimer(); + }; + }, [clearShowTimer, clearHideTimer]); + + React.useEffect(() => { + const subscription = addEventListener(Dimensions, 'change', () => + setVisible(false) + ); + + return () => subscription.remove(); + }, []); + + const show = React.useCallback(() => { + clearHideTimer(); + setVisible(true); + }, [clearHideTimer]); + + const hide = React.useCallback(() => { + clearShowTimer(); + setVisible(false); + }, [clearShowTimer]); + + const scheduleHide = React.useCallback(() => { + clearShowTimer(); + hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); + }, [clearShowTimer, leaveTouchDelay]); + + // Mobile: a tap toggles the tooltip. + const handlePress = React.useCallback(() => { + setVisible((v) => !v); + clearShowTimer(); + clearHideTimer(); + }, [clearShowTimer, clearHideTimer]); + + // Web: open on hover (with a short enter delay) and on keyboard focus. + const handleHoverIn = React.useCallback(() => { + clearHideTimer(); + showTimer.current = setTimeout(() => setVisible(true), enterTouchDelay); + }, [clearHideTimer, enterTouchDelay]); + + // Trigger props handed to the consumer's render function. + const triggerProps: TooltipRichTriggerProps = + Platform.OS === 'web' + ? { + onHoverIn: handleHoverIn, + onHoverOut: scheduleHide, + onFocus: show, + onBlur: scheduleHide, + } + : { onPress: handlePress }; + + // Web only: keep the tooltip open while the pointer travels from the trigger + // into the tooltip (and re-schedule the hide once it leaves the tooltip). + const tooltipHoverProps = + Platform.OS === 'web' + ? { onHoverIn: clearHideTimer, onHoverOut: scheduleHide } + : {}; + + return ( + <> + {rendered && ( + + + + + + {title ? ( + + {title} + + ) : null} + {typeof content === 'string' ? ( + + {content} + + ) : ( + content + )} + {actions ? ( + + {actions({ dismiss: hide })} + + ) : null} + + + + + )} + + {children(triggerProps)} + + + ); +}; + +RichTooltip.displayName = 'Tooltip.Rich'; + +const styles = StyleSheet.create({ + container: { + alignSelf: 'flex-start', + maxWidth: Tokens.rich.maxWidth, + }, + surface: { + paddingHorizontal: Tokens.rich.paddingHorizontal, + paddingVertical: Tokens.rich.paddingVertical, + rowGap: Tokens.rich.gap, + }, + actions: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + pressContainer: { + alignSelf: 'flex-start', + ...(Platform.OS === 'web' && { cursor: 'default' }), + } as ViewStyle, +}); + +export default RichTooltip; diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 36dc08971b..73f51c6ca4 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -1,26 +1,42 @@ import * as React from 'react'; -import { - Dimensions, - View, - StyleSheet, - Platform, - Pressable, -} from 'react-native'; -import type { LayoutChangeEvent, ViewStyle } from 'react-native'; +import { Dimensions, StyleSheet, Platform, Pressable } from 'react-native'; +import type { ViewStyle } from 'react-native'; +import Animated from 'react-native-reanimated'; + +import { useTooltipFade } from './hooks'; +import { Tokens } from './tokens'; import { getTooltipPosition } from './utils'; -import type { Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; import Portal from '../Portal/Portal'; import Text from '../Typography/Text'; +/** + * Props passed to the `children` render function. Spread them onto the trigger + * element (and merge with your own handlers when you have them). + */ +export type TooltipTriggerProps = { + onLongPress?: () => void; + onPressOut?: () => void; + delayLongPress?: number; + onHoverIn?: () => void; + onHoverOut?: () => void; +}; + export type Props = { /** - * Tooltip reference element. Needs to be able to hold a ref. + * Render function returning the trigger element. The provided props wire the + * tooltip's show/hide behavior and must be spread onto the returned element: + * + * ```js + * + * {(props) => {}} />} + * + * ``` */ - children: React.ReactElement; + children: (props: TooltipTriggerProps) => React.ReactElement; /** * The number of milliseconds a user must touch the element before showing the tooltip. */ @@ -48,6 +64,8 @@ export type Props = { * * Plain tooltips, when activated, display a text label identifying an element, such as a description of its function. Tooltips should include only short, descriptive text and avoid restating visible UI text. * + * For tooltips with a title, supporting text and action buttons, see `Tooltip.Rich`. + * * ## Usage * ```js * import * as React from 'react'; @@ -55,7 +73,7 @@ export type Props = { * * const MyComponent = () => ( * - * {}} /> + * {(props) => {}} />} * * ); * @@ -69,40 +87,21 @@ const Tooltip = ({ title, theme: themeOverrides, titleMaxFontSizeMultiplier, - ...rest }: Props) => { - const isWeb = Platform.OS === 'web'; - const theme = useInternalTheme(themeOverrides); + // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted + // through the exit animation and owns the measurement + opacity. const [visible, setVisible] = React.useState(false); + const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = + useTooltipFade(theme, visible); - const [measurement, setMeasurement] = React.useState({ - children: {}, - tooltip: {}, - measured: false, - }); - const showTooltipTimer = React.useRef([]); - const hideTooltipTimer = React.useRef([]); - - const childrenWrapperRef = React.useRef(null); - const touched = React.useRef(false); - - const isValidChild = React.useMemo( - () => React.isValidElement(children), - [children] - ); + const showTimer = React.useRef | null>(null); + const hideTimer = React.useRef | null>(null); React.useEffect(() => { return () => { - if (showTooltipTimer.current.length) { - showTooltipTimer.current.forEach((t) => clearTimeout(t)); - showTooltipTimer.current = []; - } - - if (hideTooltipTimer.current.length) { - hideTooltipTimer.current.forEach((t) => clearTimeout(t)); - hideTooltipTimer.current = []; - } + if (showTimer.current) clearTimeout(showTimer.current); + if (hideTimer.current) clearTimeout(hideTimer.current); }; }, []); @@ -115,102 +114,49 @@ const Tooltip = ({ }, []); const handleTouchStart = React.useCallback(() => { - if (hideTooltipTimer.current.length) { - hideTooltipTimer.current.forEach((t) => clearTimeout(t)); - hideTooltipTimer.current = []; + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; } - if (isWeb) { - let id = setTimeout(() => { - touched.current = true; - setVisible(true); - }, enterTouchDelay) as unknown as NodeJS.Timeout; - showTooltipTimer.current.push(id); + if (Platform.OS === 'web') { + showTimer.current = setTimeout(() => setVisible(true), enterTouchDelay); } else { - touched.current = true; setVisible(true); } - }, [isWeb, enterTouchDelay]); + }, [enterTouchDelay]); const handleTouchEnd = React.useCallback(() => { - touched.current = false; - if (showTooltipTimer.current.length) { - showTooltipTimer.current.forEach((t) => clearTimeout(t)); - showTooltipTimer.current = []; + if (showTimer.current) { + clearTimeout(showTimer.current); + showTimer.current = null; } - - let id = setTimeout(() => { - setVisible(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); - }, leaveTouchDelay) as unknown as NodeJS.Timeout; - hideTooltipTimer.current.push(id); + hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); }, [leaveTouchDelay]); - const handlePress = React.useCallback(() => { - if (touched.current) { - return null; - } - if (!isValidChild) return null; - const props = children.props as TooltipChildProps; - if (props.disabled) return null; - return props.onPress?.(); - }, [children.props, isValidChild]); - - const handleHoverIn = React.useCallback(() => { - handleTouchStart(); - if (isValidChild) { - (children.props as TooltipChildProps).onHoverIn?.(); - } - }, [children.props, handleTouchStart, isValidChild]); - - const handleHoverOut = React.useCallback(() => { - handleTouchEnd(); - if (isValidChild) { - (children.props as TooltipChildProps).onHoverOut?.(); - } - }, [children.props, handleTouchEnd, isValidChild]); - - const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - childrenWrapperRef.current?.measure( - (_x, _y, width, height, pageX, pageY) => { - setMeasurement({ - children: { pageX, pageY, height, width }, - tooltip: { ...layout }, - measured: true, - }); - } - ); - }; - - const mobilePressProps = { - onPress: handlePress, - onLongPress: () => handleTouchStart(), - onPressOut: () => handleTouchEnd(), - delayLongPress: enterTouchDelay, - }; - - const webPressProps = { - onHoverIn: handleHoverIn, - onHoverOut: handleHoverOut, - }; + const triggerProps: TooltipTriggerProps = + Platform.OS === 'web' + ? { onHoverIn: handleTouchStart, onHoverOut: handleTouchEnd } + : { + onLongPress: handleTouchStart, + onPressOut: handleTouchEnd, + delayLongPress: enterTouchDelay, + }; return ( <> - {visible && ( + {rendered && ( - - ), - borderRadius: theme.shapes.corner.extraSmall, - ...(measurement.measured ? styles.visible : styles.hidden), + backgroundColor: theme.colors[Tokens.plain.container], + ...getTooltipPosition(measurement), + borderRadius: theme.shapes.corner[Tokens.plain.shape], }, + fadeStyle, ]} testID="tooltip-container" > @@ -218,24 +164,22 @@ const Tooltip = ({ accessibilityLiveRegion="polite" numberOfLines={1} selectable={false} - variant="labelLarge" - style={{ color: theme.colors.surface }} + variant={Tokens.plain.typescale} + style={{ color: theme.colors[Tokens.plain.content] }} maxFontSizeMultiplier={titleMaxFontSizeMultiplier} > {title} - + )} - {React.cloneElement(children, { - ...rest, - ...(isWeb ? webPressProps : mobilePressProps), - })} + {children(triggerProps)} ); @@ -247,15 +191,9 @@ const styles = StyleSheet.create({ tooltip: { alignSelf: 'flex-start', justifyContent: 'center', - paddingHorizontal: 16, - height: 32, - maxHeight: 32, - }, - visible: { - opacity: 1, - }, - hidden: { - opacity: 0, + paddingHorizontal: Tokens.plain.paddingHorizontal, + height: Tokens.plain.height, + maxHeight: Tokens.plain.height, }, pressContainer: { ...(Platform.OS === 'web' && { cursor: 'default' }), diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts new file mode 100644 index 0000000000..ff696588e0 --- /dev/null +++ b/src/components/Tooltip/hooks.ts @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import type { LayoutChangeEvent } from 'react-native'; + +import { cubicBezier } from 'react-native-reanimated'; + +import { Tokens } from './tokens'; +import type { Measurement } from './utils'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import type { InternalTheme } from '../../types'; + +/** + * Drives the show/hide fade shared by both tooltip variants. + * + * Given a `visible` intent it keeps the tooltip mounted (`rendered`) through + * the exit fade so the animation can play before unmounting, holds the opacity + * at 0 until the tooltip has been measured (so it never flashes at the wrong + * position), and honors the reduce-motion preference. The fade itself is a + * Reanimated CSS transition on `opacity`; the unmount is deferred by the exit + * duration via a timer, which keeps the behavior deterministic and testable. + */ +export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { + const reduceMotion = useReduceMotion(); + const [rendered, setRendered] = React.useState(false); + const [measurement, setMeasurement] = React.useState({ + children: { pageX: 0, pageY: 0, width: 0, height: 0 }, + tooltip: { x: 0, y: 0, width: 0, height: 0 }, + measured: false, + }); + const childrenWrapperRef = React.useRef(null); + // The trigger is measured synchronously and stashed here so the tooltip's + // own layout can combine the two into the final measurement in one update. + const childrenMeasurement = React.useRef( + null + ); + + const enterDuration = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.enter.duration]; + const exitDuration = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + // Mount as soon as the tooltip is requested — derived during render rather + // than synced from an effect. + if (visible && !rendered) { + setRendered(true); + } + + // Measure the trigger synchronously once the tooltip is requested, instead + // of waiting for the tooltip's `onLayout` to do it. (The tooltip itself + // lives in a `Portal`, so its own size still comes from its layout below.) + React.useLayoutEffect(() => { + if (!rendered || !visible) { + return; + } + + childrenWrapperRef.current?.measure( + (_x, _y, width, height, pageX, pageY) => { + childrenMeasurement.current = { pageX, pageY, width, height }; + } + ); + }, [rendered, visible]); + + // Keep the tooltip mounted through the exit fade, then unmount. + React.useEffect(() => { + if (!rendered || visible) { + return; + } + + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ + children: { pageX: 0, pageY: 0, width: 0, height: 0 }, + tooltip: { x: 0, y: 0, width: 0, height: 0 }, + measured: false, + }); + childrenMeasurement.current = null; + }, exitDuration); + + return () => clearTimeout(id); + }, [rendered, visible, exitDuration]); + + // The tooltip reports its own size on layout; combine it with the trigger + // measurement captured above to compute the final position in one update. + const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + if (!childrenMeasurement.current) { + return; + } + + setMeasurement({ + children: childrenMeasurement.current, + tooltip: layout, + measured: true, + }); + }; + + // A Reanimated CSS transition drives the fade — no shared values. Opacity is + // held at 0 until the tooltip has been measured so it never flashes at the + // wrong position; entering decelerates in, exiting accelerates out. + const fadeStyle = { + opacity: visible && measurement.measured ? 1 : 0, + transitionProperty: 'opacity', + transitionDuration: `${visible ? enterDuration : exitDuration}ms`, + transitionTimingFunction: visible + ? cubicBezier(...theme.motion.easing[Tokens.motion.enter.easing]) + : cubicBezier(...theme.motion.easing[Tokens.motion.exit.easing]), + }; + + return { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef }; +}; diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx new file mode 100644 index 0000000000..f5fa880a6b --- /dev/null +++ b/src/components/Tooltip/index.tsx @@ -0,0 +1,6 @@ +import RichTooltip from './RichTooltip'; +import TooltipBase from './Tooltip'; + +const Tooltip = Object.assign(TooltipBase, { Rich: RichTooltip }); + +export default Tooltip; diff --git a/src/components/Tooltip/tokens.ts b/src/components/Tooltip/tokens.ts new file mode 100644 index 0000000000..eddf391669 --- /dev/null +++ b/src/components/Tooltip/tokens.ts @@ -0,0 +1,42 @@ +/** + * Plain tooltip — a single line of text on an inverse-surface container. + * https://m3.material.io/components/tooltips/specs#1e6d4d8a + */ +const plain = { + container: 'inverseSurface', + content: 'inverseOnSurface', + shape: 'extraSmall', + height: 32, + paddingHorizontal: 16, + typescale: 'bodySmall', +} as const; + +/** + * Rich tooltip — an optional subhead, supporting text and action buttons on a + * surface-container container at elevation level 2. + * https://m3.material.io/components/tooltips/specs#8e6cf915 + */ +const rich = { + container: 'surfaceContainer', + title: 'onSurface', + content: 'onSurfaceVariant', + shape: 'medium', + elevation: 2, + maxWidth: 312, + paddingHorizontal: 16, + paddingVertical: 12, + titleTypescale: 'titleSmall', + contentTypescale: 'bodyMedium', + gap: 4, +} as const; + +/** + * Fade transition on show/hide. Keys are resolved against `theme.motion` at + * runtime: enter decelerates in, exit accelerates out, per the M3 motion spec. + */ +const motion = { + enter: { duration: 'short3', easing: 'standardDecelerate' }, + exit: { duration: 'short2', easing: 'standardAccelerate' }, +} as const; + +export const Tokens = { plain, rich, motion }; diff --git a/src/components/Tooltip/utils.ts b/src/components/Tooltip/utils.ts index 43baf684fd..190a99b77b 100644 --- a/src/components/Tooltip/utils.ts +++ b/src/components/Tooltip/utils.ts @@ -119,14 +119,12 @@ const getChildrenMeasures = ( export const getTooltipPosition = ( { children, tooltip, measured }: Measurement, - component: React.ReactElement<{ - style: StyleProp; - }> + childStyle?: StyleProp ): {} | { left: number; top: number } => { if (!measured) return {}; let measures = children; - if (component.props.style) { - measures = getChildrenMeasures(component.props.style, children); + if (childStyle) { + measures = getChildrenMeasures(childStyle, children); } return { diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 75b4a18cf7..c1e91bf52a 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, + beforeEach, describe, expect, it, @@ -14,8 +15,10 @@ import { import { act, fireEvent, userEvent } from '@testing-library/react-native'; import PaperProvider from '../../core/PaperProvider'; +import { getTheme } from '../../core/theming'; import { render } from '../../test-utils'; -import Tooltip from '../Tooltip/Tooltip'; +import TooltipCompound from '../Tooltip'; +import Tooltip, { type TooltipTriggerProps } from '../Tooltip/Tooltip'; const mockedRemoveEventListener = jest.fn(); @@ -28,10 +31,11 @@ jest.mock('../../utils/addEventListener', () => ({ const DummyComponent = ({ ref, ...props -}: ViewProps & { - ref?: React.RefObject; -}) => ( - +}: ViewProps & + TooltipTriggerProps & { + ref?: React.RefObject; + }) => ( + dummy component ); @@ -51,13 +55,12 @@ describe('Tooltip', () => { return trigger; }; - const runTimers = async (ms?: number) => { - await act(() => { - if (ms === undefined) { - jest.runOnlyPendingTimers(); - } else { - jest.advanceTimersByTime(ms); - } + // Advancing async lets the timer callbacks' state updates flush and re-render + // (a sync `act` doesn't under the async renderer). Default to a large step + // that drains every pending tooltip timer. + const runTimers = async (ms = 1000) => { + await act(async () => { + await jest.advanceTimersByTimeAsync(ms); }); }; @@ -66,7 +69,7 @@ describe('Tooltip', () => { measure = {} ) => { const defaultProps = { - children: , + children: (props: TooltipTriggerProps) => , title: 'some tooltip text', ...propOverrides, }; @@ -94,6 +97,14 @@ describe('Tooltip', () => { return { wrapper }; }; + // `userEvent.setup()` coordinates the press gestures with the fake timers so + // its `act()` scopes don't overlap the tooltip's own timer-driven updates + // (overlapping act() calls corrupt the renderer across tests). + let user: ReturnType; + beforeEach(() => { + user = userEvent.setup(); + }); + describe('Mobile', () => { beforeAll(() => { Platform.OS = 'android'; @@ -126,7 +137,7 @@ describe('Tooltip', () => { wrapper: { getByText, findByText, unmount }, } = await setup(); - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await findByText('some tooltip text'); @@ -152,8 +163,8 @@ describe('Tooltip', () => { } = await setup(); const trigger = getTrigger(getByText); - await userEvent.longPress(trigger); - await userEvent.longPress(trigger); + await user.longPress(trigger); + await user.longPress(trigger); expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); @@ -165,16 +176,60 @@ describe('Tooltip', () => { wrapper: { queryByText, getByText, findByText }, } = await setup({ enterTouchDelay: 50, leaveTouchDelay: 0 }); - await userEvent.longPress(getTrigger(getByText)); + // `longPress` includes the release (pressOut), which schedules the hide. + await user.longPress(getTrigger(getByText)); await findByText('some tooltip text'); - await runTimers(); + await runTimers(); // leaveTouchDelay + exit fade duration → unmounts expect(queryByText('some tooltip text')).not.toBeOnTheScreen(); }); }); + describe('MD3 styling', () => { + it('renders an inverseSurface container with inverseOnSurface text', async () => { + const { + wrapper: { getByText, getByTestId, findByText }, + } = await setup(); + + await user.longPress(getTrigger(getByText)); + + await findByText('some tooltip text'); + + expect(getByTestId('tooltip-container')).toHaveStyle({ + backgroundColor: getTheme().colors.inverseSurface, + }); + + // bodySmall (12sp) text in the inverseOnSurface role. + expect(getByText('some tooltip text')).toHaveStyle({ + color: getTheme().colors.inverseOnSurface, + fontSize: 12, + }); + }); + }); + + describe('fade animation', () => { + it('stays mounted through the exit fade before unmounting', async () => { + const { + wrapper: { queryByText, getByText, findByText }, + } = await setup({ leaveTouchDelay: 0 }); + + // `longPress` includes the release (pressOut), which schedules the hide. + await user.longPress(getTrigger(getByText)); + + await findByText('some tooltip text'); + + await runTimers(0); // leaveTouchDelay (0) elapses → exit fade starts + + // Still mounted while fading out so the animation can play. + expect(getByText('some tooltip text')).toBeTruthy(); + + await runTimers(); // exit fade duration elapses → unmounts + expect(queryByText('some tooltip text')).toBeNull(); + }); + }); + describe('Tooltip position', () => { const LAYOUT_WIDTH = 360; const LAYOUT_HEIGHT = 705; @@ -196,7 +251,7 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = await setup(); - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await fireEvent(await findByText('some tooltip text'), 'layout', { nativeEvent: { @@ -217,7 +272,7 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = await setup({}, { pageX: 0 }); // Component starting at the starting 0 X coord - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await fireEvent(await findByText('some tooltip text'), 'layout', { nativeEvent: { @@ -238,7 +293,7 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = await setup({}, { pageX: 900, width: 150 }); // Component close to the screen limit - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await fireEvent(await findByText('some tooltip text'), 'layout', { nativeEvent: { @@ -259,7 +314,7 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = await setup({}, { pageY: 600, height: 50 }); - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await fireEvent(await findByText('some tooltip text'), 'layout', { nativeEvent: { @@ -363,8 +418,13 @@ describe('Tooltip', () => { await findByText('some tooltip text'); - await fireEvent(getTrigger(getByText), 'hoverOut'); - await runTimers(); + // Settle the hover-out in its own act() so its state update can't + // escape act and corrupt the renderer, then drain the fade-out timers. + await act(async () => { + await fireEvent(getTrigger(getByText), 'hoverOut'); + }); + await runTimers(); // leaveTouchDelay → schedules the exit fade + await runTimers(); // exit fade duration → unmounts expect(queryByText('some tooltip text')).not.toBeOnTheScreen(); }); @@ -475,3 +535,226 @@ describe('Tooltip', () => { }); }); }); + +describe('Tooltip.Rich', () => { + const getTrigger = ( + getByText: Awaited>['getByText'] + ) => getByText('dummy component').parent!; + + const runTimers = async (ms = 1000) => { + await act(async () => { + await jest.advanceTimersByTimeAsync(ms); + }); + }; + + let user: ReturnType; + beforeEach(() => { + user = userEvent.setup(); + }); + + const setup = async ( + propOverrides?: Partial> + ) => { + jest + .spyOn(View.prototype, 'measure') + .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + + const wrapper = await render( + + + {(props) => } + + + ); + + return { wrapper }; + }; + + it('is exposed as a compound component on Tooltip', () => { + expect(TooltipCompound.Rich).toBeDefined(); + }); + + describe('Mobile', () => { + beforeAll(() => { + Platform.OS = 'android'; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('toggles title, content and actions when the trigger is pressed', async () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = await setup({ + title: 'Heading', + actions: () => Learn more, + }); + + expect(queryByText('Body text')).toBeNull(); + + await user.press(getTrigger(getByText)); + + expect(getByText('Heading')).toBeTruthy(); + expect(getByText('Body text')).toBeTruthy(); + expect(getByText('Learn more')).toBeTruthy(); + expect(getByTestId('tooltip-rich-container')).toBeTruthy(); + + // Pressing again toggles it back off. + await user.press(getTrigger(getByText)); + await runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('renders a custom element as content', async () => { + const { + wrapper: { getByText }, + } = await setup({ content: Custom node }); + + await user.press(getTrigger(getByText)); + + expect(getByText('Custom node')).toBeTruthy(); + }); + + it('uses the surfaceContainer container with MD3 title/content roles', async () => { + const { + wrapper: { getByText, getByTestId }, + } = await setup({ title: 'Heading' }); + + await user.press(getTrigger(getByText)); + + expect(getByText('Heading')).toHaveStyle({ + color: getTheme().colors.onSurface, + }); + expect(getByText('Body text')).toHaveStyle({ + color: getTheme().colors.onSurfaceVariant, + }); + + // Surface (container) uses the surfaceContainer color. + expect(getByTestId('tooltip-rich-surface-container')).toHaveStyle({ + backgroundColor: getTheme().colors.surfaceContainer, + }); + }); + + it('dismisses when the backdrop is pressed', async () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = await setup(); + + await user.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + await user.press(getByTestId('tooltip-rich-backdrop')); + await runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('dismisses when an action calls dismiss', async () => { + const { + wrapper: { getByText, queryByText }, + } = await setup({ + actions: ({ dismiss }) => Learn more, + }); + + await user.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + await user.press(getByText('Learn more')); + await runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + }); + + describe('Web', () => { + beforeAll(() => { + Platform.OS = 'web'; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('opens on hover after the enter delay', async () => { + const { + wrapper: { getByText, queryByText }, + } = await setup({ enterTouchDelay: 100 }); + + await act(async () => { + await fireEvent(getTrigger(getByText), 'hoverIn'); + }); + expect(queryByText('Body text')).toBeNull(); // still within the delay + + await runTimers(100); + + expect(getByText('Body text')).toBeTruthy(); + }); + + it('opens on keyboard focus and hides on blur', async () => { + const { + wrapper: { getByText, queryByText }, + } = await setup({ leaveTouchDelay: 500 }); + + // Focus shows the tooltip synchronously, so settle it in act() before + // asserting (and so its update can't escape act and corrupt the renderer). + await act(async () => { + await fireEvent(getTrigger(getByText), 'focus'); + }); + expect(getByText('Body text')).toBeTruthy(); + + await act(async () => { + await fireEvent(getTrigger(getByText), 'blur'); + }); + await runTimers(500); // leave delay → hide intent + await runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('keeps the tooltip open while the pointer moves into it (gap bridge)', async () => { + const { + wrapper: { getByText, getByTestId }, + } = await setup({ enterTouchDelay: 0, leaveTouchDelay: 500 }); + + await act(async () => { + await fireEvent(getTrigger(getByText), 'hoverIn'); + }); + await runTimers(0); + expect(getByText('Body text')).toBeTruthy(); + + // Leaving the trigger schedules a hide... + await act(async () => { + await fireEvent(getTrigger(getByText), 'hoverOut'); + // ...but entering the tooltip cancels it. + await fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn'); + }); + await runTimers(500); + + expect(getByText('Body text')).toBeTruthy(); + }); + + it('opens on hover even when the trigger ignores the hover props', async () => { + // Some triggers (e.g. `IconButton`) don't forward `onHoverIn` on web, + // so the wrapper must carry the handlers itself. Here the trigger + // deliberately drops the provided props. + jest + .spyOn(View.prototype, 'measure') + .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + + const { getByTestId, getByText, queryByText } = await render( + + + {() => } + + + ); + + await fireEvent(getByTestId('tooltip-rich-trigger'), 'hoverIn'); + expect(queryByText('Body text')).toBeNull(); // within the enter delay + + await runTimers(100); + + expect(getByText('Body text')).toBeTruthy(); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..da86483501 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -49,7 +49,7 @@ export { default as TouchableRipple } from './components/TouchableRipple/Touchab export { default as TextInput } from './components/TextInput'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; -export { default as Tooltip } from './components/Tooltip/Tooltip'; +export { default as Tooltip } from './components/Tooltip'; export { default as Text, customText } from './components/Typography/Text'; @@ -146,5 +146,9 @@ export type { Props as TextProps } from './components/Typography/Text'; export type { Props as SegmentedButtonsProps } from './components/SegmentedButtons/SegmentedButtons'; export type { Props as ListImageProps } from './components/List/ListImage'; export type { Props as TooltipProps } from './components/Tooltip/Tooltip'; +export type { + Props as TooltipRichProps, + TooltipRichTriggerProps, +} from './components/Tooltip/RichTooltip'; export { type TypescaleKey, type Theme, type Elevation } from './types';