增加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