增加Notification支持组件。
This commit is contained in:
parent
4d4192c0f0
commit
bb7a800911
130
src/components/Notification.module.css
Normal file
130
src/components/Notification.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
291
src/components/Notifications.tsx
Normal file
291
src/components/Notifications.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user