Skip to content

Commit a1d5ca5

Browse files
authored
Merge pull request Expensify#89276 from bernhardoj/feat/84883-add-expand-collapse-to-search-suggested-search-sections
Add expand collapse to search suggested search sections
2 parents 97a3d58 + 03d026a commit a1d5ca5

6 files changed

Lines changed: 193 additions & 30 deletions

File tree

src/CONST/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8931,6 +8931,9 @@ const CONST = {
89318931
COLLAPSIBLE_SECTION: {
89328932
TOGGLE: 'CollapsibleSection-Toggle',
89338933
},
8934+
ACCORDION_SECTION: {
8935+
TOGGLE: 'AccordionSection-Toggle',
8936+
},
89348937
VIDEO_PLAYER: {
89358938
PLAY_PAUSE_BUTTON: 'VideoPlayer-PlayPauseButton',
89368939
FULLSCREEN_BUTTON: 'VideoPlayer-FullscreenButton',

src/components/Accordion/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty
5050
easing: Easing.inOut(Easing.quad),
5151
},
5252
(finished) => {
53-
if (!finished || !isExpanded.get()) {
53+
if (!finished) {
5454
return;
5555
}
5656
isAnimating.set(false);
@@ -67,12 +67,17 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty
6767
};
6868
}
6969

70+
let display = 'inline';
71+
if (!isExpanded.get() && !isAnimating.get()) {
72+
display = 'none';
73+
}
74+
7075
return {
7176
height: !isToggleTriggered.get() ? undefined : derivedHeight.get(),
7277
maxHeight: !isToggleTriggered.get() ? undefined : derivedHeight.get(),
7378
opacity: derivedOpacity.get(),
7479
overflow: isAnimating.get() ? 'hidden' : 'visible',
75-
display: isExpanded.get() ? 'inline' : 'none',
80+
display,
7681
};
7782
});
7883

src/libs/SearchUIUtils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,23 @@ function formatBadgeText(count: number): string {
413413
return count > CONST.SEARCH.TODO_BADGE_MAX_COUNT ? `${CONST.SEARCH.TODO_BADGE_MAX_COUNT}+` : count.toString();
414414
}
415415

416+
function getSectionBadgeText(sectionTranslationPath: TranslationPaths, reportCounts: Record<string, number>): string | undefined {
417+
if (
418+
sectionTranslationPath === 'search.tabs.expenseReports' &&
419+
(CONST.SEARCH.SEARCH_KEYS.SUBMIT in reportCounts || CONST.SEARCH.SEARCH_KEYS.APPROVE in reportCounts || CONST.SEARCH.SEARCH_KEYS.PAY in reportCounts)
420+
) {
421+
return formatBadgeText(
422+
(reportCounts[CONST.SEARCH.SEARCH_KEYS.SUBMIT] ?? 0) + (reportCounts[CONST.SEARCH.SEARCH_KEYS.APPROVE] ?? 0) + (reportCounts[CONST.SEARCH.SEARCH_KEYS.PAY] ?? 0),
423+
);
424+
}
425+
426+
if (sectionTranslationPath === 'search.tabs.accounting' && CONST.SEARCH.SEARCH_KEYS.EXPORT in reportCounts) {
427+
return formatBadgeText(reportCounts[CONST.SEARCH.SEARCH_KEYS.EXPORT]);
428+
}
429+
430+
return undefined;
431+
}
432+
416433
function getItemBadgeText(itemKey: string, reportCounts: Record<string, number>): string | undefined {
417434
if (itemKey in reportCounts) {
418435
return formatBadgeText(reportCounts[itemKey]);
@@ -5788,6 +5805,7 @@ export {
57885805
getActions,
57895806
createTypeMenuSections,
57905807
formatBadgeText,
5808+
getSectionBadgeText,
57915809
getItemBadgeText,
57925810
createBaseSavedSearchMenuItem,
57935811
shouldShowEmptyState,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React, {useState} from 'react';
2+
import {View} from 'react-native';
3+
import Animated, {useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
4+
import type {SharedValue} from 'react-native-reanimated';
5+
import Accordion from '@components/Accordion';
6+
import Badge from '@components/Badge';
7+
import Icon from '@components/Icon';
8+
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
9+
import Text from '@components/Text';
10+
import useAccordionAnimation from '@hooks/useAccordionAnimation';
11+
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
12+
import useTheme from '@hooks/useTheme';
13+
import useThemeStyles from '@hooks/useThemeStyles';
14+
import variables from '@styles/variables';
15+
import CONST from '@src/CONST';
16+
import type ChildrenProps from '@src/types/utils/ChildrenProps';
17+
18+
type SearchTypeMenuAccordionProps = ChildrenProps & {
19+
title: string;
20+
defaultExpanded?: boolean;
21+
badgeText?: string;
22+
};
23+
24+
type AnimatedBadgeProps = {
25+
text: string;
26+
isExpanded: SharedValue<boolean>;
27+
};
28+
29+
function getBadgeOpacity(isExpanded: boolean) {
30+
return Number(!isExpanded);
31+
}
32+
33+
function getBadgeOffsetY(isExpanded: boolean): `${number}%` | number {
34+
return isExpanded ? '50%' : 0;
35+
}
36+
37+
function getArrowRotation(isExpanded: boolean) {
38+
return isExpanded ? 0 : 180;
39+
}
40+
41+
function AnimatedBadge({text, isExpanded}: AnimatedBadgeProps) {
42+
const styles = useThemeStyles();
43+
44+
const badgeOpacity = useSharedValue(getBadgeOpacity(isExpanded.get()));
45+
const badgeOffsetY = useSharedValue(getBadgeOffsetY(isExpanded.get()));
46+
47+
useAnimatedReaction(
48+
() => isExpanded.get(),
49+
(isExpandedValue) => {
50+
badgeOpacity.set(withTiming(getBadgeOpacity(isExpandedValue), {duration: CONST.ANIMATED_TRANSITION}));
51+
badgeOffsetY.set(withTiming(getBadgeOffsetY(isExpandedValue), {duration: CONST.ANIMATED_TRANSITION}));
52+
},
53+
);
54+
55+
const badgeAnimatedStyle = useAnimatedStyle(() => ({
56+
opacity: badgeOpacity.get(),
57+
transform: [{translateY: badgeOffsetY.get()}],
58+
}));
59+
60+
return (
61+
<Animated.View style={[badgeAnimatedStyle]}>
62+
<Badge
63+
text={text}
64+
badgeStyles={styles.searchSectionBadge}
65+
success
66+
isCondensed
67+
/>
68+
</Animated.View>
69+
);
70+
}
71+
72+
function SearchTypeMenuAccordion({title, defaultExpanded = true, badgeText, children}: SearchTypeMenuAccordionProps) {
73+
const icons = useMemoizedLazyExpensifyIcons(['UpArrow']);
74+
const theme = useTheme();
75+
const styles = useThemeStyles();
76+
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
77+
const {isAccordionExpanded, shouldAnimateAccordionSection} = useAccordionAnimation(isExpanded);
78+
79+
const toggleSection = () => {
80+
setIsExpanded((prevIsExpanded) => !prevIsExpanded);
81+
};
82+
83+
const arrowRotation = useSharedValue(getArrowRotation(defaultExpanded));
84+
85+
useAnimatedReaction(
86+
() => isAccordionExpanded.get(),
87+
(isExpandedValue) => {
88+
const rotateDegree = getArrowRotation(isExpandedValue);
89+
arrowRotation.set(withTiming(rotateDegree, {duration: CONST.ANIMATED_TRANSITION}));
90+
},
91+
);
92+
93+
const arrowAnimatedStyle = useAnimatedStyle(() => ({transform: [{rotate: `${arrowRotation.get()}deg`}]}));
94+
95+
return (
96+
<View>
97+
<PressableWithoutFeedback
98+
onPress={toggleSection}
99+
style={[styles.flexRow, styles.p2, styles.gap2, styles.alignItemsCenter, styles.br2]}
100+
role={CONST.ROLE.BUTTON}
101+
accessibilityLabel={title}
102+
sentryLabel={CONST.SENTRY_LABEL.ACCORDION_SECTION.TOGGLE}
103+
hoverStyle={styles.hoveredComponentBG}
104+
>
105+
<Text
106+
style={[styles.flex1, styles.mutedNormalTextLabel]}
107+
accessibilityRole={CONST.ROLE.HEADER}
108+
>
109+
{title}
110+
</Text>
111+
{!!badgeText && (
112+
<AnimatedBadge
113+
text={badgeText}
114+
isExpanded={isAccordionExpanded}
115+
/>
116+
)}
117+
<Animated.View style={[arrowAnimatedStyle]}>
118+
<Icon
119+
fill={theme.icon}
120+
src={icons.UpArrow}
121+
width={variables.iconSizeSmall}
122+
height={variables.iconSizeSmall}
123+
/>
124+
</Animated.View>
125+
</PressableWithoutFeedback>
126+
<Accordion
127+
isExpanded={isAccordionExpanded}
128+
isToggleTriggered={shouldAnimateAccordionSection}
129+
>
130+
{children}
131+
</Accordion>
132+
</View>
133+
);
134+
}
135+
136+
export default SearchTypeMenuAccordion;

src/pages/Search/SearchTypeMenuWide.tsx

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
77
import ScrollView from '@components/ScrollView';
88
import {useSearchActionsContext} from '@components/Search/SearchContext';
99
import type {SearchQueryJSON} from '@components/Search/types';
10-
import Text from '@components/Text';
1110
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
1211
import useLocalize from '@hooks/useLocalize';
1312
import useNetwork from '@hooks/useNetwork';
@@ -17,14 +16,15 @@ import useSingleExecution from '@hooks/useSingleExecution';
1716
import useThemeStyles from '@hooks/useThemeStyles';
1817
import {setSearchContext} from '@libs/actions/Search';
1918
import Navigation from '@libs/Navigation/Navigation';
20-
import {getItemBadgeText} from '@libs/SearchUIUtils';
19+
import {getItemBadgeText, getSectionBadgeText} from '@libs/SearchUIUtils';
2120
import type {SearchTypeMenuSection} from '@libs/SearchUIUtils';
2221
import CONST from '@src/CONST';
2322
import ONYXKEYS from '@src/ONYXKEYS';
2423
import ROUTES from '@src/ROUTES';
2524
import todosReportCountsSelector from '@src/selectors/Todos';
2625
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
2726
import SavedSearchList from './SavedSearchList';
27+
import SearchTypeMenuAccordion from './SearchTypeMenuAccordion';
2828
import SearchTypeMenuItem from './SearchTypeMenuItem';
2929
import SuggestedSearchSkeleton from './SuggestedSearchSkeleton';
3030

@@ -99,37 +99,32 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) {
9999
const areSuggestedSearchesLoading = !isOffline && !isSearchDataLoaded && !isLoadingOnyxValue(isSearchDataLoadedResult);
100100

101101
const renderSection = (section: SearchTypeMenuSection, sectionIndex: number) => (
102-
<View key={section.translationPath}>
103-
<Text
104-
style={styles.sectionTitle}
105-
accessibilityRole={CONST.ROLE.HEADER}
106-
>
107-
{translate(section.translationPath)}
108-
</Text>
109-
102+
<SearchTypeMenuAccordion
103+
key={section.translationPath}
104+
title={translate(section.translationPath)}
105+
badgeText={getSectionBadgeText(section.translationPath, reportCounts)}
106+
>
110107
{section.translationPath === 'search.savedSearchesMenuItemTitle' ? (
111108
<SavedSearchList hash={hash} />
112109
) : (
113-
<>
114-
{section.menuItems.map((item, itemIndex) => {
115-
const flattenedIndex = (sectionStartIndices?.at(sectionIndex) ?? 0) + itemIndex;
116-
const focused = activeItemIndex === flattenedIndex;
117-
const icon = typeof item.icon === 'string' ? expensifyIcons[item.icon] : item.icon;
110+
section.menuItems.map((item, itemIndex) => {
111+
const flattenedIndex = (sectionStartIndices?.at(sectionIndex) ?? 0) + itemIndex;
112+
const focused = activeItemIndex === flattenedIndex;
113+
const icon = typeof item.icon === 'string' ? expensifyIcons[item.icon] : item.icon;
118114

119-
return (
120-
<SearchTypeMenuItem
121-
key={item.key}
122-
title={translate(item.translationPath)}
123-
icon={icon}
124-
badgeText={getItemBadgeText(item.key, reportCounts)}
125-
focused={focused}
126-
onPress={() => handleTypeMenuItemPress(item.searchQuery)}
127-
/>
128-
);
129-
})}
130-
</>
115+
return (
116+
<SearchTypeMenuItem
117+
key={item.key}
118+
title={translate(item.translationPath)}
119+
icon={icon}
120+
badgeText={getItemBadgeText(item.key, reportCounts)}
121+
focused={focused}
122+
onPress={() => handleTypeMenuItemPress(item.searchQuery)}
123+
/>
124+
);
125+
})
131126
)}
132-
</View>
127+
</SearchTypeMenuAccordion>
133128
);
134129

135130
return (

src/styles/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5638,6 +5638,12 @@ const staticStyles = (theme: ThemeColors) =>
56385638
justifyContent: 'center',
56395639
},
56405640

5641+
searchSectionBadge: {
5642+
alignItems: 'center',
5643+
justifyContent: 'center',
5644+
height: 16,
5645+
},
5646+
56415647
stickToBottom: {
56425648
position: 'absolute',
56435649
bottom: 0,

0 commit comments

Comments
 (0)