Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Component/4087 toast #4676

Merged
merged 11 commits into from
Jul 21, 2022
1 change: 1 addition & 0 deletions app/component-library/components/ButtonPrimary/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default } from './ButtonPrimary';
export { ButtonPrimaryVariant } from './ButtonPrimary.types';
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default } from './ButtonSecondary';
export { ButtonSecondaryVariant } from './ButtonSecondary.types';
1 change: 1 addition & 0 deletions app/component-library/components/ButtonTertiary/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default } from './ButtonTertiary';
export { ButtonTertiaryVariant } from './ButtonTertiary.types';
80 changes: 80 additions & 0 deletions app/component-library/components/Toast/README.md
Original file line number Diff line number Diff line change
@@ -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)
```

| <span style="color:gray;font-size:14px">PARAMETERS</span> | <span style="color:gray;font-size:14px">TYPE</span> | <span style="color:gray;font-size:14px">DESCRIPTION</span> |
| :-------------------------------------------------------- | :-------------------------------------------------- | :--------------------------------------------------------- |
| 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 = () => (
<ToastContextWrapper>
<Root />
</ToastContextWrapper>
);
```

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 (
<>
<YourContent />
<Toast ref={toastRef} />
</>
);
};
```

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 <TouchableOpacity onPress={showToast} />;
};
```
16 changes: 16 additions & 0 deletions app/component-library/components/Toast/Toast.context.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastContextParams>({
toastRef: undefined,
});

export const ToastContextWrapper: React.FC = ({ children }) => {
const toastRef = useRef<ToastRef | null>(null);
return (
<ToastContext.Provider value={{ toastRef }}>
{children}
</ToastContext.Provider>
);
};
79 changes: 79 additions & 0 deletions app/component-library/components/Toast/Toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ButtonTertiary
variant={ButtonTertiaryVariant.Normal}
size={BaseButtonSize.Md}
label={'Show Account Toast'}
onPress={() => {
toastRef?.current?.showToast({
variant: ToastVariant.Account,
labelOptions: [
{ label: 'Switching to' },
{ label: ' Account 2.', isBold: true },
],
accountAddress:
'0x10e08af911f2e489480fb2855b24771745d0198b50f5c55891369844a8c57092',
});
}}
/>
<ButtonTertiary
variant={ButtonTertiaryVariant.Normal}
size={BaseButtonSize.Md}
label={'Show Network Toast'}
onPress={() => {
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!');
},
},
});
}}
/>
<ButtonTertiary
variant={ButtonTertiaryVariant.Normal}
size={BaseButtonSize.Md}
label={'Show Plain Toast'}
onPress={() => {
toastRef?.current?.showToast({
variant: ToastVariant.Plain,
labelOptions: [{ label: 'This is a plain message.' }],
});
}}
/>

<Toast ref={toastRef} />
</>
);
};

storiesOf('Component Library / Toast', module)
.addDecorator((storyFn) => (
<SafeAreaProvider>
<ToastContextWrapper>{storyFn()}</ToastContextWrapper>
</SafeAreaProvider>
))
.add('Default', () => <ToastExample />);
36 changes: 36 additions & 0 deletions app/component-library/components/Toast/Toast.styles.ts
Original file line number Diff line number Diff line change
@@ -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,
jpcloureiro marked this conversation as resolved.
Show resolved Hide resolved
borderRadius: 4,
padding,
flexDirection: 'row',
},
avatar: {
marginRight: 8,
},
labelsContainer: {
justifyContent: 'center',
},
label: {
color: colors.text.default,
},
});

export default styleSheet;
171 changes: 171 additions & 0 deletions app/component-library/components/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastRef>) => {
const [toastOptions, setToastOptions] = useState<ToastOptions | undefined>(
undefined,
);
const { bottom: bottomNotchSpacing } = useSafeAreaInsets();
const translateYProgress = useSharedValue(screenHeight);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateYProgress.value }],
}));
const baseStyle: StyleProp<Animated.AnimateStyle<StyleProp<ViewStyle>>> =
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) => (
<BaseText variant={BaseTextVariant.sBodyMD}>
{labelOptions.map(({ label, isBold }, index) => (
<BaseText
key={`toast-label-${index}`}
variant={
isBold ? BaseTextVariant.sBodyMDBold : BaseTextVariant.sBodyMD
}
style={styles.label}
>
{label}
</BaseText>
))}
</BaseText>
);

const renderLink = (linkOption?: ToastLinkOption) =>
linkOption ? (
<Link onPress={linkOption.onPress} variant={BaseTextVariant.sBodyMD}>
{linkOption.label}
</Link>
) : null;

const renderAvatar = () => {
switch (toastOptions?.variant) {
Cal-L marked this conversation as resolved.
Show resolved Hide resolved
case ToastVariant.Plain:
return null;
case ToastVariant.Account: {
const { accountAddress } = toastOptions;
return (
<AccountAvatar
accountAddress={accountAddress}
type={AccountAvatarType.JazzIcon}
size={BaseAvatarSize.Md}
style={styles.avatar}
/>
);
}
case ToastVariant.Network: {
{
const { networkImageUrl } = toastOptions;
return (
<NetworkAvatar
networkImageUrl={networkImageUrl}
size={BaseAvatarSize.Md}
style={styles.avatar}
/>
);
}
}
}
};

const renderToastContent = (options: ToastOptions) => {
const { labelOptions, linkOption } = options;

return (
<>
{renderAvatar()}
<View style={styles.labelsContainer}>
{renderLabel(labelOptions)}
{renderLink(linkOption)}
</View>
</>
);
};

if (!toastOptions) {
return null;
}

return (
<Animated.View onLayout={onAnimatedViewLayout} style={baseStyle}>
{renderToastContent(toastOptions)}
</Animated.View>
);
});

export default Toast;
Loading