Add notifications at the top right of the screen.
This commit is contained in:
parent
ed4e766650
commit
64a77d077c
7 changed files with 363 additions and 11 deletions
|
@ -33,6 +33,8 @@ import {DemoModal} from "./DemoModal";
|
||||||
import {useCallableModal} from "../src/Components/Modals/Modals";
|
import {useCallableModal} from "../src/Components/Modals/Modals";
|
||||||
import {ModalType} from "../src/Components/Modals/ModalsTypes";
|
import {ModalType} from "../src/Components/Modals/ModalsTypes";
|
||||||
import {Buttons} from "../src/Components/Buttons/Buttons";
|
import {Buttons} from "../src/Components/Buttons/Buttons";
|
||||||
|
import {useNotify} from "../src/Components/Notifications/Notifications";
|
||||||
|
import {Notification, NotificationType} from "../src/Components/Notifications/Notification";
|
||||||
|
|
||||||
export function DemoApp()
|
export function DemoApp()
|
||||||
{
|
{
|
||||||
|
@ -48,6 +50,8 @@ export function DemoApp()
|
||||||
// Easy modal.
|
// Easy modal.
|
||||||
const easyModal = useCallableModal((type: ModalType = ModalType.NONE) => <DemoModal type={type} />);
|
const easyModal = useCallableModal((type: ModalType = ModalType.NONE) => <DemoModal type={type} />);
|
||||||
|
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
const [datetime, setDatetime] = useState(null);
|
const [datetime, setDatetime] = useState(null);
|
||||||
|
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
|
@ -506,11 +510,11 @@ export function DemoApp()
|
||||||
<h2>Notifications</h2>
|
<h2>Notifications</h2>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<button>Information notification</button>
|
<button onClick={() => { notify(<Notification type={NotificationType.INFO}>Test notification</Notification>); }}>Information notification</button>
|
||||||
<button className={"success"}>Success notification</button>
|
<button className={"success"} onClick={() => { notify(<Notification type={NotificationType.SUCCESS}>Test notification</Notification>); }}>Success notification</button>
|
||||||
<button className={"warning"}>Warning notification</button>
|
<button className={"warning"} onClick={() => { notify(<Notification type={NotificationType.WARNING}>Test notification</Notification>); }}>Warning notification</button>
|
||||||
<button className={"error"}>Error notification</button>
|
<button className={"error"} onClick={() => { notify(<Notification type={NotificationType.ERROR}>Test notification</Notification>); }}>Error notification</button>
|
||||||
<button className={"flat"}>Generic notification</button>
|
<button className={"flat"} onClick={() => { notify(<Notification>Test notification</Notification>); }}>Generic notification</button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</Application>
|
</Application>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { IconContext } from "@phosphor-icons/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
||||||
import {Curtains} from "../Components/Curtains/Curtains";
|
import {Curtains} from "../Components/Curtains/Curtains";
|
||||||
|
import {NotificationsProvider} from "../Components/Notifications/Notifications";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Kernel UI app component which initializes everything.
|
* Main Kernel UI app component which initializes everything.
|
||||||
|
@ -17,11 +18,13 @@ export function Kernel({header, footer, router}: {
|
||||||
size: 16,
|
size: 16,
|
||||||
weight: "bold",
|
weight: "bold",
|
||||||
}}>
|
}}>
|
||||||
<Curtains>
|
<NotificationsProvider>
|
||||||
{header}
|
<Curtains>
|
||||||
<RouterProvider router={router} />
|
{header}
|
||||||
{footer}
|
<RouterProvider router={router} />
|
||||||
</Curtains>
|
{footer}
|
||||||
|
</Curtains>
|
||||||
|
</NotificationsProvider>
|
||||||
</IconContext.Provider>
|
</IconContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
41
src/Components/Notifications/Notification.tsx
Normal file
41
src/Components/Notifications/Notification.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import React, {useCallback, useContext} from "react";
|
||||||
|
import {classes} from "../../Utils";
|
||||||
|
import {NotificationContext, NotificationsContext} from "./Notifications";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications types enumeration.
|
||||||
|
*/
|
||||||
|
export enum NotificationType
|
||||||
|
{
|
||||||
|
NONE = "none",
|
||||||
|
INFO = "info",
|
||||||
|
SUCCESS = "success",
|
||||||
|
WARNING = "warning",
|
||||||
|
ERROR = "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification component.
|
||||||
|
*/
|
||||||
|
export function Notification({type, children}: React.PropsWithChildren<{
|
||||||
|
type?: NotificationType;
|
||||||
|
}>)
|
||||||
|
{
|
||||||
|
// Default type is NONE.
|
||||||
|
type = type ?? NotificationType.NONE;
|
||||||
|
|
||||||
|
// Get notifications context.
|
||||||
|
const {close} = useContext(NotificationsContext);
|
||||||
|
|
||||||
|
// Get current notification UUID.
|
||||||
|
const {uuid, closed} = useContext(NotificationContext);
|
||||||
|
|
||||||
|
// Initialize close notification function.
|
||||||
|
const closeNotification = useCallback(() => { close(uuid); }, [uuid]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={classes("notification", type, closed ? "closed" : undefined)} onMouseDown={closeNotification}>
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
217
src/Components/Notifications/Notifications.tsx
Normal file
217
src/Components/Notifications/Notifications.tsx
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
import React, {startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState} from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import {v4 as uuidv4} from "uuid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification UUID type.
|
||||||
|
*/
|
||||||
|
export type NotificationUuid = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification data.
|
||||||
|
*/
|
||||||
|
export interface NotificationData
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The notification content.
|
||||||
|
*/
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of notification emitter function.
|
||||||
|
*/
|
||||||
|
export type EmitNotificationFunction = (content: React.ReactNode) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of notification close function.
|
||||||
|
*/
|
||||||
|
export type RemoveNotificationFunction = (uuid: NotificationUuid) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of notification close function.
|
||||||
|
*/
|
||||||
|
export type CloseNotificationFunction = (uuid: NotificationUuid) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of notification closed state function.
|
||||||
|
*/
|
||||||
|
export type IsNotificationClosedFunction = (uuid: NotificationUuid) => boolean;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface of notifications state.
|
||||||
|
*/
|
||||||
|
export interface NotificationsContextState
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Notification function.
|
||||||
|
* @param content Notification content.
|
||||||
|
*/
|
||||||
|
notify: EmitNotificationFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close notification function.
|
||||||
|
* @param uuid UUID of notification to close.
|
||||||
|
*/
|
||||||
|
close: CloseNotificationFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is given notification closed?
|
||||||
|
* @param uuid UUID of notification to get closed state.
|
||||||
|
*/
|
||||||
|
isClosed: IsNotificationClosedFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationsContext = React.createContext<NotificationsContextState>({
|
||||||
|
notify() {},
|
||||||
|
close() {},
|
||||||
|
isClosed() { return false; },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to emit a notification.
|
||||||
|
*/
|
||||||
|
export function useNotify(): EmitNotificationFunction
|
||||||
|
{
|
||||||
|
return useContext(NotificationsContext).notify;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications provider.
|
||||||
|
*/
|
||||||
|
export function NotificationsProvider({children}: React.PropsWithChildren<{}>)
|
||||||
|
{
|
||||||
|
// Notifications.
|
||||||
|
const [notifications, setNotifications] = useState<Record<NotificationUuid, NotificationData>>({});
|
||||||
|
// Keeping track of closed notifications that are still on (while transitioning out).
|
||||||
|
const [closedNotifications, setClosedNotifications] = useState<Record<NotificationUuid, boolean>>()
|
||||||
|
|
||||||
|
// Initialize remove notification function.
|
||||||
|
const remove = useRef<RemoveNotificationFunction>();
|
||||||
|
remove.current = useCallback((uuid) => {
|
||||||
|
// Copy the notifications list.
|
||||||
|
const newNotifications = {...notifications};
|
||||||
|
const newClosedNotifications = {...closedNotifications};
|
||||||
|
// Remove the given notification from the list.
|
||||||
|
delete newNotifications[uuid];
|
||||||
|
delete newClosedNotifications[uuid];
|
||||||
|
// Set the new notifications list.
|
||||||
|
setNotifications(newNotifications);
|
||||||
|
setClosedNotifications(newClosedNotifications);
|
||||||
|
}, [notifications, setNotifications, closedNotifications, setClosedNotifications]);
|
||||||
|
|
||||||
|
// Initialize close notification function with animation.
|
||||||
|
const close = useRef<CloseNotificationFunction>();
|
||||||
|
close.current = useCallback((uuid) => {
|
||||||
|
// Add the given curtain UUID to the list of closed curtains.
|
||||||
|
setClosedNotifications({
|
||||||
|
...closedNotifications,
|
||||||
|
[uuid]: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the notification 300ms later.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
// Remove the curtain.
|
||||||
|
remove.current(uuid);
|
||||||
|
}, 300);
|
||||||
|
}, [remove, closedNotifications, setClosedNotifications]);
|
||||||
|
|
||||||
|
// Initialize isClosed notification function.
|
||||||
|
const isClosed = useRef<IsNotificationClosedFunction>();
|
||||||
|
isClosed.current = useCallback((uuid) => (!!closedNotifications?.[uuid]), [closedNotifications]);
|
||||||
|
|
||||||
|
// Initialize notify function.
|
||||||
|
const notify = useRef<EmitNotificationFunction>();
|
||||||
|
notify.current = useCallback((content: React.ReactNode) => {
|
||||||
|
// Generate a new notification UUID for the new notification.
|
||||||
|
const notificationUuid = uuidv4();
|
||||||
|
|
||||||
|
// Add the notification to the list of notifications, with the generated UUID.
|
||||||
|
setNotifications({
|
||||||
|
...notifications,
|
||||||
|
[notificationUuid]: {
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close notification 10s later.
|
||||||
|
setTimeout(() => {
|
||||||
|
close.current(notificationUuid);
|
||||||
|
}, 10000);
|
||||||
|
}, [notifications, setNotifications]);
|
||||||
|
|
||||||
|
// Initialize context state from action functions.
|
||||||
|
const contextState = useMemo(() => ({
|
||||||
|
notify: (content: React.ReactNode) => notify.current(content),
|
||||||
|
close: (uuid: NotificationUuid) => close.current(uuid),
|
||||||
|
isClosed: (uuid: NotificationUuid) => isClosed.current(uuid),
|
||||||
|
}), [notify, isClosed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationsContext.Provider value={contextState}>
|
||||||
|
{children}
|
||||||
|
<NotificationsPortal notifications={notifications} />
|
||||||
|
</NotificationsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curtains portal manager.
|
||||||
|
*/
|
||||||
|
function NotificationsPortal({notifications}: {
|
||||||
|
notifications: Record<NotificationUuid, NotificationData>;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return ReactDOM.createPortal((
|
||||||
|
<ul className={"notifications"}>
|
||||||
|
{ // Show notifications list.
|
||||||
|
Object.entries(notifications).map(([uuid, notificationData]) => (
|
||||||
|
<NotificationInstance key={uuid} uuid={uuid}>
|
||||||
|
{notificationData.content}
|
||||||
|
</NotificationInstance>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
), document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notification context.
|
||||||
|
*/
|
||||||
|
export const NotificationContext = React.createContext<{
|
||||||
|
/**
|
||||||
|
* Notification UUID.
|
||||||
|
*/
|
||||||
|
uuid: NotificationUuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification closed state.
|
||||||
|
*/
|
||||||
|
closed: boolean;
|
||||||
|
}>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification component.
|
||||||
|
*/
|
||||||
|
function NotificationInstance({uuid, children}: React.PropsWithChildren<{
|
||||||
|
/**
|
||||||
|
* Notification UUID.
|
||||||
|
*/
|
||||||
|
uuid: NotificationUuid;
|
||||||
|
}>)
|
||||||
|
{
|
||||||
|
// Get notifications context.
|
||||||
|
const {isClosed} = useContext(NotificationsContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={{
|
||||||
|
uuid: uuid,
|
||||||
|
closed: isClosed(uuid),
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
|
@ -12,6 +12,7 @@
|
||||||
@import "components/_loaders";
|
@import "components/_loaders";
|
||||||
@import "components/_menus";
|
@import "components/_menus";
|
||||||
@import "components/_modal";
|
@import "components/_modal";
|
||||||
|
@import "components/_notifications";
|
||||||
@import "components/_pagination";
|
@import "components/_pagination";
|
||||||
@import "components/_select";
|
@import "components/_select";
|
||||||
@import "components/_steps";
|
@import "components/_steps";
|
||||||
|
|
|
@ -31,7 +31,7 @@ body.dimmed
|
||||||
{ // Disable scroll and blur the content when the body is dimmed.
|
{ // Disable scroll and blur the content when the body is dimmed.
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
> *:not(.curtain)
|
> *:not(.curtain):not(.notifications)
|
||||||
{
|
{
|
||||||
transition: filter 0.4s ease-in;
|
transition: filter 0.4s ease-in;
|
||||||
|
|
||||||
|
|
86
src/styles/components/_notifications.less
Normal file
86
src/styles/components/_notifications.less
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
body > ul.notifications
|
||||||
|
{ // Notifications list.
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
margin: 0 1em 1em;
|
||||||
|
padding: 0;
|
||||||
|
width: 20em;
|
||||||
|
max-width: 66%;
|
||||||
|
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
z-index: 2000;
|
||||||
|
|
||||||
|
> li.notification
|
||||||
|
{ // A single notification.
|
||||||
|
margin: 1em auto;
|
||||||
|
padding: 1em;
|
||||||
|
|
||||||
|
border-radius: 0.25em;
|
||||||
|
|
||||||
|
box-shadow: 0 0 0.5em 0 var(--foreground-shadow);
|
||||||
|
background: var(--background);
|
||||||
|
|
||||||
|
// Show an animation when entering screen.
|
||||||
|
animation: notification-in 0.3s ease-in;
|
||||||
|
transform-origin: center;
|
||||||
|
|
||||||
|
&.closed
|
||||||
|
{ // Added when the notification is closing and will soon be removed from DOM.
|
||||||
|
transition: transform 0.3s ease-out, filter 0.3s ease-out, opacity 0.3s ease-out;
|
||||||
|
|
||||||
|
transform: scale(1.15);
|
||||||
|
filter: blur(0.25em);
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.info
|
||||||
|
{
|
||||||
|
border: solid var(--primary-darker) thin;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success
|
||||||
|
{
|
||||||
|
border: solid var(--green-darker) thin;
|
||||||
|
background: var(--green);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning
|
||||||
|
{
|
||||||
|
border: solid var(--orange-darker) thin;
|
||||||
|
background: var(--orange);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error
|
||||||
|
{
|
||||||
|
border: solid var(--red-darker) thin;
|
||||||
|
background: var(--red);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes notification-in
|
||||||
|
{ // Screen enter animation.
|
||||||
|
from
|
||||||
|
{
|
||||||
|
transform: scale(1.15);
|
||||||
|
filter: blur(0.25em);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to
|
||||||
|
{
|
||||||
|
transform: scale(1);
|
||||||
|
filter: blur(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue