From bb7a800911e6aaff5a840de4d3f90315c75aa32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Wed, 25 Dec 2024 09:29:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0Notification=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=BB=84=E4=BB=B6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Notification.module.css | 130 +++++++++++ src/components/Notifications.tsx | 291 +++++++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 src/components/Notification.module.css create mode 100644 src/components/Notifications.tsx diff --git a/src/components/Notification.module.css b/src/components/Notification.module.css new file mode 100644 index 0000000..d9592dc --- /dev/null +++ b/src/components/Notification.module.css @@ -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; + } +} diff --git a/src/components/Notifications.tsx b/src/components/Notifications.tsx new file mode 100644 index 0000000..a4dd04f --- /dev/null +++ b/src/components/Notifications.tsx @@ -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({ + addNotification: () => '', + removeNotification: () => {}, + showToast: () => '', +}); + +type NotificationProps = { + kind?: NotificationType; + nid: string; + icon?: IconifyIconProps['icon']; + title?: string; + message?: ReactNode; + duration?: number; + ref: RefObject; + 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 ( +
+ {icon && } +
+ {title &&
{title}
} + {message &&
{message}
} +
+ {duration === 0 && ( + closeAction?.(nid)} + /> + )} +
+ ); +}; + +type ToastProps = { + kind: NotificationType; + tid: string; + message?: string; + icon?: string; + duration?: ToastDuration; + ref: RefObject; + 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 ( +
+ {icon && } +
{message}
+ {duration === ToastDuration.MANUAL && ( + closeAction?.(tid)} /> + )} +
+ ); +}; + +export function useNotification() { + const functions = useContext(NotificationHostContext); + return functions; +} + +type NotificationElement = { + id: string; + element: ReactNode; + ref: RefObject; +}; +type NotificationsProps = { + defaultDuration?: number; + maxNotifications?: number; + children?: ReactNode; +}; +export function Notifications({ + defaultDuration = 3000, + maxNotifications = 5, + children, +}: NotificationsProps) { + const [notifications, setNotifications] = useState([]); + const [toasts, setToasts] = useState([]); + + 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 = ( + + ); + 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 = ( + + ); + setToasts((prev) => [...prev, { id, element: newToast, ref } as NotificationElement]); + + return id; + }, + [removeToast], + ); + + return ( + '', + showModalDialog: () => '', + closeDialog: () => {}, + showToast, + }}> + {children} + {createPortal( +
+ + {notifications.slice(0, maxNotifications).map(({ id, element, ref }) => ( + + {element} + + ))} + +
, + document.body, + )} + {createPortal( +
+ + {toasts.slice(0, 1).map(({ id, element, ref }) => ( + + {element} + + ))} + +
, + document.body, + )} +
+ ); +}