diff --git a/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts b/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts index d9247ba3392d4..bd098e05b502c 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts +++ b/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts @@ -7,8 +7,8 @@ import { useEventCallback, resolveShorthand, } from '@fluentui/react-utilities'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; import type { ToastContainerProps, ToastContainerState } from './ToastContainer.types'; -import { useToast } from '../../state'; import { Timer, TimerProps } from '../Timer/Timer'; const intentPolitenessMap = { @@ -42,9 +42,30 @@ export const useToastContainer_unstable = ( timeout: timerTimeout, politeness: desiredPoliteness, intent = 'info', + pauseOnHover, + pauseOnWindowBlur, ...rest } = props; - const { play, running, toastRef } = useToast({ ...props }); + const toastRef = React.useRef(null); + const { targetDocument } = useFluent_unstable(); + const [running, setRunning] = React.useState(false); + const pause = useEventCallback(() => setRunning(false)); + const play = useEventCallback(() => setRunning(true)); + + React.useEffect(() => { + if (!targetDocument) { + return; + } + + if (pauseOnWindowBlur) { + targetDocument.defaultView?.addEventListener('focus', play); + targetDocument.defaultView?.addEventListener('blur', pause); + return () => { + targetDocument.defaultView?.removeEventListener('focus', play); + targetDocument.defaultView?.removeEventListener('blur', pause); + }; + } + }, [targetDocument, pause, play, pauseOnWindowBlur]); React.useEffect(() => { if (!visible) { @@ -76,6 +97,16 @@ export const useToastContainer_unstable = ( userRootSlot?.onAnimationEnd?.(e); }); + const onMouseEnter = useEventCallback((e: React.MouseEvent) => { + pause(); + userRootSlot?.onMouseEnter?.(e); + }); + + const onMouseLeave = useEventCallback((e: React.MouseEvent) => { + play(); + userRootSlot?.onMouseEnter?.(e); + }); + return { components: { timer: Timer, @@ -91,6 +122,8 @@ export const useToastContainer_unstable = ( ...rest, ...userRootSlot, onAnimationEnd, + onMouseEnter, + onMouseLeave, }), timerTimeout, transitionTimeout: 500, diff --git a/packages/react-components/react-toast/src/state/index.ts b/packages/react-components/react-toast/src/state/index.ts index 73fca72cb3fe4..33a93fc5349eb 100644 --- a/packages/react-components/react-toast/src/state/index.ts +++ b/packages/react-components/react-toast/src/state/index.ts @@ -1,6 +1,5 @@ export * from './types'; export * from './useToaster'; -export * from './useToast'; export * from './useToastController'; export { getPositionStyles } from './vanilla'; export { TOAST_POSITIONS } from './constants'; diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts deleted file mode 100644 index 77d19cb8fbab5..0000000000000 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react'; -import { useForceUpdate } from '@fluentui/react-utilities'; -import { Toast } from './vanilla/toast'; -import { Toast as ToastProps } from './types'; - -const noop = () => null; - -interface UseToastOptions extends Pick { - visible: boolean; -} - -export function useToast(options: UseToastOptions) { - const { pauseOnHover, pauseOnWindowBlur, visible } = options; - - const forceRender = useForceUpdate(); - const [toast] = React.useState(() => new Toast()); - - const toastRef = React.useRef(null); - - React.useEffect(() => { - if (visible && toast && toastRef.current) { - toast.onUpdate = forceRender; - toast.connectToDOM(toastRef.current, { - pauseOnHover, - pauseOnWindowBlur, - }); - - return () => toast.disconnect(); - } - }, [toast, pauseOnWindowBlur, pauseOnHover, forceRender, visible]); - - if (!toast) { - return { - toastRef, - play: noop, - pause: noop, - running: false, - }; - } - - return { - toastRef, - play: toast.play, - pause: toast.pause, - running: toast.running, - }; -} diff --git a/packages/react-components/react-toast/src/state/useToaster.test.ts b/packages/react-components/react-toast/src/state/useToaster.test.ts new file mode 100644 index 0000000000000..a1520afd603c9 --- /dev/null +++ b/packages/react-components/react-toast/src/state/useToaster.test.ts @@ -0,0 +1,66 @@ +import { EVENTS } from './constants'; +import { Toast, ToastId } from './types'; +import { useToaster } from './useToaster'; +import { createToaster } from './vanilla/createToaster'; +import { renderHook, act } from '@testing-library/react-hooks'; + +jest.mock('./vanilla/createToaster.ts'); + +type Toaster = ReturnType; + +describe('useToaster', () => { + const mockToaster = (options?: Partial): Toaster => { + const mock: Toaster = { + buildToast: jest.fn(), + dismissAllToasts: jest.fn(), + dismissToast: jest.fn(), + updateToast: jest.fn(), + isToastVisible: jest.fn(), + toasts: new Map(), + visibleToasts: new Set(), + ...options, + }; + + (createToaster as jest.Mock).mockReturnValue(mock); + return mock; + }; + beforeEach(() => { + mockToaster(); + }); + + it('should register document event handlers', () => { + const toaster = mockToaster(); + renderHook(() => useToaster()); + + const detail = { toasterId: undefined }; + act(() => { + document.dispatchEvent(new CustomEvent(EVENTS.dismiss, { detail })); + document.dispatchEvent(new CustomEvent(EVENTS.update, { detail })); + document.dispatchEvent(new CustomEvent(EVENTS.dismissAll, { detail })); + document.dispatchEvent(new CustomEvent(EVENTS.show, { detail })); + }); + + expect(toaster.buildToast).toHaveBeenCalledTimes(1); + expect(toaster.updateToast).toHaveBeenCalledTimes(1); + expect(toaster.dismissAllToasts).toHaveBeenCalledTimes(1); + expect(toaster.dismissToast).toHaveBeenCalledTimes(1); + }); + + it('should respect toasterId for events', () => { + const toaster = mockToaster(); + renderHook(() => useToaster({ toasterId: 'foo' })); + + const detail = { toasterId: 'bar' }; + act(() => { + document.dispatchEvent(new CustomEvent(EVENTS.dismiss, { detail })); + document.dispatchEvent(new CustomEvent(EVENTS.update, { detail })); + document.dispatchEvent(new CustomEvent(EVENTS.dismissAll, { detail })); + document.dispatchEvent(new CustomEvent(EVENTS.show, { detail })); + }); + + expect(toaster.buildToast).toHaveBeenCalledTimes(0); + expect(toaster.updateToast).toHaveBeenCalledTimes(0); + expect(toaster.dismissAllToasts).toHaveBeenCalledTimes(0); + expect(toaster.dismissToast).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/react-components/react-toast/src/state/useToaster.ts b/packages/react-components/react-toast/src/state/useToaster.ts index f4738b82f8c51..5e6985d2b6f9b 100644 --- a/packages/react-components/react-toast/src/state/useToaster.ts +++ b/packages/react-components/react-toast/src/state/useToaster.ts @@ -1,24 +1,79 @@ import * as React from 'react'; -import { useForceUpdate } from '@fluentui/react-utilities'; +import { useEventCallback, useForceUpdate } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; -import { Toaster } from './vanilla/toaster'; -import { Toast, ToastPosition, ToasterOptions } from './types'; +import { createToaster } from './vanilla'; +import type { + CommonToastDetail, + ShowToastEventDetail, + Toast, + ToastListenerMap, + ToastPosition, + ToasterId, + ToasterOptions, +} from './types'; import { ToasterProps } from '../components/Toaster'; +import { EVENTS } from './constants'; export function useToaster(options: ToasterProps = {}) { - const forceRender = useForceUpdate(); + const forceUpdate = useForceUpdate(); const toasterOptions = useToasterOptions(options); - const [toaster] = React.useState(() => new Toaster()); + const [toaster] = React.useState(() => createToaster(toasterOptions)); const { targetDocument } = useFluent(); + const isCorrectToaster = useEventCallback((toasterId: ToasterId | undefined) => { + return toasterId === toasterOptions.toasterId; + }); + React.useEffect(() => { - if (targetDocument) { - toaster.connectToDOM(targetDocument, toasterOptions); - toaster.onUpdate = forceRender; + if (!targetDocument) { + return; } - return () => toaster.disconnect(); - }, [toaster, forceRender, toasterOptions, targetDocument]); + const addToastListener = ( + eventType: TType, + callback: ToastListenerMap[TType], + ) => { + const listener: ToastListenerMap[TType] = (e: CustomEvent) => { + if (!isCorrectToaster(e.detail.toasterId)) { + return; + } + + callback(e as CustomEvent); + forceUpdate(); + }; + + targetDocument.addEventListener(eventType, listener as () => void); + return () => targetDocument.removeEventListener(eventType, listener as () => void); + }; + + const buildToast: ToastListenerMap[typeof EVENTS.show] = e => { + toaster.buildToast(e.detail, forceUpdate); + }; + + const dismissToast: ToastListenerMap[typeof EVENTS.dismiss] = e => { + toaster.dismissToast(e.detail.toastId); + }; + + const updateToast: ToastListenerMap[typeof EVENTS.update] = e => { + toaster.updateToast(e.detail); + }; + + const dismissAllToasts: ToastListenerMap[typeof EVENTS.dismissAll] = e => { + toaster.dismissAllToasts(); + }; + + const cleanupBuildListener = addToastListener(EVENTS.show, buildToast); + const cleanupUpdateListener = addToastListener(EVENTS.update, updateToast); + const cleanupDismissListener = addToastListener(EVENTS.dismiss, dismissToast); + const cleanupDismissAllListener = addToastListener(EVENTS.dismissAll, dismissAllToasts); + + return () => { + cleanupBuildListener(); + cleanupDismissAllListener(); + cleanupUpdateListener(); + cleanupDismissListener(); + }; + }, [toaster, forceUpdate, targetDocument, isCorrectToaster]); const toastsToRender = (() => { if (!toaster) { diff --git a/packages/react-components/react-toast/src/state/vanilla/createToaster.test.ts b/packages/react-components/react-toast/src/state/vanilla/createToaster.test.ts new file mode 100644 index 0000000000000..de875884e944c --- /dev/null +++ b/packages/react-components/react-toast/src/state/vanilla/createToaster.test.ts @@ -0,0 +1,177 @@ +import { Toast } from '../types'; +import { createToaster } from './createToaster'; + +describe('createToaster', () => { + function assertToast(toast: Toast | undefined): asserts toast is Toast { + if (toast === undefined) { + throw new Error('Toast is undefined'); + } + } + + it('should have defaults without user config', () => { + const toaster = createToaster({}); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + const toast = toaster.toasts.get('foo'); + assertToast(toast); + expect(toast).toEqual({ + close: expect.any(Function), + content: 'foo', + data: {}, + dispatchedAt: expect.any(Number), + onStatusChange: undefined, + pauseOnHover: false, + pauseOnWindowBlur: false, + position: 'bottom-end', + priority: 0, + remove: expect.any(Function), + timeout: 3000, + toastId: 'foo', + toasterId: undefined, + updateId: 0, + }); + }); + + it('should make a toast visible', () => { + const toaster = createToaster({}); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + + expect(toaster.toasts.size).toBe(1); + expect(toaster.visibleToasts.size).toBe(1); + expect(toaster.visibleToasts.has('foo')).toBe(true); + }); + + it('should make a toast visible', () => { + const toaster = createToaster({}); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + + expect(toaster.toasts.size).toBe(1); + expect(toaster.visibleToasts.size).toBe(1); + expect(toaster.visibleToasts.has('foo')).toBe(true); + }); + + it('should close a toast', () => { + const toaster = createToaster({}); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + const toast = toaster.toasts.get('foo'); + assertToast(toast); + toast.close(); + + expect(toaster.visibleToasts.size).toBe(0); + }); + + it('should remove a toast', () => { + const toaster = createToaster({}); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + const toast = toaster.toasts.get('foo'); + assertToast(toast); + toast.close(); + toast.remove(); + + expect(toaster.visibleToasts.size).toBe(0); + expect(toaster.toasts.size).toBe(0); + }); + + it('should dismiss a toast', () => { + const toaster = createToaster({}); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + toaster.dismissToast('foo'); + + expect(toaster.visibleToasts.size).toBe(0); + }); + + it('should dismiss all toasts', () => { + const toaster = createToaster({}); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + toaster.buildToast({ content: 'foo', toastId: 'bar' }, () => null); + toaster.buildToast({ content: 'foo', toastId: 'baz' }, () => null); + toaster.dismissAllToasts(); + + expect(toaster.visibleToasts.size).toBe(0); + }); + + it('should update a toasts', () => { + const toaster = createToaster({}); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + toaster.updateToast({ content: 'bar', toastId: 'foo' }); + + const toast = toaster.toasts.get('foo'); + assertToast(toast); + + expect(toast.content).toBe('bar'); + expect(toast.updateId).toBe(1); + }); + + it('should not have more visible toasts than the limit', () => { + const toaster = createToaster({ limit: 1 }); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + toaster.buildToast({ content: 'foo', toastId: 'bar' }, () => null); + + expect(toaster.visibleToasts.has('bar')).toBe(false); + }); + + it('should dequeue new toast from queue after toast is removed', () => { + const toaster = createToaster({ limit: 1 }); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + toaster.buildToast({ content: 'foo', toastId: 'bar' }, () => null); + + const toast = toaster.toasts.get('foo'); + assertToast(toast); + toast.remove(); + + expect(toaster.visibleToasts.size).toBe(1); + expect(toaster.toasts.get('bar')).not.toBeUndefined(); + }); + + it('should set default toast options', () => { + const toaster = createToaster({ position: 'top-end' }); + + toaster.buildToast({ content: 'foo', toastId: 'foo' }, () => null); + + const toast = toaster.toasts.get('foo'); + assertToast(toast); + expect(toast.position).toBe('top-end'); + }); + + it('should let toast options win over defaults', () => { + const toaster = createToaster({ position: 'top-end' }); + + toaster.buildToast({ content: 'foo', toastId: 'foo', position: 'bottom-start' }, () => null); + + const toast = toaster.toasts.get('foo'); + assertToast(toast); + expect(toast.position).toBe('bottom-start'); + }); + + it('should dequeue toasts in priority', () => { + const toaster = createToaster({ limit: 1 }); + + toaster.buildToast({ content: 'foo', toastId: 'one', priority: 1 }, () => null); + toaster.buildToast({ content: 'foo', toastId: 'two', priority: 2 }, () => null); + toaster.buildToast({ content: 'foo', toastId: 'four', priority: 4 }, () => null); + + expect(toaster.visibleToasts.has('one')).toBe(true); + const one = toaster.toasts.get('one'); + assertToast(one); + one.close(); + one.remove(); + + expect(toaster.visibleToasts.has('one')).toBe(false); + expect(toaster.visibleToasts.has('four')).toBe(false); + const four = toaster.toasts.get('four'); + assertToast(four); + four.close(); + four.remove(); + + expect(toaster.visibleToasts.has('two')).toBe(true); + }); +}); diff --git a/packages/react-components/react-toast/src/state/vanilla/createToaster.ts b/packages/react-components/react-toast/src/state/vanilla/createToaster.ts new file mode 100644 index 0000000000000..e011c104b8d1c --- /dev/null +++ b/packages/react-components/react-toast/src/state/vanilla/createToaster.ts @@ -0,0 +1,160 @@ +import { createPriorityQueue } from '@fluentui/react-utilities'; +import { Toast, ToasterOptions, ToastId, ToastOptions, UpdateToastEventDetail } from '../types'; + +function assignDefined(a: Partial, b: Partial) { + // This cast is required, as Object.entries will return string as key which is not indexable + for (const [key, prop] of Object.entries(b) as [keyof T, T[keyof T]][]) { + // eslint-disable-next-line eqeqeq + if (prop != undefined) { + a[key] = prop; + } + } +} +const defaulToastOptions: Pick< + ToastOptions, + 'priority' | 'pauseOnHover' | 'pauseOnWindowBlur' | 'position' | 'timeout' | 'politeness' | 'onStatusChange' +> = { + onStatusChange: undefined, + priority: 0, + pauseOnHover: false, + pauseOnWindowBlur: false, + position: 'bottom-end', + timeout: 3000, +}; + +/** + * Toast are managed outside of the react lifecycle because they can be + * dispatched imperatively. Therefore the state of toast visibility can't + * really be managed properly by a declarative lifecycle. + */ +export function createToaster(options: Partial) { + const { limit = Number.POSITIVE_INFINITY } = options; + const visibleToasts = new Set(); + const toasts = new Map(); + + const queue = createPriorityQueue((ta, tb) => { + const a = toasts.get(ta); + const b = toasts.get(tb); + if (!a || !b) { + return 0; + } + + if (a.priority === b.priority) { + return a.dispatchedAt - b.dispatchedAt; + } + + return a.priority - b.priority; + }); + + const isToastVisible = (toastId: ToastId) => { + return visibleToasts.has(toastId); + }; + + /** + * Updates an existing toast with any available option + */ + const updateToast = (toastOptions: UpdateToastEventDetail) => { + const { toastId } = toastOptions; + const toastToUpdate = toasts.get(toastId); + if (!toastToUpdate) { + return; + } + + Object.assign(toastToUpdate, toastOptions); + toastToUpdate.updateId++; + }; + + /** + * Dismisses a toast with a specific id + */ + const dismissToast = (toastId: ToastId) => { + visibleToasts.delete(toastId); + }; + + /** + * Dismisses all toasts and clears the queue + */ + const dismissAllToasts = () => { + visibleToasts.clear(); + queue.clear(); + }; + + /** + * @param toastOptions user configured options + * @param onUpdate Some toast methods can result in UI changes (i.e. close) use this to dispatch callbacks + */ + const buildToast = (toastOptions: Partial & { toastId: ToastId }, onUpdate: () => void) => { + const { toastId, content, toasterId } = toastOptions; + + if (toasts.has(toastId)) { + return; + } + + const close = () => { + const toast = toasts.get(toastId); + if (!toast) { + return; + } + + visibleToasts.delete(toastId); + onUpdate(); + toast.onStatusChange?.(null, { status: 'dismissed', ...toast }); + }; + + const remove = () => { + const toast = toasts.get(toastId); + if (!toast) { + return; + } + + toast.onStatusChange?.(null, { status: 'unmounted', ...toast }); + toasts.delete(toastId); + + if (visibleToasts.size < limit && queue.peek()) { + const nextToast = toasts.get(queue.dequeue()); + if (!nextToast) { + return; + } + + visibleToasts.add(nextToast.toastId); + toast.onStatusChange?.(null, { status: 'visible', ...nextToast }); + } + + onUpdate(); + }; + + const toast: Toast = { + ...defaulToastOptions, + close, + remove, + toastId, + content, + updateId: 0, + toasterId, + dispatchedAt: Date.now(), + data: {}, + }; + + assignDefined(toast, options); + assignDefined(toast, toastOptions); + + toasts.set(toastId, toast); + toast.onStatusChange?.(null, { status: 'queued', ...toast }); + if (visibleToasts.size >= limit) { + queue.enqueue(toastId); + } else { + visibleToasts.add(toastId); + toast.onStatusChange?.(null, { status: 'visible', ...toast }); + } + }; + + return { + buildToast, + dismissAllToasts, + dismissToast, + isToastVisible, + updateToast, + visibleToasts, + toasts, + }; +} diff --git a/packages/react-components/react-toast/src/state/vanilla/index.ts b/packages/react-components/react-toast/src/state/vanilla/index.ts index 256fae40d5f6f..10bed790db531 100644 --- a/packages/react-components/react-toast/src/state/vanilla/index.ts +++ b/packages/react-components/react-toast/src/state/vanilla/index.ts @@ -2,6 +2,5 @@ export * from './dispatchToast'; export * from './dismissToast'; export * from './dismissAllToasts'; export * from './updateToast'; -export * from './toast'; -export * from './toaster'; +export * from './createToaster'; export * from './getPositionStyles'; diff --git a/packages/react-components/react-toast/src/state/vanilla/toast.ts b/packages/react-components/react-toast/src/state/vanilla/toast.ts deleted file mode 100644 index 0c5bde424eb94..0000000000000 --- a/packages/react-components/react-toast/src/state/vanilla/toast.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ToastOptions } from '../types'; - -// TODO convert to closure -export class Toast { - public running: boolean; - public onUpdate: () => void; - private toastElement?: HTMLElement; - private pauseOnWindowBlur: ToastOptions['pauseOnWindowBlur']; - private pauseOnHover: ToastOptions['pauseOnHover']; - - constructor() { - this.running = false; - this.onUpdate = () => null; - - this.pauseOnHover = false; - this.pauseOnWindowBlur = false; - } - - public disconnect() { - if (!this.toastElement) { - return; - } - - const targetDocument = this.toastElement.ownerDocument; - - if (this.pauseOnWindowBlur) { - targetDocument.defaultView?.removeEventListener('focus', this.play); - targetDocument.defaultView?.removeEventListener('blur', this.pause); - } - - if (this.pauseOnHover) { - this.toastElement.addEventListener('mouseenter', this.pause); - this.toastElement.addEventListener('mouseleave', this.play); - } - - this.toastElement = undefined; - } - - public connectToDOM(element: HTMLElement, options: Pick) { - const { pauseOnHover, pauseOnWindowBlur } = options; - - this.pauseOnHover = pauseOnHover; - this.pauseOnWindowBlur = pauseOnWindowBlur; - - this.toastElement = element; - const targetDocument = element.ownerDocument; - if (this.pauseOnWindowBlur) { - targetDocument.defaultView?.addEventListener('focus', this.play); - targetDocument.defaultView?.addEventListener('blur', this.pause); - } - - if (this.pauseOnHover) { - this.toastElement.addEventListener('mouseenter', this.pause); - this.toastElement.addEventListener('mouseleave', this.play); - } - } - - public play = () => { - this.running = true; - this.onUpdate(); - }; - - public pause = () => { - this.running = false; - this.onUpdate(); - }; -} diff --git a/packages/react-components/react-toast/src/state/vanilla/toaster.ts b/packages/react-components/react-toast/src/state/vanilla/toaster.ts deleted file mode 100644 index e2ac5d245dc95..0000000000000 --- a/packages/react-components/react-toast/src/state/vanilla/toaster.ts +++ /dev/null @@ -1,218 +0,0 @@ -// The underlying implementation of priority queue is vanilla js -import { createPriorityQueue, PriorityQueue } from '@fluentui/react-utilities'; -import { - Toast, - ToasterOptions, - ToastId, - ToastOptions, - ToastListenerMap, - UpdateToastEventDetail, - ShowToastEventDetail, - CommonToastDetail, - ToasterId, -} from '../types'; -import { EVENTS } from '../constants'; - -function assignDefined(a: Partial, b: Partial) { - // This cast is required, as Object.entries will return string as key which is not indexable - for (const [key, prop] of Object.entries(b) as [keyof T, T[keyof T]][]) { - // eslint-disable-next-line eqeqeq - if (prop != undefined) { - a[key] = prop; - } - } -} - -// TODO convert to closure -export class Toaster { - public visibleToasts: Set; - public toasts: Map; - public onUpdate: () => void; - private targetDocument?: Document; - private toastOptions: Pick< - ToastOptions, - 'priority' | 'pauseOnHover' | 'pauseOnWindowBlur' | 'position' | 'timeout' | 'politeness' | 'onStatusChange' - >; - private toasterId?: ToasterId; - private queue: PriorityQueue; - private limit: number; - - private listeners = new Map(); - - constructor() { - this.toasts = new Map(); - this.visibleToasts = new Set(); - this.onUpdate = () => null; - this.toastOptions = { - onStatusChange: undefined, - priority: 0, - pauseOnHover: false, - pauseOnWindowBlur: false, - position: 'bottom-end', - timeout: 3000, - }; - this.queue = createPriorityQueue((ta, tb) => { - const a = this.toasts.get(ta); - const b = this.toasts.get(tb); - if (!a || !b) { - return 0; - } - - if (a.priority === b.priority) { - return a.dispatchedAt - b.dispatchedAt; - } - - return a.priority - b.priority; - }); - this.limit = Number.POSITIVE_INFINITY; - } - - public disconnect() { - this.toasts.clear(); - this.queue.clear(); - - for (const [event, callback] of this.listeners.entries()) { - this._removeEventListener(event, callback); - this.listeners.delete(event); - } - - this.targetDocument = undefined; - } - - public connectToDOM(targetDocument: Document, options: Partial) { - const { limit = Number.POSITIVE_INFINITY, toasterId, ...rest } = options; - this.targetDocument = targetDocument; - this.limit = limit; - this.toasterId = toasterId; - assignDefined(this.toastOptions, rest); - - const buildToast: ToastListenerMap[typeof EVENTS.show] = e => this._buildToast(e.detail); - const updateToast: ToastListenerMap[typeof EVENTS.update] = e => this._updateToast(e.detail); - const dismissToast: ToastListenerMap[typeof EVENTS.dismiss] = e => this._dismissToast(e.detail.toastId); - const dismissAllToasts: ToastListenerMap[typeof EVENTS.dismissAll] = e => this._dismissAllToasts(); - - this._addEventListener(EVENTS.show, buildToast); - this._addEventListener(EVENTS.dismiss, dismissToast); - this._addEventListener(EVENTS.dismissAll, dismissAllToasts); - this._addEventListener(EVENTS.update, updateToast); - } - - public isToastVisible = (toastId: ToastId) => { - return this.visibleToasts.has(toastId); - }; - - private _addEventListener(eventType: TType, callback: ToastListenerMap[TType]) { - if (!this.targetDocument) { - return; - } - - const listener: ToastListenerMap[TType] = (e: CustomEvent) => { - if (e.detail.toasterId !== this.toasterId) { - return; - } - - callback(e as CustomEvent); - }; - - this.listeners.set(eventType, listener); - this.targetDocument.addEventListener(eventType, listener as () => void); - } - - private _removeEventListener( - eventType: TType, - callback: ToastListenerMap[TType], - ) { - if (!this.targetDocument) { - return; - } - - this.targetDocument.removeEventListener(eventType, callback as () => void); - } - - private _updateToast(toastOptions: UpdateToastEventDetail) { - const { toastId } = toastOptions; - const toastToUpdate = this.toasts.get(toastId); - if (!toastToUpdate) { - return; - } - - Object.assign(toastToUpdate, toastOptions); - toastToUpdate.updateId++; - this.onUpdate(); - } - - private _dismissToast(toastId: ToastId) { - this.visibleToasts.delete(toastId); - this.onUpdate(); - } - - private _dismissAllToasts() { - this.visibleToasts.clear(); - this.queue.clear(); - this.onUpdate(); - } - - private _buildToast(toastOptions: Partial & { toastId: ToastId }) { - const { toastId, content, toasterId } = toastOptions; - - if (this.toasts.has(toastId)) { - return; - } - - const close = () => { - const toast = this.toasts.get(toastId); - if (!toast) { - return; - } - - this.visibleToasts.delete(toastId); - this.onUpdate(); - toast.onStatusChange?.(null, { status: 'dismissed', ...toast }); - }; - - const remove = () => { - const toast = this.toasts.get(toastId); - if (!toast) { - return; - } - - toast.onStatusChange?.(null, { status: 'unmounted', ...toast }); - this.toasts.delete(toastId); - - if (this.visibleToasts.size < this.limit && this.queue.peek()) { - const nextToast = this.toasts.get(this.queue.dequeue()); - if (!nextToast) { - return; - } - - this.visibleToasts.add(nextToast.toastId); - toast.onStatusChange?.(null, { status: 'visible', ...nextToast }); - } - this.onUpdate(); - }; - - const toast: Toast = { - ...this.toastOptions, - close, - remove, - toastId, - content, - updateId: 0, - toasterId, - dispatchedAt: Date.now(), - data: {}, - }; - - assignDefined(toast, toastOptions); - - this.toasts.set(toastId, toast); - toast.onStatusChange?.(null, { status: 'queued', ...toast }); - if (this.visibleToasts.size >= this.limit) { - this.queue.enqueue(toastId); - } else { - this.visibleToasts.add(toastId); - this.onUpdate(); - toast.onStatusChange?.(null, { status: 'visible', ...toast }); - } - } -} diff --git a/packages/react-components/react-toast/stories/Toast/DismissToast.stories.tsx b/packages/react-components/react-toast/stories/Toast/DismissToast.stories.tsx index df6db2c4f5b7a..f9fcac1f71ad9 100644 --- a/packages/react-components/react-toast/stories/Toast/DismissToast.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/DismissToast.stories.tsx @@ -5,8 +5,9 @@ import { useId, Button } from '@fluentui/react-components'; export const DismissToast = () => { const toasterId = useId('toaster'); const toastId = useId('example'); - const [unmounted, setUnmounted] = React.useState(false); + const [unmounted, setUnmounted] = React.useState(true); const { dispatchToast, dismissToast } = useToastController(toasterId); + console.log(unmounted); const notify = () => { dispatchToast( @@ -21,7 +22,7 @@ export const DismissToast = () => { return ( <> - + ); }; diff --git a/packages/react-components/react-toast/stories/Toast/ToastLifecycle.stories.tsx b/packages/react-components/react-toast/stories/Toast/ToastLifecycle.stories.tsx index ccfb961df969c..9de3c2e03ba0c 100644 --- a/packages/react-components/react-toast/stories/Toast/ToastLifecycle.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/ToastLifecycle.stories.tsx @@ -88,10 +88,10 @@ export const ToastLifecycle = () => { Status log
- {statusLog.map(([time, toastStatus]) => { + {statusLog.map(([time, toastStatus], i) => { const date = new Date(time); return ( -
+
{date.toLocaleTimeString()} {toastStatus}
);