Support Line Breaks in Tooltip Component Title
Is your feature request related to a problem? Please describe. Yes, currently the Tooltip component in react-native-paper does not support line breaks within its content. This limitation makes it challenging to present information that's inherently structured in multiple lines, such as addresses or lists, leading to decreased readability.
Describe the solution you'd like I'd like the content of the Tooltip component to recognize and render newline characters (\n). Alternatively, the component could support styled content that can be formatted with line breaks.
Describe alternatives you've considered A possible alternative might be to use a custom tooltip component or another library, but it would be more beneficial and coherent to have this feature natively in react-native-paper.
Additional context Allowing line breaks in the Tooltip component would greatly enhance its versatility. As UI/UX design evolves, presenting content in a clear and readable format is essential, and this change would cater to a wider variety of use cases.
Hey @bake0937, the MD documentation recommends to avoid wrapping text to the multiple lines:
At the same time, there is new Tooltip type called rich which can be implemented in the library:
I will create a feature request for new variant.
Actually, documentation also presents the plain Tooltip in the multiline variant, which is kinda confusing:
Hi @lukewalczak, is there a public branch where this feature is being developed? I'd love to track the progress or possibly contribute with a PR. Thanks a lot!
Doesn't look like its happening. You can use this one for now if you need multiline
import * as React from 'react'
import {
Dimensions,
View,
LayoutChangeEvent,
StyleSheet,
Platform,
Pressable,
ViewStyle,
Text,
} from 'react-native'
import { Portal, useTheme, TooltipChildProps } from 'react-native-paper'
import type { ThemeProp } from 'src/types'
export type Props = {
/**
* Tooltip reference element. Needs to be able to hold a ref.
*/
children: React.ReactElement
/**
* The number of milliseconds a user must touch the element before showing the tooltip.
*/
enterTouchDelay?: number
/**
* The number of milliseconds after the user stops touching an element before hiding the tooltip.
*/
leaveTouchDelay?: number
/**
* Tooltip title
*/
title: string
/**
* Specifies the largest possible scale a title font can reach.
*/
titleMaxFontSizeMultiplier?: number
/**
* @optional
*/
theme?: ThemeProp
}
type Measurement = {
children: {
width: number
height: number
pageX: number
pageY: number
}
tooltip: {
width: number
height: number
}
measured: boolean
}
const TOOLTIP_OFFSET = 8
const getTooltipPosition = (
measurement: Measurement,
children: React.ReactElement<TooltipChildProps>,
) => {
const { width: screenWidth } = Dimensions.get('window')
const { children: childrenMeasurement, tooltip } = measurement
// Default position is above the element
let top = childrenMeasurement.pageY - tooltip.height - TOOLTIP_OFFSET
let left =
childrenMeasurement.pageX + (childrenMeasurement.width - tooltip.width) / 2
// If tooltip would go off the top of the screen, place it below the element
if (top < 0) {
top =
childrenMeasurement.pageY + childrenMeasurement.height + TOOLTIP_OFFSET
}
// If tooltip would go off the left side of the screen
if (left < TOOLTIP_OFFSET) {
left = TOOLTIP_OFFSET
}
// If tooltip would go off the right side of the screen
if (left + tooltip.width > screenWidth - TOOLTIP_OFFSET) {
left = screenWidth - tooltip.width - TOOLTIP_OFFSET
}
return {
top,
left,
position: 'absolute',
}
}
/**
* Tooltips display informative text when users hover over, focus on, or tap an element.
*
* 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.
*
* ## Usage
* ```js
* import * as React from 'react';
* import { IconButton, Tooltip } from 'react-native-paper';
*
* const MyComponent = () => (
* <Tooltip title="Selected Camera">
* <IconButton icon="camera" selected size={24} onPress={() => {}} />
* </Tooltip>
* );
*
* export default MyComponent;
* ```
*/
const Tooltip = ({
children,
enterTouchDelay = 500,
leaveTouchDelay = 1500,
title,
theme: themeOverrides,
titleMaxFontSizeMultiplier,
...rest
}: Props) => {
const isWeb = Platform.OS === 'web'
const theme = useTheme()
const [visible, setVisible] = React.useState(false)
const [measurement, setMeasurement] = React.useState<Measurement>({
children: {
width: 0,
height: 0,
pageX: 0,
pageY: 0,
},
tooltip: {
width: 0,
height: 0,
},
measured: false,
})
const showTooltipTimer = React.useRef<NodeJS.Timeout[]>([])
const hideTooltipTimer = React.useRef<NodeJS.Timeout[]>([])
const childrenWrapperRef = React.useRef<View>(null)
const touched = React.useRef(false)
const isValidChild = React.useMemo(
() => React.isValidElement<TooltipChildProps>(children),
[children],
)
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 = []
}
}
}, [])
React.useEffect(() => {
const subscription = Dimensions.addEventListener('change', () =>
setVisible(false),
)
return () => subscription.remove()
}, [])
const handleTouchStart = React.useCallback(() => {
if (hideTooltipTimer.current.length) {
hideTooltipTimer.current.forEach((t) => clearTimeout(t))
hideTooltipTimer.current = []
}
if (isWeb) {
const id = setTimeout(() => {
touched.current = true
setVisible(true)
}, enterTouchDelay) as unknown as NodeJS.Timeout
showTooltipTimer.current.push(id)
} else {
touched.current = true
setVisible(true)
}
}, [isWeb, enterTouchDelay])
const handleTouchEnd = React.useCallback(() => {
touched.current = false
if (showTooltipTimer.current.length) {
showTooltipTimer.current.forEach((t) => clearTimeout(t))
showTooltipTimer.current = []
}
const id = setTimeout(() => {
setVisible(false)
setMeasurement({
children: { width: 0, height: 0, pageX: 0, pageY: 0 },
tooltip: { width: 0, height: 0 },
measured: false,
})
}, leaveTouchDelay) as unknown as NodeJS.Timeout
hideTooltipTimer.current.push(id)
}, [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,
}
return (
<>
{visible && (
<Portal>
<View
onLayout={handleOnLayout}
style={[
styles.tooltip,
{
backgroundColor: theme.isV3
? theme.colors.onSurface
: theme.colors.surface,
...getTooltipPosition(
measurement,
children as React.ReactElement<TooltipChildProps>,
),
borderRadius: theme.roundness,
...(measurement.measured ? styles.visible : styles.hidden),
},
]}
testID="tooltip-container"
>
<Text
style={{ color: theme.colors.surface }}
maxFontSizeMultiplier={titleMaxFontSizeMultiplier}
>
{title}
</Text>
</View>
</Portal>
)}
<Pressable
ref={childrenWrapperRef}
style={styles.pressContainer}
{...(isWeb ? webPressProps : mobilePressProps)}
>
{React.cloneElement(children, {
...rest,
...(isWeb ? webPressProps : mobilePressProps),
})}
</Pressable>
</>
)
}
Tooltip.displayName = 'Tooltip'
const styles = StyleSheet.create({
tooltip: {
alignSelf: 'flex-start',
justifyContent: 'center',
padding: 16,
maxWidth: 300,
color: 'white',
},
visible: {
opacity: 1,
},
hidden: {
opacity: 0,
},
pressContainer: {
...(Platform.OS === 'web' && { cursor: 'default' }),
} as ViewStyle,
})
export default Tooltip