292 lines
7.4 KiB
TypeScript
292 lines
7.4 KiB
TypeScript
import { Icon, IconifyIconProps } from '@iconify/react/dist/iconify.js';
|
|
import cx from 'clsx';
|
|
import { filter, isEqual } from 'lodash-es';
|
|
import {
|
|
createContext,
|
|
createRef,
|
|
ReactNode,
|
|
RefObject,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
|
import { v4 } from 'uuid';
|
|
import styles from './Notification.module.css';
|
|
|
|
export enum NotificationType {
|
|
INFO,
|
|
PROMPTION,
|
|
SUCCESS,
|
|
WARNING,
|
|
ERROR,
|
|
CUSTOM,
|
|
}
|
|
|
|
export enum ToastDuration {
|
|
MANUAL = 0,
|
|
SHORT = 1500,
|
|
MEDIUM = 3000,
|
|
LONG = 5000,
|
|
}
|
|
|
|
interface NotificationFunctions {
|
|
addNotification(
|
|
kind: NotificationType,
|
|
title?: string,
|
|
message?: ReactNode,
|
|
icon?: IconifyIconProps['icon'],
|
|
duration?: number,
|
|
): string;
|
|
removeNotification(id: string): void;
|
|
showToast(
|
|
kind: NotificationType,
|
|
message: string,
|
|
icon?: IconifyIconProps['icon'],
|
|
duration?: ToastDuration,
|
|
): string;
|
|
}
|
|
|
|
const NotificationStyleMap = {
|
|
[NotificationType.INFO]: styles.kind_info,
|
|
[NotificationType.PROMPTION]: styles.kind_promption,
|
|
[NotificationType.SUCCESS]: styles.kind_success,
|
|
[NotificationType.WARNING]: styles.kind_warning,
|
|
[NotificationType.ERROR]: styles.kind_error,
|
|
[NotificationType.CUSTOM]: styles.kind_custom,
|
|
};
|
|
|
|
const NotificationHostContext = createContext<NotificationFunctions>({
|
|
addNotification: () => '',
|
|
removeNotification: () => {},
|
|
showToast: () => '',
|
|
});
|
|
|
|
type NotificationProps = {
|
|
kind?: NotificationType;
|
|
nid: string;
|
|
icon?: IconifyIconProps['icon'];
|
|
title?: string;
|
|
message?: ReactNode;
|
|
duration?: number;
|
|
ref: RefObject<HTMLDivElement>;
|
|
closeAction?: (id: string) => void;
|
|
};
|
|
const Notification = ({
|
|
kind = NotificationType.INFO,
|
|
nid,
|
|
icon,
|
|
title,
|
|
message,
|
|
duration = 3000,
|
|
ref,
|
|
closeAction,
|
|
}: NotificationProps) => {
|
|
useEffect(() => {
|
|
if (duration > 0) {
|
|
const timer = setTimeout(() => {
|
|
closeAction?.(nid);
|
|
}, duration);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [nid, duration, closeAction]);
|
|
|
|
return (
|
|
<div className={cx(styles.notification, NotificationStyleMap[kind])} ref={ref}>
|
|
{icon && <Icon icon={icon} className={cx(styles.notification_icon)} />}
|
|
<div className={cx(styles.notificaiton_content)}>
|
|
{title && <div className={cx(styles.notification_title)}>{title}</div>}
|
|
{message && <div>{message}</div>}
|
|
</div>
|
|
{duration === 0 && (
|
|
<Icon
|
|
icon="tabler:x"
|
|
className={styles.notification_close}
|
|
onClick={() => closeAction?.(nid)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type ToastProps = {
|
|
kind: NotificationType;
|
|
tid: string;
|
|
message?: string;
|
|
icon?: string;
|
|
duration?: ToastDuration;
|
|
ref: RefObject<HTMLDivElement>;
|
|
closeAction: () => void;
|
|
};
|
|
const Toast = ({
|
|
kind,
|
|
tid,
|
|
message,
|
|
icon,
|
|
duration = ToastDuration.MEDIUM,
|
|
ref,
|
|
closeAction,
|
|
}: ToastProps) => {
|
|
useEffect(() => {
|
|
if (duration !== ToastDuration.MANUAL) {
|
|
const timer = setTimeout(() => {
|
|
closeAction?.(tid);
|
|
}, duration);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [tid, duration, closeAction]);
|
|
|
|
return (
|
|
<div className={cx(styles.toast, NotificationStyleMap[kind])} ref={ref}>
|
|
{icon && <Icon icon={icon} className={styles.toast_icon} />}
|
|
<div className={styles.toast_content}>{message}</div>
|
|
{duration === ToastDuration.MANUAL && (
|
|
<Icon icon="tabler:x" className={styles.toast_close} onClick={() => closeAction?.(tid)} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export function useNotification() {
|
|
const functions = useContext<NotificationFunctions>(NotificationHostContext);
|
|
return functions;
|
|
}
|
|
|
|
type NotificationElement = {
|
|
id: string;
|
|
element: ReactNode;
|
|
ref: RefObject<ReactNode>;
|
|
};
|
|
type NotificationsProps = {
|
|
defaultDuration?: number;
|
|
maxNotifications?: number;
|
|
children?: ReactNode;
|
|
};
|
|
export function Notifications({
|
|
defaultDuration = 3000,
|
|
maxNotifications = 5,
|
|
children,
|
|
}: NotificationsProps) {
|
|
const [notifications, setNotifications] = useState<NotificationElement[]>([]);
|
|
const [toasts, setToasts] = useState<NotificationElement[]>([]);
|
|
|
|
const removeNotification = useCallback((id: string) => {
|
|
setNotifications((prev) => filter(prev, (n) => !isEqual(n.id, id)));
|
|
}, []);
|
|
const addNotification = useCallback(
|
|
(
|
|
kind: NotificationType,
|
|
title?: string,
|
|
message?: ReactNode,
|
|
icon?: IconifyIconProps['icon'],
|
|
duration?: number,
|
|
) => {
|
|
const id = v4();
|
|
const ref = createRef(null);
|
|
const newNotify = (
|
|
<Notification
|
|
kind={kind}
|
|
nid={id}
|
|
icon={icon}
|
|
title={title}
|
|
message={message}
|
|
duration={duration ?? defaultDuration}
|
|
closeAction={removeNotification}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
setNotifications((prev) => [...prev, { id, element: newNotify, ref } as NotificationElement]);
|
|
|
|
return id;
|
|
},
|
|
[removeNotification, defaultDuration],
|
|
);
|
|
const removeToast = useCallback((id: string) => {
|
|
setToasts((prev) => filter(prev, (n) => !isEqual(n.id, id)));
|
|
}, []);
|
|
const showToast = useCallback(
|
|
(
|
|
kind: NotificationType,
|
|
message?: string,
|
|
icon?: IconifyIconProps['icon'],
|
|
duration?: ToastDuration,
|
|
) => {
|
|
const id = v4();
|
|
const ref = createRef(null);
|
|
const newToast = (
|
|
<Toast
|
|
kind={kind}
|
|
tid={id}
|
|
message={message ?? ''}
|
|
icon={icon}
|
|
duration={duration ?? ToastDuration.MEDIUM}
|
|
ref={ref}
|
|
closeAction={removeToast}
|
|
/>
|
|
);
|
|
setToasts((prev) => [...prev, { id, element: newToast, ref } as NotificationElement]);
|
|
|
|
return id;
|
|
},
|
|
[removeToast],
|
|
);
|
|
|
|
return (
|
|
<NotificationHostContext
|
|
value={{
|
|
addNotification,
|
|
removeNotification,
|
|
showDialog: () => '',
|
|
showModalDialog: () => '',
|
|
closeDialog: () => {},
|
|
showToast,
|
|
}}>
|
|
{children}
|
|
{createPortal(
|
|
<div className={cx(styles.notification_positioner)}>
|
|
<TransitionGroup component={null}>
|
|
{notifications.slice(0, maxNotifications).map(({ id, element, ref }) => (
|
|
<CSSTransition
|
|
key={id}
|
|
nodeRef={ref}
|
|
unmountOnExit
|
|
timeout={500}
|
|
classNames={{
|
|
enter: styles.slide_in_enter,
|
|
enterActive: styles.slide_in_enter_active,
|
|
exitActive: styles.slide_out_exit_active,
|
|
}}>
|
|
{element}
|
|
</CSSTransition>
|
|
))}
|
|
</TransitionGroup>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
{createPortal(
|
|
<div className={cx(styles.toast_positioner)}>
|
|
<TransitionGroup component={null}>
|
|
{toasts.slice(0, 1).map(({ id, element, ref }) => (
|
|
<CSSTransition
|
|
key={id}
|
|
nodeRef={ref}
|
|
unmountOnExit
|
|
timeout={500}
|
|
classNames={{
|
|
enter: styles.fade_in_enter,
|
|
enterActive: styles.fade_in_enter_active,
|
|
exitActive: styles.fade_out_exit_active,
|
|
}}>
|
|
{element}
|
|
</CSSTransition>
|
|
))}
|
|
</TransitionGroup>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</NotificationHostContext>
|
|
);
|
|
}
|