Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ const config = {
},
Tooltip: {
Tooltip: 'Tooltip/Tooltip',
TooltipRich: 'Tooltip/RichTooltip',
},
TouchableRipple: {
TouchableRipple: 'TouchableRipple/TouchableRipple',
Expand Down
26 changes: 26 additions & 0 deletions example/src/Examples/TooltipExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Appbar,
Avatar,
Banner,
Button,
Chip,
FAB,
IconButton,
Expand Down Expand Up @@ -146,6 +147,31 @@ const TooltipExample = () => {
</Card>
</Tooltip>
</List.Section>
<List.Section title="Rich tooltips">
<View style={styles.iconButtonContainer}>
<Tooltip.Rich
title="Add to library"
content="Save this item to read it later from any of your devices."
actions={({ dismiss }) => (
<>
<Button compact onPress={dismiss}>
Learn more
</Button>
<Button compact mode="contained" onPress={dismiss}>
Add
</Button>
</>
)}
>
{(props) => <IconButton {...props} icon="plus" size={24} />}
</Tooltip.Rich>
<Tooltip.Rich content="A rich tooltip with body text only — no title or actions.">
{(props) => (
<IconButton {...props} icon="information" size={24} />
)}
</Tooltip.Rich>
</View>
</List.Section>
</ScreenWrapper>
<View style={styles.fabContainer}>
<Tooltip title="Press Me">
Expand Down
326 changes: 326 additions & 0 deletions src/components/Tooltip/RichTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
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 type { Measurement } 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
* <Tooltip.Rich content="...">
* {(props) => <IconButton {...props} icon="plus" />}
* </Tooltip.Rich>
* ```
*/
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 }) => (
* <Button onPress={() => { doThing(); dismiss(); }}>Learn more</Button>
* )}
* ```
*/
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 = () => (
* <Tooltip.Rich
* title="Add to library"
* content="Save this item to read it later."
* actions={({ dismiss }) => (
* <Button compact onPress={dismiss}>
* Learn more
* </Button>
* )}
* >
* {(props) => <IconButton {...props} icon="plus" />}
* </Tooltip.Rich>
* );
*
* export default MyComponent;
* ```
*/
const RichTooltip = ({
children,
title,
content,
actions,
enterTouchDelay = 100,
leaveTouchDelay = 500,
titleMaxFontSizeMultiplier,
contentMaxFontSizeMultiplier,
theme: themeOverrides,
}: 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<NodeJS.Timeout[]>([]);
const hideTooltipTimer = React.useRef<NodeJS.Timeout[]>([]);

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.
const handlePress = React.useCallback(() => {
setVisible((v) => !v);
clearShowTimers();
clearHideTimers();
}, [clearShowTimers, clearHideTimers]);

// Web: open on hover (with a short enter delay) and on keyboard focus.
const handleHoverIn = React.useCallback(() => {
clearHideTimers();
const id = setTimeout(
() => setVisible(true),
enterTouchDelay
) as unknown as NodeJS.Timeout;
showTooltipTimer.current.push(id);
}, [clearHideTimers, enterTouchDelay]);

// Trigger props handed to the consumer's render function.
const triggerProps: TooltipRichTriggerProps = isWeb
? {
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 = isWeb
? { onHoverIn: clearHideTimers, onHoverOut: scheduleHide }
: {};

return (
<>
{rendered && (
<Portal>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close"
accessibilityHint="Dismisses the tooltip"
onPress={hide}
pointerEvents={visible ? 'auto' : 'none'}
style={StyleSheet.absoluteFill}
testID="tooltip-rich-backdrop"
/>
<Animated.View
onLayout={onLayout}
style={[
styles.container,
getTooltipPosition(measurement as Measurement),
animatedStyle,
]}
testID="tooltip-rich-container"
>
<Pressable {...tooltipHoverProps} testID="tooltip-rich-surface">
<Surface
elevation={Tokens.rich.elevation}
testID="tooltip-rich-surface-container"
style={[
styles.surface,
{
backgroundColor: theme.colors[Tokens.rich.container],
borderRadius: theme.shapes.corner[Tokens.rich.shape],
},
]}
>
{title ? (
<Text
accessibilityLiveRegion="polite"
selectable={false}
variant={Tokens.rich.titleTypescale}
style={{ color: theme.colors[Tokens.rich.title] }}
maxFontSizeMultiplier={titleMaxFontSizeMultiplier}
>
{title}
</Text>
) : null}
{typeof content === 'string' ? (
<Text
accessibilityLiveRegion="polite"
selectable={false}
variant={Tokens.rich.contentTypescale}
style={{ color: theme.colors[Tokens.rich.content] }}
maxFontSizeMultiplier={contentMaxFontSizeMultiplier}
>
{content}
</Text>
) : (
content
)}
{actions ? (
<View style={styles.actions} testID="tooltip-rich-actions">
{actions({ dismiss: hide })}
</View>
) : null}
</Surface>
</Pressable>
</Animated.View>
</Portal>
)}
<Pressable
ref={childrenWrapperRef}
style={styles.pressContainer}
testID="tooltip-rich-trigger"
// On web the wrapper carries the hover/focus handlers because the
// trigger element (e.g. `IconButton`) doesn't reliably forward them.
// On mobile the press handler stays on the trigger itself (via
// `triggerProps` below) so the wrapper doesn't double-fire the toggle.
{...(isWeb ? triggerProps : null)}
>
{children(triggerProps)}
</Pressable>
</>
);
};

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;
Loading
Loading