callstack / react-native-paper

Material Design for React Native (Android & iOS)
https://reactnativepaper.com
MIT License
12.71k stars 2.07k forks source link

feat: rich `Tooltip` type #4074

Open lukewalczak opened 12 months ago

lukewalczak commented 12 months ago

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.

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

Describe the solution you'd like

image

Additional context

Should be available in both MD generations.

ravindraguptacapgemini commented 8 months ago

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

lukewalczak commented 8 months ago

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

It's ready to be picked up 🙂

ravindraguptacapgemini commented 8 months ago

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

lukewalczak commented 8 months ago

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.

ravindraguptacapgemini commented 8 months ago

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

ravindraguptacapgemini commented 8 months ago

@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.

lukewalczak commented 8 months ago

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".

Steven-MKN commented 6 months ago

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

tibbe commented 3 months ago

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

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 commented 3 months ago

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:

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,
  },
});