add Notification function.
This commit is contained in:
132
src/components/Notifications.module.css
Normal file
132
src/components/Notifications.module.css
Normal file
@@ -0,0 +1,132 @@
|
||||
@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;
|
||||
z-index: 600;
|
||||
}
|
||||
.message_box_positioner {
|
||||
position: absolute;
|
||||
}
|
||||
.toast_positioner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 20vh;
|
||||
z-index: 700;
|
||||
}
|
||||
.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-on-info-container);
|
||||
background-color: var(--color-info-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_promption {
|
||||
color: var(--color-on-tertiary-container);
|
||||
background-color: var(--color-tertiary-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_success {
|
||||
color: var(--color-on-success-container);
|
||||
background-color: var(--color-success-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_warning {
|
||||
color: var(--color-on-warning-container);
|
||||
background-color: var(--color-warning-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_error {
|
||||
color: var(--color-on-error-container);
|
||||
background-color: var(--color-error-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_custom {
|
||||
color: var(--color-on-defensive-container);
|
||||
background-color: var(--color-defensive-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
296
src/components/Notifications.tsx
Normal file
296
src/components/Notifications.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Icon, IconifyIconProps } from '@iconify/react/dist/iconify.js';
|
||||
import cx from 'clsx';
|
||||
import {
|
||||
createContext,
|
||||
createRef,
|
||||
FC,
|
||||
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 './Notifications.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: FC<NotificationProps> = ({
|
||||
kind = NotificationType.INFO,
|
||||
nid,
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
duration = 3000,
|
||||
ref,
|
||||
closeAction,
|
||||
}) => {
|
||||
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="material-symbols-light:close"
|
||||
className={styles.notification_close}
|
||||
onClick={() => closeAction?.(nid)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ToastProps = {
|
||||
kind: NotificationType;
|
||||
tid: string;
|
||||
message?: string;
|
||||
icon?: string;
|
||||
duration?: ToastDuration;
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
closeAction: (tid?: string) => void;
|
||||
};
|
||||
|
||||
const Toast: FC<ToastProps> = ({
|
||||
kind,
|
||||
tid,
|
||||
message,
|
||||
icon,
|
||||
duration = ToastDuration.MEDIUM,
|
||||
ref,
|
||||
closeAction,
|
||||
}) => {
|
||||
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="material-symbols-light:close"
|
||||
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 | HTMLDivElement>;
|
||||
};
|
||||
|
||||
type NotificationsProps = {
|
||||
defaultDuration?: number;
|
||||
maxNotifications?: number;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const Notifications: FC<NotificationsProps> = ({
|
||||
defaultDuration = 3000,
|
||||
maxNotifications = 5,
|
||||
children,
|
||||
}) => {
|
||||
const [notifications, setNotifications] = useState<NotificationElement[]>([]);
|
||||
const [toasts, setToasts] = useState<NotificationElement[]>([]);
|
||||
|
||||
const removeNotification = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
const addNotification = useCallback(
|
||||
(
|
||||
kind: NotificationType,
|
||||
title?: string,
|
||||
message?: ReactNode,
|
||||
icon?: IconifyIconProps['icon'],
|
||||
duration?: number,
|
||||
) => {
|
||||
const id = v4();
|
||||
const ref = createRef<ReactNode | HTMLDivElement>();
|
||||
const newNotify = (
|
||||
<Notification
|
||||
kind={kind}
|
||||
nid={id}
|
||||
icon={icon}
|
||||
title={title}
|
||||
message={message}
|
||||
duration={duration ?? defaultDuration}
|
||||
closeAction={removeNotification}
|
||||
//@ts-expect-error TS2322
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
setNotifications((prev) => [...prev, { id, element: newNotify, ref } as NotificationElement]);
|
||||
|
||||
return id;
|
||||
},
|
||||
[removeNotification, defaultDuration],
|
||||
);
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
const showToast = useCallback(
|
||||
(kind: NotificationType, message?: string, icon?: string, duration?: ToastDuration) => {
|
||||
const id = v4();
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
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,
|
||||
showToast,
|
||||
}}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className={cx(styles.notification_positioner)}>
|
||||
<TransitionGroup component={null}>
|
||||
{notifications.slice(0, maxNotifications).map(({ id, element, ref }) => (
|
||||
<CSSTransition
|
||||
key={id}
|
||||
//@ts-expect-error TS2322
|
||||
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}
|
||||
//@ts-expect-error TS2322
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
35
src/main.tsx
35
src/main.tsx
@@ -5,27 +5,36 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
import Notifications from './components/Notifications';
|
||||
import EstimWatchProvider from './context/EstimContext';
|
||||
import CreatePattern from './pages/CreatePattern';
|
||||
import Device from './pages/Device';
|
||||
import PatternEditor from './pages/PatternEditor';
|
||||
import PatternLibrary from './pages/PatternLibrary';
|
||||
import PatternNavigator from './pages/PatternNavigator';
|
||||
import PlayControl from './pages/Play';
|
||||
import Settings from './pages/Settings';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<EstimWatchProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Device />} />
|
||||
<Route path="/play" element={<PlayControl />} />
|
||||
<Route path="/library" element={<PatternLibrary />} />
|
||||
<Route path="/pattern-editor" element={<PatternEditor />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</EstimWatchProvider>
|
||||
<Notifications>
|
||||
<EstimWatchProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Device />} />
|
||||
<Route path="/play" element={<PlayControl />} />
|
||||
<Route path="/library" element={<PatternLibrary />} />
|
||||
<Route path="/pattern-editor">
|
||||
<Route index element={<PatternNavigator />} />
|
||||
<Route path="new" element={<CreatePattern />} />
|
||||
<Route path=":pattern" element={<PatternEditor />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</EstimWatchProvider>
|
||||
</Notifications>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user