From e7dd017a7238986349f47ecfc3a773ab9e4ff2ff Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 17:36:29 +0200 Subject: [PATCH 1/5] feat: Implement Toast pause Implements two new options: * pauseOnWindowBlur - pauses toast timeout when window is not focused * pauseOnHover - pauses toast when cursor enters the toast surface --- .../react-toast/src/components/Toast.tsx | 9 +-- .../react-toast/src/components/Toaster.tsx | 6 +- .../react-toast/src/state/types.ts | 9 ++- .../react-toast/src/state/useToast.ts | 43 +++++++++++--- .../react-toast/src/state/vanilla/toast.ts | 57 +++++++++++++++++-- .../react-toast/src/state/vanilla/toaster.ts | 11 +++- .../stories/Toast/PauseOnHover.stories.tsx | 14 +++++ .../Toast/PauseOnWindowBlur.stories.tsx | 14 +++++ .../stories/Toast/index.stories.tsx | 2 + 9 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 packages/react-components/react-toast/stories/Toast/PauseOnHover.stories.tsx create mode 100644 packages/react-components/react-toast/stories/Toast/PauseOnWindowBlur.stories.tsx diff --git a/packages/react-components/react-toast/src/components/Toast.tsx b/packages/react-components/react-toast/src/components/Toast.tsx index 3d29381e27d5ac..d9d6248485520b 100644 --- a/packages/react-components/react-toast/src/components/Toast.tsx +++ b/packages/react-components/react-toast/src/components/Toast.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { Transition } from 'react-transition-group'; -import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities'; import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; import { useToast, Toast as ToastProps } from '../state'; import { Timer } from './Timer'; @@ -77,9 +77,10 @@ const useStyles = makeStyles({ export const Toast: React.FC & { visible: boolean }> = props => { const styles = useStyles(); - const { visible, children, close, remove, timeout } = props; - const { play, running } = useToast(); - const toastRef = React.useRef(null); + const { visible, children, close, remove, ...toastOptions } = props; + const { timeout } = toastOptions; + const { play, running, toastRef: toastRefBase } = useToast(toastOptions); + const toastRef = useMergedRefs(React.useRef(null), toastRefBase); // 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 b387b043d05e3f..ebb779ff4881b4 100644 --- a/packages/react-components/react-toast/src/components/Toaster.tsx +++ b/packages/react-components/react-toast/src/components/Toaster.tsx @@ -27,11 +27,7 @@ export const Toaster: React.FC = () => {
{toasts.map(({ content, ...toastProps }) => { return ( - + {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 d5a359e2f5ade3..686ee1e3a04a23 100644 --- a/packages/react-components/react-toast/src/state/types.ts +++ b/packages/react-components/react-toast/src/state/types.ts @@ -9,9 +9,16 @@ export interface ToastOptions { position?: ToastPosition; content?: unknown; timeout?: number; + pauseOnWindowBlur?: boolean; + pauseOnHover?: boolean; } -export interface Toast extends Required> { +export interface DefaultToastOptions + extends Pick {} + +export interface ValidatedToastOptions extends Required {} + +export interface Toast extends Required { close: () => void; remove: () => void; } diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts index 2142da55df6fa7..9b5817726e3886 100644 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -1,18 +1,45 @@ import * as React from 'react'; -import { Toast } from './vanilla/toast'; import { useForceUpdate } from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { Toast } from './vanilla/toast'; +import { ValidatedToastOptions } from './types'; -export function useToast() { +export function useToast(options: ValidatedToastOptions) { const forceRender = useForceUpdate(); + const { targetDocument } = useFluent(); const [toast] = React.useState(() => { - const newToast = new Toast(); - newToast.onUpdate = forceRender; - return newToast; + if (targetDocument) { + const newToast = new Toast(targetDocument, options); + newToast.onUpdate = forceRender; + return newToast; + } }); + const toastRef = React.useCallback( + (el: HTMLElement | null) => { + if (el && toast) { + toast.setToastElement(el); + } + }, + [toast], + ); + + const play = React.useCallback(() => { + if (toast) { + toast.play(); + } + }, [toast]); + + const pause = React.useCallback(() => { + if (toast) { + toast.pause(); + } + }, [toast]); + return { - play: toast.play, - pause: toast.pause, - running: toast.running, + toastRef, + play, + pause, + running: toast?.running ?? false, }; } 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 544cca1479e6bc..b0e5ec848f8ff4 100644 --- a/packages/react-components/react-toast/src/state/vanilla/toast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/toast.ts @@ -1,13 +1,58 @@ +import { ValidatedToastOptions } from '../types'; + // TODO convert to closure export class Toast { - public running = false; - public onUpdate: () => void = () => null; + public running: boolean; + public onUpdate: () => void; + private targetDocument: Document; + private toastElement?: HTMLElement; + private pauseOnWindowBlur: ValidatedToastOptions['pauseOnWindowBlur']; + private pauseOnHover: ValidatedToastOptions['pauseOnHover']; - public play() { - this.running = true; + constructor(targetDocument: Document, options: ValidatedToastOptions) { + this.running = false; + this.onUpdate = () => null; + this.targetDocument = targetDocument; + + const { pauseOnHover, pauseOnWindowBlur } = options; + this.pauseOnHover = pauseOnHover; + this.pauseOnWindowBlur = pauseOnWindowBlur; + + if (this.pauseOnWindowBlur) { + this.targetDocument.defaultView?.addEventListener('focus', this.play); + this.targetDocument.defaultView?.addEventListener('blur', this.pause); + } } - public pause() { - this.running = false; + public dispose() { + if (this.pauseOnWindowBlur) { + this.targetDocument.defaultView?.removeEventListener('focus', this.play); + this.targetDocument.defaultView?.removeEventListener('blur', this.pause); + } + + if (this.toastElement && this.pauseOnHover) { + this.toastElement.addEventListener('mouseenter', this.pause); + this.toastElement.addEventListener('mouseleave', this.play); + } + + this.toastElement = undefined; } + + public setToastElement = (element: HTMLElement) => { + this.toastElement = element; + 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 index 64fbb9d7217119..1ed018765e2d86 100644 --- a/packages/react-components/react-toast/src/state/vanilla/toaster.ts +++ b/packages/react-components/react-toast/src/state/vanilla/toaster.ts @@ -74,7 +74,14 @@ export class Toaster { } private _buildToast(toastOptions: ToastOptions) { - const { toastId = '', position = 'bottom-right', timeout = 3000, content = '' } = toastOptions; + const { + toastId = '', + position = 'bottom-right', + timeout = 3000, + content = '', + pauseOnHover = false, + pauseOnWindowBlur = false, + } = toastOptions; if (this.toasts.has(toastId)) { return; } @@ -90,6 +97,8 @@ export class Toaster { }; const toast: Toast = { + pauseOnHover, + pauseOnWindowBlur, position, toastId, timeout, diff --git a/packages/react-components/react-toast/stories/Toast/PauseOnHover.stories.tsx b/packages/react-components/react-toast/stories/Toast/PauseOnHover.stories.tsx new file mode 100644 index 00000000000000..5b052fa6ce31cb --- /dev/null +++ b/packages/react-components/react-toast/stories/Toast/PauseOnHover.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Toaster, useToastController } from '@fluentui/react-toast'; + +export const PauseOnHover = () => { + const { dispatchToast } = useToastController(); + const notify = () => dispatchToast('Hover me!', { pauseOnHover: true }); + + return ( + <> + + + + ); +}; diff --git a/packages/react-components/react-toast/stories/Toast/PauseOnWindowBlur.stories.tsx b/packages/react-components/react-toast/stories/Toast/PauseOnWindowBlur.stories.tsx new file mode 100644 index 00000000000000..01477029bff0d3 --- /dev/null +++ b/packages/react-components/react-toast/stories/Toast/PauseOnWindowBlur.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Toaster, useToastController } from '@fluentui/react-toast'; + +export const PauseOnWindowBlur = () => { + const { dispatchToast } = useToastController(); + const notify = () => dispatchToast('Click on another window!', { pauseOnWindowBlur: true }); + + 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 d1a749f4a62e00..ca6306ae12b230 100644 --- a/packages/react-components/react-toast/stories/Toast/index.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/index.stories.tsx @@ -3,6 +3,8 @@ export { CustomTimeout } from './CustomTimeout.stories'; export { ToastPositions } from './ToastPositions.stories'; export { DismissToast } from './DismissToast.stories'; export { DismissAll } from './DismissAll.stories'; +export { PauseOnWindowBlur } from './PauseOnWindowBlur.stories'; +export { PauseOnHover } from './PauseOnHover.stories'; export default { title: 'Preview Components/Toast', From 6680dad506b2696e413131aea7839ee1826fc4f4 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Thu, 11 May 2023 05:44:19 +0200 Subject: [PATCH 2/5] dispose toast --- .../react-toast/src/state/useToast.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts index 9b5817726e3886..0983a12d9bc5d4 100644 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -5,16 +5,21 @@ import { Toast } from './vanilla/toast'; import { ValidatedToastOptions } from './types'; export function useToast(options: ValidatedToastOptions) { + const toastOptions = useToastOptions(options); const forceRender = useForceUpdate(); const { targetDocument } = useFluent(); const [toast] = React.useState(() => { if (targetDocument) { - const newToast = new Toast(targetDocument, options); + const newToast = new Toast(targetDocument, toastOptions); newToast.onUpdate = forceRender; return newToast; } }); + React.useEffect(() => { + return () => toast?.dispose(); + }, [toast]); + const toastRef = React.useCallback( (el: HTMLElement | null) => { if (el && toast) { @@ -43,3 +48,17 @@ export function useToast(options: ValidatedToastOptions) { running: toast?.running ?? false, }; } + +function useToastOptions(options: ValidatedToastOptions) { + const { pauseOnHover, pauseOnWindowBlur, position, timeout } = options; + + return React.useMemo( + () => ({ + pauseOnHover, + pauseOnWindowBlur, + position, + timeout, + }), + [pauseOnHover, pauseOnWindowBlur, position, timeout], + ); +} From 717839b653a679f848503337bad1eef41a37a9ef Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Thu, 11 May 2023 10:58:16 +0100 Subject: [PATCH 3/5] update vanilla toast usage pattern --- .../react-toast/src/components/Toast.tsx | 5 +-- .../react-toast/src/state/useToast.ts | 22 +++++----- .../react-toast/src/state/vanilla/toast.ts | 40 +++++++++++-------- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/react-components/react-toast/src/components/Toast.tsx b/packages/react-components/react-toast/src/components/Toast.tsx index d9d6248485520b..d0a43e10340773 100644 --- a/packages/react-components/react-toast/src/components/Toast.tsx +++ b/packages/react-components/react-toast/src/components/Toast.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { Transition } from 'react-transition-group'; -import { useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; import { useToast, Toast as ToastProps } from '../state'; import { Timer } from './Timer'; @@ -79,8 +79,7 @@ export const Toast: React.FC & { visible: boolean }> const styles = useStyles(); const { visible, children, close, remove, ...toastOptions } = props; const { timeout } = toastOptions; - const { play, running, toastRef: toastRefBase } = useToast(toastOptions); - const toastRef = useMergedRefs(React.useRef(null), toastRefBase); + const { play, running, toastRef } = useToast(toastOptions); // start the toast once it's fully in useIsomorphicLayoutEffect(() => { diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts index 0983a12d9bc5d4..e059d8c0b5c285 100644 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -4,30 +4,26 @@ import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts import { Toast } from './vanilla/toast'; import { ValidatedToastOptions } from './types'; -export function useToast(options: ValidatedToastOptions) { +export function useToast(options: ValidatedToastOptions) { const toastOptions = useToastOptions(options); const forceRender = useForceUpdate(); const { targetDocument } = useFluent(); const [toast] = React.useState(() => { if (targetDocument) { - const newToast = new Toast(targetDocument, toastOptions); + const newToast = new Toast(); newToast.onUpdate = forceRender; return newToast; } }); - React.useEffect(() => { - return () => toast?.dispose(); - }, [toast]); + const toastRef = React.useRef(null); - const toastRef = React.useCallback( - (el: HTMLElement | null) => { - if (el && toast) { - toast.setToastElement(el); - } - }, - [toast], - ); + React.useEffect(() => { + if (toast && toastRef.current) { + toast.connectToDOM(toastRef.current, toastOptions); + return () => toast.disconnect(); + } + }, [toast, toastOptions]); const play = React.useCallback(() => { if (toast) { 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 b0e5ec848f8ff4..c7b5aee5b1903a 100644 --- a/packages/react-components/react-toast/src/state/vanilla/toast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/toast.ts @@ -4,33 +4,31 @@ import { ValidatedToastOptions } from '../types'; export class Toast { public running: boolean; public onUpdate: () => void; - private targetDocument: Document; private toastElement?: HTMLElement; private pauseOnWindowBlur: ValidatedToastOptions['pauseOnWindowBlur']; private pauseOnHover: ValidatedToastOptions['pauseOnHover']; - constructor(targetDocument: Document, options: ValidatedToastOptions) { + constructor() { this.running = false; this.onUpdate = () => null; - this.targetDocument = targetDocument; - const { pauseOnHover, pauseOnWindowBlur } = options; - this.pauseOnHover = pauseOnHover; - this.pauseOnWindowBlur = pauseOnWindowBlur; + this.pauseOnHover = false; + this.pauseOnWindowBlur = false; + } - if (this.pauseOnWindowBlur) { - this.targetDocument.defaultView?.addEventListener('focus', this.play); - this.targetDocument.defaultView?.addEventListener('blur', this.pause); + public disconnect() { + if (!this.toastElement) { + return; } - } - public dispose() { + const targetDocument = this.toastElement.ownerDocument; + if (this.pauseOnWindowBlur) { - this.targetDocument.defaultView?.removeEventListener('focus', this.play); - this.targetDocument.defaultView?.removeEventListener('blur', this.pause); + targetDocument.defaultView?.removeEventListener('focus', this.play); + targetDocument.defaultView?.removeEventListener('blur', this.pause); } - if (this.toastElement && this.pauseOnHover) { + if (this.pauseOnHover) { this.toastElement.addEventListener('mouseenter', this.pause); this.toastElement.addEventListener('mouseleave', this.play); } @@ -38,13 +36,23 @@ export class Toast { this.toastElement = undefined; } - public setToastElement = (element: HTMLElement) => { + public connectToDOM(element: HTMLElement, options: ValidatedToastOptions) { + 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; From 62d6318c483e2a6ddcdba56d3acb1778789fc7b3 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Thu, 11 May 2023 11:01:20 +0100 Subject: [PATCH 4/5] use early return --- .../react-toast/src/state/useToast.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts index e059d8c0b5c285..59760e02978187 100644 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -4,6 +4,8 @@ import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts import { Toast } from './vanilla/toast'; import { ValidatedToastOptions } from './types'; +const noop = () => null; + export function useToast(options: ValidatedToastOptions) { const toastOptions = useToastOptions(options); const forceRender = useForceUpdate(); @@ -25,23 +27,20 @@ export function useToast(options: ValidatedToastOp } }, [toast, toastOptions]); - const play = React.useCallback(() => { - if (toast) { - toast.play(); - } - }, [toast]); - - const pause = React.useCallback(() => { - if (toast) { - toast.pause(); - } - }, [toast]); + if (!toast) { + return { + toastRef, + play: noop, + pause: noop, + running: false, + }; + } return { toastRef, - play, - pause, - running: toast?.running ?? false, + play: toast.play, + pause: toast.pause, + running: toast.running, }; } From 767014be7c2dc43e7101a6d1732bf7a388166356 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Thu, 11 May 2023 11:06:34 +0100 Subject: [PATCH 5/5] simplify vanilla toast creation --- .../react-toast/src/state/useToast.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts index 59760e02978187..9699389d1c0686 100644 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -1,6 +1,5 @@ import * as React from 'react'; import { useForceUpdate } from '@fluentui/react-utilities'; -import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { Toast } from './vanilla/toast'; import { ValidatedToastOptions } from './types'; @@ -9,23 +8,17 @@ const noop = () => null; export function useToast(options: ValidatedToastOptions) { const toastOptions = useToastOptions(options); const forceRender = useForceUpdate(); - const { targetDocument } = useFluent(); - const [toast] = React.useState(() => { - if (targetDocument) { - const newToast = new Toast(); - newToast.onUpdate = forceRender; - return newToast; - } - }); + const [toast] = React.useState(() => new Toast()); const toastRef = React.useRef(null); React.useEffect(() => { if (toast && toastRef.current) { + toast.onUpdate = forceRender; toast.connectToDOM(toastRef.current, toastOptions); return () => toast.disconnect(); } - }, [toast, toastOptions]); + }, [toast, toastOptions, forceRender]); if (!toast) { return {