Add notifications at the top right of the screen.

This commit is contained in:
Madeorsk 2024-07-14 19:51:34 +02:00
parent ed4e766650
commit 64a77d077c
Signed by: Madeorsk
SSH key fingerprint: SHA256:J9G0ofIOLKf7kyS2IfrMqtMaPdfsk1W02+oGueZzDDU
7 changed files with 363 additions and 11 deletions

View file

@ -33,6 +33,8 @@ import {DemoModal} from "./DemoModal";
import {useCallableModal} from "../src/Components/Modals/Modals";
import {ModalType} from "../src/Components/Modals/ModalsTypes";
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()
{
@ -48,6 +50,8 @@ export function DemoApp()
// Easy modal.
const easyModal = useCallableModal((type: ModalType = ModalType.NONE) => <DemoModal type={type} />);
const notify = useNotify();
const [datetime, setDatetime] = useState(null);
const [selected, setSelected] = useState(null);
@ -506,11 +510,11 @@ export function DemoApp()
<h2>Notifications</h2>
<Card>
<button>Information notification</button>
<button className={"success"}>Success notification</button>
<button className={"warning"}>Warning notification</button>
<button className={"error"}>Error notification</button>
<button className={"flat"}>Generic notification</button>
<button onClick={() => { notify(<Notification type={NotificationType.INFO}>Test notification</Notification>); }}>Information notification</button>
<button className={"success"} onClick={() => { notify(<Notification type={NotificationType.SUCCESS}>Test notification</Notification>); }}>Success notification</button>
<button className={"warning"} onClick={() => { notify(<Notification type={NotificationType.WARNING}>Test notification</Notification>); }}>Warning notification</button>
<button className={"error"} onClick={() => { notify(<Notification type={NotificationType.ERROR}>Test notification</Notification>); }}>Error notification</button>
<button className={"flat"} onClick={() => { notify(<Notification>Test notification</Notification>); }}>Generic notification</button>
</Card>
</Application>

View file

@ -2,6 +2,7 @@ import { IconContext } from "@phosphor-icons/react";
import React from "react";
import {createBrowserRouter, RouterProvider} from "react-router-dom";
import {Curtains} from "../Components/Curtains/Curtains";
import {NotificationsProvider} from "../Components/Notifications/Notifications";
/**
* Main Kernel UI app component which initializes everything.
@ -17,11 +18,13 @@ export function Kernel({header, footer, router}: {
size: 16,
weight: "bold",
}}>
<NotificationsProvider>
<Curtains>
{header}
<RouterProvider router={router} />
{footer}
</Curtains>
</NotificationsProvider>
</IconContext.Provider>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -12,6 +12,7 @@
@import "components/_loaders";
@import "components/_menus";
@import "components/_modal";
@import "components/_notifications";
@import "components/_pagination";
@import "components/_select";
@import "components/_steps";

View file

@ -31,7 +31,7 @@ body.dimmed
{ // Disable scroll and blur the content when the body is dimmed.
overflow: hidden;
> *:not(.curtain)
> *:not(.curtain):not(.notifications)
{
transition: filter 0.4s ease-in;

View 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;
}
}