react-native-paper icon indicating copy to clipboard operation
react-native-paper copied to clipboard

Support Line Breaks in Tooltip Component Title

Open bake0937 opened this issue 2 years ago • 4 comments

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.

bake0937 avatar Sep 12 '23 14:09 bake0937

Hey @bake0937, the MD documentation recommends to avoid wrapping text to the multiple lines:

image

At the same time, there is new Tooltip type called rich which can be implemented in the library:

image

I will create a feature request for new variant.

lukewalczak avatar Sep 12 '23 18:09 lukewalczak

Actually, documentation also presents the plain Tooltip in the multiline variant, which is kinda confusing:

image

lukewalczak avatar Sep 12 '23 18:09 lukewalczak

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!

KKA11010 avatar Aug 08 '24 18:08 KKA11010

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

MZHoffman avatar May 14 '25 08:05 MZHoffman