diff --git a/assets/images/simple-illustrations/simple-illustration__empty-state.svg b/assets/images/simple-illustrations/simple-illustration__empty-state.svg
new file mode 100644
index 000000000000..154b2269c285
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__empty-state.svg
@@ -0,0 +1,102 @@
+
+
+
diff --git a/src/CONST.ts b/src/CONST.ts
index b809bdaacaf6..f50914aab5fc 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -5250,6 +5250,13 @@ const CONST = {
},
EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[],
+
+ EMPTY_STATE_MEDIA: {
+ ANIMATION: 'animation',
+ ILLUSTRATION: 'illustration',
+ VIDEO: 'video',
+ },
+
UPGRADE_FEATURE_INTRO_MAPPING: [
{
id: 'reportFields',
@@ -5260,6 +5267,7 @@ const CONST = {
icon: 'Pencil',
},
],
+
REPORT_FIELD_TYPES: {
TEXT: 'text',
DATE: 'date',
diff --git a/src/components/AccountingListSkeletonView.tsx b/src/components/AccountingListSkeletonView.tsx
index b977903d3adc..dbe8ada6c4b7 100644
--- a/src/components/AccountingListSkeletonView.tsx
+++ b/src/components/AccountingListSkeletonView.tsx
@@ -4,12 +4,14 @@ import ItemListSkeletonView from './Skeletons/ItemListSkeletonView';
type AccountingListSkeletonViewProps = {
shouldAnimate?: boolean;
+ gradientOpacityEnabled?: boolean;
};
-function AccountingListSkeletonView({shouldAnimate = true}: AccountingListSkeletonViewProps) {
+function AccountingListSkeletonView({shouldAnimate = true, gradientOpacityEnabled = false}: AccountingListSkeletonViewProps) {
return (
(
<>
{
+ if (!event) {
+ return;
+ }
+
+ if ('naturalSize' in event) {
+ setVideoAspectRatio(event.naturalSize.width / event.naturalSize.height);
+ } else {
+ setVideoAspectRatio(event.srcElement.videoWidth / event.srcElement.videoHeight);
+ }
+ };
+
+ const HeaderComponent = useMemo(() => {
+ switch (headerMediaType) {
+ case CONST.EMPTY_STATE_MEDIA.VIDEO:
+ return (
+
+ );
+ case CONST.EMPTY_STATE_MEDIA.ANIMATION:
+ return (
+
+ );
+ case CONST.EMPTY_STATE_MEDIA.ILLUSTRATION:
+ return (
+
+ );
+ default:
+ return null;
+ }
+ }, [headerMedia, headerMediaType, headerContentStyles, videoAspectRatio, styles.emptyStateVideo]);
+
+ return (
+
+
+
+
+
+
+ {HeaderComponent}
+
+ {title}
+ {subtitle}
+ {!!buttonText && !!buttonAction && (
+
+ )}
+
+
+
+
+ );
+}
+
+EmptyStateComponent.displayName = 'EmptyStateComponent';
+export default EmptyStateComponent;
diff --git a/src/components/EmptyStateComponent/types.ts b/src/components/EmptyStateComponent/types.ts
new file mode 100644
index 000000000000..326b25542f42
--- /dev/null
+++ b/src/components/EmptyStateComponent/types.ts
@@ -0,0 +1,41 @@
+import type {ImageStyle} from 'expo-image';
+import type {StyleProp, ViewStyle} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import type DotLottieAnimation from '@components/LottieAnimations/types';
+import type SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
+import type TableRowSkeleton from '@components/Skeletons/TableRowSkeleton';
+import type CONST from '@src/CONST';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+type ValidSkeletons = typeof SearchRowSkeleton | typeof TableRowSkeleton;
+type MediaTypes = ValueOf;
+
+type SharedProps = {
+ SkeletonComponent: ValidSkeletons;
+ title: string;
+ subtitle: string;
+ buttonText?: string;
+ buttonAction?: () => void;
+ headerStyles?: StyleProp;
+ headerMediaType: T;
+ headerContentStyles?: StyleProp;
+};
+
+type MediaType = SharedProps & {
+ headerMedia: HeaderMedia;
+};
+
+type VideoProps = MediaType;
+type IllustrationProps = MediaType;
+type AnimationProps = MediaType;
+
+type EmptyStateComponentProps = VideoProps | IllustrationProps | AnimationProps;
+
+type VideoLoadedEventType = {
+ srcElement: {
+ videoWidth: number;
+ videoHeight: number;
+ };
+};
+
+export type {EmptyStateComponentProps, VideoLoadedEventType};
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 7a8186d2f38e..da72b3025340 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -53,6 +53,7 @@ import ConciergeNew from '@assets/images/simple-illustrations/simple-illustratio
import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg';
import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg';
import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg';
+import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg';
import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg';
import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg';
import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg';
@@ -198,6 +199,7 @@ export {
CheckmarkCircle,
CreditCardEyes,
LockClosedOrange,
+ EmptyState,
FolderWithPapers,
VirtualCard,
};
diff --git a/src/components/OptionsListSkeletonView.tsx b/src/components/OptionsListSkeletonView.tsx
index a11077f95bb5..6dede512f405 100644
--- a/src/components/OptionsListSkeletonView.tsx
+++ b/src/components/OptionsListSkeletonView.tsx
@@ -18,16 +18,18 @@ function getLinedWidth(index: number): string {
type OptionsListSkeletonViewProps = {
shouldAnimate?: boolean;
+ gradientOpacityEnabled?: boolean;
shouldStyleAsTable?: boolean;
};
-function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = false}: OptionsListSkeletonViewProps) {
+function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = false, gradientOpacityEnabled = false}: OptionsListSkeletonViewProps) {
const styles = useThemeStyles();
return (
{
const lineWidth = getLinedWidth(itemIndex);
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index fc5c23d5c9ec..6ea977d1bf36 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -5,7 +5,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import SearchTableHeader from '@components/SelectionList/SearchTableHeader';
import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
-import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton';
+import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -101,7 +101,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
query={query}
hash={hash}
/>
-
+
>
);
}
@@ -207,7 +207,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
onEndReached={fetchMoreResults}
listFooterContent={
isLoadingMoreItems ? (
-
diff --git a/src/components/Skeletons/ItemListSkeletonView.tsx b/src/components/Skeletons/ItemListSkeletonView.tsx
index 1ee2da8a8019..046cdfffbee5 100644
--- a/src/components/Skeletons/ItemListSkeletonView.tsx
+++ b/src/components/Skeletons/ItemListSkeletonView.tsx
@@ -1,6 +1,6 @@
-import React, {useMemo, useState} from 'react';
-import {View} from 'react-native';
-import type {StyleProp, ViewStyle} from 'react-native';
+import React, {useCallback, useMemo, useState} from 'react';
+import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
+import {StyleSheet, View} from 'react-native';
import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -10,22 +10,62 @@ type ListItemSkeletonProps = {
shouldAnimate?: boolean;
renderSkeletonItem: (args: {itemIndex: number}) => React.ReactNode;
fixedNumItems?: number;
+ gradientOpacityEnabled?: boolean;
itemViewStyle?: StyleProp;
itemViewHeight?: number;
};
-function ItemListSkeletonView({shouldAnimate = true, renderSkeletonItem, fixedNumItems, itemViewStyle = {}, itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT}: ListItemSkeletonProps) {
+const getVerticalMargin = (style: StyleProp): number => {
+ if (!style) {
+ return 0;
+ }
+
+ const flattenStyle = StyleSheet.flatten(style);
+ const marginVertical = Number(flattenStyle?.marginVertical ?? 0);
+ const marginTop = Number(flattenStyle?.marginTop ?? 0);
+ const marginBottom = Number(flattenStyle?.marginBottom ?? 0);
+
+ return marginVertical + marginTop + marginBottom;
+};
+
+function ItemListSkeletonView({
+ shouldAnimate = true,
+ renderSkeletonItem,
+ fixedNumItems,
+ gradientOpacityEnabled = false,
+ itemViewStyle = {},
+ itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT,
+}: ListItemSkeletonProps) {
const theme = useTheme();
const themeStyles = useThemeStyles();
const [numItems, setNumItems] = useState(fixedNumItems ?? 0);
+
+ const totalItemHeight = itemViewHeight + getVerticalMargin(itemViewStyle);
+
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ if (fixedNumItems) {
+ return;
+ }
+
+ const totalHeight = event.nativeEvent.layout.height;
+ const newNumItems = Math.ceil(totalHeight / totalItemHeight);
+ if (newNumItems !== numItems) {
+ setNumItems(newNumItems);
+ }
+ },
+ [fixedNumItems, numItems, totalItemHeight],
+ );
+
const skeletonViewItems = useMemo(() => {
const items = [];
for (let i = 0; i < numItems; i++) {
+ const opacity = gradientOpacityEnabled ? 1 - i / (numItems - 1) : 1;
items.push(
{
- if (fixedNumItems) {
- return;
- }
-
- const newNumItems = Math.ceil(event.nativeEvent.layout.height / itemViewHeight);
- if (newNumItems === numItems) {
- return;
- }
- setNumItems(newNumItems);
- }}
+ onLayout={handleLayout}
>
- {skeletonViewItems}
+ {skeletonViewItems}
);
}
diff --git a/src/components/Skeletons/TableListItemSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx
similarity index 54%
rename from src/components/Skeletons/TableListItemSkeleton.tsx
rename to src/components/Skeletons/SearchRowSkeleton.tsx
index 6ff3a3aedbb9..2359e47b7520 100644
--- a/src/components/Skeletons/TableListItemSkeleton.tsx
+++ b/src/components/Skeletons/SearchRowSkeleton.tsx
@@ -2,26 +2,41 @@ import React from 'react';
import {Circle, Rect} from 'react-native-svg';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
import ItemListSkeletonView from './ItemListSkeletonView';
-type TableListItemSkeletonProps = {
+type SearchRowSkeletonProps = {
shouldAnimate?: boolean;
fixedNumItems?: number;
+ gradientOpacityEnabled?: boolean;
};
-const barHeight = '10';
-const shortBarWidth = '40';
-const longBarWidth = '120';
+const barHeight = 8;
+const longBarWidth = 120;
+const leftPaneWidth = variables.sideBarWidth;
-function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListItemSkeletonProps) {
+// 12 is the gap between the element and the right button
+const gapWidth = 12;
+
+// 80 is the width of the element itself
+const rightSideElementWidth = 80;
+
+// 24 is the padding of the central pane summing two sides
+const centralPanePadding = 40;
+
+// 80 is the width of the button on the right side
+const rightButtonWidth = 80;
+
+function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: SearchRowSkeletonProps) {
const styles = useThemeStyles();
- const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
+ const {windowWidth, isSmallScreenWidth, isLargeScreenWidth} = useWindowDimensions();
if (isSmallScreenWidth) {
return (
(
@@ -51,7 +66,7 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI
height={4}
/>
);
}
+
return (
(
<>
-
+ {isLargeScreenWidth && (
+ <>
+
+
+
+ >
+ )}
+
+
>
)}
@@ -146,6 +181,6 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI
);
}
-TableListItemSkeleton.displayName = 'TableListItemSkeleton';
+SearchRowSkeleton.displayName = 'SearchRowSkeleton';
-export default TableListItemSkeleton;
+export default SearchRowSkeleton;
diff --git a/src/components/Skeletons/TableRowSkeleton.tsx b/src/components/Skeletons/TableRowSkeleton.tsx
new file mode 100644
index 000000000000..865bffc5842f
--- /dev/null
+++ b/src/components/Skeletons/TableRowSkeleton.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import {Circle, Rect} from 'react-native-svg';
+import useThemeStyles from '@hooks/useThemeStyles';
+import ItemListSkeletonView from './ItemListSkeletonView';
+
+type TableListItemSkeletonProps = {
+ shouldAnimate?: boolean;
+ fixedNumItems?: number;
+ gradientOpacityEnabled?: boolean;
+};
+
+const barHeight = '8';
+const shortBarWidth = '60';
+const longBarWidth = '124';
+
+function TableListItemSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: TableListItemSkeletonProps) {
+ const styles = useThemeStyles();
+
+ return (
+ (
+ <>
+
+
+
+ >
+ )}
+ />
+ );
+}
+
+TableListItemSkeleton.displayName = 'TableListItemSkeleton';
+
+export default TableListItemSkeleton;
diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx
index bfeb46f06298..474cbfc0aff8 100644
--- a/src/pages/Search/EmptySearchView.tsx
+++ b/src/pages/Search/EmptySearchView.tsx
@@ -1,14 +1,22 @@
import React from 'react';
+import EmptyStateComponent from '@components/EmptyStateComponent';
import * as Illustrations from '@components/Icon/Illustrations';
-import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
+import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
function EmptySearchView() {
const {translate} = useLocalize();
+ const styles = useThemeStyles();
return (
-
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
index 633d1833e43f..174251a80d5f 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -7,6 +7,7 @@ import Button from '@components/Button';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
+import EmptyStateComponent from '@components/EmptyStateComponent';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
@@ -15,9 +16,9 @@ import SelectionList from '@components/SelectionList';
import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel';
import TableListItem from '@components/SelectionList/TableListItem';
import type {ListItem} from '@components/SelectionList/types';
+import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
-import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -317,10 +318,15 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) {
color={theme.spinner}
/>
)}
+
{!hasVisibleCategories && !isLoading && (
-
)}
diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
index 92b016766742..cf9952720fc9 100644
--- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
@@ -7,6 +7,7 @@ import Button from '@components/Button';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
+import EmptyStateComponent from '@components/EmptyStateComponent';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
@@ -14,9 +15,9 @@ import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel';
import TableListItem from '@components/SelectionList/TableListItem';
+import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
-import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -343,9 +344,13 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) {
/>
)}
{!hasVisibleTag && !isLoading && (
-
)}
diff --git a/src/styles/index.ts b/src/styles/index.ts
index a0795e5d378a..e422b8bd3d1e 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5083,6 +5083,59 @@ const styles = (theme: ThemeColors) =>
fontSize: variables.fontSizeNormal,
fontWeight: FontUtils.fontWeight.bold,
},
+
+ skeletonBackground: {
+ flex: 1,
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ paddingRight: 8,
+ paddingLeft: 8,
+ },
+
+ emptyStateScrollView: {
+ minHeight: 400,
+ height: '100%',
+ flex: 1,
+ },
+
+ emptyStateForeground: (isSmallScreenWidth: boolean) => ({
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '100%',
+ padding: isSmallScreenWidth ? 32 : 0,
+ width: '100%',
+ }),
+
+ emptyStateContent: {
+ backgroundColor: theme.cardBG,
+ borderRadius: variables.componentBorderRadiusLarge,
+ maxWidth: 400,
+ },
+
+ emptyStateHeader: (isIllustration: boolean) => ({
+ borderTopLeftRadius: variables.componentBorderRadiusLarge,
+ borderTopRightRadius: variables.componentBorderRadiusLarge,
+ minHeight: 200,
+ alignItems: isIllustration ? 'center' : undefined,
+ justifyContent: isIllustration ? 'center' : undefined,
+ }),
+
+ emptyFolderBG: {
+ backgroundColor: theme.emptyFolderBG,
+ },
+
+ emptyStateVideo: {
+ borderTopLeftRadius: variables.componentBorderRadiusLarge,
+ borderTopRightRadius: variables.componentBorderRadiusLarge,
+ },
+
+ emptyStateFolderIconSize: {
+ width: 184,
+ height: 112,
+ },
} satisfies Styles);
type ThemeStyles = ReturnType;
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index b316f116c805..7ed23c7c0991 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -93,6 +93,7 @@ const darkTheme = {
white: colors.white,
videoPlayerBG: `${colors.productDark100}cc`,
transparentWhite: `${colors.white}51`,
+ emptyFolderBG: colors.yellow600,
// Adding a color here will animate the status bar to the right color when the screen is opened.
// Note that it needs to be a screen name, not a route url.
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index 05364515e264..2ebb558ee20b 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -93,6 +93,7 @@ const lightTheme = {
white: colors.white,
videoPlayerBG: `${colors.productDark100}cc`,
transparentWhite: `${colors.white}51`,
+ emptyFolderBG: colors.yellow600,
// Adding a color here will animate the status bar to the right color when the screen is opened.
// Note that it needs to be a screen name, not a route url.
diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts
index 2d8618c24ebb..ffa42e99777d 100644
--- a/src/styles/theme/types.ts
+++ b/src/styles/theme/types.ts
@@ -97,6 +97,7 @@ type ThemeColors = {
white: Color;
videoPlayerBG: Color;
transparentWhite: Color;
+ emptyFolderBG: Color;
PAGE_THEMES: Record;