react-native-paper
react-native-paper copied to clipboard
feat: rich `Tooltip` type
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:
Additional context
Should be available in both MD generations.
@lukewalczak What the current state of this feature, is it developed already?
@lukewalczak What the current state of this feature, is it developed already?
It's ready to be picked up 🙂
@lukewalczak can you please provide me any tentative timelines for the release of this component?
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 it's okay, I thought your team is going to pick this item.
@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.
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"
.
Hi @lukewalczak do you have this in the works? or can I pick this up?
I took a stab at this and it's difficult for two reasons:
-
Surface
is hard to use as it doesn't act like aView
. In particular it requires a fixedwidth
while you ideally want to set amaxWidth
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 theFAB
doesn't, unlike an absolutely positionedView
, report correctpageX
andpageY
when callingmeasure()
on it to decide where to place the tooltip. This again seems to be related to the (internal) use ofSurface
inFAB
.
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).
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 thechildren
(same asTooltip
does) doesn't work if the position of thechildren
change subsequently. - The passed in
children
needs to accept aref
of typeView
. I tried to use the same wrapper approach as inTooltip
but that didn't work for me (and doesn't seem to work correctly inTooltip
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,
},
});