From 9e65c44b0137b41e239b4100df597507cf207915 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Tue, 9 May 2023 18:17:46 +0200 Subject: [PATCH 01/16] feat: Implement state management for toasts Starts with the simplest use case - make a toast appear configurable with: - timeout - position --- package.json | 1 + .../src/components/Animation/Animation.tsx | 1 + .../react-toast/.storybook/main.js | 14 +++ .../react-toast/.storybook/preview.js | 7 ++ .../react-toast/.storybook/tsconfig.json | 10 ++ .../react-toast/etc/react-toast.api.md | 8 ++ .../react-components/react-toast/package.json | 7 +- .../react-toast/src/components/Timer.tsx | 47 ++++++++ .../react-toast/src/components/Toast.tsx | 106 ++++++++++++++++++ .../react-toast/src/components/Toaster.tsx | 50 +++++++++ .../react-components/react-toast/src/index.ts | 6 +- .../react-toast/src/state/constants.ts | 3 + .../react-toast/src/state/index.ts | 4 + .../react-toast/src/state/types.ts | 21 ++++ .../react-toast/src/state/useToast.ts | 25 +++++ .../react-toast/src/state/useToaster.ts | 50 +++++++++ .../src/state/vanilla/createToast.ts | 13 +++ .../src/state/vanilla/getPositionStyles.ts | 39 +++++++ .../react-toast/src/state/vanilla/index.ts | 4 + .../react-toast/src/state/vanilla/toast.ts | 13 +++ .../react-toast/src/state/vanilla/toaster.ts | 76 +++++++++++++ .../react-toast/src/testing/isConformant.ts | 15 +++ .../stories/Toast/CustomTimeout.stories.tsx | 15 +++ .../stories/Toast/Default.stories.tsx | 13 +++ .../stories/Toast/ToastPositions.stories.tsx | 16 +++ .../stories/Toast/index.stories.tsx | 7 ++ .../react-toast/tsconfig.json | 3 + .../react-toast/tsconfig.lib.json | 10 +- .../react-toast/tsconfig.spec.json | 10 +- yarn.lock | 7 ++ 30 files changed, 596 insertions(+), 5 deletions(-) create mode 100644 packages/react-components/react-toast/.storybook/main.js create mode 100644 packages/react-components/react-toast/.storybook/preview.js create mode 100644 packages/react-components/react-toast/.storybook/tsconfig.json create mode 100644 packages/react-components/react-toast/src/components/Timer.tsx create mode 100644 packages/react-components/react-toast/src/components/Toast.tsx create mode 100644 packages/react-components/react-toast/src/components/Toaster.tsx create mode 100644 packages/react-components/react-toast/src/state/constants.ts create mode 100644 packages/react-components/react-toast/src/state/index.ts create mode 100644 packages/react-components/react-toast/src/state/types.ts create mode 100644 packages/react-components/react-toast/src/state/useToast.ts create mode 100644 packages/react-components/react-toast/src/state/useToaster.ts create mode 100644 packages/react-components/react-toast/src/state/vanilla/createToast.ts create mode 100644 packages/react-components/react-toast/src/state/vanilla/getPositionStyles.ts create mode 100644 packages/react-components/react-toast/src/state/vanilla/index.ts create mode 100644 packages/react-components/react-toast/src/state/vanilla/toast.ts create mode 100644 packages/react-components/react-toast/src/state/vanilla/toaster.ts create mode 100644 packages/react-components/react-toast/src/testing/isConformant.ts create mode 100644 packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx create mode 100644 packages/react-components/react-toast/stories/Toast/Default.stories.tsx create mode 100644 packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx create mode 100644 packages/react-components/react-toast/stories/Toast/index.stories.tsx diff --git a/package.json b/package.json index a86ad0a03c512d..fc1dd0c96461c7 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "@types/react-dom": "17.0.15", "@types/react-is": "17.0.3", "@types/react-test-renderer": "17.0.2", + "@types/react-transition-group": "4.4.6", "@types/request-promise-native": "1.0.18", "@types/scheduler": "0.16.2", "@types/semver": "^6.2.0", diff --git a/packages/fluentui/react-northstar/src/components/Animation/Animation.tsx b/packages/fluentui/react-northstar/src/components/Animation/Animation.tsx index 109c22c5149bee..402e376b1ca5d4 100644 --- a/packages/fluentui/react-northstar/src/components/Animation/Animation.tsx +++ b/packages/fluentui/react-northstar/src/components/Animation/Animation.tsx @@ -195,6 +195,7 @@ export const Animation = React.forwardRef((props className={!isChildrenFunction ? cx(animationClasses, className, (child as any)?.props?.className) : ''} > {isChildrenFunction ? ( + // @ts-ignore - @types/react-transition-group doesn't actually include this API, nor is it documented ({ state }) => { const childWithClasses = (children as AnimationChildrenProp)({ classes: cx(animationClasses, className, (child as any)?.props?.className), diff --git a/packages/react-components/react-toast/.storybook/main.js b/packages/react-components/react-toast/.storybook/main.js new file mode 100644 index 00000000000000..26536b61b387f6 --- /dev/null +++ b/packages/react-components/react-toast/.storybook/main.js @@ -0,0 +1,14 @@ +const rootMain = require('../../../../.storybook/main'); + +module.exports = /** @type {Omit} */ ({ + ...rootMain, + stories: [...rootMain.stories, '../stories/**/*.stories.mdx', '../stories/**/index.stories.@(ts|tsx)'], + addons: [...rootMain.addons], + webpackFinal: (config, options) => { + const localConfig = { ...rootMain.webpackFinal(config, options) }; + + // add your own webpack tweaks if needed + + return localConfig; + }, +}); diff --git a/packages/react-components/react-toast/.storybook/preview.js b/packages/react-components/react-toast/.storybook/preview.js new file mode 100644 index 00000000000000..1939500a3d18c7 --- /dev/null +++ b/packages/react-components/react-toast/.storybook/preview.js @@ -0,0 +1,7 @@ +import * as rootPreview from '../../../../.storybook/preview'; + +/** @type {typeof rootPreview.decorators} */ +export const decorators = [...rootPreview.decorators]; + +/** @type {typeof rootPreview.parameters} */ +export const parameters = { ...rootPreview.parameters }; diff --git a/packages/react-components/react-toast/.storybook/tsconfig.json b/packages/react-components/react-toast/.storybook/tsconfig.json new file mode 100644 index 00000000000000..ea89218a3d916f --- /dev/null +++ b/packages/react-components/react-toast/.storybook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "", + "allowJs": true, + "checkJs": true, + "types": ["static-assets", "environment", "storybook__addons"] + }, + "include": ["../stories/**/*.stories.ts", "../stories/**/*.stories.tsx", "*.js"] +} 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 e85599257dab54..51f52710afe01d 100644 --- a/packages/react-components/react-toast/etc/react-toast.api.md +++ b/packages/react-components/react-toast/etc/react-toast.api.md @@ -4,6 +4,14 @@ ```ts +import * as React_2 from 'react'; + +// @public (undocumented) +export function createToast(content: React_2.ReactNode, options?: ToastOptions): void; + +// @public (undocumented) +export const Toaster: React_2.FC; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/react-toast/package.json b/packages/react-components/react-toast/package.json index 276520bb4d01a6..286619fde89cb7 100644 --- a/packages/react-components/react-toast/package.json +++ b/packages/react-components/react-toast/package.json @@ -21,7 +21,9 @@ "test": "jest --passWithNoTests", "test-ssr": "test-ssr ./stories/**/*.stories.tsx", "type-check": "tsc -b tsconfig.json", - "generate-api": "just-scripts generate-api" + "generate-api": "just-scripts generate-api", + "storybook": "start-storybook", + "start": "yarn storybook" }, "devDependencies": { "@fluentui/eslint-plugin": "*", @@ -31,7 +33,10 @@ "@fluentui/scripts-tasks": "*" }, "dependencies": { + "react-transition-group": "^4.4.1", "@fluentui/react-jsx-runtime": "9.0.0-alpha.2", + "@fluentui/react-portal": "^9.2.6", + "@fluentui/react-shared-contexts": "^9.3.3", "@fluentui/react-theme": "^9.1.7", "@fluentui/react-utilities": "^9.8.0", "@griffel/react": "^1.5.2", diff --git a/packages/react-components/react-toast/src/components/Timer.tsx b/packages/react-components/react-toast/src/components/Timer.tsx new file mode 100644 index 00000000000000..136a3e4bd4676b --- /dev/null +++ b/packages/react-components/react-toast/src/components/Timer.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { makeStyles } from '@griffel/react'; + +const useStyles = makeStyles({ + progress: { + animationName: { + from: { + opacity: 0, + }, + to: { + opacity: 0, + }, + }, + }, +}); + +export const Timer: React.FC<{ + running: boolean; + timeout: number; + onTimeout: () => void; +}> = props => { + const styles = useStyles(); + const { running, timeout, onTimeout } = props; + const cleanupRef = React.useRef<() => void>(() => null); + + const bindEventListeners = React.useCallback( + (el: HTMLElement) => { + cleanupRef.current(); + if (el) { + el.addEventListener('animationend', onTimeout); + cleanupRef.current = () => el.removeEventListener('animationend', onTimeout); + } + }, + [onTimeout], + ); + + const style: React.CSSProperties = { + animationDuration: `${timeout}ms`, + animationPlayState: running ? 'running' : 'paused', + }; + + if (timeout < 0) { + return null; + } + + return ; +}; diff --git a/packages/react-components/react-toast/src/components/Toast.tsx b/packages/react-components/react-toast/src/components/Toast.tsx new file mode 100644 index 00000000000000..63f60c84d674e8 --- /dev/null +++ b/packages/react-components/react-toast/src/components/Toast.tsx @@ -0,0 +1,106 @@ +/** + * ⚠️ This is temporary and WILL be removed + */ + +import * as React from 'react'; +import { Transition } from 'react-transition-group'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { useToast, Toast as ToastProps } from '../state'; +import { Timer } from './Timer'; + +const useStyles = makeStyles({ + toast: { + ...shorthands.border('2px', 'dashed', 'red'), + ...shorthands.padding('4px'), + display: 'flex', + minHeight: '40px', + maxHeight: '40px', + minWidth: '200px', + maxWidth: '200px', + alignItems: 'center', + backgroundColor: 'white', + }, + + slide: { + animationDuration: '200ms, 400ms', + animationDelay: '0ms, 200ms', + animationName: [ + { + from: { + height: '0', + minHeight: '0', + maxHeight: '0', + opacity: 0, + }, + to: { + opacity: 0, + }, + }, + { + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + }, + ], + }, + + fadeOut: { + animationDuration: '400ms, 200ms', + animationDelay: '0ms, 400ms', + animationName: [ + { + from: { + opacity: 1, + }, + to: { + opacity: 0, + }, + }, + { + from: { + opacity: 0, + }, + to: { + opacity: 0, + height: 0, + maxHeight: 0, + minHeight: 0, + }, + }, + ], + }, +}); + +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); + + // start the toast once it's fully in + useIsomorphicLayoutEffect(() => { + if (toastRef.current) { + const toast = toastRef.current; + toast.addEventListener('animationend', play, { + once: true, + }); + + return () => { + toast.removeEventListener('animationend', play); + }; + } + }, [play, toastRef]); + + return ( + +
+ {children as React.ReactNode} + +
+
+ ); +}; diff --git a/packages/react-components/react-toast/src/components/Toaster.tsx b/packages/react-components/react-toast/src/components/Toaster.tsx new file mode 100644 index 00000000000000..86c0b2a146b294 --- /dev/null +++ b/packages/react-components/react-toast/src/components/Toaster.tsx @@ -0,0 +1,50 @@ +/** + * ⚠️ This is temporary and WILL be removed + */ + +import * as React from 'react'; +import { Portal } from '@fluentui/react-portal'; +import { ToastPosition, useToaster, getPositionStyles } from '../state'; +import { Toast } from './Toast'; +import { makeStyles, mergeClasses } from '@griffel/react'; + +interface ToasterProps { + position: ToastPosition; + targetDocument: Document | null | undefined; +} + +const useStyles = makeStyles({ + container: { + position: 'fixed', + }, +}); + +export const Toaster: React.FC = props => { + const { getToastToRender, isToastVisible } = useToaster(); + + const styles = useStyles(); + + return ( + +
+ {getToastToRender((position, toasts) => { + return ( +
+ {toasts.map(({ content, ...toastProps }) => { + return ( + + {content as React.ReactNode} + + ); + })} +
+ ); + })} +
+
+ ); +}; diff --git a/packages/react-components/react-toast/src/index.ts b/packages/react-components/react-toast/src/index.ts index aacbad0068e241..2e1564c0724848 100644 --- a/packages/react-components/react-toast/src/index.ts +++ b/packages/react-components/react-toast/src/index.ts @@ -1,2 +1,4 @@ -// TODO: replace with real exports -export {}; +export { Toaster } from './components/Toaster'; + +export { createToast } from './state'; +export type { ToastPosition } from './state'; diff --git a/packages/react-components/react-toast/src/state/constants.ts b/packages/react-components/react-toast/src/state/constants.ts new file mode 100644 index 00000000000000..bfaa28528c6f38 --- /dev/null +++ b/packages/react-components/react-toast/src/state/constants.ts @@ -0,0 +1,3 @@ +export const EVENTS = { + show: 'fui-toast-show', +} as const; diff --git a/packages/react-components/react-toast/src/state/index.ts b/packages/react-components/react-toast/src/state/index.ts new file mode 100644 index 00000000000000..294c85db27747c --- /dev/null +++ b/packages/react-components/react-toast/src/state/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './useToaster'; +export * from './useToast'; +export { createToast, getPositionStyles } from './vanilla'; diff --git a/packages/react-components/react-toast/src/state/types.ts b/packages/react-components/react-toast/src/state/types.ts new file mode 100644 index 00000000000000..3f82f373c6667d --- /dev/null +++ b/packages/react-components/react-toast/src/state/types.ts @@ -0,0 +1,21 @@ +import { EVENTS } from './constants'; + +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; +} + +export interface Toast extends Required> { + close: () => void; + remove: () => void; +} + +export interface ToastEventMap { + [EVENTS.show]: CustomEvent; +} diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts new file mode 100644 index 00000000000000..22535cd0f032d0 --- /dev/null +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Toast } from './vanilla/toast'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; + +export function useToast() { + const [toast] = React.useState(() => new Toast()); + const [_, forceRender] = React.useReducer(() => ({}), {}); + useIsomorphicLayoutEffect(() => { + toast.onUpdate = forceRender; + }, [toast]); + + const play = React.useCallback(() => { + toast.play(); + }, [toast]); + + const pause = React.useCallback(() => { + toast.pause(); + }, [toast]); + + return { + play, + pause, + running: toast.running, + }; +} diff --git a/packages/react-components/react-toast/src/state/useToaster.ts b/packages/react-components/react-toast/src/state/useToaster.ts new file mode 100644 index 00000000000000..33c90c8e3481f3 --- /dev/null +++ b/packages/react-components/react-toast/src/state/useToaster.ts @@ -0,0 +1,50 @@ +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import * as React from 'react'; +import { Toaster } from './vanilla/toaster'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { Toast, ToastId, ToastPosition } from './types'; + +export function useToaster() { + const { targetDocument } = useFluent(); + const [_, forceRender] = React.useReducer(() => ({}), {}); + const [toaster] = React.useState(() => (targetDocument ? new Toaster(targetDocument) : undefined)); + + useIsomorphicLayoutEffect(() => { + if (toaster) { + toaster.onUpdate = forceRender; + } + + return () => toaster?.dispose(); + }, [toaster]); + + const getToastToRender = React.useCallback( + (cb: (position: ToastPosition, toasts: Toast[]) => T) => { + if (!toaster) { + return []; + } + + const toRender = new Map(); + const toasts = Array.from(toaster.toasts.values()); + + toasts.forEach(toast => { + const { position } = toast; + toRender.has(position) || toRender.set(position, []); + toRender.get(position)!.push(toast); + }); + + return Array.from(toRender, ([position, toastsToRender]) => { + if (position.startsWith('top')) { + toastsToRender.reverse(); + } + + return cb(position, toastsToRender); + }); + }, + [toaster], + ); + + return { + isToastVisible: (toastId: ToastId) => !!toaster?.isToastVisible(toastId), + getToastToRender, + }; +} diff --git a/packages/react-components/react-toast/src/state/vanilla/createToast.ts b/packages/react-components/react-toast/src/state/vanilla/createToast.ts new file mode 100644 index 00000000000000..5f7d62d69062c0 --- /dev/null +++ b/packages/react-components/react-toast/src/state/vanilla/createToast.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { ToastOptions } from '../types'; +import { EVENTS } from '../constants'; + +let counter = 0; + +export function createToast(content: React.ReactNode, options: ToastOptions = {}) { + if (!options.toastId) { + options.toastId = (counter++).toString(); + } + const event = new CustomEvent(EVENTS.show, { bubbles: false, cancelable: false, detail: { ...options, content } }); + document.dispatchEvent(event); +} diff --git a/packages/react-components/react-toast/src/state/vanilla/getPositionStyles.ts b/packages/react-components/react-toast/src/state/vanilla/getPositionStyles.ts new file mode 100644 index 00000000000000..d7fa70a8108c6c --- /dev/null +++ b/packages/react-components/react-toast/src/state/vanilla/getPositionStyles.ts @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { ToastPosition } from '../types'; + +export const getPositionStyles = (position: ToastPosition) => { + const containerStyles: React.CSSProperties = { + position: 'fixed', + }; + + let positionStyles: React.CSSProperties = {}; + switch (position) { + case 'top-left': + positionStyles = { + top: 0, + left: 0, + }; + break; + case 'top-right': + positionStyles = { + top: 0, + right: 0, + }; + break; + case 'bottom-left': + positionStyles = { + bottom: 0, + left: 0, + }; + break; + case 'bottom-right': + positionStyles = { + bottom: 0, + right: 0, + }; + break; + } + + Object.assign(containerStyles, positionStyles); + return containerStyles; +}; diff --git a/packages/react-components/react-toast/src/state/vanilla/index.ts b/packages/react-components/react-toast/src/state/vanilla/index.ts new file mode 100644 index 00000000000000..e83b9025ced478 --- /dev/null +++ b/packages/react-components/react-toast/src/state/vanilla/index.ts @@ -0,0 +1,4 @@ +export * from './createToast'; +export * from './toast'; +export * from './toaster'; +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 new file mode 100644 index 00000000000000..544cca1479e6bc --- /dev/null +++ b/packages/react-components/react-toast/src/state/vanilla/toast.ts @@ -0,0 +1,13 @@ +// TODO convert to closure +export class Toast { + public running = false; + public onUpdate: () => void = () => null; + + public play() { + this.running = true; + } + + public pause() { + this.running = false; + } +} diff --git a/packages/react-components/react-toast/src/state/vanilla/toaster.ts b/packages/react-components/react-toast/src/state/vanilla/toaster.ts new file mode 100644 index 00000000000000..9edef031181b62 --- /dev/null +++ b/packages/react-components/react-toast/src/state/vanilla/toaster.ts @@ -0,0 +1,76 @@ +import { Toast, ToastEventMap, ToastId, ToastOptions } from '../types'; +import { EVENTS } from '../constants'; + +// TODO convert to closure +export class Toaster { + public visibleToasts: Set; + public toasts: Map; + public onUpdate: () => void; + private targetDocument: Document; + + private listeners = new Map(); + + constructor(targetDocument: Document) { + this.toasts = new Map(); + this.visibleToasts = new Set(); + this.onUpdate = () => null; + this.targetDocument = targetDocument; + + this._initEvents(); + } + + public dispose() { + for (const [event, callback] of this.listeners.entries()) { + this._removeEventListener(event, callback as () => void); + } + } + + public isToastVisible(toastId: ToastId) { + return this.visibleToasts.has(toastId); + } + + private _initEvents() { + const buildToast: (e: ToastEventMap[typeof EVENTS.show]) => void = e => this._buildToast(e.detail); + + this.listeners.set(EVENTS.show, buildToast); + + this._addEventListener(EVENTS.show, buildToast); + } + + private _addEventListener( + eventType: TEvent, + callback: (event: ToastEventMap[TEvent]) => void, + ) { + this.targetDocument.addEventListener(eventType, callback as () => void); + } + + private _removeEventListener(eventType: keyof ToastEventMap, callback: () => void) { + this.targetDocument.removeEventListener(eventType, callback); + } + + private _buildToast(toastOptions: ToastOptions) { + const { toastId = '', position = 'bottom-right', timeout = 3000, content = '' } = toastOptions; + const close = () => { + this.visibleToasts.delete(toastId); + this.onUpdate(); + }; + + const remove = () => { + this.toasts.delete(toastId); + this.onUpdate(); + }; + + const toast: Toast = { + position, + toastId, + timeout, + content, + close, + remove, + }; + + this.visibleToasts.add(toastId); + this.toasts.set(toastId, toast); + this.onUpdate(); + } +} diff --git a/packages/react-components/react-toast/src/testing/isConformant.ts b/packages/react-components/react-toast/src/testing/isConformant.ts new file mode 100644 index 00000000000000..a3d988f29a1728 --- /dev/null +++ b/packages/react-components/react-toast/src/testing/isConformant.ts @@ -0,0 +1,15 @@ +import { isConformant as baseIsConformant } from '@fluentui/react-conformance'; +import type { IsConformantOptions, TestObject } from '@fluentui/react-conformance'; +import griffelTests from '@fluentui/react-conformance-griffel'; + +export function isConformant( + testInfo: Omit, 'componentPath'> & { componentPath?: string }, +) { + const defaultOptions: Partial> = { + tsConfig: { configName: 'tsconfig.spec.json' }, + componentPath: require.main?.filename.replace('.test', ''), + extraTests: griffelTests as TestObject, + }; + + baseIsConformant(defaultOptions, testInfo); +} diff --git a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx new file mode 100644 index 00000000000000..0842cbfc5fac2e --- /dev/null +++ b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { Toaster, createToast } from '@fluentui/react-toast'; + +let toastId = 0; + +export const CustomTimeout = () => { + const notify = () => createToast('This is a toast', { toastId: (toastId++).toString(), timeout: 1000 }); + + return ( + <> + + + + ); +}; diff --git a/packages/react-components/react-toast/stories/Toast/Default.stories.tsx b/packages/react-components/react-toast/stories/Toast/Default.stories.tsx new file mode 100644 index 00000000000000..77f55827d691ec --- /dev/null +++ b/packages/react-components/react-toast/stories/Toast/Default.stories.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { Toaster, createToast } from '@fluentui/react-toast'; + +export const Default = () => { + const notify = () => createToast('This is a toast'); + + return ( + <> + + + + ); +}; diff --git a/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx b/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx new file mode 100644 index 00000000000000..f52ac7796245ed --- /dev/null +++ b/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Toaster, createToast, ToastPosition } from '@fluentui/react-toast'; + +export const ToastPositions = () => { + const notify = (position: ToastPosition) => createToast('This is a toast', { position }); + + 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 new file mode 100644 index 00000000000000..8ed02a592f3835 --- /dev/null +++ b/packages/react-components/react-toast/stories/Toast/index.stories.tsx @@ -0,0 +1,7 @@ +export { Default } from './Default.stories'; +export { CustomTimeout } from './CustomTimeout.stories'; +export { ToastPositions } from './ToastPositions.stories'; + +export default { + title: 'Preview Components/Toast', +}; diff --git a/packages/react-components/react-toast/tsconfig.json b/packages/react-components/react-toast/tsconfig.json index 12ca516af1c5b2..1941a041d46c19 100644 --- a/packages/react-components/react-toast/tsconfig.json +++ b/packages/react-components/react-toast/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "./tsconfig.spec.json" + }, + { + "path": "./.storybook/tsconfig.json" } ] } diff --git a/packages/react-components/react-toast/tsconfig.lib.json b/packages/react-components/react-toast/tsconfig.lib.json index b2da24eff1b32f..6f90cf95c005bd 100644 --- a/packages/react-components/react-toast/tsconfig.lib.json +++ b/packages/react-components/react-toast/tsconfig.lib.json @@ -9,6 +9,14 @@ "inlineSources": true, "types": ["static-assets", "environment"] }, - "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"], + "exclude": [ + "./src/testing/**", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.stories.ts", + "**/*.stories.tsx" + ], "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/packages/react-components/react-toast/tsconfig.spec.json b/packages/react-components/react-toast/tsconfig.spec.json index 469fcba4d7ba75..911456fe4b4d91 100644 --- a/packages/react-components/react-toast/tsconfig.spec.json +++ b/packages/react-components/react-toast/tsconfig.spec.json @@ -5,5 +5,13 @@ "outDir": "dist", "types": ["jest", "node"] }, - "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"] + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.d.ts", + "./src/testing/**/*.ts", + "./src/testing/**/*.tsx" + ] } diff --git a/yarn.lock b/yarn.lock index 730a36ee10b92e..f06568ba3158c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5929,6 +5929,13 @@ dependencies: "@types/react" "^17" +"@types/react-transition-group@4.4.6": + version "4.4.6" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.6.tgz#18187bcda5281f8e10dfc48f0943e2fdf4f75e2e" + integrity sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew== + dependencies: + "@types/react" "*" + "@types/react-virtualized@^9.21.8": version "9.21.8" resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.8.tgz#dc0150a75fd6e42f33729886463ece04d03367ea" From 09e42f90aba49da6b416d0a7cba2378181ba004b Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Tue, 9 May 2023 18:31:55 +0200 Subject: [PATCH 02/16] remove redundant import --- .../react-toast/src/components/Toaster.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/react-components/react-toast/src/components/Toaster.tsx b/packages/react-components/react-toast/src/components/Toaster.tsx index 86c0b2a146b294..9bb1d485b4f1a1 100644 --- a/packages/react-components/react-toast/src/components/Toaster.tsx +++ b/packages/react-components/react-toast/src/components/Toaster.tsx @@ -4,22 +4,17 @@ import * as React from 'react'; import { Portal } from '@fluentui/react-portal'; -import { ToastPosition, useToaster, getPositionStyles } from '../state'; +import { useToaster, getPositionStyles } from '../state'; import { Toast } from './Toast'; import { makeStyles, mergeClasses } from '@griffel/react'; -interface ToasterProps { - position: ToastPosition; - targetDocument: Document | null | undefined; -} - const useStyles = makeStyles({ container: { position: 'fixed', }, }); -export const Toaster: React.FC = props => { +export const Toaster: React.FC = () => { const { getToastToRender, isToastVisible } = useToaster(); const styles = useStyles(); From 7e15b989d86c8d4c99128b6dbcc0654791f01a41 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Tue, 9 May 2023 18:33:25 +0200 Subject: [PATCH 03/16] remove props --- .../react-toast/stories/Toast/CustomTimeout.stories.tsx | 2 +- .../react-toast/stories/Toast/Default.stories.tsx | 2 +- .../react-toast/stories/Toast/ToastPositions.stories.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx index 0842cbfc5fac2e..a851107ee2005b 100644 --- a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx @@ -8,7 +8,7 @@ export const CustomTimeout = () => { return ( <> - + ); diff --git a/packages/react-components/react-toast/stories/Toast/Default.stories.tsx b/packages/react-components/react-toast/stories/Toast/Default.stories.tsx index 77f55827d691ec..def25ffdfc8fea 100644 --- a/packages/react-components/react-toast/stories/Toast/Default.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/Default.stories.tsx @@ -6,7 +6,7 @@ export const Default = () => { return ( <> - + ); diff --git a/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx b/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx index f52ac7796245ed..cbd051ddb5565a 100644 --- a/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx @@ -6,7 +6,7 @@ export const ToastPositions = () => { return ( <> - + From 0c9f61c74183d4c1d711ae9123098ea86c50c90f Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Tue, 9 May 2023 19:00:30 +0200 Subject: [PATCH 04/16] useToastFactory --- .../react-toast/etc/react-toast.api.md | 9 ++++++-- .../react-components/react-toast/src/index.ts | 2 +- .../react-toast/src/state/index.ts | 3 ++- .../react-toast/src/state/useToastFactory.ts | 21 +++++++++++++++++++ .../src/state/vanilla/createToast.ts | 4 ++-- .../stories/Toast/CustomTimeout.stories.tsx | 3 ++- .../stories/Toast/Default.stories.tsx | 3 ++- .../stories/Toast/ToastPositions.stories.tsx | 3 ++- 8 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 packages/react-components/react-toast/src/state/useToastFactory.ts 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 51f52710afe01d..70ecaadaf7e49c 100644 --- a/packages/react-components/react-toast/etc/react-toast.api.md +++ b/packages/react-components/react-toast/etc/react-toast.api.md @@ -7,10 +7,15 @@ import * as React_2 from 'react'; // @public (undocumented) -export function createToast(content: React_2.ReactNode, options?: ToastOptions): void; +export const Toaster: React_2.FC; // @public (undocumented) -export const Toaster: React_2.FC; +export type ToastPosition = 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left'; + +// @public (undocumented) +export function useToastFactory(): { + createToast: (content: React_2.ReactNode, options?: ToastOptions | undefined) => void; +}; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-toast/src/index.ts b/packages/react-components/react-toast/src/index.ts index 2e1564c0724848..70a05dfe565f19 100644 --- a/packages/react-components/react-toast/src/index.ts +++ b/packages/react-components/react-toast/src/index.ts @@ -1,4 +1,4 @@ export { Toaster } from './components/Toaster'; -export { createToast } from './state'; +export { useToastFactory } from './state'; export type { ToastPosition } from './state'; diff --git a/packages/react-components/react-toast/src/state/index.ts b/packages/react-components/react-toast/src/state/index.ts index 294c85db27747c..39fc16a027f0bd 100644 --- a/packages/react-components/react-toast/src/state/index.ts +++ b/packages/react-components/react-toast/src/state/index.ts @@ -1,4 +1,5 @@ export * from './types'; export * from './useToaster'; export * from './useToast'; -export { createToast, getPositionStyles } from './vanilla'; +export * from './useToastFactory'; +export { getPositionStyles } from './vanilla'; diff --git a/packages/react-components/react-toast/src/state/useToastFactory.ts b/packages/react-components/react-toast/src/state/useToastFactory.ts new file mode 100644 index 00000000000000..494cb8dade5fb9 --- /dev/null +++ b/packages/react-components/react-toast/src/state/useToastFactory.ts @@ -0,0 +1,21 @@ +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { createToast as createToastVanilla } from './vanilla/createToast'; +import * as React from 'react'; +import { ToastOptions } from './types'; + +export function useToastFactory() { + const { targetDocument } = useFluent(); + + const createToast = React.useCallback( + (content: React.ReactNode, options?: ToastOptions) => { + if (targetDocument) { + createToastVanilla(content, options, targetDocument); + } + }, + [targetDocument], + ); + + return { + createToast, + }; +} diff --git a/packages/react-components/react-toast/src/state/vanilla/createToast.ts b/packages/react-components/react-toast/src/state/vanilla/createToast.ts index 5f7d62d69062c0..a07b38f34fca33 100644 --- a/packages/react-components/react-toast/src/state/vanilla/createToast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/createToast.ts @@ -4,10 +4,10 @@ import { EVENTS } from '../constants'; let counter = 0; -export function createToast(content: React.ReactNode, options: ToastOptions = {}) { +export function createToast(content: React.ReactNode, options: ToastOptions = {}, targetDocument: Document) { if (!options.toastId) { options.toastId = (counter++).toString(); } const event = new CustomEvent(EVENTS.show, { bubbles: false, cancelable: false, detail: { ...options, content } }); - document.dispatchEvent(event); + targetDocument.dispatchEvent(event); } diff --git a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx index a851107ee2005b..af58aea845c053 100644 --- a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { Toaster, createToast } from '@fluentui/react-toast'; +import { Toaster, useToastFactory } from '@fluentui/react-toast'; let toastId = 0; export const CustomTimeout = () => { + const { createToast } = useToastFactory(); const notify = () => createToast('This is a toast', { toastId: (toastId++).toString(), timeout: 1000 }); return ( diff --git a/packages/react-components/react-toast/stories/Toast/Default.stories.tsx b/packages/react-components/react-toast/stories/Toast/Default.stories.tsx index def25ffdfc8fea..37e2db069ae306 100644 --- a/packages/react-components/react-toast/stories/Toast/Default.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/Default.stories.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { Toaster, createToast } from '@fluentui/react-toast'; +import { Toaster, useToastFactory } from '@fluentui/react-toast'; export const Default = () => { + const { createToast } = useToastFactory(); const notify = () => createToast('This is a toast'); return ( diff --git a/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx b/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx index cbd051ddb5565a..9fb21959be236d 100644 --- a/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { Toaster, createToast, ToastPosition } from '@fluentui/react-toast'; +import { Toaster, useToastFactory, ToastPosition } from '@fluentui/react-toast'; export const ToastPositions = () => { + const { createToast } = useToastFactory(); const notify = (position: ToastPosition) => createToast('This is a toast', { position }); return ( From 72d5cf1ed93e8995799bcb339a11540bc885d1b8 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Tue, 9 May 2023 20:31:42 +0200 Subject: [PATCH 05/16] review feedback --- .../react-toast/src/components/Toast.tsx | 2 +- .../react-toast/src/components/Toaster.tsx | 4 ++-- .../react-toast/src/state/useToast.ts | 13 ++++++----- .../react-toast/src/state/useToaster.ts | 22 +++++++++---------- .../src/state/vanilla/createToast.ts | 4 +--- 5 files changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/react-components/react-toast/src/components/Toast.tsx b/packages/react-components/react-toast/src/components/Toast.tsx index 63f60c84d674e8..a3ea11e069d884 100644 --- a/packages/react-components/react-toast/src/components/Toast.tsx +++ b/packages/react-components/react-toast/src/components/Toast.tsx @@ -98,7 +98,7 @@ export const Toast: React.FC & { visible: boolean }> return (
- {children as React.ReactNode} + {children}
diff --git a/packages/react-components/react-toast/src/components/Toaster.tsx b/packages/react-components/react-toast/src/components/Toaster.tsx index 9bb1d485b4f1a1..b387b043d05e3f 100644 --- a/packages/react-components/react-toast/src/components/Toaster.tsx +++ b/packages/react-components/react-toast/src/components/Toaster.tsx @@ -15,14 +15,14 @@ const useStyles = makeStyles({ }); export const Toaster: React.FC = () => { - const { getToastToRender, isToastVisible } = useToaster(); + const { getToastsToRender, isToastVisible } = useToaster(); const styles = useStyles(); return (
- {getToastToRender((position, toasts) => { + {getToastsToRender((position, toasts) => { return (
{toasts.map(({ content, ...toastProps }) => { diff --git a/packages/react-components/react-toast/src/state/useToast.ts b/packages/react-components/react-toast/src/state/useToast.ts index 22535cd0f032d0..caadcebf58ca3b 100644 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -1,13 +1,14 @@ import * as React from 'react'; import { Toast } from './vanilla/toast'; -import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useForceUpdate } from '@fluentui/react-utilities'; export function useToast() { - const [toast] = React.useState(() => new Toast()); - const [_, forceRender] = React.useReducer(() => ({}), {}); - useIsomorphicLayoutEffect(() => { - toast.onUpdate = forceRender; - }, [toast]); + const forceRender = useForceUpdate(); + const [toast] = React.useState(() => { + const newToast = new Toast(); + newToast.onUpdate = forceRender; + return newToast; + }); const play = React.useCallback(() => { toast.play(); diff --git a/packages/react-components/react-toast/src/state/useToaster.ts b/packages/react-components/react-toast/src/state/useToaster.ts index 33c90c8e3481f3..5f3a0403b17979 100644 --- a/packages/react-components/react-toast/src/state/useToaster.ts +++ b/packages/react-components/react-toast/src/state/useToaster.ts @@ -1,23 +1,21 @@ import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import * as React from 'react'; import { Toaster } from './vanilla/toaster'; -import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useForceUpdate } from '@fluentui/react-utilities'; import { Toast, ToastId, ToastPosition } from './types'; export function useToaster() { const { targetDocument } = useFluent(); - const [_, forceRender] = React.useReducer(() => ({}), {}); - const [toaster] = React.useState(() => (targetDocument ? new Toaster(targetDocument) : undefined)); - - useIsomorphicLayoutEffect(() => { - if (toaster) { - toaster.onUpdate = forceRender; + const forceRender = useForceUpdate(); + const [toaster] = React.useState(() => { + if (targetDocument) { + const newToaster = new Toaster(targetDocument); + newToaster.onUpdate = forceRender; + return newToaster; } + }); - return () => toaster?.dispose(); - }, [toaster]); - - const getToastToRender = React.useCallback( + const getToastsToRender = React.useCallback( (cb: (position: ToastPosition, toasts: Toast[]) => T) => { if (!toaster) { return []; @@ -45,6 +43,6 @@ export function useToaster() { return { isToastVisible: (toastId: ToastId) => !!toaster?.isToastVisible(toastId), - getToastToRender, + getToastsToRender, }; } diff --git a/packages/react-components/react-toast/src/state/vanilla/createToast.ts b/packages/react-components/react-toast/src/state/vanilla/createToast.ts index a07b38f34fca33..f88fe7660faf34 100644 --- a/packages/react-components/react-toast/src/state/vanilla/createToast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/createToast.ts @@ -5,9 +5,7 @@ import { EVENTS } from '../constants'; let counter = 0; export function createToast(content: React.ReactNode, options: ToastOptions = {}, targetDocument: Document) { - if (!options.toastId) { - options.toastId = (counter++).toString(); - } + options.toastId ??= (counter++).toString(); const event = new CustomEvent(EVENTS.show, { bubbles: false, cancelable: false, detail: { ...options, content } }); targetDocument.dispatchEvent(event); } From aa53249fd27700b376f26a99d91e37de1f1ef475 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 08:45:00 +0200 Subject: [PATCH 06/16] rename toast controls --- .../react-toast/etc/react-toast.api.md | 4 ++-- packages/react-components/react-toast/src/index.ts | 2 +- .../react-components/react-toast/src/state/index.ts | 2 +- .../{useToastFactory.ts => useToastController.ts} | 10 +++++----- .../state/vanilla/{createToast.ts => dispatchToast.ts} | 2 +- .../react-toast/src/state/vanilla/index.ts | 2 +- .../stories/Toast/CustomTimeout.stories.tsx | 6 +++--- .../react-toast/stories/Toast/Default.stories.tsx | 6 +++--- .../stories/Toast/ToastPositions.stories.tsx | 6 +++--- 9 files changed, 20 insertions(+), 20 deletions(-) rename packages/react-components/react-toast/src/state/{useToastFactory.ts => useToastController.ts} (58%) rename packages/react-components/react-toast/src/state/vanilla/{createToast.ts => dispatchToast.ts} (75%) 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 70ecaadaf7e49c..54701f3fc974cb 100644 --- a/packages/react-components/react-toast/etc/react-toast.api.md +++ b/packages/react-components/react-toast/etc/react-toast.api.md @@ -13,8 +13,8 @@ export const Toaster: React_2.FC; export type ToastPosition = 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left'; // @public (undocumented) -export function useToastFactory(): { - createToast: (content: React_2.ReactNode, options?: ToastOptions | undefined) => void; +export function useToastController(): { + dispatchToast: (content: React_2.ReactNode, options?: ToastOptions | undefined) => void; }; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-toast/src/index.ts b/packages/react-components/react-toast/src/index.ts index 70a05dfe565f19..d5a5e178359dbc 100644 --- a/packages/react-components/react-toast/src/index.ts +++ b/packages/react-components/react-toast/src/index.ts @@ -1,4 +1,4 @@ export { Toaster } from './components/Toaster'; -export { useToastFactory } from './state'; +export { useToastController } from './state'; export type { ToastPosition } from './state'; diff --git a/packages/react-components/react-toast/src/state/index.ts b/packages/react-components/react-toast/src/state/index.ts index 39fc16a027f0bd..551edbd9f4763e 100644 --- a/packages/react-components/react-toast/src/state/index.ts +++ b/packages/react-components/react-toast/src/state/index.ts @@ -1,5 +1,5 @@ export * from './types'; export * from './useToaster'; export * from './useToast'; -export * from './useToastFactory'; +export * from './useToastController'; export { getPositionStyles } from './vanilla'; diff --git a/packages/react-components/react-toast/src/state/useToastFactory.ts b/packages/react-components/react-toast/src/state/useToastController.ts similarity index 58% rename from packages/react-components/react-toast/src/state/useToastFactory.ts rename to packages/react-components/react-toast/src/state/useToastController.ts index 494cb8dade5fb9..59af3fd3694389 100644 --- a/packages/react-components/react-toast/src/state/useToastFactory.ts +++ b/packages/react-components/react-toast/src/state/useToastController.ts @@ -1,21 +1,21 @@ import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; -import { createToast as createToastVanilla } from './vanilla/createToast'; +import { dispatchToast as dispatchToastVanilla } from './vanilla/dispatchToast'; import * as React from 'react'; import { ToastOptions } from './types'; -export function useToastFactory() { +export function useToastController() { const { targetDocument } = useFluent(); - const createToast = React.useCallback( + const dispatchToast = React.useCallback( (content: React.ReactNode, options?: ToastOptions) => { if (targetDocument) { - createToastVanilla(content, options, targetDocument); + dispatchToastVanilla(content, options, targetDocument); } }, [targetDocument], ); return { - createToast, + dispatchToast, }; } diff --git a/packages/react-components/react-toast/src/state/vanilla/createToast.ts b/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts similarity index 75% rename from packages/react-components/react-toast/src/state/vanilla/createToast.ts rename to packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts index f88fe7660faf34..7f48f13e9d9c9e 100644 --- a/packages/react-components/react-toast/src/state/vanilla/createToast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts @@ -4,7 +4,7 @@ import { EVENTS } from '../constants'; let counter = 0; -export function createToast(content: React.ReactNode, options: ToastOptions = {}, targetDocument: Document) { +export function dispatchToast(content: React.ReactNode, options: ToastOptions = {}, targetDocument: Document) { options.toastId ??= (counter++).toString(); const event = new CustomEvent(EVENTS.show, { bubbles: false, cancelable: false, detail: { ...options, content } }); targetDocument.dispatchEvent(event); 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 e83b9025ced478..671fab78b6b05e 100644 --- a/packages/react-components/react-toast/src/state/vanilla/index.ts +++ b/packages/react-components/react-toast/src/state/vanilla/index.ts @@ -1,4 +1,4 @@ -export * from './createToast'; +export * from './dispatchToast'; export * from './toast'; export * from './toaster'; export * from './getPositionStyles'; diff --git a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx index af58aea845c053..0a68def3c0315a 100644 --- a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; -import { Toaster, useToastFactory } from '@fluentui/react-toast'; +import { Toaster, useToastController } from '@fluentui/react-toast'; let toastId = 0; export const CustomTimeout = () => { - const { createToast } = useToastFactory(); - const notify = () => createToast('This is a toast', { toastId: (toastId++).toString(), timeout: 1000 }); + const { dispatchToast } = useToastController(); + const notify = () => dispatchToast('This is a toast', { toastId: (toastId++).toString(), timeout: 1000 }); return ( <> diff --git a/packages/react-components/react-toast/stories/Toast/Default.stories.tsx b/packages/react-components/react-toast/stories/Toast/Default.stories.tsx index 37e2db069ae306..2e7061eca874f0 100644 --- a/packages/react-components/react-toast/stories/Toast/Default.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/Default.stories.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { Toaster, useToastFactory } from '@fluentui/react-toast'; +import { Toaster, useToastController } from '@fluentui/react-toast'; export const Default = () => { - const { createToast } = useToastFactory(); - const notify = () => createToast('This is a toast'); + const { dispatchToast } = useToastController(); + const notify = () => dispatchToast('This is a toast'); return ( <> diff --git a/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx b/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx index 9fb21959be236d..b2a1380c29a9eb 100644 --- a/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/ToastPositions.stories.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { Toaster, useToastFactory, ToastPosition } from '@fluentui/react-toast'; +import { Toaster, useToastController, ToastPosition } from '@fluentui/react-toast'; export const ToastPositions = () => { - const { createToast } = useToastFactory(); - const notify = (position: ToastPosition) => createToast('This is a toast', { position }); + const { dispatchToast } = useToastController(); + const notify = (position: ToastPosition) => dispatchToast('This is a toast', { position }); return ( <> From 43664a50ffb1b04764acda3211a8570d646c23e6 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 08:57:13 +0200 Subject: [PATCH 07/16] don't create duplicate toasts --- .../react-components/react-toast/src/state/vanilla/toaster.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 9edef031181b62..582cbc7aa0fea5 100644 --- a/packages/react-components/react-toast/src/state/vanilla/toaster.ts +++ b/packages/react-components/react-toast/src/state/vanilla/toaster.ts @@ -50,6 +50,10 @@ export class Toaster { private _buildToast(toastOptions: ToastOptions) { const { toastId = '', position = 'bottom-right', timeout = 3000, content = '' } = toastOptions; + if (this.toasts.has(toastId)) { + return; + } + const close = () => { this.visibleToasts.delete(toastId); this.onUpdate(); From 56f4c84d8996c8d41f643d4097dcd7a8673b196e Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 09:07:18 +0200 Subject: [PATCH 08/16] make position styles vanilla --- .../src/state/vanilla/getPositionStyles.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/react-components/react-toast/src/state/vanilla/getPositionStyles.ts b/packages/react-components/react-toast/src/state/vanilla/getPositionStyles.ts index d7fa70a8108c6c..96c06fc01bd466 100644 --- a/packages/react-components/react-toast/src/state/vanilla/getPositionStyles.ts +++ b/packages/react-components/react-toast/src/state/vanilla/getPositionStyles.ts @@ -1,39 +1,44 @@ -import * as React from 'react'; import { ToastPosition } from '../types'; +interface PositionStyles { + position: 'fixed'; + top?: number; + left?: number; + right?: number; + bottom?: number; +} + export const getPositionStyles = (position: ToastPosition) => { - const containerStyles: React.CSSProperties = { + const positionStyles: PositionStyles = { position: 'fixed', }; - let positionStyles: React.CSSProperties = {}; switch (position) { case 'top-left': - positionStyles = { + Object.assign(positionStyles, { top: 0, left: 0, - }; + }); break; case 'top-right': - positionStyles = { + Object.assign(positionStyles, { top: 0, right: 0, - }; + }); break; case 'bottom-left': - positionStyles = { + Object.assign(positionStyles, { bottom: 0, left: 0, - }; + }); break; case 'bottom-right': - positionStyles = { + Object.assign(positionStyles, { bottom: 0, right: 0, - }; + }); break; } - Object.assign(containerStyles, positionStyles); - return containerStyles; + return positionStyles; }; From 686cac167a0d86f0a8d4f7991614c12edcc8c139 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 11:53:09 +0200 Subject: [PATCH 09/16] refactor Timer --- .../react-toast/src/components/Timer.tsx | 47 ------------------- .../src/components/Timer/Timer.tsx | 31 ++++++++++++ .../react-toast/src/components/Timer/index.ts | 1 + .../components/Timer/useTimerStyles.styles.ts | 14 ++++++ 4 files changed, 46 insertions(+), 47 deletions(-) delete mode 100644 packages/react-components/react-toast/src/components/Timer.tsx create mode 100644 packages/react-components/react-toast/src/components/Timer/Timer.tsx create mode 100644 packages/react-components/react-toast/src/components/Timer/index.ts create mode 100644 packages/react-components/react-toast/src/components/Timer/useTimerStyles.styles.ts diff --git a/packages/react-components/react-toast/src/components/Timer.tsx b/packages/react-components/react-toast/src/components/Timer.tsx deleted file mode 100644 index 136a3e4bd4676b..00000000000000 --- a/packages/react-components/react-toast/src/components/Timer.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react'; -import { makeStyles } from '@griffel/react'; - -const useStyles = makeStyles({ - progress: { - animationName: { - from: { - opacity: 0, - }, - to: { - opacity: 0, - }, - }, - }, -}); - -export const Timer: React.FC<{ - running: boolean; - timeout: number; - onTimeout: () => void; -}> = props => { - const styles = useStyles(); - const { running, timeout, onTimeout } = props; - const cleanupRef = React.useRef<() => void>(() => null); - - const bindEventListeners = React.useCallback( - (el: HTMLElement) => { - cleanupRef.current(); - if (el) { - el.addEventListener('animationend', onTimeout); - cleanupRef.current = () => el.removeEventListener('animationend', onTimeout); - } - }, - [onTimeout], - ); - - const style: React.CSSProperties = { - animationDuration: `${timeout}ms`, - animationPlayState: running ? 'running' : 'paused', - }; - - if (timeout < 0) { - return null; - } - - return ; -}; diff --git a/packages/react-components/react-toast/src/components/Timer/Timer.tsx b/packages/react-components/react-toast/src/components/Timer/Timer.tsx new file mode 100644 index 00000000000000..f98265da4ca1f5 --- /dev/null +++ b/packages/react-components/react-toast/src/components/Timer/Timer.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { useStyles } from './useTimerStyles.styles'; + +export const Timer: React.FC<{ + running: boolean; + timeout: number; + onTimeout: () => void; +}> = props => { + const styles = useStyles(); + const { running, timeout, onTimeout } = props; + const ref = React.useRef(null); + + React.useEffect(() => { + if (ref.current) { + const timerElement = ref.current; + timerElement.addEventListener('animationend', onTimeout); + return () => timerElement.removeEventListener('animationend', onTimeout); + } + }, [onTimeout]); + + const style: React.CSSProperties = { + animationDuration: `${timeout}ms`, + animationPlayState: running ? 'running' : 'paused', + }; + + if (timeout < 0) { + return null; + } + + return ; +}; diff --git a/packages/react-components/react-toast/src/components/Timer/index.ts b/packages/react-components/react-toast/src/components/Timer/index.ts new file mode 100644 index 00000000000000..e79e25f75ab3a2 --- /dev/null +++ b/packages/react-components/react-toast/src/components/Timer/index.ts @@ -0,0 +1 @@ +export * from './Timer'; diff --git a/packages/react-components/react-toast/src/components/Timer/useTimerStyles.styles.ts b/packages/react-components/react-toast/src/components/Timer/useTimerStyles.styles.ts new file mode 100644 index 00000000000000..aa90d34e0adbc2 --- /dev/null +++ b/packages/react-components/react-toast/src/components/Timer/useTimerStyles.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from '@griffel/react'; + +export const useStyles = makeStyles({ + progress: { + animationName: { + from: { + opacity: 0, + }, + to: { + opacity: 0, + }, + }, + }, +}); From 9c3566ca6314d67eb6f30e5304f57337ecad35aa Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 11:53:30 +0200 Subject: [PATCH 10/16] fix cruft --- .../react-toast/stories/Toast/CustomTimeout.stories.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx index 0a68def3c0315a..b23bd00f62cbdf 100644 --- a/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/CustomTimeout.stories.tsx @@ -1,11 +1,9 @@ import * as React from 'react'; import { Toaster, useToastController } from '@fluentui/react-toast'; -let toastId = 0; - export const CustomTimeout = () => { const { dispatchToast } = useToastController(); - const notify = () => dispatchToast('This is a toast', { toastId: (toastId++).toString(), timeout: 1000 }); + const notify = () => dispatchToast('This is a toast', { timeout: 1000 }); return ( <> From 815c30c8f389a9b2d9770f83a756724a3d6e1299 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 11:54:12 +0200 Subject: [PATCH 11/16] remove unnecessary callbacks --- .../react-toast/src/state/useToast.ts | 12 ++---------- 1 file changed, 2 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 caadcebf58ca3b..2142da55df6fa7 100644 --- a/packages/react-components/react-toast/src/state/useToast.ts +++ b/packages/react-components/react-toast/src/state/useToast.ts @@ -10,17 +10,9 @@ export function useToast() { return newToast; }); - const play = React.useCallback(() => { - toast.play(); - }, [toast]); - - const pause = React.useCallback(() => { - toast.pause(); - }, [toast]); - return { - play, - pause, + play: toast.play, + pause: toast.pause, running: toast.running, }; } From d77f18c06f674adf2a3cedf4cb11df37a14863b6 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 11:54:47 +0200 Subject: [PATCH 12/16] stop leaking react --- .../react-toast/src/state/vanilla/dispatchToast.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 7f48f13e9d9c9e..fa0f4e504a3ce3 100644 --- a/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts @@ -1,10 +1,9 @@ -import * as React from 'react'; import { ToastOptions } from '../types'; import { EVENTS } from '../constants'; let counter = 0; -export function dispatchToast(content: React.ReactNode, options: ToastOptions = {}, targetDocument: Document) { +export function dispatchToast(content: unknown, options: ToastOptions = {}, targetDocument: Document) { options.toastId ??= (counter++).toString(); const event = new CustomEvent(EVENTS.show, { bubbles: false, cancelable: false, detail: { ...options, content } }); targetDocument.dispatchEvent(event); From ce1387c051312aa81a0da72ded6fce7c7c54996d Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 12:07:19 +0200 Subject: [PATCH 13/16] update toaster --- .../react-toast/src/state/types.ts | 4 ++++ .../react-toast/src/state/vanilla/toaster.ts | 19 ++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/react-components/react-toast/src/state/types.ts b/packages/react-components/react-toast/src/state/types.ts index 3f82f373c6667d..fa985d5925f797 100644 --- a/packages/react-components/react-toast/src/state/types.ts +++ b/packages/react-components/react-toast/src/state/types.ts @@ -19,3 +19,7 @@ export interface Toast extends Required> { export interface ToastEventMap { [EVENTS.show]: CustomEvent; } + +export type ToastEventListenerGeneric = (e: ToastEventMap[K]) => void; +export type ToastShowEventListener = ToastEventListenerGeneric; +export type ToastEventListener = ToastShowEventListener; 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 582cbc7aa0fea5..f4fe62a7c4ebc1 100644 --- a/packages/react-components/react-toast/src/state/vanilla/toaster.ts +++ b/packages/react-components/react-toast/src/state/vanilla/toaster.ts @@ -1,4 +1,4 @@ -import { Toast, ToastEventMap, ToastId, ToastOptions } from '../types'; +import { Toast, ToastEventListener, ToastEventListenerGeneric, ToastEventMap, ToastId, ToastOptions } from '../types'; import { EVENTS } from '../constants'; // TODO convert to closure @@ -8,7 +8,7 @@ export class Toaster { public onUpdate: () => void; private targetDocument: Document; - private listeners = new Map(); + private listeners = new Map(); constructor(targetDocument: Document) { this.toasts = new Map(); @@ -20,8 +20,10 @@ export class Toaster { } public dispose() { + this.toasts.clear(); for (const [event, callback] of this.listeners.entries()) { - this._removeEventListener(event, callback as () => void); + this._removeEventListener(event, callback); + this.listeners.delete(event); } } @@ -30,7 +32,7 @@ export class Toaster { } private _initEvents() { - const buildToast: (e: ToastEventMap[typeof EVENTS.show]) => void = e => this._buildToast(e.detail); + const buildToast: ToastEventListener = e => this._buildToast(e.detail); this.listeners.set(EVENTS.show, buildToast); @@ -39,13 +41,16 @@ export class Toaster { private _addEventListener( eventType: TEvent, - callback: (event: ToastEventMap[TEvent]) => void, + callback: ToastEventListenerGeneric, ) { this.targetDocument.addEventListener(eventType, callback as () => void); } - private _removeEventListener(eventType: keyof ToastEventMap, callback: () => void) { - this.targetDocument.removeEventListener(eventType, callback); + private _removeEventListener( + eventType: keyof ToastEventMap, + callback: ToastEventListenerGeneric, + ) { + this.targetDocument.removeEventListener(eventType, callback as () => void); } private _buildToast(toastOptions: ToastOptions) { From 8eed249c4f288d64e7c4a900a52d1b2ac90f6b53 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 12:08:17 +0200 Subject: [PATCH 14/16] update useToaster --- .../react-components/react-toast/src/state/useToaster.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-toast/src/state/useToaster.ts b/packages/react-components/react-toast/src/state/useToaster.ts index 5f3a0403b17979..b467edef72afe4 100644 --- a/packages/react-components/react-toast/src/state/useToaster.ts +++ b/packages/react-components/react-toast/src/state/useToaster.ts @@ -42,7 +42,13 @@ export function useToaster() { ); return { - isToastVisible: (toastId: ToastId) => !!toaster?.isToastVisible(toastId), + isToastVisible: (toastId: ToastId) => { + if (toaster) { + return toaster.isToastVisible(toastId); + } + + return false; + }, getToastsToRender, }; } From 6305979ce861518f784e0f43e7bc5c75d9b71d9c Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 12:10:25 +0200 Subject: [PATCH 15/16] update timer usage --- packages/react-components/react-toast/src/components/Toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-toast/src/components/Toast.tsx b/packages/react-components/react-toast/src/components/Toast.tsx index a3ea11e069d884..28e82b9570cef7 100644 --- a/packages/react-components/react-toast/src/components/Toast.tsx +++ b/packages/react-components/react-toast/src/components/Toast.tsx @@ -99,7 +99,7 @@ export const Toast: React.FC & { visible: boolean }>
{children} - +
); From 4a35e67a46e85603dbc9fea9577b28e6d1603d70 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 10 May 2023 12:10:47 +0200 Subject: [PATCH 16/16] update timer usage --- packages/react-components/react-toast/src/components/Toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-toast/src/components/Toast.tsx b/packages/react-components/react-toast/src/components/Toast.tsx index 28e82b9570cef7..3d29381e27d5ac 100644 --- a/packages/react-components/react-toast/src/components/Toast.tsx +++ b/packages/react-components/react-toast/src/components/Toast.tsx @@ -99,7 +99,7 @@ export const Toast: React.FC & { visible: boolean }>
{children} - +
);