From 64a77d077ca79323e15699b764425c1e366673f1 Mon Sep 17 00:00:00 2001 From: Madeorsk Date: Sun, 14 Jul 2024 19:51:34 +0200 Subject: [PATCH] Add notifications at the top right of the screen. --- demo/DemoApp.tsx | 14 +- src/Application/Kernel.tsx | 13 +- src/Components/Notifications/Notification.tsx | 41 ++++ .../Notifications/Notifications.tsx | 217 ++++++++++++++++++ src/styles/_components.less | 1 + src/styles/components/_curtains.less | 2 +- src/styles/components/_notifications.less | 86 +++++++ 7 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 src/Components/Notifications/Notification.tsx create mode 100644 src/Components/Notifications/Notifications.tsx create mode 100644 src/styles/components/_notifications.less diff --git a/demo/DemoApp.tsx b/demo/DemoApp.tsx index 5482323..b6704a0 100644 --- a/demo/DemoApp.tsx +++ b/demo/DemoApp.tsx @@ -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) => ); + const notify = useNotify(); + const [datetime, setDatetime] = useState(null); const [selected, setSelected] = useState(null); @@ -506,11 +510,11 @@ export function DemoApp()

Notifications

- - - - - + + + + + diff --git a/src/Application/Kernel.tsx b/src/Application/Kernel.tsx index c2c9aab..f5ff55f 100644 --- a/src/Application/Kernel.tsx +++ b/src/Application/Kernel.tsx @@ -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", }}> - - {header} - - {footer} - + + + {header} + + {footer} + + ); } diff --git a/src/Components/Notifications/Notification.tsx b/src/Components/Notifications/Notification.tsx new file mode 100644 index 0000000..0b85dd1 --- /dev/null +++ b/src/Components/Notifications/Notification.tsx @@ -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 ( +
  • + {children} +
  • + ); +} diff --git a/src/Components/Notifications/Notifications.tsx b/src/Components/Notifications/Notifications.tsx new file mode 100644 index 0000000..b9704a2 --- /dev/null +++ b/src/Components/Notifications/Notifications.tsx @@ -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({ + 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>({}); + // Keeping track of closed notifications that are still on (while transitioning out). + const [closedNotifications, setClosedNotifications] = useState>() + + // Initialize remove notification function. + const remove = useRef(); + 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(); + 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(); + isClosed.current = useCallback((uuid) => (!!closedNotifications?.[uuid]), [closedNotifications]); + + // Initialize notify function. + const notify = useRef(); + 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 ( + + {children} + + + ); +} + +/** + * Curtains portal manager. + */ +function NotificationsPortal({notifications}: { + notifications: Record; +}) +{ + return ReactDOM.createPortal(( +
      + { // Show notifications list. + Object.entries(notifications).map(([uuid, notificationData]) => ( + + {notificationData.content} + + )) + } +
    + ), 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 ( + + {children} + + ); +} diff --git a/src/styles/_components.less b/src/styles/_components.less index 4b51633..3eb9f1b 100644 --- a/src/styles/_components.less +++ b/src/styles/_components.less @@ -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"; diff --git a/src/styles/components/_curtains.less b/src/styles/components/_curtains.less index cc37f71..b05f19c 100644 --- a/src/styles/components/_curtains.less +++ b/src/styles/components/_curtains.less @@ -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; diff --git a/src/styles/components/_notifications.less b/src/styles/components/_notifications.less new file mode 100644 index 0000000..7dc9697 --- /dev/null +++ b/src/styles/components/_notifications.less @@ -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; + } +}