feat: toast component (1677) (#1998)
This commit is contained in:
parent
e598cd1247
commit
a3df65952d
@ -1,6 +1,24 @@
|
||||
module.exports = {
|
||||
stories: [],
|
||||
addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'],
|
||||
addons: [
|
||||
'@storybook/addon-actions',
|
||||
'@storybook/addon-viewport',
|
||||
{
|
||||
name: '@storybook/addon-docs',
|
||||
options: {
|
||||
configureJSX: true,
|
||||
babelOptions: {},
|
||||
sourceLoaderOptions: null,
|
||||
transcludeMarkdown: true,
|
||||
},
|
||||
},
|
||||
'@storybook/addon-controls',
|
||||
'@storybook/addon-backgrounds',
|
||||
'@storybook/addon-toolbars',
|
||||
'@storybook/addon-measure',
|
||||
'@storybook/addon-outline',
|
||||
'@storybook/addon-a11y',
|
||||
],
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -13,7 +13,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"lib": ["es5", "es6", "dom", "dom.iterable"]
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -13,7 +13,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"lib": ["es5", "es6", "dom", "dom.iterable"]
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -1,6 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -13,5 +13,6 @@
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "next-env.d.ts"],
|
||||
"exclude": ["node_modules", "jest.config.ts"]
|
||||
"exclude": ["node_modules", "jest.config.ts"],
|
||||
"files": ["../../node_modules/next/dist/client/image.d.ts"]
|
||||
}
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -1,6 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -1,6 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -9,7 +9,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -10,7 +10,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -37,3 +37,4 @@ export * from './tooltip';
|
||||
export * from './vega-icons';
|
||||
export * from './vega-logo';
|
||||
export * from './traffic-light';
|
||||
export * from './toast';
|
||||
|
3
libs/ui-toolkit/src/components/toast/index.tsx
Normal file
3
libs/ui-toolkit/src/components/toast/index.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './toast';
|
||||
export * from './toasts-container';
|
||||
export * from './use-toasts';
|
20
libs/ui-toolkit/src/components/toast/toast.module.css
Normal file
20
libs/ui-toolkit/src/components/toast/toast.module.css
Normal file
@ -0,0 +1,20 @@
|
||||
.initial {
|
||||
top: 20px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
border: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.showing {
|
||||
opacity: 1;
|
||||
transition: all 0.3s;
|
||||
max-height: 100vw;
|
||||
}
|
||||
|
||||
.expired {
|
||||
right: -375px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transition: all 0.75s;
|
||||
}
|
59
libs/ui-toolkit/src/components/toast/toast.stories.tsx
Normal file
59
libs/ui-toolkit/src/components/toast/toast.stories.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
/* eslint-disable jsx-a11y/accessible-emoji */
|
||||
import { Toast } from './toast';
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { Intent } from '../../utils/intent';
|
||||
|
||||
export default {
|
||||
title: 'Toast',
|
||||
component: Toast,
|
||||
} as ComponentMeta<typeof Toast>;
|
||||
|
||||
const Template: ComponentStory<typeof Toast> = (args) => {
|
||||
return (
|
||||
<Toast
|
||||
{...args}
|
||||
render={() => (
|
||||
<>
|
||||
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
|
||||
<p>Eaque exercitationem saepe cupiditate sunt impedit.</p>
|
||||
<p>I really like 🥪🥪🥪!</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
id: 'def',
|
||||
intent: Intent.None,
|
||||
state: 'showing',
|
||||
};
|
||||
|
||||
export const Primary = Template.bind({});
|
||||
Primary.args = {
|
||||
id: 'pri',
|
||||
intent: Intent.Primary,
|
||||
state: 'showing',
|
||||
};
|
||||
|
||||
export const Danger = Template.bind({});
|
||||
Danger.args = {
|
||||
id: 'dan',
|
||||
intent: Intent.Danger,
|
||||
state: 'showing',
|
||||
};
|
||||
|
||||
export const Warning = Template.bind({});
|
||||
Warning.args = {
|
||||
id: 'war',
|
||||
intent: Intent.Warning,
|
||||
state: 'showing',
|
||||
};
|
||||
|
||||
export const Success = Template.bind({});
|
||||
Success.args = {
|
||||
id: 'suc',
|
||||
intent: Intent.Success,
|
||||
state: 'showing',
|
||||
};
|
132
libs/ui-toolkit/src/components/toast/toast.tsx
Normal file
132
libs/ui-toolkit/src/components/toast/toast.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import styles from './toast.module.css';
|
||||
|
||||
import type { IconName } from '@blueprintjs/icons';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Intent } from '../../utils/intent';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
type ToastContentProps = { id: string };
|
||||
type ToastContent = (props: ToastContentProps) => JSX.Element;
|
||||
|
||||
type ToastState = 'initial' | 'showing' | 'expired';
|
||||
|
||||
export type Toast = {
|
||||
id: string;
|
||||
intent: Intent;
|
||||
render: ToastContent;
|
||||
closeAfter?: number;
|
||||
signal?: 'close';
|
||||
};
|
||||
|
||||
type ToastProps = Toast & {
|
||||
state?: ToastState;
|
||||
onClose?: (id: string) => void;
|
||||
};
|
||||
|
||||
const toastIconMapping: { [i in Intent]: IconName } = {
|
||||
[Intent.None]: IconNames.HELP,
|
||||
[Intent.Primary]: IconNames.INFO_SIGN,
|
||||
[Intent.Success]: IconNames.TICK_CIRCLE,
|
||||
[Intent.Warning]: IconNames.ERROR,
|
||||
[Intent.Danger]: IconNames.ERROR,
|
||||
};
|
||||
|
||||
const getToastAccent = (intent: Intent) => ({
|
||||
// strip
|
||||
'bg-gray-200 text-black text-opacity-70': intent === Intent.None,
|
||||
'bg-vega-blue text-white text-opacity-70': intent === Intent.Primary,
|
||||
'bg-success text-white text-opacity-70': intent === Intent.Success,
|
||||
'bg-warning text-white text-opacity-70': intent === Intent.Warning,
|
||||
'bg-vega-pink text-white text-opacity-70': intent === Intent.Danger,
|
||||
});
|
||||
|
||||
export const CLOSE_DELAY = 750;
|
||||
|
||||
export const Toast = ({
|
||||
id,
|
||||
intent,
|
||||
render,
|
||||
closeAfter,
|
||||
signal,
|
||||
state = 'initial',
|
||||
onClose,
|
||||
}: ToastProps) => {
|
||||
const toastRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const closeToast = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (toastRef.current?.classList.contains(styles['showing'])) {
|
||||
toastRef.current.classList.remove(styles['showing']);
|
||||
toastRef.current.classList.add(styles['expired']);
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
onClose?.(id);
|
||||
}, CLOSE_DELAY);
|
||||
}, [id, onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const req = requestAnimationFrame(() => {
|
||||
if (toastRef.current?.classList.contains(styles['initial'])) {
|
||||
toastRef.current.classList.remove(styles['initial']);
|
||||
toastRef.current.classList.add(styles['showing']);
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(req);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
let t: NodeJS.Timeout;
|
||||
if (closeAfter && closeAfter > 0) {
|
||||
t = setTimeout(() => {
|
||||
closeToast();
|
||||
}, closeAfter);
|
||||
}
|
||||
return () => clearTimeout(t);
|
||||
}, [closeAfter, closeToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (signal === 'close') {
|
||||
closeToast();
|
||||
}
|
||||
}, [closeToast, signal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-toast-id={id}
|
||||
ref={toastRef}
|
||||
className={classNames(
|
||||
'relative w-[300px] top-0 right-0 rounded-md border overflow-hidden mb-2',
|
||||
'text-black bg-white dark:border-zinc-700',
|
||||
{
|
||||
[styles['initial']]: state === 'initial',
|
||||
[styles['showing']]: state === 'showing',
|
||||
[styles['expired']]: state === 'expired',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex relative">
|
||||
<button
|
||||
data-testid="toast-close"
|
||||
onClick={closeToast}
|
||||
className="absolute p-2 top-0 right-0"
|
||||
>
|
||||
<Icon name="cross" size={3} className="!block" />
|
||||
</button>
|
||||
<div
|
||||
className={classNames(getToastAccent(intent), 'p-2 pt-3 text-center')}
|
||||
>
|
||||
<Icon name={toastIconMapping[intent]} size={4} className="!block" />
|
||||
</div>
|
||||
<div className="flex-1 p-2 pr-6 text-sm" data-testid="toast-content">
|
||||
{render({ id })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
136
libs/ui-toolkit/src/components/toast/toasts-container.spec.tsx
Normal file
136
libs/ui-toolkit/src/components/toast/toasts-container.spec.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||
import { CLOSE_DELAY, ToastsContainer, useToasts } from '..';
|
||||
import { Intent } from '../../utils/intent';
|
||||
|
||||
describe('ToastsContainer', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useToasts((state) => state.removeAll));
|
||||
act(() => result.current());
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
it('displays a list of toasts in ascending order', () => {
|
||||
const { baseElement } = render(<ToastsContainer order="asc" />);
|
||||
const { result } = renderHook(() => useToasts((state) => state.add));
|
||||
const add = result.current;
|
||||
act(() => {
|
||||
add({
|
||||
id: 'toast-a',
|
||||
intent: Intent.None,
|
||||
render: () => <p>A</p>,
|
||||
});
|
||||
add({
|
||||
id: 'toast-b',
|
||||
intent: Intent.None,
|
||||
render: () => <p>B</p>,
|
||||
});
|
||||
add({
|
||||
id: 'toast-c',
|
||||
intent: Intent.None,
|
||||
render: () => <p>C</p>,
|
||||
});
|
||||
});
|
||||
const toasts = [...screen.queryAllByTestId('toast-content')].map((t) =>
|
||||
t.textContent?.trim()
|
||||
);
|
||||
expect(toasts).toEqual(['A', 'B', 'C']);
|
||||
expect(baseElement.classList).not.toContain('flex-col-reverse');
|
||||
});
|
||||
it('displays a list of toasts in descending order', () => {
|
||||
const { baseElement } = render(<ToastsContainer order="desc" />);
|
||||
const { result } = renderHook(() => useToasts((state) => state.add));
|
||||
const add = result.current;
|
||||
act(() => {
|
||||
add({
|
||||
id: 'toast-a',
|
||||
intent: Intent.None,
|
||||
render: () => <p>A</p>,
|
||||
});
|
||||
add({
|
||||
id: 'toast-b',
|
||||
intent: Intent.None,
|
||||
render: () => <p>B</p>,
|
||||
});
|
||||
add({
|
||||
id: 'toast-c',
|
||||
intent: Intent.None,
|
||||
render: () => <p>C</p>,
|
||||
});
|
||||
});
|
||||
const toasts = [...screen.queryAllByTestId('toast-content')].map((t) =>
|
||||
t.textContent?.trim()
|
||||
);
|
||||
expect(toasts).toEqual(['A', 'B', 'C']);
|
||||
expect(baseElement.classList).not.toContain('flex-col-reverse');
|
||||
});
|
||||
it('closes a toast after clicking on "Close" button', () => {
|
||||
const { baseElement } = render(<ToastsContainer order="asc" />);
|
||||
const { result } = renderHook(() => useToasts((state) => state.add));
|
||||
const add = result.current;
|
||||
act(() => {
|
||||
add({
|
||||
id: 'toast-a',
|
||||
intent: Intent.None,
|
||||
render: () => <p>A</p>,
|
||||
});
|
||||
add({
|
||||
id: 'toast-b',
|
||||
intent: Intent.None,
|
||||
render: () => <p>B</p>,
|
||||
});
|
||||
});
|
||||
const closeBtn = baseElement.querySelector(
|
||||
'[data-testid="toast-close"]'
|
||||
) as HTMLButtonElement;
|
||||
act(() => {
|
||||
closeBtn.click();
|
||||
jest.runAllTimers();
|
||||
});
|
||||
const toasts = [...screen.queryAllByTestId('toast-content')].map((t) =>
|
||||
t.textContent?.trim()
|
||||
);
|
||||
expect(toasts).toEqual(['B']);
|
||||
});
|
||||
it('auto-closes a toast after given time', () => {
|
||||
render(<ToastsContainer order="asc" />);
|
||||
const { result } = renderHook(() => useToasts((state) => state.add));
|
||||
const add = result.current;
|
||||
act(() => {
|
||||
add({
|
||||
id: 'toast-a',
|
||||
intent: Intent.None,
|
||||
render: () => <p>A</p>,
|
||||
closeAfter: 1000,
|
||||
});
|
||||
add({
|
||||
id: 'toast-b',
|
||||
intent: Intent.None,
|
||||
render: () => <p>B</p>,
|
||||
closeAfter: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000 + CLOSE_DELAY);
|
||||
});
|
||||
expect(
|
||||
[...screen.queryAllByTestId('toast-content')].map((t) =>
|
||||
t.textContent?.trim()
|
||||
)
|
||||
).toEqual(['B']);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000 + CLOSE_DELAY);
|
||||
});
|
||||
expect(
|
||||
[...screen.queryAllByTestId('toast-content')].map((t) =>
|
||||
t.textContent?.trim()
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
@ -0,0 +1,197 @@
|
||||
/* eslint-disable jsx-a11y/accessible-emoji */
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { Intent } from '../../utils/intent';
|
||||
import type { Toast } from './toast';
|
||||
import { ToastsContainer } from './toasts-container';
|
||||
import random from 'lodash/random';
|
||||
import sample from 'lodash/sample';
|
||||
import uniqueId from 'lodash/uniqueId';
|
||||
import { useToasts } from './use-toasts';
|
||||
import create from 'zustand';
|
||||
import { useEffect } from '@storybook/addons';
|
||||
import { formatNumber } from '@vegaprotocol/react-helpers';
|
||||
|
||||
export default {
|
||||
title: 'ToastContainer',
|
||||
component: ToastsContainer,
|
||||
} as ComponentMeta<typeof ToastsContainer>;
|
||||
|
||||
const contents = [
|
||||
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Eaque consequatur minima fugit dolorum assumenda maxime. ',
|
||||
'Sint ut inventore voluptatem eos consectetur nesciunt corporis repudiandae fuga mollitia sit officia eum, ab hic nobis, velit et rem est vero!',
|
||||
'Veritatis sit adipisci est inventore id maiores eaque!',
|
||||
'Exercitationem, voluptatem voluptates animi est culpa dolorem sint, dicta aspernatur accusamus voluptatibus excepturi eius.',
|
||||
'Fuga assumenda minus maiores dolor asperiores, error molestiae aperiam porro consequuntur soluta earum enim exercitationem.',
|
||||
'Consequatur, voluptas sint ducimus excepturi sit totam itaque qui praesentium nobis optio blanditiis repellendus sunt ullam quaerat iste exercitationem fugiat fuga. Quia!',
|
||||
];
|
||||
|
||||
const randomWords = [
|
||||
'advertise',
|
||||
'therapist',
|
||||
'toss',
|
||||
'beam',
|
||||
'worm',
|
||||
'solo',
|
||||
'soldier',
|
||||
'photography',
|
||||
'accountant',
|
||||
'satisfaction',
|
||||
'think',
|
||||
'suppress',
|
||||
'sentiment',
|
||||
'arise',
|
||||
'grant',
|
||||
'greeting',
|
||||
'diagram',
|
||||
'switch',
|
||||
'opposition',
|
||||
'destruction',
|
||||
'flush',
|
||||
'decline',
|
||||
'banana',
|
||||
'emotion',
|
||||
'inject',
|
||||
'avant-garde',
|
||||
'fill',
|
||||
'decay',
|
||||
'wound',
|
||||
'shelter',
|
||||
];
|
||||
|
||||
const randomToast = (): Toast => {
|
||||
const content = sample(contents);
|
||||
return {
|
||||
id: String(uniqueId('toast_')),
|
||||
intent: sample<Intent>([
|
||||
Intent.None,
|
||||
Intent.Primary,
|
||||
Intent.Warning,
|
||||
Intent.Danger,
|
||||
Intent.Success,
|
||||
]) as Intent,
|
||||
render: () => <p>{content}</p>,
|
||||
closeAfter: sample([undefined, random(1000, 5000)]),
|
||||
};
|
||||
};
|
||||
|
||||
type PriceStore = { price: number; setPrice: (p: number) => void };
|
||||
const usePrice = create<PriceStore>((set) => ({
|
||||
price: 0,
|
||||
setPrice: (p) => set((state) => ({ price: p })),
|
||||
}));
|
||||
|
||||
const Template: ComponentStory<typeof ToastsContainer> = (args) => {
|
||||
const setPrice = usePrice((state) => state.setPrice);
|
||||
|
||||
const { add, close, closeAll, update } = useToasts((state) => ({
|
||||
add: state.add,
|
||||
close: state.close,
|
||||
closeAll: state.closeAll,
|
||||
update: state.update,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => {
|
||||
setPrice(random(0, 30, true));
|
||||
}, 1000);
|
||||
return () => clearInterval(i);
|
||||
}, [setPrice]);
|
||||
|
||||
const addRandomToast = () => add(randomToast());
|
||||
const addRandomToastWithAction = () => {
|
||||
const t = randomToast();
|
||||
const words = [
|
||||
sample(randomWords),
|
||||
sample(randomWords),
|
||||
sample(randomWords),
|
||||
];
|
||||
add({
|
||||
...t,
|
||||
render: ({ id }) => (
|
||||
<>
|
||||
<h1>{words[0]}</h1>
|
||||
<div>
|
||||
<button
|
||||
className="underline text-gray-600 mr-2"
|
||||
onClick={() => setTimeout(() => close(id), 500)}
|
||||
>
|
||||
{words[1]}
|
||||
</button>
|
||||
<button
|
||||
className="underline text-gray-600"
|
||||
onClick={() =>
|
||||
update(id, {
|
||||
intent: sample([
|
||||
Intent.Danger,
|
||||
Intent.Warning,
|
||||
Intent.Success,
|
||||
]) as Intent,
|
||||
})
|
||||
}
|
||||
>
|
||||
{words[2]}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const addRandomToastWithUpdatingData = () => {
|
||||
const t = randomToast();
|
||||
const ToastContent = () => {
|
||||
const { price } = usePrice();
|
||||
const getIcon = () => {
|
||||
if (price === 0) return '🤷';
|
||||
if (price > 20) return '💰';
|
||||
if (price > 10) return '📈';
|
||||
if (price < 10) return '📉';
|
||||
return '';
|
||||
};
|
||||
return (
|
||||
<p className="text-3xl font-mono">
|
||||
{getIcon()} {formatNumber(price, 5)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
add({
|
||||
...t,
|
||||
render: () => <ToastContent />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="bg-gray-200 dark:bg-gray-800 p-2 mr-2"
|
||||
onClick={() => addRandomToast()}
|
||||
>
|
||||
🥪
|
||||
</button>
|
||||
<button
|
||||
className="bg-orange-200 dark:bg-orange-800 p-2 mr-2"
|
||||
onClick={() => addRandomToastWithAction()}
|
||||
>
|
||||
🎬 + 🥪
|
||||
</button>
|
||||
<button
|
||||
className="bg-purple-200 dark:bg-purple-800 p-2 mr-2"
|
||||
onClick={() => addRandomToastWithUpdatingData()}
|
||||
>
|
||||
📈 + 🥪
|
||||
</button>
|
||||
<button
|
||||
className="bg-red-200 dark:bg-red-800 p-2 mr-2"
|
||||
onClick={() => closeAll()}
|
||||
>
|
||||
🧽
|
||||
</button>
|
||||
<ToastsContainer {...args} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
order: 'asc',
|
||||
};
|
37
libs/ui-toolkit/src/components/toast/toasts-container.tsx
Normal file
37
libs/ui-toolkit/src/components/toast/toasts-container.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import classNames from 'classnames';
|
||||
import { useCallback } from 'react';
|
||||
import { Toast } from './toast';
|
||||
import { useToasts } from './use-toasts';
|
||||
|
||||
type ToastsContainerProps = {
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export const ToastsContainer = ({ order = 'asc' }: ToastsContainerProps) => {
|
||||
const { toasts, remove } = useToasts();
|
||||
const onClose = useCallback(
|
||||
(id: string) => {
|
||||
remove(id);
|
||||
},
|
||||
[remove]
|
||||
);
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={classNames(
|
||||
'absolute top-2 right-2 overflow-hidden max-w-full',
|
||||
{
|
||||
'flex flex-col-reverse': order === 'desc',
|
||||
}
|
||||
)}
|
||||
>
|
||||
{toasts.map((toast) => {
|
||||
return (
|
||||
<li key={toast.id}>
|
||||
<Toast onClose={onClose} {...toast} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
64
libs/ui-toolkit/src/components/toast/use-toasts.ts
Normal file
64
libs/ui-toolkit/src/components/toast/use-toasts.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import create from 'zustand';
|
||||
import type { Toast } from './toast';
|
||||
|
||||
type ToastsStore = {
|
||||
/**
|
||||
* A list of active toasts
|
||||
*/
|
||||
toasts: Toast[];
|
||||
/**
|
||||
* Adds/displays a new toast
|
||||
*/
|
||||
add: (toast: Toast) => void;
|
||||
/**
|
||||
* Updates a toast
|
||||
*/
|
||||
update: (id: string, toastData: Partial<Toast>) => void;
|
||||
/**
|
||||
* Closes a toast
|
||||
*/
|
||||
close: (id: string) => void;
|
||||
/**
|
||||
* Closes all toasts
|
||||
*/
|
||||
closeAll: () => void;
|
||||
/**
|
||||
* Arbitrary removes a toast
|
||||
*/
|
||||
remove: (id: string) => void;
|
||||
/**
|
||||
* Arbitrary removes all toasts
|
||||
*/
|
||||
removeAll: () => void;
|
||||
};
|
||||
|
||||
const update =
|
||||
(id: string, toastData: Partial<Toast>) =>
|
||||
(store: ToastsStore): Partial<ToastsStore> => {
|
||||
const toasts = [...store.toasts];
|
||||
const toastIdx = toasts.findIndex((t) => t.id === id);
|
||||
if (toastIdx > -1) toasts[toastIdx] = { ...toasts[toastIdx], ...toastData };
|
||||
return { toasts };
|
||||
};
|
||||
|
||||
export const useToasts = create<ToastsStore>((set) => ({
|
||||
toasts: [],
|
||||
add: (toast) =>
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, toast],
|
||||
})),
|
||||
update: (id, toastData) => set(update(id, toastData)),
|
||||
close: (id) => set(update(id, { signal: 'close' })),
|
||||
closeAll: () =>
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts].map((t) => ({ ...t, signal: 'close' })),
|
||||
})),
|
||||
remove: (id) =>
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts].filter((t) => t.id !== id),
|
||||
})),
|
||||
removeAll: () =>
|
||||
set(() => ({
|
||||
toasts: [],
|
||||
})),
|
||||
}));
|
@ -1,3 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -15,6 +15,7 @@
|
||||
"**/*.test.jsx",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.d.ts",
|
||||
"jest.config.ts"
|
||||
"jest.config.ts",
|
||||
"../../index.d.ts"
|
||||
]
|
||||
}
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,7 +12,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
|
@ -12,8 +12,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
|
@ -4,10 +4,6 @@
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [
|
||||
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
|
||||
"../../node_modules/@nrwl/react/typings/image.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
|
@ -113,6 +113,7 @@
|
||||
"@nrwl/workspace": "14.5.10",
|
||||
"@sentry/webpack-plugin": "^1.18.8",
|
||||
"@storybook/addon-a11y": "^6.4.19",
|
||||
"@storybook/addon-docs": "^6.5.13",
|
||||
"@storybook/addon-essentials": "6.5.10",
|
||||
"@storybook/builder-webpack5": "6.5.10",
|
||||
"@storybook/core-server": "6.5.10",
|
||||
|
@ -48,5 +48,9 @@
|
||||
"@vegaprotocol/withdraws": ["libs/withdraws/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "tmp"]
|
||||
"exclude": ["node_modules", "tmp"],
|
||||
"files": [
|
||||
"node_modules/@nrwl/react/typings/cssmodule.d.ts",
|
||||
"node_modules/@nrwl/react/typings/image.d.ts"
|
||||
]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user