From fc6278c3d4c27ec6e9e0ee4fcba116714b816757 Mon Sep 17 00:00:00 2001 From: Kyrylo Hudym-Levkovych Date: Wed, 13 Dec 2023 12:32:34 +0200 Subject: [PATCH 1/8] feat: add simple logic with reducer --- src/ToastNew/README.md | 96 +++++++++++++++++++++++++++++++++ src/ToastNew/Toast.jsx | 32 +++++++++++ src/ToastNew/ToastContainer.jsx | 20 +++++++ src/ToastNew/ToastContext.jsx | 49 +++++++++++++++++ src/index.js | 2 + 5 files changed, 199 insertions(+) create mode 100644 src/ToastNew/README.md create mode 100644 src/ToastNew/Toast.jsx create mode 100644 src/ToastNew/ToastContainer.jsx create mode 100644 src/ToastNew/ToastContext.jsx diff --git a/src/ToastNew/README.md b/src/ToastNew/README.md new file mode 100644 index 0000000000..9b36e17520 --- /dev/null +++ b/src/ToastNew/README.md @@ -0,0 +1,96 @@ +--- +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 + + + +## 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 new file mode 100644 index 0000000000..76117427d7 --- /dev/null +++ b/src/ToastNew/Toast.jsx @@ -0,0 +1,32 @@ +/* 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 new file mode 100644 index 0000000000..82773d7427 --- /dev/null +++ b/src/ToastNew/ToastContainer.jsx @@ -0,0 +1,20 @@ +/* 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 new file mode 100644 index 0000000000..82cd889a5a --- /dev/null +++ b/src/ToastNew/ToastContext.jsx @@ -0,0 +1,49 @@ +/* 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 a25410d8f9..13ee0c82aa 100644 --- a/src/index.js +++ b/src/index.js @@ -134,6 +134,8 @@ export { } 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 Tooltip } from './Tooltip'; export { default as ValidationFormGroup } from './ValidationFormGroup'; export { default as TransitionReplace } from './TransitionReplace'; From 5833ac09be4ab1005df3aecda6065af0b91c5ec2 Mon Sep 17 00:00:00 2001 From: Kyrylo Hudym-Levkovych Date: Thu, 21 Dec 2023 16:17:45 +0200 Subject: [PATCH 2/8] feat: new toast component --- src/Toast/EventEmitter.js | 22 ++++ src/Toast/README.md | 138 +++++++++++----------- src/Toast/Toast.test.jsx | 92 --------------- src/Toast/ToastContainer.jsx | 100 +++++++++++----- src/Toast/ToastContainer.scss | 24 ---- src/Toast/index.jsx | 169 +++++++++++++-------------- src/Toast/index.scss | 34 +++--- src/Toast/tests/EventEmitter.test.js | 59 ++++++++++ src/Toast/tests/Toast.test.jsx | 91 +++++++++++++++ src/Toast/toast.js | 6 + src/ToastNew/README.md | 96 --------------- src/ToastNew/Toast.jsx | 32 ----- src/ToastNew/ToastContainer.jsx | 20 ---- src/ToastNew/ToastContext.jsx | 49 -------- src/index.js | 5 +- src/index.scss | 1 - 16 files changed, 423 insertions(+), 515 deletions(-) create mode 100644 src/Toast/EventEmitter.js delete mode 100644 src/Toast/Toast.test.jsx delete mode 100644 src/Toast/ToastContainer.scss create mode 100644 src/Toast/tests/EventEmitter.test.js create mode 100644 src/Toast/tests/Toast.test.jsx create mode 100644 src/Toast/toast.js delete mode 100644 src/ToastNew/README.md delete mode 100644 src/ToastNew/Toast.jsx delete mode 100644 src/ToastNew/ToastContainer.jsx delete mode 100644 src/ToastNew/ToastContext.jsx diff --git a/src/Toast/EventEmitter.js b/src/Toast/EventEmitter.js new file mode 100644 index 0000000000..4639f50875 --- /dev/null +++ b/src/Toast/EventEmitter.js @@ -0,0 +1,22 @@ +class EventEmitter { + constructor() { + this.events = {}; + } + + subscribe(event, callback) { + if (!this.events[event]) { + this.events[event] = []; + } + this.events[event].push(callback); + } + + emit(event, data) { + const eventSubscribers = this.events[event]; + if (eventSubscribers) { + eventSubscribers.forEach(callback => callback(data)); + } + } +} + +// eslint-disable-next-line import/prefer-default-export +export const toastEmitter = new EventEmitter(); diff --git a/src/Toast/README.md b/src/Toast/README.md index 9b36e17520..4ac7129133 100644 --- a/src/Toast/README.md +++ b/src/Toast/README.md @@ -2,7 +2,8 @@ title: 'Toast' type: 'component' components: -- Toast +- ToastContainer +- toast categories: - Overlays status: 'New' @@ -11,85 +12,88 @@ devStatus: 'Done' notes: '' --- -``Toast`` is a pop-up style message that shows the user a brief, fleeting, dismissible message about a successful app process. +`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. +## Features + +- **Customizable Appearance**: Choose the window position for toast. +- **Interactive**: Includes a close button for manual dismissal. +- **Auto-dismiss**: Disappears automatically after a set duration. +- **Hover Interactivity**: Auto-dismiss timer pauses on hover or focus, allowing users to interact with the content. ## Behaviors - +- Auto-dismiss: Toast automatically dismisses after a default duration of 5 seconds. +- Disable timer: Pause the auto-dismiss timer on hover or focus of the Toast or the dismiss icon. +- Re-enable timer: Resume the auto-dismiss timer on mouse leave or blur of the Toast component. ## 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); + const [position, setPosition] = useState('bottom-left'); + const [timer, setTimer] = useState(5000); + const [message, setMessage] = useState('Example of a basic Toast.'); + const [actions, setActions] = useState([]); - 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); + const testAction = { + label: "Optional Button", + onClick: () => console.log('You clicked the action button.') + }; return ( <> - setShow(false)} - show={show} - > - Success! Example of a Toast with a link. - - - +
+ Message: + setMessage(e.target.value)} + /> +
+ +
+ Duration (ms): + setTimer(Number(e.target.value))} /> +
+ +
+ Position: + setPosition(e.target.value)} + > + + + + + +
+ +
+ Add and remove actions: + +

Total added: {actions.length}

+ + + + + +
+ + + + + ); } diff --git a/src/Toast/Toast.test.jsx b/src/Toast/Toast.test.jsx deleted file mode 100644 index 9e591054fc..0000000000 --- a/src/Toast/Toast.test.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { IntlProvider } from 'react-intl'; -import userEvent from '@testing-library/user-event'; - -import Toast from '.'; - -/* eslint-disable-next-line react/prop-types */ -function ToastWrapper({ children, ...props }) { - return ( - - - {children} - - - ); -} - -describe('', () => { - const onCloseHandler = () => {}; - const props = { - onClose: onCloseHandler, - show: true, - }; - it('renders optional action as link', () => { - render( - - Success message. - , - ); - - const toastLink = screen.getByRole('button', { name: 'Optional action' }); - expect(toastLink).toBeTruthy(); - }); - it('renders optional action as button', () => { - render( - {}, - }} - > - Success message. - , - ); - const toastButton = screen.getByRole('button', { name: 'Optional action' }); - expect(toastButton.className).toContain('btn'); - }); - it('autohide is set to false on onMouseOver and true on onMouseLeave', async () => { - render( - - Success message. - , - ); - const toast = screen.getByTestId('toast'); - await userEvent.hover(toast); - setTimeout(() => { - expect(screen.getByText('Success message.')).toEqual(true); - expect(toast).toHaveLength(1); - }, 6000); - await userEvent.unhover(toast); - setTimeout(() => { - expect(screen.getByText('Success message.')).toEqual(false); - expect(toast).toHaveLength(1); - }, 6000); - }); - it('autohide is set to false onFocus and true onBlur', async () => { - render( - - Success message. - , - ); - const toast = screen.getByTestId('toast'); - toast.focus(); - setTimeout(() => { - expect(screen.getByText('Success message.')).toEqual(true); - expect(toast).toHaveLength(1); - }, 6000); - await userEvent.tab(); - setTimeout(() => { - expect(screen.getByText('Success message.')).toEqual(false); - expect(toast).toHaveLength(1); - }, 6000); - }); -}); diff --git a/src/Toast/ToastContainer.jsx b/src/Toast/ToastContainer.jsx index 05049ae0f4..915a4e8b01 100644 --- a/src/Toast/ToastContainer.jsx +++ b/src/Toast/ToastContainer.jsx @@ -1,40 +1,82 @@ -import React from 'react'; +/* eslint-disable react/prop-types */ +import React, { useState, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; -class ToastContainer extends React.Component { - constructor(props) { - super(props); - this.toastRootName = 'toast-root'; - if (typeof document === 'undefined') { - this.rootElement = null; - } else if (document.getElementById(this.toastRootName)) { - this.rootElement = document.getElementById(this.toastRootName); - } else { - const rootElement = document.createElement('div'); - rootElement.setAttribute('id', this.toastRootName); - rootElement.setAttribute('class', 'toast-container'); - rootElement.setAttribute('role', 'alert'); - rootElement.setAttribute('aria-live', 'polite'); - rootElement.setAttribute('aria-atomic', 'true'); - this.rootElement = document.body.appendChild(rootElement); - } +import { toastEmitter } from './EventEmitter'; +import Toast from '.'; + +const positionStyles = { + 'top-left': { + top: '0', left: '0', right: 'auto', bottom: 'auto', + }, + 'top-right': { + top: '0', right: '0', left: 'auto', bottom: 'auto', + }, + 'bottom-left': { + bottom: '0', left: '0', right: 'auto', top: 'auto', + }, + 'bottom-right': { + bottom: '0', right: '0', left: 'auto', top: 'auto', + }, +}; + +function ToastContainer({ position, className }) { + const [toasts, setToasts] = useState([]); + const portalDivRef = useRef(null); + const positionStyle = positionStyles[position] || positionStyles['bottom-left']; + + if (!portalDivRef.current) { + portalDivRef.current = document.createElement('div'); + portalDivRef.current.setAttribute('class', 'toast-portal'); + portalDivRef.current.setAttribute('role', 'alert'); + portalDivRef.current.setAttribute('aria-live', 'polite'); + portalDivRef.current.setAttribute('aria-atomic', 'true'); + document.body.appendChild(portalDivRef.current); } - render() { - if (this.rootElement) { - return ReactDOM.createPortal( - this.props.children, - this.rootElement, + const removeToast = (id) => { + setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id)); + }; + + useEffect(() => { + const handleShowToast = ({ message, duration, actions }) => { + const id = Date.now(); + setToasts(currentToasts => [...currentToasts, { + id, message, duration, actions, + }]); + }; + + toastEmitter.subscribe('showToast', handleShowToast); + + return () => { + toastEmitter.events.showToast = toastEmitter.events.showToast.filter( + callback => callback !== handleShowToast, ); - } - return null; - } + if (portalDivRef.current) { + document.body.removeChild(portalDivRef.current); + } + }; + }, []); + + return portalDivRef.current ? ReactDOM.createPortal( +
+ {toasts.map(toast => ( + removeToast(toast.id)} className={className} /> + ))} +
, + portalDivRef.current, + ) : null; } +export default ToastContainer; + ToastContainer.propTypes = { - /** Specifies contents of the component. */ - children: PropTypes.node.isRequired, + position: PropTypes.oneOf(['top-left', 'top-right', 'bottom-left', 'bottom-right']), + className: PropTypes.string, }; -export default ToastContainer; +ToastContainer.defaultProps = { + position: 'bottom-left', + className: '', +}; diff --git a/src/Toast/ToastContainer.scss b/src/Toast/ToastContainer.scss deleted file mode 100644 index b29ba0e5dc..0000000000 --- a/src/Toast/ToastContainer.scss +++ /dev/null @@ -1,24 +0,0 @@ -@import "variables"; - -.toast-container { - bottom: $toast-container-gutter-lg; - left: $toast-container-gutter-lg; - position: fixed; - z-index: 2; - - [dir="rtl"] & { - right: $toast-container-gutter-lg; - left: 0; - } - - @media only screen and (max-width: 768px) { - bottom: $toast-container-gutter-sm; - right: $toast-container-gutter-sm; - left: $toast-container-gutter-sm; - - [dir="rtl"] & { - left: $toast-container-gutter-sm; - right: $toast-container-gutter-sm; - } - } -} diff --git a/src/Toast/index.jsx b/src/Toast/index.jsx index 11461666c2..a5162393c4 100644 --- a/src/Toast/index.jsx +++ b/src/Toast/index.jsx @@ -1,111 +1,104 @@ -import React, { useState } from 'react'; -import classNames from 'classnames'; +/* eslint-disable react/prop-types */ +import React, { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; - -import BaseToast from 'react-bootstrap/Toast'; import { useIntl } from 'react-intl'; +import classNames from 'classnames'; -import { Close } from '../../icons'; -import ToastContainer from './ToastContainer'; -import Button from '../Button'; import Icon from '../Icon'; import IconButton from '../IconButton'; - -export const TOAST_CLOSE_LABEL_TEXT = 'Close'; -export const TOAST_DELAY = 5000; +import Button from '../Button'; +import { Close } from '../../icons'; function Toast({ - action, children, className, closeLabel, onClose, show, ...rest + id, message, onDismiss, actions, className, duration, ...rest }) { const intl = useIntl(); - const [autoHide, setAutoHide] = useState(true); - const intlCloseLabel = closeLabel || intl.formatMessage({ + const intlCloseLabel = intl.formatMessage({ id: 'pgn.Toast.closeLabel', defaultMessage: 'Close', description: 'Close label for Toast component', }); + + const timerRef = useRef(); + + useEffect(() => { + timerRef.current = setTimeout(() => onDismiss(id), duration); + + return () => clearTimeout(timerRef.current); + }, [id, onDismiss, duration]); + + const clearTimer = () => { + clearTimeout(timerRef.current); + }; + + const startTimer = () => { + clearTimer(); + timerRef.current = setTimeout(() => onDismiss(id), duration); + }; + return ( - - setAutoHide(true)} - onFocus={() => setAutoHide(false)} - onMouseOut={() => setAutoHide(true)} - onMouseOver={() => setAutoHide(false)} - show={show} - {...rest} - > -
-

{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"; From 42768581f19bf019a2195da857e1a5bdd57d81c9 Mon Sep 17 00:00:00 2001 From: Kyrylo Hudym-Levkovych Date: Fri, 22 Dec 2023 12:23:08 +0200 Subject: [PATCH 3/8] fix: fix build-docs issue, refactor toast component --- src/Toast/ToastContainer.jsx | 2 +- www/src/components/CodeBlock.tsx | 13 +++---------- www/src/components/IconsTable.tsx | 13 +++---------- www/src/pages/foundations/elevation.jsx | 16 ++++------------ 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/src/Toast/ToastContainer.jsx b/src/Toast/ToastContainer.jsx index 915a4e8b01..3bd6d82492 100644 --- a/src/Toast/ToastContainer.jsx +++ b/src/Toast/ToastContainer.jsx @@ -26,7 +26,7 @@ function ToastContainer({ position, className }) { const portalDivRef = useRef(null); const positionStyle = positionStyles[position] || positionStyles['bottom-left']; - if (!portalDivRef.current) { + if (!portalDivRef.current && typeof document !== 'undefined') { portalDivRef.current = document.createElement('div'); portalDivRef.current.setAttribute('class', 'toast-portal'); portalDivRef.current.setAttribute('role', 'alert'); diff --git a/www/src/components/CodeBlock.tsx b/www/src/components/CodeBlock.tsx index d5b7460e1e..ff14ffc7dc 100644 --- a/www/src/components/CodeBlock.tsx +++ b/www/src/components/CodeBlock.tsx @@ -31,7 +31,7 @@ import HipsterIpsum from './exampleComponents/HipsterIpsum'; import ExamplePropsForm from './exampleComponents/ExamplePropsForm'; const { - Collapsible, Toast, IconButton, Icon, + Collapsible, IconButton, Icon, toast, ToastContainer } = ParagonReact; export type CollapsibleLiveEditorTypes = { @@ -125,14 +125,13 @@ function CodeBlock({ }: ICodeBlock) { const intl = useIntl(); const language: any = className ? className.replace(/language-/, '') : 'jsx'; - const [showToast, setShowToast] = useState(false); const [codeExample, setCodeExample] = useState(children); const handleCodeChange = (e) => setCodeExample(e.target.value); const handleCopyCodeExample = () => { navigator.clipboard.writeText(codeExample); - setShowToast(true); + toast({message: 'Code example copied to clipboard!', duration: 2000 }); }; if (live) { @@ -168,13 +167,7 @@ function CodeBlock({ - setShowToast(false)} - show={showToast} - delay={2000} - > - Code example copied to clipboard! - +
); } diff --git a/www/src/components/IconsTable.tsx b/www/src/components/IconsTable.tsx index a95113b73d..cb0bdd8afe 100644 --- a/www/src/components/IconsTable.tsx +++ b/www/src/components/IconsTable.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; -import { Icon, SearchField, Toast } from '~paragon-react'; +import { Icon, SearchField, ToastContainer, toast } from '~paragon-react'; import * as IconComponents from '~paragon-icons'; import { ICON_COPIED_EVENT, sendUserAnalyticsEvent } from '../../segment-events'; @@ -67,7 +67,6 @@ function IconsTable({ iconNames }) { const [tableWidth, setTableWidth] = useState(0); const [data, setData] = useState({ iconsList: iconNames, rowsCount: ROWS_PER_WINDOW }); const [currentIcon, setCurrentIcon] = useState(iconNames[0]); - const [showToast, setShowToast] = useState(false); const currentIconImport = `import { ${currentIcon} } from '@openedx/paragon/icons';`; const { rowsCount, iconsList } = data; @@ -75,7 +74,7 @@ function IconsTable({ iconNames }) { const copyToClipboard = (content) => { navigator.clipboard.writeText(content); - setShowToast(true); + toast({ message: 'Copied to clipboard!', duration: 2000 }); sendUserAnalyticsEvent(ICON_COPIED_EVENT, { name: currentIcon }); }; @@ -178,13 +177,7 @@ function IconsTable({ iconNames }) {
- setShowToast(false)} - show={showToast} - delay={2000} - > - Copied to clipboard! - + ); } diff --git a/www/src/pages/foundations/elevation.jsx b/www/src/pages/foundations/elevation.jsx index 0b696ed65d..fa2bf850a9 100644 --- a/www/src/pages/foundations/elevation.jsx +++ b/www/src/pages/foundations/elevation.jsx @@ -5,7 +5,8 @@ import { Button, Form, Input, - Toast, + ToastContainer, + toast, Icon, IconButtonWithTooltip, } from '~paragon-react'; @@ -28,11 +29,9 @@ const controlsProps = [ ]; function BoxShadowNode() { - const [showToast, setShowToast] = useState(false); - const isBoxShadowCopied = (level, side) => { navigator.clipboard.writeText(`@include pgn-box-shadow(${level}, "${side}");`); - setShowToast(true); + toast({ message: 'Box-shadow copied to clipboard!', duration: 2000 }); }; const boxShadowCells = boxShadowLevels.map(level => ( @@ -56,14 +55,7 @@ function BoxShadowNode() { return (
{ boxShadowCells } - setShowToast(false)} - show={showToast} - delay={2000} - > - Box-shadow copied to clipboard! - +
); } From 4bf88cf094ff2f492c24c457ad808e7588520ccb Mon Sep 17 00:00:00 2001 From: Kyrylo Hudym-Levkovych Date: Tue, 2 Jan 2024 13:40:56 +0200 Subject: [PATCH 4/8] refactor: add changes after review --- src/Toast/BaseToast.jsx | 104 ++++++++++++++++++ src/Toast/README.md | 78 +++++--------- src/Toast/ToastContainer.jsx | 82 -------------- src/Toast/constants/index.js | 16 +++ src/Toast/index.jsx | 138 +++++++++--------------- src/Toast/index.scss | 10 +- src/Toast/tests/Toast.test.jsx | 4 +- src/Toast/toast.js | 6 -- src/Toast/{ => utils}/EventEmitter.js | 0 src/Toast/utils/index.jsx | 2 + src/Toast/utils/toast.js | 27 +++++ src/index.js | 3 +- www/gatsby-browser.jsx | 2 + www/src/components/CodeBlock.tsx | 5 +- www/src/components/IconsTable.tsx | 3 +- www/src/pages/foundations/elevation.jsx | 2 - 16 files changed, 240 insertions(+), 242 deletions(-) create mode 100644 src/Toast/BaseToast.jsx delete mode 100644 src/Toast/ToastContainer.jsx create mode 100644 src/Toast/constants/index.js delete mode 100644 src/Toast/toast.js rename src/Toast/{ => utils}/EventEmitter.js (100%) create mode 100644 src/Toast/utils/index.jsx create mode 100644 src/Toast/utils/toast.js diff --git a/src/Toast/BaseToast.jsx b/src/Toast/BaseToast.jsx new file mode 100644 index 0000000000..ca89bb2044 --- /dev/null +++ b/src/Toast/BaseToast.jsx @@ -0,0 +1,104 @@ +/* eslint-disable react/prop-types */ +import React, { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import classNames from 'classnames'; + +import Icon from '../Icon'; +import IconButton from '../IconButton'; +import Button from '../Button'; +import { Close } from '../../icons'; + +function Toast({ + id, message, onDismiss, actions, className, duration, ...rest +}) { + const intl = useIntl(); + const intlCloseLabel = intl.formatMessage({ + id: 'pgn.Toast.closeLabel', + defaultMessage: 'Close', + description: 'Close label for Toast component', + }); + + const timerRef = useRef(); + + useEffect(() => { + timerRef.current = setTimeout(() => onDismiss(id), duration); + + return () => clearTimeout(timerRef.current); + }, [id, onDismiss, duration]); + + const clearTimer = () => { + clearTimeout(timerRef.current); + }; + + const startTimer = () => { + clearTimer(); + timerRef.current = setTimeout(() => onDismiss(id), duration); + }; + + return ( +
+
+

{message}

+ + onDismiss(id)} + variant="primary" + invertColors + /> +
+ {actions + ? ( +
+ {actions.map((action) => ( + + ))} +
+ ) + : null} +
+ ); +} + +export default Toast; + +Toast.propTypes = { + 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, +}; + +Toast.defaultProps = { + onDismiss: () => {}, + actions: null, + className: '', + duration: 5000, +}; diff --git a/src/Toast/README.md b/src/Toast/README.md index 4ac7129133..a1df2e2ebb 100644 --- a/src/Toast/README.md +++ b/src/Toast/README.md @@ -3,7 +3,6 @@ title: 'Toast' type: 'component' components: - ToastContainer -- toast categories: - Overlays status: 'New' @@ -14,18 +13,12 @@ notes: '' `Toast` is a pop-up style message that shows the user a brief, fleeting, dismissible message about a successful app process. -## Features - -- **Customizable Appearance**: Choose the window position for toast. -- **Interactive**: Includes a close button for manual dismissal. -- **Auto-dismiss**: Disappears automatically after a set duration. -- **Hover Interactivity**: Auto-dismiss timer pauses on hover or focus, allowing users to interact with the content. - ## Behaviors -- Auto-dismiss: Toast automatically dismisses after a default duration of 5 seconds. -- Disable timer: Pause the auto-dismiss timer on hover or focus of the Toast or the dismiss icon. -- Re-enable timer: Resume the auto-dismiss timer on mouse leave or blur of the Toast component. +- **Customizable Appearance**: Choose the window position for toast. +- **Auto-dismiss**: Toast automatically dismisses after a default duration of 5 seconds. +- **Disable timer**: Pause the auto-dismiss timer on hover or focus of the Toast or the dismiss icon. +- **Re-enable timer**: Resume the auto-dismiss timer on mouse leave or blur of the Toast component. ## Basic Usage @@ -34,7 +27,7 @@ notes: '' const [position, setPosition] = useState('bottom-left'); const [timer, setTimer] = useState(5000); const [message, setMessage] = useState('Example of a basic Toast.'); - const [actions, setActions] = useState([]); + const [withActions, setWithActions] = useState('false'); const testAction = { label: "Optional Button", @@ -43,7 +36,26 @@ notes: '' return ( <> -
+ + {/* start example form block */} + setPosition(value), name: 'Position', options: [ + { value: "top-left", name: "top-left" }, + { value: "top-right", name: "top-right" }, + { value: "bottom-left", name: "bottom-left" }, + { value: "bottom-right", name: "bottom-right" }] + }, + { value: timer, setValue: (value) => setTimer(value), name: 'Duration (ms)', range: { min: 1000 , max: 10000, step: 1000 } }, + { value: withActions, setValue: (value) => setWithActions(value), name: 'With actions', options: [ + { value: 'true', name: "True" }, + { value: 'false', name: "False" }, + ]}, + ]} + /> + {/* end example form block */} + +
Message: setMessage(e.target.value)} />
- -
- Duration (ms): - setTimer(Number(e.target.value))} /> -
- -
- Position: - setPosition(e.target.value)} - > - - - - - -
- -
- Add and remove actions: - -

Total added: {actions.length}

- - - - -
- - - - - ); } diff --git a/src/Toast/ToastContainer.jsx b/src/Toast/ToastContainer.jsx deleted file mode 100644 index 3bd6d82492..0000000000 --- a/src/Toast/ToastContainer.jsx +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { useState, useEffect, useRef } from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; - -import { toastEmitter } from './EventEmitter'; -import Toast from '.'; - -const positionStyles = { - 'top-left': { - top: '0', left: '0', right: 'auto', bottom: 'auto', - }, - 'top-right': { - top: '0', right: '0', left: 'auto', bottom: 'auto', - }, - 'bottom-left': { - bottom: '0', left: '0', right: 'auto', top: 'auto', - }, - 'bottom-right': { - bottom: '0', right: '0', left: 'auto', top: 'auto', - }, -}; - -function ToastContainer({ position, className }) { - const [toasts, setToasts] = useState([]); - const portalDivRef = useRef(null); - const positionStyle = positionStyles[position] || positionStyles['bottom-left']; - - if (!portalDivRef.current && typeof document !== 'undefined') { - portalDivRef.current = document.createElement('div'); - portalDivRef.current.setAttribute('class', 'toast-portal'); - portalDivRef.current.setAttribute('role', 'alert'); - portalDivRef.current.setAttribute('aria-live', 'polite'); - portalDivRef.current.setAttribute('aria-atomic', 'true'); - document.body.appendChild(portalDivRef.current); - } - - const removeToast = (id) => { - setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id)); - }; - - useEffect(() => { - const handleShowToast = ({ message, duration, actions }) => { - const id = Date.now(); - setToasts(currentToasts => [...currentToasts, { - id, message, duration, actions, - }]); - }; - - toastEmitter.subscribe('showToast', handleShowToast); - - return () => { - toastEmitter.events.showToast = toastEmitter.events.showToast.filter( - callback => callback !== handleShowToast, - ); - if (portalDivRef.current) { - document.body.removeChild(portalDivRef.current); - } - }; - }, []); - - return portalDivRef.current ? ReactDOM.createPortal( -
- {toasts.map(toast => ( - removeToast(toast.id)} className={className} /> - ))} -
, - portalDivRef.current, - ) : null; -} - -export default ToastContainer; - -ToastContainer.propTypes = { - position: PropTypes.oneOf(['top-left', 'top-right', 'bottom-left', 'bottom-right']), - className: PropTypes.string, -}; - -ToastContainer.defaultProps = { - position: 'bottom-left', - className: '', -}; diff --git a/src/Toast/constants/index.js b/src/Toast/constants/index.js new file mode 100644 index 0000000000..6462c01108 --- /dev/null +++ b/src/Toast/constants/index.js @@ -0,0 +1,16 @@ +export const TOAST_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + +export const positionStyles = { + 'top-left': { + top: '0', left: '0', right: 'auto', bottom: 'auto', + }, + 'top-right': { + top: '0', right: '0', left: 'auto', bottom: 'auto', + }, + 'bottom-left': { + bottom: '0', left: '0', right: 'auto', top: 'auto', + }, + 'bottom-right': { + bottom: '0', right: '0', left: 'auto', top: 'auto', + }, +}; diff --git a/src/Toast/index.jsx b/src/Toast/index.jsx index a5162393c4..e5eb8ef1ac 100644 --- a/src/Toast/index.jsx +++ b/src/Toast/index.jsx @@ -1,104 +1,72 @@ /* eslint-disable react/prop-types */ -import React, { useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; -import classNames from 'classnames'; -import Icon from '../Icon'; -import IconButton from '../IconButton'; -import Button from '../Button'; -import { Close } from '../../icons'; +import { toastEmitter } from './utils'; +import BaseToast from './BaseToast'; +import { positionStyles, TOAST_POSITIONS } from './constants'; -function Toast({ - id, message, onDismiss, actions, className, duration, ...rest -}) { - const intl = useIntl(); - const intlCloseLabel = intl.formatMessage({ - id: 'pgn.Toast.closeLabel', - defaultMessage: 'Close', - description: 'Close label for Toast component', - }); +function ToastContainer({ position: defaultPosition, className }) { + const [toasts, setToasts] = useState([]); + const portalDivRef = useRef(null); - const timerRef = useRef(); + if (!portalDivRef.current && typeof document !== 'undefined') { + portalDivRef.current = document.createElement('div'); + portalDivRef.current.setAttribute('class', 'pgn__toast-portal'); + portalDivRef.current.setAttribute('role', 'alert'); + portalDivRef.current.setAttribute('aria-live', 'polite'); + portalDivRef.current.setAttribute('aria-atomic', 'true'); + document.body.appendChild(portalDivRef.current); + } - useEffect(() => { - timerRef.current = setTimeout(() => onDismiss(id), duration); - - return () => clearTimeout(timerRef.current); - }, [id, onDismiss, duration]); - - const clearTimer = () => { - clearTimeout(timerRef.current); + const removeToast = (id) => { + setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id)); }; - const startTimer = () => { - clearTimer(); - timerRef.current = setTimeout(() => onDismiss(id), duration); - }; + useEffect(() => { + const handleShowToast = ({ + message, duration, actions, position, + }) => { + const id = Date.now(); + setToasts(currentToasts => [...currentToasts, { + id, message, duration, actions, position: position || defaultPosition, + }]); + }; + + toastEmitter.subscribe('showToast', handleShowToast); - return ( -
-
-

{message}

+ return () => { + toastEmitter.events.showToast = toastEmitter.events.showToast.filter( + callback => callback !== handleShowToast, + ); + if (portalDivRef.current) { + document.body.removeChild(portalDivRef.current); + } + }; + }, [defaultPosition]); - onDismiss(id)} - variant="primary" - invertColors - /> + return portalDivRef.current ? ReactDOM.createPortal( + Object.keys(positionStyles).map(position => ( +
+ {toasts.filter(toast => toast.position === position).map(toast => ( + removeToast(toast.id)} /> + ))}
- {actions - ? ( -
- {actions.map((action) => ( - - ))} -
- ) - : null} -
- ); + )), + portalDivRef.current, + ) : null; } -export default Toast; +export default ToastContainer; +export { toast } from './utils'; -Toast.propTypes = { - 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, - }), - ), +ToastContainer.propTypes = { + position: PropTypes.oneOf(TOAST_POSITIONS), className: PropTypes.string, - duration: PropTypes.number, }; -Toast.defaultProps = { - onDismiss: () => {}, - actions: null, +ToastContainer.defaultProps = { + position: 'bottom-left', className: '', - duration: 5000, }; diff --git a/src/Toast/index.scss b/src/Toast/index.scss index b114cfc349..1bfb4aefc9 100644 --- a/src/Toast/index.scss +++ b/src/Toast/index.scss @@ -1,6 +1,6 @@ @import "variables"; -.toast { +.pgn__toast { background-color: $toast-background-color; box-shadow: $toast-box-shadow; margin: 0; @@ -16,14 +16,14 @@ align-self: flex-start; } - .toast__header { + .pgn__toast__header { display: flex; align-items: center; border-bottom: 0; justify-content: space-between; padding: 0; - .toast__message { + .pgn__toast__message { font-size: $small-font-size; margin: 0; padding-right: .75rem; @@ -35,7 +35,7 @@ } } - .toast__optional-actions { + .pgn__toast__optional-actions { display: flex; flex-wrap: wrap; } @@ -50,7 +50,7 @@ } } -.toast-container { +.pgn__toast-container { display: flex; flex-direction: column; gap: .5rem; diff --git a/src/Toast/tests/Toast.test.jsx b/src/Toast/tests/Toast.test.jsx index f8a7671663..dac469a745 100644 --- a/src/Toast/tests/Toast.test.jsx +++ b/src/Toast/tests/Toast.test.jsx @@ -3,8 +3,8 @@ 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'; +import ToastContainer from '..'; +import { toast } from '../utils'; jest.useFakeTimers(); diff --git a/src/Toast/toast.js b/src/Toast/toast.js deleted file mode 100644 index 0d525461c7..0000000000 --- a/src/Toast/toast.js +++ /dev/null @@ -1,6 +0,0 @@ -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/Toast/EventEmitter.js b/src/Toast/utils/EventEmitter.js similarity index 100% rename from src/Toast/EventEmitter.js rename to src/Toast/utils/EventEmitter.js diff --git a/src/Toast/utils/index.jsx b/src/Toast/utils/index.jsx new file mode 100644 index 0000000000..abbbc7cbf2 --- /dev/null +++ b/src/Toast/utils/index.jsx @@ -0,0 +1,2 @@ +export { toast } from './toast'; +export { toastEmitter } from './EventEmitter'; diff --git a/src/Toast/utils/toast.js b/src/Toast/utils/toast.js new file mode 100644 index 0000000000..b1118d41c5 --- /dev/null +++ b/src/Toast/utils/toast.js @@ -0,0 +1,27 @@ +import { toastEmitter } from './EventEmitter'; +/** + * Represents an action for a toast notification. + * + * @typedef {Object} ToastAction + * @property {string} [href] - If present, the action is rendered as an anchor tag (``). + * @property {Function} [onClick] - The function to call when the action is clicked. + * @property {string} label - The text label for the action. + */ + +/** + * Displays a toast notification. + * + * @param {string} message - The message to be displayed in the toast. + * @param {number} duration - The duration for which the toast should be visible. + * @param {ToastAction[]} actions - An array of action objects for the toast. + * @param {string} position - The position where the toast will be displayed. +*/ + +// eslint-disable-next-line import/prefer-default-export +export const toast = ({ + message, duration, actions, position, +}) => { + toastEmitter.emit('showToast', { + message, duration, actions, position, + }); +}; diff --git a/src/index.js b/src/index.js index 7eb3625c49..a7da756891 100644 --- a/src/index.js +++ b/src/index.js @@ -133,8 +133,7 @@ export { TabPane, } from './Tabs'; export { default as TextArea } from './TextArea'; -export { default as ToastContainer } from './Toast/ToastContainer'; -export { toast } from './Toast/toast'; +export { default as ToastContainer, toast } from './Toast'; export { default as Tooltip } from './Tooltip'; export { default as ValidationFormGroup } from './ValidationFormGroup'; export { default as TransitionReplace } from './TransitionReplace'; diff --git a/www/gatsby-browser.jsx b/www/gatsby-browser.jsx index 8b9fe8c574..065a67e0bc 100644 --- a/www/gatsby-browser.jsx +++ b/www/gatsby-browser.jsx @@ -1,12 +1,14 @@ const React = require('react'); const { SettingsContextProvider } = require('./src/context/SettingsContext'); const { InsightsContextProvider } = require('./src/context/InsightsContext'); +const { ToastContainer } = require("~paragon-react"); // wrap whole app in settings context exports.wrapRootElement = ({ element }) => ( {element} + ); diff --git a/www/src/components/CodeBlock.tsx b/www/src/components/CodeBlock.tsx index ff14ffc7dc..3cdccf443b 100644 --- a/www/src/components/CodeBlock.tsx +++ b/www/src/components/CodeBlock.tsx @@ -30,9 +30,7 @@ import MiyazakiCard from './exampleComponents/MiyazakiCard'; import HipsterIpsum from './exampleComponents/HipsterIpsum'; import ExamplePropsForm from './exampleComponents/ExamplePropsForm'; -const { - Collapsible, IconButton, Icon, toast, ToastContainer -} = ParagonReact; +const { Collapsible, IconButton, Icon, toast } = ParagonReact; export type CollapsibleLiveEditorTypes = { children: React.ReactNode; @@ -167,7 +165,6 @@ function CodeBlock({ -
); } diff --git a/www/src/components/IconsTable.tsx b/www/src/components/IconsTable.tsx index cb0bdd8afe..a10e805bdd 100644 --- a/www/src/components/IconsTable.tsx +++ b/www/src/components/IconsTable.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; -import { Icon, SearchField, ToastContainer, toast } from '~paragon-react'; +import { Icon, SearchField, toast } from '~paragon-react'; import * as IconComponents from '~paragon-icons'; import { ICON_COPIED_EVENT, sendUserAnalyticsEvent } from '../../segment-events'; @@ -177,7 +177,6 @@ function IconsTable({ iconNames }) {
- ); } diff --git a/www/src/pages/foundations/elevation.jsx b/www/src/pages/foundations/elevation.jsx index fa2bf850a9..3242c1ec68 100644 --- a/www/src/pages/foundations/elevation.jsx +++ b/www/src/pages/foundations/elevation.jsx @@ -5,7 +5,6 @@ import { Button, Form, Input, - ToastContainer, toast, Icon, IconButtonWithTooltip, @@ -55,7 +54,6 @@ function BoxShadowNode() { return (
{ boxShadowCells } -
); } From 6b38233a59d873c4b87db7624c92659d9c248af5 Mon Sep 17 00:00:00 2001 From: Kyrylo Hudym-Levkovych Date: Tue, 2 Jan 2024 15:44:07 +0200 Subject: [PATCH 5/8] refactor: update styles after review --- src/Toast/index.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Toast/index.scss b/src/Toast/index.scss index 1bfb4aefc9..873e5aa151 100644 --- a/src/Toast/index.scss +++ b/src/Toast/index.scss @@ -4,10 +4,9 @@ background-color: $toast-background-color; box-shadow: $toast-box-shadow; margin: 0; - padding: 1rem; + padding: $spacer; position: relative; border-radius: $toast-border-radius; - z-index: 1000; display: flex; flex-direction: column; @@ -54,6 +53,7 @@ display: flex; flex-direction: column; gap: .5rem; + padding: $spacer; position: fixed; z-index: 3000; } From b5563daeb01fe88b8a2c00e6695cdc330908bf5e Mon Sep 17 00:00:00 2001 From: Kyrylo Hudym-Levkovych Date: Tue, 2 Jan 2024 15:53:23 +0200 Subject: [PATCH 6/8] refactor: remove unnecessary gap after review --- src/Toast/BaseToast.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Toast/BaseToast.jsx b/src/Toast/BaseToast.jsx index ca89bb2044..64eb1e28bb 100644 --- a/src/Toast/BaseToast.jsx +++ b/src/Toast/BaseToast.jsx @@ -47,7 +47,6 @@ function Toast({ >

{message}

- Date: Wed, 3 Jan 2024 13:18:19 +0200 Subject: [PATCH 7/8] test: change toastEmitter path in tests --- src/Toast/tests/EventEmitter.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Toast/tests/EventEmitter.test.js b/src/Toast/tests/EventEmitter.test.js index 2e21ac5646..cc5df569c5 100644 --- a/src/Toast/tests/EventEmitter.test.js +++ b/src/Toast/tests/EventEmitter.test.js @@ -1,4 +1,4 @@ -import { toastEmitter } from '../EventEmitter'; +import { toastEmitter } from '../utils'; describe('EventEmitter', () => { test('subscribes and emits an event', () => { From 21168db4d5d11f7d433907f92652445c6230d475 Mon Sep 17 00:00:00 2001 From: Kyrylo Hudym-Levkovych Date: Wed, 3 Jan 2024 13:26:56 +0200 Subject: [PATCH 8/8] fix: fix linter errors --- www/gatsby-browser.jsx | 2 +- www/src/components/CodeBlock.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/www/gatsby-browser.jsx b/www/gatsby-browser.jsx index 065a67e0bc..ce53143da5 100644 --- a/www/gatsby-browser.jsx +++ b/www/gatsby-browser.jsx @@ -1,7 +1,7 @@ const React = require('react'); +const { ToastContainer } = require('~paragon-react'); const { SettingsContextProvider } = require('./src/context/SettingsContext'); const { InsightsContextProvider } = require('./src/context/InsightsContext'); -const { ToastContainer } = require("~paragon-react"); // wrap whole app in settings context exports.wrapRootElement = ({ element }) => ( diff --git a/www/src/components/CodeBlock.tsx b/www/src/components/CodeBlock.tsx index 3cdccf443b..8be3735543 100644 --- a/www/src/components/CodeBlock.tsx +++ b/www/src/components/CodeBlock.tsx @@ -30,7 +30,9 @@ import MiyazakiCard from './exampleComponents/MiyazakiCard'; import HipsterIpsum from './exampleComponents/HipsterIpsum'; import ExamplePropsForm from './exampleComponents/ExamplePropsForm'; -const { Collapsible, IconButton, Icon, toast } = ParagonReact; +const { + Collapsible, IconButton, Icon, toast, +} = ParagonReact; export type CollapsibleLiveEditorTypes = { children: React.ReactNode; @@ -129,7 +131,7 @@ function CodeBlock({ const handleCopyCodeExample = () => { navigator.clipboard.writeText(codeExample); - toast({message: 'Code example copied to clipboard!', duration: 2000 }); + toast({ message: 'Code example copied to clipboard!', duration: 2000 }); }; if (live) {