diff --git a/README.md b/README.md index aff6e8025..c35c5a9ce 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [Join our Community Slack](http://community.infinite.red/) -# Introduction +## Introduction Reactotron is a powerful debugger for React and React Native applications. It provides an easy-to-use interface for developers to monitor their application's **state, network requests, and performance metrics** and can be used for any size of project, from small personal apps to large-scale enterprise applications. The OG debugger at [Infinite Red](https://infinite.red) that we use on a day-to-day basis to build client apps. Additionally, Reactotron is completely open source and free to use, making it an invaluable tool for developers at all levels of experience. @@ -66,7 +66,11 @@ On the [Releases](https://github.com/infinitered/reactotron/releases?q=reactotro [Some tips that will elevate your Reactotron experience.](https://docs.infinite.red/reactotron/tips/) -## Want to contribute? Here are some helpful reading materials: +## Bug Reports + +When reporting problems with Reactotron, use the provided example app located in `app/example-app` to replicate the issue. This approach enables us to isolate and expedite the resolution of the problem. + +## Want to contribute? Here are some helpful reading materials - [**Contributing**](https://docs.infinite.red/reactotron/contributing/) - [**Architecture**](https://docs.infinite.red/reactotron/contributing/architecture/) @@ -78,10 +82,10 @@ On the [Releases](https://github.com/infinitered/reactotron/releases?q=reactotro - [**React Native iOS**](https://docs.infinite.red/reactotron/troubleshooting/#react-native-ios) - [**React Native Android**](https://docs.infinite.red/reactotron/troubleshooting/#react-native-android) -# Credits +## Credits Reactotron is developed by [Infinite Red](https://infinite.red), [@rmevans9](https://github.com/rmevans9), and 70+ amazing contributors! Special thanks to [@skellock](https://github.com/skellock) for originally creating Reactotron while at Infinite Red. -# Premium Support +## Premium Support [Reactotron](https://infinite.red/reactotron), as an open source project, is free to use and always will be. [Infinite Red](https://infinite.red/) offers premium React and [React Native](https://infinite.red/react-native) mobile app design/development services. Email us at [hello@infinite.red](mailto:hello@infinite.red) to get in touch for more details. diff --git a/apps/example-app/.gitignore b/apps/example-app/.gitignore new file mode 100644 index 000000000..a5c60cce3 --- /dev/null +++ b/apps/example-app/.gitignore @@ -0,0 +1,88 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +ios/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +*.keystore +!debug.keystore + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ignite-specific items below +# You can safely replace everything above this comment with whatever is +# in the default .gitignore generated by React-Native CLI + +# VS Code +.vscode + +# Expo +.expo/* +bin/Exponent.app +/android +/ios + +## Secrets +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# Configurations +!env.js + +/coverage diff --git a/apps/example-app/.prettierignore b/apps/example-app/.prettierignore new file mode 100644 index 000000000..883e252ee --- /dev/null +++ b/apps/example-app/.prettierignore @@ -0,0 +1,6 @@ +node_modules +ios +android +.vscode +ignite/ignite.json +package.json diff --git a/apps/example-app/App.tsx b/apps/example-app/App.tsx new file mode 100644 index 000000000..1fd79cc56 --- /dev/null +++ b/apps/example-app/App.tsx @@ -0,0 +1,11 @@ +import App from "./app/app" +import React from "react" +import * as SplashScreen from "expo-splash-screen" + +SplashScreen.preventAutoHideAsync() + +function IgniteApp() { + return +} + +export default IgniteApp diff --git a/apps/example-app/README.md b/apps/example-app/README.md new file mode 100644 index 000000000..5026063bd --- /dev/null +++ b/apps/example-app/README.md @@ -0,0 +1,16 @@ +# Welcome to the Reactotron Example App + +The sole purpose of this app is to aid in developing Reactotron. It contains an example configuration and code for exercising the functionality of Reactotron's packages. + +The example app was generated with [Ignite](https://github.com/infinitered/ignite); the boilerplate that [Infinite Red](https://infinite.red) uses as a way to test bleeding-edge changes to our React Native stack. + +## Quick Start + +:::info +Before you start, run `yarn` in the root directory of the repo to make sure all of the dependencies have been installed successfully. +::: + +1. From the parent directory, run `yarn build && yarn start` This will build the latest version of Reactotron and open it. +2. From the parent directory, run `yarn start:example` or inside of example app directory `yarn start`. (Check the example app's `package.json` for additional options.) +3. Select the host platform you'd like to build for. +4. Once the build has completed, there should be a connection on the home screen of Reactotron from the example app. diff --git a/apps/example-app/app.json b/apps/example-app/app.json new file mode 100644 index 000000000..ab482f06f --- /dev/null +++ b/apps/example-app/app.json @@ -0,0 +1,77 @@ +{ + "name": "example-app", + "displayName": "example-app", + "expo": { + "name": "example-app", + "slug": "example-app", + "scheme": "example-app", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/app-icon-all.png", + "splash": { + "image": "./assets/images/splash-logo-all.png", + "resizeMode": "contain", + "backgroundColor": "#191015" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "jsEngine": "hermes", + "assetBundlePatterns": [ + "**/*" + ], + "android": { + "icon": "./assets/images/app-icon-android-legacy.png", + "package": "com.exampleapp", + "adaptiveIcon": { + "foregroundImage": "./assets/images/app-icon-android-adaptive-foreground.png", + "backgroundImage": "./assets/images/app-icon-android-adaptive-background.png" + }, + "splash": { + "image": "./assets/images/splash-logo-android-universal.png", + "resizeMode": "contain", + "backgroundColor": "#191015" + } + }, + "ios": { + "icon": "./assets/images/app-icon-ios.png", + "supportsTablet": true, + "bundleIdentifier": "com.exampleapp", + "splash": { + "image": "./assets/images/splash-logo-ios-mobile.png", + "tabletImage": "./assets/images/splash-logo-ios-tablet.png", + "resizeMode": "contain", + "backgroundColor": "#191015" + } + }, + "web": { + "favicon": "./assets/images/app-icon-web-favicon.png", + "splash": { + "image": "./assets/images/splash-logo-web.png", + "resizeMode": "contain", + "backgroundColor": "#191015" + }, + "bundler": "metro" + }, + "plugins": [ + "expo-localization", + [ + "expo-build-properties", + { + "ios": { + "newArchEnabled": false + }, + "android": { + "newArchEnabled": false + } + } + ] + ], + "experiments": { + "tsconfigPaths": true + } + }, + "ignite": { + "version": "9.4.0" + } +} \ No newline at end of file diff --git a/apps/example-app/app/app.tsx b/apps/example-app/app/app.tsx new file mode 100644 index 000000000..c968524de --- /dev/null +++ b/apps/example-app/app/app.tsx @@ -0,0 +1,117 @@ +/* eslint-disable import/first */ +/** + * Welcome to the main entry point of the app. In this file, we'll + * be kicking off our app. + * + * Most of this file is boilerplate and you shouldn't need to modify + * it very often. But take some time to look through and understand + * what is going on here. + * + * The app navigation resides in ./app/navigators, so head over there + * if you're interested in adding screens and navigators. + */ +if (__DEV__) { + // Load Reactotron configuration in development. We don't want to + // include this in our production bundle, so we are using `if (__DEV__)` + // to only execute this in development. + require("./devtools/ReactotronConfig.ts") +} +import "./i18n" +import "./utils/ignoreWarnings" +import { useFonts } from "expo-font" +import React from "react" +import { initialWindowMetrics, SafeAreaProvider } from "react-native-safe-area-context" +import * as Linking from "expo-linking" +import { useInitialRootStore } from "./models" +import { AppNavigator, useNavigationPersistence } from "./navigators" +import { ErrorBoundary } from "./screens/ErrorScreen/ErrorBoundary" +import * as storage from "./utils/storage" +import { customFontsToLoad } from "./theme" +import Config from "./config" +import { GestureHandlerRootView } from "react-native-gesture-handler" +import { ViewStyle } from "react-native" + +export const NAVIGATION_PERSISTENCE_KEY = "NAVIGATION_STATE" + +// Web linking configuration +const prefix = Linking.createURL("/") +const config = { + screens: { + Login: { + path: "", + }, + Welcome: "welcome", + Demo: { + screens: { + DemoShowroom: { + path: "showroom/:queryIndex?/:itemIndex?", + }, + DemoDebug: "debug", + DemoPodcastList: "podcast", + DemoCommunity: "community", + }, + }, + }, +} + +interface AppProps { + hideSplashScreen: () => Promise +} + +/** + * This is the root component of our app. + */ +function App(props: AppProps) { + const { hideSplashScreen } = props + const { + initialNavigationState, + onNavigationStateChange, + isRestored: isNavigationStateRestored, + } = useNavigationPersistence(storage, NAVIGATION_PERSISTENCE_KEY) + + const [areFontsLoaded] = useFonts(customFontsToLoad) + + const { rehydrated } = useInitialRootStore(() => { + // This runs after the root store has been initialized and rehydrated. + + // If your initialization scripts run very fast, it's good to show the splash screen for just a bit longer to prevent flicker. + // Slightly delaying splash screen hiding for better UX; can be customized or removed as needed, + // Note: (vanilla Android) The splash-screen will not appear if you launch your app via the terminal or Android Studio. Kill the app and launch it normally by tapping on the launcher icon. https://stackoverflow.com/a/69831106 + // Note: (vanilla iOS) You might notice the splash-screen logo change size. This happens in debug/development mode. Try building the app for release. + setTimeout(hideSplashScreen, 500) + }) + + // Before we show the app, we have to wait for our state to be ready. + // In the meantime, don't render anything. This will be the background + // color set in native by rootView's background color. + // In iOS: application:didFinishLaunchingWithOptions: + // In Android: https://stackoverflow.com/a/45838109/204044 + // You can replace with your own loading component if you wish. + if (!rehydrated || !isNavigationStateRestored || !areFontsLoaded) return null + + const linking = { + prefixes: [prefix], + config, + } + + // otherwise, we're ready to render the app + return ( + + + + + + + + ) +} + +export default App + +const $container: ViewStyle = { + flex: 1, +} diff --git a/apps/example-app/app/components/AutoImage.tsx b/apps/example-app/app/components/AutoImage.tsx new file mode 100644 index 000000000..bf2f7c89a --- /dev/null +++ b/apps/example-app/app/components/AutoImage.tsx @@ -0,0 +1,72 @@ +import React, { useLayoutEffect, useState } from "react" +import { Image, ImageProps, ImageURISource, Platform } from "react-native" + +// TODO: document new props +export interface AutoImageProps extends ImageProps { + /** + * How wide should the image be? + */ + maxWidth?: number + /** + * How tall should the image be? + */ + maxHeight?: number +} + +/** + * A hook that will return the scaled dimensions of an image based on the + * provided dimensions' aspect ratio. If no desired dimensions are provided, + * it will return the original dimensions of the remote image. + * + * How is this different from `resizeMode: 'contain'`? Firstly, you can + * specify only one side's size (not both). Secondly, the image will scale to fit + * the desired dimensions instead of just being contained within its image-container. + * + */ +export function useAutoImage( + remoteUri: string, + dimensions?: [maxWidth?: number, maxHeight?: number], +): [width: number, height: number] { + const [[remoteWidth, remoteHeight], setRemoteImageDimensions] = useState([0, 0]) + const remoteAspectRatio = remoteWidth / remoteHeight + const [maxWidth, maxHeight] = dimensions ?? [] + + useLayoutEffect(() => { + if (!remoteUri) return + + Image.getSize(remoteUri, (w, h) => setRemoteImageDimensions([w, h])) + }, [remoteUri]) + + if (Number.isNaN(remoteAspectRatio)) return [0, 0] + + if (maxWidth && maxHeight) { + const aspectRatio = Math.min(maxWidth / remoteWidth, maxHeight / remoteHeight) + return [remoteWidth * aspectRatio, remoteHeight * aspectRatio] + } else if (maxWidth) { + return [maxWidth, maxWidth / remoteAspectRatio] + } else if (maxHeight) { + return [maxHeight * remoteAspectRatio, maxHeight] + } else { + return [remoteWidth, remoteHeight] + } +} + +/** + * An Image component that automatically sizes a remote or data-uri image. + * + * - [Documentation and Examples](https://docs.infinite.red/ignite-cli/boilerplate/components/AutoImage/) + */ +export function AutoImage(props: AutoImageProps) { + const { maxWidth, maxHeight, ...ImageProps } = props + const source = props.source as ImageURISource + + const [width, height] = useAutoImage( + Platform.select({ + web: (source?.uri as string) ?? (source as string), + default: source?.uri as string, + }), + [maxWidth, maxHeight], + ) + + return +} diff --git a/apps/example-app/app/components/Button.tsx b/apps/example-app/app/components/Button.tsx new file mode 100644 index 000000000..6e43c5498 --- /dev/null +++ b/apps/example-app/app/components/Button.tsx @@ -0,0 +1,215 @@ +import React, { ComponentType } from "react" +import { + Pressable, + PressableProps, + PressableStateCallbackType, + StyleProp, + TextStyle, + ViewStyle, +} from "react-native" +import { colors, spacing, typography } from "../theme" +import { Text, TextProps } from "./Text" + +type Presets = keyof typeof $viewPresets + +export interface ButtonAccessoryProps { + style: StyleProp + pressableState: PressableStateCallbackType + disabled?: boolean +} + +export interface ButtonProps extends PressableProps { + /** + * Text which is looked up via i18n. + */ + tx?: TextProps["tx"] + /** + * The text to display if not using `tx` or nested components. + */ + text?: TextProps["text"] + /** + * Optional options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + txOptions?: TextProps["txOptions"] + /** + * An optional style override useful for padding & margin. + */ + style?: StyleProp + /** + * An optional style override for the "pressed" state. + */ + pressedStyle?: StyleProp + /** + * An optional style override for the button text. + */ + textStyle?: StyleProp + /** + * An optional style override for the button text when in the "pressed" state. + */ + pressedTextStyle?: StyleProp + /** + * An optional style override for the button text when in the "disabled" state. + */ + disabledTextStyle?: StyleProp + /** + * One of the different types of button presets. + */ + preset?: Presets + /** + * An optional component to render on the right side of the text. + * Example: `RightAccessory={(props) => }` + */ + RightAccessory?: ComponentType + /** + * An optional component to render on the left side of the text. + * Example: `LeftAccessory={(props) => }` + */ + LeftAccessory?: ComponentType + /** + * Children components. + */ + children?: React.ReactNode + /** + * disabled prop, accessed directly for declarative styling reasons. + * https://reactnative.dev/docs/pressable#disabled + */ + disabled?: boolean + /** + * An optional style override for the disabled state + */ + disabledStyle?: StyleProp +} + +/** + * A component that allows users to take actions and make choices. + * Wraps the Text component with a Pressable component. + * + * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Button.md) + */ +export function Button(props: ButtonProps) { + const { + tx, + text, + txOptions, + style: $viewStyleOverride, + pressedStyle: $pressedViewStyleOverride, + textStyle: $textStyleOverride, + pressedTextStyle: $pressedTextStyleOverride, + disabledTextStyle: $disabledTextStyleOverride, + children, + RightAccessory, + LeftAccessory, + disabled, + disabledStyle: $disabledViewStyleOverride, + ...rest + } = props + + const preset: Presets = props.preset ?? "default" + function $viewStyle({ pressed }: PressableStateCallbackType) { + return [ + $viewPresets[preset], + $viewStyleOverride, + !!pressed && [$pressedViewPresets[preset], $pressedViewStyleOverride], + !!disabled && $disabledViewStyleOverride, + ] + } + function $textStyle({ pressed }: PressableStateCallbackType) { + return [ + $textPresets[preset], + $textStyleOverride, + !!pressed && [$pressedTextPresets[preset], $pressedTextStyleOverride], + !!disabled && $disabledTextStyleOverride, + ] + } + + return ( + + {(state) => ( + <> + {!!LeftAccessory && ( + + )} + + + {children} + + + {!!RightAccessory && ( + + )} + + )} + + ) +} + +const $baseViewStyle: ViewStyle = { + minHeight: 56, + borderRadius: 4, + justifyContent: "center", + alignItems: "center", + flexDirection: "row", + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm, + overflow: "hidden", +} + +const $baseTextStyle: TextStyle = { + fontSize: 16, + lineHeight: 20, + fontFamily: typography.primary.medium, + textAlign: "center", + flexShrink: 1, + flexGrow: 0, + zIndex: 2, +} + +const $rightAccessoryStyle: ViewStyle = { marginStart: spacing.xs, zIndex: 1 } +const $leftAccessoryStyle: ViewStyle = { marginEnd: spacing.xs, zIndex: 1 } + +const $viewPresets = { + default: [ + $baseViewStyle, + { + borderWidth: 1, + borderColor: colors.palette.neutral400, + backgroundColor: colors.palette.neutral100, + }, + ] as StyleProp, + + filled: [$baseViewStyle, { backgroundColor: colors.palette.neutral300 }] as StyleProp, + + reversed: [ + $baseViewStyle, + { backgroundColor: colors.palette.neutral800 }, + ] as StyleProp, +} + +const $textPresets: Record> = { + default: $baseTextStyle, + filled: $baseTextStyle, + reversed: [$baseTextStyle, { color: colors.palette.neutral100 }], +} + +const $pressedViewPresets: Record> = { + default: { backgroundColor: colors.palette.neutral200 }, + filled: { backgroundColor: colors.palette.neutral400 }, + reversed: { backgroundColor: colors.palette.neutral700 }, +} + +const $pressedTextPresets: Record> = { + default: { opacity: 0.9 }, + filled: { opacity: 0.9 }, + reversed: { opacity: 0.9 }, +} diff --git a/apps/example-app/app/components/Card.tsx b/apps/example-app/app/components/Card.tsx new file mode 100644 index 000000000..66323f18c --- /dev/null +++ b/apps/example-app/app/components/Card.tsx @@ -0,0 +1,298 @@ +import React, { ComponentType, Fragment, ReactElement } from "react" +import { + StyleProp, + TextStyle, + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, + ViewStyle, +} from "react-native" +import { colors, spacing } from "../theme" +import { Text, TextProps } from "./Text" + +type Presets = keyof typeof $containerPresets + +interface CardProps extends TouchableOpacityProps { + /** + * One of the different types of text presets. + */ + preset?: Presets + /** + * How the content should be aligned vertically. This is especially (but not exclusively) useful + * when the card is a fixed height but the content is dynamic. + * + * `top` (default) - aligns all content to the top. + * `center` - aligns all content to the center. + * `space-between` - spreads out the content evenly. + * `force-footer-bottom` - aligns all content to the top, but forces the footer to the bottom. + */ + verticalAlignment?: "top" | "center" | "space-between" | "force-footer-bottom" + /** + * Custom component added to the left of the card body. + */ + LeftComponent?: ReactElement + /** + * Custom component added to the right of the card body. + */ + RightComponent?: ReactElement + /** + * The heading text to display if not using `headingTx`. + */ + heading?: TextProps["text"] + /** + * Heading text which is looked up via i18n. + */ + headingTx?: TextProps["tx"] + /** + * Optional heading options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + headingTxOptions?: TextProps["txOptions"] + /** + * Style overrides for heading text. + */ + headingStyle?: StyleProp + /** + * Pass any additional props directly to the heading Text component. + */ + HeadingTextProps?: TextProps + /** + * Custom heading component. + * Overrides all other `heading*` props. + */ + HeadingComponent?: ReactElement + /** + * The content text to display if not using `contentTx`. + */ + content?: TextProps["text"] + /** + * Content text which is looked up via i18n. + */ + contentTx?: TextProps["tx"] + /** + * Optional content options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + contentTxOptions?: TextProps["txOptions"] + /** + * Style overrides for content text. + */ + contentStyle?: StyleProp + /** + * Pass any additional props directly to the content Text component. + */ + ContentTextProps?: TextProps + /** + * Custom content component. + * Overrides all other `content*` props. + */ + ContentComponent?: ReactElement + /** + * The footer text to display if not using `footerTx`. + */ + footer?: TextProps["text"] + /** + * Footer text which is looked up via i18n. + */ + footerTx?: TextProps["tx"] + /** + * Optional footer options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + footerTxOptions?: TextProps["txOptions"] + /** + * Style overrides for footer text. + */ + footerStyle?: StyleProp + /** + * Pass any additional props directly to the footer Text component. + */ + FooterTextProps?: TextProps + /** + * Custom footer component. + * Overrides all other `footer*` props. + */ + FooterComponent?: ReactElement +} + +/** + * Cards are useful for displaying related information in a contained way. + * If a ListItem displays content horizontally, a Card can be used to display content vertically. + * + * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Card.md) + */ +export function Card(props: CardProps) { + const { + content, + contentTx, + contentTxOptions, + footer, + footerTx, + footerTxOptions, + heading, + headingTx, + headingTxOptions, + ContentComponent, + HeadingComponent, + FooterComponent, + LeftComponent, + RightComponent, + verticalAlignment = "top", + style: $containerStyleOverride, + contentStyle: $contentStyleOverride, + headingStyle: $headingStyleOverride, + footerStyle: $footerStyleOverride, + ContentTextProps, + HeadingTextProps, + FooterTextProps, + ...WrapperProps + } = props + + const preset: Presets = props.preset ?? "default" + const isPressable = !!WrapperProps.onPress + const isHeadingPresent = !!(HeadingComponent || heading || headingTx) + const isContentPresent = !!(ContentComponent || content || contentTx) + const isFooterPresent = !!(FooterComponent || footer || footerTx) + + const Wrapper = (isPressable ? TouchableOpacity : View) as ComponentType< + TouchableOpacityProps | ViewProps + > + const HeaderContentWrapper = verticalAlignment === "force-footer-bottom" ? View : Fragment + + const $containerStyle = [$containerPresets[preset], $containerStyleOverride] + const $headingStyle = [ + $headingPresets[preset], + (isFooterPresent || isContentPresent) && { marginBottom: spacing.xxxs }, + $headingStyleOverride, + HeadingTextProps?.style, + ] + const $contentStyle = [ + $contentPresets[preset], + isHeadingPresent && { marginTop: spacing.xxxs }, + isFooterPresent && { marginBottom: spacing.xxxs }, + $contentStyleOverride, + ContentTextProps?.style, + ] + const $footerStyle = [ + $footerPresets[preset], + (isHeadingPresent || isContentPresent) && { marginTop: spacing.xxxs }, + $footerStyleOverride, + FooterTextProps?.style, + ] + const $alignmentWrapperStyle = [ + $alignmentWrapper, + { justifyContent: $alignmentWrapperFlexOptions[verticalAlignment] }, + LeftComponent && { marginStart: spacing.md }, + RightComponent && { marginEnd: spacing.md }, + ] + + return ( + + {LeftComponent} + + + + {HeadingComponent || + (isHeadingPresent && ( + + ))} + + {ContentComponent || + (isContentPresent && ( + + ))} + + + {FooterComponent || + (isFooterPresent && ( + + ))} + + + {RightComponent} + + ) +} + +const $containerBase: ViewStyle = { + borderRadius: spacing.md, + padding: spacing.xs, + borderWidth: 1, + shadowColor: colors.palette.neutral800, + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 0.08, + shadowRadius: 12.81, + elevation: 16, + minHeight: 96, + flexDirection: "row", +} + +const $alignmentWrapper: ViewStyle = { + flex: 1, + alignSelf: "stretch", +} + +const $alignmentWrapperFlexOptions = { + top: "flex-start", + center: "center", + "space-between": "space-between", + "force-footer-bottom": "space-between", +} as const + +const $containerPresets = { + default: [ + $containerBase, + { + backgroundColor: colors.palette.neutral100, + borderColor: colors.palette.neutral300, + }, + ] as StyleProp, + + reversed: [ + $containerBase, + { backgroundColor: colors.palette.neutral800, borderColor: colors.palette.neutral500 }, + ] as StyleProp, +} + +const $headingPresets: Record = { + default: {}, + reversed: { color: colors.palette.neutral100 }, +} + +const $contentPresets: Record = { + default: {}, + reversed: { color: colors.palette.neutral100 }, +} + +const $footerPresets: Record = { + default: {}, + reversed: { color: colors.palette.neutral100 }, +} diff --git a/apps/example-app/app/components/EmptyState.tsx b/apps/example-app/app/components/EmptyState.tsx new file mode 100644 index 000000000..5f78ed180 --- /dev/null +++ b/apps/example-app/app/components/EmptyState.tsx @@ -0,0 +1,226 @@ +import React from "react" +import { Image, ImageProps, ImageStyle, StyleProp, TextStyle, View, ViewStyle } from "react-native" +import { translate } from "../i18n" +import { spacing } from "../theme" +import { Button, ButtonProps } from "./Button" +import { Text, TextProps } from "./Text" + +const sadFace = require("../../assets/images/sad-face.png") + +interface EmptyStateProps { + /** + * An optional prop that specifies the text/image set to use for the empty state. + */ + preset?: keyof typeof EmptyStatePresets + /** + * Style override for the container. + */ + style?: StyleProp + /** + * An Image source to be displayed above the heading. + */ + imageSource?: ImageProps["source"] + /** + * Style overrides for image. + */ + imageStyle?: StyleProp + /** + * Pass any additional props directly to the Image component. + */ + ImageProps?: Omit + /** + * The heading text to display if not using `headingTx`. + */ + heading?: TextProps["text"] + /** + * Heading text which is looked up via i18n. + */ + headingTx?: TextProps["tx"] + /** + * Optional heading options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + headingTxOptions?: TextProps["txOptions"] + /** + * Style overrides for heading text. + */ + headingStyle?: StyleProp + /** + * Pass any additional props directly to the heading Text component. + */ + HeadingTextProps?: TextProps + /** + * The content text to display if not using `contentTx`. + */ + content?: TextProps["text"] + /** + * Content text which is looked up via i18n. + */ + contentTx?: TextProps["tx"] + /** + * Optional content options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + contentTxOptions?: TextProps["txOptions"] + /** + * Style overrides for content text. + */ + contentStyle?: StyleProp + /** + * Pass any additional props directly to the content Text component. + */ + ContentTextProps?: TextProps + /** + * The button text to display if not using `buttonTx`. + */ + button?: TextProps["text"] + /** + * Button text which is looked up via i18n. + */ + buttonTx?: TextProps["tx"] + /** + * Optional button options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + buttonTxOptions?: TextProps["txOptions"] + /** + * Style overrides for button. + */ + buttonStyle?: ButtonProps["style"] + /** + * Style overrides for button text. + */ + buttonTextStyle?: ButtonProps["textStyle"] + /** + * Called when the button is pressed. + */ + buttonOnPress?: ButtonProps["onPress"] + /** + * Pass any additional props directly to the Button component. + */ + ButtonProps?: ButtonProps +} + +interface EmptyStatePresetItem { + imageSource: ImageProps["source"] + heading: TextProps["text"] + content: TextProps["text"] + button: TextProps["text"] +} + +const EmptyStatePresets = { + generic: { + imageSource: sadFace, + heading: translate("emptyStateComponent.generic.heading"), + content: translate("emptyStateComponent.generic.content"), + button: translate("emptyStateComponent.generic.button"), + } as EmptyStatePresetItem, +} as const + +/** + * A component to use when there is no data to display. It can be utilized to direct the user what to do next. + * + * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-EmptyState.md) + */ +export function EmptyState(props: EmptyStateProps) { + const preset = EmptyStatePresets[props.preset ?? "generic"] + + const { + button = preset.button, + buttonTx, + buttonOnPress, + buttonTxOptions, + content = preset.content, + contentTx, + contentTxOptions, + heading = preset.heading, + headingTx, + headingTxOptions, + imageSource = preset.imageSource, + style: $containerStyleOverride, + buttonStyle: $buttonStyleOverride, + buttonTextStyle: $buttonTextStyleOverride, + contentStyle: $contentStyleOverride, + headingStyle: $headingStyleOverride, + imageStyle: $imageStyleOverride, + ButtonProps, + ContentTextProps, + HeadingTextProps, + ImageProps, + } = props + + const isImagePresent = !!imageSource + const isHeadingPresent = !!(heading || headingTx) + const isContentPresent = !!(content || contentTx) + const isButtonPresent = !!(button || buttonTx) + + const $containerStyles = [$containerStyleOverride] + const $imageStyles = [ + $image, + (isHeadingPresent || isContentPresent || isButtonPresent) && { marginBottom: spacing.xxxs }, + $imageStyleOverride, + ImageProps?.style, + ] + const $headingStyles = [ + $heading, + isImagePresent && { marginTop: spacing.xxxs }, + (isContentPresent || isButtonPresent) && { marginBottom: spacing.xxxs }, + $headingStyleOverride, + HeadingTextProps?.style, + ] + const $contentStyles = [ + $content, + (isImagePresent || isHeadingPresent) && { marginTop: spacing.xxxs }, + isButtonPresent && { marginBottom: spacing.xxxs }, + $contentStyleOverride, + ContentTextProps?.style, + ] + const $buttonStyles = [ + (isImagePresent || isHeadingPresent || isContentPresent) && { marginTop: spacing.xl }, + $buttonStyleOverride, + ButtonProps?.style, + ] + + return ( + + {isImagePresent && } + + {isHeadingPresent && ( + + )} + + {isContentPresent && ( + + )} + + {isButtonPresent && ( +