diff --git a/components/notifications/.npmignore b/components/notifications/.npmignore
new file mode 100644
index 00000000..2b07fde8
--- /dev/null
+++ b/components/notifications/.npmignore
@@ -0,0 +1,3 @@
+src
+rollup.config.js
+tsconfig.json
diff --git a/components/notifications/README.md b/components/notifications/README.md
new file mode 100644
index 00000000..349d0710
--- /dev/null
+++ b/components/notifications/README.md
@@ -0,0 +1,245 @@
+# `@byndyusoft-ui/notifications`
+
+
+## Installation
+
+```sh
+npm i @byndyusoft-ui/notifications
+# or
+yarn add @byndyusoft-ui/notifications
+```
+
+
+## Usage
+
+```tsx
+import React from 'react';
+import NotificationsManager, {useNotifications, INotificationData} from '@byndyusoft-ui/notifications';
+
+
+const SomeComponent = () => {
+ const {success} = useNotifications();
+
+ const onShowSuccessNotification = () => {
+ success({
+ title: 'Some string',
+ message: 'Some ReactNode...',
+ footer: 'Some ReactNode...',
+ })
+ }
+
+ return (
+
+ Show success
+
+ )
+}
+
+const NotificationComponent = ({data}: INotificationData) => (
+
+
{data.title}
+ {data?.message &&
{data.message}
}
+ {data?.footer &&
{data.footer}
}
+ {data?.isClosable && (
+
+ ✖️
+
+ )}
+
+)
+
+const App = () => {
+ return (
+
+
+
+
+ )
+}
+
+export default App;
+```
+
+# API
+
+## NotificationsManager
+
+### Props
+
+| Props | Type | Default | Description |
+|------------------------------|-------------------------------|----------------|--------------------------------------------------------------------------------------|
+| position | `TNotificationPosition` | "top-right" | One of top-right, top-center, top-left, bottom-right, bottom-center, bottom-left |
+| duration | number | 5000 | Duration in ms to close the notification. |
+| isClosable | boolean | true | Allows manual closing of the notification. |
+| isAutoClosable | boolean | true | Automatically closes the notification after the specified duration. |
+| isPauseWhenPageHidden | boolean | true | Pauses the notification timer when the page is hidden. |
+| isPauseOnHover | boolean | true | Pauses the notification timer on hover. |
+| isCloseOnClick | boolean | false | Closes the notification when clicked. |
+| isNewestOnTop | boolean | false | Displays new notifications on top of older ones. |
+| limit | number | - | The maximum number of notifications displayed simultaneously at a specific position. |
+| renderNotification | `TNotificationRender` | - | Component for rendering notifications. |
+| renderNotificationComponents | `TNotificationRecordRender` | - | Object with components for rendering different types of notifications. |
+| offset | `TPlatformValue` | 24 | Offset for web and mobile versions. |
+| gap | `TPlatformValue` | 8 | Gap between notifications for web and mobile versions. |
+| width | string \| number | 356 | Width of the notifications. |
+| className | string | - | Class for the notification list container. |
+| classNameItem | string | - | Class for each notification item. |
+| style | CSSProperties | - | Style object for the notification list container. |
+| styleItem | CSSProperties | - | Style object for each notification item. |
+
+### Usages
+
+```tsx
+import NotificationsManager from '@byndyusoft-ui/notifications';
+
+
+
+ renderNotificationComponents={{
+ success: SuccessNotification,
+ danger: DangerNotification,
+ info: InfoNotification,
+ warning: WarningNotification,
+ ordinary: OrdinaryNotification,
+ }}
+/>
+```
+
+> #### Notes
+> - `renderNotificationComponents` allows you to set a component personally for each `theme` of notification (e.g., `success`, `danger`, `info`, `warning`, `ordinary`).
+> - If you use `renderNotification`, then `renderNotificationComponents` will override the components for all notification themes.
+
+## Emitter (actions from useNotifications)
+
+### Params create
+
+> When displaying a notification , the options are inherited from the container. Notification options supersede Notifications Manager props
+
+| Options | Type | Description |
+|----------------|-------------------------|---------------------------------------------------------------------|
+| id | string \| number | Identifier for the notification. |
+| position | `TNotificationPosition` | Position of the notification. |
+| duration | number | Duration in ms to close notification |
+| title | string | Title of the notification. |
+| message | ReactNode | Main content of the notification. |
+| footer | ReactNode | Footer content of the notification. |
+| isClosable | boolean | Allows manual closing of the notification. |
+| isAutoClosable | boolean | Automatically closes the notification after the specified duration. |
+| isCloseOnClick | boolean | Closes the notification when clicked. |
+| render | `TNotificationRender` | Component for rendering the notification. |
+| theme | `TNotificationTheme` | Theme of the notification. |
+| afterClose | () => void | Callback function to be executed after the notification is closed. |
+| className | string | Class name for the notifications component. |
+| classNameItem | CSSProperties | Class for the notification container. |
+| style | CSSProperties | Style object for the notifications component. |
+| styleItem | CSSProperties | Style object for the notification container. |
+
+### Usages
+
+```js
+import { useNotifications } from '@byndyusoft-ui/notifications';
+
+
+const options = {
+ position: "top-left",
+ duration: 3000,
+ title: 'Some string',
+ message: 'Some ReactNode...',
+ footer: 'Some ReactNode...',
+ isClosable: true,
+ isAutoClosable: true,
+ isCloseOnClick: false,
+ className: 'notification',
+ style: {/* ...styles notification */ },
+ classNameItem: 'contaider-item',
+ styleItem: {/* ...styles contaider*/ },
+ afterClose: () => console.log("afterClose"),
+};
+
+const notifications = useNotifications()
+
+// create new
+notifications.success(options);
+notifications.danger(options);
+notifications.info(options);
+notifications.warning(options);
+notifications.ordinary(options);
+notifications.custom(({ data, index }) => {data.title}
, { /* ...options */ });
+// update by id
+notifications.update('id', options);
+// deleting with animation
+notifications.dismiss('id');
+notifications.dismissAll();
+// deleting without animation
+notifications.remove('id');
+notifications.removeAll();
+```
+
+### Types
+
+#### TNotificationPosition
+
+```ts
+type TNotificationPosition =
+ | 'top-left'
+ | 'top-right'
+ | 'bottom-left'
+ | 'bottom-right'
+ | 'top-center'
+ | 'bottom-center';
+```
+
+#### TNotificationRender
+
+```ts
+interface INotificationData {
+ data: INotificationsItem;
+ index: number;
+}
+
+type TNotificationRender = ((params: INotificationData) => ReactNode) | ReactNode;
+```
+
+#### TNotificationRecordRender
+
+```ts
+type TNotificationRecordRender = Partial, TNotificationRender>>;
+```
+
+#### TPlatformValue
+
+```ts
+type TPlatformValue =
+ | string
+ | number
+ | {
+ web: string | number;
+ mobile: string | number;
+};
+```
+
+#### TNotificationTheme
+```ts
+type TNotificationTheme = 'success' | 'info' | 'warning' | 'danger' | 'custom' | 'ordinary';
+```
diff --git a/components/notifications/package.json b/components/notifications/package.json
new file mode 100644
index 00000000..0d2fca0b
--- /dev/null
+++ b/components/notifications/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@byndyusoft-ui/notifications",
+ "version": "0.0.1",
+ "description": "Byndyusoft UI Portal React Component",
+ "keywords": [
+ "byndyusoft",
+ "byndyusoft-ui",
+ "react",
+ "notifications"
+ ],
+ "author": "",
+ "homepage": "",
+ "license": "ISC",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/Byndyusoft/ui.git"
+ },
+ "scripts": {
+ "build": "tsc --project tsconfig.build.json",
+ "clean": "rimraf dist",
+ "lint": "eslint src --config ../../eslint.config.js",
+ "test": "jest --config ../../jest.config.js --roots components/notifications/src"
+ },
+ "bugs": {
+ "url": "https://github.com/Byndyusoft/ui/issues"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "devDependencies": {
+ "@byndyusoft-ui/types": "^0.1.0"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17",
+ "@byndyusoft-ui/pub-sub": "^0.0.1"
+ }
+}
diff --git a/components/notifications/rollup.config.js b/components/notifications/rollup.config.js
new file mode 100644
index 00000000..231a2aca
--- /dev/null
+++ b/components/notifications/rollup.config.js
@@ -0,0 +1,15 @@
+import typescript from '@rollup/plugin-typescript';
+import baseConfig from '../../rollup.base.config';
+
+export default {
+ ...baseConfig,
+ input: ['src/index.ts'],
+ plugins: [
+ ...baseConfig.plugins,
+ typescript({
+ tsconfig: './tsconfig.json',
+ module: 'ESNext',
+ exclude: ['src/**/*.stories.tsx', 'src/__tests__/*', 'node_modules']
+ })
+ ]
+};
diff --git a/components/notifications/src/Notifications.types.ts b/components/notifications/src/Notifications.types.ts
new file mode 100644
index 00000000..2cdadcb2
--- /dev/null
+++ b/components/notifications/src/Notifications.types.ts
@@ -0,0 +1,111 @@
+import { CSSProperties, ReactNode } from 'react';
+import { Callback } from '@byndyusoft-ui/types';
+
+export type TNotificationItemId = string | number;
+
+export type TNotificationTheme = 'success' | 'info' | 'warning' | 'danger' | 'custom' | 'ordinary';
+
+export type TNotificationPosition =
+ | 'top-left'
+ | 'top-right'
+ | 'bottom-left'
+ | 'bottom-right'
+ | 'top-center'
+ | 'bottom-center';
+
+export interface INotification {
+ theme?: TNotificationTheme;
+ title?: string;
+ message?: ReactNode;
+ footer?: ReactNode;
+ isClosable?: boolean;
+ onClose?: Callback;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export interface INotificationData {
+ data: INotificationsItem;
+ index: number;
+}
+
+export type TNotificationRender = ((params: INotificationData) => ReactNode) | ReactNode;
+
+export type TNotificationRecordRender = Partial, TNotificationRender>>;
+
+export interface INotificationsItem extends INotification {
+ id: TNotificationItemId;
+ position?: TNotificationPosition;
+ duration?: number;
+ isClosable?: boolean;
+ isAutoClosable?: boolean;
+ dismiss?: boolean;
+ isCloseOnClick?: boolean;
+ render?: TNotificationRender;
+ afterClose?: Callback;
+ classNameItem?: string;
+ styleItem?: CSSProperties;
+}
+
+export type TCreateNotificationParams = Partial>;
+
+export type TThemedNotificationParams = Omit, 'theme' | 'render' | 'onClose' | 'dismiss'>;
+
+export type TCustomNotificationParams = Partial<
+ Omit
+>;
+
+export type TPlatformValue =
+ | string
+ | number
+ | {
+ web: string | number;
+ mobile: string | number;
+ };
+
+export interface INotificationsManagerProps {
+ position?: TNotificationPosition;
+ duration?: number;
+ isClosable?: boolean;
+ isAutoClosable?: boolean;
+ isPauseWhenPageHidden?: boolean;
+ isPauseOnHover?: boolean;
+ isCloseOnClick?: boolean;
+ isNewestOnTop?: boolean;
+ limit?: number;
+ renderNotification?: TNotificationRender;
+ renderNotificationComponents?: TNotificationRecordRender;
+ offset?: TPlatformValue;
+ gap?: TPlatformValue;
+ width?: string | number;
+ className?: string;
+ classNameItem?: string;
+ style?: CSSProperties;
+ styleItem?: CSSProperties;
+}
+
+export interface IUseNotificationsStateParams {
+ position: TNotificationPosition;
+ isCloseOnClick: boolean;
+ isClosable: boolean;
+ limit?: number;
+ isNewestOnTop?: boolean;
+ renderNotification?: TNotificationRender;
+ renderNotificationComponents?: TNotificationRecordRender;
+}
+
+export interface INotificationsItemProps {
+ children: ReactNode;
+ duration: number;
+ position: TNotificationPosition;
+ removeNotification: () => void;
+ dismissNotification: () => void;
+ theme?: TNotificationTheme;
+ isAutoClosable?: boolean;
+ dismiss?: boolean;
+ isPauseToRemove?: boolean;
+ onClick?: () => void;
+ afterClose?: () => void;
+ className?: string;
+ style?: CSSProperties;
+}
diff --git a/components/notifications/src/NotificationsManager.module.css b/components/notifications/src/NotificationsManager.module.css
new file mode 100644
index 00000000..26ee5367
--- /dev/null
+++ b/components/notifications/src/NotificationsManager.module.css
@@ -0,0 +1,78 @@
+.notifications {
+ margin: 0;
+ padding: 0;
+ position: fixed;
+ z-index: 99999;
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-mobile);
+ width: calc(100% - var(--offset-mobile) * 2);
+ list-style-type: none;
+}
+
+.top-left,
+.top-right,
+.top-center {
+ top: var(--offset-mobile);
+ left: var(--offset-mobile);
+ right: var(--offset-mobile);
+ transform: none;
+}
+
+.bottom-left,
+.bottom-right,
+.bottom-center {
+ bottom: var(--offset-mobile);
+ left: var(--offset-mobile);
+ right: var(--offset-mobile);
+ transform: none;
+}
+
+@media (min-width: 768px) {
+ .notifications {
+ width: var(--width);
+ gap: var(--gap);
+ }
+
+ .top-left {
+ top: var(--offset);
+ left: var(--offset);
+ right: auto;
+ transform: none;
+ }
+
+ .top-right {
+ top: var(--offset);
+ right: var(--offset);
+ left: auto;
+ transform: none;
+ }
+
+ .bottom-left {
+ bottom: var(--offset);
+ left: var(--offset);
+ right: auto;
+ transform: none;
+ }
+
+ .bottom-right {
+ bottom: var(--offset);
+ right: var(--offset);
+ left: auto;
+ transform: none;
+ }
+
+ .top-center {
+ top: var(--offset);
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
+ }
+
+ .bottom-center {
+ bottom: var(--offset);
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
+ }
+}
diff --git a/components/notifications/src/NotificationsManager.tsx b/components/notifications/src/NotificationsManager.tsx
new file mode 100644
index 00000000..a631cde1
--- /dev/null
+++ b/components/notifications/src/NotificationsManager.tsx
@@ -0,0 +1,111 @@
+import React, { CSSProperties, useState } from 'react';
+import Portal from '@byndyusoft-ui/portal';
+import { normalizeCssValue, normalizePlatformValue } from './utilities';
+import { NotificationsItem } from './partials/NotificationsItem';
+import { useIsDocumentHidden } from './hooks/useDocumentVisibility';
+import { INotificationsManagerProps, TNotificationPosition } from './Notifications.types';
+import { useNotificationsManager } from './hooks/useNotificationsManager';
+import { cn } from './utilities';
+import { NOTIFICATION_LIFETIME, OFFSET, POSITION, NOTIFICATION_WIDTH, GAP } from './constants';
+import styles from './NotificationsManager.module.css';
+
+const NotificationsManager = (props: INotificationsManagerProps) => {
+ const {
+ position = POSITION,
+ duration = NOTIFICATION_LIFETIME,
+ limit,
+ renderNotification,
+ renderNotificationComponents,
+ isPauseWhenPageHidden = true,
+ isPauseOnHover = true,
+ isClosable = true,
+ isAutoClosable = true,
+ isCloseOnClick = false,
+ isNewestOnTop = false,
+ gap,
+ offset,
+ width,
+ className,
+ classNameItem,
+ style,
+ styleItem
+ } = props;
+
+ const [focusedPosition, setFocusedPosition] = useState(null); // Хранение текущей позиции
+
+ const {
+ notifications,
+ possiblePositions,
+ onClickAndClose,
+ dismissNotification,
+ removeNotification,
+ renderNotificationItem,
+ prepareNotifications
+ } = useNotificationsManager({
+ position,
+ isCloseOnClick,
+ isClosable,
+ isNewestOnTop,
+ limit,
+ renderNotification,
+ renderNotificationComponents
+ });
+
+ const isDocumentHidden = useIsDocumentHidden();
+
+ if (!notifications?.length) return null;
+
+ return (
+
+ {possiblePositions?.map((position, positionIndex) => {
+ const preparedNotifications = prepareNotifications(position, positionIndex);
+
+ if (!preparedNotifications?.length) return null;
+
+ return (
+ setFocusedPosition(position)}
+ onMouseLeave={() => setFocusedPosition(null)}
+ >
+ {preparedNotifications?.map((item, itemIndex) => (
+
+ {renderNotificationItem(item, itemIndex)}
+
+ ))}
+
+ );
+ })}
+
+ );
+};
+
+export default NotificationsManager;
diff --git a/components/notifications/src/__stories__/Notifications.docs.mdx b/components/notifications/src/__stories__/Notifications.docs.mdx
new file mode 100644
index 00000000..9549f4f4
--- /dev/null
+++ b/components/notifications/src/__stories__/Notifications.docs.mdx
@@ -0,0 +1,11 @@
+import { Meta, Markdown, Canvas, Source, ArgsTable } from '@storybook/blocks';
+import Readme from '../../README.md';
+import * as NotificationsStories from './Notifications.stories';
+
+
+
+{Readme}
+
+# Story
+
+
diff --git a/components/notifications/src/__stories__/Notifications.stories.module.css b/components/notifications/src/__stories__/Notifications.stories.module.css
new file mode 100644
index 00000000..6d9a384f
--- /dev/null
+++ b/components/notifications/src/__stories__/Notifications.stories.module.css
@@ -0,0 +1,93 @@
+.container {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.col {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.row > button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 6px 12px;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ cursor: pointer;
+ transition: border-color 0.25s;
+ white-space: nowrap;
+}
+.row > button:hover {
+ border-color: #646cff;
+}
+
+.row > .button_active {
+ background: #646cff;
+ color: white;
+}
+
+.notification {
+ box-sizing: border-box;
+ position: relative;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px;
+ border-radius: 6px;
+ color: black;
+}
+
+.notification.success {
+ background: #48ffc8;
+}
+
+.notification.danger {
+ background: #ff484e;
+}
+
+.notification.info {
+ background: #48b9ff;
+}
+
+.notification.warning {
+ background: #ff8b48;
+}
+
+.notification.ordinary {
+ background: #d7d7d7;
+}
+
+.notification_close_btn {
+ box-sizing: border-box;
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.code {
+ padding: 10px;
+ height: 250px;
+ border-radius: 5px;
+ overflow-x: auto;
+ font-family: 'Courier New, Courier, monospace', serif;
+ font-size: 16px;
+ background-color: #e6e6e6;
+ color: #333;
+}
diff --git a/components/notifications/src/__stories__/Notifications.stories.tsx b/components/notifications/src/__stories__/Notifications.stories.tsx
new file mode 100644
index 00000000..34457877
--- /dev/null
+++ b/components/notifications/src/__stories__/Notifications.stories.tsx
@@ -0,0 +1,260 @@
+import React, { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
+import { StoryObj } from '@storybook/react';
+import NotificationsManager, { useNotifications, INotificationData } from '..';
+import { TNotificationPosition, TNotificationTheme } from '../Notifications.types';
+import styles from './Notifications.stories.module.css';
+
+const positions: TNotificationPosition[] = [
+ 'top-right',
+ 'top-left',
+ 'top-center',
+ 'bottom-right',
+ 'bottom-left',
+ 'bottom-center'
+];
+const themes: TNotificationTheme[] = ['success', 'danger', 'info', 'warning', 'ordinary', 'custom'];
+
+const NotificationComponent = ({ data }: INotificationData): JSX.Element => {
+ return (
+
+
{data.title}
+ {data?.message &&
{data.message}
}
+ {data?.footer &&
{data.footer}
}
+ {data?.isClosable && (
+
+ ✖️
+
+ )}
+
+ );
+};
+
+const Template = (): JSX.Element => {
+ const [activePosition, setActivePosition] = useState('top-right');
+ const [activeTheme, setActiveTheme] = useState('ordinary');
+ const [duration, setDuration] = useState(5000);
+ const [limit, setLimit] = useState(0);
+ const [isCloseOnClick, setIsCloseOnClick] = useState(false);
+ const [isPauseWhenPageHidden, setIsPauseWhenPageHidden] = useState(true);
+ const [isPauseOnHover, setIsPauseOnHover] = useState(true);
+ const [isNewestOnTop, setIsNewestOnTop] = useState(false);
+ const [isClosable, setIsClosable] = useState(true);
+ const [isAutoClosable, setIsAutoClosable] = useState(true);
+
+ const { custom, create, dismissAll } = useNotifications();
+
+ const handleChangeInputNumber = (set: Dispatch>) => (e: ChangeEvent) => {
+ const numericValue = e.target.value.replace(/[^0-9]/g, '');
+ set(Number(numericValue));
+ };
+
+ const onShowNotification = () => {
+ if (activeTheme === 'custom') {
+ custom(({ index }) => {index} | Custom notification
, {
+ position: activePosition,
+ duration,
+ isCloseOnClick,
+ isAutoClosable
+ });
+ } else {
+ create({
+ position: activePosition,
+ theme: activeTheme,
+ title: `🔔 Notification ${activeTheme}`,
+ message: 'Notification message',
+ footer: new Date().toDateString(),
+ duration,
+ isCloseOnClick,
+ isClosable,
+ isAutoClosable
+ });
+ }
+ };
+
+ const renderEmitterCode = () => {
+ const baseCode = (type?: string) => `const { ${type} } = useNotifications();
+
+${type}({
+ title: \`🔔 Notification ${activeTheme}\`,
+ message: 'Notification message',
+ footer: new Date().toDateString(),
+ position: ${activePosition},
+ delay: ${duration},
+ isCloseOnClick: ${isCloseOnClick}
+ isClosable: ${isClosable}
+ isAutoClosable: ${isAutoClosable}
+});`;
+
+ if (activeTheme === 'custom') {
+ return `const { custom } = useNotifications();
+
+custom(({data}) => {data.id} | Custom notification
,
+ {
+ position: ${activePosition},
+ duration: ${duration},
+ isCloseOnClick: ${isCloseOnClick}
+ isAutoClosable: ${isAutoClosable}
+ }
+);`;
+ }
+
+ return baseCode(activeTheme);
+ };
+
+ return (
+
+
Notifications
+
+
+
+
+
+
Notifications Container
+
+
+ {` `}
+
+
+
+
+
Notification Emitter
+
+ {renderEmitterCode()}
+
+
+
+
+
+
+
Position
+
+ {positions.map(position => (
+ setActivePosition(position)}
+ >
+ {position}
+
+ ))}
+
+
+
+
Theme
+
+ {themes.map(theme => (
+ setActiveTheme(theme)}
+ >
+ {theme}
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export const NotificationsStory: StoryObj = {
+ name: 'Notifications story',
+ render: Template,
+ args: {}
+};
+
+export default {
+ title: 'components/Notifications'
+};
diff --git a/components/notifications/src/__tests__/NotificationsManager.tests.tsx b/components/notifications/src/__tests__/NotificationsManager.tests.tsx
new file mode 100644
index 00000000..74f0de37
--- /dev/null
+++ b/components/notifications/src/__tests__/NotificationsManager.tests.tsx
@@ -0,0 +1,290 @@
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import { render, waitFor } from '@testing-library/react';
+import { INotificationData, INotificationsManagerProps, TThemedNotificationParams } from '../Notifications.types';
+import NotificationsManager, { useNotifications } from '../index';
+import { TIME_BEFORE_UNMOUNT } from '../constants';
+
+const BUTTON_NAMES = {
+ CREATE: 'Create notification',
+ UPDATE: 'Update notification',
+ SUCCESS: 'Show success',
+ DANGER: 'Show danger',
+ INFO: 'Show info',
+ WARNING: 'Show warning',
+ ORDINARY: 'Show ordinary',
+ CUSTOM: 'Show custom',
+ REMOVE: 'Remove notification',
+ REMOVE_ALL: 'Remove all notifications',
+ DISMISS: 'Dismiss notification',
+ DISMISS_ALL: 'Dismiss all notifications'
+};
+
+const NotificationComponent = ({ data }: INotificationData) => (
+
+
{data.title}
+ {data?.message &&
{data.message}
}
+ {data?.footer &&
{data.footer}
}
+ {data?.isClosable && (
+
+ ✖️
+
+ )}
+
+);
+
+const ActionsComponent = (paramsEmitter: TThemedNotificationParams = {}) => {
+ const { create, update, success, danger, info, warning, ordinary, custom, remove, removeAll, dismiss, dismissAll } =
+ useNotifications();
+ return (
+
+
+ create({
+ id: 'id-1',
+ title: 'Title',
+ message: 'Message',
+ footer: 'Footer',
+ ...paramsEmitter
+ })
+ }
+ >
+ {BUTTON_NAMES.CREATE}
+
+
update('id-1', { title: 'New title', ...paramsEmitter })}>
+ {BUTTON_NAMES.UPDATE}
+
+
success({ title: 'Success', ...paramsEmitter })}>{BUTTON_NAMES.SUCCESS}
+
danger({ title: 'Danger', ...paramsEmitter })}>{BUTTON_NAMES.DANGER}
+
info({ title: 'Info', ...paramsEmitter })}>{BUTTON_NAMES.INFO}
+
warning({ title: 'Warning', ...paramsEmitter })}>{BUTTON_NAMES.WARNING}
+
ordinary({ title: 'Ordinary', ...paramsEmitter })}>{BUTTON_NAMES.ORDINARY}
+
custom(Custom
, paramsEmitter)}>{BUTTON_NAMES.CUSTOM}
+
remove('id-1')}>{BUTTON_NAMES.REMOVE}
+
{BUTTON_NAMES.REMOVE_ALL}
+
dismiss('id-1')}>{BUTTON_NAMES.DISMISS}
+
{BUTTON_NAMES.DISMISS_ALL}
+
+ );
+};
+
+const setup = (props: INotificationsManagerProps = {}, paramsEmitter: TThemedNotificationParams = {}) => {
+ return render(
+
+ );
+};
+
+describe('NotificationsManager', () => {
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ test('create notification', async () => {
+ const { getByRole, getByTestId } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE }));
+
+ expect(getByTestId('notification-item')).toBeInTheDocument();
+ expect(getByRole('status')).toBeInTheDocument();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('updates notification', async () => {
+ const { getByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE }));
+
+ expect(getByRole('heading', { level: 3 })).toHaveTextContent('Title');
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.UPDATE }));
+
+ expect(getByRole('heading', { level: 3 })).toHaveTextContent('New title');
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('adds `success` notification', async () => {
+ const { getByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.SUCCESS }));
+
+ expect(getByRole('status')).toBeInTheDocument();
+ expect(getByRole('heading')).toHaveTextContent('Success');
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('adds `info` notification', async () => {
+ const { getByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.INFO }));
+
+ expect(getByRole('status')).toBeInTheDocument();
+ expect(getByRole('heading')).toHaveTextContent('Info');
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('adds `warning` notification', async () => {
+ const { getByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.WARNING }));
+
+ expect(getByRole('status')).toBeInTheDocument();
+ expect(getByRole('heading')).toHaveTextContent('Warning');
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('adds `ordinary` notification', async () => {
+ const { getByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.ORDINARY }));
+
+ expect(getByRole('status')).toBeInTheDocument();
+ expect(getByRole('heading')).toHaveTextContent('Ordinary');
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('adds `danger` notification', async () => {
+ const { getByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER }));
+
+ expect(getByRole('alert')).toBeInTheDocument();
+ expect(getByRole('heading')).toHaveTextContent('Danger');
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('adds `custom` notification', async () => {
+ const { getByRole, getByText, queryByTestId } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CUSTOM }));
+
+ expect(getByText('Custom')).toBeInTheDocument();
+ expect(queryByTestId('notification-item')).not.toBeInTheDocument();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('dismiss notification', async () => {
+ const { getByRole, queryByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE }));
+
+ expect(getByRole('status')).toBeInTheDocument();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DISMISS }));
+
+ await waitFor(
+ () => {
+ expect(queryByRole('status')).not.toBeInTheDocument();
+ },
+ { timeout: TIME_BEFORE_UNMOUNT + 1 }
+ );
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('dismiss all notifications', async () => {
+ const { getByRole, getAllByRole, queryAllByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER }));
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.SUCCESS }));
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.WARNING }));
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER }));
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER }));
+
+ expect(getAllByRole('status')).toHaveLength(2);
+ expect(getAllByRole('alert')).toHaveLength(3);
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DISMISS_ALL }));
+
+ await waitFor(
+ () => {
+ expect(queryAllByRole('status')).toHaveLength(0);
+ expect(queryAllByRole('alert')).toHaveLength(0);
+ },
+ { timeout: TIME_BEFORE_UNMOUNT + 1 }
+ );
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('remove notification', async () => {
+ const { getByRole, queryByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE }));
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE }));
+
+ expect(queryByRole('status')).not.toBeInTheDocument();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+ });
+
+ test('remove all notifications', async () => {
+ const { getByRole, getAllByRole, queryAllByRole } = setup();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.SUCCESS }));
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.WARNING }));
+
+ expect(getAllByRole('status')).toHaveLength(2);
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL }));
+
+ expect(queryAllByRole('status')).toHaveLength(0);
+ });
+
+ test('creates and closes custom notification', async () => {
+ const { getByRole, getByText, queryByRole, getByLabelText } = setup(
+ {},
+ {
+ title: 'test-title',
+ message: 'test-message',
+ footer: 'test-footer',
+ isClosable: true
+ }
+ );
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.SUCCESS }));
+
+ expect(getByRole('status')).toBeInTheDocument();
+ expect(getByText('test-title')).toBeInTheDocument();
+ expect(getByText('test-message')).toBeInTheDocument();
+ expect(getByText('test-footer')).toBeInTheDocument();
+ expect(getByLabelText('close-notification')).toBeInTheDocument();
+
+ await userEvent.click(getByRole('button', { name: 'close-notification' }));
+
+ await waitFor(() => {
+ expect(queryByRole('status')).not.toBeInTheDocument();
+ });
+ });
+ test('limits the number of notifications displayed', async () => {
+ const limit = 2;
+ const { getByRole, getAllByRole, queryByRole } = setup({ limit });
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE }));
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.ORDINARY }));
+
+ expect(getAllByRole('status')).toHaveLength(2);
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER }));
+
+ expect(getAllByRole('status')).toHaveLength(2);
+ expect(queryByRole('alert')).not.toBeInTheDocument();
+
+ await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE }));
+
+ expect(getAllByRole('status')).toHaveLength(1);
+ expect(getAllByRole('alert')).toHaveLength(1);
+ expect(queryByRole('alert')).toBeInTheDocument();
+ });
+});
diff --git a/components/notifications/src/constants.ts b/components/notifications/src/constants.ts
new file mode 100644
index 00000000..5f6d74f5
--- /dev/null
+++ b/components/notifications/src/constants.ts
@@ -0,0 +1,6 @@
+export const POSITION = 'top-right'
+export const GAP = 8;
+export const OFFSET = 24;
+export const NOTIFICATION_WIDTH = 356;
+export const NOTIFICATION_LIFETIME = 5000;
+export const TIME_BEFORE_UNMOUNT = 300;
diff --git a/components/notifications/src/hooks/useDocumentVisibility.ts b/components/notifications/src/hooks/useDocumentVisibility.ts
new file mode 100644
index 00000000..ee79b013
--- /dev/null
+++ b/components/notifications/src/hooks/useDocumentVisibility.ts
@@ -0,0 +1,15 @@
+import React from 'react';
+
+export const useIsDocumentHidden = () => {
+ const [isDocumentHidden, setIsDocumentHidden] = React.useState(document.hidden);
+
+ React.useEffect(() => {
+ const callback = () => {
+ setIsDocumentHidden(document.hidden);
+ };
+ document.addEventListener('visibilitychange', callback);
+ return () => document.removeEventListener('visibilitychange', callback);
+ }, []);
+
+ return isDocumentHidden;
+};
diff --git a/components/notifications/src/hooks/useNotificationsActions.ts b/components/notifications/src/hooks/useNotificationsActions.ts
new file mode 100644
index 00000000..a8e67c34
--- /dev/null
+++ b/components/notifications/src/hooks/useNotificationsActions.ts
@@ -0,0 +1,16 @@
+import { notificationService } from '../services/notifications.service';
+
+export const useNotificationsActions = () => ({
+ create: notificationService.create,
+ update: notificationService.update,
+ success: notificationService.success,
+ danger: notificationService.danger,
+ info: notificationService.info,
+ warning: notificationService.warning,
+ ordinary: notificationService.ordinary,
+ custom: notificationService.custom,
+ remove: notificationService.remove,
+ removeAll: notificationService.removeAll,
+ dismiss: notificationService.dismiss,
+ dismissAll: notificationService.dismissAll
+});
diff --git a/components/notifications/src/hooks/useNotificationsManager.ts b/components/notifications/src/hooks/useNotificationsManager.ts
new file mode 100644
index 00000000..49c02c7f
--- /dev/null
+++ b/components/notifications/src/hooks/useNotificationsManager.ts
@@ -0,0 +1,133 @@
+import { ReactNode, useEffect, useMemo, useState } from 'react';
+import { Callback } from '@byndyusoft-ui/types';
+import {
+ INotificationsItem,
+ TNotificationItemId,
+ TNotificationPosition,
+ IUseNotificationsStateParams,
+ TNotificationRender
+} from '../Notifications.types';
+import { subscriber } from '../services/notificationsPubSub.service';
+import { notificationService } from '../services/notifications.service';
+
+export const useNotificationsManager = (params: IUseNotificationsStateParams) => {
+ const {
+ position,
+ limit,
+ isCloseOnClick,
+ isClosable,
+ isNewestOnTop,
+ renderNotification,
+ renderNotificationComponents
+ } = params;
+
+ const [notifications, setNotifications] = useState>([]);
+
+ const possiblePositions: Array = useMemo(() => {
+ const currentPositions = [position].concat(
+ notifications
+ .filter((n): n is INotificationsItem & { position: TNotificationPosition } => Boolean(n?.position))
+ .map(n => n.position)
+ );
+ return Array.from(new Set(currentPositions));
+ }, [notifications, position]);
+
+ const cuttingByLimit = (items: Array, isNewestOnTop?: boolean): Array => {
+ if (!limit) return items;
+
+ return isNewestOnTop ? items.slice(-limit) : items.slice(0, limit);
+ };
+
+ const onClickAndClose = (itemId: TNotificationItemId, isCloseOnClickItem?: boolean): Callback | undefined => {
+ if (isCloseOnClickItem === false || !isCloseOnClick) return undefined;
+
+ return () => notificationService.dismiss(itemId);
+ };
+
+ const dismissNotification = (itemId: TNotificationItemId) => (): void => {
+ notificationService.dismiss(itemId);
+ };
+
+ const removeNotification = (itemId: TNotificationItemId) => (): void => {
+ notificationService.remove(itemId);
+ };
+
+ const executeRender = (
+ render: TNotificationRender | undefined,
+ data: INotificationsItem,
+ index: number
+ ): ReactNode | null => {
+ return typeof render === 'function' ? render({ data, index }) : render || null;
+ };
+
+ const prepareNotificationProps = (item: INotificationsItem): INotificationsItem => {
+ return {
+ ...item,
+ isClosable: item?.isClosable || isClosable,
+ onClose: () => {
+ notificationService.dismiss(item.id);
+ }
+ };
+ };
+
+ const renderNotificationItem = (notificationItem: INotificationsItem, index: number): ReactNode | null => {
+ const prepareItem = prepareNotificationProps(notificationItem);
+
+ if (prepareItem.render) {
+ return executeRender(prepareItem.render, prepareItem, index);
+ }
+
+ if (
+ typeof renderNotificationComponents === 'object' &&
+ prepareItem.theme &&
+ prepareItem.theme !== 'custom' &&
+ renderNotificationComponents[prepareItem.theme]
+ ) {
+ return executeRender(renderNotificationComponents[prepareItem.theme], prepareItem, index);
+ }
+
+ if (renderNotification) {
+ return executeRender(renderNotification, prepareItem, index);
+ }
+
+ return null;
+ };
+
+ const getNotificationsByPosition = (
+ positionItem: TNotificationPosition,
+ positionIndex: number
+ ): Array =>
+ notifications.filter(
+ notification => (!notification.position && positionIndex === 0) || notification.position === positionItem
+ );
+
+ const prepareNotifications = (
+ position: TNotificationPosition,
+ positionIndex: number
+ ): Array => {
+ const notificationsByPosition = getNotificationsByPosition(position, positionIndex);
+
+ return cuttingByLimit(
+ isNewestOnTop ? notificationsByPosition.reverse() : notificationsByPosition,
+ isNewestOnTop
+ );
+ };
+
+ useEffect(() => {
+ const listener = (newNotifications: Array) => {
+ setNotifications([...newNotifications]);
+ };
+
+ return subscriber(listener);
+ }, [setNotifications]);
+
+ return {
+ possiblePositions,
+ notifications,
+ onClickAndClose,
+ dismissNotification,
+ removeNotification,
+ renderNotificationItem,
+ prepareNotifications
+ };
+};
diff --git a/components/notifications/src/index.ts b/components/notifications/src/index.ts
new file mode 100644
index 00000000..21f93202
--- /dev/null
+++ b/components/notifications/src/index.ts
@@ -0,0 +1,5 @@
+export { default } from './NotificationsManager';
+
+export type { INotificationData } from './Notifications.types';
+export { default as NotificationsManager } from './NotificationsManager';
+export { useNotificationsActions as useNotifications } from './hooks/useNotificationsActions';
diff --git a/components/notifications/src/partials/NotificationsItem.module.css b/components/notifications/src/partials/NotificationsItem.module.css
new file mode 100644
index 00000000..347f7eaa
--- /dev/null
+++ b/components/notifications/src/partials/NotificationsItem.module.css
@@ -0,0 +1,144 @@
+.notification_item {
+ overflow: hidden;
+ position: relative;
+ opacity: 1;
+ height: auto;
+}
+
+.notification_item.pointer {
+ cursor: pointer;
+}
+
+.animation_bottom_left,
+.animation_top_left {
+ animation: slideInRight 0.3s ease-out forwards;
+}
+
+.animation_bottom_left.hidden,
+.animation_top_left.hidden {
+ animation: slideOutLeft 0.2s ease-in forwards, collapseHeight 0.2s ease-out 0.1s forwards;
+}
+
+.animation_bottom_right,
+.animation_top_right {
+ animation: slideInLeft 0.3s ease-out forwards;
+}
+
+.animation_bottom_right.hidden,
+.animation_top_right.hidden {
+ animation: slideOutRight 0.2s ease-out forwards, collapseHeight 0.2s ease-out 0.1s forwards;
+}
+
+.animation_top_center {
+ animation: slideInBottom 0.2s ease-out forwards;
+}
+
+.animation_top_center.hidden {
+ animation: slideOutTop 0.2s ease-out forwards, collapseHeight 0.2s ease-out 0.1s forwards;
+}
+
+.animation_bottom_center {
+ animation: slideInTop 0.3s ease-out forwards;
+}
+
+.animation_bottom_center.hidden {
+ animation: slideOutBottom 0.2s ease-out forwards, collapseHeight 0.2s ease-out 0.1s forwards;
+}
+
+
+@keyframes collapseHeight {
+ 0% {
+ max-height: 800px;
+ }
+ 100% {
+ max-height: 0;
+ }
+}
+
+@keyframes slideInLeft {
+ 0% {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ 100% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideOutRight {
+ 0% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+}
+
+@keyframes slideInRight {
+ 0% {
+ transform: translateX(-100%);
+ opacity: 0;
+ }
+ 100% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideOutLeft {
+ 0% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateX(-100%);
+ opacity: 0;
+ }
+}
+
+@keyframes slideInBottom {
+ 0% {
+ transform: translateY(-100%);
+ opacity: 0;
+ }
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideOutTop {
+ 0% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(-100%);
+ opacity: 0;
+ }
+}
+
+@keyframes slideInTop {
+ 0% {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideOutBottom {
+ 0% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+}
diff --git a/components/notifications/src/partials/NotificationsItem.tsx b/components/notifications/src/partials/NotificationsItem.tsx
new file mode 100644
index 00000000..b1015f2d
--- /dev/null
+++ b/components/notifications/src/partials/NotificationsItem.tsx
@@ -0,0 +1,103 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { TIME_BEFORE_UNMOUNT } from '../constants';
+import { cn } from '../utilities';
+import { INotificationsItemProps, TNotificationPosition } from '../Notifications.types';
+import styles from './NotificationsItem.module.css';
+
+const animationClasses: Record = {
+ 'bottom-left': styles.animation_bottom_left,
+ 'top-left': styles.animation_top_left,
+ 'bottom-right': styles.animation_bottom_right,
+ 'top-right': styles.animation_top_right,
+ 'top-center': styles.animation_top_center,
+ 'bottom-center': styles.animation_bottom_center
+};
+
+export const NotificationsItem = (props: INotificationsItemProps) => {
+ const [isFadingOut, setIsFadingOut] = useState(false);
+
+ const {
+ dismiss,
+ children,
+ position,
+ theme,
+ duration,
+ isAutoClosable,
+ isPauseToRemove,
+ dismissNotification,
+ removeNotification,
+ className,
+ style,
+ onClick,
+ afterClose
+ } = props;
+
+ const timerStartRef = useRef(0);
+ const remainingTime = useRef(duration);
+
+ const closeNotification = useCallback(() => {
+ setIsFadingOut(true);
+ afterClose?.();
+
+ setTimeout(() => {
+ removeNotification();
+ }, TIME_BEFORE_UNMOUNT);
+ }, []);
+
+ useEffect(() => {
+ if (!isAutoClosable) return;
+ remainingTime.current = duration;
+ }, [isAutoClosable, duration]);
+
+ useEffect(() => {
+ if (!isAutoClosable || duration === Infinity || duration < 0) return;
+
+ let timeoutId: ReturnType;
+
+ const pauseTimer = () => {
+ if (timerStartRef.current) {
+ const elapsedTime = new Date().getTime() - timerStartRef.current;
+
+ remainingTime.current -= elapsedTime;
+ }
+ };
+
+ const startTimer = () => {
+ if (remainingTime.current === Infinity) return;
+
+ timerStartRef.current = new Date().getTime();
+
+ timeoutId = setTimeout(dismissNotification, remainingTime.current);
+ };
+
+ if (isPauseToRemove) {
+ pauseTimer();
+ } else {
+ startTimer();
+ }
+
+ return () => clearTimeout(timeoutId);
+ }, [isPauseToRemove, closeNotification, isAutoClosable]);
+
+ useEffect(() => {
+ if (dismiss) {
+ closeNotification();
+ }
+ }, [dismiss]);
+
+ const classes = cn(
+ styles.notification_item,
+ animationClasses[position],
+ isFadingOut ? styles.hidden : '',
+ onClick ? styles.pointer : '',
+ className
+ );
+
+ const containerRole = theme === 'danger' ? 'alert' : 'status';
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/components/notifications/src/services/notifications.service.ts b/components/notifications/src/services/notifications.service.ts
new file mode 100644
index 00000000..62e3b5af
--- /dev/null
+++ b/components/notifications/src/services/notifications.service.ts
@@ -0,0 +1,111 @@
+import {
+ INotificationsItem,
+ TCreateNotificationParams,
+ TCustomNotificationParams,
+ TNotificationItemId,
+ TNotificationRender,
+ TThemedNotificationParams
+} from '../Notifications.types';
+import { publisher } from './notificationsPubSub.service';
+
+let notificationCounter = 1;
+
+class NotificationsService {
+ private notifications: Array = [];
+ private readonly notify: () => void;
+
+ constructor(publisher: (data: Array) => void) {
+ this.notify = () => publisher(this.notifications);
+ }
+
+ create = (partial: TCreateNotificationParams): TNotificationItemId => {
+ const id = partial?.id || notificationCounter++;
+ const theme = partial?.theme || 'ordinary';
+ const index = this.notifications.findIndex(item => item.id === id);
+ const updatedItem = {
+ ...this.notifications[index],
+ ...partial,
+ id,
+ theme
+ };
+
+ if (index !== -1) {
+ this.notifications[index] = updatedItem;
+ } else {
+ this.notifications.push(updatedItem);
+ }
+
+ this.notify();
+
+ return id;
+ };
+
+ update = (notificationId: TNotificationItemId, patch: Partial): void => {
+ const index = this.notifications.findIndex(notification => notification.id === notificationId);
+
+ if (index === -1) return;
+
+ Object.assign(this.notifications[index], patch);
+
+ this.notify();
+ };
+
+ ordinary = (params: TThemedNotificationParams): TNotificationItemId => {
+ return this.create({ ...params, theme: 'ordinary' });
+ };
+
+ success = (params: TThemedNotificationParams): TNotificationItemId => {
+ return this.create({ ...params, theme: 'success' });
+ };
+
+ danger = (params: TThemedNotificationParams): TNotificationItemId => {
+ return this.create({ ...params, theme: 'danger' });
+ };
+
+ warning = (params: TThemedNotificationParams): TNotificationItemId => {
+ return this.create({ ...params, theme: 'warning' });
+ };
+
+ info = (params: TThemedNotificationParams): TNotificationItemId => {
+ return this.create({ ...params, theme: 'info' });
+ };
+
+ custom = (render: TNotificationRender, params: TCustomNotificationParams = {}): TNotificationItemId => {
+ return this.create({ ...params, render, theme: 'custom' });
+ };
+
+ remove = (id: TNotificationItemId): void => {
+ const index = this.notifications.findIndex(notification => notification.id === id);
+
+ this.notifications = [...this.notifications.slice(0, index), ...this.notifications.slice(index + 1)];
+
+ this.notify();
+ };
+
+ removeAll = (): void => {
+ this.notifications = [];
+
+ this.notify();
+ };
+
+ dismiss = (id: TNotificationItemId): void => {
+ this.notifications = this.notifications.map(notification => {
+ if (id === notification.id) {
+ return { ...notification, dismiss: true };
+ }
+ return notification;
+ });
+
+ this.notify();
+ };
+
+ dismissAll = (): void => {
+ this.notifications = this.notifications.map(notification => {
+ return { ...notification, dismiss: true };
+ });
+
+ this.notify();
+ };
+}
+
+export const notificationService = new NotificationsService(publisher);
diff --git a/components/notifications/src/services/notificationsPubSub.service.ts b/components/notifications/src/services/notificationsPubSub.service.ts
new file mode 100644
index 00000000..906a2948
--- /dev/null
+++ b/components/notifications/src/services/notificationsPubSub.service.ts
@@ -0,0 +1,21 @@
+import PubSub from '@byndyusoft-ui/pub-sub';
+import { Callback } from '@byndyusoft-ui/types';
+import { INotificationsItem } from '../Notifications.types';
+
+type TNotificationsPubSubInstance = {
+ updateState: (data: Array) => void;
+};
+
+const INSTANCE_NAME = 'notifications';
+
+const notificationsPubSubInstance = PubSub.getInstance(INSTANCE_NAME);
+
+export const publisher = (dataItems: Array): void => {
+ notificationsPubSubInstance.publish('updateState', dataItems);
+};
+
+export const subscriber = (listener: Callback>): Callback => {
+ notificationsPubSubInstance.subscribe('updateState', listener);
+
+ return () => notificationsPubSubInstance.unsubscribe('updateState', listener);
+};
diff --git a/components/notifications/src/utilities.ts b/components/notifications/src/utilities.ts
new file mode 100644
index 00000000..73d40bde
--- /dev/null
+++ b/components/notifications/src/utilities.ts
@@ -0,0 +1,21 @@
+import { TPlatformValue } from './Notifications.types';
+
+export function cn(...classes: Array): string {
+ return classes.filter(Boolean).join(' ');
+}
+
+export const normalizeCssValue = (value: number | string): number | string => {
+ return typeof value === 'number' ? `${value}px` : value;
+};
+
+export const normalizePlatformValue = (
+ type: 'web' | 'mobile',
+ defaultValue: number,
+ platformValue?: TPlatformValue
+): string | number => {
+ if (typeof platformValue === 'number' || typeof platformValue === 'string') {
+ return normalizeCssValue(platformValue ?? defaultValue);
+ }
+
+ return normalizeCssValue(platformValue?.[type] ?? defaultValue);
+};
diff --git a/components/notifications/tsconfig.build.json b/components/notifications/tsconfig.build.json
new file mode 100644
index 00000000..8623abd9
--- /dev/null
+++ b/components/notifications/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["src/*.tests.ts", "src/*.stories.*"]
+}
diff --git a/components/notifications/tsconfig.json b/components/notifications/tsconfig.json
new file mode 100644
index 00000000..98f42aee
--- /dev/null
+++ b/components/notifications/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationDir": "dist",
+ "outDir": "dist",
+ "module": "commonjs"
+ },
+ "include": ["../../types.d.ts", "src"]
+}
diff --git a/package-lock.json b/package-lock.json
index 8833650e..96f6540f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -100,6 +100,24 @@
"react-dom": ">=17"
}
},
+ "components/notifications": {
+ "version": "0.0.1",
+ "license": "ISC",
+ "devDependencies": {
+ "@byndyusoft-ui/types": "^0.1.0"
+ },
+ "peerDependencies": {
+ "@byndyusoft-ui/pub-sub": "^0.0.1",
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "components/notifications/node_modules/@byndyusoft-ui/types": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@byndyusoft-ui/types/-/types-0.1.1.tgz",
+ "integrity": "sha512-hRQbyzPbz1pdcLbYtgUnnk5pOlUY2yNpJ/o28NX4vQwmg2hNyPgifaK8STT77rhMEL5btcETYkD7YrMSz11EDA==",
+ "dev": true
+ },
"components/portal": {
"name": "@byndyusoft-ui/portal",
"version": "1.0.0",
@@ -2294,10 +2312,18 @@
"resolved": "components/modals-provider",
"link": true
},
+ "node_modules/@byndyusoft-ui/notifications": {
+ "resolved": "components/notifications",
+ "link": true
+ },
"node_modules/@byndyusoft-ui/portal": {
"resolved": "components/portal",
"link": true
},
+ "node_modules/@byndyusoft-ui/pub-sub": {
+ "resolved": "services/pub-sub",
+ "link": true
+ },
"node_modules/@byndyusoft-ui/reset-css": {
"resolved": "styles/reset-css",
"link": true
@@ -32115,6 +32141,10 @@
"version": "0.2.0",
"license": "Apache-2.0"
},
+ "services/pub-sub": {
+ "version": "0.0.1",
+ "license": "Apache-2.0"
+ },
"styles/keyframes-css": {
"name": "@byndyusoft-ui/keyframes-css",
"version": "0.0.1",
diff --git a/services/pub-sub/.npmignore b/services/pub-sub/.npmignore
new file mode 100644
index 00000000..85de9cf9
--- /dev/null
+++ b/services/pub-sub/.npmignore
@@ -0,0 +1 @@
+src
diff --git a/services/pub-sub/README.md b/services/pub-sub/README.md
new file mode 100644
index 00000000..0713649d
--- /dev/null
+++ b/services/pub-sub/README.md
@@ -0,0 +1,137 @@
+# `@byndyusoft-ui/pub-sub`
+
+> A performant Pub/Sub interface with controlled instance management
+
+### Installation
+
+```bash
+npm i @byndyusoft-ui/pub-sub
+```
+
+## Usage
+
+#### Import the class
+
+```ts
+import PubSub from '@byndyusoft-ui/pub-sub';
+```
+
+#### Define your channels
+Create a type that defines the channels and their corresponding callback signatures.
+
+```ts
+type ChannelsType = {
+ addTodo: (data: TodoType) => void;
+ removeTodo: (todoId: number) => void;
+ removeAll: () => void;
+};
+```
+
+#### Create an instance
+Use the `getInstance` method to create or retrieve a singleton instance of `PubSub`.
+
+```ts
+const pubSubInstance = PubSub.getInstance();
+```
+
+#### Subscribe and unsubscribe to a channel
+Remove a specific callback from a channel to stop receiving notifications.
+
+```ts
+const addTodoCallback = (data: TodoType) => {
+ console.log('Added new todo:', data);
+};
+
+const removeTodoCallback = (todoId: number) => {
+ console.log(`Removed todo: ${todoId}`);
+};
+
+const removeAllCallback = () => {
+ console.log('All todos deleted');
+};
+
+// subscribe
+pubSubInstance.subscribe('addTodo', addTodoCallback);
+pubSubInstance.subscribe('removeTodo', removeTodoCallback);
+pubSubInstance.subscribe('removeAll', removeAllCallback);
+
+// unsubscribe
+pubSubInstance.unsubscribe('addTodo', addTodoCallback);
+pubSubInstance.unsubscribe('removeTodo', removeTodoCallback);
+pubSubInstance.unsubscribe('removeAll', removeAllCallback);
+
+```
+
+#### Publish to a channel
+
+```ts
+pubSubInstance.publish('addTodo', { id: 1, text: 'Some todo'});
+pubSubInstance.publish('removeTodo', 1);
+pubSubInstance.publish('removeAll');
+```
+
+#### Publish asynchronously
+Use publishAsync to publish data and handle asynchronous subscribers.
+
+```ts
+
+pubSubInstance.subscribe('asyncMessage', async (data) => {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ console.log(`Async received: ${data}`);
+});
+
+await pubSubInstance.publishAsync('asyncMessage', 'This is asynchronous!');
+```
+
+#### Reset all subscriptions
+Clear all channels and their associated subscribers.
+
+```ts
+pubSubInstance.reset();
+```
+
+#### Singleton Instances
+`PubSub` supports multiple named singleton instances using instanceKey.
+This allows you to create isolated instances for different parts of your application.
+
+> Note: If instanceKey is not provided, the instance with the default name `"_default"` will be used.
+
+Usage example:
+```ts
+import PubSub from '@byndyusoft-ui/pub-sub';
+
+// Get the instance with the default name "_default"
+const defaultPubSub = PubSub.getInstance();
+
+// Get an instance with a custom name
+const customPubSub = PubSub.getInstance('custom');
+
+// Instances are isolated from each other
+defaultPubSub.subscribe('event', (data) => {
+ console.log(`Default instance: ${data}`);
+});
+
+customPubSub.subscribe('event', (data) => {
+ console.log(`Custom instance: ${data}`);
+});
+
+// Publish events in different instances
+defaultPubSub.publish('event', 'Hello from default!');
+customPubSub.publish('event', 'Hello from custom!');
+```
+
+#### Adapter for Interfaces
+If you're using `interface` instead of `type`, you can use the helper type
+`ChannelsRecordAdapter` to ensure compatibility with the index signature:
+
+```ts
+import { type ChannelsRecordAdapter } from '@byndyusoft-ui/pub-sub'
+
+interface TodoChannels {
+ addTodo: (data: TodoType) => void;
+ removeTodo: (todoId: number) => void;
+ removeAll: () => void;
+}
+
+const pubSubInstance = PubSub.getInstance>();
+```
diff --git a/services/pub-sub/package.json b/services/pub-sub/package.json
new file mode 100644
index 00000000..b6ccbb9e
--- /dev/null
+++ b/services/pub-sub/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@byndyusoft-ui/pub-sub",
+ "version": "0.0.1",
+ "description": "Byndyusoft UI Service",
+ "keywords": [
+ "byndyusoft",
+ "byndyusoft-ui",
+ "channels",
+ "publish",
+ "subscribe",
+ "Pub/Sub"
+ ],
+ "author": "Gleb Fomin ",
+ "homepage": "https://github.com/Byndyusoft/ui/tree/master/services/pub-sub#readme",
+ "license": "Apache-2.0",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/Byndyusoft/ui.git"
+ },
+ "scripts": {
+ "build": "tsc --project tsconfig.build.json",
+ "clean": "rimraf dist",
+ "lint": "eslint src --config ../../eslint.config.js",
+ "test": "jest --config ../../jest.config.js --roots services/pub-sub/src"
+ },
+ "bugs": {
+ "url": "https://github.com/Byndyusoft/ui/issues"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/services/pub-sub/src/index.ts b/services/pub-sub/src/index.ts
new file mode 100644
index 00000000..ebf70700
--- /dev/null
+++ b/services/pub-sub/src/index.ts
@@ -0,0 +1,3 @@
+export { default } from './pubSub';
+
+export type { ChannelsRecordAdapter } from './pubSub.types';
diff --git a/services/pub-sub/src/pubSub.tests.ts b/services/pub-sub/src/pubSub.tests.ts
new file mode 100644
index 00000000..caccc734
--- /dev/null
+++ b/services/pub-sub/src/pubSub.tests.ts
@@ -0,0 +1,87 @@
+import PubSub from './pubSub';
+
+type TChannels = {
+ testChannel: (data?: string) => void;
+ asyncChannel: (data?: string) => Promise;
+};
+
+describe('services/pub-sub', () => {
+ let pubSub: PubSub;
+
+ beforeEach(() => {
+ pubSub = PubSub.getInstance();
+ });
+
+ afterEach(() => {
+ pubSub.reset();
+ });
+
+ test('should create a new instance and get the same instance for the same key', () => {
+ const instance1 = PubSub.getInstance('instance1');
+ const instance2 = PubSub.getInstance('instance1');
+ const instance3 = PubSub.getInstance('instance2');
+
+ expect(instance1).toBe(instance2);
+ expect(instance1).not.toBe(instance3);
+ });
+
+ test('should subscribe and publish to a channel', () => {
+ const callback = jest.fn();
+ pubSub.subscribe('testChannel', callback);
+
+ pubSub.publish('testChannel', 'Hello, World!');
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith('Hello, World!');
+ });
+
+ test('should not call callback if no subscribers', () => {
+ const callback = jest.fn();
+ pubSub.publish('testChannel');
+
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ test('should unsubscribe from a channel', () => {
+ const callback = jest.fn();
+ pubSub.subscribe('testChannel', callback);
+ pubSub.unsubscribe('testChannel', callback);
+
+ pubSub.publish('testChannel');
+
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ test('should warn if no subscribers are present for a channel', () => {
+ console.warn = jest.fn();
+
+ pubSub.publish('testChannel', 'No one is listening');
+
+ expect(console.warn).toHaveBeenCalledWith('No subscribers for channel: testChannel');
+ });
+
+ test('should handle async subscribe callbacks', async () => {
+ const asyncCallback = jest.fn().mockResolvedValue(undefined);
+ pubSub.subscribe('asyncChannel', asyncCallback);
+
+ await pubSub.publishAsync('asyncChannel', 'Async data');
+
+ expect(asyncCallback).toHaveBeenCalledTimes(1);
+ expect(asyncCallback).toHaveBeenCalledWith('Async data');
+ });
+
+ test('should reset all subscriptions', () => {
+ const callback1 = jest.fn();
+ const callback2 = jest.fn();
+
+ pubSub.subscribe('testChannel', callback1);
+ pubSub.subscribe('testChannel', callback2);
+
+ pubSub.reset();
+
+ pubSub.publish('testChannel');
+
+ expect(callback1).not.toHaveBeenCalled();
+ expect(callback2).not.toHaveBeenCalled();
+ });
+});
diff --git a/services/pub-sub/src/pubSub.ts b/services/pub-sub/src/pubSub.ts
new file mode 100644
index 00000000..a2b83178
--- /dev/null
+++ b/services/pub-sub/src/pubSub.ts
@@ -0,0 +1,94 @@
+import { TChannelData, TChannelMap, TDefaultChannels, TPubSubInstances } from './pubSub.types';
+
+const DEFAULT_NAME_INSTANCE = '_default';
+
+class PubSub {
+ private static instances: TPubSubInstances = new Map();
+ private channels: TChannelMap = new Map();
+
+ private constructor() {}
+
+ /**
+ * Getting an instance of a class.
+ */
+ static getInstance(
+ instanceKey: string = DEFAULT_NAME_INSTANCE
+ ): PubSub {
+ if (!this.instances.get(instanceKey)) {
+ this.instances.set(instanceKey, new PubSub());
+ }
+
+ return this.instances.get(instanceKey) as PubSub;
+ }
+
+ /**
+ * Subscribe to the channel.
+ */
+ subscribe(
+ channel: ChannelKey,
+ callback: ChannelsRecord[ChannelKey]
+ ): void {
+ if (!this.channels.has(channel)) {
+ this.channels.set(channel, new Set());
+ }
+ (this.channels.get(channel) as Set).add(callback);
+ }
+
+ /**
+ * Unsubscribe from the channel.
+ */
+ unsubscribe(
+ channel: ChannelKey,
+ callback: ChannelsRecord[ChannelKey]
+ ): void {
+ const channelSet = this.channels.get(channel);
+ if (channelSet) {
+ channelSet.delete(callback);
+ if (channelSet.size === 0) {
+ this.channels.delete(channel);
+ }
+ }
+ }
+
+ /**
+ * Publishing to the channel.
+ */
+ publish(
+ channel: ChannelKey,
+ data?: TChannelData
+ ): void {
+ const channelSet = this.channels.get(channel);
+ if (channelSet) {
+ for (const callback of channelSet) {
+ callback(data);
+ }
+ } else {
+ console.warn(`No subscribers for channel: ${channel as string}`);
+ }
+ }
+
+ async publishAsync(
+ channel: ChannelKey,
+ data?: TChannelData
+ ): Promise {
+ const channelSet = this.channels.get(channel);
+ if (channelSet) {
+ for (const callback of channelSet) {
+ if (callback) {
+ await callback(data);
+ }
+ }
+ } else {
+ console.warn(`No subscribers for channel: ${channel as string}`);
+ }
+ }
+
+ /**
+ * Reset all subscriptions.
+ */
+ reset(): void {
+ this.channels.clear();
+ }
+}
+
+export default PubSub;
diff --git a/services/pub-sub/src/pubSub.types.ts b/services/pub-sub/src/pubSub.types.ts
new file mode 100644
index 00000000..df78ebb8
--- /dev/null
+++ b/services/pub-sub/src/pubSub.types.ts
@@ -0,0 +1,14 @@
+export type TDefaultChannels = Record void>;
+
+export type TChannelMap = Map<
+ keyof ChannelsRecord,
+ Set
+>;
+
+export type TPubSubInstances = Map;
+
+export type TChannelData = Parameters<
+ ChannelsRecord[ChannelKey]
+>[0];
+
+export type ChannelsRecordAdapter = { [K in keyof T]: T[K] };
diff --git a/services/pub-sub/tsconfig.build.json b/services/pub-sub/tsconfig.build.json
new file mode 100644
index 00000000..b4b36060
--- /dev/null
+++ b/services/pub-sub/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["src/*.tests.ts"]
+}
diff --git a/services/pub-sub/tsconfig.json b/services/pub-sub/tsconfig.json
new file mode 100644
index 00000000..5b7870da
--- /dev/null
+++ b/services/pub-sub/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationDir": "dist",
+ "outDir": "dist",
+ "module": "commonjs",
+ "target": "es6"
+ },
+ "include": ["src"]
+}