增加Notification支持组件。

This commit is contained in:
徐涛 2024-12-25 09:29:19 +08:00
parent 4d4192c0f0
commit bb7a800911
2 changed files with 421 additions and 0 deletions

View File

@ -0,0 +1,130 @@
@layer components {
.notification_positioner {
position: absolute;
bottom: 20px;
right: 20px;
width: 25vw;
display: flex;
flex-direction: column-reverse;
justify-content: flex-start;
align-items: stretch;
gap: 10px;
}
.message_box_positioner {
position: absolute;
}
.toast_positioner {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 20vh;
}
.notification {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 10px;
padding: 10px 15px;
border-radius: 2px;
.notification_icon {
flex-shrink: 0;
font-size: 24px;
}
.notificaiton_content {
flex-grow: 1;
.notification_title {
font-size: 1.4em;
font-weight: bold;
}
}
.notification_close {
flex-shrink: 0;
font-size: 16px;
align-self: flex-start;
cursor: pointer;
}
}
.toast {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 10px;
padding: 4px 8px;
border-radius: 2px;
width: 35vw;
.toast_icon {
flex-shrink: 0;
font-size: 18px;
}
.toast_content {
flex-grow: 1;
display: inline-block;
overflow-wrap: break-word;
}
.toast_close {
flex-shrink: 0;
font-size: 14px;
cursor: pointer;
}
}
.kind_info {
color: var(--color-typo-dark);
background-color: var(--color-info);
box-shadow: 2px 2px 4px oklch(from var(--color-info) l c h / 40%);
}
.kind_promption {
color: var(--color-typo-dark);
background-color: var(--color-blue);
box-shadow: 2px 2px 4px oklch(from var(--color-blue) l c h / 40%);
}
.kind_success {
color: var(--color-typo-dark);
background-color: var(--color-success);
box-shadow: 2px 2px 4px oklch(from var(--color-success) l c h / 40%);
}
.kind_warning {
color: var(--color-typo-dark);
background-color: var(--color-warn);
box-shadow: 2px 2px 4px oklch(from var(--color-warn) l c h / 40%);
}
.kind_error {
color: var(--color-typo-dark);
background-color: var(--color-danger);
box-shadow: 2px 2px 4px oklch(from var(--color-danger) l c h / 40%);
}
.kind_custom {
color: var(--color-typo-dark);
background-color: var(--color-wumeizi);
box-shadow: 2px 2px 4px oklch(from var(--color-wumeizi) l c h / 40%);
}
.slide_in_enter {
transform: translateX(100%);
opacity: 0;
}
.slide_in_enter_active {
transition: all 500ms ease-in-out;
transform: translateX(0);
opacity: 1;
}
.slide_out_exit_active {
transition: all 500ms ease-in-out;
opacity: 0;
}
.fade_in_enter {
opacity: 0;
}
.fade_in_enter_active {
transition: all 500ms ease-in-out;
opacity: 1;
}
.fade_out_exit_active {
transition: all 500ms ease-in-out;
opacity: 0;
}
}

View File

@ -0,0 +1,291 @@
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>
);
}