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. + * + *
+ *
+ * + *
TODO
+ *
+ *
+ * + * ## 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 = {