-
{children}
-
-
+
+
+
{message}
+
+
onDismiss(id)}
+ variant="primary"
+ invertColors
+ />
+
+ {actions
+ ? (
+
+ {actions.map((action) => (
+
+ ))}
-
- {action && (
-
- )}
-
-
+ )
+ : null}
+
);
}
-Toast.defaultProps = {
- action: null,
- closeLabel: undefined,
- delay: TOAST_DELAY,
- className: undefined,
-};
+export default Toast;
Toast.propTypes = {
- /** A string or an element that is rendered inside the main body of the `Toast`. */
- children: PropTypes.string.isRequired,
- /**
- * A function that is called on close. It can be used to perform
- * actions upon closing of the `Toast`, such as setting the "show"
- * element to false.
- * */
- onClose: PropTypes.func.isRequired,
- /** Boolean used to control whether the `Toast` shows */
- show: PropTypes.bool.isRequired,
- /**
- * Fields used to build optional action button.
- * `label` is a string rendered inside the button.
- * `href` is a link that will render the action button as an anchor tag.
- * `onClick` is a function that is called when the button is clicked.
- */
- action: PropTypes.shape({
- label: PropTypes.string.isRequired,
- href: PropTypes.string,
- onClick: PropTypes.func,
- }),
- /**
- * Alt text for the `Toast`'s dismiss button. Defaults to 'Close'.
- */
- closeLabel: PropTypes.string,
- /** Time in milliseconds for which the `Toast` will display. */
- delay: PropTypes.number,
- /** Class names for the `BaseToast` component */
+ id: PropTypes.number.isRequired,
+ message: PropTypes.string.isRequired,
+ onDismiss: PropTypes.func,
+ actions: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string.isRequired,
+ onClick: PropTypes.func,
+ href: PropTypes.string,
+ }),
+ ),
className: PropTypes.string,
+ duration: PropTypes.number,
};
-export default Toast;
+Toast.defaultProps = {
+ onDismiss: () => {},
+ actions: null,
+ className: '',
+ duration: 5000,
+};
diff --git a/src/Toast/index.scss b/src/Toast/index.scss
index 58658f0e9c..b114cfc349 100644
--- a/src/Toast/index.scss
+++ b/src/Toast/index.scss
@@ -1,5 +1,4 @@
@import "variables";
-@import "~bootstrap/scss/toasts";
.toast {
background-color: $toast-background-color;
@@ -8,33 +7,27 @@
padding: 1rem;
position: relative;
border-radius: $toast-border-radius;
- z-index: 2;
-
- &.show {
- display: flex;
- flex-direction: column;
- }
-
- .toast-header-btn-container {
- margin: -.25rem -.5rem;
- align-self: flex-start;
- }
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
.btn {
margin-top: .75rem;
align-self: flex-start;
}
- .toast-header {
+ .toast__header {
+ display: flex;
align-items: center;
border-bottom: 0;
justify-content: space-between;
padding: 0;
- p {
+ .toast__message {
font-size: $small-font-size;
margin: 0;
padding-right: .75rem;
+ color: $toast-header-color;
}
& + .btn {
@@ -42,6 +35,11 @@
}
}
+ .toast__optional-actions {
+ display: flex;
+ flex-wrap: wrap;
+ }
+
@media only screen and (max-width: 768px) {
max-width: 100%;
}
@@ -51,3 +49,11 @@
max-width: $toast-max-width;
}
}
+
+.toast-container {
+ display: flex;
+ flex-direction: column;
+ gap: .5rem;
+ position: fixed;
+ z-index: 3000;
+}
diff --git a/src/Toast/tests/EventEmitter.test.js b/src/Toast/tests/EventEmitter.test.js
new file mode 100644
index 0000000000..2e21ac5646
--- /dev/null
+++ b/src/Toast/tests/EventEmitter.test.js
@@ -0,0 +1,59 @@
+import { toastEmitter } from '../EventEmitter';
+
+describe('EventEmitter', () => {
+ test('subscribes and emits an event', () => {
+ const mockCallback = jest.fn();
+ toastEmitter.subscribe('testEvent', mockCallback);
+
+ toastEmitter.emit('testEvent', 'testData');
+ expect(mockCallback).toHaveBeenCalledWith('testData');
+ });
+
+ test('emits an event with data', () => {
+ const mockCallback = jest.fn();
+ toastEmitter.subscribe('testEvent', mockCallback);
+
+ const testData = { key: 'value' };
+ toastEmitter.emit('testEvent', testData);
+ expect(mockCallback).toHaveBeenCalledWith(testData);
+ });
+
+ test('handles multiple subscriptions to the same event', () => {
+ const mockCallback1 = jest.fn();
+ const mockCallback2 = jest.fn();
+
+ toastEmitter.subscribe('testEvent', mockCallback1);
+ toastEmitter.subscribe('testEvent', mockCallback2);
+
+ toastEmitter.emit('testEvent');
+ expect(mockCallback1).toHaveBeenCalled();
+ expect(mockCallback2).toHaveBeenCalled();
+ });
+
+ test('emits an event with no subscribers', () => {
+ const mockCallback = jest.fn();
+
+ toastEmitter.emit('testEvent');
+ expect(mockCallback).not.toHaveBeenCalled();
+ });
+
+ test('handles multiple different events', () => {
+ const mockCallback1 = jest.fn();
+ const mockCallback2 = jest.fn();
+
+ toastEmitter.subscribe('testEvent1', mockCallback1);
+ toastEmitter.subscribe('testEvent2', mockCallback2);
+
+ toastEmitter.emit('testEvent1');
+ expect(mockCallback1).toHaveBeenCalled();
+ expect(mockCallback2).not.toHaveBeenCalled();
+ });
+
+ test('emits an undefined event', () => {
+ const mockCallback = jest.fn();
+ toastEmitter.subscribe('testEvent', mockCallback);
+
+ toastEmitter.emit('undefinedEvent');
+ expect(mockCallback).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/Toast/tests/Toast.test.jsx b/src/Toast/tests/Toast.test.jsx
new file mode 100644
index 0000000000..f8a7671663
--- /dev/null
+++ b/src/Toast/tests/Toast.test.jsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { render, act, screen } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import userEvent from '@testing-library/user-event';
+
+import ToastContainer from '../ToastContainer';
+import { toast } from '../toast';
+
+jest.useFakeTimers();
+
+function ToastWrapper(props) {
+ return (
+
+
+
+ );
+}
+
+describe('
', () => {
+ const mockOnDismiss = jest.fn();
+ const props = {
+ onDismiss: mockOnDismiss,
+ message: 'Success message.',
+ duration: 5000,
+ };
+
+ it('renders Toasts when emitted', () => {
+ render(
);
+ act(() => {
+ toast({ message: 'Toast 1', duration: 5000 });
+ });
+ expect(screen.queryByText('Toast 1')).toBeInTheDocument();
+ });
+
+ it('removes Toasts after duration', () => {
+ render(
);
+ act(() => {
+ toast({ message: 'Toast 2', duration: 5000 });
+ jest.advanceTimersByTime(5000);
+ });
+ expect(screen.queryByText('Toast 2')).not.toBeInTheDocument();
+ });
+
+ it('renders multiple toasts', () => {
+ render(
);
+
+ act(() => {
+ toast({ message: 'Toast 1', duration: 5000 });
+ toast({ message: 'Toast 2', duration: 5000 });
+ });
+
+ expect(screen.queryByText('Toast 1')).toBeInTheDocument();
+ expect(screen.queryByText('Toast 2')).toBeInTheDocument();
+ });
+
+ it('renders optional action as button', () => {
+ render(
);
+ act(() => {
+ toast({
+ actions: [{
+ label: 'Optional action',
+ onClick: () => {},
+ }],
+ });
+ });
+
+ const toastButton = screen.getByRole('button', { name: 'Optional action' });
+ expect(toastButton).toBeInTheDocument();
+ });
+
+ it('pauses and resumes timer on hover', async () => {
+ render(
);
+ act(() => {
+ toast({ message: 'Hover Test', duration: 5000 });
+ });
+ const toastElement = screen.getByText('Hover Test');
+ await userEvent.hover(toastElement);
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(screen.queryByText('Hover Test')).toBeInTheDocument();
+
+ await userEvent.unhover(toastElement);
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+
+ expect(screen.queryByText('Hover Test')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/Toast/toast.js b/src/Toast/toast.js
new file mode 100644
index 0000000000..0d525461c7
--- /dev/null
+++ b/src/Toast/toast.js
@@ -0,0 +1,6 @@
+import { toastEmitter } from './EventEmitter';
+
+// eslint-disable-next-line import/prefer-default-export
+export const toast = ({ message, duration, actions }) => {
+ toastEmitter.emit('showToast', { message, duration, actions });
+};
diff --git a/src/ToastNew/README.md b/src/ToastNew/README.md
deleted file mode 100644
index 9b36e17520..0000000000
--- a/src/ToastNew/README.md
+++ /dev/null
@@ -1,96 +0,0 @@
----
-title: 'Toast'
-type: 'component'
-components:
-- Toast
-categories:
-- Overlays
-status: 'New'
-designStatus: 'Done'
-devStatus: 'Done'
-notes: ''
----
-
-``Toast`` is a pop-up style message that shows the user a brief, fleeting, dismissible message about a successful app process.
-
-``Toasts`` sit fixed to the bottom left of the window.
-
-## Behaviors
-
-
- - Auto-dismiss: Toast automatically dismisses after 5 seconds by default.
- - Disable timer: On hover of the Toast container. On hover or focus of dismiss icon or optional button
- - Re-enable timer: On mouse leave of the Toast container. On blur of dismiss icon or option button
- - Auto-dismiss timer: 5 - 15 second range.
-
-
-## Basic Usage
-
-```jsx live
-() => {
- const [show, setShow] = useState(false);
-
- return (
- <>
-
setShow(false)}
- show={show}
- >
- Example of a basic Toast.
-
-
-
- >
- );
-}
-```
-
-## With Button
-
-```jsx live
-() => {
- const [show, setShow] = useState(false);
-
- return (
- <>
-
console.log('You clicked the action button.')
- }}
- onClose={() => setShow(false)}
- show={show}
- >
- Success! Example of a Toast with a button.
-
-
-
- >
- );
-}
-```
-
-## With Link
-
-```jsx live
-() => {
- const [show, setShow] = useState(false);
-
- return (
- <>
-
setShow(false)}
- show={show}
- >
- Success! Example of a Toast with a link.
-
-
-
- >
- );
-}
-```
diff --git a/src/ToastNew/Toast.jsx b/src/ToastNew/Toast.jsx
deleted file mode 100644
index 76117427d7..0000000000
--- a/src/ToastNew/Toast.jsx
+++ /dev/null
@@ -1,32 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { useEffect } from 'react';
-import { useToast } from './ToastContext';
-
-function Toast({ id, content, options }) {
- const { removeToast } = useToast();
-
- useEffect(() => {
- const timer = setTimeout(() => {
- removeToast(id);
- }, options.duration || 3000);
-
- return () => clearTimeout(timer);
- }, [id, options.duration, removeToast]);
-
- return (
-
- {content}
-
-
- );
-}
-
-export const ToastFunction = () => {
- const { addToast } = useToast();
-
- return (content, options) => {
- addToast(content, options);
- };
-};
-
-export default Toast;
diff --git a/src/ToastNew/ToastContainer.jsx b/src/ToastNew/ToastContainer.jsx
deleted file mode 100644
index 82773d7427..0000000000
--- a/src/ToastNew/ToastContainer.jsx
+++ /dev/null
@@ -1,20 +0,0 @@
-/* eslint-disable react/prop-types */
-import React from 'react';
-import { ToastProvider, useToast } from './ToastContext';
-import Toast from './Toast';
-
-function ToastContainer({ config }) {
- const { toasts } = useToast();
-
- return (
-
-
- {toasts.map((toast) => (
-
- ))}
-
-
- );
-}
-
-export default ToastContainer;
diff --git a/src/ToastNew/ToastContext.jsx b/src/ToastNew/ToastContext.jsx
deleted file mode 100644
index 82cd889a5a..0000000000
--- a/src/ToastNew/ToastContext.jsx
+++ /dev/null
@@ -1,49 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { createContext, useReducer, useContext } from 'react';
-
-const ToastContext = createContext();
-
-const initialState = {
- toasts: [],
-};
-
-const reducer = (state, action) => {
- switch (action.type) {
- case 'ADD_TOAST':
- return { ...state, toasts: [...state.toasts, action.payload] };
- case 'REMOVE_TOAST':
- return { ...state, toasts: state.toasts.filter((toast) => toast.id !== action.payload) };
- default:
- return state;
- }
-};
-
-function ToastProvider({ children }) {
- const [state, dispatch] = useReducer(reducer, initialState);
-
- const addToast = (content, options = {}) => {
- const id = Date.now();
- const toast = { id, content, options };
- dispatch({ type: 'ADD_TOAST', payload: toast });
- };
-
- const removeToast = (id) => {
- dispatch({ type: 'REMOVE_TOAST', payload: id });
- };
-
- return (
-
- {children}
-
- );
-}
-
-const useToast = () => {
- const context = useContext(ToastContext);
- if (!context) {
- throw new Error('useToast must be used within a ToastProvider');
- }
- return context;
-};
-
-export { ToastProvider, useToast };
diff --git a/src/index.js b/src/index.js
index 13ee0c82aa..7eb3625c49 100644
--- a/src/index.js
+++ b/src/index.js
@@ -133,9 +133,8 @@ export {
TabPane,
} from './Tabs';
export { default as TextArea } from './TextArea';
-export { default as Toast, TOAST_CLOSE_LABEL_TEXT, TOAST_DELAY } from './Toast';
-export { default as ToastContainer } from './ToastNew/ToastContainer';
-export { ToastProvider } from './ToastNew/ToastContext';
+export { default as ToastContainer } from './Toast/ToastContainer';
+export { toast } from './Toast/toast';
export { default as Tooltip } from './Tooltip';
export { default as ValidationFormGroup } from './ValidationFormGroup';
export { default as TransitionReplace } from './TransitionReplace';
diff --git a/src/index.scss b/src/index.scss
index 41a8e68e6c..147a01e3fd 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -46,7 +46,6 @@
@import "./IconButton";
@import "./IconButtonToggle";
@import "./Toast";
-@import "./Toast/ToastContainer";
@import "./SelectableBox";
@import "./ProductTour/Checkpoint";
@import "./Sticky";