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

feat: rich `Tooltip` type

Open lukewalczak opened this issue 1 year ago • 10 comments

Is your feature request related to a problem? Please describe.

According to the Material Design documentation there is new type for Tooltip component, called rich.

  • Rich tooltips provide more details, like describing the value of a feature
  • Rich tooltips can include an optional title, link, and buttons

https://m3.material.io/components/tooltips/overview

Describe the solution you'd like

  • Follow component anatomy from: https://m3.material.io/components/tooltips/guidelines

  • Create all possible variants:

image

Additional context

Should be available in both MD generations.

lukewalczak avatar Sep 12 '23 18:09 lukewalczak

@lukewalczak What the current state of this feature, is it developed already?

ravindraguptacapgemini avatar Dec 13 '23 10:12 ravindraguptacapgemini

@lukewalczak What the current state of this feature, is it developed already?

It's ready to be picked up 🙂

lukewalczak avatar Dec 13 '23 10:12 lukewalczak

@lukewalczak can you please provide me any tentative timelines for the release of this component?

ravindraguptacapgemini avatar Dec 13 '23 11:12 ravindraguptacapgemini

I apologize for any confusion. What I intended to convey is that anyone willing to take on the task can handle the feature and develop a new Tooltip type.

lukewalczak avatar Dec 13 '23 11:12 lukewalczak

@lukewalczak it's okay, I thought your team is going to pick this item.

ravindraguptacapgemini avatar Dec 13 '23 11:12 ravindraguptacapgemini

@lukewalczak Can you please guide me how to start implementing the tooltip component from scratch, what other component, logic I can reuse to build it.

ravindraguptacapgemini avatar Dec 13 '23 15:12 ravindraguptacapgemini

From the internal component as a base, you should use Surface component, for the title and supporting texts the Text component with the appropriate variant indicated in the specs. For the actions just use Button with a default mode="text".

lukewalczak avatar Dec 19 '23 22:12 lukewalczak

Hi @lukewalczak do you have this in the works? or can I pick this up?

Steven-MKN avatar Feb 29 '24 05:02 Steven-MKN

I took a stab at this and it's difficult for two reasons:

  • Surface is hard to use as it doesn't act like a View. In particular it requires a fixed width while you ideally want to set a maxWidth instead (320 seems to be the max width of a rich tooltip) so the tool tip size scales with the content.
  • Trying to absolutely position a rich tooltip w.r.t. an absolutely positioned FAB doesn't work as the FAB doesn't, unlike an absolutely positioned View, report correct pageX and pageY when calling measure() on it to decide where to place the tooltip. This again seems to be related to the (internal) use of Surface in FAB.

Edit: a quick test shows that Tooltip doesn't work with FAB either (it's position on the screen is wrong and far away from the FAB, probably due to incorrectly measuring the FAB as being as wide as the screen).

tibbe avatar May 21 '24 14:05 tibbe

Here's an initial working prototype.

It uses a mix of the approaches used in Tooltip and Menu to work correctly with absolutely positioned children (which Tooltip doesn't seem to work correctly with from my testing).

Possible improvements:

  • The MD3 spec is a bit unclear on exactly how to position the tooltip if it would overflow outside the screen (https://m3.material.io/components/tooltips/guidelines#18dbc2e8-142c-47fc-aa42-b48f60e70440). Currently I just move the tooltip on the X-axis until it's on the screen in that case.
  • I didn't give the actions prop much attention (the MD3 spec doesn't look like it uses normal text buttons but doesn't spec the buttons further).
  • The implementation's use of onLayout on the tooltip itself to compute the position of the children (same as Tooltip does) doesn't work if the position of the children change subsequently.
  • The passed in children needs to accept a ref of type View. I tried to use the same wrapper approach as in Tooltip but that didn't work for me (and doesn't seem to work correctly in Tooltip either).
import React from 'react';
import {
  Dimensions,
  LayoutChangeEvent,
  LayoutRectangle,
  StyleSheet,
  View,
} from 'react-native';
import {Portal, Surface, Text, useTheme} from 'react-native-paper';

// react-native paper currently lacks a rich tooltip component:
// https://github.com/callstack/react-native-paper/issues/4074

type ChildrenMeasurement = {
  width: number;
  height: number;
  pageX: number;
  pageY: number;
};

type TooltipLayout = LayoutRectangle;

export type Measurement = {
  children: ChildrenMeasurement;
  tooltip: TooltipLayout;
  measured: boolean;
};

/**
 * Return true when the tooltip center x-coordinate relative to the wrapped element is negative.
 * The tooltip will be placed at the starting x-coordinate from the wrapped element.
 */
const overflowLeft = (childrenX: number, tooltipWidth: number): boolean => {
  return childrenX - tooltipWidth < 0;
};

/**
 * Return true when the tooltip center x-coordinate + tooltip width is greater than the layout width
 * The tooltip width will grow from right to left relative to the wrapped element.
 */
const overflowRight = (
  childrenX: number,
  childrenWidth: number,
  tooltipWidth: number,
): boolean => {
  const {width: layoutWidth} = Dimensions.get('window');

  return childrenX + childrenWidth + tooltipWidth > layoutWidth;
};

/**
 * Return true when the children y-coordinate + its height + tooltip height is greater than the layout height.
 * The tooltip will be placed at the top of the wrapped element.
 */
const overflowBottom = (
  childrenY: number,
  childrenHeight: number,
  tooltipHeight: number,
): boolean => {
  const {height: layoutHeight} = Dimensions.get('window');

  return childrenY + childrenHeight + tooltipHeight > layoutHeight;
};

const getTooltipXPosition = (
  {pageX: childrenX, width: childrenWidth}: ChildrenMeasurement,
  {width: tooltipWidth}: TooltipLayout,
): number => {
  if (overflowRight(childrenX, childrenWidth, tooltipWidth)) {
    if (overflowLeft(childrenX, tooltipWidth)) {
      const {width: layoutWidth} = Dimensions.get('window');
      return layoutWidth - tooltipWidth;
    }
    return childrenX - tooltipWidth;
  }

  return childrenX + childrenWidth;
};

const getTooltipYPosition = (
  {pageY: childrenY, height: childrenHeight}: ChildrenMeasurement,
  {height: tooltipHeight}: TooltipLayout,
): number => {
  if (overflowBottom(childrenY, childrenHeight, tooltipHeight)) {
    return childrenY - tooltipHeight;
  }

  // We assume that we can't both overflow bottom and top.
  return childrenY + childrenHeight;
};

export const getTooltipPosition = ({
  children,
  tooltip,
  measured,
}: Measurement): Record<string, never> | {left: number; top: number} => {
  if (!measured) {
    return {};
  }

  return {
    left: getTooltipXPosition(children, tooltip),
    top: getTooltipYPosition(children, tooltip),
  };
};

type Props = {
  actions?: React.ReactElement[];
  children: React.ReactElement;
  subhead?: string;
  supportingText: string;
  visible?: boolean;
};

/**
 * Material Design 3 rich tooltip.
 *
 * Note that `children` must be a React element with a ref of type `View`.
 *
 * See https://m3.material.io/components/tooltips/overview.
 */
const RichTooltip = ({
  actions = [],
  children,
  subhead,
  supportingText,
  visible = false,
}: Props): JSX.Element => {
  const theme = useTheme();

  const [measurement, setMeasurement] = React.useState({
    children: {},
    tooltip: {},
    measured: false,
  });
  const childrenRef = React.useRef() as React.MutableRefObject<View>;

  const handleOnLayout = ({nativeEvent: {layout}}: LayoutChangeEvent) => {
    childrenRef.current.measureInWindow((pageX, pageY, width, height) => {
      setMeasurement({
        children: {pageX, pageY, height, width},
        tooltip: {...layout},
        measured: true,
      });
    });
  };

  return (
    <>
      {visible && (
        <Portal>
          <Surface
            elevation={2}
            onLayout={handleOnLayout}
            style={[
              styles.surface,
              {
                ...getTooltipPosition(measurement as Measurement),
                ...(measurement.measured ? styles.visible : styles.hidden),
              },
            ]}
            testID="tooltip-container">
            {subhead ? (
              <Text
                style={[styles.subhead, {color: theme.colors.onSurfaceVariant}]}
                variant="titleSmall">
                {subhead}
              </Text>
            ) : null}
            <Text
              style={{color: theme.colors.onSurfaceVariant}}
              variant="bodyMedium">
              {supportingText}
            </Text>
            {actions.length > 0 ? (
              <View style={styles.actions}>
                {actions.map((action, index) => (
                  <View key={index}>{action}</View>
                ))}
              </View>
            ) : null}
          </Surface>
        </Portal>
      )}
      {React.cloneElement(children, {ref: childrenRef})}
    </>
  );
};

export default RichTooltip;

const styles = StyleSheet.create({
  actions: {
    flexDirection: 'row',
    marginTop: 12,
  },
  surface: {
    alignSelf: 'flex-start',
    borderRadius: 12,
    paddingTop: 12,
    paddingBottom: 8,
    paddingHorizontal: 16,
    // The MD3 spec doesn't specify the width but this is the max width of the
    // tooltip used in the Jetpack implementation. This includes the padding.
    maxWidth: 320 - 2 * 16,
  },
  subhead: {
    marginBottom: 4,
  },
  visible: {
    opacity: 1,
  },
  hidden: {
    opacity: 0,
  },
});

tibbe avatar May 22 '24 12:05 tibbe