diff --git a/src/components/SelectionList/BaseListItem.js b/src/components/SelectionList/BaseListItem.js
new file mode 100644
index 000000000000..d198979fdf8e
--- /dev/null
+++ b/src/components/SelectionList/BaseListItem.js
@@ -0,0 +1,100 @@
+import React from 'react';
+import {View} from 'react-native';
+import lodashGet from 'lodash/get';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import styles from '../../styles/styles';
+import Icon from '../Icon';
+import * as Expensicons from '../Icon/Expensicons';
+import themeColors from '../../styles/themes/default';
+import {baseListItemPropTypes} from './selectionListPropTypes';
+import * as StyleUtils from '../../styles/StyleUtils';
+import UserListItem from './UserListItem';
+import RadioListItem from './RadioListItem';
+import OfflineWithFeedback from '../OfflineWithFeedback';
+import CONST from '../../CONST';
+
+function BaseListItem({item, isFocused = false, isDisabled = false, showTooltip, canSelectMultiple = false, onSelectRow, onDismissError = () => {}}) {
+ const isUserItem = lodashGet(item, 'icons.length', 0) > 0;
+ const ListItem = isUserItem ? UserListItem : RadioListItem;
+
+ return (
+ onDismissError(item)}
+ pendingAction={item.pendingAction}
+ errors={item.errors}
+ errorRowStyles={styles.ph5}
+ >
+ onSelectRow(item)}
+ disabled={isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
+ >
+
+ {canSelectMultiple && (
+
+
+ {item.isSelected && (
+
+ )}
+
+
+ )}
+
+
+
+ {!canSelectMultiple && item.isSelected && (
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+BaseListItem.displayName = 'BaseListItem';
+BaseListItem.propTypes = baseListItemPropTypes;
+
+export default BaseListItem;
diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js
index 8d894e4c983a..ebb95475bcd9 100644
--- a/src/components/SelectionList/BaseSelectionList.js
+++ b/src/components/SelectionList/BaseSelectionList.js
@@ -7,12 +7,9 @@ import SectionList from '../SectionList';
import Text from '../Text';
import styles from '../../styles/styles';
import TextInput from '../TextInput';
-import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
import CONST from '../../CONST';
import variables from '../../styles/variables';
import {propTypes as selectionListPropTypes} from './selectionListPropTypes';
-import RadioListItem from './RadioListItem';
-import UserListItem from './UserListItem';
import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
import SafeAreaConsumer from '../SafeAreaConsumer';
import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState';
@@ -24,6 +21,9 @@ import useLocalize from '../../hooks/useLocalize';
import Log from '../../libs/Log';
import OptionsListSkeletonView from '../OptionsListSkeletonView';
import useActiveElement from '../../hooks/useActiveElement';
+import BaseListItem from './BaseListItem';
+import themeColors from '../../styles/themes/default';
+import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
const propTypes = {
...keyboardStatePropTypes,
@@ -48,10 +48,13 @@ function BaseSelectionList({
headerMessage = '',
confirmButtonText = '',
onConfirm,
+ footerContent,
showScrollIndicator = false,
showLoadingPlaceholder = false,
showConfirmButton = false,
isKeyboardShown = false,
+ disableKeyboardShortcuts = false,
+ children,
}) {
const {translate} = useLocalize();
const firstLayoutRef = useRef(true);
@@ -136,19 +139,19 @@ function BaseSelectionList({
};
}, [canSelectMultiple, sections]);
- // Disable `Enter` hotkey if the active element is a button or checkbox
- const shouldDisableHotkeys = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role);
-
// If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member
const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey));
+ // Disable `Enter` shortcut if the active element is a button or checkbox
+ const disableEnterShortcut = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role);
+
/**
* Scrolls to the desired item index in the section list
*
* @param {Number} index - the index of the item to scroll to
* @param {Boolean} animated - whether to animate the scroll
*/
- const scrollToIndex = (index, animated) => {
+ const scrollToIndex = useCallback((index, animated = true) => {
const item = flattenedSections.allOptions[index];
if (!listRef.current || !item) {
@@ -169,7 +172,10 @@ function BaseSelectionList({
}
listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight});
- };
+
+ // If we don't disable dependencies here, we would need to make sure that the `sections` prop is stable in every usage of this component.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
/**
* Logic to run when a row is selected, either with click/press or keyboard hotkeys.
@@ -234,6 +240,14 @@ function BaseSelectionList({
const getItemLayout = (data, flatDataArrayIndex) => {
const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
+ if (!targetItem) {
+ return {
+ length: 0,
+ offset: 0,
+ index: flatDataArrayIndex,
+ };
+ }
+
return {
length: targetItem.length,
offset: targetItem.offset,
@@ -259,33 +273,40 @@ function BaseSelectionList({
const renderItem = ({item, index, section}) => {
const normalizedIndex = index + lodashGet(section, 'indexOffset', 0);
- const isDisabled = section.isDisabled;
+ const isDisabled = section.isDisabled || item.isDisabled;
const isItemFocused = !isDisabled && focusedIndex === normalizedIndex;
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
const showTooltip = normalizedIndex < 10;
- if (canSelectMultiple) {
- return (
- selectRow(item, true)}
- onDismissError={onDismissError}
- showTooltip={showTooltip}
- />
- );
- }
-
return (
- selectRow(item, true)}
+ onDismissError={onDismissError}
/>
);
};
+ const scrollToFocusedIndexOnFirstRender = useCallback(() => {
+ if (!firstLayoutRef.current) {
+ return;
+ }
+ scrollToIndex(focusedIndex, false);
+ firstLayoutRef.current = false;
+ }, [focusedIndex, scrollToIndex]);
+
+ const updateAndScrollToFocusedIndex = useCallback(
+ (newFocusedIndex) => {
+ setFocusedIndex(newFocusedIndex);
+ scrollToIndex(newFocusedIndex, true);
+ },
+ [scrollToIndex],
+ );
+
/** Focuses the text input when the component comes into focus and after any navigation animations finish. */
useFocusEffect(
useCallback(() => {
@@ -305,14 +326,14 @@ function BaseSelectionList({
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
captureOnInputs: true,
shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- isActive: !shouldDisableHotkeys && isFocused,
+ isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused,
});
/** Calls confirm action when pressing CTRL (CMD) + Enter */
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm, {
captureOnInputs: true,
shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- isActive: Boolean(onConfirm) && isFocused,
+ isActive: !disableKeyboardShortcuts && Boolean(onConfirm) && isFocused,
});
return (
@@ -320,10 +341,7 @@ function BaseSelectionList({
disabledIndexes={flattenedSections.disabledOptionsIndexes}
focusedIndex={focusedIndex}
maxIndex={flattenedSections.allOptions.length - 1}
- onFocusedIndexChanged={(newFocusedIndex) => {
- setFocusedIndex(newFocusedIndex);
- scrollToIndex(newFocusedIndex, true);
- }}
+ onFocusedIndexChanged={updateAndScrollToFocusedIndex}
>
{({safeAreaPaddingBottomStyle}) => (
@@ -360,7 +378,7 @@ function BaseSelectionList({
style={[styles.peopleRow, styles.userSelectNone, styles.ph5, styles.pb3]}
onPress={onSelectAll}
accessibilityLabel={translate('workspace.people.selectAll')}
- accessibilityRole="button"
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityState={{checked: flattenedSections.allSelected}}
disabled={flattenedSections.allOptions.length === flattenedSections.disabledOptionsIndexes.length}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
@@ -387,7 +405,7 @@ function BaseSelectionList({
onScrollBeginDrag={onScrollBeginDrag}
keyExtractor={(item) => item.keyForList}
extraData={focusedIndex}
- indicatorStyle="white"
+ indicatorStyle={themeColors.selectionListIndicatorColor}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={showScrollIndicator}
initialNumToRender={12}
@@ -395,18 +413,14 @@ function BaseSelectionList({
windowSize={5}
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
testID="selection-list"
- onLayout={() => {
- if (!firstLayoutRef.current) {
- return;
- }
- scrollToIndex(focusedIndex, false);
- firstLayoutRef.current = false;
- }}
+ style={[styles.flexGrow0]}
+ onLayout={scrollToFocusedIndexOnFirstRender}
/>
+ {children}
>
)}
{showConfirmButton && (
-
+
)}
+ {Boolean(footerContent) && {footerContent}}
)}
diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.js
index 530af66d91d3..83d0fc922f08 100644
--- a/src/components/SelectionList/RadioListItem.js
+++ b/src/components/SelectionList/RadioListItem.js
@@ -1,51 +1,18 @@
import React from 'react';
import {View} from 'react-native';
-import CONST from '../../CONST';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
import styles from '../../styles/styles';
import Text from '../Text';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
import {radioListItemPropTypes} from './selectionListPropTypes';
-function RadioListItem({item, isFocused = false, isDisabled = false, onSelectRow}) {
+function RadioListItem({item, isFocused = false}) {
return (
- onSelectRow(item)}
- disabled={isDisabled}
- accessibilityLabel={item.text}
- accessibilityRole="button"
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
- >
-
-
-
- {item.text}
-
+
+ {item.text}
- {Boolean(item.alternateText) && (
- {item.alternateText}
- )}
-
-
- {item.isSelected && (
-
-
-
-
-
- )}
-
-
+ {Boolean(item.alternateText) && (
+ {item.alternateText}
+ )}
+
);
}
diff --git a/src/components/SelectionList/UserListItem.js b/src/components/SelectionList/UserListItem.js
index 0d37162a7995..436ae8cb056b 100644
--- a/src/components/SelectionList/UserListItem.js
+++ b/src/components/SelectionList/UserListItem.js
@@ -1,108 +1,50 @@
import React from 'react';
import {View} from 'react-native';
-import _ from 'underscore';
import lodashGet from 'lodash/get';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
import styles from '../../styles/styles';
import Text from '../Text';
import {userListItemPropTypes} from './selectionListPropTypes';
-import Avatar from '../Avatar';
-import OfflineWithFeedback from '../OfflineWithFeedback';
-import CONST from '../../CONST';
-import * as StyleUtils from '../../styles/StyleUtils';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
import Tooltip from '../Tooltip';
-import UserDetailsTooltip from '../UserDetailsTooltip';
-
-function UserListItem({item, isFocused = false, showTooltip, onSelectRow, onDismissError = () => {}}) {
- const hasError = !_.isEmpty(item.errors);
-
- const avatar = (
-
- );
-
- const text = (
-
- {item.text}
-
- );
-
- const alternateText = (
-
- {item.alternateText}
-
- );
+import SubscriptAvatar from '../SubscriptAvatar';
+function UserListItem({item, isFocused = false, showTooltip}) {
return (
- onDismissError(item)}
- pendingAction={item.pendingAction}
- errors={item.errors}
- errorRowStyles={styles.ph5}
- >
- onSelectRow(item)}
- disabled={item.isDisabled}
- accessibilityLabel={item.text}
- accessibilityRole="checkbox"
- accessibilityState={{checked: item.isSelected}}
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
- >
-
-
+ {Boolean(item.icons) && (
+
+ )}
+
+
+
+ {item.text}
+
+
+ {Boolean(item.alternateText) && (
+
- {item.isSelected && (
-
- )}
-
-
- {Boolean(item.avatar) &&
- (showTooltip ? (
-
- {avatar}
-
- ) : (
- avatar
- ))}
-
- {showTooltip ? {text} : text}
- {Boolean(item.alternateText) && (showTooltip ? {alternateText} : alternateText)}
-
- {Boolean(item.rightElement) && item.rightElement}
-
-
+ {item.alternateText}
+
+
+ )}
+
+ {Boolean(item.rightElement) && item.rightElement}
+ >
);
}
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
index 0a3c1efdf6a3..96c2f63eb09a 100644
--- a/src/components/SelectionList/selectionListPropTypes.js
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -2,7 +2,29 @@ import PropTypes from 'prop-types';
import _ from 'underscore';
import CONST from '../../CONST';
+const commonListItemPropTypes = {
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Whether this item is disabled */
+ isDisabled: PropTypes.bool,
+
+ /** Whether this item should show Tooltip */
+ showTooltip: PropTypes.bool.isRequired,
+
+ /** Whether to use the Checkbox (multiple selection) instead of the Checkmark (single selection) */
+ canSelectMultiple: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+};
+
const userListItemPropTypes = {
+ ...commonListItemPropTypes,
+
/** The section list item */
item: PropTypes.shape({
/** Text to display */
@@ -29,12 +51,14 @@ const userListItemPropTypes = {
/** Element to show on the right side of the item */
rightElement: PropTypes.element,
- /** Avatar for the user */
- avatar: PropTypes.shape({
- source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
- name: PropTypes.string,
- type: PropTypes.string,
- }),
+ /** Icons for the user (can be multiple if it's a Workspace) */
+ icons: PropTypes.arrayOf(
+ PropTypes.shape({
+ source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
+ name: PropTypes.string,
+ type: PropTypes.string,
+ }),
+ ),
/** Errors that this user may contain */
errors: PropTypes.objectOf(PropTypes.string),
@@ -42,21 +66,11 @@ const userListItemPropTypes = {
/** The type of action that's pending */
pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
}).isRequired,
-
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Whether this item should show Tooltip */
- showTooltip: PropTypes.bool.isRequired,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func.isRequired,
-
- /** Callback to fire when an error is dismissed */
- onDismissError: PropTypes.func,
};
const radioListItemPropTypes = {
+ ...commonListItemPropTypes,
+
/** The section list item */
item: PropTypes.shape({
/** Text to display */
@@ -71,15 +85,11 @@ const radioListItemPropTypes = {
/** Whether this option is selected */
isSelected: PropTypes.bool,
}).isRequired,
+};
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Whether this item is disabled */
- isDisabled: PropTypes.bool,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func.isRequired,
+const baseListItemPropTypes = {
+ ...commonListItemPropTypes,
+ item: PropTypes.oneOfType([PropTypes.shape(userListItemPropTypes.item), PropTypes.shape(radioListItemPropTypes.item)]),
};
const propTypes = {
@@ -156,6 +166,9 @@ const propTypes = {
/** Whether to show the default confirm button */
showConfirmButton: PropTypes.bool,
+
+ /** Custom content to display in the footer */
+ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
};
-export {propTypes, radioListItemPropTypes, userListItemPropTypes};
+export {propTypes, baseListItemPropTypes, radioListItemPropTypes, userListItemPropTypes};
diff --git a/src/components/SubscriptAvatar.js b/src/components/SubscriptAvatar.js
index 81864d6e5af2..4102ae5ec043 100644
--- a/src/components/SubscriptAvatar.js
+++ b/src/components/SubscriptAvatar.js
@@ -26,6 +26,9 @@ const propTypes = {
/** Removes margin from around the avatar, used for the chat view */
noMargin: PropTypes.bool,
+
+ /** Whether to show the tooltip */
+ showTooltip: PropTypes.bool,
};
const defaultProps = {
@@ -34,42 +37,46 @@ const defaultProps = {
mainAvatar: {},
secondaryAvatar: {},
noMargin: false,
+ showTooltip: true,
};
-function SubscriptAvatar(props) {
- const isSmall = props.size === CONST.AVATAR_SIZE.SMALL;
- const subscriptStyle = props.size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript;
+function SubscriptAvatar({size, backgroundColor, mainAvatar, secondaryAvatar, noMargin, showTooltip}) {
+ const isSmall = size === CONST.AVATAR_SIZE.SMALL;
+ const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript;
const containerStyle = isSmall ? styles.emptyAvatarSmall : styles.emptyAvatar;
// Default the margin style to what is normal for small or normal sized avatars
let marginStyle = isSmall ? styles.emptyAvatarMarginSmall : styles.emptyAvatarMargin;
// Some views like the chat view require that there be no margins
- if (props.noMargin) {
+ if (noMargin) {
marginStyle = {};
}
+
return (
diff --git a/src/components/Tooltip/BaseTooltip.js b/src/components/Tooltip/BaseTooltip.js
new file mode 100644
index 000000000000..f60982f52dd4
--- /dev/null
+++ b/src/components/Tooltip/BaseTooltip.js
@@ -0,0 +1,168 @@
+import _ from 'underscore';
+import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
+import {Animated} from 'react-native';
+import {BoundsObserver} from '@react-ng/bounds-observer';
+import TooltipRenderedOnPageBody from './TooltipRenderedOnPageBody';
+import Hoverable from '../Hoverable';
+import * as tooltipPropTypes from './tooltipPropTypes';
+import TooltipSense from './TooltipSense';
+import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
+import usePrevious from '../../hooks/usePrevious';
+import useLocalize from '../../hooks/useLocalize';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+
+const hasHoverSupport = DeviceCapabilities.hasHoverSupport();
+
+/**
+ * A component used to wrap an element intended for displaying a tooltip. The term "tooltip's target" refers to the
+ * wrapped element, which, upon hover, triggers the tooltip to be shown.
+ * @param {propTypes} props
+ * @returns {ReactNodeLike}
+ */
+function Tooltip(props) {
+ const {children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey} = props;
+
+ 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);
+
+ /**
+ * Display the tooltip in an animation.
+ */
+ const showTooltip = useCallback(() => {
+ if (!isRendered) {
+ 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();
+ }, [isRendered]);
+
+ // 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
+ *
+ * @param {Object} bounds - updated bounds
+ */
+ const updateBounds = (bounds) => {
+ 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 = () => {
+ 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);
+ };
+
+ // 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 ((_.isEmpty(text) && renderTooltipContent == null) || !hasHoverSupport) {
+ return children;
+ }
+
+ return (
+ <>
+ {isRendered && (
+
+ )}
+
+
+ {children}
+
+
+ >
+ );
+}
+
+Tooltip.propTypes = tooltipPropTypes.propTypes;
+Tooltip.defaultProps = tooltipPropTypes.defaultProps;
+export default memo(Tooltip);
diff --git a/src/components/Tooltip/index.native.js b/src/components/Tooltip/BaseTooltip.native.js
similarity index 100%
rename from src/components/Tooltip/index.native.js
rename to src/components/Tooltip/BaseTooltip.native.js
diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js
index f60982f52dd4..2e6789ec73f6 100644
--- a/src/components/Tooltip/index.js
+++ b/src/components/Tooltip/index.js
@@ -1,168 +1,37 @@
-import _ from 'underscore';
-import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
-import {Animated} from 'react-native';
-import {BoundsObserver} from '@react-ng/bounds-observer';
-import TooltipRenderedOnPageBody from './TooltipRenderedOnPageBody';
-import Hoverable from '../Hoverable';
-import * as tooltipPropTypes from './tooltipPropTypes';
-import TooltipSense from './TooltipSense';
-import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
-import usePrevious from '../../hooks/usePrevious';
-import useLocalize from '../../hooks/useLocalize';
-import useWindowDimensions from '../../hooks/useWindowDimensions';
+import React from 'react';
+import PropTypes from 'prop-types';
+import {propTypes as tooltipPropTypes, defaultProps as tooltipDefaultProps} from './tooltipPropTypes';
+import BaseTooltip from './BaseTooltip';
-const hasHoverSupport = DeviceCapabilities.hasHoverSupport();
+const propTypes = {
+ ...tooltipPropTypes,
-/**
- * A component used to wrap an element intended for displaying a tooltip. The term "tooltip's target" refers to the
- * wrapped element, which, upon hover, triggers the tooltip to be shown.
- * @param {propTypes} props
- * @returns {ReactNodeLike}
- */
-function Tooltip(props) {
- const {children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey} = props;
+ /** Whether the actual Tooltip should be rendered. If false, it's just going to return the children */
+ shouldRender: PropTypes.bool,
+};
- const {preferredLocale} = useLocalize();
- const {windowWidth} = useWindowDimensions();
+const defaultProps = {
+ ...tooltipDefaultProps,
+ shouldRender: true,
+};
- // 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);
-
- /**
- * Display the tooltip in an animation.
- */
- const showTooltip = useCallback(() => {
- if (!isRendered) {
- 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();
- }, [isRendered]);
-
- // 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
- *
- * @param {Object} bounds - updated bounds
- */
- const updateBounds = (bounds) => {
- 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 = () => {
- 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);
- };
-
- // 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 ((_.isEmpty(text) && renderTooltipContent == null) || !hasHoverSupport) {
+function Tooltip({shouldRender, children, ...props}) {
+ if (!shouldRender) {
return children;
}
return (
- <>
- {isRendered && (
-
- )}
-
-
- {children}
-
-
- >
+
+ {children}
+
);
}
-Tooltip.propTypes = tooltipPropTypes.propTypes;
-Tooltip.defaultProps = tooltipPropTypes.defaultProps;
-export default memo(Tooltip);
+Tooltip.displayName = 'Tooltip';
+Tooltip.propTypes = propTypes;
+Tooltip.defaultProps = defaultProps;
+
+export default Tooltip;
diff --git a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.js b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.js
new file mode 100644
index 000000000000..6c611dae17fd
--- /dev/null
+++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.js
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ /** Children to wrap with Tooltip. */
+ children: PropTypes.node.isRequired,
+};
+
+/**
+ * @param {propTypes} props
+ * @returns {ReactNodeLike}
+ */
+function BaseUserDetailsTooltip(props) {
+ return props.children;
+}
+
+BaseUserDetailsTooltip.propTypes = propTypes;
+BaseUserDetailsTooltip.displayName = 'BaseUserDetailsTooltip';
+
+export default BaseUserDetailsTooltip;
diff --git a/src/components/UserDetailsTooltip/index.web.js b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
similarity index 94%
rename from src/components/UserDetailsTooltip/index.web.js
rename to src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
index e961c237ae5f..5f124cb21467 100644
--- a/src/components/UserDetailsTooltip/index.web.js
+++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
@@ -14,7 +14,7 @@ import CONST from '../../CONST';
import * as LocalePhoneNumber from '../../libs/LocalePhoneNumber';
import useLocalize from '../../hooks/useLocalize';
-function UserDetailsTooltip(props) {
+function BaseUserDetailsTooltip(props) {
const {translate} = useLocalize();
const userDetails = lodashGet(props.personalDetailsList, props.accountID, props.fallbackUserDetails);
@@ -74,12 +74,12 @@ function UserDetailsTooltip(props) {
);
}
-UserDetailsTooltip.propTypes = propTypes;
-UserDetailsTooltip.defaultProps = defaultProps;
-UserDetailsTooltip.displayName = 'UserDetailsTooltip';
+BaseUserDetailsTooltip.propTypes = propTypes;
+BaseUserDetailsTooltip.defaultProps = defaultProps;
+BaseUserDetailsTooltip.displayName = 'BaseUserDetailsTooltip';
export default withOnyx({
personalDetailsList: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
-})(UserDetailsTooltip);
+})(BaseUserDetailsTooltip);
diff --git a/src/components/UserDetailsTooltip/index.js b/src/components/UserDetailsTooltip/index.js
index b51dfee2060c..86778e4a1019 100644
--- a/src/components/UserDetailsTooltip/index.js
+++ b/src/components/UserDetailsTooltip/index.js
@@ -1,19 +1,37 @@
+import React from 'react';
import PropTypes from 'prop-types';
+import {propTypes as userDetailsTooltipPropTypes, defaultProps as userDetailsTooltipDefaultProps} from './userDetailsTooltipPropTypes';
+import BaseUserDetailsTooltip from './BaseUserDetailsTooltip';
const propTypes = {
- /** Children to wrap with Tooltip. */
- children: PropTypes.node.isRequired,
+ ...userDetailsTooltipPropTypes,
+
+ /** Whether the actual UserDetailsTooltip should be rendered. If false, it's just going to return the children */
+ shouldRender: PropTypes.bool,
+};
+
+const defaultProps = {
+ ...userDetailsTooltipDefaultProps,
+ shouldRender: true,
};
-/**
- * @param {propTypes} props
- * @returns {ReactNodeLike}
- */
-function UserDetailsTooltip(props) {
- return props.children;
+function UserDetailsTooltip({shouldRender, children, ...props}) {
+ if (!shouldRender) {
+ return children;
+ }
+
+ return (
+
+ {children}
+
+ );
}
-UserDetailsTooltip.propTypes = propTypes;
UserDetailsTooltip.displayName = 'UserDetailsTooltip';
+UserDetailsTooltip.propTypes = propTypes;
+UserDetailsTooltip.defaultProps = defaultProps;
export default UserDetailsTooltip;
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 1aca48b17f4e..7832b9a180fe 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -1426,32 +1426,28 @@ function getShareDestinationOptions(
* Format personalDetails or userToInvite to be shown in the list
*
* @param {Object} member - personalDetails or userToInvite
- * @param {Boolean} isSelected - whether the item is selected
+ * @param {Object} config - keys to overwrite the default values
* @returns {Object}
*/
-function formatMemberForList(member, isSelected) {
+function formatMemberForList(member, config = {}) {
if (!member) {
return undefined;
}
- const avatarSource = lodashGet(member, 'participantsList[0].avatar', '') || lodashGet(member, 'avatar', '');
const accountID = lodashGet(member, 'accountID', '');
return {
text: lodashGet(member, 'text', '') || lodashGet(member, 'displayName', ''),
alternateText: lodashGet(member, 'alternateText', '') || lodashGet(member, 'login', ''),
keyForList: lodashGet(member, 'keyForList', '') || String(accountID),
- isSelected,
+ isSelected: false,
isDisabled: false,
accountID,
login: lodashGet(member, 'login', ''),
rightElement: null,
- avatar: {
- source: UserUtils.getAvatar(avatarSource, accountID),
- name: lodashGet(member, 'participantsList[0].login', '') || lodashGet(member, 'displayName', ''),
- type: 'avatar',
- },
+ icons: lodashGet(member, 'icons'),
pendingAction: lodashGet(member, 'pendingAction'),
+ ...config,
};
}
diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js
index 5e57968ab77a..39495911b8dc 100644
--- a/src/pages/workspace/WorkspaceInvitePage.js
+++ b/src/pages/workspace/WorkspaceInvitePage.js
@@ -87,7 +87,7 @@ function WorkspaceInvitePage(props) {
// Update selectedOptions with the latest personalDetails and policyMembers information
const detailsMap = {};
- _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail, false)));
+ _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail)));
const newSelectedOptions = [];
_.forEach(selectedOptions, (option) => {
newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option);
@@ -114,7 +114,7 @@ function WorkspaceInvitePage(props) {
// Filtering out selected users from the search results
const selectedLogins = _.map(selectedOptions, ({login}) => login);
const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login));
- const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false));
+ const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, OptionsListUtils.formatMemberForList);
const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login);
sections.push({
@@ -128,7 +128,7 @@ function WorkspaceInvitePage(props) {
if (hasUnselectedUserToInvite) {
sections.push({
title: undefined,
- data: [OptionsListUtils.formatMemberForList(userToInvite, false)],
+ data: [OptionsListUtils.formatMemberForList(userToInvite)],
shouldShow: true,
indexOffset,
});
diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js
index 9eff4eba47ee..319099a9e1c8 100644
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.js
@@ -322,11 +322,13 @@ function WorkspaceMembersPage(props) {
{props.translate('common.admin')}
) : null,
- avatar: {
- source: UserUtils.getAvatar(details.avatar, accountID),
- name: details.login,
- type: CONST.ICON_TYPE_AVATAR,
- },
+ icons: [
+ {
+ source: UserUtils.getAvatar(details.avatar, accountID),
+ name: details.login,
+ type: CONST.ICON_TYPE_AVATAR,
+ },
+ ],
errors: policyMember.errors,
pendingAction: policyMember.pendingAction,
});
diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js
index ef64c4caec35..173fda328d1f 100644
--- a/src/styles/themes/default.js
+++ b/src/styles/themes/default.js
@@ -82,6 +82,7 @@ const darkTheme = {
QRLogo: colors.green400,
starDefaultBG: 'rgb(254, 228, 94)',
loungeAccessOverlay: colors.blue800,
+ selectionListIndicatorColor: colors.white,
mapAttributionText: colors.black,
};
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 6f20e48835fd..437d37e625dd 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -1,5 +1,6 @@
import _ from 'underscore';
import Onyx from 'react-native-onyx';
+import {View} from 'react-native';
import * as OptionsListUtils from '../../src/libs/OptionsListUtils';
import * as ReportUtils from '../../src/libs/ReportUtils';
import ONYXKEYS from '../../src/ONYXKEYS';
@@ -1547,7 +1548,7 @@ describe('OptionsListUtils', () => {
});
it('formatMemberForList()', () => {
- const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail, key) => OptionsListUtils.formatMemberForList(personalDetail, key === '1'));
+ const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail, key) => OptionsListUtils.formatMemberForList(personalDetail, {isSelected: key === '1'}));
// We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array
expect(formattedMembers[0].text).toBe('Mister Fantastic');
@@ -1566,7 +1567,8 @@ describe('OptionsListUtils', () => {
// `rightElement` is always null
expect(_.every(formattedMembers, (personalDetail) => personalDetail.rightElement === null)).toBe(true);
- // The PERSONAL_DETAILS list doesn't specify `participantsList[n].avatar`, so the default one should be used
- expect(_.every(formattedMembers, (personalDetail) => Boolean(personalDetail.avatar.source))).toBe(true);
+ // Passing a config should override the other keys
+ const formattedMembersWithRightElement = _.map(PERSONAL_DETAILS, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, {rightElement: }));
+ expect(_.every(formattedMembersWithRightElement, (personalDetail) => Boolean(personalDetail.rightElement))).toBe(true);
});
});