diff --git a/jest.setup.js b/jest.setup.js
index d0ec5200..15fc7d82 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -1,5 +1,5 @@
/* eslint-disable import/no-extraneous-dependencies */
-import 'jest-dom/extend-expect';
+import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import * as emotion from 'emotion';
import { createSerializer, matchers as emotionMatchers } from 'jest-emotion';
diff --git a/package.json b/package.json
index af08ad96..033c3b4e 100644
--- a/package.json
+++ b/package.json
@@ -66,9 +66,10 @@
"@storybook/addon-options": "^5.2.8",
"@storybook/addons": "^5.2.8",
"@storybook/react": "^5.2.8",
- "@testing-library/react": "^8.0.1",
+ "@testing-library/react": "^10.0.5",
+ "@testing-library/jest-dom": "^5.9.0",
"@types/classnames": "^2.2.7",
- "@types/jest": "^24.0.15",
+ "@types/jest": "^25.2.3",
"@types/lodash": "^4.14.123",
"@types/luxon": "^1.15.2",
"@types/react": "^16.8.23",
@@ -100,8 +101,7 @@
"htmltojsx": "^0.3.0",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
- "jest": "^24.5.0",
- "jest-dom": "^3.0.0",
+ "jest": "^26.0.1",
"jest-emotion": "^10.0.0",
"json-loader": "^0.5.7",
"lerna": "^3.10.2",
diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md
index 76cb349d..4ea2382f 100644
--- a/packages/flame/CHANGELOG.md
+++ b/packages/flame/CHANGELOG.md
@@ -21,6 +21,7 @@ Refer to the [CONTRIBUTING guide](https://github.com/lightspeed/flame/blob/maste
- New AlertInCard component ([#82](https://github.com/lightspeed/flame/pull/82))
- Alert component will now automatically inject the right icons as per DSD specs ([#82](https://github.com/lightspeed/flame/pull/82))
+- New Toaster component ([#86](https://github.com/lightspeed/flame/pull/86))
### Fixed
diff --git a/packages/flame/package.json b/packages/flame/package.json
index 09258746..2b76a26b 100644
--- a/packages/flame/package.json
+++ b/packages/flame/package.json
@@ -40,11 +40,13 @@
"dependencies": {
"@styled-system/css": "^5.1.5",
"@styled-system/theme-get": "5.0.16",
+ "@types/react-toast-notifications": "^2.4.0",
"@types/styled-system": "5.1.6",
"polished": "^2.3.0",
"popper.js": "^1.15.0",
"react-modal": "^3.5.1",
"react-select": "^2.0.0",
+ "react-toast-notifications": "^2.4.0",
"styled-system": "5.1.4",
"type-fest": "^0.3.0"
},
diff --git a/packages/flame/src/Dialog/Dialog.test.tsx b/packages/flame/src/Dialog/Dialog.test.tsx
index b4b3c007..77a38f01 100644
--- a/packages/flame/src/Dialog/Dialog.test.tsx
+++ b/packages/flame/src/Dialog/Dialog.test.tsx
@@ -115,7 +115,7 @@ describe('', () => {
const { getByRole } = customRender(
,
);
- const modalEl = getByRole('dialog');
+ const modalEl = getByRole('dialog', { hidden: true });
const modalStyles = window.getComputedStyle(modalEl);
expect(modalStyles.getPropertyValue('max-width')).toEqual('500px');
@@ -124,7 +124,7 @@ describe('', () => {
it('should allow to override the defaults', () => {
const maxWidth = '6969px';
const { getByRole } = customRender();
- const modalEl = getByRole('dialog');
+ const modalEl = getByRole('dialog', { hidden: true });
const modalStyles = window.getComputedStyle(modalEl);
expect(modalStyles.getPropertyValue('max-width')).toEqual(maxWidth);
diff --git a/packages/flame/src/Toaster/README.md b/packages/flame/src/Toaster/README.md
new file mode 100644
index 00000000..d6e448c3
--- /dev/null
+++ b/packages/flame/src/Toaster/README.md
@@ -0,0 +1,109 @@
+# Toaster
+
+Toaster inform users on the outcome of an action. They appear temporarily, towards the bottom of the screen. They shouldn’t interrupt the user experience, and they don’t require user input to disappear.
+
+This component uses the [react-toast-notifications](https://github.com/jossmac/react-toast-notifications) library.
+
+## Usage
+
+First, wrap your application with the `` component.
+
+```jsx
+// App.js
+import React from 'react';
+import { FlameTheme, FlameGlobalStyles } from '@lightspeed/flame/Core';
+import { ToasterProvider } from '@lightspeed/flame/Toaster';
+
+const App = () => (
+
+
+
+
{/* The rest of your app */}
+
+
+);
+```
+
+Once that is done, you may use the provided hooks to generate a toast notification.
+
+```jsx
+// MyComponent.js
+import * as React from 'react';
+import { useToast } from '@lightspeed/flame/Toaster';
+
+const MyComponent = () => {
+ const { addToast } = useToasts();
+ return (
+
+ );
+};
+```
+
+## Components
+
+### ``
+
+A pre-configured `ToastProvider` from the [react-toast-notifications](https://github.com/jossmac/react-toast-notifications) library.
+
+Please consult its documentation for a full list of all the props available.
+
+### `useToast()`
+
+An augmented hook of the original `useToast` found in [react-toast-notifications](https://github.com/jossmac/react-toast-notifications).
+
+The `useToast` hook has the following signature:
+
+```jsx
+const {
+ addToast,
+ addActionableToast,
+ removeToast,
+ removeAllToasts,
+ updateToast,
+ toastStack,
+} = useToasts();
+```
+
+The `addToast` method has three arguments:
+
+1. The first is the content of the toast, which can be any renderable `Node`.
+1. The second is the `Options` object, which can take any shape you like. `Options.appearance` is required when using the `DefaultToast`. When departing from the default shape, you must provide an alternative, compliant `Toast` component.
+1. The third is an optional callback, which is passed the added toast `ID`.
+
+The `addActionableToast` method has three arguments:
+
+1. The first is the `ActionableContent` object, which requires 3 properties to be filled. `ActionableContent.content` is the content of the toast, which can be any renderable `Node`. `ActionableContent.actionTitle` is the string that'll be shown for the action button. `ActionableContent.actionCallback` is the function that will be executed when the action button is clicked.
+1. The second is the `Options` object, which can take any shape you like. `Options.appearance` is required when using the `DefaultToast`. When departing from the default shape, you must provide an alternative, compliant `Toast` component.
+1. The third is an optional callback, which is passed the added toast `ID`.
+
+The `removeToast` method has two arguments:
+
+1. The first is the `ID` of the toast to remove.
+1. The second is an optional callback.
+
+The `removeAllToasts` method has no arguments.
+
+The `updateToast` method has three arguments:
+
+1. The first is the `ID` of the toast to update.
+1. The second is the `Options` object, which differs slightly from the add method because it accepts a `content` property.
+1. The third is an optional callback, which is passed the updated toast `ID`.
+
+The `toastStack` is an array of objects representing the current toasts, e.g.
+
+```jsx
+[
+ { content: 'Something went wrong', id: 'generated-string', appearance: 'error' },
+ { content: 'Item saved', id: 'generated-string', appearance: 'success' },
+];
+```
diff --git a/packages/flame/src/Toaster/Toaster.test.tsx b/packages/flame/src/Toaster/Toaster.test.tsx
new file mode 100644
index 00000000..0111460e
--- /dev/null
+++ b/packages/flame/src/Toaster/Toaster.test.tsx
@@ -0,0 +1,114 @@
+import * as React from 'react';
+import { customRender, screen, fireEvent, waitFor } from 'test-utils';
+import { ToasterProvider, useToasts } from './index';
+
+const TestAddToast: React.FC<{ appearance: 'success' | 'error'; autoDismiss: boolean }> = ({
+ appearance,
+ autoDismiss,
+}) => {
+ const { addToast } = useToasts();
+ return (
+
+ );
+};
+
+const TestAddActionableToast: React.FC<{ actionCallback: () => void }> = ({ actionCallback }) => {
+ const { addActionableToast } = useToasts();
+ return (
+
+ );
+};
+
+describe('Toaster', () => {
+ describe('without auto-dismss set to false', () => {
+ it('should render out a toaster', async () => {
+ customRender(
+
+
+ ,
+ );
+
+ const toaster = await screen.queryByRole('alert');
+ expect(toaster).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByRole('alert')).toHaveTextContent(/this is a message/);
+ fireEvent.click(screen.getByLabelText('Dismiss toast'));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should render out a success toaster with a custom action', async () => {
+ const spy = jest.fn();
+ customRender(
+
+
+ ,
+ );
+
+ const toaster = await screen.queryByRole('alert');
+ expect(toaster).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('create toast'));
+ expect(screen.getByRole('alert')).toHaveTextContent(/actionable toast/);
+
+ fireEvent.click(screen.getByLabelText('action-title'));
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByLabelText('Dismiss toast'));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ // We have fake timers running here, isolate these test-cases in their own
+ // describe block
+ describe('with auto-dismiss set to true', () => {
+ it('should render out a success toaster that disappears after a while', async () => {
+ jest.useFakeTimers();
+
+ customRender(
+
+
+ ,
+ );
+
+ const toaster = await screen.queryByRole('alert');
+ expect(toaster).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByRole('alert')).toHaveTextContent(/this is a message/);
+
+ jest.runAllTimers();
+
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/packages/flame/src/Toaster/Toaster.tsx b/packages/flame/src/Toaster/Toaster.tsx
new file mode 100644
index 00000000..7509fcfb
--- /dev/null
+++ b/packages/flame/src/Toaster/Toaster.tsx
@@ -0,0 +1,295 @@
+import * as React from 'react';
+import { keyframes } from '@emotion/core';
+import { css } from '@styled-system/css';
+import {
+ ToastProvider,
+ ToastProviderProps,
+ ToastProps,
+ useToasts as useReactToastNotifications,
+ Options,
+} from 'react-toast-notifications';
+
+import { IconVerified } from '../Icon/Verified';
+import { IconDanger } from '../Icon/Danger';
+import { IconMathMultiply } from '../Icon/MathMultiply';
+import { Box, Flex } from '../Core';
+
+const toastStates = {
+ entering: { transform: 'translate3d(0, 120%, 0)', opacity: 0, height: 0 },
+ entered: { transform: 'translate3d(0,0,0)', opacity: 1, height: 'initial' },
+ exiting: { transform: 'translate3d(0, 120%, 0)', opacity: 0 },
+ exited: { transform: 'translate3d(0, 120%, 0)', opacity: 0 },
+};
+
+const gutter = 8;
+const shrinkKeyframes = keyframes`from { width: 100%; } to { width: 0% }`;
+const TIMEOUT_MS = 4000;
+const AUTO_DISMISS = true;
+const COMPONENT_CONTAINER_HEIGHT = 60;
+
+const ToastCountdownContext = React.createContext<{
+ autoDismiss: boolean | number;
+ autoDismissTimeout: number;
+ isRunning: boolean;
+}>({
+ autoDismiss: AUTO_DISMISS,
+ autoDismissTimeout: TIMEOUT_MS,
+ isRunning: true,
+});
+
+interface CountdownProps {
+ autoDismissTimeout: number;
+ opacity: number;
+ isRunning: boolean;
+}
+const Countdown: React.FC = ({
+ autoDismissTimeout,
+ opacity,
+ isRunning,
+ ...props
+}) => (
+
+);
+
+const Toaster: React.FC = ({
+ children,
+ transitionDuration,
+ transitionState,
+ autoDismiss,
+ autoDismissTimeout,
+ isRunning,
+ appearance = 'success',
+ onDismiss,
+ ...rest
+}) => {
+ const [minHeight, setMinHeight] = React.useState('auto');
+ const elementRef = React.useRef(null);
+ // Only allow success or error, since the DSD specs says that
+ // these are the only two possibilities
+ const nextAppearance = appearance !== 'success' ? 'error' : 'success';
+
+ React.useEffect(() => {
+ if (transitionState === 'entered') {
+ const el = elementRef.current;
+ setMinHeight(el.offsetHeight + gutter);
+ }
+ if (transitionState === 'exiting') {
+ setMinHeight(0);
+ }
+ }, [transitionState]);
+
+ return (
+
+
+ This is a success toaster
+
+
+
+ This is a failure toaster
+
+
+
+
+ This is a success toaster with action
+
+
+
+
+
+ This is a success toaster with action
+
+
+