diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a4b672640c..3aee5acc26 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -175,6 +175,7 @@ const config = { }, Tooltip: { Tooltip: 'Tooltip/Tooltip', + TooltipRich: 'Tooltip/RichTooltip', }, TouchableRipple: { TouchableRipple: 'TouchableRipple/TouchableRipple', diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 8e0802d4a4..59a3b6bbef 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, @@ -146,6 +147,29 @@ const TooltipExample = () => { + + + + + + + } + > + {}} /> + + + {}} /> + + + diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx new file mode 100644 index 0000000000..9e97ba7bbe --- /dev/null +++ b/src/components/Tooltip/RichTooltip.tsx @@ -0,0 +1,320 @@ +import * as React from 'react'; +import { + Dimensions, + View, + StyleSheet, + Platform, + Pressable, + ViewStyle, +} from 'react-native'; + +import Animated from 'react-native-reanimated'; + +import { useTooltipFade } from './hooks'; +import { Tokens } from './tokens'; +import { getTooltipPosition, 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 Surface from '../Surface'; +import Text from '../Typography/Text'; + +export type Props = { + /** + * Tooltip reference element. Needs to be able to hold a ref. + */ + children: 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; + /** + * Action buttons (and/or links) rendered in a row below the content. + * Pressing one dismisses the tooltip. + */ + actions?: 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. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Button, IconButton, Tooltip } from 'react-native-paper'; + * + * const MyComponent = () => ( + * Learn more} + * > + * {}} /> + * + * ); + * + * export default MyComponent; + * ``` + */ +const RichTooltip = ({ + children, + title, + content, + actions, + enterTouchDelay = 100, + leaveTouchDelay = 500, + titleMaxFontSizeMultiplier, + contentMaxFontSizeMultiplier, + theme: themeOverrides, + ...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, animatedStyle, onLayout, childrenWrapperRef } = + useTooltipFade(theme, visible); + + const showTooltipTimer = React.useRef([]); + const hideTooltipTimer = React.useRef([]); + + const isValidChild = React.useMemo( + () => React.isValidElement(children), + [children] + ); + + const clearShowTimers = React.useCallback(() => { + showTooltipTimer.current.forEach((t) => clearTimeout(t)); + showTooltipTimer.current = []; + }, []); + + const clearHideTimers = React.useCallback(() => { + hideTooltipTimer.current.forEach((t) => clearTimeout(t)); + hideTooltipTimer.current = []; + }, []); + + React.useEffect(() => { + return () => { + clearShowTimers(); + clearHideTimers(); + }; + }, [clearShowTimers, clearHideTimers]); + + React.useEffect(() => { + const subscription = addEventListener(Dimensions, 'change', () => + setVisible(false) + ); + + return () => subscription.remove(); + }, []); + + const show = React.useCallback(() => { + clearHideTimers(); + setVisible(true); + }, [clearHideTimers]); + + const hide = React.useCallback(() => { + clearShowTimers(); + setVisible(false); + }, [clearShowTimers]); + + const scheduleHide = React.useCallback(() => { + clearShowTimers(); + const id = setTimeout( + () => setVisible(false), + leaveTouchDelay + ) as unknown as NodeJS.Timeout; + hideTooltipTimer.current.push(id); + }, [clearShowTimers, leaveTouchDelay]); + + // Mobile: a tap toggles the tooltip and still forwards the child's onPress. + const handlePress = React.useCallback(() => { + if (visible) { + hide(); + } else { + show(); + } + if (isValidChild) { + (children.props as TooltipChildProps).onPress?.(); + } + }, [visible, hide, show, isValidChild, children.props]); + + // Web: open on hover, with a short enter delay. + const handleHoverIn = React.useCallback(() => { + clearHideTimers(); + const id = setTimeout( + () => setVisible(true), + enterTouchDelay + ) as unknown as NodeJS.Timeout; + showTooltipTimer.current.push(id); + if (isValidChild) { + (children.props as TooltipChildProps).onHoverIn?.(); + } + }, [clearHideTimers, enterTouchDelay, isValidChild, children.props]); + + const handleHoverOut = React.useCallback(() => { + scheduleHide(); + if (isValidChild) { + (children.props as TooltipChildProps).onHoverOut?.(); + } + }, [scheduleHide, isValidChild, children.props]); + + const mobilePressProps = { + onPress: handlePress, + }; + + const webPressProps = { + onHoverIn: handleHoverIn, + onHoverOut: handleHoverOut, + }; + + // 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 = isWeb + ? { onHoverIn: clearHideTimers, onHoverOut: scheduleHide } + : {}; + + return ( + <> + {rendered && ( + + + + ), + animatedStyle, + ]} + testID="tooltip-rich-container" + > + + + {title ? ( + + {title} + + ) : null} + {typeof content === 'string' ? ( + + {content} + + ) : ( + content + )} + {actions ? ( + // `onTouchEnd` bubbles from the pressed action up to this + // wrapper, so selecting any action dismisses the tooltip. + + {actions} + + ) : null} + + + + + )} + + {React.cloneElement(children, { + ...rest, + ...(isWeb ? webPressProps : mobilePressProps), + })} + + + ); +}; + +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: { + ...(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 86e2e3cff5..0989f1a24e 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -1,14 +1,16 @@ import * as React from 'react'; import { Dimensions, - View, - LayoutChangeEvent, StyleSheet, Platform, Pressable, ViewStyle, } from 'react-native'; +import Animated from 'react-native-reanimated'; + +import { useTooltipFade } from './hooks'; +import { Tokens } from './tokens'; import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -48,6 +50,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'; @@ -74,17 +78,15 @@ const Tooltip = ({ 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, animatedStyle, 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( @@ -141,7 +143,6 @@ const Tooltip = ({ let id = setTimeout(() => { setVisible(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); }, leaveTouchDelay) as unknown as NodeJS.Timeout; hideTooltipTimer.current.push(id); }, [leaveTouchDelay]); @@ -170,18 +171,6 @@ const Tooltip = ({ } }, [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(), @@ -196,21 +185,21 @@ const Tooltip = ({ return ( <> - {visible && ( + {rendered && ( - ), - borderRadius: theme.shapes.corner.extraSmall, - ...(measurement.measured ? styles.visible : styles.hidden), + borderRadius: theme.shapes.corner[Tokens.plain.shape], }, + animatedStyle, ]} testID="tooltip-container" > @@ -218,13 +207,13 @@ 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} - + )} { + const reduceMotion = useReduceMotion(); + const [rendered, setRendered] = React.useState(false); + const [measurement, setMeasurement] = React.useState({ + children: {}, + tooltip: {}, + measured: false, + }); + const childrenWrapperRef = React.useRef(null); + + const opacity = useSharedValue(0); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const enterConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.enter.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.exit.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitDurationMs = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + // Mount as soon as the tooltip is requested. + React.useEffect(() => { + if (visible) { + setRendered(true); + } + }, [visible]); + + // Drive the fade and defer unmount until the exit animation has played. + React.useEffect(() => { + if (!rendered) { + return; + } + + if (visible) { + opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; + return; + } + + opacity.value = withTiming(0, exitConfig); + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ children: {}, tooltip: {}, measured: false }); + }, exitDurationMs) as unknown as NodeJS.Timeout; + + return () => clearTimeout(id); + }, [ + visible, + rendered, + measurement.measured, + opacity, + enterConfig, + exitConfig, + exitDurationMs, + ]); + + const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + childrenWrapperRef.current?.measure( + (_x, _y, width, height, pageX, pageY) => { + setMeasurement({ + children: { pageX, pageY, height, width }, + tooltip: { ...layout }, + measured: true, + }); + } + ); + }; + + return { rendered, measurement, animatedStyle, 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..a4e863431a --- /dev/null +++ b/src/components/Tooltip/tokens.ts @@ -0,0 +1,72 @@ +import type { + ColorRole, + Elevation, + ThemeShapeCorners, + TypescaleKey, +} from '../../theme/types'; + +type ShapeKey = keyof ThemeShapeCorners; + +/** + * 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 satisfies { + container: ColorRole; + content: ColorRole; + shape: ShapeKey; + height: number; + paddingHorizontal: number; + typescale: TypescaleKey; +}; + +/** + * 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', + action: 'primary', + shape: 'medium', + elevation: 2, + maxWidth: 312, + paddingHorizontal: 16, + paddingVertical: 12, + titleTypescale: 'titleSmall', + contentTypescale: 'bodyMedium', + gap: 4, +} as const satisfies { + container: ColorRole; + title: ColorRole; + content: ColorRole; + action: ColorRole; + shape: ShapeKey; + elevation: Elevation; + maxWidth: number; + paddingHorizontal: number; + paddingVertical: number; + titleTypescale: TypescaleKey; + contentTypescale: TypescaleKey; + gap: number; +}; + +/** + * 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/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 6c59eaa60b..794e08a138 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -1,11 +1,13 @@ import React, { RefObject } from 'react'; -import { Dimensions, Text, View, Platform } from 'react-native'; +import { Dimensions, StyleSheet, Text, View, Platform } from 'react-native'; import { act, fireEvent } from '@testing-library/react-native'; import type { ReactTestInstance } from 'react-test-renderer'; import PaperProvider from '../../core/PaperProvider'; +import { getTheme } from '../../core/theming'; import { render } from '../../test-utils'; +import TooltipCompound from '../Tooltip'; import Tooltip from '../Tooltip/Tooltip'; const mockedRemoveEventListener = jest.fn(); @@ -139,12 +141,60 @@ describe('Tooltip', () => { await findByText('some tooltip text'); fireEvent(getTrigger(getByText), 'pressOut'); - runTimers(); + runTimers(); // leaveTouchDelay → starts the fade-out + runTimers(); // exit fade duration → unmounts expect(queryByText('some tooltip text')).toBeNull(); }); }); + describe('MD3 styling', () => { + it('renders an inverseSurface container with inverseOnSurface text', async () => { + const { + wrapper: { getByText, getByTestId, findByText }, + } = setup(); + + fireEvent(getTrigger(getByText), 'longPress'); + + await findByText('some tooltip text'); + + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { backgroundColor: getTheme().colors.inverseSurface }, + {}, + ]); + + // bodySmall (12sp) text in the inverseOnSurface role. + expect( + StyleSheet.flatten(getByText('some tooltip text').props.style) + ).toMatchObject({ + color: getTheme().colors.inverseOnSurface, + fontSize: 12, + }); + }); + }); + + describe('fade animation', () => { + it('stays mounted through the exit fade before unmounting', async () => { + const { + wrapper: { queryByText, getByText, findByText }, + } = setup({ leaveTouchDelay: 0 }); + + fireEvent(getTrigger(getByText), 'longPress'); + + await findByText('some tooltip text'); + + fireEvent(getTrigger(getByText), 'pressOut'); + runTimers(); // leaveTouchDelay elapses → exit fade starts + + // Still mounted while fading out so the animation can play. + expect(queryByText('some tooltip text')).not.toBeNull(); + + runTimers(); // exit fade duration elapses → unmounts + expect(queryByText('some tooltip text')).toBeNull(); + }); + }); + describe('Tooltip position', () => { const LAYOUT_WIDTH = 360; const LAYOUT_HEIGHT = 705; @@ -180,6 +230,7 @@ describe('Tooltip', () => { left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 top: 250, // pageY (200) + height (50) }, + {}, ]); }); }); @@ -204,6 +255,7 @@ describe('Tooltip', () => { left: 0, // Tooltip renders starting from children's x coord top: 250, }, + {}, ]); }); }); @@ -228,6 +280,7 @@ describe('Tooltip', () => { left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen top: 250, }, + {}, ]); }); }); @@ -252,6 +305,7 @@ describe('Tooltip', () => { left: 210, top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, }, + {}, ]); }); }); @@ -339,7 +393,8 @@ describe('Tooltip', () => { await findByText('some tooltip text'); fireEvent(getTrigger(getByText), 'hoverOut'); - runTimers(); + runTimers(); // leaveTouchDelay → starts the fade-out + runTimers(); // exit fade duration → unmounts expect(queryByText('some tooltip text')).toBeNull(); }); @@ -381,6 +436,7 @@ describe('Tooltip', () => { left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 top: 250, // pageY (200) + height (50) }, + {}, ]); }); }); @@ -406,6 +462,7 @@ describe('Tooltip', () => { left: 0, // Tooltip renders starting from children's x coord top: 250, }, + {}, ]); }); }); @@ -431,6 +488,7 @@ describe('Tooltip', () => { left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen top: 250, }, + {}, ]); }); }); @@ -456,9 +514,179 @@ describe('Tooltip', () => { left: 210, top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, }, + {}, ]); }); }); }); }); }); + +describe('Tooltip.Rich', () => { + const getTrigger = (getByText: (text: string) => ReactTestInstance) => + getByText('dummy component').parent as ReactTestInstance; + + const runTimers = (ms?: number) => { + act(() => { + if (ms === undefined) { + jest.runOnlyPendingTimers(); + } else { + jest.advanceTimersByTime(ms); + } + }); + }; + + const setup = ( + propOverrides?: Partial> + ) => { + jest + .spyOn(View.prototype, 'measure') + .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + + const wrapper = render( + + + + + + ); + + 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', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup({ title: 'Heading', actions: Learn more }); + + expect(queryByText('Body text')).toBeNull(); + + fireEvent.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. + fireEvent.press(getTrigger(getByText)); + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('renders a custom element as content', () => { + const { + wrapper: { getByText }, + } = setup({ content: Custom node }); + + fireEvent.press(getTrigger(getByText)); + + expect(getByText('Custom node')).toBeTruthy(); + }); + + it('uses the surfaceContainer container with MD3 title/content roles', () => { + const { + wrapper: { getByText, getByTestId }, + } = setup({ title: 'Heading' }); + + fireEvent.press(getTrigger(getByText)); + + expect( + StyleSheet.flatten(getByText('Heading').props.style) + ).toMatchObject({ + color: getTheme().colors.onSurface, + }); + expect( + StyleSheet.flatten(getByText('Body text').props.style) + ).toMatchObject({ + color: getTheme().colors.onSurfaceVariant, + }); + + // Surface (container) uses the surfaceContainer color. + expect( + StyleSheet.flatten( + getByTestId('tooltip-rich-surface-container').props.style + ) + ).toMatchObject({ + backgroundColor: getTheme().colors.surfaceContainer, + }); + }); + + it('dismisses when the backdrop is pressed', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup(); + + fireEvent.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + fireEvent.press(getByTestId('tooltip-rich-backdrop')); + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('dismisses when an action is selected', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup({ actions: Learn more }); + + fireEvent.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + fireEvent(getByTestId('tooltip-rich-actions'), 'touchEnd'); + 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', () => { + const { + wrapper: { getByText, queryByText }, + } = setup({ enterTouchDelay: 100 }); + + fireEvent(getTrigger(getByText), 'hoverIn'); + expect(queryByText('Body text')).toBeNull(); // still within the delay + + runTimers(100); + + expect(getByText('Body text')).toBeTruthy(); + }); + + it('keeps the tooltip open while the pointer moves into it (gap bridge)', () => { + const { + wrapper: { getByText, getByTestId }, + } = setup({ enterTouchDelay: 0, leaveTouchDelay: 500 }); + + fireEvent(getTrigger(getByText), 'hoverIn'); + runTimers(0); + expect(getByText('Body text')).toBeTruthy(); + + // Leaving the trigger schedules a hide... + fireEvent(getTrigger(getByText), 'hoverOut'); + // ...but entering the tooltip cancels it. + fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn'); + runTimers(500); + + expect(getByText('Body text')).toBeTruthy(); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..0f783a0d47 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,6 @@ 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 } from './components/Tooltip/RichTooltip'; export { type TypescaleKey, type Theme, type Elevation } from './types';