diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx
index 5f3fa51ccf..2890be9aaf 100644
--- a/example/src/ExampleList.tsx
+++ b/example/src/ExampleList.tsx
@@ -40,6 +40,7 @@ import TextExample from './Examples/TextExample';
import TextInputExample from './Examples/TextInputExample';
import ThemeExample from './Examples/ThemeExample';
import ToggleButtonExample from './Examples/ToggleButtonExample';
+import TooltipExample from './Examples/TooltipExample';
import TouchableRippleExample from './Examples/TouchableRippleExample';
import { useExampleTheme } from '.';
@@ -82,6 +83,7 @@ export const examples: Record<
text: TextExample,
textInput: TextInputExample,
toggleButton: ToggleButtonExample,
+ tooltipExample: TooltipExample,
touchableRipple: TouchableRippleExample,
theme: ThemeExample,
};
diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx
new file mode 100644
index 0000000000..aa215549c7
--- /dev/null
+++ b/example/src/Examples/TooltipExample.tsx
@@ -0,0 +1,91 @@
+import * as React from 'react';
+import { Platform, StyleSheet } from 'react-native';
+
+import type { StackNavigationProp } from '@react-navigation/stack';
+import {
+ Appbar,
+ Banner,
+ FAB,
+ List,
+ ToggleButton,
+ Tooltip,
+} from 'react-native-paper';
+
+import ScreenWrapper from '../ScreenWrapper';
+
+type Props = {
+ navigation: StackNavigationProp<{}>;
+};
+
+const MORE_ICON = Platform.OS === 'ios' ? 'dots-horizontal' : 'dots-vertical';
+
+const TooltipExample = ({ navigation }: Props) => {
+ React.useLayoutEffect(() => {
+ navigation.setOptions({
+ header: () => (
+
+
+ navigation.goBack()} />
+
+
+
+ {}} />
+
+
+ {}} />
+
+
+ {}} />
+
+
+ ),
+ });
+ });
+
+ const renderFAB = () => {
+ return (
+ {}} style={[styles.fab]} />
+ );
+ };
+
+ return (
+ <>
+
+
+ A tooltip is displayed upon tapping and holding a screen element or
+ component. Continuously display the tooltip as long as the user
+ long-presses the element.
+
+
+ {}}
+ >
+
+
+
+
+
+
+
+ {renderFAB()}
+ >
+ );
+};
+
+TooltipExample.title = 'Tooltip';
+
+export default TooltipExample;
+
+const styles = StyleSheet.create({
+ fab: {
+ position: 'absolute',
+ margin: 16,
+ right: 0,
+ bottom: 0,
+ },
+ toggleButtonRow: {
+ paddingHorizontal: 16,
+ },
+});
diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx
index bd7068ef9f..e5211baf4b 100644
--- a/src/components/Appbar/AppbarAction.tsx
+++ b/src/components/Appbar/AppbarAction.tsx
@@ -1,9 +1,5 @@
import * as React from 'react';
-import type {
- StyleProp,
- TouchableWithoutFeedback,
- ViewStyle,
-} from 'react-native';
+import type { StyleProp, ViewStyle, View } from 'react-native';
import color from 'color';
@@ -44,7 +40,7 @@ export type Props = React.ComponentPropsWithoutRef & {
*/
isLeading?: boolean;
style?: StyleProp;
- ref?: React.RefObject;
+ ref?: React.RefObject;
};
/**
@@ -72,39 +68,45 @@ export type Props = React.ComponentPropsWithoutRef & {
* export default MyComponent;
* ```
*/
-const AppbarAction = ({
- size = 24,
- color: iconColor,
- icon,
- disabled,
- onPress,
- accessibilityLabel,
- isLeading,
- ...rest
-}: Props) => {
- const theme = useInternalTheme();
+const AppbarAction = React.forwardRef(
+ (
+ {
+ size = 24,
+ color: iconColor,
+ icon,
+ disabled,
+ onPress,
+ accessibilityLabel,
+ isLeading,
+ ...rest
+ }: Props,
+ ref
+ ) => {
+ const theme = useInternalTheme();
- const actionIconColor = iconColor
- ? iconColor
- : theme.isV3
- ? isLeading
- ? theme.colors.onSurface
- : theme.colors.onSurfaceVariant
- : color(black).alpha(0.54).rgb().string();
+ const actionIconColor = iconColor
+ ? iconColor
+ : theme.isV3
+ ? isLeading
+ ? theme.colors.onSurface
+ : theme.colors.onSurfaceVariant
+ : color(black).alpha(0.54).rgb().string();
- return (
-
- );
-};
+ return (
+
+ );
+ }
+);
AppbarAction.displayName = 'Appbar.Action';
diff --git a/src/components/Appbar/AppbarBackAction.tsx b/src/components/Appbar/AppbarBackAction.tsx
index 197010006b..0f391e352a 100644
--- a/src/components/Appbar/AppbarBackAction.tsx
+++ b/src/components/Appbar/AppbarBackAction.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import type { StyleProp, ViewStyle } from 'react-native';
+import type { StyleProp, ViewStyle, View } from 'react-native';
import type { $Omit } from './../../types';
import AppbarAction from './AppbarAction';
@@ -30,6 +30,7 @@ export type Props = $Omit<
*/
onPress?: () => void;
style?: StyleProp;
+ ref?: React.RefObject;
};
/**
@@ -54,13 +55,16 @@ export type Props = $Omit<
* export default MyComponent;
* ```
*/
-const AppbarBackAction = ({ accessibilityLabel = 'Back', ...rest }: Props) => (
-
+const AppbarBackAction = React.forwardRef(
+ ({ accessibilityLabel = 'Back', ...rest }: Props, ref) => (
+
+ )
);
AppbarBackAction.displayName = 'Appbar.BackAction';
diff --git a/src/components/Appbar/utils.ts b/src/components/Appbar/utils.ts
index 11bbdf414d..c92dea6d16 100644
--- a/src/components/Appbar/utils.ts
+++ b/src/components/Appbar/utils.ts
@@ -7,6 +7,7 @@ import color from 'color';
import overlay from '../../styles/overlay';
import { black, white } from '../../styles/themes/v2/colors';
import type { InternalTheme } from '../../types';
+import Tooltip from '../Tooltip/Tooltip';
import AppbarAction from './AppbarAction';
import AppbarBackAction from './AppbarBackAction';
import AppbarContent from './AppbarContent';
@@ -91,7 +92,7 @@ export const renderAppbarContent = ({
.map((child, i) => {
if (
!React.isValidElement(child) ||
- ![AppbarContent, AppbarAction, AppbarBackAction].includes(
+ ![AppbarContent, AppbarAction, AppbarBackAction, Tooltip].includes(
// @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
child.type
)
diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx
index 88867e2719..e55fa99f06 100644
--- a/src/components/FAB/FAB.tsx
+++ b/src/components/FAB/FAB.tsx
@@ -113,6 +113,7 @@ export type Props = $RemoveChildren & {
*/
theme: InternalTheme;
testID?: string;
+ ref?: React.RefObject;
};
/**
@@ -150,152 +151,159 @@ export type Props = $RemoveChildren & {
* export default MyComponent;
* ```
*/
-const FAB = ({
- icon,
- label,
- accessibilityLabel = label,
- accessibilityState,
- animated = true,
- color: customColor,
- disabled,
- onPress,
- onLongPress,
- theme,
- style,
- visible = true,
- uppercase = !theme.isV3,
- loading,
- testID = 'fab',
- size = 'medium',
- customSize,
- mode = 'elevated',
- variant = 'primary',
- ...rest
-}: Props) => {
- const { current: visibility } = React.useRef(
- new Animated.Value(visible ? 1 : 0)
- );
- const { isV3, animation } = theme;
- const { scale } = animation;
+const FAB = React.forwardRef(
+ (
+ {
+ icon,
+ label,
+ accessibilityLabel = label,
+ accessibilityState,
+ animated = true,
+ color: customColor,
+ disabled,
+ onPress,
+ onLongPress,
+ theme,
+ style,
+ visible = true,
+ uppercase = !theme.isV3,
+ loading,
+ testID = 'fab',
+ size = 'medium',
+ customSize,
+ mode = 'elevated',
+ variant = 'primary',
+ ...rest
+ }: Props,
+ ref
+ ) => {
+ const { current: visibility } = React.useRef(
+ new Animated.Value(visible ? 1 : 0)
+ );
+ const { isV3, animation } = theme;
+ const { scale } = animation;
- React.useEffect(() => {
- if (visible) {
- Animated.timing(visibility, {
- toValue: 1,
- duration: 200 * scale,
- useNativeDriver: true,
- }).start();
- } else {
- Animated.timing(visibility, {
- toValue: 0,
- duration: 150 * scale,
- useNativeDriver: true,
- }).start();
- }
- }, [visible, scale, visibility]);
+ React.useEffect(() => {
+ if (visible) {
+ Animated.timing(visibility, {
+ toValue: 1,
+ duration: 200 * scale,
+ useNativeDriver: true,
+ }).start();
+ } else {
+ Animated.timing(visibility, {
+ toValue: 0,
+ duration: 150 * scale,
+ useNativeDriver: true,
+ }).start();
+ }
+ }, [visible, scale, visibility]);
- const IconComponent = animated ? CrossFadeIcon : Icon;
+ const IconComponent = animated ? CrossFadeIcon : Icon;
- const { backgroundColor, foregroundColor, rippleColor } = getFABColors({
- theme,
- variant,
- disabled,
- customColor,
- style,
- });
+ const { backgroundColor, foregroundColor, rippleColor } = getFABColors({
+ theme,
+ variant,
+ disabled,
+ customColor,
+ style,
+ });
- const isLargeSize = size === 'large';
- const isFlatMode = mode === 'flat';
- const iconSize = isLargeSize ? 36 : 24;
- const loadingIndicatorSize = isLargeSize ? 24 : 18;
- const font = isV3 ? theme.fonts.labelLarge : theme.fonts.medium;
+ const isLargeSize = size === 'large';
+ const isFlatMode = mode === 'flat';
+ const iconSize = isLargeSize ? 36 : 24;
+ const loadingIndicatorSize = isLargeSize ? 24 : 18;
+ const font = isV3 ? theme.fonts.labelLarge : theme.fonts.medium;
- const fabStyle = getFabStyle({ customSize, size, theme });
- const extendedStyle = getExtendedFabStyle({ customSize, theme });
- const textStyle = {
- color: foregroundColor,
- ...font,
- };
+ const fabStyle = getFabStyle({ customSize, size, theme });
+ const extendedStyle = getExtendedFabStyle({ customSize, theme });
+ const textStyle = {
+ color: foregroundColor,
+ ...font,
+ };
- const { borderRadius = fabStyle.borderRadius } = (StyleSheet.flatten(style) ||
- {}) as ViewStyle;
+ const { borderRadius = fabStyle.borderRadius } = (StyleSheet.flatten(
+ style
+ ) || {}) as ViewStyle;
- const md3Elevation = isFlatMode || disabled ? 0 : 3;
+ const md3Elevation = isFlatMode || disabled ? 0 : 3;
- const newAccessibilityState = { ...accessibilityState, disabled };
+ const newAccessibilityState = { ...accessibilityState, disabled };
- return (
-
- }
- pointerEvents={visible ? 'auto' : 'none'}
- testID={`${testID}-container`}
- {...(isV3 && { elevation: md3Elevation })}
- >
-
+ }
+ pointerEvents={visible ? 'auto' : 'none'}
+ testID={`${testID}-container`}
+ {...(isV3 && { elevation: md3Elevation })}
>
-
- {icon && loading !== true ? (
-
- ) : null}
- {loading ? (
-
- ) : null}
- {label ? (
-
- {label}
-
- ) : null}
-
-
-
- );
-};
+
+ {icon && loading !== true ? (
+
+ ) : null}
+ {loading ? (
+
+ ) : null}
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+
+ );
+ }
+);
const styles = StyleSheet.create({
elevated: {
diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx
index 082aa00910..8809882d6d 100644
--- a/src/components/IconButton/IconButton.tsx
+++ b/src/components/IconButton/IconButton.tsx
@@ -3,8 +3,8 @@ import {
GestureResponderEvent,
StyleProp,
StyleSheet,
- TouchableWithoutFeedback,
ViewStyle,
+ View,
} from 'react-native';
import { useInternalTheme } from '../../core/theming';
@@ -65,7 +65,7 @@ export type Props = $RemoveChildren & {
*/
onPress?: (e: GestureResponderEvent) => void;
style?: StyleProp;
- ref?: React.RefObject;
+ ref?: React.RefObject;
/**
* @optional
*/
@@ -113,85 +113,91 @@ export type Props = $RemoveChildren & {
*
* @extends TouchableRipple props https://callstack.github.io/react-native-paper/touchable-ripple.html
*/
-const IconButton = ({
- icon,
- iconColor: customIconColor,
- containerColor: customContainerColor,
- size = 24,
- accessibilityLabel,
- disabled,
- onPress,
- selected = false,
- animated = false,
- mode,
- style,
- ...rest
-}: Props) => {
- const theme = useInternalTheme();
- const { isV3 } = theme;
-
- const IconComponent = animated ? CrossFadeIcon : Icon;
-
- const { iconColor, rippleColor, backgroundColor, borderColor } =
- getIconButtonColor({
- theme,
+const IconButton = React.forwardRef(
+ (
+ {
+ icon,
+ iconColor: customIconColor,
+ containerColor: customContainerColor,
+ size = 24,
+ accessibilityLabel,
disabled,
- selected,
+ onPress,
+ selected = false,
+ animated = false,
mode,
- customIconColor,
- customContainerColor,
- });
+ style,
+ ...rest
+ }: Props,
+ ref
+ ) => {
+ const theme = useInternalTheme();
+ const { isV3 } = theme;
+
+ const IconComponent = animated ? CrossFadeIcon : Icon;
- const buttonSize = isV3 ? size + 2 * PADDING : size * 1.5;
+ const { iconColor, rippleColor, backgroundColor, borderColor } =
+ getIconButtonColor({
+ theme,
+ disabled,
+ selected,
+ mode,
+ customIconColor,
+ customContainerColor,
+ });
- const borderStyles = {
- borderWidth: isV3 && mode === 'outlined' && !selected ? 1 : 0,
- borderRadius: buttonSize / 2,
- borderColor,
- };
+ const buttonSize = isV3 ? size + 2 * PADDING : size * 1.5;
- return (
-
- }
- {...(isV3 && { elevation: 0 })}
- >
-
}
- {...rest}
+ {...(isV3 && { elevation: 0 })}
>
-
-
-
- );
-};
+
+
+
+
+ );
+ }
+);
const styles = StyleSheet.create({
container: {
diff --git a/src/components/Surface.tsx b/src/components/Surface.tsx
index 4825a76948..eb80b93630 100644
--- a/src/components/Surface.tsx
+++ b/src/components/Surface.tsx
@@ -36,32 +36,32 @@ export type Props = React.ComponentPropsWithRef & {
* TestID used for testing purposes
*/
testID?: string;
+ ref?: React.RefObject;
};
-const MD2Surface = ({
- style,
- theme: overrideTheme,
- ...rest
-}: Omit) => {
- const { elevation = 4 } = (StyleSheet.flatten(style) || {}) as ViewStyle;
- const { dark: isDarkTheme, mode, colors } = useInternalTheme(overrideTheme);
+const MD2Surface = React.forwardRef(
+ ({ style, theme: overrideTheme, ...rest }: Omit, ref) => {
+ const { elevation = 4 } = (StyleSheet.flatten(style) || {}) as ViewStyle;
+ const { dark: isDarkTheme, mode, colors } = useInternalTheme(overrideTheme);
- return (
-
- );
-};
+ return (
+
+ );
+ }
+);
/**
* Surface is a basic container that can give depth to an element with elevation shadow.
@@ -105,149 +105,182 @@ const MD2Surface = ({
* });
* ```
*/
-const Surface = ({
- elevation = 1,
- children,
- theme: overridenTheme,
- style,
- testID,
- ...props
-}: Props) => {
- const theme = useInternalTheme(overridenTheme);
-
- if (!theme.isV3)
- return (
-
- {children}
-
- );
-
- const { colors } = theme;
-
- const inputRange = [0, 1, 2, 3, 4, 5];
-
- const backgroundColor = (() => {
- if (isAnimatedValue(elevation)) {
- return elevation.interpolate({
- inputRange,
- outputRange: inputRange.map((elevation) => {
- return colors.elevation?.[`level${elevation as MD3Elevation}`];
- }),
- });
- }
+const Surface = React.forwardRef(
+ (
+ {
+ elevation = 1,
+ children,
+ theme: overridenTheme,
+ style,
+ testID,
+ ...props
+ }: Props,
+ ref
+ ) => {
+ const theme = useInternalTheme(overridenTheme);
- return colors.elevation?.[`level${elevation}`];
- })();
+ if (!theme.isV3)
+ return (
+
+ {children}
+
+ );
- if (Platform.OS === 'web') {
- return (
-
- {children}
-
- );
- }
+ const { colors } = theme;
- if (Platform.OS === 'android') {
- const elevationLevel = [0, 3, 6, 9, 12, 15];
+ const inputRange = [0, 1, 2, 3, 4, 5];
- const getElevationAndroid = () => {
+ const backgroundColor = (() => {
if (isAnimatedValue(elevation)) {
return elevation.interpolate({
inputRange,
- outputRange: elevationLevel,
+ outputRange: inputRange.map((elevation) => {
+ return colors.elevation?.[`level${elevation as MD3Elevation}`];
+ }),
});
}
- return elevationLevel[elevation];
- };
+ return colors.elevation?.[`level${elevation}`];
+ })();
- const { margin, padding, transform, borderRadius } = (StyleSheet.flatten(
- style
- ) || {}) as ViewStyle;
+ if (Platform.OS === 'web') {
+ return (
+
+ {children}
+
+ );
+ }
- const outerLayerStyles = { margin, padding, transform, borderRadius };
- const sharedStyle = [{ backgroundColor }, style];
+ if (Platform.OS === 'android') {
+ const elevationLevel = [0, 3, 6, 9, 12, 15];
- return (
-
- {children}
-
- );
- }
+ const getElevationAndroid = () => {
+ if (isAnimatedValue(elevation)) {
+ return elevation.interpolate({
+ inputRange,
+ outputRange: elevationLevel,
+ });
+ }
- const iOSShadowOutputRanges = [
- {
- shadowOpacity: 0.15,
- height: [0, 1, 2, 4, 6, 8],
- shadowRadius: [0, 3, 6, 8, 10, 12],
- },
- {
- shadowOpacity: 0.3,
- height: [0, 1, 1, 1, 2, 4],
- shadowRadius: [0, 1, 2, 3, 3, 4],
- },
- ];
+ return elevationLevel[elevation];
+ };
- const shadowColor = '#000';
+ const { margin, padding, transform, borderRadius } = (StyleSheet.flatten(
+ style
+ ) || {}) as ViewStyle;
- const { position, alignSelf, top, left, right, bottom, ...restStyle } =
- (StyleSheet.flatten(style) || {}) as ViewStyle;
+ const outerLayerStyles = { margin, padding, transform, borderRadius };
+ const sharedStyle = [{ backgroundColor }, style];
- const absoluteStyles = { position, alignSelf, top, right, bottom, left };
- const sharedStyle = [{ backgroundColor }, restStyle];
+ return (
+
+ {children}
+
+ );
+ }
- if (isAnimatedValue(elevation)) {
- const inputRange = [0, 1, 2, 3, 4, 5];
+ const iOSShadowOutputRanges = [
+ {
+ shadowOpacity: 0.15,
+ height: [0, 1, 2, 4, 6, 8],
+ shadowRadius: [0, 3, 6, 8, 10, 12],
+ },
+ {
+ shadowOpacity: 0.3,
+ height: [0, 1, 1, 1, 2, 4],
+ shadowRadius: [0, 1, 2, 3, 3, 4],
+ },
+ ];
+
+ const shadowColor = '#000';
+
+ const { position, alignSelf, top, left, right, bottom, ...restStyle } =
+ (StyleSheet.flatten(style) || {}) as ViewStyle;
+
+ const absoluteStyles = { position, alignSelf, top, right, bottom, left };
+ const sharedStyle = [{ backgroundColor }, restStyle];
+
+ if (isAnimatedValue(elevation)) {
+ const inputRange = [0, 1, 2, 3, 4, 5];
- const getStyleForAnimatedShadowLayer = (layer: 0 | 1) => {
+ const getStyleForAnimatedShadowLayer = (layer: 0 | 1) => {
+ return {
+ shadowColor,
+ shadowOpacity: elevation.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, iOSShadowOutputRanges[layer].shadowOpacity],
+ extrapolate: 'clamp',
+ }),
+ shadowOffset: {
+ width: 0,
+ height: elevation.interpolate({
+ inputRange,
+ outputRange: iOSShadowOutputRanges[layer].height,
+ }),
+ },
+ shadowRadius: elevation.interpolate({
+ inputRange,
+ outputRange: iOSShadowOutputRanges[layer].shadowRadius,
+ }),
+ };
+ };
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ const getStyleForShadowLayer = (layer: 0 | 1) => {
return {
shadowColor,
- shadowOpacity: elevation.interpolate({
- inputRange: [0, 1],
- outputRange: [0, iOSShadowOutputRanges[layer].shadowOpacity],
- extrapolate: 'clamp',
- }),
+ shadowOpacity: elevation
+ ? iOSShadowOutputRanges[layer].shadowOpacity
+ : 0,
shadowOffset: {
width: 0,
- height: elevation.interpolate({
- inputRange,
- outputRange: iOSShadowOutputRanges[layer].height,
- }),
+ height: iOSShadowOutputRanges[layer].height[elevation],
},
- shadowRadius: elevation.interpolate({
- inputRange,
- outputRange: iOSShadowOutputRanges[layer].shadowRadius,
- }),
+ shadowRadius: iOSShadowOutputRanges[layer].shadowRadius[elevation],
};
};
return (
-
+
{children}
@@ -255,28 +288,6 @@ const Surface = ({
);
}
-
- const getStyleForShadowLayer = (layer: 0 | 1) => {
- return {
- shadowColor,
- shadowOpacity: elevation ? iOSShadowOutputRanges[layer].shadowOpacity : 0,
- shadowOffset: {
- width: 0,
- height: iOSShadowOutputRanges[layer].height[elevation],
- },
- shadowRadius: iOSShadowOutputRanges[layer].shadowRadius[elevation],
- };
- };
-
- return (
-
-
-
- {children}
-
-
-
- );
-};
+);
export default Surface;
diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx
index 5e3e18b261..2f0970cfc4 100644
--- a/src/components/ToggleButton/ToggleButton.tsx
+++ b/src/components/ToggleButton/ToggleButton.tsx
@@ -4,6 +4,7 @@ import {
StyleProp,
StyleSheet,
ViewStyle,
+ View,
} from 'react-native';
import color from 'color';
@@ -54,6 +55,7 @@ export type Props = {
* @optional
*/
theme: InternalTheme;
+ ref?: React.RefObject;
};
/**
@@ -90,67 +92,75 @@ export type Props = {
*
* ```
*/
-const ToggleButton = ({
- icon,
- size,
- theme,
- accessibilityLabel,
- disabled,
- style,
- value,
- status,
- onPress,
- ...rest
-}: Props) => {
- const borderRadius = theme.roundness;
+const ToggleButton = React.forwardRef(
+ (
+ {
+ icon,
+ size,
+ theme,
+ accessibilityLabel,
+ disabled,
+ style,
+ value,
+ status,
+ onPress,
+ ...rest
+ }: Props,
+ ref
+ ) => {
+ const borderRadius = theme.roundness;
- return (
-
- {(context: { value: string | null; onValueChange: Function } | null) => {
- const checked: boolean | null =
- (context && context.value === value) || status === 'checked';
+ return (
+
+ {(
+ context: { value: string | null; onValueChange: Function } | null
+ ) => {
+ const checked: boolean | null =
+ (context && context.value === value) || status === 'checked';
- const backgroundColor = getToggleButtonColor({ theme, checked });
- const borderColor = theme.isV3
- ? theme.colors.outline
- : color(theme.dark ? white : black)
- .alpha(0.29)
- .rgb()
- .string();
+ const backgroundColor = getToggleButtonColor({ theme, checked });
+ const borderColor = theme.isV3
+ ? theme.colors.outline
+ : color(theme.dark ? white : black)
+ .alpha(0.29)
+ .rgb()
+ .string();
- return (
- {
- if (onPress) {
- onPress(e);
- }
+ return (
+ {
+ if (onPress) {
+ onPress(e);
+ }
- if (context) {
- context.onValueChange(!checked ? value : null);
- }
- }}
- size={size}
- accessibilityLabel={accessibilityLabel}
- accessibilityState={{ disabled, selected: checked }}
- disabled={disabled}
- style={[
- styles.content,
- {
- backgroundColor,
- borderRadius,
- borderColor,
- },
- style,
- ]}
- {...rest}
- />
- );
- }}
-
- );
-};
+ if (context) {
+ context.onValueChange(!checked ? value : null);
+ }
+ }}
+ size={size}
+ accessibilityLabel={accessibilityLabel}
+ accessibilityState={{ disabled, selected: checked }}
+ disabled={disabled}
+ style={[
+ styles.content,
+ {
+ backgroundColor,
+ borderRadius,
+ borderColor,
+ },
+ style,
+ ]}
+ ref={ref}
+ {...rest}
+ />
+ );
+ }}
+
+ );
+ }
+);
const styles = StyleSheet.create({
content: {
diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx
new file mode 100644
index 0000000000..99e56933c7
--- /dev/null
+++ b/src/components/Tooltip/Tooltip.tsx
@@ -0,0 +1,185 @@
+import * as React from 'react';
+import { Dimensions, View, LayoutChangeEvent, StyleSheet } from 'react-native';
+
+import { useInternalTheme } from '../../core/theming';
+import { addEventListener } from '../../utils/addEventListener';
+import Portal from '../Portal/Portal';
+import Text from '../Typography/Text';
+import { getTooltipPosition, Measurement } from './utils';
+
+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;
+};
+
+/**
+ * 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 = () => (
+ *
+ * {}} />
+ *
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+const Tooltip = ({
+ children,
+ enterTouchDelay = 500,
+ leaveTouchDelay = 1500,
+ title,
+ ...rest
+}: Props) => {
+ const theme = useInternalTheme();
+ const [visible, setVisible] = React.useState(false);
+ const [measurement, setMeasurement] = React.useState({
+ children: {},
+ tooltip: {},
+ measured: false,
+ });
+ const showTooltipTimer = React.useRef();
+ const hideTooltipTimer = React.useRef();
+ const childrenWrapperRef = React.useRef() as React.MutableRefObject;
+
+ React.useEffect(() => {
+ return () => {
+ if (showTooltipTimer.current) {
+ clearTimeout(showTooltipTimer.current);
+ }
+
+ if (hideTooltipTimer.current) {
+ clearTimeout(hideTooltipTimer.current);
+ }
+ };
+ }, []);
+
+ React.useEffect(() => {
+ const subscription = addEventListener(Dimensions, 'change', () =>
+ setVisible(false)
+ );
+
+ return () => subscription.remove();
+ }, []);
+
+ 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 handleTouchStart = () => {
+ if (hideTooltipTimer.current) {
+ clearTimeout(hideTooltipTimer.current);
+ }
+
+ showTooltipTimer.current = setTimeout(
+ () => setVisible(true),
+ enterTouchDelay
+ ) as unknown as NodeJS.Timeout;
+ };
+
+ const handleTouchEnd = () => {
+ if (showTooltipTimer.current) {
+ clearTimeout(showTooltipTimer.current);
+ }
+
+ hideTooltipTimer.current = setTimeout(() => {
+ setVisible(false);
+ setMeasurement({ children: {}, tooltip: {}, measured: false });
+ }, leaveTouchDelay) as unknown as NodeJS.Timeout;
+ };
+
+ return (
+ <>
+ {visible && (
+
+
+
+ {title}
+
+
+
+ )}
+
+ {React.cloneElement(children, { ...rest, ref: childrenWrapperRef })}
+
+ >
+ );
+};
+
+const styles = StyleSheet.create({
+ tooltip: {
+ alignSelf: 'flex-start',
+ justifyContent: 'center',
+ paddingHorizontal: 16,
+ height: 32,
+ maxHeight: 32,
+ },
+ visible: {
+ opacity: 1,
+ },
+ hidden: {
+ opacity: 0,
+ },
+});
+
+export default Tooltip;
diff --git a/src/components/Tooltip/utils.ts b/src/components/Tooltip/utils.ts
new file mode 100644
index 0000000000..8962b66461
--- /dev/null
+++ b/src/components/Tooltip/utils.ts
@@ -0,0 +1,85 @@
+import { Dimensions, LayoutRectangle } from 'react-native';
+
+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 = (center: number): boolean => {
+ return center < 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 = (center: number, tooltipWidth: number): boolean => {
+ const { width: layoutWidth } = Dimensions.get('window');
+
+ return center + 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 => {
+ const center = childrenX + (childrenWidth - tooltipWidth) / 2;
+
+ if (overflowLeft(center)) return childrenX;
+
+ if (overflowRight(center, tooltipWidth))
+ return childrenX + childrenWidth - tooltipWidth;
+
+ return center;
+};
+
+const getTooltipYPosition = (
+ { pageY: childrenY, height: childrenHeight }: ChildrenMeasurement,
+ { height: tooltipHeight }: TooltipLayout
+): number => {
+ if (overflowBottom(childrenY, childrenHeight, tooltipHeight))
+ return childrenY - tooltipHeight;
+
+ return childrenY + childrenHeight;
+};
+
+export const getTooltipPosition = ({
+ children,
+ tooltip,
+ measured,
+}: Measurement): {} | { left: number; top: number } => {
+ if (!measured) return {};
+
+ return {
+ left: getTooltipXPosition(children, tooltip),
+ top: getTooltipYPosition(children, tooltip),
+ };
+};
diff --git a/src/components/__tests__/Appbar/Appbar.test.js b/src/components/__tests__/Appbar/Appbar.test.js
index c8ca94e4cb..fd800c21cc 100644
--- a/src/components/__tests__/Appbar/Appbar.test.js
+++ b/src/components/__tests__/Appbar/Appbar.test.js
@@ -4,6 +4,7 @@ import { Platform } from 'react-native';
import { render } from '@testing-library/react-native';
import renderer from 'react-test-renderer';
+import Provider from '../../../core/Provider';
import { getTheme } from '../../../core/theming';
import overlay from '../../../styles/overlay';
import { tokens } from '../../../styles/themes/v3/tokens';
@@ -15,6 +16,7 @@ import AppbarHeader from '../../Appbar/AppbarHeader';
import { getAppbarColor, renderAppbarContent } from '../../Appbar/utils';
import Menu from '../../Menu/Menu';
import Searchbar from '../../Searchbar';
+import Tooltip from '../../Tooltip/Tooltip';
describe('Appbar', () => {
it('does not pass any additional props to Searchbar', () => {
@@ -233,6 +235,38 @@ describe('AppbarAction', () => {
const appbarBackActionIcon = getByTestId('appbar-action').props.children[0];
expect(appbarBackActionIcon.props.color).toBe('purple');
});
+
+ describe('When V2', () => {
+ const theme = { isV3: false };
+
+ it('should be rendered with the right color when no color is passed', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const appbarActionIcon = getByTestId('appbar-action').props.children[0];
+
+ expect(appbarActionIcon.props.color).toBe('#ffffff');
+ });
+
+ it('should be rendered with the right color when no color is passed but is wrapped by a Tooltip', () => {
+ const { getByTestId } = render(
+
+
+
+
+
+
+
+ );
+
+ const appbarActionIcon = getByTestId('appbar-action').props.children[0];
+
+ expect(appbarActionIcon.props.color).toBe('#ffffff');
+ });
+ });
});
describe('getAppbarColors', () => {
diff --git a/src/components/__tests__/Tooltip.test.js b/src/components/__tests__/Tooltip.test.js
new file mode 100644
index 0000000000..c2269c0a08
--- /dev/null
+++ b/src/components/__tests__/Tooltip.test.js
@@ -0,0 +1,254 @@
+import React from 'react';
+import { Dimensions, Text, View } from 'react-native';
+
+import {
+ fireEvent,
+ render,
+ waitForElementToBeRemoved,
+} from '@testing-library/react-native';
+
+import Provider from '../../core/Provider';
+import Tooltip from '../Tooltip/Tooltip';
+
+const mockedRemoveEventListener = jest.fn();
+
+jest.mock('react-native', () => {
+ const RN = jest.requireActual('react-native');
+
+ RN.Dimensions.addEventListener = () => ({
+ remove: mockedRemoveEventListener,
+ });
+
+ return RN;
+});
+
+const DummyComponent = React.forwardRef((props, ref) => (
+
+ dummy component
+
+));
+
+describe('Tooltip', () => {
+ const setup = (propOverrides, measure = {}) => {
+ const defaultProps = {
+ children: ,
+ title: 'some tooltip text',
+ ...propOverrides,
+ };
+
+ const { x, y, width, height, pageX, pageY } = {
+ x: null,
+ y: null,
+ width: 80,
+ height: 50,
+ pageX: 220,
+ pageY: 200,
+ ...measure,
+ };
+
+ jest
+ .spyOn(View.prototype, 'measure')
+ .mockImplementation((cb) => cb(x, y, width, height, pageX, pageY));
+
+ const wrapper = render(
+
+
+
+ );
+
+ return { wrapper };
+ };
+
+ describe('Unmount', () => {
+ beforeAll(() => jest.spyOn(global, 'clearTimeout'));
+ afterEach(() => jest.clearAllMocks());
+
+ it('removes showTooltipTimer when the component unmounts', async () => {
+ const {
+ wrapper: { getByText, unmount },
+ } = setup({ enterTouchDelay: 5000 });
+
+ fireEvent(getByText('dummy component'), 'touchStart');
+
+ unmount();
+
+ expect(global.clearTimeout).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes hideTooltipTimer when the component unmounts', async () => {
+ const {
+ wrapper: { getByText, unmount },
+ } = setup({ enterTouchDelay: 5000 });
+
+ fireEvent(getByText('dummy component'), 'touchEnd');
+
+ unmount();
+
+ expect(global.clearTimeout).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes Dimensions listener when the component unmount', async () => {
+ const {
+ wrapper: { getByText, findByText, unmount },
+ } = setup();
+
+ fireEvent(getByText('dummy component'), 'touchStart');
+
+ await findByText('some tooltip text');
+
+ unmount();
+
+ expect(mockedRemoveEventListener).toHaveBeenCalled();
+ });
+ });
+
+ describe('touchStart', () => {
+ beforeAll(() => jest.spyOn(global, 'clearTimeout'));
+ afterEach(() => jest.clearAllMocks());
+
+ it('clears the hide timer when the user start pressing the component again', () => {
+ jest.spyOn(global, 'clearTimeout');
+
+ const {
+ wrapper: { getByText },
+ } = setup();
+
+ fireEvent(getByText('dummy component'), 'touchStart');
+ fireEvent(getByText('dummy component'), 'touchEnd');
+ fireEvent(getByText('dummy component'), 'touchStart');
+
+ expect(global.clearTimeout).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe.each([
+ ['touchEnd', 'hides the tooltip when the user stop pressing the component'],
+ ['touchCancel', 'hides the tooltip when the user cancel the touch action'],
+ ])('%s', (eventName, testDescription) => {
+ // eslint-disable-next-line jest/valid-title
+ it(testDescription, async () => {
+ const {
+ wrapper: { queryByText, getByText, findByText },
+ } = setup({ enterTouchDelay: 50, leaveTouchDelay: 0 });
+
+ fireEvent(getByText('dummy component'), 'touchStart');
+
+ await findByText('some tooltip text');
+
+ fireEvent(getByText('dummy component'), eventName);
+
+ await waitForElementToBeRemoved(() => getByText('some tooltip text'));
+
+ expect(queryByText('some tooltip text')).toBeNull();
+ });
+ });
+
+ describe('Tooltip position', () => {
+ const LAYOUT_WIDTH = 360;
+ const LAYOUT_HEIGHT = 705;
+ const TOOLTIP_WIDTH = 100;
+ const TOOLTIP_HEIGHT = 100;
+
+ beforeAll(() => {
+ jest
+ .spyOn(Dimensions, 'get')
+ .mockReturnValue({ width: LAYOUT_WIDTH, height: LAYOUT_HEIGHT });
+ });
+
+ describe('When it does not overflow', () => {
+ it('centers the tooltip in the middle of the children component', async () => {
+ const {
+ wrapper: { getByText, getByTestId, findByText },
+ } = setup();
+
+ fireEvent(getByText('dummy component'), 'touchStart');
+
+ fireEvent(await findByText('some tooltip text'), 'layout', {
+ nativeEvent: {
+ layout: { width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT },
+ },
+ });
+
+ expect(getByTestId('tooltip-container').props.style).toMatchObject([
+ {},
+ {
+ left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210
+ top: 250, // pageY (200) + height (50)
+ },
+ ]);
+ });
+ });
+
+ describe('When it overflows to left', () => {
+ it('renders the tooltip with the right placement', async () => {
+ const {
+ wrapper: { getByText, getByTestId, findByText },
+ } = setup({}, { pageX: 0 }); // Component starting at the starting 0 X coord
+
+ fireEvent(getByText('dummy component'), 'touchStart');
+
+ fireEvent(await findByText('some tooltip text'), 'layout', {
+ nativeEvent: {
+ layout: { width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT },
+ },
+ });
+
+ expect(getByTestId('tooltip-container').props.style).toMatchObject([
+ {},
+ {
+ left: 0, // Tooltip renders starting from children's x coord
+ top: 250,
+ },
+ ]);
+ });
+ });
+
+ describe('When it overflows to right', () => {
+ it('renders the tooltip with the right placement', async () => {
+ const {
+ wrapper: { getByText, getByTestId, findByText },
+ } = setup({}, { pageX: 900, width: 150 }); // Component close to the screen limit
+
+ fireEvent(getByText('dummy component'), 'touchStart');
+
+ fireEvent(await findByText('some tooltip text'), 'layout', {
+ nativeEvent: {
+ layout: { width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT },
+ },
+ });
+
+ expect(getByTestId('tooltip-container').props.style).toMatchObject([
+ {},
+ {
+ left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen
+ top: 250,
+ },
+ ]);
+ });
+ });
+
+ describe('When it overflows to bottom', () => {
+ it('renders the tooltip with the right placement', async () => {
+ const {
+ wrapper: { getByText, getByTestId, findByText },
+ } = setup({}, { pageY: 600, height: 50 });
+
+ fireEvent(getByText('dummy component'), 'touchStart');
+
+ fireEvent(await findByText('some tooltip text'), 'layout', {
+ nativeEvent: {
+ layout: { width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT },
+ },
+ });
+
+ expect(getByTestId('tooltip-container').props.style).toMatchObject([
+ {},
+ {
+ left: 210,
+ top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component,
+ },
+ ]);
+ });
+ });
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index e12a086bd9..da07c7794f 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -54,6 +54,7 @@ export { default as TouchableRipple } from './components/TouchableRipple/Touchab
export { default as TextInput } from './components/TextInput/TextInput';
export { default as ToggleButton } from './components/ToggleButton';
export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons';
+export { default as Tooltip } from './components/Tooltip/Tooltip';
export {
Caption,
@@ -146,5 +147,6 @@ export type { Props as TitleProps } from './components/Typography/v2/Title';
export type { Props as TextProps } from './components/Typography/Text';
export type { Props as SegmentedButtonsProps } from './components/SegmentedButtons/SegmentedButtons';
export type { Props as ListImageProps } from './components/List/ListImage';
+export type { Props as TooltipProps } from './components/Tooltip/Tooltip';
export type { MD2Theme, MD3Theme, ThemeBase, MD3Elevation } from './types';
diff --git a/src/styles/themes/v2/DarkTheme.tsx b/src/styles/themes/v2/DarkTheme.tsx
index ac5d3df6d7..05540a44b5 100644
--- a/src/styles/themes/v2/DarkTheme.tsx
+++ b/src/styles/themes/v2/DarkTheme.tsx
@@ -24,6 +24,7 @@ export const MD2DarkTheme: MD2Theme = {
placeholder: color(white).alpha(0.54).rgb().string(),
backdrop: color(black).alpha(0.5).rgb().string(),
notification: pinkA100,
+ tooltip: 'rgba(230, 225, 229, 1)',
},
fonts: configureFonts(),
};
diff --git a/src/styles/themes/v2/LightTheme.tsx b/src/styles/themes/v2/LightTheme.tsx
index 4305883a2b..67dde7bb7e 100644
--- a/src/styles/themes/v2/LightTheme.tsx
+++ b/src/styles/themes/v2/LightTheme.tsx
@@ -21,6 +21,7 @@ export const MD2LightTheme: MD2Theme = {
placeholder: color(black).alpha(0.54).rgb().string(),
backdrop: color(black).alpha(0.5).rgb().string(),
notification: pinkA400,
+ tooltip: 'rgba(28, 27, 31, 1)',
},
fonts: configureFonts(),
animation: {
diff --git a/src/types.tsx b/src/types.tsx
index 960d22d6eb..5688f927e6 100644
--- a/src/types.tsx
+++ b/src/types.tsx
@@ -39,6 +39,7 @@ export type MD2Colors = {
placeholder: string;
backdrop: string;
notification: string;
+ tooltip: string;
};
export type MD3Colors = {