diff --git a/.gitignore b/.gitignore
index 9f0be511..685199d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ node_modules
# Artifacts
dist
build
+.DS_Store
diff --git a/package-lock.json b/package-lock.json
index 3a626b57..c863c726 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "@gravity-ui/components",
- "version": "1.7.1",
+ "version": "1.7.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -20558,6 +20558,11 @@
"setimmediate": "^1.0.4"
}
},
+ "tinygesture": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinygesture/-/tinygesture-2.0.0.tgz",
+ "integrity": "sha512-Xhpo6tCvUOyVq7BmJh/WDi+9qFh5AtuUqbKoMG5vpG+PT6JLPGE9D5hl9kZlj1ZqsgHuGb1OrNAXoHh8qIDpjA=="
+ },
"tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
diff --git a/package.json b/package.json
index 350d082b..7e5d1acb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@gravity-ui/components",
- "version": "1.7.1",
+ "version": "1.7.2",
"description": "",
"license": "MIT",
"main": "./build/cjs/index.js",
@@ -37,7 +37,8 @@
"@gravity-ui/i18n": "^1.0.0",
"@gravity-ui/icons": "^1.1.0",
"lodash": "^4.17.21",
- "resize-observer-polyfill": "^1.5.1"
+ "resize-observer-polyfill": "^1.5.1",
+ "tinygesture": "^2.0.0"
},
"devDependencies": {
"@commitlint/cli": "^17.0.0",
diff --git a/src/components/Notification/Notification.scss b/src/components/Notification/Notification.scss
new file mode 100644
index 00000000..99d33ba5
--- /dev/null
+++ b/src/components/Notification/Notification.scss
@@ -0,0 +1,189 @@
+@use '../variables';
+
+$block: '.#{variables.$ns}notification';
+
+$notificationSourceIconSize: 36px;
+
+#{$block} {
+ display: flex;
+ padding: 12px;
+ gap: 12px;
+ border-radius: 4px;
+ box-sizing: border-box;
+ width: 100%;
+
+ &:hover {
+ background: var(--yc-color-base-simple-hover);
+ }
+
+ &__right {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+ overflow-x: hidden;
+ }
+
+ &__right-top-part {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ overflow-x: hidden;
+ }
+
+ &__right-meta-and-title {
+ flex: 1;
+ min-width: 0;
+ overflow-x: hidden;
+ }
+
+ &__right-meta,
+ &__right-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__right-meta {
+ display: flex;
+ gap: 4px;
+ color: var(--yc-color-text-secondary);
+ }
+
+ &__right-title {
+ font-weight: 500;
+ font-size: 13px;
+ line-height: 18px;
+
+ color: var(--yc-color-text-primary);
+ }
+
+ &__right-content {
+ font-size: 13px;
+ line-height: 18px;
+
+ color: var(--yc-color-text-secondary);
+ }
+
+ &_unread {
+ background: var(--yc-color-base-selection);
+ &:hover {
+ background: var(--yc-color-base-selection-hover);
+ }
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+
+ &__actions_right-bottom-actions {
+ margin-top: 8px;
+ gap: 8px;
+ }
+
+ &__actions_right-side-actions {
+ opacity: 0;
+ }
+ &:hover &__actions_right-side-actions {
+ opacity: 1;
+ }
+ &_mobile &__actions_right-side-actions {
+ opacity: 1;
+ }
+
+ &__action_icon {
+ color: var(--yc-color-text-secondary);
+ }
+
+ &_theme_success {
+ border-left: 4px solid var(--yc-color-line-positive);
+ }
+ &_theme_info {
+ border-left: 4px solid var(--yc-color-line-info);
+ }
+ &_theme_warning {
+ border-left: 4px solid var(--yc-color-line-warning);
+ }
+ &_theme_danger {
+ border-left: 4px solid var(--yc-color-line-danger);
+ }
+
+ &__swipe-wrap {
+ width: 100%;
+ overflow: hidden;
+ }
+
+ &__swipe {
+ width: 200%;
+ display: flex;
+ overflow-x: hidden;
+ align-items: stretch;
+ }
+
+ &__swipe_position_notification#{&}__swipe_has-left {
+ transform: translateX(-25%);
+ }
+
+ &__notification-wrapper {
+ width: 50%;
+ transition: opacity 0.5s;
+ }
+
+ &__swipe-action-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 25%;
+ }
+
+ &__swipe-action {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ flex: 1;
+ }
+
+ &__swipe-action_theme_base {
+ background: var(--yc-color-base-misc);
+ }
+ &__swipe-action_theme_base &__swipe-action-icon {
+ background: var(--yc-color-text-misc);
+ }
+ &__swipe-action_theme_base &__swipe-action-text {
+ color: var(--yc-color-text-misc);
+ }
+
+ &__swipe-action_theme_warning {
+ background: var(--yc-color-base-warning);
+ }
+ &__swipe-action_theme_warning &__swipe-action-icon {
+ background: var(--yc-color-base-warning-heavy);
+ }
+ &__swipe-action_theme_warning &__swipe-action-text {
+ color: var(--yc-color-base-warning-heavy);
+ }
+
+ &__swipe-action_theme_danger {
+ background: var(--yc-color-base-danger);
+ }
+ &__swipe-action_theme_danger &__swipe-action-icon {
+ background: var(--yc-color-base-danger-heavy);
+ }
+ &__swipe-action_theme_danger &__swipe-action-text {
+ color: var(--yc-color-base-danger-heavy);
+ }
+
+ &__swipe-action-icon {
+ padding: 8px;
+ border-radius: 100%;
+ color: var(--yc-color-base-background);
+ }
+
+ &__swipe-action-text {
+ font-size: 16px;
+ }
+}
diff --git a/src/components/Notification/Notification.tsx b/src/components/Notification/Notification.tsx
new file mode 100644
index 00000000..a5f316ad
--- /dev/null
+++ b/src/components/Notification/Notification.tsx
@@ -0,0 +1,89 @@
+import {Icon, Link, useMobile} from '@gravity-ui/uikit';
+import React from 'react';
+import {CnMods, block} from '../utils/cn';
+import './Notification.scss';
+import {NotificationProps, NotificationSourceProps} from './definitions';
+
+const b = block('notification');
+
+type Props = {notification: NotificationProps};
+
+export const Notification = React.memo(function Notification(props: Props) {
+ const [mobile] = useMobile();
+ const {notification} = props;
+ const {title, content, formattedDate, source, unread, theme} = notification;
+
+ const modifiers: CnMods = {unread, theme, mobile};
+
+ return (
+
+ {source ?
{renderSourceIcon(source)}
: null}
+
+
+
+
+ {source?.title ? renderSourceTitle(source.title, source.href) : null}
+ {source?.title && formattedDate ?
• : null}
+ {formattedDate ? (
+
{formattedDate}
+ ) : null}
+
+ {title ?
{title}
: null}
+
+ {props.notification.sideActions ? (
+
+ {props.notification.sideActions}
+
+ ) : null}
+
+
{content}
+ {props.notification.bottomActions ? (
+
+ {props.notification.bottomActions}
+
+ ) : null}
+
+
+ );
+});
+
+function renderSourceTitle(title: string, href: string | undefined): JSX.Element {
+ return href ? (
+
+ {title}
+
+ ) : (
+
+ {title}
+
+ );
+}
+
+function renderSourceIcon(source: NotificationSourceProps): JSX.Element | null {
+ const iconElement = getIconElement(source);
+
+ if (!iconElement) return null;
+
+ return source.href ? (
+
+ {iconElement}
+
+ ) : (
+ iconElement
+ );
+}
+
+function getIconElement(source: NotificationSourceProps): JSX.Element | null {
+ if ('icon' in source && source.icon) {
+ return ;
+ } else if ('imageSrc' in source && source.imageSrc) {
+ return
;
+ } else {
+ return null;
+ }
+}
diff --git a/src/components/Notification/NotificationAction.tsx b/src/components/Notification/NotificationAction.tsx
new file mode 100644
index 00000000..6500048f
--- /dev/null
+++ b/src/components/Notification/NotificationAction.tsx
@@ -0,0 +1,31 @@
+import {Button, Icon, Tooltip} from '@gravity-ui/uikit';
+import React from 'react';
+import {block} from '../utils/cn';
+import './Notification.scss';
+import {NotificationActionProps} from './definitions';
+
+const b = block('notification');
+
+type Props = {action: NotificationActionProps};
+
+export const NotificationAction = React.memo(function NotificationAction({action}: Props) {
+ const content = renderContent(action);
+
+ const button = (
+
+ );
+
+ return action.icon ? {button} : button;
+});
+
+function renderContent(action: NotificationActionProps): React.ReactNode {
+ return action.icon ? : action.text;
+}
diff --git a/src/components/Notification/NotificationSwipeAction.tsx b/src/components/Notification/NotificationSwipeAction.tsx
new file mode 100644
index 00000000..36ddd027
--- /dev/null
+++ b/src/components/Notification/NotificationSwipeAction.tsx
@@ -0,0 +1,26 @@
+import {Icon, IconData} from '@gravity-ui/uikit';
+import React from 'react';
+import {block} from '../utils/cn';
+import './Notification.scss';
+
+const b = block('notification');
+
+type Props = {
+ icon: IconData;
+ text?: React.ReactNode;
+ theme?: 'base' | 'warning' | 'danger';
+ action?: () => void;
+};
+
+export const NotificationSwipeAction = React.memo(function NotificationSwipeAction(props: Props) {
+ const {icon, text, theme = 'base', action} = props;
+
+ return (
+
+
+
+
+ {text}
+
+ );
+});
diff --git a/src/components/Notification/NotificationWithSwipe.tsx b/src/components/Notification/NotificationWithSwipe.tsx
new file mode 100644
index 00000000..82a7bd83
--- /dev/null
+++ b/src/components/Notification/NotificationWithSwipe.tsx
@@ -0,0 +1,145 @@
+import clamp from 'lodash/clamp';
+import React, {useState} from 'react';
+import TinyGesture from 'tinygesture';
+import {block} from '../utils/cn';
+import {Notification} from './Notification';
+import './Notification.scss';
+import {NotificationProps, NotificationSwipeActionProps} from './definitions';
+
+const b = block('notification');
+const notificationWrapperCls = b('notification-wrapper');
+const swipeActionContainerCls = b('swipe-action-container');
+
+type Props = {notification: NotificationProps; swipeThreshold?: number};
+
+export const NotificationWithSwipe = React.memo(function NotificationWithSwipe(props: Props) {
+ const swipeThreshold = props.swipeThreshold ?? 0.4;
+
+ if (swipeThreshold < 0 || swipeThreshold > 1) {
+ throw new Error('Invalid value for swipeThreshold');
+ }
+
+ const ref = React.useRef(null);
+ const notification = props.notification;
+ const swipeActions = notification.swipeActions;
+ const leftAction = swipeActions && 'left' in swipeActions ? swipeActions.left : undefined;
+ const rightAction = swipeActions && 'right' in swipeActions ? swipeActions.right : undefined;
+
+ const [position, setPosition] = useState<'left-action' | 'notification' | 'right-action'>(
+ 'notification',
+ );
+
+ React.useEffect(() => {
+ const element = ref.current;
+ if (!element) return undefined;
+
+ const actionsElements = Array.from(element.querySelectorAll(`.${swipeActionContainerCls}`));
+ const notificationWrapperElement: HTMLElement | null = element.querySelector(
+ `.${notificationWrapperCls}`,
+ );
+ const leftActionElement = leftAction ? actionsElements[0] : undefined;
+ const rightActionElement = rightAction
+ ? actionsElements[1] ?? actionsElements[0]
+ : undefined;
+
+ if (!notificationWrapperElement) return () => {};
+
+ const leftActionWidth = leftActionElement?.getBoundingClientRect()?.width ?? 0;
+ const rightActionWidth = rightActionElement?.getBoundingClientRect()?.width ?? 0;
+
+ // | | | |
+ // | <- rightActionX | <- notificationX | <- leftActionX |
+ // | | | |
+ // example | -200 | -100 | 0 |
+ const leftActionX = 0;
+ const notificationX = -leftActionWidth;
+ const rightActionX = -leftActionWidth - rightActionWidth;
+
+ const leftActionThresholdX = notificationX + leftActionWidth * swipeThreshold;
+ const rightActionThresholdX = notificationX - rightActionWidth * swipeThreshold;
+
+ let startX: number;
+ if (position === 'left-action') startX = leftActionX;
+ else if (position === 'notification') startX = notificationX;
+ else startX = rightActionX;
+
+ const gesture = new TinyGesture(element, {
+ velocityThreshold: 10,
+ mouseSupport: true,
+ diagonalSwipes: false,
+ });
+
+ element.style.transform = `translateX(${startX}px)`;
+
+ gesture.on('panstart', () => {
+ notificationWrapperElement.style.opacity = `0.5`;
+ element.style.transition = 'transform 0s';
+ });
+
+ gesture.on('panmove', () => {
+ const x = getX();
+ if (x === undefined) return;
+
+ element.style.transform = `translateX(${x}px)`;
+ });
+
+ gesture.on('panend', () => {
+ element.style.transition = 'transform 0.2s';
+
+ const x = getX();
+ if (x === undefined) {
+ if (position === 'notification') {
+ notificationWrapperElement.style.opacity = `1`;
+ }
+ return;
+ }
+
+ if (x >= leftActionThresholdX && leftAction) {
+ setPosition('left-action');
+ leftAction.onSwipe?.();
+ element.style.transform = `translateX(${leftActionX}px)`;
+ } else if (x <= rightActionThresholdX && rightAction) {
+ setPosition('right-action');
+ rightAction.onSwipe?.();
+ element.style.transform = `translateX(${rightActionX}px)`;
+ } else {
+ setPosition('notification');
+ element.style.transform = `translateX(${notificationX}px)`;
+ notificationWrapperElement.style.opacity = `1`;
+ }
+ });
+
+ function getX() {
+ if (!gesture.touchMoveX) return undefined;
+
+ return clamp(startX + gesture.touchMoveX, rightActionX, leftActionX);
+ }
+
+ return () => {
+ gesture.destroy();
+ };
+ }, [position]);
+
+ return (
+
+
+ {leftAction ? renderAction(leftAction) : null}
+
+
+
+ {rightAction ? renderAction(rightAction) : null}
+
+
+ );
+});
+
+function renderAction(action: NotificationSwipeActionProps) {
+ return {action.content}
;
+}
diff --git a/src/components/Notification/definitions.ts b/src/components/Notification/definitions.ts
new file mode 100644
index 00000000..5b9b538a
--- /dev/null
+++ b/src/components/Notification/definitions.ts
@@ -0,0 +1,48 @@
+import {ButtonProps, IconData} from '@gravity-ui/uikit';
+
+export type NotificationTheme = 'success' | 'info' | 'warning' | 'danger';
+
+type SvgOrImage = {icon: IconData} | {imageSrc: string};
+
+export type NotificationSourceProps = {
+ title?: string;
+ href?: string;
+} & Partial;
+
+export type NotificationSwipeActionProps = {
+ content: React.ReactNode;
+ onSwipe?: () => void;
+};
+export type NotificationSwipeActionsProps =
+ | {left: NotificationSwipeActionProps}
+ | {right: NotificationSwipeActionProps}
+ | {left: NotificationSwipeActionProps; right: NotificationSwipeActionProps};
+
+export type NotificationProps = {
+ id: string;
+ content: React.ReactNode;
+
+ title?: React.ReactNode;
+ formattedDate?: React.ReactNode;
+ unread?: boolean;
+ source?: NotificationSourceProps;
+ theme?: NotificationTheme;
+ className?: string;
+
+ sideActions?: React.ReactNode;
+ bottomActions?: React.ReactNode;
+ swipeActions?: NotificationSwipeActionsProps;
+
+ onMouseEnter?: React.MouseEventHandler;
+ onMouseLeave?: React.MouseEventHandler;
+ onClick?: React.MouseEventHandler;
+};
+
+export type NotificationActionProps = {
+ view?: ButtonProps['view'];
+ icon?: IconData;
+ text: string;
+ onClick?: () => void;
+ href?: string;
+ target?: '_blank';
+};
diff --git a/src/components/Notification/index.ts b/src/components/Notification/index.ts
new file mode 100644
index 00000000..9c253c25
--- /dev/null
+++ b/src/components/Notification/index.ts
@@ -0,0 +1,5 @@
+export * from './definitions';
+export * from './Notification';
+export * from './NotificationAction';
+export * from './NotificationSwipeAction';
+export * from './NotificationWithSwipe';
diff --git a/src/components/Notifications/Notifications.scss b/src/components/Notifications/Notifications.scss
new file mode 100644
index 00000000..335fd378
--- /dev/null
+++ b/src/components/Notifications/Notifications.scss
@@ -0,0 +1,83 @@
+@use '../variables';
+
+$block: '.#{variables.$ns}notifications';
+
+#{$block} {
+ display: flex;
+ flex-direction: column;
+ color: var(--yc-color-text-primary);
+ background: var(--yc-color-base-background);
+ height: 100%;
+
+ &__head {
+ display: flex;
+ padding: 16px;
+ border-bottom: 1px solid var(--yc-color-line-generic);
+ }
+
+ &__head-title {
+ flex: 1;
+
+ font-weight: 500;
+ font-size: 17px;
+ line-height: 24px;
+
+ color: var(--yc-color-text-primary);
+ }
+
+ &__body {
+ height: 100%;
+ padding: 4px;
+ overflow-y: auto;
+ }
+
+ &__empty {
+ height: 100%;
+ gap: 16px;
+ }
+
+ &__empty,
+ &__empty-message {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ }
+
+ &__empty-title {
+ margin-bottom: 3px;
+ font-weight: 500;
+ font-size: 17px;
+ line-height: 24px;
+ }
+
+ &__empty-message-content {
+ font-size: 13px;
+ line-height: 18px;
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ }
+
+ &__notification-wrapper:not(:first-child)::before {
+ content: '';
+ display: block;
+ border-top: 1px solid var(--yc-color-line-generic);
+ margin: 0 12px;
+ }
+
+ // :hover
+ &__notification-wrapper:hover:not(:first-child)::before,
+ &__notification-wrapper:hover + &__notification-wrapper::before,
+ // .unread
+ &__notification-wrapper.unread:not(:first-child)::before,
+ &__notification-wrapper.unread + &__notification-wrapper::before {
+ content: '';
+ display: block;
+ border-top: 1px solid transparent;
+ margin: 0 12px;
+ }
+}
diff --git a/src/components/Notifications/Notifications.tsx b/src/components/Notifications/Notifications.tsx
new file mode 100644
index 00000000..b4d2d23e
--- /dev/null
+++ b/src/components/Notifications/Notifications.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import {block} from '../utils/cn';
+import './Notifications.scss';
+import {NotificationsEmptyState} from './NotificationsEmptyState';
+import {NotificationsList} from './NotificationsList';
+import {NotificationsProps} from './definitions';
+import i18n from './i18n';
+
+const b = block('notifications');
+
+export const Notifications = React.memo(function Notifications(props: NotificationsProps) {
+ return (
+
+
+
{props.title || i18n('title')}
+ {props.actions ?
{props.actions}
: null}
+
+
+ {props.notifications.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+});
diff --git a/src/components/Notifications/NotificationsEmptyState.tsx b/src/components/Notifications/NotificationsEmptyState.tsx
new file mode 100644
index 00000000..f3901413
--- /dev/null
+++ b/src/components/Notifications/NotificationsEmptyState.tsx
@@ -0,0 +1,28 @@
+import {Icon, useTheme} from '@gravity-ui/uikit';
+import React from 'react';
+import {block} from '../utils/cn';
+import './Notifications.scss';
+
+const b = block('notifications');
+
+const nothingFoundSvg = ``;
+
+const nothingFoundDarkSvg = ``;
+
+type Props = {content: React.ReactNode};
+
+export const NotificationsEmptyState = React.memo(function NotificationsEmptyState(props: Props) {
+ const [theme] = useTheme();
+
+ return (
+
+
+
+
No notifications
+ {props.content ? (
+
{props.content}
+ ) : null}
+
+
+ );
+});
diff --git a/src/components/Notifications/NotificationsList.tsx b/src/components/Notifications/NotificationsList.tsx
new file mode 100644
index 00000000..49191408
--- /dev/null
+++ b/src/components/Notifications/NotificationsList.tsx
@@ -0,0 +1,44 @@
+import {useMobile} from '@gravity-ui/uikit';
+import React from 'react';
+import {Notification} from '../Notification';
+import {NotificationWithSwipe} from '../Notification/NotificationWithSwipe';
+import {NotificationProps} from '../Notification/definitions';
+import {block} from '../utils/cn';
+import './Notifications.scss';
+
+const b = block('notifications');
+
+type Props = {
+ notifications: NotificationProps[];
+ swipeThreshold?: number;
+};
+
+export const NotificationsList = React.memo(function NotificationsList(props: Props) {
+ return (
+
+ {props.notifications.map((notification) =>
+ renderNotification(notification, props.swipeThreshold),
+ )}
+
+ );
+});
+
+function renderNotification(notification: NotificationProps, swipeThreshold?: number): JSX.Element {
+ const [mobile] = useMobile();
+
+ return (
+
+ {mobile && notification.swipeActions ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/Notifications/README.md b/src/components/Notifications/README.md
new file mode 100644
index 00000000..529e34b1
--- /dev/null
+++ b/src/components/Notifications/README.md
@@ -0,0 +1,70 @@
+## Notifications
+
+Components for displaying notifications.
+Can be used on desktop and touch devices.
+
+### Simple usage example
+
+```typescript
+const YourComponent: React.FC = () => {
+ const notifications = useMemo(
+ () => [
+ {
+ id: 'minimum',
+ content: Bare minimum,
+ },
+ ],
+ [],
+ );
+
+ const action = useMemo(() => ({icon: Plus, text: 'Add', onClick: () => console.log('ADD')}), []);
+
+ return (
+ }
+ emptyMessage={'Unfortunately, there are no notifications for you, pal'}
+ />
+ );
+};
+```
+
+### Components
+
+**Notifications** — renders notifications and actions on these notifications.
+
+| Property | Type | Required | Default | Description |
+| :--------------- | :-------------------- | :------: | :---------------- | :--------------------------------------------------------- |
+| `notifications` | `NotificationProps[]` | `true` | `false` | Touch device (mobile) mode |
+| `title` | `ReactNode` | | `"Notifications"` | Notifications' title |
+| `actions` | `ReactNode` | | | Notifications' actions (e.g. create new, mark all as read) |
+| `emptyMessage` | `ReactNode` | | | Message for «No notifications» case |
+| `swipeThreshold` | `number` | | 0.4 | A value from 0 to 1 — the more the harder to swipe |
+
+**Notification** — renders a notification with actions (side/bottom/swipe).
+
+| Property | Type | Required | Default | Description |
+| :------------- | :------------------ | :------: | :------ | :------------------------- |
+| `notification` | `NotificationProps` | `true` | | The notification to render |
+
+**NotificationProps** — notification's type:
+
+| Property | Type | Required | Default | Description |
+| :------------ | :---------------------------------- | :------: | :------ | :---------------------------------------------------- |
+| id | `string` | `true` | | Unique identifier (used in `key` for example) |
+| content | `ReactNode` | `true` | | Notification's content (what it's about) |
+| title | `ReactNode` | | | Notification's title (bold) |
+| formattedDate | `ReactNode` | | | Notification's creation date (already formatted) |
+| unread | `boolean` | | `false` | Is notification unread |
+| source | `NotificationSourceProps` | | | Notification's source (e.g. Cloud/Tracker/Console) |
+| theme | `NotificationTheme` | | | Notification's theme (e.g. warning/danger) |
+| className | `string` | | | Notification's `className` |
+| sideActions | `ReactNode` | | | Notification's actions on the right side |
+| bottomActions | `ReactNode` | | | Notification's bottom actions (as buttons by default) |
+| swipeActions | `NotificationSwipeActionsProps` | | | Notification's action on left/right swipe |
+| onMouseEnter | `MouseEventHandler` | | | Callback for `onMouseEnter` |
+| onMouseLeave | `MouseEventHandler` | | | Callback for `onMouseLeave` |
+| onClick | `MouseEventHandler` | | | Callback for `onClick` |
+
+For a more detailed info on types go to [Notifications' types](https://github.com/gravity-ui/components/blob/main/src/components/Notifications/definitions.ts) and [Notification' types](https://github.com/gravity-ui/components/blob/main/src/components/Notification/definitions.ts).
diff --git a/src/components/Notifications/__stories__/Notifications.stories.tsx b/src/components/Notifications/__stories__/Notifications.stories.tsx
new file mode 100644
index 00000000..32c9a852
--- /dev/null
+++ b/src/components/Notifications/__stories__/Notifications.stories.tsx
@@ -0,0 +1,36 @@
+import {ComponentMeta, ComponentStory} from '@storybook/react';
+import React from 'react';
+import {Notifications} from '../Notifications';
+import {notificationsMockActions, mockNotifications} from './mockData';
+
+export default {
+ title: 'Components/Notifications',
+ component: Notifications,
+} as ComponentMeta;
+
+const Template: ComponentStory = (args) => (
+
+
+
+);
+
+export const Default = Template.bind({});
+Default.args = {
+ notifications: mockNotifications,
+ actions: notificationsMockActions,
+};
+
+export const Empty = Template.bind({});
+Empty.args = {
+ notifications: [],
+ emptyMessage: 'You have not received any notifications',
+};
diff --git a/src/components/Notifications/__stories__/mockData.tsx b/src/components/Notifications/__stories__/mockData.tsx
new file mode 100644
index 00000000..dd56cfed
--- /dev/null
+++ b/src/components/Notifications/__stories__/mockData.tsx
@@ -0,0 +1,159 @@
+/* eslint-disable no-console */
+import {Archive, Funnel, PencilToSquare, Plus, Trash} from '@gravity-ui/icons';
+import {DropdownMenu} from '@gravity-ui/uikit';
+import React from 'react';
+import {NotificationAction} from '../../Notification/NotificationAction';
+import {NotificationSwipeAction} from '../../Notification/NotificationSwipeAction';
+import {NotificationProps, NotificationSwipeActionsProps} from '../../Notification/definitions';
+import {
+ svgCloudStoryIcon,
+ svgReactStoryIcon,
+ svgTrackerStoryIcon,
+ svgYandexStoryIcon,
+} from './storyIcons';
+
+export const notificationsMockActions: JSX.Element = (
+ <>
+ console.log('ADD')}} />
+ console.log('FILTER')}}
+ />
+ }
+ items={[
+ {text: 'Any', action: () => console.log('any')},
+ {text: 'Tracker', action: () => console.log('tracker')},
+ {text: 'Cloud', action: () => console.log('cloud')},
+ {text: 'You can put any popup here', action: () => console.log('cloud')},
+ ]}
+ />
+ >
+);
+
+export const notificationsMockSwipeActions: NotificationSwipeActionsProps = {
+ left: {
+ content: (
+ console.log('DELETE')}
+ />
+ ),
+ onSwipe: () => console.log('LEFT ACTION (DELETE) SHOWN'),
+ },
+ right: {
+ content: (
+ console.log('ARCHIVE')}
+ />
+ ),
+ onSwipe: () => console.log('RIGHT ACTION (ARCHIVE) SHOWN'),
+ },
+};
+
+export const notificationSideActions: JSX.Element = (
+ <>
+ console.log('FILTER')}}
+ />
+ console.log('DELETE')}}
+ />
+ >
+);
+
+export const notificationBottomActions: JSX.Element = (
+ <>
+ console.log('CONFIRM')}}
+ />
+ console.log('DENY')}}
+ />
+ >
+);
+
+export const mockNotifications: NotificationProps[] = [
+ {
+ id: 'tracker',
+ title: 'An unread notification',
+ content: 'No one has read this notification yet...',
+ formattedDate: 'just now',
+ source: {
+ title: 'Tracker',
+ icon: svgTrackerStoryIcon,
+ href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ },
+ unread: true,
+ sideActions: notificationSideActions,
+ swipeActions: notificationsMockSwipeActions,
+ },
+ {
+ id: 'samurai',
+ content: A samurai has no goal, only a path,
+ formattedDate: '12 seconds ago',
+ unread: true,
+ swipeActions: notificationsMockSwipeActions,
+ },
+ {
+ id: 'minimum',
+ content: Bare minimum,
+ },
+ {
+ id: 'ninja',
+ content: Reaction of a cat, speed of a mongoose,
+ formattedDate: '28 seconds ago',
+ swipeActions: notificationsMockSwipeActions,
+ },
+ {
+ id: 'yandex',
+ content: (
+ <>
+ You can put any ReactNode
here
+ >
+ ),
+ formattedDate: '30 seconds ago',
+ source: {
+ title: 'Yandex',
+ icon: svgYandexStoryIcon,
+ href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ },
+ unread: false,
+ theme: 'info',
+ sideActions: notificationSideActions,
+ swipeActions: notificationsMockSwipeActions,
+ },
+ {
+ id: 'one',
+ title: 'An extremely long title without any need being that long',
+ content:
+ 'Chat-GPT says: Cloud finance has revolutionized the way businesses manage their financial operations. With the ability to access financial data and tools from anywhere, at any time, cloud finance has made it easier than ever for businesses to stay on top of their finances and make informed decisions. Additionally, the scalability and flexibility of cloud finance solutions have made them an attractive option for businesses of all sizes.',
+ formattedDate: '5 minutes ago',
+ source: {
+ title: 'Billing',
+ icon: svgCloudStoryIcon,
+ href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ },
+ theme: 'success',
+ bottomActions: notificationBottomActions,
+ swipeActions: {...notificationsMockSwipeActions, left: undefined},
+ },
+ {
+ id: 'three',
+ title: 'Update is required',
+ content: 'React 18 is now deprecated! Please, update to React 18.0.1',
+ source: {
+ title: 'React',
+ icon: svgReactStoryIcon,
+ href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ },
+ formattedDate: 'ethernity ago',
+ theme: 'danger',
+ swipeActions: {...notificationsMockSwipeActions, right: undefined},
+ },
+];
diff --git a/src/components/Notifications/__stories__/storyIcons.ts b/src/components/Notifications/__stories__/storyIcons.ts
new file mode 100644
index 00000000..fc424e5a
--- /dev/null
+++ b/src/components/Notifications/__stories__/storyIcons.ts
@@ -0,0 +1,4 @@
+export const svgCloudStoryIcon = ``;
+export const svgReactStoryIcon = ``;
+export const svgTrackerStoryIcon = ``;
+export const svgYandexStoryIcon = ``;
diff --git a/src/components/Notifications/definitions.ts b/src/components/Notifications/definitions.ts
new file mode 100644
index 00000000..f34e845c
--- /dev/null
+++ b/src/components/Notifications/definitions.ts
@@ -0,0 +1,13 @@
+import {NotificationProps, NotificationActionProps} from '../Notification/definitions';
+
+export type NotificationsActionProps = NotificationActionProps;
+
+export type NotificationsProps = {
+ title?: React.ReactNode;
+ actions?: React.ReactNode;
+
+ notifications: NotificationProps[];
+
+ emptyMessage?: React.ReactNode;
+ swipeThreshold?: number;
+};
diff --git a/src/components/Notifications/i18n/en.json b/src/components/Notifications/i18n/en.json
new file mode 100644
index 00000000..64586638
--- /dev/null
+++ b/src/components/Notifications/i18n/en.json
@@ -0,0 +1,3 @@
+{
+ "title": "Notifications"
+}
diff --git a/src/components/Notifications/i18n/index.ts b/src/components/Notifications/i18n/index.ts
new file mode 100644
index 00000000..d12e3774
--- /dev/null
+++ b/src/components/Notifications/i18n/index.ts
@@ -0,0 +1,6 @@
+import {registerKeyset} from '../../utils/registerKeyset';
+import en from './en.json';
+import ru from './ru.json';
+
+const COMPONENT = 'Notifications';
+export default registerKeyset({en, ru}, COMPONENT);
diff --git a/src/components/Notifications/i18n/ru.json b/src/components/Notifications/i18n/ru.json
new file mode 100644
index 00000000..bc77c487
--- /dev/null
+++ b/src/components/Notifications/i18n/ru.json
@@ -0,0 +1,3 @@
+{
+ "title": "Уведомления"
+}
diff --git a/src/components/Notifications/index.ts b/src/components/Notifications/index.ts
new file mode 100644
index 00000000..e2188db5
--- /dev/null
+++ b/src/components/Notifications/index.ts
@@ -0,0 +1,2 @@
+export * from './definitions';
+export * from './Notifications';
diff --git a/src/components/index.ts b/src/components/index.ts
index 371d0184..3f9124a7 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -3,6 +3,8 @@ export * from './ChangelogDialog';
export * from './FormRow';
export * from './InfiniteScroll';
export * from './ItemSelector';
+export * from './Notification';
+export * from './Notifications';
export * from './PlaceholderContainer';
export * from './ActionsPanel';
export * from './StoreBadge';
diff --git a/src/components/utils/cn.ts b/src/components/utils/cn.ts
index 9ca60836..8f80b125 100644
--- a/src/components/utils/cn.ts
+++ b/src/components/utils/cn.ts
@@ -1,5 +1,7 @@
import {withNaming} from '@bem-react/classname';
+export type CnMods = Record;
+
export const NAMESPACE = 'gc-';
export const block = withNaming({n: NAMESPACE, e: '__', m: '_', v: '_'});