From fb152bec22eabd19a9b8191766fa432041cfe8bc Mon Sep 17 00:00:00 2001 From: Pavel Klibani Date: Fri, 3 May 2024 17:06:23 +0200 Subject: [PATCH] Feat(web-react): Introduce stacking of the `Toast` queue #DS-1209 - Make Toast work with dynamic collapsible queue and dynamic ToastBars - solves DS-1209 --- apps/web-twig-demo/yarn.lock | 8 +- .../web-react/src/components/Toast/README.md | 47 +++- .../web-react/src/components/Toast/Toast.tsx | 10 +- .../src/components/Toast/ToastBar.tsx | 26 +- .../src/components/Toast/ToastContext.tsx | 123 ++++++--- .../components/Toast/UncontrolledToast.tsx | 42 +-- .../__tests__/UncontrolledToast.test.tsx | 20 +- .../components/Toast/demo/ToastAlignment.tsx | 152 ----------- .../Toast/demo/ToastDynamicToastQueue.tsx | 247 ++++++++++++++++++ .../Toast/demo/ToastStaticToast.tsx | 36 +++ .../Toast/demo/UncontrolledToastDemo.tsx | 53 +++- .../src/components/Toast/demo/index.tsx | 10 +- .../components/Toast/useToastBarStyleProps.ts | 2 + .../components/Toast/useToastStyleProps.ts | 5 +- packages/web-react/src/types/toast.ts | 3 +- 15 files changed, 522 insertions(+), 262 deletions(-) delete mode 100644 packages/web-react/src/components/Toast/demo/ToastAlignment.tsx create mode 100644 packages/web-react/src/components/Toast/demo/ToastDynamicToastQueue.tsx create mode 100644 packages/web-react/src/components/Toast/demo/ToastStaticToast.tsx diff --git a/apps/web-twig-demo/yarn.lock b/apps/web-twig-demo/yarn.lock index b5e1f628ff..f3bb1f27c7 100644 --- a/apps/web-twig-demo/yarn.lock +++ b/apps/web-twig-demo/yarn.lock @@ -2268,10 +2268,10 @@ core-js-compat@^3.36.1: dependencies: browserslist "^4.23.0" -core-js@3.36.1: - version "3.36.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.36.1.tgz#c97a7160ebd00b2de19e62f4bbd3406ab720e578" - integrity sha512-BTvUrwxVBezj5SZ3f10ImnX2oRByMxql3EimVqMysepbC9EeMUOpLwdy6Eoili2x6E4kf+ZUB5k/+Jv55alPfA== +core-js@3.37.0: + version "3.37.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.37.0.tgz#d8dde58e91d156b2547c19d8a4efd5c7f6c426bb" + integrity sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug== core-util-is@~1.0.0: version "1.0.3" diff --git a/packages/web-react/src/components/Toast/README.md b/packages/web-react/src/components/Toast/README.md index 9717df62d8..b594070de0 100644 --- a/packages/web-react/src/components/Toast/README.md +++ b/packages/web-react/src/components/Toast/README.md @@ -102,25 +102,41 @@ sorted from top to bottom for the `top` vertical alignment, and from bottom to t 👉 Please note the _actual_ order in the DOM is followed when users tab over the interface, no matter the _visual_ order of the toast queue. -#### Toast Queue Limitations +#### Collapsing + +The collapsible Toast queue is turned on by default and can hold up to 3 ToastBar components. +When the queue is full, the oldest ToastBar components are collapsed at the start of +the queue and are only accessible by closing the newer ones. -While the Toast queue becomes scrollable when it does not fit the screen, we recommend displaying only a few toasts at -once for several reasons: +#### Scrolling -⚠️ **We strongly discourage from displaying too many toasts at once as it may cause the page to be unusable, -especially on mobile screens. As of now, there is no automatic stacking of the toast queue items. It is the -responsibility of the developer to ensure that the Toast queue does not overflow the screen.** +By default, the Toast queue collapses when there are more than 3 ToastBars. To turn off this behavior and make the queue scrollable when it does not fit the screen, +set the `isCollapsible` prop to `false`. ⚠️ Please note that scrolling is not available on iOS devices due to a limitation in the WebKit engine. 👉 Please note that the initial scroll position is always at the **top** of the queue. +```jsx + + + +``` + +#### Toast Queue Limitations + +👉 Please note only the _visible_ ToastBar components are scrollable. Collapsed items are not accessible until visible +items are dismissed. + +👉 For the sake of simplicity, the collapsible items limit cannot be configured at the moment. + ### API -| Name | Type | Default | Required | Description | -| ------------ | ----------------------------------------------------------- | -------- | -------- | --------------------------------------- | -| `alignmentX` | [[AlignmentX dictionary][dictionary-alignment] \| `object`] | `center` | ✕ | Horizontal alignment of the toast queue | -| `alignmentY` | [`top` \| `bottom` \| `object`] | `bottom` | ✕ | Vertical alignment of the toast queue | +| Name | Type | Default | Required | Description | +| --------------- | ----------------------------------------------------------- | -------- | -------- | ----------------------------------------------------------------- | +| `alignmentX` | [[AlignmentX dictionary][dictionary-alignment] \| `object`] | `center` | ✕ | Horizontal alignment of the toast queue | +| `alignmentY` | [`top` \| `bottom` \| `object`] | `bottom` | ✕ | Vertical alignment of the toast queue | +| `isCollapsible` | `bool` | `true` | ✕ | If true, Toast queue collapses if there are more than 3 ToastBars | On top of the API options, the components accept [additional attributes][readme-additional-attributes]. If you need more control over the styling of a component, you can use [style props][readme-style-props] @@ -315,20 +331,23 @@ This hook returns: | Name | Type | Default | Description | | ---------- | ------------------------------------------------------------ | ---------- | --------------------------------------------------- | +| `clear` | `() => void` | () => {} | Function that will clear toast queue | | `color` | [[Emotion Color dictionary][dictionary-color] \| `inverted`] | `inverted` | Color variant | -| `hide` | `function` | () => {} | Function that will hide UncontrolledToast | +| `hide` | `(toastId) => void` | () => {} | Function that will hide UncontrolledToast | | `iconName` | `string` | — | Name of a custom icon to be shown along the message | | `id` | `string` | `''` | ToastBar ID | | `isOpen` | `bool` | `false` | Open state of UncontrolledToast | | `message` | [`string` \| `ReactNode`] | null | Message inside UncontrolledToast | -| `show` | `function` | () => {} | Function that will show UncontrolledToast | +| `show` | `(message, toastId, options?) => void` | () => {} | Function that will show UncontrolledToast | -#### How to use `showToast` function: +#### How to use `show` function: This function has two required parameters: message and ID. All other options are not required and can be omitted entirely. ```jsx +const { show } = useToast(); + ┌─⫸ Message inside UncontrolledToast (required) │ │ ┌─⫸ ToastBar ID (required) @@ -336,7 +355,7 @@ All other options are not required and can be omitted entirely. show('Toast message', 'toast-id', { color: 'danger', // Color variant, default: 'inverted' iconName: 'download', // Name of a custom icon to be shown along the message, default: undefined -}) +}); ``` ### API diff --git a/packages/web-react/src/components/Toast/Toast.tsx b/packages/web-react/src/components/Toast/Toast.tsx index 9a5b22a71a..2dceb602dd 100644 --- a/packages/web-react/src/components/Toast/Toast.tsx +++ b/packages/web-react/src/components/Toast/Toast.tsx @@ -2,10 +2,18 @@ import classNames from 'classnames'; import React from 'react'; import { useStyleProps } from '../../hooks'; import { SpiritToastProps } from '../../types'; +import { AlignmentX, AlignmentY } from '../../constants'; import { useToastStyleProps } from './useToastStyleProps'; +const defaultProps: SpiritToastProps = { + alignmentX: AlignmentX.CENTER, + alignmentY: AlignmentY.BOTTOM, + isCollapsible: true, +}; + const Toast = (props: SpiritToastProps) => { - const { children, alignmentX = 'center', alignmentY = 'bottom', ...restProps } = props; + const propsWithDefaults = { ...defaultProps, ...props }; + const { children, alignmentX, alignmentY, ...restProps } = propsWithDefaults; const { classProps, props: modifiedProps } = useToastStyleProps({ ...restProps, alignmentX, alignmentY }); const { styleProps, props: otherProps } = useStyleProps(modifiedProps); diff --git a/packages/web-react/src/components/Toast/ToastBar.tsx b/packages/web-react/src/components/Toast/ToastBar.tsx index 43b8e3dc94..0a25b9b635 100644 --- a/packages/web-react/src/components/Toast/ToastBar.tsx +++ b/packages/web-react/src/components/Toast/ToastBar.tsx @@ -47,19 +47,21 @@ const ToastBar = (props: SpiritToastBarProps) => { className={classNames(classProps.root, TRANSITIONING_STYLES[transitionState], styleProps.className)} ref={rootElementRef} > -
- {(hasIcon || iconName) && } -
{children}
-
+
+
+ {(hasIcon || iconName) && } +
{children}
+
- + +
)} diff --git a/packages/web-react/src/components/Toast/ToastContext.tsx b/packages/web-react/src/components/Toast/ToastContext.tsx index 9be160ea72..4650472ce6 100644 --- a/packages/web-react/src/components/Toast/ToastContext.tsx +++ b/packages/web-react/src/components/Toast/ToastContext.tsx @@ -1,31 +1,37 @@ import React, { FC, ReactNode, createContext, useCallback, useMemo, useReducer } from 'react'; import { ToastColorType } from '../../types'; -interface ToastState { +type ToastState = { + queue: ToastItem[]; +}; + +export interface ToastItem { color: ToastColorType | undefined; iconName: string | undefined; + hasIcon: boolean; + isDismissible: boolean; id: string; isOpen: boolean; - message: string | JSX.Element | null; + message: string | JSX.Element; } export interface ToastContextType extends ToastState { - show: (text: string | JSX.Element, id: string, options?: { color?: ToastColorType; iconName?: string }) => void; - hide: () => void; + clear: () => void; + hide: (id: string) => void; + setQueue: (newQueue: ToastItem[]) => void; + show: ( + text: string | JSX.Element, + id: string, + options?: { color?: ToastColorType; iconName?: string; hasIcon?: boolean; isDismissible?: boolean }, + ) => void; } -const initialToastState: ToastState = { - color: undefined, - iconName: undefined, - id: '', - isOpen: false, - message: null, -}; - const defaultToastContext: ToastContextType = { + clear: () => {}, hide: () => {}, + queue: [], + setQueue: () => {}, show: () => {}, - ...initialToastState, }; export const ToastContext = createContext(defaultToastContext); @@ -36,27 +42,41 @@ type ActionType = payload: { text: string | JSX.Element; toastId: string; - options?: { color?: ToastColorType; iconName?: string }; + options?: { color?: ToastColorType; iconName?: string; hasIcon?: boolean; isDismissible?: boolean }; }; } - | { type: 'hide'; payload: null }; + | { type: 'hide'; payload: { id: string } } + | { type: 'clear'; payload: null } + | { type: 'setQueue'; payload: { newQueue: ToastItem[] } }; const reducer = (state: ToastState, action: ActionType): ToastState => { const { type, payload } = action; + const { queue: currentQueue } = state; switch (type) { - case 'show': - return { - ...state, - message: payload?.text, - id: payload?.toastId, - color: payload?.options?.color || undefined, - iconName: payload?.options?.iconName || undefined, - isOpen: true, - }; + case 'show': { + const newQueue = [ + ...currentQueue, + { + color: payload.options?.color || undefined, + hasIcon: payload.options?.hasIcon || false, + iconName: payload.options?.iconName, + id: payload.toastId, + isDismissible: payload.options?.isDismissible || false, + isOpen: true, + message: payload.text, + }, + ]; + + return { queue: newQueue }; + } + + case 'hide': { + return { queue: currentQueue.filter((item) => item.id !== payload.id) }; + } - case 'hide': - return { ...state, isOpen: false }; + case 'clear': + return { queue: [] }; default: throw new Error(); @@ -67,31 +87,62 @@ interface ToastProviderProps { children: ReactNode; } +const initialToastState: ToastState = { + queue: [], +}; + export const ToastProvider: FC = ({ children }) => { - const [{ message, id, color, iconName, isOpen }, dispatch] = useReducer(reducer, initialToastState); + const [state, dispatch] = useReducer(reducer, initialToastState); + const { queue } = state; const show = useCallback( - (text: string | JSX.Element, toastId: string, options?: { color?: ToastColorType; iconName?: string }) => { + ( + text: string | JSX.Element, + toastId: string, + options?: { color?: ToastColorType; iconName?: string; hasIcon?: boolean; isDismissible?: boolean }, + ) => { dispatch({ type: 'show', payload: { text, toastId, options } }); }, [], ); - const hide = useCallback(() => { - dispatch({ type: 'hide', payload: null }); + const hide = useCallback((id: string) => { + dispatch({ type: 'hide', payload: { id } }); + }, []); + + const clear = useCallback(() => { + dispatch({ type: 'clear', payload: null }); + }, []); + + const setQueue = useCallback((newQueue: ToastItem[]) => { + dispatch({ type: 'clear', payload: null }); + + newQueue.forEach((item) => { + dispatch({ + type: 'show', + payload: { + text: item.message, + toastId: item.id, + options: { + color: item.color, + iconName: item.iconName, + hasIcon: item.hasIcon, + isDismissible: item.isDismissible, + }, + }, + }); + }); }, []); const contextValue = useMemo( () => ({ - color, + clear, hide, - iconName, - id, - isOpen, - message, + queue, + setQueue, show, }), - [color, hide, iconName, id, isOpen, message, show], + [hide, show, clear, queue.length, setQueue], ); return {children}; diff --git a/packages/web-react/src/components/Toast/UncontrolledToast.tsx b/packages/web-react/src/components/Toast/UncontrolledToast.tsx index 5cfa866d0e..1efe66c93c 100644 --- a/packages/web-react/src/components/Toast/UncontrolledToast.tsx +++ b/packages/web-react/src/components/Toast/UncontrolledToast.tsx @@ -4,30 +4,32 @@ import ToastBar from './ToastBar'; import { useToast } from './useToast'; import { UncontrolledToastProps } from '../../types'; -const defaultProps = { - isDismissible: true, -}; - const UncontrolledToast = (props: UncontrolledToastProps) => { - const propsWithDefaults = { ...defaultProps, ...props }; - const { alignmentX, alignmentY, closeLabel, hasIcon, isDismissible, ...restProps } = propsWithDefaults; - const { hide, color, iconName, message, id, isOpen } = useToast(); + const { alignmentX, alignmentY, closeLabel, ...restProps } = props; + const { hide, queue } = useToast(); return ( - - {message} - + {queue.map((item) => { + const { color, iconName, id, isOpen, message, hasIcon, isDismissible } = item; + + return ( + hide(id)} + isOpen={!!isOpen && !!message} + > + {message} + + ); + })} ); }; diff --git a/packages/web-react/src/components/Toast/__tests__/UncontrolledToast.test.tsx b/packages/web-react/src/components/Toast/__tests__/UncontrolledToast.test.tsx index 7df94858da..3be55b8b9f 100644 --- a/packages/web-react/src/components/Toast/__tests__/UncontrolledToast.test.tsx +++ b/packages/web-react/src/components/Toast/__tests__/UncontrolledToast.test.tsx @@ -4,14 +4,22 @@ import React from 'react'; import UncontrolledToast from '../UncontrolledToast'; import { ToastContext } from '../ToastContext'; -const defaultContextValue = { +const defaultToast = { id: 'test-id', message: 'Toast message', isOpen: false, iconName: undefined, + hasIcon: false, + isDismissible: false, color: undefined, +}; + +const defaultContextValue = { + queue: [defaultToast], hide: jest.fn(), show: jest.fn(), + clear: jest.fn(), + setQueue: jest.fn(), }; describe('UncontrolledToast', () => { @@ -30,7 +38,7 @@ describe('UncontrolledToast', () => { it('should render opened toast', () => { const contextValue = { ...defaultContextValue, - isOpen: true, + queue: [{ ...defaultToast, isOpen: true }], }; const dom = render( @@ -51,12 +59,12 @@ describe('UncontrolledToast', () => { it('should render opened toast with params', () => { const contextValue = { ...defaultContextValue, - isOpen: true, + queue: [{ ...defaultToast, isOpen: true, isDismissible: true, hasIcon: true }], }; const dom = render( - + , ); @@ -74,12 +82,12 @@ describe('UncontrolledToast', () => { it('should close toast when close button is clicked', () => { const contextValue = { ...defaultContextValue, - isOpen: true, + queue: [{ ...defaultToast, isOpen: true, isDismissible: true }], }; const dom = render( - + , ); diff --git a/packages/web-react/src/components/Toast/demo/ToastAlignment.tsx b/packages/web-react/src/components/Toast/demo/ToastAlignment.tsx deleted file mode 100644 index 0a0b26f29e..0000000000 --- a/packages/web-react/src/components/Toast/demo/ToastAlignment.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React, { ChangeEvent, useState } from 'react'; -import { AlignmentXDictionaryType, AlignmentYDictionaryType } from '../../../types'; -import { Button } from '../../Button'; -import { Link } from '../../Link'; -import { Radio } from '../../Radio'; -import { TextField } from '../../TextField'; -import Toast from '../Toast'; -import ToastBar from '../ToastBar'; - -const ToastAlignment = () => { - const [isOpenFirst, setIsOpenFirst] = React.useState(true); - const [isOpenSecond, setIsOpenSecond] = React.useState(true); - const [isOpenThird, setIsOpenThird] = React.useState(false); - const [alignmentY, setAlignmentY] = useState('bottom'); - const [alignmentX, setAlignmentX] = useState('center'); - - const buttonLabel = isOpenThird ? 'Hide the showed toast' : 'Show the hidden toast'; - - const handleAlignmentYChange = (e: ChangeEvent) => { - setAlignmentY(e.target.value as AlignmentYDictionaryType); - }; - - const handleAlignmentXChange = (e: ChangeEvent) => { - setAlignmentX(e.target.value as AlignmentXDictionaryType); - }; - - // Experimental, Chrome 94+ on Android only: - // Enable CSS to detect the presence of virtual keyboard. - if ('virtualKeyboard' in navigator) { - // @ts-expect-error 'navigator.virtualKeyboard' is of type 'unknown'. - navigator.virtualKeyboard.overlaysContent = true; - } - - return ( - <> -
-
- Vertical alignment: - {' '} - -
- -
- Horizontal alignment: - {' '} - {' '} - -
-
- -
-
- Virtual keyboard interaction: - -
-
- -
- Show the toast prepared in the DOM: - -
- - - setIsOpenFirst(false)} - color="success" - hasIcon - isDismissible - > - I was first! - - Action - - - setIsOpenSecond(false)} - color="informative" - hasIcon - isDismissible - > - I appeared later. This toast has a long message that wraps automatically. - - Action - - - setIsOpenThird(false)} - color="warning" - hasIcon - isDismissible - > - I was hidden and you exposed me! - - - - ); -}; - -export default ToastAlignment; diff --git a/packages/web-react/src/components/Toast/demo/ToastDynamicToastQueue.tsx b/packages/web-react/src/components/Toast/demo/ToastDynamicToastQueue.tsx new file mode 100644 index 0000000000..ecf63c5dbc --- /dev/null +++ b/packages/web-react/src/components/Toast/demo/ToastDynamicToastQueue.tsx @@ -0,0 +1,247 @@ +import React, { ChangeEvent, useEffect, useState } from 'react'; +import { AlignmentXDictionaryType, AlignmentYDictionaryType, ToastColorType } from '../../../types'; +import { Button } from '../../Button'; +import { Checkbox } from '../../Checkbox'; +import { Stack } from '../../Stack'; +import { Select } from '../../Select'; +import { TextArea } from '../../TextArea'; +import { Radio } from '../../Radio'; +import { Link } from '../../Link'; +import { TextField } from '../../TextField'; +import Toast from '../Toast'; +import ToastBar from '../ToastBar'; +import { useToast } from '../useToast'; +import { ToastItem, ToastProvider } from '../ToastContext'; + +const ToastDynamicToastQueue = () => { + const [isCollapsible, setIsCollapsible] = useState(true); + const [alignmentY, setAlignmentY] = useState('bottom'); + const [alignmentX, setAlignmentX] = useState('center'); + const [colorValue, setColorValue] = useState('inverted'); + const [hasIconValue, setHasIconValue] = useState(true); + const [isDismissibleValue, setIsDismissibleValue] = useState(true); + const [messageValue, setMessageValue] = useState('This is a new toast message.'); + + const { queue, show, hide, clear, setQueue } = useToast(); + + const defaultToastQueue: ToastItem[] = [ + { + id: '1', + isOpen: true, + message: ( + <> + I was first!{' '} + + Action + + + ), + color: 'success', + hasIcon: true, + isDismissible: true, + iconName: undefined, + }, + { + id: '2', + message: ( + <> + I appeared later. This toast has a long message that wraps automatically.{' '} + + Action + + + ), + isOpen: true, + color: 'informative', + hasIcon: true, + isDismissible: true, + iconName: undefined, + }, + ]; + + useEffect(() => { + setQueue(defaultToastQueue); + }, []); + + const handleAlignmentYChange = (event: ChangeEvent) => { + setAlignmentY(event.target.value as AlignmentYDictionaryType); + }; + + const handleAlignmentXChange = (event: ChangeEvent) => { + setAlignmentX(event.target.value as AlignmentXDictionaryType); + }; + + // Experimental, Chrome 94+ on Android only: + // Enable CSS to detect the presence of virtual keyboard. + if ('virtualKeyboard' in navigator) { + // @ts-expect-error 'navigator.virtualKeyboard' is of type 'unknown'. + navigator.virtualKeyboard.overlaysContent = true; + } + + return ( + <> +
+
+ setIsCollapsible(!isCollapsible)} + /> +
+
+ Vertical alignment: + {' '} + +
+ +
+ Horizontal alignment: + {' '} + {' '} + +
+
+ +
+
+ New Toast: + + + setHasIconValue(!hasIconValue)} + /> + setIsDismissibleValue(!isDismissibleValue)} + /> +