diff --git a/app/component-library/components/ButtonPrimary/index.ts b/app/component-library/components/ButtonPrimary/index.ts index 94612932ed1..59027b5a078 100644 --- a/app/component-library/components/ButtonPrimary/index.ts +++ b/app/component-library/components/ButtonPrimary/index.ts @@ -1 +1,2 @@ export { default } from './ButtonPrimary'; +export { ButtonPrimaryVariant } from './ButtonPrimary.types'; diff --git a/app/component-library/components/ButtonSecondary/index.ts b/app/component-library/components/ButtonSecondary/index.ts index 51b0e227aaa..4d54c5a57be 100644 --- a/app/component-library/components/ButtonSecondary/index.ts +++ b/app/component-library/components/ButtonSecondary/index.ts @@ -1 +1,2 @@ export { default } from './ButtonSecondary'; +export { ButtonSecondaryVariant } from './ButtonSecondary.types'; diff --git a/app/component-library/components/ButtonTertiary/index.ts b/app/component-library/components/ButtonTertiary/index.ts index 8e60a7b1d5f..95ab2c9f70a 100644 --- a/app/component-library/components/ButtonTertiary/index.ts +++ b/app/component-library/components/ButtonTertiary/index.ts @@ -1 +1,2 @@ export { default } from './ButtonTertiary'; +export { ButtonTertiaryVariant } from './ButtonTertiary.types'; diff --git a/app/component-library/components/Toast/README.md b/app/component-library/components/Toast/README.md new file mode 100644 index 00000000000..de3c7cfe626 --- /dev/null +++ b/app/component-library/components/Toast/README.md @@ -0,0 +1,80 @@ +# Toast + +Toast is a component that slides up from the bottom. It is typically used to show post confirmation information. + +## Methods + +### `showToast()` + +```javascript +showToast(toastOptions: ToastOptions) +``` + +| PARAMETERS | TYPE | DESCRIPTION | +| :-------------------------------------------------------- | :-------------------------------------------------- | :--------------------------------------------------------- | +| toastOptions | [ToastOptions](./Toast.types.ts#L36) | Toast options to show. | + +## Use Case + +Using this component requires a three step process: + +1. Wrap a root element with `ToastContextWrapper`. + +```javascript +// Replace import with relative path. +import { ToastContextWrapper } from 'app/component-library/components/Toast'; + +const App = () => ( + + + +); +``` + +2. Implement `Toast` component within a child of the root element and apply `toastRef` from `ToastContext`. + +```javascript +// Replace import with relative path. +import Toast, { ToastContext } from 'app/component-library/components/Toast'; + +const Root = () => { + const { toastRef } = useContext(ToastContext); + + return ( + <> + + + + ); +}; +``` + +3. Reference `toastRef` and call `toastRef.current?.showToast(...)` to show toast. + +```javascript +// Replace import with relative path. +import Toast, { ToastContext } from 'app/component-library/components/Toast'; + +const NestedComponent = () => { + const { toastRef } = useContext(ToastContext); + + const showToast = () => { + // Example of showing toast with Account variant. + toastRef.current?.showToast({ + variant: ToastVariant.Account, + labelOptions: [ + { label: 'Switching to' }, + { label: ' Account 2.', isBold: true }, + ], + accountAddress: + '0x10e08af911f2e489480fb2855b24771745d0198b50f5c55891369844a8c57092', + linkOption: { + label: 'Click here.', + onPress: () => {}, + }, + }); + }; + + return ; +}; +``` diff --git a/app/component-library/components/Toast/Toast.context.tsx b/app/component-library/components/Toast/Toast.context.tsx new file mode 100644 index 00000000000..7fa9bd863a7 --- /dev/null +++ b/app/component-library/components/Toast/Toast.context.tsx @@ -0,0 +1,16 @@ +/* eslint-disable react/prop-types */ +import React, { useRef } from 'react'; +import { ToastRef, ToastContextParams } from './Toast.types'; + +export const ToastContext = React.createContext({ + toastRef: undefined, +}); + +export const ToastContextWrapper: React.FC = ({ children }) => { + const toastRef = useRef(null); + return ( + + {children} + + ); +}; diff --git a/app/component-library/components/Toast/Toast.stories.tsx b/app/component-library/components/Toast/Toast.stories.tsx new file mode 100644 index 00000000000..81cd579e265 --- /dev/null +++ b/app/component-library/components/Toast/Toast.stories.tsx @@ -0,0 +1,79 @@ +/* eslint-disable no-console */ +import React, { useContext } from 'react'; +import { Alert } from 'react-native'; +import { storiesOf } from '@storybook/react-native'; +import Toast from './Toast'; +import { ToastContext, ToastContextWrapper } from './Toast.context'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { ToastVariant } from './Toast.types'; +import { BaseButtonSize } from '../BaseButton'; +import ButtonTertiary, { ButtonTertiaryVariant } from '../ButtonTertiary'; + +const ToastExample = () => { + const { toastRef } = useContext(ToastContext); + + return ( + <> + { + toastRef?.current?.showToast({ + variant: ToastVariant.Account, + labelOptions: [ + { label: 'Switching to' }, + { label: ' Account 2.', isBold: true }, + ], + accountAddress: + '0x10e08af911f2e489480fb2855b24771745d0198b50f5c55891369844a8c57092', + }); + }} + /> + { + toastRef?.current?.showToast({ + variant: ToastVariant.Network, + labelOptions: [ + { label: 'Added' }, + { label: ' Mainnet', isBold: true }, + { label: ' network.' }, + ], + networkImageUrl: + 'https://assets.coingecko.com/coins/images/279/small/ethereum.png?1595348880', + linkOption: { + label: 'Click here!', + onPress: () => { + Alert.alert('Clicked toast link!'); + }, + }, + }); + }} + /> + { + toastRef?.current?.showToast({ + variant: ToastVariant.Plain, + labelOptions: [{ label: 'This is a plain message.' }], + }); + }} + /> + + + + ); +}; + +storiesOf('Component Library / Toast', module) + .addDecorator((storyFn) => ( + + {storyFn()} + + )) + .add('Default', () => ); diff --git a/app/component-library/components/Toast/Toast.styles.ts b/app/component-library/components/Toast/Toast.styles.ts new file mode 100644 index 00000000000..96cd29b770f --- /dev/null +++ b/app/component-library/components/Toast/Toast.styles.ts @@ -0,0 +1,36 @@ +import { StyleSheet, Dimensions } from 'react-native'; +import { darkTheme } from '@metamask/design-tokens'; + +const { colors } = darkTheme; +const marginWidth = 16; +const padding = 16; +const toastWidth = Dimensions.get('window').width - marginWidth * 2; + +/** + * Style sheet for Toast component. + * + * @returns StyleSheet object. + */ +const styleSheet = StyleSheet.create({ + base: { + position: 'absolute', + width: toastWidth, + left: marginWidth, + bottom: 0, + backgroundColor: colors.background.alternative, + borderRadius: 4, + padding, + flexDirection: 'row', + }, + avatar: { + marginRight: 8, + }, + labelsContainer: { + justifyContent: 'center', + }, + label: { + color: colors.text.default, + }, +}); + +export default styleSheet; diff --git a/app/component-library/components/Toast/Toast.tsx b/app/component-library/components/Toast/Toast.tsx new file mode 100644 index 00000000000..275b51ed72d --- /dev/null +++ b/app/component-library/components/Toast/Toast.tsx @@ -0,0 +1,171 @@ +/* eslint-disable react/prop-types */ +import React, { + forwardRef, + useImperativeHandle, + useMemo, + useState, +} from 'react'; +import { + Dimensions, + LayoutChangeEvent, + StyleProp, + View, + ViewStyle, +} from 'react-native'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from 'react-native-reanimated'; +import AccountAvatar, { AccountAvatarType } from '../AccountAvatar'; +import { BaseAvatarSize } from '../BaseAvatar'; +import BaseText, { BaseTextVariant } from '../BaseText'; +import Link from '../Link'; +import styles from './Toast.styles'; +import { + ToastLabelOptions, + ToastLinkOption, + ToastOptions, + ToastRef, + ToastVariant, +} from './Toast.types'; +import NetworkAvatar from '../NetworkAvatar'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const visibilityDuration = 2500; +const animationDuration = 250; +const bottomPadding = 16; +const screenHeight = Dimensions.get('window').height; + +const Toast = forwardRef((_, ref: React.ForwardedRef) => { + const [toastOptions, setToastOptions] = useState( + undefined, + ); + const { bottom: bottomNotchSpacing } = useSafeAreaInsets(); + const translateYProgress = useSharedValue(screenHeight); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateYProgress.value }], + })); + const baseStyle: StyleProp>> = + useMemo( + () => [styles.base, animatedStyle], + /* eslint-disable-next-line */ + [], + ); + + const showToast = (options: ToastOptions) => { + if (toastOptions) { + return; + } + setToastOptions(options); + }; + + useImperativeHandle(ref, () => ({ + showToast, + })); + + const resetState = () => setToastOptions(undefined); + + const onAnimatedViewLayout = (e: LayoutChangeEvent) => { + if (toastOptions) { + const { height } = e.nativeEvent.layout; + const translateYToValue = -(bottomPadding + bottomNotchSpacing); + + translateYProgress.value = height; + translateYProgress.value = withTiming( + translateYToValue, + { duration: animationDuration }, + () => + (translateYProgress.value = withDelay( + visibilityDuration, + withTiming( + height, + { duration: animationDuration }, + runOnJS(resetState), + ), + )), + ); + } + }; + + const renderLabel = (labelOptions: ToastLabelOptions) => ( + + {labelOptions.map(({ label, isBold }, index) => ( + + {label} + + ))} + + ); + + const renderLink = (linkOption?: ToastLinkOption) => + linkOption ? ( + + {linkOption.label} + + ) : null; + + const renderAvatar = () => { + switch (toastOptions?.variant) { + case ToastVariant.Plain: + return null; + case ToastVariant.Account: { + const { accountAddress } = toastOptions; + return ( + + ); + } + case ToastVariant.Network: { + { + const { networkImageUrl } = toastOptions; + return ( + + ); + } + } + } + }; + + const renderToastContent = (options: ToastOptions) => { + const { labelOptions, linkOption } = options; + + return ( + <> + {renderAvatar()} + + {renderLabel(labelOptions)} + {renderLink(linkOption)} + + + ); + }; + + if (!toastOptions) { + return null; + } + + return ( + + {renderToastContent(toastOptions)} + + ); +}); + +export default Toast; diff --git a/app/component-library/components/Toast/Toast.types.ts b/app/component-library/components/Toast/Toast.types.ts new file mode 100644 index 00000000000..efdd57049b7 --- /dev/null +++ b/app/component-library/components/Toast/Toast.types.ts @@ -0,0 +1,47 @@ +export enum ToastVariant { + Plain = 'Plain', + Account = 'Account', + Network = 'Network', +} + +export type ToastLabelOptions = { + label: string; + isBold?: boolean; +}[]; + +export interface ToastLinkOption { + label: string; + onPress: () => void; +} + +interface BaseToastVariant { + labelOptions: ToastLabelOptions; + linkOption?: ToastLinkOption; +} + +interface PlainToastOption extends BaseToastVariant { + variant: ToastVariant.Plain; +} + +interface AccountToastOption extends BaseToastVariant { + variant: ToastVariant.Account; + accountAddress: string; +} + +interface NetworkToastOption extends BaseToastVariant { + variant: ToastVariant.Network; + networkImageUrl: string; +} + +export type ToastOptions = + | PlainToastOption + | AccountToastOption + | NetworkToastOption; + +export interface ToastRef { + showToast: (toastOptions: ToastOptions) => void; +} + +export interface ToastContextParams { + toastRef: React.RefObject | undefined; +} diff --git a/app/component-library/components/Toast/index.ts b/app/component-library/components/Toast/index.ts new file mode 100644 index 00000000000..242ffd87421 --- /dev/null +++ b/app/component-library/components/Toast/index.ts @@ -0,0 +1,3 @@ +export { default } from './Toast'; +export { ToastVariant } from './Toast.types'; +export { ToastContext, ToastContextWrapper } from './Toast.context'; diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 8a147a4b22a..6f8658cacfd 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { NavigationContainer, CommonActions } from '@react-navigation/native'; import { Animated, Linking } from 'react-native'; import { createStackNavigator } from '@react-navigation/stack'; @@ -44,6 +50,9 @@ import { mockTheme, useAppThemeFromContext } from '../../../util/theme'; import Device from '../../../util/device'; import { colors as importedColors } from '../../../styles/common'; import Routes from '../../../constants/navigation/Routes'; +import Toast, { + ToastContext, +} from '../../../component-library/components/Toast'; const Stack = createStackNavigator(); /** @@ -149,6 +158,7 @@ const App = ({ userLoggedIn }) => { const [route, setRoute] = useState(); const [animationPlayed, setAnimationPlayed] = useState(); const { colors } = useAppThemeFromContext() || mockTheme; + const { toastRef } = useContext(ToastContext); const isAuthChecked = useSelector((state) => state.user.isAuthChecked); const dispatch = useDispatch(); @@ -386,6 +396,7 @@ const App = ({ userLoggedIn }) => { {renderSplash()} + )) || null diff --git a/app/components/Views/Root/index.js b/app/components/Views/Root/index.js index 254d52faa7b..d36cebae9ba 100644 --- a/app/components/Views/Root/index.js +++ b/app/components/Views/Root/index.js @@ -10,6 +10,8 @@ import EntryScriptWeb3 from '../../../core/EntryScriptWeb3'; import Logger from '../../../util/Logger'; import ErrorBoundary from '../ErrorBoundary'; import { useAppTheme, ThemeContext } from '../../../util/theme'; +import { ToastContextWrapper } from '../../../component-library/components/Toast'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; /** * Top level of the component hierarchy @@ -52,10 +54,14 @@ const ConnectedRoot = () => { const theme = useAppTheme(); return ( - - - - - + + + + + + + + + ); }; diff --git a/storybook/storyLoader.js b/storybook/storyLoader.js index b5b2b1b56b7..cac8ec221d5 100644 --- a/storybook/storyLoader.js +++ b/storybook/storyLoader.js @@ -34,6 +34,7 @@ function loadStories() { require('../app/component-library/components/TabBarItem/TabBarItem.stories'); require('../app/component-library/components/Tag/Tag.stories'); require('../app/component-library/components/TagUrl/TagUrl.stories'); + require('../app/component-library/components/Toast/Toast.stories'); require('../app/component-library/components/TokenAvatar/TokenAvatar.stories'); } @@ -68,6 +69,7 @@ const stories = [ '../app/component-library/components/TabBarItem/TabBarItem.stories', '../app/component-library/components/Tag/Tag.stories', '../app/component-library/components/TagUrl/TagUrl.stories', + '../app/component-library/components/Toast/Toast.stories', '../app/component-library/components/TokenAvatar/TokenAvatar.stories', ];