diff --git a/src/components/toaster.tsx b/src/components/toaster.tsx index aabf222..715d4e9 100644 --- a/src/components/toaster.tsx +++ b/src/components/toaster.tsx @@ -1,6 +1,11 @@ import { css, setup } from 'goober'; import * as React from 'react'; -import { resolveValue, ToasterProps, ToastPosition } from '../core/types'; +import { + resolveValue, + ToastMessageProps, + ToasterProps, + ToastPosition, +} from '../core/types'; import { useToaster } from '../core/use-toaster'; import { createRectRef, prefersReducedMotion } from '../core/utils'; import { ToastBar } from './toast-bar'; @@ -45,6 +50,30 @@ const activeClass = css` const DEFAULT_OFFSET = 16; +const ToastMessage: React.FC = ({ + id, + height, + className, + style, + onUpdateHeight, + children, +}) => { + const hasHeight = typeof height === 'number'; + const ref = React.useMemo(() => { + return hasHeight + ? undefined + : createRectRef((rect) => { + onUpdateHeight(id, rect.height); + }); + }, [hasHeight, onUpdateHeight]); + + return ( +
+ {children} +
+ ); +}; + export const Toaster: React.FC = ({ reverseOrder, position = 'top-center', @@ -81,18 +110,14 @@ export const Toaster: React.FC = ({ }); const positionStyle = getPositionStyle(toastPosition, offset); - const ref = t.height - ? undefined - : createRectRef((rect) => { - handlers.updateHeight(t.id, rect.height); - }); - return ( -
{t.type === 'custom' ? ( resolveValue(t.message, t) @@ -101,7 +126,7 @@ export const Toaster: React.FC = ({ ) : ( )} -
+ ); })} diff --git a/src/core/store.ts b/src/core/store.ts index 1ac4284..6c6c560 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -4,13 +4,13 @@ import { DefaultToastOptions, Toast, ToastType } from './types'; const TOAST_LIMIT = 20; export enum ActionType { - ADD_TOAST, - UPDATE_TOAST, - UPSERT_TOAST, - DISMISS_TOAST, - REMOVE_TOAST, - START_PAUSE, - END_PAUSE, + ADD_TOAST = 'ADD_TOAST', + UPDATE_TOAST = 'UPDATE_TOAST', + UPSERT_TOAST = 'UPSERT_TOAST', + DISMISS_TOAST = 'DISMISS_TOAST', + REMOVE_TOAST = 'REMOVE_TOAST', + START_PAUSE = 'START_PAUSE', + END_PAUSE = 'END_PAUSE', } type Action = diff --git a/src/core/types.ts b/src/core/types.ts index aeccbf0..30ed449 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -73,6 +73,15 @@ export type DefaultToastOptions = ToastOptions & [key in ToastType]?: ToastOptions; }; +export interface ToastMessageProps { + id: string; + height: number | undefined; + className?: string; + style?: React.CSSProperties; + onUpdateHeight: (id: string, height: number) => void; + children?: React.ReactNode; +} + export interface ToasterProps { position?: ToastPosition; toastOptions?: DefaultToastOptions; diff --git a/src/core/use-toaster.ts b/src/core/use-toaster.ts index ceb9d32..fe1bacd 100644 --- a/src/core/use-toaster.ts +++ b/src/core/use-toaster.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useCallback } from 'react'; import { dispatch, ActionType, useStore } from './store'; import { toast } from './toast'; import { DefaultToastOptions, Toast, ToastPosition } from './types'; @@ -34,58 +34,64 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => { }; }, [toasts, pausedAt]); - const handlers = useMemo( - () => ({ - startPause: () => { - dispatch({ - type: ActionType.START_PAUSE, - time: Date.now(), - }); - }, - endPause: () => { - if (pausedAt) { - dispatch({ type: ActionType.END_PAUSE, time: Date.now() }); - } - }, - updateHeight: (toastId: string, height: number) => - dispatch({ - type: ActionType.UPDATE_TOAST, - toast: { id: toastId, height }, - }), - calculateOffset: ( - toast: Toast, - opts?: { - reverseOrder?: boolean; - gutter?: number; - defaultPosition?: ToastPosition; - } - ) => { - const { reverseOrder = false, gutter = 8, defaultPosition } = - opts || {}; + const startPause = useCallback(() => { + dispatch({ + type: ActionType.START_PAUSE, + time: Date.now(), + }); + }, []); + + const endPause = useCallback(() => { + if (pausedAt) { + dispatch({ type: ActionType.END_PAUSE, time: Date.now() }); + } + }, [pausedAt]); + + const updateHeight = useCallback((toastId: string, height: number) => { + dispatch({ + type: ActionType.UPDATE_TOAST, + toast: { id: toastId, height }, + }); + }, []); + + const calculateOffset = useCallback( + ( + toast: Toast, + opts?: { + reverseOrder?: boolean; + gutter?: number; + defaultPosition?: ToastPosition; + } + ) => { + const { reverseOrder = false, gutter = 8, defaultPosition } = opts || {}; - const relevantToasts = toasts.filter( - (t) => - (t.position || defaultPosition) === - (toast.position || defaultPosition) && t.height - ); - const toastIndex = relevantToasts.findIndex((t) => t.id === toast.id); - const toastsBefore = relevantToasts.filter( - (toast, i) => i < toastIndex && toast.visible - ).length; + const relevantToasts = toasts.filter( + (t) => + (t.position || defaultPosition) === + (toast.position || defaultPosition) && t.height + ); + const toastIndex = relevantToasts.findIndex((t) => t.id === toast.id); + const toastsBefore = relevantToasts.filter( + (toast, i) => i < toastIndex && toast.visible + ).length; - const offset = relevantToasts - .filter((t) => t.visible) - .slice(...(reverseOrder ? [toastsBefore + 1] : [0, toastsBefore])) - .reduce((acc, t) => acc + (t.height || 0) + gutter, 0); + const offset = relevantToasts + .filter((t) => t.visible) + .slice(...(reverseOrder ? [toastsBefore + 1] : [0, toastsBefore])) + .reduce((acc, t) => acc + (t.height || 0) + gutter, 0); - return offset; - }, - }), - [toasts, pausedAt] + return offset; + }, + [toasts] ); return { toasts, - handlers, + handlers: { + startPause, + endPause, + updateHeight, + calculateOffset, + }, }; }; diff --git a/test/toast.test.tsx b/test/toast.test.tsx new file mode 100644 index 0000000..2a97193 --- /dev/null +++ b/test/toast.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import toast, { Toaster } from '../src'; +import { TOAST_EXPIRE_DISMISS_DELAY } from '../src/core/store'; + +let matchMedia: MatchMediaMock; + +beforeAll(() => { + matchMedia = new MatchMediaMock(); + matchMedia.useMediaQuery('(prefers-reduced-motion: reduce)'); +}); + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach((done) => { + act(() => { + jest.runAllTimers(); + jest.useRealTimers(); + done(); + }); +}); + +afterAll(() => { + matchMedia.destroy(); +}); + +test('close notification', async () => { + render( + <> + + + + )); + }} + > + Notify! + + + ); + userEvent.click(screen.getByRole('button', { name: /notify/i })); + screen.getByText(/example/i); + + userEvent.click(await screen.findByRole('button', { name: /close/i })); + act(() => { + jest.advanceTimersByTime(TOAST_EXPIRE_DISMISS_DELAY); + }); + expect(screen.queryByText(/example/i)).not.toBeInTheDocument(); +});