diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 06edd921440c..c1fe4270d4e1 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -37,6 +37,7 @@ import RenderHTML from './RenderHTML'; import SelectCircle from './SelectCircle'; import SubscriptAvatar from './SubscriptAvatar'; import Text from './Text'; +import EducationalTooltip from './Tooltip/EducationalTooltip'; type IconProps = { /** Flag to choose between avatar image or an icon */ @@ -285,6 +286,18 @@ type MenuItemBaseProps = { /** Optional account id if it's user avatar or policy id if it's workspace avatar */ avatarID?: number | string; + + /** Whether to show the tooltip */ + shouldRenderTooltip?: boolean; + + /** Whether to align the tooltip left */ + shouldForceRenderingTooltipLeft?: boolean; + + /** Additional styles for tooltip wrapper */ + tooltipWrapperStyle?: StyleProp; + + /** Render custom content inside the tooltip. */ + renderTooltipContent?: () => ReactNode; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -369,6 +382,10 @@ function MenuItem( onFocus, onBlur, avatarID, + shouldRenderTooltip = false, + shouldForceRenderingTooltipLeft = false, + tooltipWrapperStyle = {}, + renderTooltipContent, }: MenuItemProps, ref: PressableRef, ) { @@ -471,267 +488,287 @@ function MenuItem( {label} )} - - {(isHovered) => ( - shouldBlockSelection && shouldUseNarrowLayout && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={ControlSelection.unblock} - onSecondaryInteraction={onSecondaryInteraction} - wrapperStyle={outerWrapperStyle} - style={({pressed}) => - [ - containerStyle, - combinedStyle, - !interactive && styles.cursorDefault, - StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true), - ...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]), - !focused && (isHovered || pressed) && hoverAndPressStyle, - shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled, - ] as StyleProp - } - disabledStyle={shouldUseDefaultCursorWhenDisabled && [styles.cursorDefault]} - disabled={disabled || isExecuting} - ref={ref} - role={CONST.ROLE.MENUITEM} - accessibilityLabel={title ? title.toString() : ''} - accessible - onFocus={onFocus} - > - {({pressed}) => ( - - - - {!!label && isLabelHoverable && ( - - - {label} - - - )} - - {!!icon && Array.isArray(icon) && ( - - )} - {!icon && shouldPutLeftPaddingWhenNoIcon && } - {icon && !Array.isArray(icon) && ( - - {typeof icon !== 'string' && iconType === CONST.ICON_TYPE_ICON && ( - + + + {(isHovered) => ( + shouldBlockSelection && shouldUseNarrowLayout && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={ControlSelection.unblock} + onSecondaryInteraction={onSecondaryInteraction} + wrapperStyle={outerWrapperStyle} + style={({pressed}) => + [ + containerStyle, + combinedStyle, + !interactive && styles.cursorDefault, + StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true), + ...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]), + !focused && (isHovered || pressed) && hoverAndPressStyle, + shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled, + ] as StyleProp + } + disabledStyle={shouldUseDefaultCursorWhenDisabled && [styles.cursorDefault]} + disabled={disabled || isExecuting} + ref={ref} + role={CONST.ROLE.MENUITEM} + accessibilityLabel={title ? title.toString() : ''} + accessible + onFocus={onFocus} + > + {({pressed}) => ( + + + + {!!label && isLabelHoverable && ( + + + {label} + + + )} + + {!!icon && Array.isArray(icon) && ( + )} - {icon && iconType === CONST.ICON_TYPE_WORKSPACE && ( - + {!icon && shouldPutLeftPaddingWhenNoIcon && ( + )} - {iconType === CONST.ICON_TYPE_AVATAR && ( - + {icon && !Array.isArray(icon) && ( + + {typeof icon !== 'string' && iconType === CONST.ICON_TYPE_ICON && ( + + )} + {icon && iconType === CONST.ICON_TYPE_WORKSPACE && ( + + )} + {iconType === CONST.ICON_TYPE_AVATAR && ( + + )} + )} - - )} - {secondaryIcon && ( - - - - )} - - {!!description && shouldShowDescriptionOnTop && ( - + + + )} + - {description} - - )} - {(!!title || !!shouldShowTitleIcon) && ( - - {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && ( - - + {!!description && shouldShowDescriptionOnTop && ( + + {description} + + )} + {(!!title || !!shouldShowTitleIcon) && ( + + {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && ( + + + + )} + {!shouldRenderAsHTML && !shouldParseTitle && !!title && ( + + {renderTitleContent()} + + )} + {shouldShowTitleIcon && titleIcon && ( + + + + )} )} - {!shouldRenderAsHTML && !shouldParseTitle && !!title && ( + {!!description && !shouldShowDescriptionOnTop && ( - {renderTitleContent()} + {description} )} - {shouldShowTitleIcon && titleIcon && ( - - + {!!furtherDetails && ( + + {!!furtherDetailsIcon && ( + + )} + + {furtherDetails} + )} + {titleComponent} + + + + {badgeText && ( + )} - {!!description && !shouldShowDescriptionOnTop && ( - - {description} - + {/* Since subtitle can be of type number, we should allow 0 to be shown */} + {(subtitle === 0 || subtitle) && ( + + {subtitle} + )} - {!!furtherDetails && ( - - {!!furtherDetailsIcon && ( - 0 && ( + + {shouldShowSubscriptRightAvatar ? ( + + ) : ( + )} - - {furtherDetails} - )} - {titleComponent} + {!!brickRoadIndicator && ( + + + + )} + {!title && !!rightLabel && !errorText && ( + + {rightLabel} + + )} + {shouldShowRightIcon && ( + + + + )} + {shouldShowRightComponent && rightComponent} + {shouldShowSelectedState && } - - - {badgeText && ( - )} - {/* Since subtitle can be of type number, we should allow 0 to be shown */} - {(subtitle === 0 || subtitle) && ( - - {subtitle} - - )} - {floatRightAvatars?.length > 0 && ( - - {shouldShowSubscriptRightAvatar ? ( - - ) : ( - - )} - - )} - {!!brickRoadIndicator && ( - - - - )} - {!title && !!rightLabel && !errorText && ( - - {rightLabel} - - )} - {shouldShowRightIcon && ( - - - + {!!hintText && ( + )} - {shouldShowRightComponent && rightComponent} - {shouldShowSelectedState && } - - {!!errorText && ( - )} - {!!hintText && ( - - )} - + )} - - )} - - {!!helperText && {helperText}} + + {!!helperText && {helperText}} + + ); } diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index 3ef267292e90..47486fd32791 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -4,6 +4,7 @@ import Modal from '@components/Modal'; import {PopoverContext} from '@components/PopoverProvider'; import PopoverWithoutOverlay from '@components/PopoverWithoutOverlay'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import TooltipRefManager from '@libs/TooltipRefManager'; import CONST from '@src/CONST'; import type {PopoverProps} from './types'; @@ -52,6 +53,7 @@ function Popover(props: PopoverProps) { if (popover && 'current' in anchorRef) { close(anchorRef); } + TooltipRefManager.hideTooltip(); onClose(); }; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 1ed819ca853b..731db79f67fc 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -242,6 +242,10 @@ function PopoverMenu({ onFocus={() => setFocusedIndex(menuIndex)} success={item.success} containerStyle={item.containerStyle} + shouldRenderTooltip={item.shouldRenderTooltip} + shouldForceRenderingTooltipLeft={item.shouldForceRenderingTooltipLeft} + tooltipWrapperStyle={item.tooltipWrapperStyle} + renderTooltipContent={item.renderTooltipContent} /> ))} diff --git a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx new file mode 100644 index 000000000000..e5a9b873dbc0 --- /dev/null +++ b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx @@ -0,0 +1,132 @@ +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {Animated, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text as RNText, View as RNView} from 'react-native'; +import Text from '@components/Text'; +import useStyleUtils from '@hooks/useStyleUtils'; +import type {BaseGenericTooltipProps} from './types'; + +// Props will change frequently. +// On every tooltip hover, we update the position in state which will result in re-rendering. +// We also update the state on layout changes which will be triggered often. +// There will be n number of tooltip components in the page. +// It's good to memoize this one. +function BaseGenericTooltip({ + animation, + windowWidth, + xOffset, + yOffset, + targetWidth, + targetHeight, + shiftHorizontal = 0, + shiftVertical = 0, + text, + numberOfLines, + maxWidth = 0, + renderTooltipContent, + shouldForceRenderingBelow = false, + shouldForceRenderingLeft = false, + wrapperStyle = {}, +}: BaseGenericTooltipProps) { + // The width of tooltip's inner content. Has to be undefined in the beginning + // as a width of 0 will cause the content to be rendered of a width of 0, + // which prevents us from measuring it correctly. + const [contentMeasuredWidth, setContentMeasuredWidth] = useState(); + + // The height of tooltip's wrapper. + const [wrapperMeasuredHeight, setWrapperMeasuredHeight] = useState(); + const textContentRef = useRef(null); + const viewContentRef = useRef(null); + const rootWrapper = useRef(null); + + const StyleUtils = useStyleUtils(); + + // Measure content width + useEffect(() => { + if (!textContentRef.current && !viewContentRef.current) { + return; + } + const contentRef = viewContentRef.current ?? textContentRef.current; + contentRef?.measure((x, y, width) => setContentMeasuredWidth(width)); + }, []); + + const {animationStyle, rootWrapperStyle, textStyle, pointerWrapperStyle, pointerStyle} = useMemo( + () => + StyleUtils.getTooltipStyles({ + tooltip: rootWrapper.current, + currentSize: animation, + windowWidth, + xOffset, + yOffset, + tooltipTargetWidth: targetWidth, + tooltipTargetHeight: targetHeight, + maxWidth, + tooltipContentWidth: contentMeasuredWidth, + tooltipWrapperHeight: wrapperMeasuredHeight, + manualShiftHorizontal: shiftHorizontal, + manualShiftVertical: shiftVertical, + shouldForceRenderingBelow, + shouldForceRenderingLeft, + wrapperStyle, + }), + [ + StyleUtils, + animation, + windowWidth, + xOffset, + yOffset, + targetWidth, + targetHeight, + maxWidth, + contentMeasuredWidth, + wrapperMeasuredHeight, + shiftHorizontal, + shiftVertical, + shouldForceRenderingBelow, + shouldForceRenderingLeft, + wrapperStyle, + ], + ); + + let content; + if (renderTooltipContent) { + content = {renderTooltipContent()}; + } else { + content = ( + + + {text} + + + ); + } + + return ( + { + const {height} = e.nativeEvent.layout; + if (height === wrapperMeasuredHeight) { + return; + } + setWrapperMeasuredHeight(height); + }} + > + {content} + + + + + ); +} + +BaseGenericTooltip.displayName = 'BaseGenericTooltip'; + +export default React.memo(BaseGenericTooltip); diff --git a/src/components/Tooltip/TooltipRenderedOnPageBody.tsx b/src/components/Tooltip/BaseGenericTooltip/index.tsx similarity index 69% rename from src/components/Tooltip/TooltipRenderedOnPageBody.tsx rename to src/components/Tooltip/BaseGenericTooltip/index.tsx index 0e97c2463532..bb02e17f07d9 100644 --- a/src/components/Tooltip/TooltipRenderedOnPageBody.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.tsx @@ -1,47 +1,18 @@ -import React, {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useLayoutEffect, useMemo, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; import {Animated, View} from 'react-native'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; -import Log from '@libs/Log'; import textRef from '@src/types/utils/textRef'; import viewRef from '@src/types/utils/viewRef'; -import type TooltipProps from './types'; - -type TooltipRenderedOnPageBodyProps = { - /** Window width */ - windowWidth: number; - - /** Tooltip Animation value */ - animation: Animated.Value; - - /** The distance between the left side of the wrapper view and the left side of the window */ - xOffset: number; - - /** The distance between the top of the wrapper view and the top of the window */ - yOffset: number; - - /** The width of the tooltip's target */ - targetWidth: number; - - /** The height of the tooltip's target */ - targetHeight: number; - - /** Any additional amount to manually adjust the horizontal position of the tooltip. - A positive value shifts the tooltip to the right, and a negative value shifts it to the left. */ - shiftHorizontal?: number; - - /** Any additional amount to manually adjust the vertical position of the tooltip. - A positive value shifts the tooltip down, and a negative value shifts it up. */ - shiftVertical?: number; -} & Pick; +import type {BaseGenericTooltipProps} from './types'; // Props will change frequently. // On every tooltip hover, we update the position in state which will result in re-rendering. // We also update the state on layout changes which will be triggered often. // There will be n number of tooltip components in the page. // It's good to memoize this one. -function TooltipRenderedOnPageBody({ +function BaseGenericTooltip({ animation, windowWidth, xOffset, @@ -55,7 +26,9 @@ function TooltipRenderedOnPageBody({ maxWidth = 0, renderTooltipContent, shouldForceRenderingBelow = false, -}: TooltipRenderedOnPageBodyProps) { + wrapperStyle = {}, + shouldForceRenderingLeft = false, +}: BaseGenericTooltipProps) { // The width of tooltip's inner content. Has to be undefined in the beginning // as a width of 0 will cause the content to be rendered of a width of 0, // which prevents us from measuring it correctly. @@ -67,13 +40,6 @@ function TooltipRenderedOnPageBody({ const StyleUtils = useStyleUtils(); - useEffect(() => { - if (!renderTooltipContent || !text) { - return; - } - Log.warn('Developer error: Cannot use both text and renderTooltipContent props at the same time in !'); - }, [text, renderTooltipContent]); - useLayoutEffect(() => { // Calculate the tooltip width and height before the browser repaints the screen to prevent flicker // because of the late update of the width and the height from onLayout. @@ -97,6 +63,8 @@ function TooltipRenderedOnPageBody({ manualShiftHorizontal: shiftHorizontal, manualShiftVertical: shiftVertical, shouldForceRenderingBelow, + shouldForceRenderingLeft, + wrapperStyle, }), [ StyleUtils, @@ -112,6 +80,8 @@ function TooltipRenderedOnPageBody({ shiftHorizontal, shiftVertical, shouldForceRenderingBelow, + shouldForceRenderingLeft, + wrapperStyle, ], ); @@ -154,6 +124,6 @@ function TooltipRenderedOnPageBody({ ); } -TooltipRenderedOnPageBody.displayName = 'TooltipRenderedOnPageBody'; +BaseGenericTooltip.displayName = 'BaseGenericTooltip'; -export default React.memo(TooltipRenderedOnPageBody); +export default React.memo(BaseGenericTooltip); diff --git a/src/components/Tooltip/BaseGenericTooltip/types.ts b/src/components/Tooltip/BaseGenericTooltip/types.ts new file mode 100644 index 000000000000..662905fc1ec6 --- /dev/null +++ b/src/components/Tooltip/BaseGenericTooltip/types.ts @@ -0,0 +1,33 @@ +import type {Animated} from 'react-native'; +import type TooltipProps from '@components/Tooltip/types'; + +type BaseGenericTooltipProps = { + /** Window width */ + windowWidth: number; + + /** Tooltip Animation value */ + animation: Animated.Value; + + /** The distance between the left side of the wrapper view and the left side of the window */ + xOffset: number; + + /** The distance between the top of the wrapper view and the top of the window */ + yOffset: number; + + /** The width of the tooltip's target */ + targetWidth: number; + + /** The height of the tooltip's target */ + targetHeight: number; + + /** Any additional amount to manually adjust the horizontal position of the tooltip. + A positive value shifts the tooltip to the right, and a negative value shifts it to the left. */ + shiftHorizontal?: number; + + /** Any additional amount to manually adjust the vertical position of the tooltip. + A positive value shifts the tooltip down, and a negative value shifts it up. */ + shiftVertical?: number; +} & Pick; + +// eslint-disable-next-line import/prefer-default-export +export type {BaseGenericTooltipProps}; diff --git a/src/components/Tooltip/BaseTooltip/index.tsx b/src/components/Tooltip/BaseTooltip/index.tsx index 656761006d91..90b3b2429310 100644 --- a/src/components/Tooltip/BaseTooltip/index.tsx +++ b/src/components/Tooltip/BaseTooltip/index.tsx @@ -1,19 +1,11 @@ import {BoundsObserver} from '@react-ng/bounds-observer'; import type {ForwardedRef} from 'react'; -import React, {forwardRef, memo, useCallback, useEffect, useRef, useState} from 'react'; -import {Animated} from 'react-native'; +import React, {forwardRef, memo, useCallback, useRef} from 'react'; +import type {LayoutRectangle} from 'react-native'; import Hoverable from '@components/Hoverable'; -import TooltipRenderedOnPageBody from '@components/Tooltip/TooltipRenderedOnPageBody'; -import TooltipSense from '@components/Tooltip/TooltipSense'; +import GenericTooltip from '@components/Tooltip/GenericTooltip'; import type TooltipProps from '@components/Tooltip/types'; -import useLocalize from '@hooks/useLocalize'; -import usePrevious from '@hooks/usePrevious'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import StringUtils from '@libs/StringUtils'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import callOrReturn from '@src/types/utils/callOrReturn'; const hasHoverSupport = DeviceCapabilities.hasHoverSupport(); @@ -54,42 +46,7 @@ function chooseBoundingBox(target: HTMLElement, clientX: number, clientY: number return target.getBoundingClientRect(); } -function Tooltip( - { - children, - numberOfLines = CONST.TOOLTIP_MAX_LINES, - maxWidth = variables.sideBarWidth, - text = '', - renderTooltipContent, - renderTooltipContentKey = [], - shouldHandleScroll = false, - shiftHorizontal = 0, - shiftVertical = 0, - shouldForceRenderingBelow = false, - }: TooltipProps, - ref: ForwardedRef, -) { - const {preferredLocale} = useLocalize(); - const {windowWidth} = useWindowDimensions(); - - // Is tooltip already rendered on the page's body? happens once. - const [isRendered, setIsRendered] = useState(false); - // Is the tooltip currently visible? - const [isVisible, setIsVisible] = useState(false); - // The distance between the left side of the wrapper view and the left side of the window - const [xOffset, setXOffset] = useState(0); - // The distance between the top of the wrapper view and the top of the window - const [yOffset, setYOffset] = useState(0); - // The width and height of the wrapper view - const [wrapperWidth, setWrapperWidth] = useState(0); - const [wrapperHeight, setWrapperHeight] = useState(0); - - // Whether the tooltip is first tooltip to activate the TooltipSense - const isTooltipSenseInitiator = useRef(false); - const animation = useRef(new Animated.Value(0)); - const isAnimationCanceled = useRef(false); - const prevText = usePrevious(text); - +function Tooltip({children, shouldHandleScroll = false, ...props}: TooltipProps, ref: ForwardedRef) { const target = useRef(null); const initialMousePosition = useRef({x: 0, y: 0}); @@ -102,88 +59,19 @@ function Tooltip( }, []); /** - * Display the tooltip in an animation. + * Get the tooltip bounding rectangle */ - const showTooltip = useCallback(() => { - setIsRendered(true); - setIsVisible(true); - - animation.current.stopAnimation(); - - // When TooltipSense is active, immediately show the tooltip - if (TooltipSense.isActive()) { - animation.current.setValue(1); - } else { - isTooltipSenseInitiator.current = true; - Animated.timing(animation.current, { - toValue: 1, - duration: 140, - delay: 500, - useNativeDriver: false, - }).start(({finished}) => { - isAnimationCanceled.current = !finished; - }); - } - TooltipSense.activate(); - }, []); - - // eslint-disable-next-line rulesdir/prefer-early-return - useEffect(() => { - // if the tooltip text changed before the initial animation was finished, then the tooltip won't be shown - // we need to show the tooltip again - if (isVisible && isAnimationCanceled.current && text && prevText !== text) { - isAnimationCanceled.current = false; - showTooltip(); - } - }, [isVisible, text, prevText, showTooltip]); - - /** - * Update the tooltip bounding rectangle - */ - const updateBounds = (bounds: DOMRect) => { - if (bounds.width === 0) { - setIsRendered(false); - } + const getBounds = (bounds: DOMRect): LayoutRectangle => { if (!target.current) { - return; + return bounds; } // Choose a bounding box for the tooltip to target. // In the case when the target is a link that has wrapped onto // multiple lines, we want to show the tooltip over the part // of the link that the user is hovering over. - const betterBounds = chooseBoundingBox(target.current, initialMousePosition.current.x, initialMousePosition.current.y); - if (!betterBounds) { - return; - } - setWrapperWidth(betterBounds.width); - setWrapperHeight(betterBounds.height); - setXOffset(betterBounds.x); - setYOffset(betterBounds.y); + return chooseBoundingBox(target.current, initialMousePosition.current.x, initialMousePosition.current.y); }; - /** - * Hide the tooltip in an animation. - */ - const hideTooltip = useCallback(() => { - animation.current.stopAnimation(); - - if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) { - animation.current.setValue(0); - } else { - // Hide the first tooltip which initiated the TooltipSense with animation - isTooltipSenseInitiator.current = false; - Animated.timing(animation.current, { - toValue: 0, - duration: 140, - useNativeDriver: false, - }).start(); - } - - TooltipSense.deactivate(); - - setIsVisible(false); - }, []); - const updateTargetPositionOnMouseEnter = useCallback( (e: MouseEvent) => { updateTargetAndMousePosition(e); @@ -195,42 +83,23 @@ function Tooltip( [children, updateTargetAndMousePosition], ); - // Skip the tooltip and return the children if the text is empty, - // we don't have a render function or the device does not support hovering - if ((StringUtils.isEmptyString(text) && renderTooltipContent == null) || !hasHoverSupport) { + // Skip the tooltip and return the children if the device does not support hovering + if (!hasHoverSupport) { return children; } return ( - <> - {isRendered && ( - - )} - - { + // eslint-disable-next-line react/jsx-props-no-spreading + + {({isVisible, showTooltip, hideTooltip, updateTargetBounds}) => // Checks if valid element so we can wrap the BoundsObserver around it // If not, we just return the primitive children React.isValidElement(children) ? ( { + updateTargetBounds(getBounds(bounds)); + }} ref={ref} > + ); } diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx new file mode 100644 index 000000000000..1398d74bbd67 --- /dev/null +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -0,0 +1,58 @@ +import React, {memo, useEffect, useRef} from 'react'; +import type {LayoutEvent} from 'react-native'; +import GenericTooltip from '@components/Tooltip/GenericTooltip'; +import type TooltipProps from '@components/Tooltip/types'; +import getBounds from './getBounds'; + +/** + * A component used to wrap an element intended for displaying a tooltip. + * This tooltip would show immediately without user's interaction and hide after 5 seconds. + */ +function BaseEducationalTooltip({children, ...props}: TooltipProps) { + const hideTooltipRef = useRef<() => void>(); + + useEffect( + () => () => { + if (!hideTooltipRef.current) { + return; + } + + hideTooltipRef.current(); + }, + [], + ); + + // Automatically hide tooltip after 5 seconds + useEffect(() => { + if (!hideTooltipRef.current) { + return; + } + + const intervalID = setInterval(hideTooltipRef.current, 5000); + return () => { + clearInterval(intervalID); + }; + }, []); + + return ( + + {({showTooltip, hideTooltip, updateTargetBounds}) => { + hideTooltipRef.current = hideTooltip; + return React.cloneElement(children as React.ReactElement, { + onLayout: (e: LayoutEvent) => { + updateTargetBounds(getBounds(e)); + showTooltip(); + }, + }); + }} + + ); +} + +BaseEducationalTooltip.displayName = 'BaseEducationalTooltip'; + +export default memo(BaseEducationalTooltip); diff --git a/src/components/Tooltip/EducationalTooltip/getBounds/index.native.ts b/src/components/Tooltip/EducationalTooltip/getBounds/index.native.ts new file mode 100644 index 000000000000..e0f06785d338 --- /dev/null +++ b/src/components/Tooltip/EducationalTooltip/getBounds/index.native.ts @@ -0,0 +1,6 @@ +import type {LayoutEvent} from 'react-native'; +import type GetBounds from './types'; + +const getBounds: GetBounds = (layoutEvent: LayoutEvent) => layoutEvent.nativeEvent.layout; + +export default getBounds; diff --git a/src/components/Tooltip/EducationalTooltip/getBounds/index.ts b/src/components/Tooltip/EducationalTooltip/getBounds/index.ts new file mode 100644 index 000000000000..be728b519c51 --- /dev/null +++ b/src/components/Tooltip/EducationalTooltip/getBounds/index.ts @@ -0,0 +1,6 @@ +import type {LayoutEvent} from 'react-native'; +import type GetBounds from './types'; + +const getBounds: GetBounds = (layoutEvent: LayoutEvent) => (layoutEvent.nativeEvent.target as HTMLElement).getBoundingClientRect(); + +export default getBounds; diff --git a/src/components/Tooltip/EducationalTooltip/getBounds/types.ts b/src/components/Tooltip/EducationalTooltip/getBounds/types.ts new file mode 100644 index 000000000000..5edf6f60e0c6 --- /dev/null +++ b/src/components/Tooltip/EducationalTooltip/getBounds/types.ts @@ -0,0 +1,5 @@ +import type {LayoutEvent, LayoutRectangle} from 'react-native'; + +type GetBounds = (layoutEvent: LayoutEvent) => LayoutRectangle; + +export default GetBounds; diff --git a/src/components/Tooltip/EducationalTooltip/index.tsx b/src/components/Tooltip/EducationalTooltip/index.tsx new file mode 100644 index 000000000000..d43ff64d7e8e --- /dev/null +++ b/src/components/Tooltip/EducationalTooltip/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import type {TooltipExtendedProps} from '@components/Tooltip/types'; +import BaseEducationalTooltip from './BaseEducationalTooltip'; + +function EducationalTooltip({shouldRender = true, children, ...props}: TooltipExtendedProps) { + if (!shouldRender) { + return children; + } + + return ( + + {children} + + ); +} + +EducationalTooltip.displayName = 'EducationalTooltip'; + +export default EducationalTooltip; diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx new file mode 100644 index 000000000000..2b48fa91141f --- /dev/null +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -0,0 +1,178 @@ +import React, {memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import type {LayoutRectangle} from 'react-native'; +import {Animated} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import Log from '@libs/Log'; +import StringUtils from '@libs/StringUtils'; +import TooltipRefManager from '@libs/TooltipRefManager'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import callOrReturn from '@src/types/utils/callOrReturn'; +import BaseGenericTooltip from './BaseGenericTooltip'; +import TooltipSense from './TooltipSense'; +import type {GenericTooltipProps} from './types'; + +/** + * The generic tooltip implementation, exposing the tooltip's state + * while leaving the tooltip's target bounds computation to its parent. + */ +function GenericTooltip({ + children, + numberOfLines = CONST.TOOLTIP_MAX_LINES, + maxWidth = variables.sideBarWidth, + text = '', + renderTooltipContent, + renderTooltipContentKey = [], + shiftHorizontal = 0, + shiftVertical = 0, + shouldForceRenderingBelow = false, + wrapperStyle = {}, + shouldForceRenderingLeft = false, + shouldForceAnimate = false, +}: GenericTooltipProps) { + const {preferredLocale} = useLocalize(); + const {windowWidth} = useWindowDimensions(); + + // Is tooltip already rendered on the page's body? happens once. + const [isRendered, setIsRendered] = useState(false); + + // Is the tooltip currently visible? + const [isVisible, setIsVisible] = useState(false); + + // The distance between the left side of the wrapper view and the left side of the window + const [xOffset, setXOffset] = useState(0); + + // The distance between the top of the wrapper view and the top of the window + const [yOffset, setYOffset] = useState(0); + + // The width and height of the wrapper view + const [wrapperWidth, setWrapperWidth] = useState(0); + const [wrapperHeight, setWrapperHeight] = useState(0); + + // Whether the tooltip is first tooltip to activate the TooltipSense + const isTooltipSenseInitiator = useRef(false); + const animation = useRef(new Animated.Value(0)); + const isAnimationCanceled = useRef(false); + const prevText = usePrevious(text); + + useEffect(() => { + if (!renderTooltipContent || !text) { + return; + } + Log.warn('Developer error: Cannot use both text and renderTooltipContent props at the same time in !'); + }, [text, renderTooltipContent]); + + /** + * Display the tooltip in an animation. + */ + const showTooltip = useCallback(() => { + setIsRendered(true); + setIsVisible(true); + + animation.current.stopAnimation(); + + // When TooltipSense is active, immediately show the tooltip + if (TooltipSense.isActive() && !shouldForceAnimate) { + animation.current.setValue(1); + } else { + isTooltipSenseInitiator.current = true; + Animated.timing(animation.current, { + toValue: 1, + duration: 140, + delay: 500, + useNativeDriver: false, + }).start(({finished}) => { + isAnimationCanceled.current = !finished; + }); + } + TooltipSense.activate(); + }, [shouldForceAnimate]); + + // eslint-disable-next-line rulesdir/prefer-early-return + useEffect(() => { + // if the tooltip text changed before the initial animation was finished, then the tooltip won't be shown + // we need to show the tooltip again + if (isVisible && isAnimationCanceled.current && text && prevText !== text) { + isAnimationCanceled.current = false; + showTooltip(); + } + }, [isVisible, text, prevText, showTooltip]); + + /** + * Update the tooltip's target bounding rectangle + */ + const updateTargetBounds = (bounds: LayoutRectangle) => { + if (bounds.width === 0) { + setIsRendered(false); + } + setWrapperWidth(bounds.width); + setWrapperHeight(bounds.height); + setXOffset(bounds.x); + setYOffset(bounds.y); + }; + + /** + * Hide the tooltip in an animation. + */ + const hideTooltip = useCallback(() => { + animation.current.stopAnimation(); + + if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) { + animation.current.setValue(0); + } else { + // Hide the first tooltip which initiated the TooltipSense with animation + isTooltipSenseInitiator.current = false; + Animated.timing(animation.current, { + toValue: 0, + duration: 140, + useNativeDriver: false, + }).start(); + } + + TooltipSense.deactivate(); + + setIsVisible(false); + }, []); + + useImperativeHandle(TooltipRefManager.ref, () => ({hideTooltip}), [hideTooltip]); + + // Skip the tooltip and return the children if the text is empty, we don't have a render function. + if (StringUtils.isEmptyString(text) && renderTooltipContent == null) { + return children({isVisible, showTooltip, hideTooltip, updateTargetBounds}); + } + + return ( + <> + {isRendered && ( + + )} + + {children({isVisible, showTooltip, hideTooltip, updateTargetBounds})} + + ); +} + +GenericTooltip.displayName = 'GenericTooltip'; + +export default memo(GenericTooltip); diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index a949e98018b3..cf2218abf5b3 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -1,7 +1,9 @@ import type {ReactNode} from 'react'; +import type React from 'react'; +import type {LayoutRectangle, StyleProp, ViewStyle} from 'react-native'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -type TooltipProps = ChildrenProps & { +type SharedTooltipProps = { /** The text to display in the tooltip. If text is ommitted, only children will be rendered. */ text?: string; @@ -25,16 +27,49 @@ type TooltipProps = ChildrenProps & { /** Unique key of renderTooltipContent to rerender the tooltip when one of the key changes */ renderTooltipContentKey?: string[]; - /** passes this down to Hoverable component to decide whether to handle the scroll behaviour to show hover once the scroll ends */ - shouldHandleScroll?: boolean; + /** Whether to left align the tooltip relative to wrapped component */ + shouldForceRenderingLeft?: boolean; + /** Whether to display tooltip below the wrapped component */ shouldForceRenderingBelow?: boolean; + + /** Additional styles for tooltip wrapper view */ + wrapperStyle?: StyleProp; +}; + +type GenericTooltipState = { + /** Is tooltip visible */ + isVisible: boolean; + + /** Show tooltip */ + showTooltip: () => void; + + /** Hide tooltip */ + hideTooltip: () => void; + + /** Update the tooltip's target bounding rectangle */ + updateTargetBounds: (rect: LayoutRectangle) => void; }; -type TooltipExtendedProps = TooltipProps & { +type GenericTooltipProps = SharedTooltipProps & { + children: React.FC; + + /** Whether to ignore TooltipSense activity and always triger animation */ + shouldForceAnimate?: boolean; +}; + +type TooltipProps = ChildrenProps & + SharedTooltipProps & { + /** passes this down to Hoverable component to decide whether to handle the scroll behaviour to show hover once the scroll ends */ + shouldHandleScroll?: boolean; + }; + +type EducationalTooltipProps = ChildrenProps & TooltipProps; + +type TooltipExtendedProps = (EducationalTooltipProps | TooltipProps) & { /** Whether the actual Tooltip should be rendered. If false, it's just going to return the children */ shouldRender?: boolean; }; export default TooltipProps; -export type {TooltipExtendedProps}; +export type {EducationalTooltipProps, GenericTooltipProps, TooltipExtendedProps}; diff --git a/src/languages/en.ts b/src/languages/en.ts index bf3803c7606d..ddeccde0fb78 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -627,6 +627,10 @@ export default { trackDistance: 'Track distance', noLongerHaveReportAccess: 'You no longer have access to your previous quick action destination. Pick a new one below.', updateDestination: 'Update destination', + tooltip: { + title: 'Quick action! ', + subtitle: 'Just a tap away.', + }, }, iou: { amount: 'Amount', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4c900e23acc5..001c631b5bb2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -621,6 +621,10 @@ export default { trackDistance: 'Crear gasto por desplazamiento', noLongerHaveReportAccess: 'Ya no tienes acceso al destino previo de esta acción rápida. Escoge uno nuevo a continuación.', updateDestination: 'Actualiza el destino', + tooltip: { + title: '¡Acción rápida! ', + subtitle: 'A un click.', + }, }, iou: { amount: 'Importe', diff --git a/src/libs/TooltipRefManager.tsx b/src/libs/TooltipRefManager.tsx new file mode 100644 index 000000000000..b94e224f5024 --- /dev/null +++ b/src/libs/TooltipRefManager.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +type TTooltipRef = { + hideTooltip: () => void; +}; + +const tooltipRef = React.createRef(); + +const TooltipRefManager = { + ref: tooltipRef, + hideTooltip: () => { + tooltipRef.current?.hideTooltip(); + }, +}; + +export default TooltipRefManager; diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index f4ed54b96e1f..ff5cfa05b57b 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -9,6 +9,7 @@ import type {SvgProps} from 'react-native-svg'; import FloatingActionButton from '@components/FloatingActionButton'; import * as Expensicons from '@components/Icon/Expensicons'; import PopoverMenu from '@components/PopoverMenu'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; @@ -193,6 +194,16 @@ function FloatingActionButtonAndPopover( // eslint-disable-next-line react-hooks/exhaustive-deps }, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy]); + const renderQuickActionTooltip = useCallback( + () => ( + + {translate('quickAction.tooltip.title')} + {translate('quickAction.tooltip.subtitle')} + + ), + [styles.quickActionTooltipTitle, styles.quickActionTooltipSubtitle, translate], + ); + const quickActionTitle = useMemo(() => { if (isEmptyObject(quickActionReport)) { return ''; @@ -460,6 +471,10 @@ function FloatingActionButtonAndPopover( numberOfLinesDescription: 1, onSelected: () => interceptAnonymousUser(() => navigateToQuickAction()), shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport), + shouldRenderTooltip: quickAction?.isFirstQuickAction, + shouldForceRenderingTooltipLeft: true, + renderTooltipContent: renderQuickActionTooltip, + tooltipWrapperStyle: styles.quickActionTooltipWrapper, }, ] : []), diff --git a/src/styles/index.ts b/src/styles/index.ts index e4b6bb7935bb..a05ea714b871 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3832,6 +3832,22 @@ const styles = (theme: ThemeColors) => ...wordBreak.breakWord, }, + quickActionTooltipWrapper: { + backgroundColor: theme.tooltipHighlightBG, + }, + + quickActionTooltipTitle: { + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, + fontSize: variables.fontSizeLabel, + color: theme.tooltipHighlightText, + }, + + quickActionTooltipSubtitle: { + fontSize: variables.fontSizeLabel, + color: theme.textDark, + }, + quickReactionsContainer: { gap: 12, flexDirection: 'row', diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index c590643c849f..8df3750b57ac 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -80,6 +80,8 @@ const darkTheme = { mentionBG: colors.blue600, ourMentionText: colors.green100, ourMentionBG: colors.green600, + tooltipHighlightBG: colors.green100, + tooltipHighlightText: colors.green500, tooltipSupportingText: colors.productLight800, tooltipPrimaryText: colors.productLight900, trialBannerBackgroundColor: colors.green700, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 132d1cc2d089..16f403355ed2 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -80,6 +80,8 @@ const lightTheme = { mentionBG: colors.blue100, ourMentionText: colors.green600, ourMentionBG: colors.green100, + tooltipHighlightBG: colors.green100, + tooltipHighlightText: colors.green500, tooltipSupportingText: colors.productDark800, tooltipPrimaryText: colors.productDark900, trialBannerBackgroundColor: colors.green100, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index d025e2cecde5..2d8618c24ebb 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -84,6 +84,8 @@ type ThemeColors = { mentionBG: Color; ourMentionText: Color; ourMentionBG: Color; + tooltipHighlightBG: Color; + tooltipHighlightText: Color; tooltipSupportingText: Color; tooltipPrimaryText: Color; trialBannerBackgroundColor: Color; diff --git a/src/styles/utils/generators/TooltipStyleUtils/index.native.ts b/src/styles/utils/generators/TooltipStyleUtils/index.native.ts new file mode 100644 index 000000000000..fa4264f45b1c --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/index.native.ts @@ -0,0 +1,179 @@ +import {Animated, StyleSheet} from 'react-native'; +import FontUtils from '@styles/utils/FontUtils'; +// eslint-disable-next-line no-restricted-imports +import type StyleUtilGenerator from '@styles/utils/generators/types'; +// eslint-disable-next-line no-restricted-imports +import positioning from '@styles/utils/positioning'; +// eslint-disable-next-line no-restricted-imports +import spacing from '@styles/utils/spacing'; +import variables from '@styles/variables'; +import type {GetTooltipStylesStyleUtil} from './types'; + +/** The height of a tooltip pointer */ +const POINTER_HEIGHT = 4; + +/** The width of a tooltip pointer */ +const POINTER_WIDTH = 12; + +/** + * Generate styles for the tooltip component. + * + * @param tooltip - The reference to the tooltip's root element + * @param currentSize - The current size of the tooltip used in the scaling animation. + * @param windowWidth - The width of the window. + * @param xOffset - The distance between the left edge of the wrapped component + * and the left edge of the parent component. + * @param yOffset - The distance between the top edge of the wrapped component + * and the top edge of the parent component. + * @param tooltipTargetWidth - The width of the tooltip's target + * @param tooltipTargetHeight - The height of the tooltip's target + * @param maxWidth - The tooltip's max width. + * @param tooltipContentWidth - The tooltip's inner content measured width. + * @param tooltipWrapperHeight - The tooltip's wrapper measured height. + * @param [manualShiftHorizontal] - Any additional amount to manually shift the tooltip to the left or right. + * A positive value shifts it to the right, + * and a negative value shifts it to the left. + * @param [manualShiftVertical] - Any additional amount to manually shift the tooltip up or down. + * A positive value shifts it down, and a negative value shifts it up. + * @param [shouldForceRenderingBelow] - Should display tooltip below the wrapped component. + * @param [shouldForceRenderingLeft] - Align the tooltip left relative to the wrapped component instead of horizontally align center. + * @param [wrapperStyle] - Any additional styles for the root wrapper. + */ +const createTooltipStyleUtils: StyleUtilGenerator = ({theme, styles}) => ({ + getTooltipStyles: ({ + currentSize, + xOffset, + yOffset, + tooltipTargetWidth, + maxWidth, + tooltipContentWidth, + tooltipWrapperHeight, + manualShiftHorizontal = 0, + manualShiftVertical = 0, + shouldForceRenderingLeft = false, + wrapperStyle = {}, + }) => { + const customWrapperStyle = StyleSheet.flatten(wrapperStyle); + const tooltipVerticalPadding = spacing.pv1; + + // We calculate tooltip width based on the tooltip's content width + // so the tooltip wrapper is just big enough to fit content and prevent white space. + // NOTE: Add 1 to the tooltipWidth to prevent truncated text in Safari + const tooltipWidth = tooltipContentWidth && tooltipContentWidth + spacing.ph2.paddingHorizontal * 2 + 1; + const tooltipHeight = tooltipWrapperHeight; + + const isTooltipSizeReady = tooltipWidth !== undefined && tooltipHeight !== undefined; + + // Set the scale to 1 to be able to measure the tooltip size correctly when it's not ready yet. + let scale = new Animated.Value(1); + let rootWrapperTop = 0; + let rootWrapperLeft = 0; + let pointerWrapperTop = 0; + let pointerWrapperLeft = 0; + let opacity = 0; + + if (isTooltipSizeReady) { + // When the tooltip size is ready, we can start animating the scale. + scale = currentSize; + + // Because it uses absolute positioning, the top-left corner of the tooltip is aligned + // with the top-left corner of the wrapped component by default. + // we will use yOffset to position the tooltip relative to the Wrapped Component + // So we need to shift the tooltip vertically and horizontally to position it correctly. + // + // First, we'll position it vertically. + // To shift the tooltip down, we'll give `top` a positive value. + // To shift the tooltip up, we'll give `top` a negative value. + rootWrapperTop = yOffset - (tooltipHeight + POINTER_HEIGHT) + manualShiftVertical; + + // Next, we'll position it horizontally. + // we will use xOffset to position the tooltip relative to the Wrapped Component + // To shift the tooltip right, we'll give `left` a positive value. + // To shift the tooltip left, we'll give `left` a negative value. + // + // So we'll: + // 1a) Horizontally align left: No need for shifting. + // 1b) Horizontally align center: + // - Shift the tooltip right (+) to the center of the component, + // so the left edge lines up with the component center. + // - Shift it left (-) to by half the tooltip's width, + // so the tooltip's center lines up with the center of the wrapped component. + // 2) Add the manual horizontal shift passed in as a parameter. + rootWrapperLeft = xOffset + (shouldForceRenderingLeft ? 0 : tooltipTargetWidth / 2 - tooltipWidth / 2) + manualShiftHorizontal; + + // By default, the pointer's top-left will align with the top-left of the tooltip wrapper. + // + // To align it vertically, the pointer up (-) by the pointer's height + // so that the bottom of the pointer lines up with the top of the tooltip + pointerWrapperTop = tooltipHeight; + + // To align it horizontally, we'll: + // 1) Left align: Shift the pointer to the right (+) by half the pointer's width, + // so the left edge of the pointer does not overlap with the wrapper's border radius. + // 2) Center align: + // - Shift the pointer to the right (+) by the half the tooltipWidth's width, + // so the left edge of the pointer lines up with the tooltipWidth's center. + // - To the left (-) by half the pointer's width, + // so the pointer's center lines up with the tooltipWidth's center. + pointerWrapperLeft = shouldForceRenderingLeft ? POINTER_WIDTH / 2 : tooltipWidth / 2 - POINTER_WIDTH / 2; + + // React Native's measure() is asynchronous, we temporarily hide the tooltip until its bound is calculated + opacity = 100; + } + + return { + animationStyle: { + // remember Transform causes a new Local cordinate system + // https://drafts.csswg.org/css-transforms-1/#transform-rendering + // so Position fixed children will be relative to this new Local cordinate system + transform: [{scale}], + }, + rootWrapperStyle: { + ...positioning.pAbsolute, + backgroundColor: theme.heading, + borderRadius: variables.componentBorderRadiusSmall, + ...tooltipVerticalPadding, + ...spacing.ph2, + zIndex: variables.tooltipzIndex, + width: tooltipWidth, + maxWidth, + top: rootWrapperTop, + left: rootWrapperLeft, + opacity, + ...customWrapperStyle, + + // We are adding this to prevent the tooltip text from being selected and copied on CTRL + A. + ...styles.userSelectNone, + ...styles.pointerEventsNone, + }, + textStyle: { + color: theme.textReversed, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, + fontSize: variables.fontSizeSmall, + overflow: 'hidden', + lineHeight: variables.lineHeightSmall, + textAlign: 'center', + }, + pointerWrapperStyle: { + ...positioning.pAbsolute, + top: pointerWrapperTop, + left: pointerWrapperLeft, + opacity, + }, + pointerStyle: { + width: 0, + height: 0, + backgroundColor: theme.transparent, + borderStyle: 'solid', + borderLeftWidth: POINTER_WIDTH / 2, + borderRightWidth: POINTER_WIDTH / 2, + borderTopWidth: POINTER_HEIGHT, + borderLeftColor: theme.transparent, + borderRightColor: theme.transparent, + borderTopColor: customWrapperStyle.backgroundColor ?? theme.heading, + }, + }; + }, +}); + +export default createTooltipStyleUtils; diff --git a/src/styles/utils/generators/TooltipStyleUtils.ts b/src/styles/utils/generators/TooltipStyleUtils/index.ts similarity index 84% rename from src/styles/utils/generators/TooltipStyleUtils.ts rename to src/styles/utils/generators/TooltipStyleUtils/index.ts index bb77a851f567..846848ab25bd 100644 --- a/src/styles/utils/generators/TooltipStyleUtils.ts +++ b/src/styles/utils/generators/TooltipStyleUtils/index.ts @@ -1,15 +1,17 @@ -import type {TextStyle, View, ViewStyle} from 'react-native'; -import {Animated} from 'react-native'; +import type {View} from 'react-native'; +import {Animated, StyleSheet} from 'react-native'; import roundToNearestMultipleOfFour from '@libs/roundToNearestMultipleOfFour'; import FontUtils from '@styles/utils/FontUtils'; // eslint-disable-next-line no-restricted-imports +import type StyleUtilGenerator from '@styles/utils/generators/types'; +// eslint-disable-next-line no-restricted-imports import positioning from '@styles/utils/positioning'; // eslint-disable-next-line no-restricted-imports import spacing from '@styles/utils/spacing'; // eslint-disable-next-line no-restricted-imports import titleBarHeight from '@styles/utils/titleBarHeight'; import variables from '@styles/variables'; -import type StyleUtilGenerator from './types'; +import type {GetTooltipStylesStyleUtil} from './types'; /** This defines the proximity with the edge of the window in which tooltips should not be displayed. * If a tooltip is too close to the edge of the screen, we'll shift it towards the center. */ @@ -97,32 +99,6 @@ function isOverlappingAtTop(tooltip: View | HTMLDivElement, xOffset: number, yOf return isOverlappingAtTargetCenterX; } -type TooltipStyles = { - animationStyle: ViewStyle; - rootWrapperStyle: ViewStyle; - textStyle: TextStyle; - pointerWrapperStyle: ViewStyle; - pointerStyle: ViewStyle; -}; - -type TooltipParams = { - tooltip: View | HTMLDivElement | null; - currentSize: Animated.Value; - windowWidth: number; - xOffset: number; - yOffset: number; - tooltipTargetWidth: number; - tooltipTargetHeight: number; - maxWidth: number; - tooltipContentWidth?: number; - tooltipWrapperHeight?: number; - manualShiftHorizontal?: number; - manualShiftVertical?: number; - shouldForceRenderingBelow?: boolean; -}; - -type GetTooltipStylesStyleUtil = {getTooltipStyles: (props: TooltipParams) => TooltipStyles}; - /** * Generate styles for the tooltip component. * @@ -143,6 +119,9 @@ type GetTooltipStylesStyleUtil = {getTooltipStyles: (props: TooltipParams) => To * and a negative value shifts it to the left. * @param [manualShiftVertical] - Any additional amount to manually shift the tooltip up or down. * A positive value shifts it down, and a negative value shifts it up. + * @param [shouldForceRenderingBelow] - Should display tooltip below the wrapped component. + * @param [shouldForceRenderingLeft] - Align the tooltip left relative to the wrapped component instead of horizontally align center. + * @param [wrapperStyle] - Any additional styles for the root wrapper. */ const createTooltipStyleUtils: StyleUtilGenerator = ({theme, styles}) => ({ getTooltipStyles: ({ @@ -159,7 +138,10 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( manualShiftHorizontal = 0, manualShiftVertical = 0, shouldForceRenderingBelow = false, + shouldForceRenderingLeft = false, + wrapperStyle = {}, }) => { + const customWrapperStyle = StyleSheet.flatten(wrapperStyle); const tooltipVerticalPadding = spacing.pv1; // We calculate tooltip width based on the tooltip's content width @@ -226,13 +208,15 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( // To shift the tooltip left, we'll give `left` a negative value. // // So we'll: - // 1) Shift the tooltip right (+) to the center of the component, - // so the left edge lines up with the component center. - // 2) Shift it left (-) to by half the tooltip's width, - // so the tooltip's center lines up with the center of the wrapped component. - // 3) Add the horizontal shift (left or right) computed above to keep it out of the gutters. - // 4) Lastly, add the manual horizontal shift passed in as a parameter. - rootWrapperLeft = xOffset + (tooltipTargetWidth / 2 - tooltipWidth / 2) + horizontalShift + manualShiftHorizontal; + // 1a) Horizontally align left: No need for shifting. + // 1b) Horizontally align center: + // - Shift the tooltip right (+) to the center of the component, + // so the left edge lines up with the component center. + // - Shift it left (-) to by half the tooltip's width, + // so the tooltip's center lines up with the center of the wrapped component. + // 2) Add the horizontal shift (left or right) computed above to keep it out of the gutters. + // 3) Lastly, add the manual horizontal shift passed in as a parameter. + rootWrapperLeft = xOffset + (shouldForceRenderingLeft ? 0 : tooltipTargetWidth / 2 - tooltipWidth / 2) + horizontalShift + manualShiftHorizontal; // By default, the pointer's top-left will align with the top-left of the tooltip wrapper. // @@ -244,14 +228,16 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( // so that the bottom of the pointer lines up with the top of the tooltip pointerWrapperTop = shouldShowBelow ? -POINTER_HEIGHT : tooltipHeight; - // To align it horizontally, we'll: - // 1) Shift the pointer to the right (+) by the half the tooltipWidth's width, - // so the left edge of the pointer lines up with the tooltipWidth's center. - // 2) To the left (-) by half the pointer's width, - // so the pointer's center lines up with the tooltipWidth's center. - // 3) Remove the wrapper's horizontalShift to maintain the pointer - // at the center of the hovered component. - pointerWrapperLeft = horizontalShiftPointer + (tooltipWidth / 2 - POINTER_WIDTH / 2); + // 1) Left align: Shift the pointer to the right (+) by half the pointer's width, + // so the left edge of the pointer does not overlap with the wrapper's border radius. + // 2) Center align: + // - Shift the pointer to the right (+) by the half the tooltipWidth's width, + // so the left edge of the pointer lines up with the tooltipWidth's center. + // - To the left (-) by half the pointer's width, + // so the pointer's center lines up with the tooltipWidth's center. + // - Remove the wrapper's horizontalShift to maintain the pointer + // at the center of the hovered component. + pointerWrapperLeft = shouldForceRenderingLeft ? POINTER_WIDTH / 2 : horizontalShiftPointer + (tooltipWidth / 2 - POINTER_WIDTH / 2); pointerAdditionalStyle = shouldShowBelow ? styles.flipUpsideDown : {}; } @@ -274,6 +260,7 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( maxWidth, top: rootWrapperTop, left: rootWrapperLeft, + ...customWrapperStyle, // We are adding this to prevent the tooltip text from being selected and copied on CTRL + A. ...styles.userSelectNone, @@ -302,7 +289,7 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( borderTopWidth: POINTER_HEIGHT, borderLeftColor: theme.transparent, borderRightColor: theme.transparent, - borderTopColor: theme.heading, + borderTopColor: customWrapperStyle.backgroundColor ?? theme.heading, ...pointerAdditionalStyle, }, }; diff --git a/src/styles/utils/generators/TooltipStyleUtils/types.ts b/src/styles/utils/generators/TooltipStyleUtils/types.ts new file mode 100644 index 000000000000..1907309e1bf5 --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/types.ts @@ -0,0 +1,31 @@ +import type {Animated, StyleProp, TextStyle, View, ViewStyle} from 'react-native'; + +type TooltipStyles = { + animationStyle: ViewStyle; + rootWrapperStyle: ViewStyle; + textStyle: TextStyle; + pointerWrapperStyle: ViewStyle; + pointerStyle: ViewStyle; +}; + +type TooltipParams = { + tooltip: View | HTMLDivElement | null; + currentSize: Animated.Value; + windowWidth: number; + xOffset: number; + yOffset: number; + tooltipTargetWidth: number; + tooltipTargetHeight: number; + maxWidth: number; + tooltipContentWidth?: number; + tooltipWrapperHeight?: number; + manualShiftHorizontal?: number; + manualShiftVertical?: number; + shouldForceRenderingBelow?: boolean; + shouldForceRenderingLeft?: boolean; + wrapperStyle: StyleProp; +}; + +type GetTooltipStylesStyleUtil = {getTooltipStyles: (props: TooltipParams) => TooltipStyles}; + +export type {TooltipStyles, TooltipParams, GetTooltipStylesStyleUtil}; diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index 58fb010b6f64..a2e271a3839d 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -259,11 +259,29 @@ declare module 'react-native' { onKeyUpCapture?: KeyboardEventHandler; } + // Extracted from react-native-web, packages/react-native-web/src/types/index.js + type LayoutValue = { + x: number; + y: number; + width: number; + height: number; + }; + type LayoutEvent = { + nativeEvent: { + layout: LayoutValue; + target: unknown; // changed from "any" to "unknown" + }; + timeStamp: number; + }; + interface LayoutProps { + onLayout?: (e: LayoutEvent) => void; + } + /** * Shared props * Extracted from react-native-web, packages/react-native-web/src/exports/View/types.js */ - interface WebSharedProps extends AccessibilityProps, PointerProps, ResponderProps, FocusProps, KeyboardProps { + interface WebSharedProps extends AccessibilityProps, PointerProps, ResponderProps, FocusProps, KeyboardProps, LayoutProps { dataSet?: Record; href?: string; hrefAttrs?: {