diff --git a/src/components/Toaster/Provider/ToasterProvider.tsx b/src/components/Toaster/Provider/ToasterProvider.tsx index f6aaf3629e..89b85adf59 100644 --- a/src/components/Toaster/Provider/ToasterProvider.tsx +++ b/src/components/Toaster/Provider/ToasterProvider.tsx @@ -2,105 +2,32 @@ import React from 'react'; -import type {InternalToastProps, ToastProps, ToasterPublicMethods} from '../types'; -import {getToastIndex} from '../utilities/getToastIndex'; -import {hasToast} from '../utilities/hasToast'; -import {removeToast} from '../utilities/removeToast'; +import type {ToasterSingleton} from '../ToasterSingleton'; +import type {InternalToastProps} from '../types'; import {ToasterContext} from './ToasterContext'; import {ToastsContext} from './ToastsContext'; -type Props = React.PropsWithChildren<{}>; +type Props = React.PropsWithChildren<{ + toaster: ToasterSingleton; +}>; -export const ToasterProvider = React.forwardRef( - function ToasterProvider({children}: Props, ref) { - const [toasts, setToasts] = React.useState([]); +export const ToasterProvider = ({toaster, children}: Props) => { + const [toasts, setToasts] = React.useState([]); - const add = React.useCallback((toast: ToastProps) => { - const {name} = toast; + React.useEffect(() => { + const unsubscribe = toaster.subscribe(setToasts); - setToasts((toasts) => { - let nextToasts = toasts; + return () => { + unsubscribe(); + }; + }, [toaster]); - if (hasToast(toasts, name)) { - nextToasts = removeToast(toasts, name); - } - - return [ - ...nextToasts, - { - ...toast, - addedAt: Date.now(), - ref: React.createRef(), - }, - ]; - }); - }, []); - - const remove = React.useCallback((toastName: ToastProps['name']) => { - setToasts((toasts) => { - return removeToast(toasts, toastName); - }); - }, []); - - const removeAll = React.useCallback(() => { - setToasts(() => []); - }, []); - - const update = React.useCallback( - (toastName: ToastProps['name'], override: Partial) => { - setToasts((toasts) => { - if (!hasToast(toasts, toastName)) { - return toasts; - } - - const index = getToastIndex(toasts, toastName); - - return [ - ...toasts.slice(0, index), - { - ...toasts[index], - ...override, - }, - ...toasts.slice(index + 1), - ]; - }); - }, - [], - ); - - const toastsRef = React.useRef(toasts); - React.useEffect(() => { - toastsRef.current = toasts; - }, [toasts]); - const has = React.useCallback((toastName: ToastProps['name']) => { - return toastsRef.current ? hasToast(toastsRef.current, toastName) : false; - }, []); - - const toasterContext = React.useMemo(() => { - return { - add, - remove, - removeAll, - update, - has, - }; - }, [add, remove, removeAll, update, has]); - - React.useImperativeHandle(ref, () => ({ - add, - remove, - removeAll, - update, - has, - })); - - return ( - - {children} - - ); - }, -); + return ( + + {children} + + ); +}; ToasterProvider.displayName = 'ToasterProvider'; diff --git a/src/components/Toaster/README.md b/src/components/Toaster/README.md index f4b8de7990..7f2eeb682a 100644 --- a/src/components/Toaster/README.md +++ b/src/components/Toaster/README.md @@ -11,11 +11,13 @@ Component for adjustable notifications. ```jsx import React from 'react'; import ReactDOMClient from 'react-dom/client'; -import {ToasterComponent, ToasterProvider} from '@gravity-ui/uikit'; +import {Toaster, ToasterComponent, ToasterProvider} from '@gravity-ui/uikit'; + +const toaster = new Toaster(); const root = ReactDOMClient.createRoot(document.getElementById('root')); root.render( - + , @@ -66,8 +68,6 @@ const FoobarWithToaster = withToaster()(FoobarComponent); Toaster has singleton, so when it is initialized in different parts of the application, the same instance will be returned. On initialization, it is possible to transmit a className that will be assigned to dom-element which wrap all toasts. -### React < 18 - ```js import {Toaster} from '@gravity-ui/uikit'; const toaster = new Toaster(); @@ -79,34 +79,13 @@ or import {toaster} from '@gravity-ui/uikit/toaster-singleton'; ``` -### React 18 - -```js -import ReactDOMClient from 'react-dom/client'; -import {Toaster} from '@gravity-ui/uikit'; -Toaster.injectReactDOMClient(ReactDOMClient); -const toaster = new Toaster(); -``` - -or - -```js -import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; -``` - -## Constructor arguments - -| Parameter | Type | Default | Description | -| :-------- | :-------- | :---------- | :-------------------------------------------------- | -| className | `string` | `undefined` | Custom class name to add to the component container | -| mobile | `boolean` | `false` | Configuration that manages mobile/desktop views | - ## Methods | Method name | Params | Description | | :---------------------------- | :----------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | | add(toastOptions) | `Object` | Creates a new notification | | remove(name) | `string` | Manually deletes an existing notification | +| removeAll() | | Deletes all existing notifications | | update(name, overrideOptions) | `string`, `Object` | Changes already rendered notification content. In `overrideOptions`, the following fields are optional: `title`, `type`, `content`, `actions` | | has(name) | `string` | Checks fora toast with the given name in the list of displayed toasts | diff --git a/src/components/Toaster/ToasterSingleton.tsx b/src/components/Toaster/ToasterSingleton.tsx index 15096e70c5..f332afebc1 100644 --- a/src/components/Toaster/ToasterSingleton.tsx +++ b/src/components/Toaster/ToasterSingleton.tsx @@ -1,19 +1,11 @@ 'use client'; -import React from 'react'; - -import get from 'lodash/get'; -import ReactDOM from 'react-dom'; - -import {block} from '../utils/cn'; - -import {ToasterProvider} from './Provider/ToasterProvider'; -import {ToasterComponent} from './ToasterComponent/ToasterComponent'; -import type {ToastProps, ToasterArgs, ToasterPublicMethods} from './types'; +import type {InternalToastProps, ToastProps} from './types'; +import {getToastIndex} from './utilities/getToastIndex'; +import {hasToast} from './utilities/hasToast'; +import {removeToast} from './utilities/removeToast'; const TOASTER_KEY: unique symbol = Symbol('Toaster instance key'); -const bToaster = block('toaster'); -let ReactDOMClient: any; declare global { interface Window { @@ -22,95 +14,86 @@ declare global { } export class ToasterSingleton { - static injectReactDOMClient(client: any) { - ReactDOMClient = client; - } + private toasts: InternalToastProps[] = []; + private listeners: ((toasts: InternalToastProps[]) => void)[] = []; - private rootNode!: HTMLDivElement; - private reactRoot!: any; - private className = ''; - private mobile = false; - private componentAPI: null | ToasterPublicMethods = null; + constructor() { + if (window[TOASTER_KEY] instanceof ToasterSingleton) { + return window[TOASTER_KEY]; + } - constructor(args?: ToasterArgs) { - const className = get(args, ['className'], ''); - const mobile = get(args, ['mobile'], false); + window[TOASTER_KEY] = this; + } - if (window[TOASTER_KEY] instanceof ToasterSingleton) { - const me = window[TOASTER_KEY]; - me.className = className; - me.mobile = mobile; - me.setRootNodeClassName(); - return me; + add(toast: ToastProps) { + let nextToasts = this.toasts; + + if (hasToast(nextToasts, toast.name)) { + nextToasts = removeToast(nextToasts, toast.name); } - this.className = className; - this.mobile = mobile; - this.createRootNode(); - this.createReactRoot(); - this.render(); + this.toasts = [ + ...nextToasts, + { + ...toast, + addedAt: Date.now(), + ref: {current: null}, + }, + ]; - window[TOASTER_KEY] = this; + this.notify(); } - destroy() { - // eslint-disable-next-line react/no-deprecated - ReactDOM.unmountComponentAtNode(this.rootNode); - document.body.removeChild(this.rootNode); + remove(name: string) { + this.toasts = removeToast(this.toasts, name); + + this.notify(); } - add = (options: ToastProps) => { - this.componentAPI?.add(options); - }; + removeAll() { + this.toasts = []; - remove = (name: string) => { - this.componentAPI?.remove(name); - }; + this.notify(); + } - removeAll = () => { - this.componentAPI?.removeAll(); - }; + update(name: string, overrideOptions: Partial) { + if (!hasToast(this.toasts, name)) { + return; + } - update = (name: string, overrideOptions: Partial) => { - this.componentAPI?.update(name, overrideOptions); - }; + const index = getToastIndex(this.toasts, name); - has = (name: string) => { - return this.componentAPI?.has(name) ?? false; - }; + this.toasts = [ + ...this.toasts.slice(0, index), + { + ...this.toasts[index], + ...overrideOptions, + }, + ...this.toasts.slice(index + 1), + ]; - private createRootNode() { - this.rootNode = document.createElement('div'); - this.setRootNodeClassName(); - document.body.appendChild(this.rootNode); + this.notify(); } - private createReactRoot() { - if (ReactDOMClient) { - this.reactRoot = ReactDOMClient.createRoot(this.rootNode); - } + has(name: string) { + return hasToast(this.toasts, name); } - private render() { - const container = ( - { - this.componentAPI = api; - }} - > - - - ); - - if (this.reactRoot) { - this.reactRoot.render(container); - } else { - // eslint-disable-next-line react/no-deprecated - ReactDOM.render(container, this.rootNode, () => Promise.resolve()); + subscribe(listener: (toasts: InternalToastProps[]) => void) { + if (typeof listener === 'function') { + this.listeners.push(listener); } + + return () => { + this.listeners = this.listeners.filter( + (currentListener) => listener !== currentListener, + ); + }; } - private setRootNodeClassName() { - this.rootNode.className = bToaster({mobile: this.mobile}, this.className); + private notify() { + for (const listener of this.listeners) { + listener(this.toasts); + } } } diff --git a/src/components/Toaster/__stories__/Toaster.stories.tsx b/src/components/Toaster/__stories__/Toaster.stories.tsx index 6ab0d4f961..eea7fede6c 100644 --- a/src/components/Toaster/__stories__/Toaster.stories.tsx +++ b/src/components/Toaster/__stories__/Toaster.stories.tsx @@ -8,6 +8,7 @@ import {BUTTON_VIEWS} from '../../Button/constants'; import {ToasterProvider} from '../Provider/ToasterProvider'; import {Toast} from '../Toast/Toast'; import {ToasterComponent} from '../ToasterComponent/ToasterComponent'; +import {ToasterSingleton} from '../ToasterSingleton'; import {TOAST_THEMES} from '../constants'; import {useToaster} from '../hooks/useToaster'; import type {ToastAction} from '../types'; @@ -53,13 +54,15 @@ function booleanControl(label: string) { }; } +const toasterInstance = new ToasterSingleton(); + export default { title: 'Components/Feedback/Toaster', component: Toast, decorators: [ function withToasters(Story) { return ( - + ); diff --git a/src/components/Toaster/__stories__/ToasterShowcase.tsx b/src/components/Toaster/__stories__/ToasterShowcase.tsx index 8f166a51a3..2fc921bc3a 100644 --- a/src/components/Toaster/__stories__/ToasterShowcase.tsx +++ b/src/components/Toaster/__stories__/ToasterShowcase.tsx @@ -3,7 +3,7 @@ import React from 'react'; import {faker} from '@faker-js/faker/locale/en'; import {CircleCheck, CircleInfo, Thunderbolt, TriangleExclamation} from '@gravity-ui/icons'; -import {ToasterComponent, useToaster} from '..'; +import {Toaster, ToasterComponent, useToaster} from '..'; import type {ToastAction, ToastProps} from '..'; import {Button} from '../../Button'; import type {ButtonView} from '../../Button'; @@ -14,6 +14,8 @@ import './ToasterShowcase.scss'; const b = cn('toaster-showcase'); +export const toasterInstance = new Toaster(); + const CONTENT = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Adipisci, atque!'; const ACTIONS = [ @@ -371,6 +373,22 @@ export const ToasterDemo = ({ ); + const singletonToasterBtn = ( + + ); + const component = React.useMemo(() => , []); return ( @@ -385,6 +403,7 @@ export const ToasterDemo = ({

{toastWithLongContent}

{dynamicallyUpdatingToast}

{overrideToastBtn}

+

{singletonToasterBtn}

{clearBtn}

{component} diff --git a/src/components/Toaster/__tests__/ToasterProvider.test.tsx b/src/components/Toaster/__tests__/ToasterProvider.test.tsx index 78494ae8e2..30280e99f6 100644 --- a/src/components/Toaster/__tests__/ToasterProvider.test.tsx +++ b/src/components/Toaster/__tests__/ToasterProvider.test.tsx @@ -7,38 +7,20 @@ import {ToasterComponent} from '../ToasterComponent/ToasterComponent'; import {fireAnimationEndEvent} from '../__mocks__/fireAnimationEndEvent'; import {getToast} from '../__mocks__/getToast'; import {tick} from '../__mocks__/tick'; -import {useToaster} from '../hooks/useToaster'; -import type {ToasterPublicMethods} from '../types'; +import {Toaster} from '../index'; -function ToastAPI({onMount}: {onMount: (api: ToasterPublicMethods) => void}) { - const toaster = useToaster(); - - React.useEffect(() => { - onMount(toaster); - }, []); - - return null; -} +const toasterInstance = new Toaster(); function setup() { - let providerAPI: undefined | ToasterPublicMethods; - render( - - { - providerAPI = api; - }} - /> + , ); - if (!providerAPI) { + if (!toasterInstance) { throw new Error('Failed to setup test'); } - - return providerAPI; } const toastTimeout = 1000; @@ -54,10 +36,10 @@ describe('api.add', () => { // We test that after adding toast the next add will remove // previous toast from DOM and add it again it('should override already added toast', async function () { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add(toastProps); + toasterInstance.add(toastProps); }); let toast = getToast(); @@ -67,7 +49,7 @@ describe('api.add', () => { jest.advanceTimersByTime(1); act(() => { - providerAPI.add(toastProps); + toasterInstance.add(toastProps); }); fireAnimationEndEvent(toast, 'toast-hide-end'); @@ -81,10 +63,10 @@ describe('api.add', () => { describe('api.remove', () => { it('should remove toast', function () { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add({ + toasterInstance.add({ ...toastProps, autoHiding: toastTimeout, }); @@ -94,7 +76,7 @@ describe('api.remove', () => { expect(toast).toBeInTheDocument(); act(() => { - providerAPI.remove(toastProps.name); + toasterInstance.remove(toastProps.name); }); tick(toast, 0); @@ -104,10 +86,10 @@ describe('api.remove', () => { }); it('should remove toast after timeout', function () { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add({ + toasterInstance.add({ ...toastProps, autoHiding: toastTimeout, }); @@ -132,10 +114,10 @@ it('should remove toast after timeout', function () { }); it('should preserve toast on hover', function () { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add({ + toasterInstance.add({ ...toastProps, autoHiding: toastTimeout, }); @@ -170,10 +152,10 @@ it('should preserve toast on hover', function () { describe('api.update', () => { it('should update toast', function () { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add({ + toasterInstance.add({ ...toastProps, autoHiding: toastTimeout, }); @@ -185,7 +167,7 @@ describe('api.update', () => { expect(screen.queryByRole('button', {name: 'Toast Button'})).not.toBeInTheDocument(); act(() => { - providerAPI.update(toastProps.name, { + toasterInstance.update(toastProps.name, { content: 'Test Content of the toast', actions: [ { @@ -202,10 +184,10 @@ describe('api.update', () => { }); it('should bypass update of unexisted toasts', function () { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add({ + toasterInstance.add({ ...toastProps, autoHiding: toastTimeout, }); @@ -214,7 +196,7 @@ describe('api.update', () => { const toast = getToast(); act(() => { - providerAPI.update(`unexisted ${toastProps.name}`, { + toasterInstance.update(`unexisted ${toastProps.name}`, { content: 'Test Content of the toast', actions: [ { @@ -231,11 +213,11 @@ describe('api.update', () => { describe('api.removeAll', () => { it('should remove all toasts', function () { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add(toastProps); - providerAPI.add({ + toasterInstance.add(toastProps); + toasterInstance.add({ ...toastProps, name: `${toastProps.name}2`, title: `${toastProps.title}2`, @@ -249,7 +231,7 @@ describe('api.removeAll', () => { expect(toast2).toBeInTheDocument(); act(() => { - providerAPI.removeAll(); + toasterInstance.removeAll(); }); [toast1, toast2].forEach((toast) => fireAnimationEndEvent(toast, 'toast-hide-end')); @@ -261,45 +243,45 @@ describe('api.removeAll', () => { describe('api.has', () => { it('should return false when toast is not added', () => { - const providerAPI = setup(); - expect(providerAPI.has('unexisted toasts')).toBe(false); + setup(); + expect(toasterInstance.has('unexisted toasts')).toBe(false); }); it('should return false when toast is removed by code', () => { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add({ + toasterInstance.add({ ...toastProps, autoHiding: toastTimeout, }); }); - expect(providerAPI.has(toastProps.name)).toBe(true); + expect(toasterInstance.has(toastProps.name)).toBe(true); act(() => { - providerAPI.remove(toastProps.name); + toasterInstance.remove(toastProps.name); }); - expect(providerAPI.has(toastProps.name)).toBe(false); + expect(toasterInstance.has(toastProps.name)).toBe(false); }); it('should return false when toast is removed by timer', () => { - const providerAPI = setup(); + setup(); act(() => { - providerAPI.add({ + toasterInstance.add({ ...toastProps, autoHiding: toastTimeout, }); }); - expect(providerAPI.has(toastProps.name)).toBe(true); + expect(toasterInstance.has(toastProps.name)).toBe(true); act(() => { jest.advanceTimersByTime(toastTimeout); }); - expect(providerAPI.has(toastProps.name)).toBe(false); + expect(toasterInstance.has(toastProps.name)).toBe(false); }); }); @@ -322,16 +304,10 @@ describe('modal remains open after toaster close', () => { }; function setup() { - let providerAPI: undefined | ToasterPublicMethods; let openModal: undefined | (() => void); render( - - { - providerAPI = api; - }} - /> + { openModal = _openModal; @@ -341,15 +317,15 @@ describe('modal remains open after toaster close', () => { , ); - if (!providerAPI || !openModal) { + if (!toasterInstance || !openModal) { throw new Error('Failed to setup test'); } - return {providerAPI, openModal}; + return {openModal}; } it('Toaster was opened after Modal', async () => { - const {providerAPI, openModal} = setup(); + const {openModal} = setup(); act(openModal); @@ -357,7 +333,7 @@ describe('modal remains open after toaster close', () => { expect(modal).toBeInTheDocument(); act(() => { - providerAPI.add({...toastProps, isClosable: true}); + toasterInstance.add({...toastProps, isClosable: true}); }); const toast = getToast(); @@ -380,10 +356,10 @@ describe('modal remains open after toaster close', () => { }); it('Toaster was opened before Modal', async () => { - const {providerAPI, openModal} = setup(); + const {openModal} = setup(); act(() => { - providerAPI.add({...toastProps, isClosable: true}); + toasterInstance.add({...toastProps, isClosable: true}); }); const toast = getToast(); @@ -411,12 +387,12 @@ describe('modal remains open after toaster close', () => { }); it('Toaster calls onClose callback when close icon is clicked', async () => { - const {providerAPI} = setup(); + setup(); const mockOnCloseFn = jest.fn(); act(() => { - providerAPI.add({...toastProps, isClosable: true, onClose: mockOnCloseFn}); + toasterInstance.add({...toastProps, isClosable: true, onClose: mockOnCloseFn}); }); const toast = getToast(); diff --git a/src/components/Toaster/hooks/useToaster.ts b/src/components/Toaster/hooks/useToaster.ts index 4d069aaa79..527a4ec101 100644 --- a/src/components/Toaster/hooks/useToaster.ts +++ b/src/components/Toaster/hooks/useToaster.ts @@ -10,5 +10,14 @@ export function useToaster(): ToasterPublicMethods { throw new Error('Toaster: `useToaster` hook is used out of context'); } - return React.useMemo(() => toaster, [toaster]); + return React.useMemo( + () => ({ + add: toaster.add.bind(toaster), + remove: toaster.remove.bind(toaster), + removeAll: toaster.removeAll.bind(toaster), + update: toaster.update.bind(toaster), + has: toaster.has.bind(toaster), + }), + [toaster], + ); } diff --git a/src/toaster-singleton-react-18.ts b/src/toaster-singleton-react-18.ts deleted file mode 100644 index 983f3594af..0000000000 --- a/src/toaster-singleton-react-18.ts +++ /dev/null @@ -1,9 +0,0 @@ -import ReactDOMClient from 'react-dom/client'; - -import {ToasterSingleton} from './components/Toaster/ToasterSingleton'; - -ToasterSingleton.injectReactDOMClient(ReactDOMClient); - -// in SSR case -export const toaster = - typeof window === 'object' ? new ToasterSingleton() : ({} as ToasterSingleton);