diff --git a/packages/react-components/react-toast/etc/react-toast.api.md b/packages/react-components/react-toast/etc/react-toast.api.md index 763885a437ce3..04a9c293cea64 100644 --- a/packages/react-components/react-toast/etc/react-toast.api.md +++ b/packages/react-components/react-toast/etc/react-toast.api.md @@ -7,7 +7,7 @@ import * as React_2 from 'react'; // @public (undocumented) -export const Toaster: React_2.FC; +export const Toaster: React_2.FC; // @public (undocumented) export type ToastId = string; @@ -17,7 +17,7 @@ export type ToastPosition = 'top-right' | 'top-center' | 'top-left' | 'bottom-ri // @public (undocumented) export function useToastController(): { - dispatchToast: (content: React_2.ReactNode, options?: ToastOptions | undefined) => void; + dispatchToast: (content: React_2.ReactNode, options?: Partial | undefined) => void; dismissToast: (toastId?: string | undefined) => void; }; diff --git a/packages/react-components/react-toast/src/components/Toast.tsx b/packages/react-components/react-toast/src/components/Toast.tsx index d0a43e1034077..19c538935f37a 100644 --- a/packages/react-components/react-toast/src/components/Toast.tsx +++ b/packages/react-components/react-toast/src/components/Toast.tsx @@ -75,11 +75,11 @@ const useStyles = makeStyles({ }, }); -export const Toast: React.FC & { visible: boolean }> = props => { +export const Toast: React.FC = props => { const styles = useStyles(); const { visible, children, close, remove, ...toastOptions } = props; const { timeout } = toastOptions; - const { play, running, toastRef } = useToast(toastOptions); + const { play, running, toastRef } = useToast({ ...toastOptions, content: children }); // start the toast once it's fully in useIsomorphicLayoutEffect(() => { diff --git a/packages/react-components/react-toast/src/components/Toaster.tsx b/packages/react-components/react-toast/src/components/Toaster.tsx index ebb779ff4881b..73891e4d25662 100644 --- a/packages/react-components/react-toast/src/components/Toaster.tsx +++ b/packages/react-components/react-toast/src/components/Toaster.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { Portal } from '@fluentui/react-portal'; -import { useToaster, getPositionStyles } from '../state'; +import { useToaster, getPositionStyles, ToasterOptions } from '../state'; import { Toast } from './Toast'; import { makeStyles, mergeClasses } from '@griffel/react'; @@ -14,21 +14,23 @@ const useStyles = makeStyles({ }, }); -export const Toaster: React.FC = () => { - const { getToastsToRender, isToastVisible } = useToaster(); +export type ToasterProps = Partial; + +export const Toaster: React.FC = props => { + const { getToastsToRender, isToastVisible, toasterRef } = useToaster(props); const styles = useStyles(); return ( -
+
{getToastsToRender((position, toasts) => { return (
- {toasts.map(({ content, ...toastProps }) => { + {toasts.map(toastProps => { return ( - {content as React.ReactNode} + {toastProps.content as React.ReactNode} ); })} diff --git a/packages/react-components/react-toast/src/state/types.ts b/packages/react-components/react-toast/src/state/types.ts index 686ee1e3a04a2..a9161b84757cb 100644 --- a/packages/react-components/react-toast/src/state/types.ts +++ b/packages/react-components/react-toast/src/state/types.ts @@ -5,32 +5,36 @@ export type ToastId = string; export type ToastPosition = 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left'; export interface ToastOptions { - toastId?: ToastId; - position?: ToastPosition; - content?: unknown; - timeout?: number; - pauseOnWindowBlur?: boolean; - pauseOnHover?: boolean; + toastId: ToastId; + position: ToastPosition; + content: unknown; + timeout: number; + pauseOnWindowBlur: boolean; + pauseOnHover: boolean; } -export interface DefaultToastOptions - extends Pick {} - -export interface ValidatedToastOptions extends Required {} +export interface ToasterOptions + extends Pick { + offset?: number[]; + toasterId?: string; +} -export interface Toast extends Required { +export interface Toast extends ToastOptions { close: () => void; remove: () => void; } +export interface ShowToastEventDetail extends Partial { + toastId: ToastId; +} + export interface DismissToastEventDetail { toastId: ToastId | undefined; } -export interface ToastEventMap { - [EVENTS.show]: CustomEvent; - [EVENTS.dismiss]: CustomEvent; -} +type EventListener = (e: CustomEvent) => void; -export type ToastEventListenerGeneric = (e: ToastEventMap[K]) => void; -export type ToastEventListener = (e: ToastEventMap[K]) => void; +export type ToastListenerMap = { + [EVENTS.show]: EventListener; + [EVENTS.dismiss]: EventListener; +}; diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts index 9699389d1c068..bc56b048c2639 100644 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -1,12 +1,13 @@ import * as React from 'react'; import { useForceUpdate } from '@fluentui/react-utilities'; import { Toast } from './vanilla/toast'; -import { ValidatedToastOptions } from './types'; +import { ToastOptions } from './types'; const noop = () => null; -export function useToast(options: ValidatedToastOptions) { - const toastOptions = useToastOptions(options); +export function useToast(options: ToastOptions) { + const { pauseOnHover, pauseOnWindowBlur } = options; + const forceRender = useForceUpdate(); const [toast] = React.useState(() => new Toast()); @@ -15,10 +16,14 @@ export function useToast(options: ValidatedToastOp React.useEffect(() => { if (toast && toastRef.current) { toast.onUpdate = forceRender; - toast.connectToDOM(toastRef.current, toastOptions); + toast.connectToDOM(toastRef.current, { + pauseOnHover, + pauseOnWindowBlur, + }); + return () => toast.disconnect(); } - }, [toast, toastOptions, forceRender]); + }, [toast, pauseOnWindowBlur, pauseOnHover, forceRender]); if (!toast) { return { @@ -36,17 +41,3 @@ export function useToast(options: ValidatedToastOp running: toast.running, }; } - -function useToastOptions(options: ValidatedToastOptions) { - const { pauseOnHover, pauseOnWindowBlur, position, timeout } = options; - - return React.useMemo( - () => ({ - pauseOnHover, - pauseOnWindowBlur, - position, - timeout, - }), - [pauseOnHover, pauseOnWindowBlur, position, timeout], - ); -} diff --git a/packages/react-components/react-toast/src/state/useToastController.ts b/packages/react-components/react-toast/src/state/useToastController.ts index 0baab5b3e1728..fbd64588edace 100644 --- a/packages/react-components/react-toast/src/state/useToastController.ts +++ b/packages/react-components/react-toast/src/state/useToastController.ts @@ -7,7 +7,7 @@ export function useToastController() { const { targetDocument } = useFluent(); const dispatchToast = React.useCallback( - (content: React.ReactNode, options?: ToastOptions) => { + (content: React.ReactNode, options?: Partial) => { if (targetDocument) { dispatchToastVanilla(content, options, targetDocument); } diff --git a/packages/react-components/react-toast/src/state/useToaster.ts b/packages/react-components/react-toast/src/state/useToaster.ts index b467edef72afe..2ffc37135a30c 100644 --- a/packages/react-components/react-toast/src/state/useToaster.ts +++ b/packages/react-components/react-toast/src/state/useToaster.ts @@ -1,19 +1,23 @@ -import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import * as React from 'react'; import { Toaster } from './vanilla/toaster'; import { useForceUpdate } from '@fluentui/react-utilities'; -import { Toast, ToastId, ToastPosition } from './types'; +import { Toast, ToastPosition, ToasterOptions } from './types'; +import { ToasterProps } from '../components/Toaster'; -export function useToaster() { - const { targetDocument } = useFluent(); +export function useToaster(options: ToasterProps = {}) { const forceRender = useForceUpdate(); - const [toaster] = React.useState(() => { - if (targetDocument) { - const newToaster = new Toaster(targetDocument); - newToaster.onUpdate = forceRender; - return newToaster; + const defaultToastOptions = useToastOptions(options); + const toasterRef = React.useRef(null); + const [toaster] = React.useState(() => new Toaster()); + + React.useEffect(() => { + if (toasterRef.current) { + toaster.connectToDOM(toasterRef.current, defaultToastOptions); + toaster.onUpdate = forceRender; } - }); + + return () => toaster.disconnect(); + }, [toaster, defaultToastOptions, forceRender]); const getToastsToRender = React.useCallback( (cb: (position: ToastPosition, toasts: Toast[]) => T) => { @@ -42,13 +46,22 @@ export function useToaster() { ); return { - isToastVisible: (toastId: ToastId) => { - if (toaster) { - return toaster.isToastVisible(toastId); - } - - return false; - }, + toasterRef, + isToastVisible: toaster.isToastVisible, getToastsToRender, }; } + +function useToastOptions(options: ToasterProps): Partial { + const { pauseOnHover, pauseOnWindowBlur, position, timeout } = options; + + return React.useMemo>( + () => ({ + pauseOnHover, + pauseOnWindowBlur, + position, + timeout, + }), + [pauseOnHover, pauseOnWindowBlur, position, timeout], + ); +} diff --git a/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts b/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts index 74a6cfac970a0..a4a95f6c60e4a 100644 --- a/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts @@ -1,14 +1,18 @@ -import { ToastOptions } from '../types'; +import { ShowToastEventDetail, ToastOptions } from '../types'; import { EVENTS } from '../constants'; let counter = 0; -export function dispatchToast(content: unknown, options: ToastOptions = {}, targetDocument: Document) { - options.toastId ??= (counter++).toString(); - const event = new CustomEvent(EVENTS.show, { +export function dispatchToast(content: unknown, options: Partial = {}, targetDocument: Document) { + const detail: ShowToastEventDetail = { + ...options, + content, + toastId: options.toastId ?? (counter++).toString(), + }; + const event = new CustomEvent(EVENTS.show, { bubbles: false, cancelable: false, - detail: { ...options, content }, + detail, }); targetDocument.dispatchEvent(event); } diff --git a/packages/react-components/react-toast/src/state/vanilla/toast.ts b/packages/react-components/react-toast/src/state/vanilla/toast.ts index c7b5aee5b1903..0c5bde424eb94 100644 --- a/packages/react-components/react-toast/src/state/vanilla/toast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/toast.ts @@ -1,12 +1,12 @@ -import { ValidatedToastOptions } from '../types'; +import { ToastOptions } from '../types'; // TODO convert to closure export class Toast { public running: boolean; public onUpdate: () => void; private toastElement?: HTMLElement; - private pauseOnWindowBlur: ValidatedToastOptions['pauseOnWindowBlur']; - private pauseOnHover: ValidatedToastOptions['pauseOnHover']; + private pauseOnWindowBlur: ToastOptions['pauseOnWindowBlur']; + private pauseOnHover: ToastOptions['pauseOnHover']; constructor() { this.running = false; @@ -36,8 +36,9 @@ export class Toast { this.toastElement = undefined; } - public connectToDOM(element: HTMLElement, options: ValidatedToastOptions) { + public connectToDOM(element: HTMLElement, options: Pick) { const { pauseOnHover, pauseOnWindowBlur } = options; + this.pauseOnHover = pauseOnHover; this.pauseOnWindowBlur = pauseOnWindowBlur; diff --git a/packages/react-components/react-toast/src/state/vanilla/toaster.ts b/packages/react-components/react-toast/src/state/vanilla/toaster.ts index 1ed018765e2d8..38d327e638b9a 100644 --- a/packages/react-components/react-toast/src/state/vanilla/toaster.ts +++ b/packages/react-components/react-toast/src/state/vanilla/toaster.ts @@ -1,39 +1,55 @@ -import { Toast, ToastEventListener, ToastEventListenerGeneric, ToastEventMap, ToastId, ToastOptions } from '../types'; +import { Toast, ToasterOptions, ToastId, ToastOptions, ToastListenerMap } from '../types'; import { EVENTS } from '../constants'; +function assignDefined(a: Partial, b: Partial) { + for (const [key, prop] of Object.entries(b)) { + if (prop !== undefined) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + a[key] = prop; + } + } +} + // TODO convert to closure export class Toaster { public visibleToasts: Set; public toasts: Map; public onUpdate: () => void; - private targetDocument: Document; + private toasterElement?: HTMLElement; + private toasterOptions: ToasterOptions; - private listeners = new Map(); + private listeners = new Map(); - constructor(targetDocument: Document) { + constructor() { this.toasts = new Map(); this.visibleToasts = new Set(); this.onUpdate = () => null; - this.targetDocument = targetDocument; - - this._initEvents(); + this.toasterOptions = { + pauseOnHover: false, + pauseOnWindowBlur: false, + position: 'bottom-right', + timeout: 3000, + }; } - public dispose() { + public disconnect() { this.toasts.clear(); + for (const [event, callback] of this.listeners.entries()) { this._removeEventListener(event, callback); this.listeners.delete(event); } - } - public isToastVisible(toastId: ToastId) { - return this.visibleToasts.has(toastId); + this.toasterElement = undefined; } - private _initEvents() { - const buildToast: ToastEventListener = e => this._buildToast(e.detail); - const dismissToast: ToastEventListener = e => { + public connectToDOM(toasterElement: HTMLElement, options: Partial) { + this.toasterElement = toasterElement; + assignDefined(this.toasterOptions, options); + + const buildToast: ToastListenerMap[typeof EVENTS.show] = e => this._buildToast(e.detail); + const dismissToast: ToastListenerMap[typeof EVENTS.dismiss] = e => { const { toastId } = e.detail; if (toastId) { this._dismissToast(toastId); @@ -49,18 +65,29 @@ export class Toaster { this._addEventListener(EVENTS.dismiss, dismissToast); } - private _addEventListener( - eventType: TEvent, - callback: ToastEventListenerGeneric, - ) { - this.targetDocument.addEventListener(eventType, callback as () => void); + public isToastVisible = (toastId: ToastId) => { + return this.visibleToasts.has(toastId); + }; + + private _addEventListener(eventType: TType, callback: ToastListenerMap[TType]) { + if (!this.toasterElement) { + return; + } + + const targetDocument = this.toasterElement?.ownerDocument; + targetDocument.addEventListener(eventType, callback as () => void); } - private _removeEventListener( - eventType: keyof ToastEventMap, - callback: ToastEventListenerGeneric, + private _removeEventListener( + eventType: TType, + callback: ToastListenerMap[TType], ) { - this.targetDocument.removeEventListener(eventType, callback as () => void); + if (!this.toasterElement) { + return; + } + + const targetDocument = this.toasterElement?.ownerDocument; + targetDocument.removeEventListener(eventType, callback as () => void); } private _dismissToast(toastId: ToastId) { @@ -73,15 +100,9 @@ export class Toaster { this.onUpdate(); } - private _buildToast(toastOptions: ToastOptions) { - const { - toastId = '', - position = 'bottom-right', - timeout = 3000, - content = '', - pauseOnHover = false, - pauseOnWindowBlur = false, - } = toastOptions; + private _buildToast(toastOptions: Partial & { toastId: ToastId }) { + const { toastId, content } = toastOptions; + if (this.toasts.has(toastId)) { return; } @@ -97,16 +118,15 @@ export class Toaster { }; const toast: Toast = { - pauseOnHover, - pauseOnWindowBlur, - position, - toastId, - timeout, - content, + ...this.toasterOptions, close, remove, + toastId, + content, }; + assignDefined(toast, toastOptions); + this.visibleToasts.add(toastId); this.toasts.set(toastId, toast); this.onUpdate(); diff --git a/packages/react-components/react-toast/stories/Toast/DefaultToastOptions.stories.tsx b/packages/react-components/react-toast/stories/Toast/DefaultToastOptions.stories.tsx new file mode 100644 index 0000000000000..2f449fd68ed39 --- /dev/null +++ b/packages/react-components/react-toast/stories/Toast/DefaultToastOptions.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Toaster, useToastController } from '@fluentui/react-toast'; + +export const DefaultToastOptions = () => { + const { dispatchToast } = useToastController(); + const notify = () => dispatchToast('This is a toast'); + + return ( + <> + + + + ); +}; diff --git a/packages/react-components/react-toast/stories/Toast/index.stories.tsx b/packages/react-components/react-toast/stories/Toast/index.stories.tsx index ca6306ae12b23..96353ca5bdbd8 100644 --- a/packages/react-components/react-toast/stories/Toast/index.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/index.stories.tsx @@ -1,4 +1,5 @@ export { Default } from './Default.stories'; +export { DefaultToastOptions } from './DefaultToastOptions.stories'; export { CustomTimeout } from './CustomTimeout.stories'; export { ToastPositions } from './ToastPositions.stories'; export { DismissToast } from './DismissToast.stories';