vega-frontend-monorepo/libs/ui-toolkit/src/components/toast/toast.tsx
2023-09-04 17:21:46 -07:00

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>
);
};