From dce24b6e2ee1e3cf15f72d1ae2a57c9be9f4dfa1 Mon Sep 17 00:00:00 2001 From: Cheton Wu Date: Tue, 29 Oct 2024 16:42:00 +0800 Subject: [PATCH 01/19] feat: enable toast management through `ToastGroup` without `ToastManager` --- packages/react/__tests__/index.test.js | 1 + packages/react/src/toast/ToastGroup.js | 69 +++++++++++ packages/react/src/toast/ToastManager.js | 147 ++++++++--------------- packages/react/src/toast/index.js | 2 + 4 files changed, 123 insertions(+), 96 deletions(-) create mode 100644 packages/react/src/toast/ToastGroup.js diff --git a/packages/react/__tests__/index.test.js b/packages/react/__tests__/index.test.js index 0d2c1f5c2e..dd4dabf1ec 100644 --- a/packages/react/__tests__/index.test.js +++ b/packages/react/__tests__/index.test.js @@ -238,6 +238,7 @@ test('should match expected exports', () => { 'ToastCloseButton', 'ToastContainer', 'ToastController', + 'ToastGroup', 'ToastIcon', 'ToastManager', 'ToastMessage', diff --git a/packages/react/src/toast/ToastGroup.js b/packages/react/src/toast/ToastGroup.js new file mode 100644 index 0000000000..5bc60e108f --- /dev/null +++ b/packages/react/src/toast/ToastGroup.js @@ -0,0 +1,69 @@ +import { ensureArray } from 'ensure-type'; +import React from 'react'; +import { isElement, isValidElementType } from 'react-is'; +import { + TransitionGroup, +} from 'react-transition-group'; +import { isNullish } from '@tonic-ui/utils'; +import { useDefaultProps } from '../default-props'; +import ToastController from './ToastController'; +import ToastTransition from './ToastTransition'; + +const ToastGroup = (inProps) => { + const { + TransitionComponent = ToastTransition, + TransitionProps, + toasts, + onClose: onCloseProp, + } = useDefaultProps({ props: inProps, name: 'ToastGroup' }); + + const closeById = (id) => () => { + onCloseProp?.(id); + }; + + return ( + + {ensureArray(toasts).map((toast) => { + if (isNullish(toast?.id)) { + // TODO: log an error if the toast id is missing + return null; + } + return ( + + + {(() => { + if (isElement(toast.content)) { + return toast.content; + } + if (isValidElementType(toast.content)) { + const ToastContent = toast.content; + return ( + + ); + } + return null; + })()} + + + ); + })} + + ); +}; + +ToastGroup.displayName = 'ToastGroup'; + +export default ToastGroup; diff --git a/packages/react/src/toast/ToastManager.js b/packages/react/src/toast/ToastManager.js index 2d8b121452..961b0c7d9b 100644 --- a/packages/react/src/toast/ToastManager.js +++ b/packages/react/src/toast/ToastManager.js @@ -3,14 +3,10 @@ import { runIfFn } from '@tonic-ui/utils'; import { ensureArray, ensureString } from 'ensure-type'; import memoize from 'micro-memoize'; import React, { useCallback, useState } from 'react'; -import { isElement, isValidElementType } from 'react-is'; -import { - TransitionGroup, -} from 'react-transition-group'; import { useDefaultProps } from '../default-props'; import { Portal } from '../portal'; import ToastContainer from './ToastContainer'; -import ToastController from './ToastController'; +import ToastGroup from './ToastGroup'; import ToastTransition from './ToastTransition'; import { ToastManagerContext } from './context'; @@ -43,6 +39,8 @@ const getToastPlacementByState = (state, id) => { const ToastManager = (inProps) => { const { + ToastContainerComponent = ToastContainer, + ToastContainerProps, TransitionComponent = ToastTransition, TransitionProps, children, @@ -57,37 +55,6 @@ const ToastManager = (inProps) => { }, {}) )); - /** - * Create properties for a new toast - */ - const createToast = useCallback((message, options) => { - const id = options?.id ?? uniqueId(); - const data = options?.data; - const duration = options?.duration; - const placement = ensureString(options?.placement ?? placementProp); - const onClose = () => close(id, placement); - - return { - // A unique identifier that represents the toast message - id, - - // The user-defined data supplied to the toast - data, - - // The toast message to render - message, - - // The placement of the toast - placement, - - // The duration (in milliseconds) that the toast should remain on the screen. If set to null, toast will never dismiss. - duration, - - // The function to close the toast - onClose, - }; - }, [close, placementProp]); - /** * Close a toast record at its placement */ @@ -145,11 +112,43 @@ const ToastManager = (inProps) => { return ensureArray(state[placement]).findIndex((toast) => toast.id === id); }, [state]); + /** + * Update a specific toast with new options based on the given id. Returns `true` if the toast exists, else `false`. + */ + const update = useCallback((id, options) => { + const placement = find(id)?.placement; + const index = findIndex(id); + + if (!placement || index === -1) { + return false; + } + + setState((prevState) => { + const nextState = { ...prevState }; + nextState[placement][index] = { + ...nextState[placement][index], + ...options, + }; + return nextState; + }); + + return true; + }, [find, findIndex]); + /** * Create a toast at the specified placement and return the id */ - const notify = useCallback((message, options) => { - const toast = createToast(message, options); + const notify = useCallback((content, options) => { + // A unique identifier that represents the toast content + const id = options?.id ?? uniqueId(); + // The user-defined data supplied to the toast + const data = options?.data; + // The duration (in milliseconds) that the toast should remain on the screen. If set to null, toast will never dismiss. + const duration = options?.duration; + // The placement of the toast + const placement = ensureString(options?.placement ?? placementProp); + + const toast = Object.freeze({ id, content, data, duration, placement }); if (!placements.includes(toast.placement)) { console.error(`[ToastManager] Error: Invalid toast placement "${toast.placement}". Please provide a valid placement from the following options: ${placements.join(', ')}.`); @@ -190,30 +189,11 @@ const ToastManager = (inProps) => { }); return toast.id; - }, [createToast]); + }, [placementProp]); - /** - * Update a specific toast with new options based on the given id. Returns `true` if the toast exists, else `false`. - */ - const update = useCallback((id, options) => { - const placement = find(id)?.placement; - const index = findIndex(id); - - if (!placement || index === -1) { - return false; - } - - setState((prevState) => { - const nextState = { ...prevState }; - nextState[placement][index] = { - ...nextState[placement][index], - ...options, - }; - return nextState; - }); - - return true; - }, [find, findIndex]); + const closeToastByPlacement = (placement) => (id) => { + close(id, placement); + }; const context = getMemoizedState({ // Methods @@ -221,8 +201,8 @@ const ToastManager = (inProps) => { closeAll, find, findIndex, - notify, update, + notify, // Properties placement: placementProp, @@ -242,43 +222,18 @@ const ToastManager = (inProps) => { {Object.keys(state).map((placement) => { const toasts = ensureArray(state[placement]); return ( - - - {toasts.map((toast) => ( - - - {(() => { - if (isElement(toast.message)) { - return toast.message; - } - if (isValidElementType(toast.message)) { - return ( - - ); - } - return null; - })()} - - - ))} - - + + ); })} diff --git a/packages/react/src/toast/index.js b/packages/react/src/toast/index.js index e5b0cdc479..06ea456185 100644 --- a/packages/react/src/toast/index.js +++ b/packages/react/src/toast/index.js @@ -2,6 +2,7 @@ import Toast from './Toast'; import ToastCloseButton from './ToastCloseButton'; import ToastContainer from './ToastContainer'; import ToastController from './ToastController'; +import ToastGroup from './ToastGroup'; import ToastIcon from './ToastIcon'; import ToastManager from './ToastManager'; import ToastMessage from './ToastMessage'; @@ -13,6 +14,7 @@ export { ToastCloseButton, ToastContainer, ToastController, + ToastGroup, ToastIcon, ToastManager, ToastMessage, From 602b6fb14567551f5a24c0bcf2bd64215bf68167 Mon Sep 17 00:00:00 2001 From: Cheton Wu Date: Mon, 4 Nov 2024 14:19:06 +0800 Subject: [PATCH 02/19] feat: rename `ToastGroup` to `ToastTransitionController` --- .../pages/components/toast/index.page.mdx | 32 ++++++--- packages/react/__tests__/index.test.js | 3 +- packages/react/src/toast/ToastController.js | 12 ++-- packages/react/src/toast/ToastGroup.js | 69 ------------------- packages/react/src/toast/ToastManager.js | 56 +++++++++++---- .../src/toast/ToastTransitionController.js | 36 ++++++++++ .../react/src/toast/ToastTransitionGroup.js | 20 ++++++ packages/react/src/toast/index.js | 6 +- 8 files changed, 134 insertions(+), 100 deletions(-) delete mode 100644 packages/react/src/toast/ToastGroup.js create mode 100644 packages/react/src/toast/ToastTransitionController.js create mode 100644 packages/react/src/toast/ToastTransitionGroup.js diff --git a/packages/react-docs/pages/components/toast/index.page.mdx b/packages/react-docs/pages/components/toast/index.page.mdx index ec00760efd..d4b5c71e29 100644 --- a/packages/react-docs/pages/components/toast/index.page.mdx +++ b/packages/react-docs/pages/components/toast/index.page.mdx @@ -8,8 +8,9 @@ A toast notification is a small popup that appears at either side of the screen, import { Toast, ToastCloseButton, - ToastController, ToastTransition, + ToastTransitionController, + ToastTransitionGroup, } from '@tonic-ui/react'; ``` @@ -78,7 +79,7 @@ The placement and size of toasts are typically determined by the design of the a In this example, the toast will be positioned 48 pixels from the top of the modal or drawer, and has a minimum width of 280 pixels. If the content of the toast message is wider than 280 pixels, the toast will expand to fit the content without exceeding 80% of the width of the modal or drawer in which it is being displayed. -To animate the toast when it is displayed or dismissed, you can use the `ToastTransition` component. The `ToastController` component can also be used to control the duration for which the toast will be displayed before it is automatically dismissed. This allows you to set a specific amount of time for the toast to be visible, ensuring that it does not interrupt the user's workflow for too long. +To animate the toast when it is displayed or dismissed, you can utilize `ToastTransitionController` to manage the duration the toast is shown before it is automatically dismissed. This enables you to specify a set amount of time for the toast to remain visible. {render('./modal-toast')} @@ -102,14 +103,6 @@ To animate the toast when it is displayed or dismissed, you can use the `ToastTr | :--- | :--- | :------ | :---------- | | children | ReactNode | | | -### ToastController - -| Name | Type | Default | Description | -| :--- | :--- | :------ | :---------- | -| children | ReactNode | | | -| duration | number | null | The duration in milliseconds after which the toast will be automatically closed. Set to `null` to disable auto-closing. | -| onClose | function | | A callback called when the toast is being closed. | - ### ToastTransition | Name | Type | Default | Description | @@ -121,3 +114,22 @@ To animate the toast when it is displayed or dismissed, you can use the `ToastTr | mountOnEnter | boolean | | If `true`, it will "lazy mount" the component on the first `in={true}`. After the first enter transition the component will stay mounted, even on the 'exited' state, unless you also specify `unmountOnExit`. By default the child component is mounted immediately along with the parent transition component. | | timeout | number \| `{ appear?: number, enter?: number, exit?: number }` | `{ enter: duration.standard, exit: duration.standard }` | The duration for the transition, in milliseconds. You may specify a single timeout for all transitions, or individually with an object. | | unmountOnExit | boolean | | If `true`, it will unmount the child component when `in={false}` and the animation has finished. By default the child component stays mounted after it reaches the 'exited' state. | + +### ToastTransitionGroup + +| Name | Type | Default | Description | +| :--- | :--- | :------ | :---------- | +| children | any | | A set of `` components, that are toggled `in` and `out` as they leave. | +| appear | boolean | | A convenience prop that enables or disables appear animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | +| enter | boolean | | A convenience prop that enables or disables enter animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | +| exit | boolean | | A convenience prop that enables or disables exit animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | + +### ToastTransitionController + +| Name | Type | Default | Description | +| :--- | :--- | :------ | :---------- | +| TransitionComponent | ElementType | ToastTransition | The component used for the transition. | +| TransitionProps | object | | Props applied to the [Transition](http://reactcommunity.org/react-transition-group/transition#Transition-props) element. | +| children | ReactNode | ReactNode \| `({ onClose }) => ReactNode` | A function child can be used intead of a React element. This function is invoked with an object that includes the `onClose` prop. | +| duration | number | null | The duration in milliseconds after which the toast will be automatically closed. Set to `null` to disable auto-closing. | +| onClose | function | | A callback called when the toast is being closed. | diff --git a/packages/react/__tests__/index.test.js b/packages/react/__tests__/index.test.js index dd4dabf1ec..1a16f5a4ae 100644 --- a/packages/react/__tests__/index.test.js +++ b/packages/react/__tests__/index.test.js @@ -238,11 +238,12 @@ test('should match expected exports', () => { 'ToastCloseButton', 'ToastContainer', 'ToastController', - 'ToastGroup', 'ToastIcon', 'ToastManager', 'ToastMessage', 'ToastTransition', + 'ToastTransitionController', + 'ToastTransitionGroup', 'useToastManager', // tooltip diff --git a/packages/react/src/toast/ToastController.js b/packages/react/src/toast/ToastController.js index 9d4ff05614..8d2afabf5f 100644 --- a/packages/react/src/toast/ToastController.js +++ b/packages/react/src/toast/ToastController.js @@ -8,23 +8,23 @@ import useTimeout from '../utils/useTimeout'; const ToastController = forwardRef((inProps, ref) => { const { children, - duration = null, - onClose, + duration: durationProp = null, + onClose: onCloseProp, onMouseEnter: onMouseEnterProp, onMouseLeave: onMouseLeaveProp, ...rest } = useDefaultProps({ props: inProps, name: 'ToastController' }); const nodeRef = useRef(); const combinedRef = useMergeRefs(nodeRef, ref); - const [delay, setDelay] = useState(duration); + const [delay, setDelay] = useState(durationProp); const onMouseEnter = useCallback((event) => { setDelay(null); }, []); const onMouseLeave = useCallback((event) => { - setDelay(duration); - }, [duration]); + setDelay(durationProp); + }, [durationProp]); - useTimeout(onClose, delay); + useTimeout(onCloseProp, delay); return ( { - const { - TransitionComponent = ToastTransition, - TransitionProps, - toasts, - onClose: onCloseProp, - } = useDefaultProps({ props: inProps, name: 'ToastGroup' }); - - const closeById = (id) => () => { - onCloseProp?.(id); - }; - - return ( - - {ensureArray(toasts).map((toast) => { - if (isNullish(toast?.id)) { - // TODO: log an error if the toast id is missing - return null; - } - return ( - - - {(() => { - if (isElement(toast.content)) { - return toast.content; - } - if (isValidElementType(toast.content)) { - const ToastContent = toast.content; - return ( - - ); - } - return null; - })()} - - - ); - })} - - ); -}; - -ToastGroup.displayName = 'ToastGroup'; - -export default ToastGroup; diff --git a/packages/react/src/toast/ToastManager.js b/packages/react/src/toast/ToastManager.js index 961b0c7d9b..62cd16d303 100644 --- a/packages/react/src/toast/ToastManager.js +++ b/packages/react/src/toast/ToastManager.js @@ -1,13 +1,15 @@ import { useHydrated } from '@tonic-ui/react-hooks'; -import { runIfFn } from '@tonic-ui/utils'; +import { isNullish, runIfFn } from '@tonic-ui/utils'; import { ensureArray, ensureString } from 'ensure-type'; import memoize from 'micro-memoize'; import React, { useCallback, useState } from 'react'; +import { isElement, isValidElementType } from 'react-is'; import { useDefaultProps } from '../default-props'; import { Portal } from '../portal'; import ToastContainer from './ToastContainer'; -import ToastGroup from './ToastGroup'; import ToastTransition from './ToastTransition'; +import ToastTransitionController from './ToastTransitionController'; +import ToastTransitionGroup from './ToastTransitionGroup'; import { ToastManagerContext } from './context'; const uniqueId = (() => { @@ -191,10 +193,6 @@ const ToastManager = (inProps) => { return toast.id; }, [placementProp]); - const closeToastByPlacement = (placement) => (id) => { - close(id, placement); - }; - const context = getMemoizedState({ // Methods close, @@ -212,6 +210,10 @@ const ToastManager = (inProps) => { setState, }); + const createCloseToastHandler = (id, placement) => () => { + close(id, placement); + }; + return ( {runIfFn(children, context)} @@ -227,12 +229,42 @@ const ToastManager = (inProps) => { placement={placement} {...ToastContainerProps} > - + + {ensureArray(toasts).map((toast) => { + if (!toast || isNullish(toast.id)) { + // TODO: log an error if the toast id is missing + return null; + } + const onClose = createCloseToastHandler(toast.id, placement); + return ( + + {({ onClose }) => { + if (isElement(toast.content)) { + return toast.content; + } + if (isValidElementType(toast.content)) { + const ToastContent = toast.content; + return ( + + ); + } + return null; + }} + + ); + })} + ); })} diff --git a/packages/react/src/toast/ToastTransitionController.js b/packages/react/src/toast/ToastTransitionController.js new file mode 100644 index 0000000000..0f530f0745 --- /dev/null +++ b/packages/react/src/toast/ToastTransitionController.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { runIfFn } from '@tonic-ui/utils'; +import { useDefaultProps } from '../default-props'; +import ToastController from './ToastController'; +import ToastTransition from './ToastTransition'; + +const ToastTransitionController = (inProps) => { + const { + TransitionComponent = ToastTransition, + TransitionProps, + children, + duration: durationProp = null, + onClose: onCloseProp, + ...rest + } = useDefaultProps({ props: inProps, name: 'ToastTransitionController' }); + + return ( + + + {runIfFn(children, { onClose: onCloseProp })} + + + ); +}; + +ToastTransitionController.displayName = 'ToastTransitionController'; + +export default ToastTransitionController; diff --git a/packages/react/src/toast/ToastTransitionGroup.js b/packages/react/src/toast/ToastTransitionGroup.js new file mode 100644 index 0000000000..2b0feadfe5 --- /dev/null +++ b/packages/react/src/toast/ToastTransitionGroup.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { + TransitionGroup, +} from 'react-transition-group'; +import { useDefaultProps } from '../default-props'; + +const ToastTransitionGroup = (inProps) => { + const props = useDefaultProps({ props: inProps, name: 'ToastTransitionGroup' }); + + return ( + element + {...props} + /> + ); +}; + +ToastTransitionGroup.displayName = 'ToastTransitionGroup'; + +export default ToastTransitionGroup; diff --git a/packages/react/src/toast/index.js b/packages/react/src/toast/index.js index 06ea456185..3f39ffd490 100644 --- a/packages/react/src/toast/index.js +++ b/packages/react/src/toast/index.js @@ -2,11 +2,12 @@ import Toast from './Toast'; import ToastCloseButton from './ToastCloseButton'; import ToastContainer from './ToastContainer'; import ToastController from './ToastController'; -import ToastGroup from './ToastGroup'; import ToastIcon from './ToastIcon'; import ToastManager from './ToastManager'; import ToastMessage from './ToastMessage'; import ToastTransition from './ToastTransition'; +import ToastTransitionController from './ToastTransitionController'; +import ToastTransitionGroup from './ToastTransitionGroup'; import useToastManager from './useToastManager'; export { @@ -14,10 +15,11 @@ export { ToastCloseButton, ToastContainer, ToastController, - ToastGroup, ToastIcon, ToastManager, ToastMessage, ToastTransition, + ToastTransitionController, + ToastTransitionGroup, useToastManager, }; From b56a1edc1377196c7d7989d81c3f6bd6db858ba0 Mon Sep 17 00:00:00 2001 From: Cheton Wu Date: Mon, 4 Nov 2024 15:29:24 +0800 Subject: [PATCH 03/19] docs: update drawer and modal with toast examples --- .../pages/components/toast/drawer-toast.js | 208 ------------------ .../toast/drawer-with-toast-notification.js | 206 +++++++++++++++++ .../pages/components/toast/index.page.mdx | 102 ++++++++- .../pages/components/toast/modal-toast.js | 208 ------------------ .../toast/modal-with-toast-notification.js | 206 +++++++++++++++++ 5 files changed, 503 insertions(+), 427 deletions(-) delete mode 100644 packages/react-docs/pages/components/toast/drawer-toast.js create mode 100644 packages/react-docs/pages/components/toast/drawer-with-toast-notification.js delete mode 100644 packages/react-docs/pages/components/toast/modal-toast.js create mode 100644 packages/react-docs/pages/components/toast/modal-with-toast-notification.js diff --git a/packages/react-docs/pages/components/toast/drawer-toast.js b/packages/react-docs/pages/components/toast/drawer-toast.js deleted file mode 100644 index 1187f5a45c..0000000000 --- a/packages/react-docs/pages/components/toast/drawer-toast.js +++ /dev/null @@ -1,208 +0,0 @@ -import { - Box, - Button, - Divider, - Flex, - Grid, - DrawerContent, - DrawerHeader, - DrawerBody, - DrawerFooter, - Skeleton, - Space, - Stack, - Text, - Toast, - ToastController, - ToastTransition, - useColorStyle, -} from '@tonic-ui/react'; -import { CloseSIcon } from '@tonic-ui/react-icons'; -import React, { useState } from 'react'; -import { TransitionGroup } from 'react-transition-group'; - -const MAX_TOASTS = 1; - -const CustomToastContainer = (props) => ( - -); - -let autoIncrementIndex = 0; - -const App = () => { - const [colorStyle] = useColorStyle(); - const [toasts, setToasts] = useState([]); - - const notify = (options) => { - const { - appearance, - content, - duration = null, - isClosable = true, - } = { ...options }; - - setToasts(prevState => { - const id = ++autoIncrementIndex; - const onClose = () => { - setToasts(toasts => toasts.filter(x => x.id !== id)); - }; - // You can decide how many toasts you want to show at the same time depending on your use case - const nextState = [ - ...prevState.slice(MAX_TOASTS > 1 ? -(MAX_TOASTS - 1) : prevState.length), - { - id, - appearance, - content, - duration, - isClosable, - onClose, - }, - ]; - return nextState; - }); - }; - - const closeAll = () => { - setToasts([]); - }; - - const handleClickAddToastByAppearance = (appearance) => (event) => { - const content = { - success: ( - <> - This is a success message. - The toast will be automatically dismissed after 5 seconds. - - ), - info: ( - <> - This is an info message. - The toast will remain visible until the user dismisses it. - - ), - warning: ( - <> - This is a warning message. - The toast will remain visible until the user dismisses it. - - ), - error: ( - <> - This is an error message. - The toast will remain visible until the user dismisses it. - - ), - }[appearance]; - - notify({ - appearance, - content, - duration: (appearance === 'success') ? 5000 : undefined, - }); - }; - - const handleClickCloseToasts = () => { - closeAll(); - }; - - return (<> - - - - - - - - - - - - - - - - ` element - > - {toasts.map(toast => ( - - - - {toast?.content} - - - - ))} - - - - Drawer - - - - - - - - - - - - - - - - - ); -}; - -export default App; diff --git a/packages/react-docs/pages/components/toast/drawer-with-toast-notification.js b/packages/react-docs/pages/components/toast/drawer-with-toast-notification.js new file mode 100644 index 0000000000..7a7e085e7f --- /dev/null +++ b/packages/react-docs/pages/components/toast/drawer-with-toast-notification.js @@ -0,0 +1,206 @@ +import { + Box, + Button, + Divider, + Flex, + Grid, + DrawerContent, + DrawerHeader, + DrawerBody, + DrawerFooter, + Skeleton, + Space, + Stack, + Text, + Toast, + ToastTransitionGroup, + ToastTransitionController, + useColorStyle, +} from '@tonic-ui/react'; +import { CloseSIcon } from '@tonic-ui/react-icons'; +import React, { useState } from 'react'; + +const MAX_TOASTS = 1; + +const InlineToastContainer = (props) => ( + +); + +let autoIncrementIndex = 0; + +const App = () => { + const [colorStyle] = useColorStyle(); + const [toasts, setToasts] = useState([]); + + const notify = (options) => { + const { + appearance, + content, + duration, + } = { ...options }; + + setToasts(prevState => { + const id = ++autoIncrementIndex; + // You can decide how many toasts you want to show at the same time depending on your use case + const nextState = [ + ...prevState.slice(MAX_TOASTS > 1 ? -(MAX_TOASTS - 1) : prevState.length), + { + id, + appearance, + content, + duration, + }, + ]; + return nextState; + }); + }; + + const closeToast = (id) => { + setToasts(toasts => toasts.filter(x => x.id !== id)); + }; + + const closeAll = () => { + setToasts([]); + }; + + const handleClickAddToastByAppearance = (appearance) => (event) => { + const content = { + success: ( + <> + This is a success message. + The toast will be automatically dismissed after 5 seconds. + + ), + info: ( + <> + This is an info message. + The toast will be automatically dismissed after 5 seconds. + + ), + warning: ( + <> + This is a warning message. + The toast will remain visible until the user dismisses it. + + ), + error: ( + <> + This is an error message. + The toast will remain visible until the user dismisses it. + + ), + }[appearance]; + + notify({ + appearance, + content, + duration: (appearance === 'success' || appearance === 'info') ? 5000 : undefined, + }); + }; + + const createCloseToastHandler = (id) => () => { + closeToast(id); + }; + + const handleClickCloseToasts = () => { + closeAll(); + }; + + return ( + <> + + + + + + + + + + + + + + + + + {toasts.map(toast => ( + + {({ onClose }) => ( + + {toast.content} + + )} + + ))} + + + + Drawer + + + + + + + + + + + + + + + + + + ); +}; + +export default App; diff --git a/packages/react-docs/pages/components/toast/index.page.mdx b/packages/react-docs/pages/components/toast/index.page.mdx index d4b5c71e29..423c99b4ca 100644 --- a/packages/react-docs/pages/components/toast/index.page.mdx +++ b/packages/react-docs/pages/components/toast/index.page.mdx @@ -79,11 +79,91 @@ The placement and size of toasts are typically determined by the design of the a In this example, the toast will be positioned 48 pixels from the top of the modal or drawer, and has a minimum width of 280 pixels. If the content of the toast message is wider than 280 pixels, the toast will expand to fit the content without exceeding 80% of the width of the modal or drawer in which it is being displayed. +```jsx +const InlineToastContainer = (props) => ( + +); +``` + +```jsx + + This is a success message. + +``` + To animate the toast when it is displayed or dismissed, you can utilize `ToastTransitionController` to manage the duration the toast is shown before it is automatically dismissed. This enables you to specify a set amount of time for the toast to remain visible. -{render('./modal-toast')} +| Name | Type | Description | +| :--- | :--- | :---------- | +| id | string \| number | A unique identifier for the toast. | +| content | function | A function that renders the toast's content and accepts `{ onClose }` as a parameter to handle closing the toast. | +| duration | number | (Optional) Defines how long (in milliseconds) the toast should remain visible before automatically dismissing. Use `undefined` to keep the toast visible indefinitely. | + +Here's an example of the array of toast objects: + +```jsx +toasts = [ + { + id: 1000, + content: ({ onClose }) => ( + + This is a success message. + + ), + duration: 5000, // auto dismiss after 5 seconds + }, +]; +``` + +```jsx + + { + setToasts(toasts => toasts.filter(x => x.id !== id)); + }} + toasts={toasts} + /> + +``` -{render('./drawer-toast')} +#### Modal with toast notification + +{render('./modal-with-toast-notification')} + +#### Drawer with multiple toasts + +{render('./drawer-with-toast-notification')} ## Props @@ -115,15 +195,6 @@ To animate the toast when it is displayed or dismissed, you can utilize `ToastTr | timeout | number \| `{ appear?: number, enter?: number, exit?: number }` | `{ enter: duration.standard, exit: duration.standard }` | The duration for the transition, in milliseconds. You may specify a single timeout for all transitions, or individually with an object. | | unmountOnExit | boolean | | If `true`, it will unmount the child component when `in={false}` and the animation has finished. By default the child component stays mounted after it reaches the 'exited' state. | -### ToastTransitionGroup - -| Name | Type | Default | Description | -| :--- | :--- | :------ | :---------- | -| children | any | | A set of `` components, that are toggled `in` and `out` as they leave. | -| appear | boolean | | A convenience prop that enables or disables appear animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | -| enter | boolean | | A convenience prop that enables or disables enter animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | -| exit | boolean | | A convenience prop that enables or disables exit animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | - ### ToastTransitionController | Name | Type | Default | Description | @@ -133,3 +204,12 @@ To animate the toast when it is displayed or dismissed, you can utilize `ToastTr | children | ReactNode | ReactNode \| `({ onClose }) => ReactNode` | A function child can be used intead of a React element. This function is invoked with an object that includes the `onClose` prop. | | duration | number | null | The duration in milliseconds after which the toast will be automatically closed. Set to `null` to disable auto-closing. | | onClose | function | | A callback called when the toast is being closed. | + +### ToastTransitionGroup + +| Name | Type | Default | Description | +| :--- | :--- | :------ | :---------- | +| children | any | | A set of `` components, that are toggled `in` and `out` as they leave. | +| appear | boolean | | A convenience prop that enables or disables appear animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | +| enter | boolean | | A convenience prop that enables or disables enter animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | +| exit | boolean | | A convenience prop that enables or disables exit animations for all children. Note that specifying this will override any defaults set on individual children Transitions. | diff --git a/packages/react-docs/pages/components/toast/modal-toast.js b/packages/react-docs/pages/components/toast/modal-toast.js deleted file mode 100644 index 5e3c5009b9..0000000000 --- a/packages/react-docs/pages/components/toast/modal-toast.js +++ /dev/null @@ -1,208 +0,0 @@ -import { - Box, - Button, - Divider, - Flex, - Grid, - ModalContent, - ModalHeader, - ModalBody, - ModalFooter, - Skeleton, - Space, - Stack, - Text, - Toast, - ToastController, - ToastTransition, - useColorStyle, -} from '@tonic-ui/react'; -import { CloseSIcon } from '@tonic-ui/react-icons'; -import React, { useState } from 'react'; -import { TransitionGroup } from 'react-transition-group'; - -const MAX_TOASTS = 1; - -const CustomToastContainer = (props) => ( - -); - -let autoIncrementIndex = 0; - -const App = () => { - const [colorStyle] = useColorStyle(); - const [toasts, setToasts] = useState([]); - - const notify = (options) => { - const { - appearance, - content, - duration = null, - isClosable = true, - } = { ...options }; - - setToasts(prevState => { - const id = ++autoIncrementIndex; - const onClose = () => { - setToasts(toasts => toasts.filter(x => x.id !== id)); - }; - // You can decide how many toasts you want to show at the same time depending on your use case - const nextState = [ - ...prevState.slice(MAX_TOASTS > 1 ? -(MAX_TOASTS - 1) : prevState.length), - { - id, - appearance, - content, - duration, - isClosable, - onClose, - }, - ]; - return nextState; - }); - }; - - const closeAll = () => { - setToasts([]); - }; - - const handleClickAddToastByAppearance = (appearance) => (event) => { - const content = { - success: ( - <> - This is a success message. - The toast will be automatically dismissed after 5 seconds. - - ), - info: ( - <> - This is an info message. - The toast will remain visible until the user dismisses it. - - ), - warning: ( - <> - This is a warning message. - The toast will remain visible until the user dismisses it. - - ), - error: ( - <> - This is an error message. - The toast will remain visible until the user dismisses it. - - ), - }[appearance]; - - notify({ - appearance, - content, - duration: (appearance === 'success') ? 5000 : undefined, - }); - }; - - const handleClickCloseToasts = () => { - closeAll(); - }; - - return (<> - - - - - - - - - - - - - - - - ` element - > - {toasts.map(toast => ( - - - - {toast?.content} - - - - ))} - - - - Modal - - - - - - - - - - - - - - - - - ); -}; - -export default App; diff --git a/packages/react-docs/pages/components/toast/modal-with-toast-notification.js b/packages/react-docs/pages/components/toast/modal-with-toast-notification.js new file mode 100644 index 0000000000..e108e1c9e3 --- /dev/null +++ b/packages/react-docs/pages/components/toast/modal-with-toast-notification.js @@ -0,0 +1,206 @@ +import { + Box, + Button, + Divider, + Flex, + Grid, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Skeleton, + Space, + Stack, + Text, + Toast, + ToastTransitionController, + ToastTransitionGroup, + useColorStyle, +} from '@tonic-ui/react'; +import { CloseSIcon } from '@tonic-ui/react-icons'; +import React, { useState } from 'react'; + +const MAX_TOASTS = 1; + +const InlineToastContainer = (props) => ( + +); + +let autoIncrementIndex = 0; + +const App = () => { + const [colorStyle] = useColorStyle(); + const [toasts, setToasts] = useState([]); + + const notify = (options) => { + const { + appearance, + content, + duration, + } = { ...options }; + + setToasts(prevState => { + const id = ++autoIncrementIndex; + // You can decide how many toasts you want to show at the same time depending on your use case + const nextState = [ + ...prevState.slice(MAX_TOASTS > 1 ? -(MAX_TOASTS - 1) : prevState.length), + { + id, + appearance, + content, + duration, + }, + ]; + return nextState; + }); + }; + + const closeToast = (id) => { + setToasts(toasts => toasts.filter(x => x.id !== id)); + }; + + const closeAll = () => { + setToasts([]); + }; + + const handleClickAddToastByAppearance = (appearance) => (event) => { + const content = { + success: ( + <> + This is a success message. + The toast will be automatically dismissed after 5 seconds. + + ), + info: ( + <> + This is an info message. + The toast will be automatically dismissed after 5 seconds. + + ), + warning: ( + <> + This is a warning message. + The toast will remain visible until the user dismisses it. + + ), + error: ( + <> + This is an error message. + The toast will remain visible until the user dismisses it. + + ), + }[appearance]; + + notify({ + appearance, + content, + duration: (appearance === 'success' || appearance === 'info') ? 5000 : undefined, + }); + }; + + const createCloseToastHandler = (id) => () => { + closeToast(id); + }; + + const handleClickCloseToasts = () => { + closeAll(); + }; + + return ( + <> + + + + + + + + + + + + + + + + + {toasts.map(toast => ( + + {({ onClose }) => ( + + {toast.content} + + )} + + ))} + + + + Modal + + + + + + + + + + + + + + + + + + ); +}; + +export default App; From 9fe16cee552d25cdcb3df063f7f177344626c1b5 Mon Sep 17 00:00:00 2001 From: Cheton Wu Date: Mon, 4 Nov 2024 15:57:48 +0800 Subject: [PATCH 04/19] docs: update toast examples --- .../pages/components/toast/index.page.mdx | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/packages/react-docs/pages/components/toast/index.page.mdx b/packages/react-docs/pages/components/toast/index.page.mdx index 423c99b4ca..60d7c45074 100644 --- a/packages/react-docs/pages/components/toast/index.page.mdx +++ b/packages/react-docs/pages/components/toast/index.page.mdx @@ -112,48 +112,57 @@ const InlineToastContainer = (props) => ( ``` -To animate the toast when it is displayed or dismissed, you can utilize `ToastTransitionController` to manage the duration the toast is shown before it is automatically dismissed. This enables you to specify a set amount of time for the toast to remain visible. +To animate the toast when it is displayed or dismissed, you can utilize `ToastTransitionController` to manage the duration the toast is shown before it is automatically dismissed. This enables you to specify a set amount of time for the toast to remain visible. To implement this, define an array of toasts with `const [toasts, setToasts] = useState([])`, where each toast object can include the following properties: | Name | Type | Description | | :--- | :--- | :---------- | | id | string \| number | A unique identifier for the toast. | -| content | function | A function that renders the toast's content and accepts `{ onClose }` as a parameter to handle closing the toast. | -| duration | number | (Optional) Defines how long (in milliseconds) the toast should remain visible before automatically dismissing. Use `undefined` to keep the toast visible indefinitely. | - -Here's an example of the array of toast objects: +| appearance | string | The appearance of the toast. | +| content | ReactNode | The content displayed within the toast. | +| duration | number | (Optional) Defines how long (in milliseconds) the toast should remain visible before automatically dismissing. Pass `null` to keep the toast visible indefinitely. | +Example toast object: ```jsx -toasts = [ - { - id: 1000, - content: ({ onClose }) => ( - - This is a success message. - - ), - duration: 5000, // auto dismiss after 5 seconds - }, -]; +{ + id: 1, + appearance: 'success', + content: ( + This is a success message. + ), + duration: 5000, // auto dismiss after 5 seconds +} ``` +Here is a complete example: ```jsx - { - setToasts(toasts => toasts.filter(x => x.id !== id)); - }} - toasts={toasts} - /> + + {toasts.map(toast => ( + { + setToasts(toasts => toasts.filter(x => x.id !== id)); + }} + > + {({ onClose }) => ( + + {toast.content} + + )} + + ))} + ``` From 0aa4b04dd1307b38cdefa5bcf0b22d177305b493 Mon Sep 17 00:00:00 2001 From: Cheton Wu Date: Mon, 4 Nov 2024 16:09:51 +0800 Subject: [PATCH 05/19] docs: update table examples --- .../react-docs/pages/components/table/index.page.mdx | 10 ++++++++-- .../react-docs/pages/components/table/row-expanding.js | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/react-docs/pages/components/table/index.page.mdx b/packages/react-docs/pages/components/table/index.page.mdx index 4d43226c84..870fab887c 100644 --- a/packages/react-docs/pages/components/table/index.page.mdx +++ b/packages/react-docs/pages/components/table/index.page.mdx @@ -82,7 +82,10 @@ Below are the code snippets that demonstrate how to render the expanded row base Cell 3 {row.getCanExpand() && ( - + {renderExpandedRow({ row })} )} @@ -102,7 +105,10 @@ Below are the code snippets that demonstrate how to render the expanded row base borderBottom={0} colSpan={row.getVisibleCells().length} > - + {renderExpandedRow({ row })} diff --git a/packages/react-docs/pages/components/table/row-expanding.js b/packages/react-docs/pages/components/table/row-expanding.js index f941f2b27b..a8c26e0720 100644 --- a/packages/react-docs/pages/components/table/row-expanding.js +++ b/packages/react-docs/pages/components/table/row-expanding.js @@ -240,7 +240,10 @@ const App = () => { })} {(row.getCanExpand() && layout === 'flexbox') && ( - + {renderExpandedRow({ row })} )} @@ -251,7 +254,10 @@ const App = () => { borderBottom={0} colSpan={row.getVisibleCells().length} > - + {renderExpandedRow({ row })} From 81debab81dfbfe02da74701321c405a1b464bc2a Mon Sep 17 00:00:00 2001 From: Cheton Wu Date: Tue, 5 Nov 2024 18:46:47 +0800 Subject: [PATCH 06/19] test: enhance test coverage for ToastTransitionController --- .../ToastTransitionController.test.js | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 packages/react/src/toast/__tests__/ToastTransitionController.test.js diff --git a/packages/react/src/toast/__tests__/ToastTransitionController.test.js b/packages/react/src/toast/__tests__/ToastTransitionController.test.js new file mode 100644 index 0000000000..01830bbd87 --- /dev/null +++ b/packages/react/src/toast/__tests__/ToastTransitionController.test.js @@ -0,0 +1,132 @@ +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '@tonic-ui/react/test-utils/render'; +import { + Button, + Flex, + Grid, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Skeleton, + Stack, + Text, + Toast, + ToastCloseButton, + ToastTransitionController, + ToastTransitionGroup, +} from '@tonic-ui/react/src'; +import { transitionDuration } from '@tonic-ui/utils/src'; +import React, { useState } from 'react'; + +const InlineToastContainer = (props) => ( + +); + +describe('ToastTransitionController', () => { + test('should display a toast within a modal', async () => { + const user = userEvent.setup(); + + const TestComponent = ({ onClose }) => { + const [toasts, setToasts] = useState([ + { + id: 1, + appearance: 'success', + content: 'This is a success message.', + duration: 5000, + }, + ]); + const closeToast = (id) => { + setToasts(toasts => toasts.filter(x => x.id !== id)); + }; + const createCloseToastHandler = (id) => () => { + closeToast(id); + }; + + return ( + + + + {toasts.map(toast => ( + + {({ onClose }) => ( + + {toast.content} + + + )} + + ))} + + + + Modal + + + + + + + + + + + + + + + + ); + }; + + render( + + ); + + // Verify the toast is displayed + expect(screen.getByText('This is a success message.')).toBeInTheDocument(); + + // Simulate closing the toast + const toastCloseButton = screen.getByTestId('toast-close-button'); + await user.click(toastCloseButton); + + await waitForElementToBeRemoved(() => screen.getByTestId('toast'), { + timeout: transitionDuration.standard + 100, // see "transitions/Collapse.js" + }); + }); +}); From 3287fe5d0418d11c4447fa8e7384e4d48d297bd0 Mon Sep 17 00:00:00 2001 From: cheton Date: Wed, 6 Nov 2024 22:20:57 +0800 Subject: [PATCH 07/19] test: enhance test coverage for `ToastManager` --- .../src/toast/__tests__/ToastManager.test.js | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/react/src/toast/__tests__/ToastManager.test.js b/packages/react/src/toast/__tests__/ToastManager.test.js index b0d624614e..c1ea0ef332 100644 --- a/packages/react/src/toast/__tests__/ToastManager.test.js +++ b/packages/react/src/toast/__tests__/ToastManager.test.js @@ -1,7 +1,7 @@ import { act, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '@tonic-ui/react/test-utils/render'; -import { Box, Button, Toast, ToastManager, useToastManager } from '@tonic-ui/react/src'; +import { Box, Button, Toast, ToastCloseButton, ToastManager, useToastManager } from '@tonic-ui/react/src'; import { transitionDuration } from '@tonic-ui/utils/src'; import React, { useCallback, useRef } from 'react'; @@ -164,7 +164,77 @@ describe('ToastManager', () => { }); }); - it('should close all toasts when `closeAll` is called', async () => { + it('should close a toast', async () => { + const user = userEvent.setup(); + + const placement = 'bottom-right'; + const message = 'This is a toast message'; + + const WrapperComponent = (props) => ( + + ); + const TestComponent = () => { + const toastCounterRef = useRef(0); + const toast = useToastManager(); + const handleClickAddToast = useCallback(() => { + const testId = ++toastCounterRef.current; + const toastTestId = `toast-${testId}`; + const toastCloseButtonTestId = `toast-close-button-${testId}`; + toast(({ onClose, placement }) => { + return ( + + {message} + + + ); + }, { placement }); + }, [toast]); + + return ( + + ); + }; + + render( + + + + ); + + const addToastButton = await screen.findByText('Add Toast'); + + // Generate more than the maximum number of toasts + await user.click(addToastButton); + await user.click(addToastButton); + await user.click(addToastButton); + + const toastPlacementElement = document.querySelector(`[data-toast-placement="${placement}"]`); + + await waitFor(() => { + expect(toastPlacementElement.childNodes.length).toBe(3); + }); + + // Close the second toast + const toastCloseButton = screen.getByTestId('toast-close-button-2'); + expect(toastCloseButton).toBeInTheDocument(); + await user.click(toastCloseButton); + + await waitFor(() => { + expect(toastPlacementElement.childNodes.length).toBe(2); + }); + + expect(screen.getByTestId('toast-close-button-1')).toBeInTheDocument(); + expect(screen.queryByTestId('toast-close-button-2')).not.toBeInTheDocument(); // use queryBy* only when an element is not present + expect(screen.getByTestId('toast-close-button-3')).toBeInTheDocument(); + }); + + it('should close all toasts', async () => { const user = userEvent.setup(); const placement = 'bottom-right'; @@ -288,6 +358,7 @@ describe('ToastManager', () => { }); const toastPlacementElement = document.querySelector(`[data-toast-placement="${placement}"]`); + expect(toastPlacementElement.childNodes.length).toBe(maxToasts); }); }); From 531fe6ca12d576d2ad9e7dafd323a39eefe0466b Mon Sep 17 00:00:00 2001 From: cheton Date: Thu, 7 Nov 2024 09:03:29 +0800 Subject: [PATCH 08/19] feat: update `ToastController` to accept function as children --- .../react-docs/pages/components/toast/index.page.mdx | 9 +++++++++ packages/react/src/toast/ToastController.js | 4 ++-- packages/react/src/toast/ToastTransitionController.js | 3 +-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/react-docs/pages/components/toast/index.page.mdx b/packages/react-docs/pages/components/toast/index.page.mdx index 60d7c45074..514dd87584 100644 --- a/packages/react-docs/pages/components/toast/index.page.mdx +++ b/packages/react-docs/pages/components/toast/index.page.mdx @@ -8,6 +8,7 @@ A toast notification is a small popup that appears at either side of the screen, import { Toast, ToastCloseButton, + ToastController, ToastTransition, ToastTransitionController, ToastTransitionGroup, @@ -192,6 +193,14 @@ Here is a complete example: | :--- | :--- | :------ | :---------- | | children | ReactNode | | | +### ToastController + +| Name | Type | Default | Description | +| :--- | :--- | :------ | :---------- | +| children | ReactNode | ReactNode \| `({ onClose }) => ReactNode` | A function child can be used intead of a React element. This function is invoked with an object that includes the `onClose` prop. | +| duration | number | null | The duration in milliseconds after which the toast will be automatically closed. Set to `null` to disable auto-closing. | +| onClose | function | | A callback called when the toast is being closed. | + ### ToastTransition | Name | Type | Default | Description | diff --git a/packages/react/src/toast/ToastController.js b/packages/react/src/toast/ToastController.js index 8d2afabf5f..b4efdee599 100644 --- a/packages/react/src/toast/ToastController.js +++ b/packages/react/src/toast/ToastController.js @@ -1,5 +1,5 @@ import { useMergeRefs } from '@tonic-ui/react-hooks'; -import { callEventHandlers } from '@tonic-ui/utils'; +import { callEventHandlers, runIfFn } from '@tonic-ui/utils'; import React, { forwardRef, useCallback, useRef, useState } from 'react'; import { Box } from '../box'; import { useDefaultProps } from '../default-props'; @@ -33,7 +33,7 @@ const ToastController = forwardRef((inProps, ref) => { onMouseLeave={callEventHandlers(onMouseLeaveProp, onMouseLeave)} {...rest} > - {children} + {runIfFn(children, { onClose: onCloseProp })} ); }); diff --git a/packages/react/src/toast/ToastTransitionController.js b/packages/react/src/toast/ToastTransitionController.js index 0f530f0745..ba1a7d8cd2 100644 --- a/packages/react/src/toast/ToastTransitionController.js +++ b/packages/react/src/toast/ToastTransitionController.js @@ -1,5 +1,4 @@ import React from 'react'; -import { runIfFn } from '@tonic-ui/utils'; import { useDefaultProps } from '../default-props'; import ToastController from './ToastController'; import ToastTransition from './ToastTransition'; @@ -25,7 +24,7 @@ const ToastTransitionController = (inProps) => { duration={durationProp} onClose={onCloseProp} > - {runIfFn(children, { onClose: onCloseProp })} + {children} ); From 5c7ad104c0623d11c872b94940f6a7adeebcd794 Mon Sep 17 00:00:00 2001 From: cheton Date: Thu, 7 Nov 2024 10:40:36 +0800 Subject: [PATCH 09/19] feat: update toast examples and remove `ToastTransitionGroup` --- .../toast-manager/useToastManager.page.mdx | 27 +++--- .../toast/drawer-with-toast-notification.js | 51 +++++----- .../pages/components/toast/index.page.mdx | 96 ++++++++++++------- .../toast/modal-with-toast-notification.js | 51 +++++----- packages/react/__tests__/index.test.js | 1 - packages/react/src/toast/ToastController.js | 7 +- packages/react/src/toast/ToastManager.js | 54 ++++++----- .../src/toast/ToastTransitionController.js | 35 ------- ...roller.test.js => ToastTransitionGroup.js} | 58 ++++++----- packages/react/src/toast/index.js | 2 - 10 files changed, 198 insertions(+), 184 deletions(-) delete mode 100644 packages/react/src/toast/ToastTransitionController.js rename packages/react/src/toast/__tests__/{ToastTransitionController.test.js => ToastTransitionGroup.js} (67%) diff --git a/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx b/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx index 7c040940c3..470ea15512 100644 --- a/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx +++ b/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx @@ -16,24 +16,24 @@ const toast = useToastManager(); The `useToastManager` Hook returns an object with the following methods and properties: -### toast(message, [options={'{}'}]) +### toast(content, [options={'{}'}]) Create a toast at the specified placement and return the toast id. #### Aliases
-
*toast.notify(message, [options={'{}'}])*
+
*toast.notify(content, [options={'{}'}])*
#### Parameters
-
`message` *(Function|string)*: The toast message to render.
+
`content` *(Function|string)*: The toast content to render.
`[options={}]` *(Object)*: The options object.
`[options.data]` *(any)*: The user-defined data supplied to the toast.
`[options.duration=null]` *(number)*: The duration (in milliseconds) that the toast should remain on the screen. If set to null, toast will never dismiss.
-
`[options.id]` *(string)*: A unique ID of the toast.
+
`[options.id]` *(string)*: A unique identifier that represents the toast.
`[options.placement]` *(string)*: The placement of the toast.
@@ -117,8 +117,9 @@ Update a specific toast with new options based on the given toast id.
`id` *(string)*: The id to update the toast.
`[options={}]` *(Object)*: The options object.
+
`[options.content]` *(Function|string)*: The toast content to render.
+
`[options.data]` *(any)*: The user-defined data supplied to the toast.
`[options.duration=null]` *(number)*: The duration (in milliseconds) that the toast should remain on the screen. If set to null, toast will never dismiss.
-
`[options.message]` *(Function|string)*: The toast message to render.
#### Returns @@ -133,7 +134,7 @@ Specify the placement to place the toast. The default placement will be used if ### toast.setState(state | updater) -The `setState` method is used to modify the internal state of the toast manager. It provides the ability to add, remove, or update toast messages. +The `setState` method is used to modify the internal state of the toast manager. It provides the ability to add, remove, or update toast content. #### Parameters @@ -156,9 +157,9 @@ toast.setState({ 'top': [ { id: '2', + content: 'New toast', + data: undefined, duration: 3000, - message: 'New toast message', - onClose: () => toast.close('2'), placement: 'top', } ], @@ -179,9 +180,9 @@ toast.setState(prevState => ({ ...prevState['top'], { id: '2', + content: 'New toast', + data: undefined, duration: null, - message: 'New toast message', - onClose: () => toast.close('2', 'top'), placement: 'top', }, ], @@ -196,10 +197,10 @@ The toast state is a placement object, each placement contains an array of objec { 'top': [ { - id: '1', // A unique identifier that represents the toast message + id: '1', // A unique identifier that represents the toast + content: ({ id, data, onClose, placement }) => , // The toast content to render + data: undefined, // The user-defined data supplied to the toast duration: null, // The duration (in milliseconds) that the toast should remain on the screen. If set to null, toast will never dismiss. - message: ({ id, onClose, placement }) => , // The toast message to render - onClose: () => toast.close(id, placement), // The function to close the toast placement: 'top', // The placement of the toast }, ], diff --git a/packages/react-docs/pages/components/toast/drawer-with-toast-notification.js b/packages/react-docs/pages/components/toast/drawer-with-toast-notification.js index 7a7e085e7f..d3412d9082 100644 --- a/packages/react-docs/pages/components/toast/drawer-with-toast-notification.js +++ b/packages/react-docs/pages/components/toast/drawer-with-toast-notification.js @@ -13,8 +13,9 @@ import { Stack, Text, Toast, + ToastController, + ToastTransition, ToastTransitionGroup, - ToastTransitionController, useColorStyle, } from '@tonic-ui/react'; import { CloseSIcon } from '@tonic-ui/react-icons'; @@ -152,29 +153,35 @@ const App = () => { > - {toasts.map(toast => ( - - {({ onClose }) => ( - { + const onClose = createCloseToastHandler(toast.id); + return ( + + - {toast.content} - - )} - - ))} + + {toast.content} + + + + ); + })} diff --git a/packages/react-docs/pages/components/toast/index.page.mdx b/packages/react-docs/pages/components/toast/index.page.mdx index 514dd87584..a807643aed 100644 --- a/packages/react-docs/pages/components/toast/index.page.mdx +++ b/packages/react-docs/pages/components/toast/index.page.mdx @@ -10,7 +10,6 @@ import { ToastCloseButton, ToastController, ToastTransition, - ToastTransitionController, ToastTransitionGroup, } from '@tonic-ui/react'; ``` @@ -100,7 +99,7 @@ const InlineToastContainer = (props) => ( ```jsx { + const { + appearance, + content, + duration, + } = { ...options }; + + setToasts(prevState => { + const id = ++(idRef.current); + // You can decide how many toasts you want to show at the same time depending on your use case + const nextState = [ + ...prevState.slice(MAX_TOASTS > 1 ? -(MAX_TOASTS - 1) : prevState.length), + { + id, + appearance, + content, + duration, + }, + ]; + return nextState; + }); +}; +const closeToast = (id) => { + setToasts(toasts => toasts.filter(x => x.id !== id)); +}; +const createCloseToastHandler = (id) => () => { + closeToast(id); +}; +``` + ```jsx - {toasts.map(toast => ( - { - setToasts(toasts => toasts.filter(x => x.id !== id)); - }} - > - {({ onClose }) => ( - { + const onClose = createCloseToastHandler(toast.id); + return ( + + - {toast.content} - - )} - - ))} + + {toast.content} + + + + ); + })} ``` @@ -213,16 +249,6 @@ Here is a complete example: | timeout | number \| `{ appear?: number, enter?: number, exit?: number }` | `{ enter: duration.standard, exit: duration.standard }` | The duration for the transition, in milliseconds. You may specify a single timeout for all transitions, or individually with an object. | | unmountOnExit | boolean | | If `true`, it will unmount the child component when `in={false}` and the animation has finished. By default the child component stays mounted after it reaches the 'exited' state. | -### ToastTransitionController - -| Name | Type | Default | Description | -| :--- | :--- | :------ | :---------- | -| TransitionComponent | ElementType | ToastTransition | The component used for the transition. | -| TransitionProps | object | | Props applied to the [Transition](http://reactcommunity.org/react-transition-group/transition#Transition-props) element. | -| children | ReactNode | ReactNode \| `({ onClose }) => ReactNode` | A function child can be used intead of a React element. This function is invoked with an object that includes the `onClose` prop. | -| duration | number | null | The duration in milliseconds after which the toast will be automatically closed. Set to `null` to disable auto-closing. | -| onClose | function | | A callback called when the toast is being closed. | - ### ToastTransitionGroup | Name | Type | Default | Description | diff --git a/packages/react-docs/pages/components/toast/modal-with-toast-notification.js b/packages/react-docs/pages/components/toast/modal-with-toast-notification.js index e108e1c9e3..abe9969b61 100644 --- a/packages/react-docs/pages/components/toast/modal-with-toast-notification.js +++ b/packages/react-docs/pages/components/toast/modal-with-toast-notification.js @@ -13,7 +13,8 @@ import { Stack, Text, Toast, - ToastTransitionController, + ToastController, + ToastTransition, ToastTransitionGroup, useColorStyle, } from '@tonic-ui/react'; @@ -152,29 +153,35 @@ const App = () => { > - {toasts.map(toast => ( - - {({ onClose }) => ( - { + const onClose = createCloseToastHandler(toast.id); + return ( + + - {toast.content} - - )} - - ))} + + {toast.content} + + + + ); + })} diff --git a/packages/react/__tests__/index.test.js b/packages/react/__tests__/index.test.js index 1a16f5a4ae..f83db6a4b4 100644 --- a/packages/react/__tests__/index.test.js +++ b/packages/react/__tests__/index.test.js @@ -242,7 +242,6 @@ test('should match expected exports', () => { 'ToastManager', 'ToastMessage', 'ToastTransition', - 'ToastTransitionController', 'ToastTransitionGroup', 'useToastManager', diff --git a/packages/react/src/toast/ToastController.js b/packages/react/src/toast/ToastController.js index b4efdee599..b8ca927b93 100644 --- a/packages/react/src/toast/ToastController.js +++ b/packages/react/src/toast/ToastController.js @@ -1,5 +1,5 @@ import { useMergeRefs } from '@tonic-ui/react-hooks'; -import { callEventHandlers, runIfFn } from '@tonic-ui/utils'; +import { callEventHandlers } from '@tonic-ui/utils'; import React, { forwardRef, useCallback, useRef, useState } from 'react'; import { Box } from '../box'; import { useDefaultProps } from '../default-props'; @@ -7,7 +7,6 @@ import useTimeout from '../utils/useTimeout'; const ToastController = forwardRef((inProps, ref) => { const { - children, duration: durationProp = null, onClose: onCloseProp, onMouseEnter: onMouseEnterProp, @@ -32,9 +31,7 @@ const ToastController = forwardRef((inProps, ref) => { onMouseEnter={callEventHandlers(onMouseEnterProp, onMouseEnter)} onMouseLeave={callEventHandlers(onMouseLeaveProp, onMouseLeave)} {...rest} - > - {runIfFn(children, { onClose: onCloseProp })} - + /> ); }); diff --git a/packages/react/src/toast/ToastManager.js b/packages/react/src/toast/ToastManager.js index 62cd16d303..67e4459a13 100644 --- a/packages/react/src/toast/ToastManager.js +++ b/packages/react/src/toast/ToastManager.js @@ -7,8 +7,8 @@ import { isElement, isValidElementType } from 'react-is'; import { useDefaultProps } from '../default-props'; import { Portal } from '../portal'; import ToastContainer from './ToastContainer'; +import ToastController from './ToastController'; import ToastTransition from './ToastTransition'; -import ToastTransitionController from './ToastTransitionController'; import ToastTransitionGroup from './ToastTransitionGroup'; import { ToastManagerContext } from './context'; @@ -141,7 +141,7 @@ const ToastManager = (inProps) => { * Create a toast at the specified placement and return the id */ const notify = useCallback((content, options) => { - // A unique identifier that represents the toast content + // A unique identifier that represents the toast const id = options?.id ?? uniqueId(); // The user-defined data supplied to the toast const data = options?.data; @@ -237,31 +237,35 @@ const ToastManager = (inProps) => { } const onClose = createCloseToastHandler(toast.id, placement); return ( - - {({ onClose }) => { - if (isElement(toast.content)) { - return toast.content; - } - if (isValidElementType(toast.content)) { - const ToastContent = toast.content; - return ( - - ); - } - return null; - }} - + + {(() => { + if (isElement(toast.content)) { + return toast.content; + } + if (isValidElementType(toast.content)) { + const ToastContent = toast.content; + return ( + + ); + } + return null; + })()} + + ); })} diff --git a/packages/react/src/toast/ToastTransitionController.js b/packages/react/src/toast/ToastTransitionController.js deleted file mode 100644 index ba1a7d8cd2..0000000000 --- a/packages/react/src/toast/ToastTransitionController.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { useDefaultProps } from '../default-props'; -import ToastController from './ToastController'; -import ToastTransition from './ToastTransition'; - -const ToastTransitionController = (inProps) => { - const { - TransitionComponent = ToastTransition, - TransitionProps, - children, - duration: durationProp = null, - onClose: onCloseProp, - ...rest - } = useDefaultProps({ props: inProps, name: 'ToastTransitionController' }); - - return ( - - - {children} - - - ); -}; - -ToastTransitionController.displayName = 'ToastTransitionController'; - -export default ToastTransitionController; diff --git a/packages/react/src/toast/__tests__/ToastTransitionController.test.js b/packages/react/src/toast/__tests__/ToastTransitionGroup.js similarity index 67% rename from packages/react/src/toast/__tests__/ToastTransitionController.test.js rename to packages/react/src/toast/__tests__/ToastTransitionGroup.js index 01830bbd87..e90610c4b4 100644 --- a/packages/react/src/toast/__tests__/ToastTransitionController.test.js +++ b/packages/react/src/toast/__tests__/ToastTransitionGroup.js @@ -14,8 +14,10 @@ import { Text, Toast, ToastCloseButton, - ToastTransitionController, + ToastController, + ToastTransition, ToastTransitionGroup, + useColorStyle, } from '@tonic-ui/react/src'; import { transitionDuration } from '@tonic-ui/utils/src'; import React, { useState } from 'react'; @@ -35,11 +37,12 @@ const InlineToastContainer = (props) => ( /> ); -describe('ToastTransitionController', () => { +describe('ToastTransitionGroup', () => { test('should display a toast within a modal', async () => { const user = userEvent.setup(); const TestComponent = ({ onClose }) => { + const [colorStyle] = useColorStyle(); const [toasts, setToasts] = useState([ { id: 1, @@ -64,30 +67,37 @@ describe('ToastTransitionController', () => { > - {toasts.map(toast => ( - - {({ onClose }) => ( - { + const onClose = createCloseToastHandler(toast.id); + return ( + + - {toast.content} - - - )} - - ))} + + {toast.content} + + + + + ); + })} diff --git a/packages/react/src/toast/index.js b/packages/react/src/toast/index.js index 3f39ffd490..cf8fa938ff 100644 --- a/packages/react/src/toast/index.js +++ b/packages/react/src/toast/index.js @@ -6,7 +6,6 @@ import ToastIcon from './ToastIcon'; import ToastManager from './ToastManager'; import ToastMessage from './ToastMessage'; import ToastTransition from './ToastTransition'; -import ToastTransitionController from './ToastTransitionController'; import ToastTransitionGroup from './ToastTransitionGroup'; import useToastManager from './useToastManager'; @@ -19,7 +18,6 @@ export { ToastManager, ToastMessage, ToastTransition, - ToastTransitionController, ToastTransitionGroup, useToastManager, }; From 7b55b873fe167e1201809d32db101eb238cd242a Mon Sep 17 00:00:00 2001 From: cheton Date: Thu, 7 Nov 2024 15:39:19 +0800 Subject: [PATCH 10/19] docs: update toast page --- packages/react-docs/pages/components/toast/index.page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-docs/pages/components/toast/index.page.mdx b/packages/react-docs/pages/components/toast/index.page.mdx index a807643aed..dadc44a378 100644 --- a/packages/react-docs/pages/components/toast/index.page.mdx +++ b/packages/react-docs/pages/components/toast/index.page.mdx @@ -233,7 +233,7 @@ const createCloseToastHandler = (id) => () => { | Name | Type | Default | Description | | :--- | :--- | :------ | :---------- | -| children | ReactNode | ReactNode \| `({ onClose }) => ReactNode` | A function child can be used intead of a React element. This function is invoked with an object that includes the `onClose` prop. | +| children | ReactNode | A function child can be used intead of a React element. This function is invoked with an object that includes the `onClose` prop. | | duration | number | null | The duration in milliseconds after which the toast will be automatically closed. Set to `null` to disable auto-closing. | | onClose | function | | A callback called when the toast is being closed. | From 650a7cdff4a02d818662bea8492b917a971489b9 Mon Sep 17 00:00:00 2001 From: cheton Date: Thu, 7 Nov 2024 16:43:54 +0800 Subject: [PATCH 11/19] test: enhance test coverage for toast --- .../react/src/toast/__tests__/Toast.test.js | 139 ++++++++++++++++- .../toast/__tests__/ToastController.test.js | 45 ++++++ .../toast/__tests__/ToastTransitionGroup.js | 142 ------------------ 3 files changed, 182 insertions(+), 144 deletions(-) create mode 100644 packages/react/src/toast/__tests__/ToastController.test.js delete mode 100644 packages/react/src/toast/__tests__/ToastTransitionGroup.js diff --git a/packages/react/src/toast/__tests__/Toast.test.js b/packages/react/src/toast/__tests__/Toast.test.js index 63e0fe72da..e925ec5c38 100644 --- a/packages/react/src/toast/__tests__/Toast.test.js +++ b/packages/react/src/toast/__tests__/Toast.test.js @@ -1,10 +1,43 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '@tonic-ui/react/test-utils/render'; -import { Collapse, Toast } from '@tonic-ui/react/src'; +import { + Button, + Collapse, + Flex, + Grid, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Skeleton, + Stack, + Text, + Toast, + ToastCloseButton, + ToastController, + ToastTransition, + ToastTransitionGroup, + useColorStyle, +} from '@tonic-ui/react/src'; import { useToggle } from '@tonic-ui/react-hooks/src'; import { callEventHandlers, transitionDuration } from '@tonic-ui/utils/src'; -import React from 'react'; +import React, { useState } from 'react'; + +const InlineToastContainer = (props) => ( + +); describe('Toast', () => { it('should render correctly', async () => { @@ -42,4 +75,106 @@ describe('Toast', () => { timeout: transitionDuration.standard + 100, // see "transitions/Collapse.js" }); }); + + test('should display a toast within a modal', async () => { + const user = userEvent.setup(); + + const TestComponent = ({ onClose }) => { + const [colorStyle] = useColorStyle(); + const [toasts, setToasts] = useState([ + { + id: 1, + appearance: 'success', + content: 'This is a success message.', + duration: 5000, + }, + ]); + const closeToast = (id) => { + setToasts(toasts => toasts.filter(x => x.id !== id)); + }; + const createCloseToastHandler = (id) => () => { + closeToast(id); + }; + + return ( + + + + {toasts.map(toast => { + const onClose = createCloseToastHandler(toast.id); + return ( + + + + {toast.content} + + + + + ); + })} + + + + Modal + + + + + + + + + + + + + + + + ); + }; + + render( + + ); + + // Verify the toast is displayed + expect(screen.getByText('This is a success message.')).toBeInTheDocument(); + + // Simulate closing the toast + const toastCloseButton = screen.getByTestId('toast-close-button'); + await user.click(toastCloseButton); + + await waitForElementToBeRemoved(() => screen.getByTestId('toast'), { + timeout: transitionDuration.standard + 100, // see "transitions/Collapse.js" + }); + }); }); diff --git a/packages/react/src/toast/__tests__/ToastController.test.js b/packages/react/src/toast/__tests__/ToastController.test.js new file mode 100644 index 0000000000..1cfe865e03 --- /dev/null +++ b/packages/react/src/toast/__tests__/ToastController.test.js @@ -0,0 +1,45 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '@tonic-ui/react/test-utils/render'; +import { Toast, ToastController } from '@tonic-ui/react/src'; + +jest.useFakeTimers(); + +describe('ToastController', () => { + test('pauses timeout on mouse enter and resumes on mouse leave', async () => { + const user = userEvent.setup({ + // Disable action delay to allow `await user.hover(element)` to complete immediately + // Reference: https://github.com/testing-library/user-event/issues/833 + delay: null, + }); + const onClose = jest.fn(); + const duration = 5000; + + render( + + + This is a toast + + + ); + + const toast = screen.getByTestId('toast'); + + // Simulate mouse enter, which should pause the timeout + await user.hover(toast); + + // Timeout should be paused + jest.advanceTimersByTime(duration); + expect(onClose).not.toHaveBeenCalled(); + + // Simulate mouse leave, which should resume the timeout + await user.unhover(toast); + + // Timeout should be resumed + jest.advanceTimersByTime(duration); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/toast/__tests__/ToastTransitionGroup.js b/packages/react/src/toast/__tests__/ToastTransitionGroup.js deleted file mode 100644 index e90610c4b4..0000000000 --- a/packages/react/src/toast/__tests__/ToastTransitionGroup.js +++ /dev/null @@ -1,142 +0,0 @@ -import { screen, waitForElementToBeRemoved } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { render } from '@tonic-ui/react/test-utils/render'; -import { - Button, - Flex, - Grid, - ModalContent, - ModalHeader, - ModalBody, - ModalFooter, - Skeleton, - Stack, - Text, - Toast, - ToastCloseButton, - ToastController, - ToastTransition, - ToastTransitionGroup, - useColorStyle, -} from '@tonic-ui/react/src'; -import { transitionDuration } from '@tonic-ui/utils/src'; -import React, { useState } from 'react'; - -const InlineToastContainer = (props) => ( - -); - -describe('ToastTransitionGroup', () => { - test('should display a toast within a modal', async () => { - const user = userEvent.setup(); - - const TestComponent = ({ onClose }) => { - const [colorStyle] = useColorStyle(); - const [toasts, setToasts] = useState([ - { - id: 1, - appearance: 'success', - content: 'This is a success message.', - duration: 5000, - }, - ]); - const closeToast = (id) => { - setToasts(toasts => toasts.filter(x => x.id !== id)); - }; - const createCloseToastHandler = (id) => () => { - closeToast(id); - }; - - return ( - - - - {toasts.map(toast => { - const onClose = createCloseToastHandler(toast.id); - return ( - - - - {toast.content} - - - - - ); - })} - - - - Modal - - - - - - - - - - - - - - - - ); - }; - - render( - - ); - - // Verify the toast is displayed - expect(screen.getByText('This is a success message.')).toBeInTheDocument(); - - // Simulate closing the toast - const toastCloseButton = screen.getByTestId('toast-close-button'); - await user.click(toastCloseButton); - - await waitForElementToBeRemoved(() => screen.getByTestId('toast'), { - timeout: transitionDuration.standard + 100, // see "transitions/Collapse.js" - }); - }); -}); From 588351ec563ff4b65cc29d90cf259eec5d435c7b Mon Sep 17 00:00:00 2001 From: cheton Date: Thu, 7 Nov 2024 17:53:48 +0800 Subject: [PATCH 12/19] test: enhance test coverage for ToastManager --- .../src/toast/__tests__/ToastManager.test.js | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/packages/react/src/toast/__tests__/ToastManager.test.js b/packages/react/src/toast/__tests__/ToastManager.test.js index c1ea0ef332..44e2f2bdb8 100644 --- a/packages/react/src/toast/__tests__/ToastManager.test.js +++ b/packages/react/src/toast/__tests__/ToastManager.test.js @@ -361,4 +361,188 @@ describe('ToastManager', () => { expect(toastPlacementElement.childNodes.length).toBe(maxToasts); }); + + it('should find a toast by ID using find()', async () => { + const user = userEvent.setup(); + const toastId = 'toast-id'; + const message = 'This is a toast message'; + + const WrapperComponent = (props) => ( + + ); + + const TestComponent = () => { + const toast = useToastManager(); + const handleClickAddToast = useCallback(() => { + toast(({ onClose, placement }) => ( + + {message} + + ), { id: toastId }); + }, [toast]); + const handleClickFindToast = useCallback(() => { + const foundToast = toast.find(toastId); + expect(foundToast).toBeDefined(); + expect(foundToast.id).toBe(toastId); + }, [toast]); + + return ( + <> + + + + ); + }; + + render( + + + + ); + + // Add the toast + await user.click(screen.getByText('Add Toast')); + + // Find the toast + await user.click(screen.getByText('Find Toast')); + + // Check if the toast message is displayed correctly + const toastElement = screen.getByTestId(toastId); + expect(toastElement).toHaveTextContent(message); + }); + + it('should find the index of a toast by ID using findIndex()', async () => { + const user = userEvent.setup(); + const toastId = 'toast-id'; + const message = 'This is a toast message'; + + const WrapperComponent = (props) => ( + + ); + + const TestComponent = () => { + const toast = useToastManager(); + const handleClickAddToast = useCallback(() => { + toast(({ onClose, placement }) => ( + + {message} + + ), { id: toastId }); + }, [toast]); + const handleClickFindToast = useCallback(() => { + const toastIndex = toast.findIndex(toastId); + expect(toastIndex).toBeGreaterThan(-1); + }, [toast]); + + return ( + <> + + + + ); + }; + + render( + + + + ); + + // Add the toast + await user.click(screen.getByText('Add Toast')); + + // Find the toast + await user.click(screen.getByText('Find Toast')); + + // Check if the toast message is displayed correctly + const toastElement = screen.getByTestId(toastId); + expect(toastElement).toHaveTextContent(message); + }); + + it('should update an existing toast by ID using update()', async () => { + const user = userEvent.setup(); + const toastId = 'toast-id'; + const initialMessage = 'Initial toast message'; + const updatedMessage = 'Updated toast message'; + + const WrapperComponent = (props) => ( + + ); + + const TestComponent = () => { + const toast = useToastManager(); + const handleClickAddToast = useCallback(() => { + toast(({ onClose }) => ( + + {initialMessage} + + ), { id: toastId }); + }, [toast]); + const handleClickUpdateToast = useCallback(() => { + const updateSuccess = toast.update(toastId, { + content: ({ onClose }) => ( + + {updatedMessage} + + ), + }); + expect(updateSuccess).toBe(true); + }, [toast]); + + return ( + <> + + + + ); + }; + + render( + + + + ); + + // Add the toast + await user.click(screen.getByText('Add Toast')); + + // Update the toast + await user.click(screen.getByText('Update Toast')); + + // Check if the content has been updated + const toastElement = screen.getByTestId(toastId); + expect(toastElement).toHaveTextContent(updatedMessage); + }); }); From 3f77c3579445eddf79dec6b092e655b7c87653f6 Mon Sep 17 00:00:00 2001 From: cheton Date: Thu, 7 Nov 2024 21:23:31 +0800 Subject: [PATCH 13/19] refactor: improve the render toast function in `ToastManager` --- .../toast-manager/useToastManager.js | 7 ++-- .../toast-manager/useToastManager.page.mdx | 34 +++++++++++++------ packages/react/src/toast/ToastManager.js | 24 ++++--------- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/packages/react-docs/pages/components/toast-manager/useToastManager.js b/packages/react-docs/pages/components/toast-manager/useToastManager.js index aa25040118..b7e3ae43eb 100644 --- a/packages/react-docs/pages/components/toast-manager/useToastManager.js +++ b/packages/react-docs/pages/components/toast-manager/useToastManager.js @@ -10,7 +10,7 @@ import React, { useCallback } from 'react'; const App = () => { const toast = useToastManager(); const handleClickOpenToast = useCallback(() => { - const render = ({ onClose, placement }) => { + const render = ({ id, data, onClose, placement }) => { const isTop = placement.includes('top'); const toastSpacingKey = isTop ? 'pb' : 'pt'; const styleProps = { @@ -20,7 +20,7 @@ const App = () => { return ( - + This is a toast notification @@ -28,6 +28,9 @@ const App = () => { }; const options = { placement: 'bottom-right', + data: { + foo: 'bar', + }, duration: 5000, }; toast(render, options); diff --git a/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx b/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx index 470ea15512..9ce0d71f6f 100644 --- a/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx +++ b/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx @@ -29,7 +29,7 @@ Create a toast at the specified placement and return the toast id. #### Parameters
-
`content` *(Function|string)*: The toast content to render.
+
`content` *(Function)*: The toast content to render.
`[options={}]` *(Object)*: The options object.
`[options.data]` *(any)*: The user-defined data supplied to the toast.
`[options.duration=null]` *(number)*: The duration (in milliseconds) that the toast should remain on the screen. If set to null, toast will never dismiss.
@@ -117,7 +117,7 @@ Update a specific toast with new options based on the given toast id.
`id` *(string)*: The id to update the toast.
`[options={}]` *(Object)*: The options object.
-
`[options.content]` *(Function|string)*: The toast content to render.
+
`[options.content]` *(Function)*: The toast content to render.
`[options.data]` *(any)*: The user-defined data supplied to the toast.
`[options.duration=null]` *(number)*: The duration (in milliseconds) that the toast should remain on the screen. If set to null, toast will never dismiss.
@@ -156,9 +156,12 @@ Example usage with a state object: toast.setState({ 'top': [ { - id: '2', - content: 'New toast', - data: undefined, + id: 1, + content: ({ id, data, onClose, placement }) => ( + + This is a toast + + ), duration: 3000, placement: 'top', } @@ -179,9 +182,12 @@ toast.setState(prevState => ({ 'top': [ ...prevState['top'], { - id: '2', - content: 'New toast', - data: undefined, + id: 1, + content: ({ id, data, onClose, placement }) => ( + + This is a toast + + ), duration: null, placement: 'top', }, @@ -197,9 +203,15 @@ The toast state is a placement object, each placement contains an array of objec { 'top': [ { - id: '1', // A unique identifier that represents the toast - content: ({ id, data, onClose, placement }) => , // The toast content to render - data: undefined, // The user-defined data supplied to the toast + id: 1, // A unique identifier that represents the toast + content: ({ id, data, onClose, placement }) => ( + + This is a toast + + ), + data: { + // The user-defined data supplied to the toast + }, duration: null, // The duration (in milliseconds) that the toast should remain on the screen. If set to null, toast will never dismiss. placement: 'top', // The placement of the toast }, diff --git a/packages/react/src/toast/ToastManager.js b/packages/react/src/toast/ToastManager.js index 67e4459a13..ba4652e067 100644 --- a/packages/react/src/toast/ToastManager.js +++ b/packages/react/src/toast/ToastManager.js @@ -3,7 +3,6 @@ import { isNullish, runIfFn } from '@tonic-ui/utils'; import { ensureArray, ensureString } from 'ensure-type'; import memoize from 'micro-memoize'; import React, { useCallback, useState } from 'react'; -import { isElement, isValidElementType } from 'react-is'; import { useDefaultProps } from '../default-props'; import { Portal } from '../portal'; import ToastContainer from './ToastContainer'; @@ -247,23 +246,12 @@ const ToastManager = (inProps) => { duration={toast.duration} onClose={onClose} > - {(() => { - if (isElement(toast.content)) { - return toast.content; - } - if (isValidElementType(toast.content)) { - const ToastContent = toast.content; - return ( - - ); - } - return null; - })()} + {runIfFn(toast.content, { + id: toast.id, + data: toast.data, + onClose, + placement: toast.placement, + })} ); From 1b521edd7d7397dd4bde202e7f70c90ff4460087 Mon Sep 17 00:00:00 2001 From: cheton Date: Thu, 7 Nov 2024 21:45:04 +0800 Subject: [PATCH 14/19] docs: update ToastManager page --- .../pages/components/toast-manager/index.page.mdx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/react-docs/pages/components/toast-manager/index.page.mdx b/packages/react-docs/pages/components/toast-manager/index.page.mdx index 428ba5b441..791011fa4b 100644 --- a/packages/react-docs/pages/components/toast-manager/index.page.mdx +++ b/packages/react-docs/pages/components/toast-manager/index.page.mdx @@ -44,7 +44,7 @@ import { useToastManager } from '@tonic-ui/react'; function MyComponent() { const toast = useToastManager(); const handleClickOpenToast = () => { - const render = ({ onClose, placement }) => { + const render = ({ id, data, onClose, placement }) => { const isTop = placement.includes('top'); const toastSpacingKey = isTop ? 'pb' : 'pt'; const styleProps = { @@ -54,7 +54,7 @@ function MyComponent() { return ( - + This is a toast notification @@ -63,6 +63,7 @@ function MyComponent() { const options = { placement: 'bottom-right', duration: 5000, + data: {}, // user-defined data }; toast(render, options); }; @@ -75,19 +76,19 @@ function MyComponent() { } ``` -The `toast` method takes a function that returns the toast element to be displayed. The function can also receive an `onClose` function and the `placement` string. The `onClose` function can be used to remove the toast when the user clicks on a close button or after a certain time period. +The `toast` method accepts a function that generates the toast element to display. This function receives `id`, `data`, `onClose`, and `placement` as arguments. ```jsx disabled -const id = toast(({ onClose, placement }) => ( +toast(({ id, data, onClose, placement }) => ( This is a toast notification )); ``` -To remove a toast, you can either call the `onClose` function or use the `toast.remove` method, which takes the toast's unique id as an argument. +To remove a toast, you can use the `onClose` function, triggered by the user clicking a close button. Alternatively, you can use the `toast.remove` method, passing the toast's unique id as an argument. -```jsx disabled +```js toast.remove(id); ``` From b32744e8a6374de3bf6480bfb25535ad2feb75af Mon Sep 17 00:00:00 2001 From: cheton Date: Fri, 8 Nov 2024 13:41:06 +0800 Subject: [PATCH 15/19] feat: enhance toast rendering in ToastManager --- packages/react/src/toast/ToastManager.js | 3 +- .../__tests__/renderComponentOrValue.test.js | 41 +++++++++++++++++++ .../react/src/utils/renderComponentOrValue.js | 15 +++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/utils/__tests__/renderComponentOrValue.test.js create mode 100644 packages/react/src/utils/renderComponentOrValue.js diff --git a/packages/react/src/toast/ToastManager.js b/packages/react/src/toast/ToastManager.js index ba4652e067..fcbbf7d1f7 100644 --- a/packages/react/src/toast/ToastManager.js +++ b/packages/react/src/toast/ToastManager.js @@ -5,6 +5,7 @@ import memoize from 'micro-memoize'; import React, { useCallback, useState } from 'react'; import { useDefaultProps } from '../default-props'; import { Portal } from '../portal'; +import { renderComponentOrValue } from '../utils/renderComponentOrValue'; import ToastContainer from './ToastContainer'; import ToastController from './ToastController'; import ToastTransition from './ToastTransition'; @@ -246,7 +247,7 @@ const ToastManager = (inProps) => { duration={toast.duration} onClose={onClose} > - {runIfFn(toast.content, { + {renderComponentOrValue(toast.content, { id: toast.id, data: toast.data, onClose, diff --git a/packages/react/src/utils/__tests__/renderComponentOrValue.test.js b/packages/react/src/utils/__tests__/renderComponentOrValue.test.js new file mode 100644 index 0000000000..ec06c0d1bf --- /dev/null +++ b/packages/react/src/utils/__tests__/renderComponentOrValue.test.js @@ -0,0 +1,41 @@ +import { screen } from '@testing-library/react'; +import { render } from '@tonic-ui/react/test-utils/render'; +import React from 'react'; +import { renderComponentOrValue } from '../renderComponentOrValue'; + +class ReactClassComponent extends React.Component { + render() { + return ( +
{this.props.message}
+ ); + } +} + +const ReactFunctionalComponent = ({ message }) =>
{message}
; + +describe('renderComponentOrValue', () => { + it('renders a functional component with props', () => { + render(renderComponentOrValue(ReactFunctionalComponent, { message: 'Hello, World!' })); + expect(screen.getByText('Hello, World!')).toBeInTheDocument(); + }); + + it('renders a class component with props', () => { + render(renderComponentOrValue(ReactClassComponent, { message: 'Hello, World!' })); + expect(screen.getByText('Hello, World!')).toBeInTheDocument(); + }); + + it('returns a non-component value directly', () => { + const { container } = render(renderComponentOrValue('Hello, World!', {})); + expect(container.textContent).toBe('Hello, World!'); + }); + + it('returns null if the value is null', () => { + const { container } = render(renderComponentOrValue(null, {})); + expect(container.textContent).toBe(''); + }); + + it('returns undefined if the value is undefined', () => { + const { container } = render(renderComponentOrValue(undefined, {})); + expect(container.textContent).toBe(''); + }); +}); diff --git a/packages/react/src/utils/renderComponentOrValue.js b/packages/react/src/utils/renderComponentOrValue.js new file mode 100644 index 0000000000..7272d903b2 --- /dev/null +++ b/packages/react/src/utils/renderComponentOrValue.js @@ -0,0 +1,15 @@ +import React from 'react'; + +const renderComponentOrValue = (componentOrValue, props) => { + if (typeof componentOrValue === 'function') { + // If it's a React function component or React class component, render it with the provided props + const Component = componentOrValue; + return ; + } + // Otherwise, return the value directly (it could be a primitive or other React element) + return componentOrValue; +}; + +export { + renderComponentOrValue, +}; From afa7a2055b03e0ba63765367a445a09da2b26771 Mon Sep 17 00:00:00 2001 From: cheton Date: Fri, 8 Nov 2024 13:41:48 +0800 Subject: [PATCH 16/19] chore: remove deprecated `isValidElementType` from `MenuToggleIcon` --- packages/react/src/menu/MenuToggleIcon.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react/src/menu/MenuToggleIcon.js b/packages/react/src/menu/MenuToggleIcon.js index 781e52ee1e..905aa4641c 100644 --- a/packages/react/src/menu/MenuToggleIcon.js +++ b/packages/react/src/menu/MenuToggleIcon.js @@ -7,7 +7,6 @@ import { } from '@tonic-ui/react-icons'; import { ariaAttr, createTransitionStyle, getEnterTransitionProps, getExitTransitionProps, reflow, transitionEasing } from '@tonic-ui/utils'; import React, { forwardRef, useEffect, useRef } from 'react'; -import { isValidElementType } from 'react-is'; import { Transition } from 'react-transition-group'; import { Box } from '../box'; import { useDefaultProps } from '../default-props'; @@ -129,7 +128,7 @@ const MenuToggleIcon = forwardRef((inProps, ref) => { {...childProps} style={style} > - {children ?? (isValidElementType(IconComponent) && )} + {children ?? (!!IconComponent && )} ); }} From c8317e50147f98055a73ed32689a2d01542750ea Mon Sep 17 00:00:00 2001 From: cheton Date: Fri, 8 Nov 2024 13:42:42 +0800 Subject: [PATCH 17/19] refactor(Stack): refine the code for filtering valid children --- packages/react/src/stack/Stack.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/stack/Stack.js b/packages/react/src/stack/Stack.js index 969c838465..aca56c61f4 100644 --- a/packages/react/src/stack/Stack.js +++ b/packages/react/src/stack/Stack.js @@ -1,4 +1,4 @@ -import React, { forwardRef } from 'react'; +import React, { forwardRef, isValidElement } from 'react'; import { Box } from '../box'; import { useDefaultProps } from '../default-props'; import StackItem from './StackItem'; @@ -21,13 +21,13 @@ const Stack = forwardRef((inProps, ref) => { // Filter only the valid children of a component, and ignore any nullish or falsy child. const validChildren = React.Children .toArray(children) - .filter(c => React.isValidElement(c)); + .filter(c => isValidElement(c)); let clones = validChildren; if (shouldWrapChildren) { clones = validChildren.map((child, index) => { // Use the provided child key, otherwise use the index as fallback - const key = (typeof child.key !== 'undefined') ? child.key : index; + const key = (child?.key) ?? index; return ( From 76abe0544c5c66326ac642a65178c562d0cb3275 Mon Sep 17 00:00:00 2001 From: cheton Date: Fri, 8 Nov 2024 15:11:25 +0800 Subject: [PATCH 18/19] test: improve test coverage for Stack component --- .../react/src/stack/__tests__/Stack.test.js | 97 +++++++++++++++++++ .../__snapshots__/Stack.test.js.snap | 30 ++++++ 2 files changed, 127 insertions(+) create mode 100644 packages/react/src/stack/__tests__/Stack.test.js create mode 100644 packages/react/src/stack/__tests__/__snapshots__/Stack.test.js.snap diff --git a/packages/react/src/stack/__tests__/Stack.test.js b/packages/react/src/stack/__tests__/Stack.test.js new file mode 100644 index 0000000000..51e9e28866 --- /dev/null +++ b/packages/react/src/stack/__tests__/Stack.test.js @@ -0,0 +1,97 @@ +import { screen } from '@testing-library/react'; +import { testA11y } from '@tonic-ui/react/test-utils/accessibility'; +import { render } from '@tonic-ui/react/test-utils/render'; +import { Box, Stack } from '@tonic-ui/react/src'; +import React from 'react'; + +describe('Stack', () => { + it('should render correctly', async () => { + const renderOptions = { + useCSSVariables: true, + }; + const { container } = render(( + + + + + + ), renderOptions); + + expect(container).toMatchSnapshot(); + + await testA11y(container); + }); + + it('should apply the default direction of column', () => { + render( + + + + + ); + + const stack = screen.getByTestId('stack'); + expect(stack).toHaveStyle('flex-direction: column'); + }); + + it('should apply the custom direction when specified', () => { + render( + <> + + + + + + + + + + ); + + expect(screen.getByTestId('stack-row')).toHaveStyle('flex-direction: row'); + expect(screen.getByTestId('stack-column')).toHaveStyle('flex-direction: column'); + }); + + it('should apply spacing between child elements', () => { + const renderOptions = { + useCSSVariables: true, + }; + render(( + + + + + ), renderOptions); + + const stack = screen.getByTestId('stack'); + expect(stack).toHaveStyle('row-gap: var(--tonic-sizes-2x)'); + }); + + it('should not wrap each child by default when shouldWrapChildren is false', () => { + render( + + + + + ); + + const stack = screen.getByTestId('stack'); + expect(stack.children).toHaveLength(2); + expect(stack.children[0]).toHaveAttribute('data-testid', 'stack-item-1'); + expect(stack.children[1]).toHaveAttribute('data-testid', 'stack-item-2'); + }); + + it('should wrap each child when shouldWrapChildren is true', () => { + render( + + + + + ); + + const stack = screen.getByTestId('stack'); + expect(stack.children).toHaveLength(2); + expect(stack.children[0].firstChild).toHaveAttribute('data-testid', 'stack-item-1'); + expect(stack.children[1].firstChild).toHaveAttribute('data-testid', 'stack-item-2'); + }); +}); diff --git a/packages/react/src/stack/__tests__/__snapshots__/Stack.test.js.snap b/packages/react/src/stack/__tests__/__snapshots__/Stack.test.js.snap new file mode 100644 index 0000000000..5d6950ee58 --- /dev/null +++ b/packages/react/src/stack/__tests__/__snapshots__/Stack.test.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Stack should render correctly 1`] = ` +.emotion-0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + row-gap: var(--tonic-sizes-4x); +} + +
+
+
+
+
+
+
+`; From ac26a80f14510c151e2cbdfdfc6dfd521ed59bed Mon Sep 17 00:00:00 2001 From: cheton Date: Fri, 8 Nov 2024 18:02:30 +0800 Subject: [PATCH 19/19] test: improve test coverage for ToastManager --- packages/react/src/toast/ToastManager.js | 8 +- .../src/toast/__tests__/ToastManager.test.js | 163 ++++++++++++++++++ 2 files changed, 165 insertions(+), 6 deletions(-) diff --git a/packages/react/src/toast/ToastManager.js b/packages/react/src/toast/ToastManager.js index fcbbf7d1f7..cc4f82042a 100644 --- a/packages/react/src/toast/ToastManager.js +++ b/packages/react/src/toast/ToastManager.js @@ -222,7 +222,7 @@ const ToastManager = (inProps) => { containerRef={containerRef} > {Object.keys(state).map((placement) => { - const toasts = ensureArray(state[placement]); + const toasts = ensureArray(state[placement]).filter(toast => !isNullish(toast)); return ( { {...ToastContainerProps} > - {ensureArray(toasts).map((toast) => { - if (!toast || isNullish(toast.id)) { - // TODO: log an error if the toast id is missing - return null; - } + {toasts.map((toast) => { const onClose = createCloseToastHandler(toast.id, placement); return ( { }); expect(updateSuccess).toBe(true); }, [toast]); + const handleClickUpdateInvalidToast = useCallback(() => { + const updateSuccess = toast.update(null, {}); + expect(updateSuccess).toBe(false); + }, [toast]); return ( <> @@ -525,6 +529,9 @@ describe('ToastManager', () => { + ); }; @@ -540,9 +547,165 @@ describe('ToastManager', () => { // Update the toast await user.click(screen.getByText('Update Toast')); + await user.click(screen.getByText('Update Invalid Toast')); // Check if the content has been updated const toastElement = screen.getByTestId(toastId); expect(toastElement).toHaveTextContent(updatedMessage); }); + + it('should not create a toast and return false for invalid placement', async () => { + const user = userEvent.setup(); + const toastId = 'toast-id'; + const placement = 'center'; // "center" is not a supported placement + const message = 'This is a toast message'; + + // Spy on console.error to capture and check the error message + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const WrapperComponent = (props) => ( + + ); + + const TestComponent = () => { + const toast = useToastManager(); + const handleClick = useCallback(() => { + const result = toast(({ onClose }) => ( + + {message} + + ), { placement }); + expect(result).toBe(false); + }, [toast]); + + return ( + + ); + }; + + render( + + + + ); + + const button = await screen.findByText('Add Toast'); + await user.click(button); + + // Check that console.error was called with the expected error message + const placements = [ + 'bottom', + 'bottom-right', + 'bottom-left', + 'top', + 'top-left', + 'top-right', + ]; + const expectedErrorMessage = `[ToastManager] Error: Invalid toast placement "${placement}". Please provide a valid placement from the following options: ${placements.join(', ')}.`; + expect(consoleErrorSpy).toHaveBeenCalledWith(expectedErrorMessage); + + // Assert that no toast element with the invalid placement was created + const toastElement = screen.queryByTestId(toastId); + expect(toastElement).not.toBeInTheDocument(); + + // Restore console.error to its original implementation + consoleErrorSpy.mockRestore(); + }); + + it('should create toasts in the correct order for top and bottom placements in the state', async () => { + const user = userEvent.setup(); + const topPlacement = 'top'; + const bottomPlacement = 'bottom'; + const message = 'This is a toast message'; + + const WrapperComponent = (props) => ( + + ); + + const TestComponent = () => { + const toast = useToastManager(); + const handleClickAddToasts = useCallback(() => { + // Add toast for top-right placement + toast(({ onClose }) => ( + + {message} + + ), { placement: topPlacement }); + + // Add toast for bottom-right placement + toast(({ onClose }) => ( + + {message} + + ), { placement: bottomPlacement }); + }, [toast]); + + return ( + <> + + {/* Access toast.state here to check order */} + {toast.state && ( +
{JSON.stringify(toast.state, null, 2)}
+ )} + + ); + }; + + render( + + + + ); + + const button = await screen.findByText('Add Toasts'); + await user.click(button); + await user.click(button); + + // Wait for the state to be updated with toasts + await screen.findByTestId('toast-state'); + + // Get the state of the toasts + const toastState = JSON.parse(screen.getByTestId('toast-state').textContent); + + // Check that toasts with top-right and bottom-right placements exist in the state + const topToasts = toastState[topPlacement]; + const bottomToasts = toastState[bottomPlacement]; + + // top-right + // + // ```js + // [ + // { id: '3', placement: 'top-right' }, + // { id: '1', placement: 'top-right' }, + // ] + // ``` + expect(topToasts).toHaveLength(2); + expect(topToasts[0].id > topToasts[1].id).toBeTruthy(); + + // bottom-right + // + // ```js + // [ + // { id: '2', placement: 'bottom-right' }, + // { id: '4', placement: 'bottom-right' }, + // ] + // ``` + expect(bottomToasts).toHaveLength(2); + expect(bottomToasts[0].id < bottomToasts[1].id).toBeTruthy(); + }); });