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 })} 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); ``` 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 7c040940c3..9ce0d71f6f 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)*: 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)*: 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 @@ -155,10 +156,13 @@ Example usage with a state object: toast.setState({ 'top': [ { - id: '2', + id: 1, + content: ({ id, data, onClose, placement }) => ( + + This is a toast + + ), duration: 3000, - message: 'New toast message', - onClose: () => toast.close('2'), placement: 'top', } ], @@ -178,10 +182,13 @@ toast.setState(prevState => ({ 'top': [ ...prevState['top'], { - id: '2', + id: 1, + content: ({ id, data, onClose, placement }) => ( + + This is a toast + + ), duration: null, - message: 'New toast message', - onClose: () => toast.close('2', 'top'), placement: 'top', }, ], @@ -196,10 +203,16 @@ 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 }) => ( + + 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. - 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-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..d3412d9082 --- /dev/null +++ b/packages/react-docs/pages/components/toast/drawer-with-toast-notification.js @@ -0,0 +1,213 @@ +import { + Box, + Button, + Divider, + Flex, + Grid, + DrawerContent, + DrawerHeader, + DrawerBody, + DrawerFooter, + Skeleton, + Space, + Stack, + Text, + Toast, + ToastController, + ToastTransition, + 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 => { + const onClose = createCloseToastHandler(toast.id); + return ( + + + + {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 ec00760efd..dadc44a378 100644 --- a/packages/react-docs/pages/components/toast/index.page.mdx +++ b/packages/react-docs/pages/components/toast/index.page.mdx @@ -10,6 +10,7 @@ import { ToastCloseButton, ToastController, ToastTransition, + ToastTransitionGroup, } from '@tonic-ui/react'; ``` @@ -78,11 +79,137 @@ 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. +```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. 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. | +| 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 +{ + id: 1, + appearance: 'success', + content: ( + This is a success message. + ), + duration: 5000, // auto dismiss after 5 seconds +} +``` + +Here is a complete example: +```js +const idRef = useRef(0); +const [toasts, setToasts] = useState([]); +const notify = (options) => { + 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); +}; +``` -{render('./modal-toast')} +```jsx + + + {toasts.map(toast => { + const onClose = createCloseToastHandler(toast.id); + return ( + + + + {toast.content} + + + + ); + })} + + +``` + +#### Modal with toast notification + +{render('./modal-with-toast-notification')} -{render('./drawer-toast')} +#### Drawer with multiple toasts + +{render('./drawer-with-toast-notification')} ## Props @@ -106,7 +233,7 @@ To animate the toast when it is displayed or dismissed, you can use the `ToastTr | Name | Type | Default | Description | | :--- | :--- | :------ | :---------- | -| children | ReactNode | | | +| 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. | @@ -121,3 +248,12 @@ 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. | 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..abe9969b61 --- /dev/null +++ b/packages/react-docs/pages/components/toast/modal-with-toast-notification.js @@ -0,0 +1,213 @@ +import { + Box, + Button, + Divider, + Flex, + Grid, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Skeleton, + Space, + Stack, + Text, + Toast, + ToastController, + ToastTransition, + 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 => { + const onClose = createCloseToastHandler(toast.id); + return ( + + + + {toast.content} + + + + ); + })} + + + + Modal + + + + + + + + + + + + + + + + + + ); +}; + +export default App; diff --git a/packages/react/__tests__/index.test.js b/packages/react/__tests__/index.test.js index 0d2c1f5c2e..f83db6a4b4 100644 --- a/packages/react/__tests__/index.test.js +++ b/packages/react/__tests__/index.test.js @@ -242,6 +242,7 @@ test('should match expected exports', () => { 'ToastManager', 'ToastMessage', 'ToastTransition', + 'ToastTransitionGroup', 'useToastManager', // tooltip 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 && )} ); }} 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 ( 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); +} + +
+
+
+
+
+
+
+`; diff --git a/packages/react/src/toast/ToastController.js b/packages/react/src/toast/ToastController.js index 9d4ff05614..b8ca927b93 100644 --- a/packages/react/src/toast/ToastController.js +++ b/packages/react/src/toast/ToastController.js @@ -7,24 +7,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 ( { onMouseEnter={callEventHandlers(onMouseEnterProp, onMouseEnter)} onMouseLeave={callEventHandlers(onMouseLeaveProp, onMouseLeave)} {...rest} - > - {children} - + /> ); }); diff --git a/packages/react/src/toast/ToastManager.js b/packages/react/src/toast/ToastManager.js index 2d8b121452..cc4f82042a 100644 --- a/packages/react/src/toast/ToastManager.js +++ b/packages/react/src/toast/ToastManager.js @@ -1,17 +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 { - TransitionGroup, -} from 'react-transition-group'; 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'; +import ToastTransitionGroup from './ToastTransitionGroup'; import { ToastManagerContext } from './context'; const uniqueId = (() => { @@ -43,6 +41,8 @@ const getToastPlacementByState = (state, id) => { const ToastManager = (inProps) => { const { + ToastContainerComponent = ToastContainer, + ToastContainerProps, TransitionComponent = ToastTransition, TransitionProps, children, @@ -57,37 +57,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 +114,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 + 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 +191,7 @@ const ToastManager = (inProps) => { }); return toast.id; - }, [createToast]); - - /** - * 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]); + }, [placementProp]); const context = getMemoizedState({ // Methods @@ -221,8 +199,8 @@ const ToastManager = (inProps) => { closeAll, find, findIndex, - notify, update, + notify, // Properties placement: placementProp, @@ -232,6 +210,10 @@ const ToastManager = (inProps) => { setState, }); + const createCloseToastHandler = (id, placement) => () => { + close(id, placement); + }; + return ( {runIfFn(children, context)} @@ -240,45 +222,39 @@ 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 ( - - - {toasts.map((toast) => ( - - + {toasts.map((toast) => { + const onClose = createCloseToastHandler(toast.id, placement); + return ( + - {(() => { - if (isElement(toast.message)) { - return toast.message; - } - if (isValidElementType(toast.message)) { - return ( - - ); - } - return null; - })()} - - - ))} - - + + {renderComponentOrValue(toast.content, { + id: toast.id, + data: toast.data, + onClose, + placement: toast.placement, + })} + + + ); + })} + + ); })} 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/__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__/ToastManager.test.js b/packages/react/src/toast/__tests__/ToastManager.test.js index b0d624614e..0bea08958f 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,354 @@ describe('ToastManager', () => { }); const toastPlacementElement = document.querySelector(`[data-toast-placement="${placement}"]`); + 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]); + const handleClickUpdateInvalidToast = useCallback(() => { + const updateSuccess = toast.update(null, {}); + expect(updateSuccess).toBe(false); + }, [toast]); + + return ( + <> + + + + + ); + }; + + render( + + + + ); + + // Add the toast + await user.click(screen.getByText('Add Toast')); + + // 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(); + }); }); diff --git a/packages/react/src/toast/index.js b/packages/react/src/toast/index.js index e5b0cdc479..cf8fa938ff 100644 --- a/packages/react/src/toast/index.js +++ b/packages/react/src/toast/index.js @@ -6,6 +6,7 @@ import ToastIcon from './ToastIcon'; import ToastManager from './ToastManager'; import ToastMessage from './ToastMessage'; import ToastTransition from './ToastTransition'; +import ToastTransitionGroup from './ToastTransitionGroup'; import useToastManager from './useToastManager'; export { @@ -17,5 +18,6 @@ export { ToastManager, ToastMessage, ToastTransition, + ToastTransitionGroup, useToastManager, }; 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, +};