401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import styles from './toast.module.css';
|
|
|
|
import type { IconName } from '@blueprintjs/icons';
|
|
import { IconNames } from '@blueprintjs/icons';
|
|
import classNames from 'classnames';
|
|
import type { HTMLAttributes, HtmlHTMLAttributes, ReactNode } from 'react';
|
|
import { useState } from 'react';
|
|
import { forwardRef, useEffect } from 'react';
|
|
import { useCallback } from 'react';
|
|
import { useLayoutEffect } from 'react';
|
|
import { useRef } from 'react';
|
|
import { Intent } from '../../utils/intent';
|
|
import { Icon, VegaIcon, VegaIconNames } from '../icon';
|
|
import { Loader } from '../loader';
|
|
import { t } from '@vegaprotocol/i18n';
|
|
|
|
export type ToastContent = JSX.Element | undefined;
|
|
|
|
type ToastState = 'initial' | 'showing' | 'expired';
|
|
|
|
type WithdrawalInfoMeta = { withdrawalId: string | undefined };
|
|
|
|
export type Toast = {
|
|
id: string;
|
|
intent: Intent;
|
|
content: ToastContent;
|
|
closeAfter?: number;
|
|
onClose?: () => void;
|
|
signal?: 'close';
|
|
loader?: boolean;
|
|
hidden?: boolean;
|
|
// meta information
|
|
meta?: WithdrawalInfoMeta | undefined;
|
|
};
|
|
|
|
type ToastProps = Toast & {
|
|
state?: ToastState;
|
|
};
|
|
|
|
export const toastIconMapping: { [i in Intent]: IconName } = {
|
|
[Intent.None]: IconNames.HELP,
|
|
[Intent.Primary]: IconNames.INFO_SIGN,
|
|
[Intent.Info]: IconNames.INFO_SIGN,
|
|
[Intent.Success]: IconNames.TICK_CIRCLE,
|
|
[Intent.Warning]: IconNames.WARNING_SIGN,
|
|
[Intent.Danger]: IconNames.ERROR,
|
|
};
|
|
|
|
export const CLOSE_DELAY = 500;
|
|
export const TICKER = 100;
|
|
export const CLOSE_AFTER = 5000;
|
|
|
|
export const Panel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
({ children, className, ...props }, ref) => {
|
|
return (
|
|
<div
|
|
data-panel
|
|
ref={ref}
|
|
data-testid="toast-panel"
|
|
className={classNames(
|
|
'p-2 rounded mt-[10px]',
|
|
'font-mono text-[12px] leading-[16px] font-normal',
|
|
'[&>h4]:font-bold',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
type CollapsiblePanelProps = {
|
|
actions?: ReactNode;
|
|
};
|
|
export const CollapsiblePanel = forwardRef<
|
|
HTMLDivElement,
|
|
CollapsiblePanelProps & HTMLAttributes<HTMLDivElement>
|
|
>(({ children, className, actions, ...props }, ref) => {
|
|
const [collapsed, setCollapsed] = useState(true);
|
|
return (
|
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
<div
|
|
data-panel
|
|
ref={ref}
|
|
data-test
|
|
className={classNames(
|
|
'relative',
|
|
'p-2 rounded mt-[10px]',
|
|
'font-mono text-[12px] leading-[16px] font-normal',
|
|
'[&>h4]:font-bold',
|
|
'overflow-auto',
|
|
{
|
|
'h-[64px] overflow-hidden': collapsed,
|
|
'pb-4': !collapsed,
|
|
},
|
|
className
|
|
)}
|
|
aria-expanded={!collapsed}
|
|
onDoubleClick={(e) => {
|
|
e.preventDefault();
|
|
setCollapsed(!collapsed);
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
{collapsed && (
|
|
<div
|
|
data-panel-curtain
|
|
className={classNames(
|
|
'bg-gradient-to-b from-transparent to-inherit',
|
|
'absolute bottom-0 left-0 h-8 w-full pointer-events-none'
|
|
)}
|
|
></div>
|
|
)}
|
|
<div
|
|
data-panel-actions
|
|
className={classNames(
|
|
'absolute bottom-0 right-0',
|
|
'p-2',
|
|
'rounded-tl',
|
|
'flex align-middle gap-1'
|
|
)}
|
|
>
|
|
{actions}
|
|
<button
|
|
className="cursor-pointer"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setCollapsed(!collapsed);
|
|
}}
|
|
title={collapsed ? t('Expand') : t('Collapse')}
|
|
aria-label={collapsed ? t('Expand') : t('Collapse')}
|
|
>
|
|
{collapsed ? (
|
|
<Icon name="expand-all" size={3} />
|
|
) : (
|
|
<Icon name="collapse-all" size={3} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export const ToastHeading = forwardRef<
|
|
HTMLHeadingElement,
|
|
HtmlHTMLAttributes<HTMLHeadingElement>
|
|
>(({ children, className, ...props }, ref) => (
|
|
<h3
|
|
ref={ref}
|
|
className={classNames('text-sm uppercase mb-1', className)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</h3>
|
|
));
|
|
|
|
export const Toast = ({
|
|
id,
|
|
intent,
|
|
content,
|
|
closeAfter,
|
|
signal,
|
|
state = 'initial',
|
|
onClose,
|
|
loader = false,
|
|
}: ToastProps) => {
|
|
const toastRef = useRef<HTMLDivElement>(null);
|
|
const progressRef = useRef<HTMLDivElement>(null);
|
|
const ticker = useRef<number>(0);
|
|
const lock = useRef<boolean>(false);
|
|
|
|
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?.();
|
|
}, CLOSE_DELAY);
|
|
}, [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, intent, content]); // DO NOT REMOVE DEPS: intent, content
|
|
|
|
useEffect(() => {
|
|
const i = setInterval(() => {
|
|
if (!closeAfter || closeAfter === 0) return;
|
|
if (!lock.current) {
|
|
ticker.current += 100;
|
|
}
|
|
if (ticker.current >= closeAfter) {
|
|
closeToast();
|
|
}
|
|
}, 100);
|
|
return () => {
|
|
clearInterval(i);
|
|
};
|
|
}, [closeAfter, closeToast]);
|
|
|
|
useEffect(() => {
|
|
if (signal === 'close') {
|
|
closeToast();
|
|
}
|
|
}, [closeToast, signal]);
|
|
|
|
const withProgress = Boolean(closeAfter && closeAfter > 0);
|
|
|
|
return (
|
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
<div
|
|
data-testid="toast"
|
|
data-toast-id={id}
|
|
ref={toastRef}
|
|
role="dialog"
|
|
onMouseLeave={() => {
|
|
lock.current = false;
|
|
if (progressRef.current) {
|
|
progressRef.current.style.animationPlayState = 'running';
|
|
}
|
|
}}
|
|
onMouseEnter={() => {
|
|
lock.current = true;
|
|
if (progressRef.current) {
|
|
progressRef.current.style.animationPlayState = 'paused';
|
|
}
|
|
}}
|
|
className={classNames(
|
|
'w-[320px] rounded-md overflow-hidden',
|
|
'shadow-[8px_8px_16px_0_rgba(0,0,0,0.4)]',
|
|
'text-black dark:text-white',
|
|
'font-alpha text-[14px] leading-[19px]',
|
|
// background
|
|
{
|
|
'bg-vega-light-100 dark:bg-vega-dark-100 ': intent === Intent.None,
|
|
'bg-vega-blue-300 dark:bg-vega-blue-700': intent === Intent.Primary,
|
|
'bg-vega-green-300 dark:bg-vega-green-700': intent === Intent.Success,
|
|
'bg-vega-orange-300 dark:bg-vega-orange-700':
|
|
intent === Intent.Warning,
|
|
'bg-vega-red-300 dark:bg-vega-red-700': intent === Intent.Danger,
|
|
},
|
|
// panel's colours
|
|
{
|
|
'[&_[data-panel]]:bg-vega-light-150 [&_[data-panel]]:dark:bg-vega-dark-150':
|
|
intent === Intent.None,
|
|
'[&_[data-panel]]:bg-vega-blue-350 [&_[data-panel]]:dark:bg-vega-blue-650':
|
|
intent === Intent.Primary,
|
|
'[&_[data-panel]]:bg-vega-green-350 [&_[data-panel]]:dark:bg-vega-green-650':
|
|
intent === Intent.Success,
|
|
'[&_[data-panel]]:bg-vega-orange-350 [&_[data-panel]]:dark:bg-vega-orange-650':
|
|
intent === Intent.Warning,
|
|
'[&_[data-panel]]:bg-vega-red-350 [&_[data-panel]]:dark:bg-vega-red-650':
|
|
intent === Intent.Danger,
|
|
},
|
|
{
|
|
'[&_[data-panel]]:to-vega-light-150 [&_[data-panel]]:dark:to-vega-dark-150':
|
|
intent === Intent.None,
|
|
'[&_[data-panel]]:to-vega-blue-350 [&_[data-panel]]:dark:to-vega-blue-650':
|
|
intent === Intent.Primary,
|
|
'[&_[data-panel]]:to-vega-green-350 [&_[data-panel]]:dark:to-vega-green-650':
|
|
intent === Intent.Success,
|
|
'[&_[data-panel]]:to-vega-orange-350 [&_[data-panel]]:dark:to-vega-orange-650':
|
|
intent === Intent.Warning,
|
|
'[&_[data-panel]]:to-vega-red-350 [&_[data-panel]]:dark:to-vega-red-650':
|
|
intent === Intent.Danger,
|
|
},
|
|
// panel's actions
|
|
{
|
|
'[&_[data-panel-actions]]:bg-vega-light-200 [&_[data-panel-actions]]:dark:bg-vega-dark-100 ':
|
|
intent === Intent.None,
|
|
'[&_[data-panel-actions]]:bg-vega-blue-400 [&_[data-panel-actions]]:dark:bg-vega-blue-600':
|
|
intent === Intent.Primary,
|
|
'[&_[data-panel-actions]]:bg-vega-green-400 [&_[data-panel-actions]]:dark:bg-vega-green-600':
|
|
intent === Intent.Success,
|
|
'[&_[data-panel-actions]]:bg-vega-orange-400 [&_[data-panel-actions]]:dark:bg-vega-orange-600':
|
|
intent === Intent.Warning,
|
|
'[&_[data-panel-actions]]:bg-vega-red-400 [&_[data-panel-actions]]:dark:bg-vega-red-600':
|
|
intent === Intent.Danger,
|
|
},
|
|
// panels's progress bar colours
|
|
'[&_[data-progress-bar]]:mt-[10px] [&_[data-progress-bar]]:mb-[4px]',
|
|
{
|
|
'[&_[data-progress-bar]]:bg-vega-light-200 [&_[data-progress-bar]]:dark:bg-vega-dark-200 ':
|
|
intent === Intent.None,
|
|
'[&_[data-progress-bar]]:bg-vega-blue-400 [&_[data-progress-bar]]:dark:bg-vega-blue-600':
|
|
intent === Intent.Primary,
|
|
'[&_[data-progress-bar-value]]:bg-vega-blue-500 [&_[data-progress-bar-value]]:dark:bg-vega-blue-500':
|
|
intent === Intent.Primary,
|
|
'[&_[data-progress-bar]]:bg-vega-green-400 [&_[data-progress-bar]]:dark:bg-vega-green-600':
|
|
intent === Intent.Success,
|
|
'[&_[data-progress-bar-value]]:bg-vega-green-600 [&_[data-progress-bar-value]]:dark:bg-vega-green-500':
|
|
intent === Intent.Success,
|
|
'[&_[data-progress-bar]]:bg-vega-orange-400 [&_[data-progress-bar]]:dark:bg-vega-orange-600':
|
|
intent === Intent.Warning,
|
|
'[&_[data-progress-bar-value]]:bg-vega-orange-500 [&_[data-progress-bar-value]]:dark:bg-vega-orange-500':
|
|
intent === Intent.Warning,
|
|
'[&_[data-progress-bar]]:bg-vega-pink-400 [&_[data-progress-bar]]:dark:bg-vega-pink-600':
|
|
intent === Intent.Danger,
|
|
'[&_[data-progress-bar-value]]:bg-vega-red-500 [&_[data-progress-bar-value]]:dark:bg-vega-red-500':
|
|
intent === Intent.Danger,
|
|
},
|
|
{
|
|
[styles['initial']]: state === 'initial',
|
|
[styles['showing']]: state === 'showing',
|
|
[styles['expired']]: state === 'expired',
|
|
}
|
|
)}
|
|
>
|
|
<div className="relative flex">
|
|
<button
|
|
type="button"
|
|
data-testid="toast-close"
|
|
onClick={closeToast}
|
|
className="absolute top-0 right-0 z-20 flex items-center p-2"
|
|
>
|
|
<VegaIcon name={VegaIconNames.CROSS} size={12} />
|
|
</button>
|
|
<div
|
|
data-testid="toast-accent"
|
|
className={classNames(
|
|
{
|
|
// gray
|
|
'bg-vega-light-200 dark:bg-vega-dark-200 text-vega-light-400 dark:text-vega-dark-100':
|
|
intent === Intent.None,
|
|
// blue
|
|
'bg-vega-blue-500 text-vega-blue-600': intent === Intent.Primary,
|
|
// green
|
|
'bg-vega-green-500 text-vega-green-600':
|
|
intent === Intent.Success,
|
|
// orange
|
|
'bg-vega-orange-500 text-vega-orange-600':
|
|
intent === Intent.Warning,
|
|
// red
|
|
'bg-vega-red-500 text-vega-red-600': intent === Intent.Danger,
|
|
},
|
|
'w-8 p-2',
|
|
'flex justify-center'
|
|
)}
|
|
>
|
|
{loader ? (
|
|
<div className="w-[15px] h-[15px]">
|
|
<Loader size="small" forceTheme="dark" />
|
|
</div>
|
|
) : (
|
|
<Icon
|
|
name={toastIconMapping[intent]}
|
|
size={4}
|
|
className="!block !w-[14px] !h-[14px]"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={classNames(
|
|
'relative',
|
|
'overflow-auto flex-1 p-4 pr-[40px] [&>p]:mb-[2.5px]'
|
|
)}
|
|
data-testid="toast-content"
|
|
>
|
|
{content}
|
|
{withProgress && (
|
|
<div
|
|
ref={progressRef}
|
|
data-testid="toast-progress-bar"
|
|
className={classNames(
|
|
{
|
|
'bg-vega-light-200 dark:bg-vega-dark-200 ':
|
|
intent === Intent.None,
|
|
'bg-vega-blue-400 dark:bg-vega-blue-600':
|
|
intent === Intent.Primary,
|
|
'bg-vega-green-400 dark:bg-vega-green-600':
|
|
intent === Intent.Success,
|
|
'bg-vega-orange-400 dark:bg-vega-orange-600':
|
|
intent === Intent.Warning,
|
|
'bg-vega-red-400 dark:bg-vega-red-600':
|
|
intent === Intent.Danger,
|
|
},
|
|
'absolute bottom-0 left-0 w-full h-[4px]',
|
|
'animate-progress'
|
|
)}
|
|
style={{
|
|
animationDuration: `${closeAfter}ms`,
|
|
}}
|
|
></div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|