Skip to content

Commit

Permalink
Feat(web-react): Introduce stacking of the Toast queue #DS-1209
Browse files Browse the repository at this point in the history
- Make Toast work with dynamic collapsible queue and dynamic ToastBars
- solves DS-1209
  • Loading branch information
pavelklibani committed May 13, 2024
1 parent ac409c5 commit fb152be
Show file tree
Hide file tree
Showing 15 changed files with 522 additions and 262 deletions.
8 changes: 4 additions & 4 deletions apps/web-twig-demo/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
47 changes: 33 additions & 14 deletions packages/web-react/src/components/Toast/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 isCollapsible={false}>
<!-- ToastBar components go here -->
</Toast>
```

#### 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]
Expand Down Expand Up @@ -315,28 +331,31 @@ 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)
│ │
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
Expand Down
10 changes: 9 additions & 1 deletion packages/web-react/src/components/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
26 changes: 14 additions & 12 deletions packages/web-react/src/components/Toast/ToastBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,21 @@ const ToastBar = (props: SpiritToastBarProps) => {
className={classNames(classProps.root, TRANSITIONING_STYLES[transitionState], styleProps.className)}
ref={rootElementRef}
>
<div className={classProps.content}>
{(hasIcon || iconName) && <Icon name={toastIconName} boxSize={ICON_BOX_SIZE} />}
<div className={classProps.message}>{children}</div>
</div>
<div className={classProps.box}>
<div className={classProps.content}>
{(hasIcon || iconName) && <Icon name={toastIconName} boxSize={ICON_BOX_SIZE} />}
<div className={classProps.message}>{children}</div>
</div>

<ToastCloseButton
id={id}
color={color}
isOpen={isOpen}
closeLabel={closeLabel}
onClose={onClose}
isDismissible={isDismissible}
/>
<ToastCloseButton
id={id}
color={color}
isOpen={isOpen}
closeLabel={closeLabel}
onClose={onClose}
isDismissible={isDismissible}
/>
</div>
</div>
)}
</Transition>
Expand Down
123 changes: 87 additions & 36 deletions packages/web-react/src/components/Toast/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastContextType>(defaultToastContext);
Expand All @@ -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();
Expand All @@ -67,31 +87,62 @@ interface ToastProviderProps {
children: ReactNode;
}

const initialToastState: ToastState = {
queue: [],
};

export const ToastProvider: FC<ToastProviderProps> = ({ 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 <ToastContext.Provider value={contextValue}>{children}</ToastContext.Provider>;
Expand Down
42 changes: 22 additions & 20 deletions packages/web-react/src/components/Toast/UncontrolledToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Toast alignmentX={alignmentX} alignmentY={alignmentY}>
<ToastBar
id={id}
closeLabel={closeLabel}
color={color}
hasIcon={hasIcon}
iconName={iconName}
isDismissible={isDismissible}
onClose={hide}
isOpen={!!isOpen && !!message}
{...restProps}
>
{message}
</ToastBar>
{queue.map((item) => {
const { color, iconName, id, isOpen, message, hasIcon, isDismissible } = item;

return (
<ToastBar
{...restProps}
key={id}
id={id}
closeLabel={closeLabel}
color={color}
hasIcon={hasIcon}
iconName={iconName}
isDismissible={isDismissible}
onClose={() => hide(id)}
isOpen={!!isOpen && !!message}
>
{message}
</ToastBar>
);
})}
</Toast>
);
};
Expand Down
Loading

0 comments on commit fb152be

Please sign in to comment.