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 14, 2024
1 parent ac409c5 commit 3091202
Show file tree
Hide file tree
Showing 15 changed files with 562 additions and 278 deletions.
70 changes: 45 additions & 25 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 @@ -302,7 +318,7 @@ const { show } = useToast(); // must be inside ToastProvider
Show Toast
</Button>

<UncontrolledToast alignmentX="right" alignmentY="top" closeLabel="Close toast" hasIcon isDismissible />
<UncontrolledToast alignmentX="right" alignmentY="top" closeLabel="Close toast" hasIcon isDismissible isCollapsible />
</ToastProvider>;
```

Expand All @@ -315,44 +331,48 @@ 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
})
hasIcon: true // If true, an icon is shown along the message, default: false \*
isDismissible: true // If true, ToastBar can be dismissed by user, default: false
});
```

### 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 |
| `closeLabel` | `string` | `Close` || Close label |
| `hasIcon` | `bool` | `false` \* || If true, an icon is shown along the message |
| `isDismissible` | `bool` | `true` || If true, ToastBar can be dismissed by user |

(\*) For each emotion color, a default icon is defined.
The icons come from the [Icon package][icon-package], or from your custom source of icons.
Read the section [Default Icons according to Color Variant](#default-icons-according-to-color-variant).

### 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 |
| `closeLabel` | `string` | `Close` || Close label |
| `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]
and [escape hatches][readme-escape-hatches].
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
Loading

0 comments on commit 3091202

Please sign in to comment.