Skip to content

Commit 08c403a

Browse files
authored
feat(Notifications): Allowed ReactNode in source icon + added sourcePlacement property (bottom is used by default) (#278)
1 parent 8266e89 commit 08c403a

File tree

5 files changed

+165
-85
lines changed

5 files changed

+165
-85
lines changed

src/components/Notification/Notification.scss

+21-29
Original file line numberDiff line numberDiff line change
@@ -17,52 +17,44 @@ $notificationSourceIconSize: 36px;
1717
}
1818

1919
&__right {
20-
display: flex;
21-
flex-direction: column;
22-
gap: 4px;
2320
flex: 1;
24-
overflow-x: hidden;
25-
}
26-
27-
&__right-top-part {
28-
display: flex;
29-
align-items: center;
30-
width: 100%;
31-
overflow-x: hidden;
3221
}
3322

34-
&__right-meta-and-title {
23+
&__title-wrapper {
3524
flex: 1;
3625
min-width: 0;
3726
overflow-x: hidden;
3827
}
3928

40-
&__right-meta,
41-
&__right-title {
29+
&__source-text,
30+
&__title {
4231
overflow: hidden;
4332
text-overflow: ellipsis;
4433
white-space: nowrap;
4534
}
4635

47-
&__right-meta {
48-
display: flex;
49-
gap: 4px;
36+
&__source-text {
5037
color: var(--g-color-text-secondary);
5138
}
5239

53-
&__right-title {
54-
font-weight: 500;
55-
font-size: 13px;
56-
line-height: 18px;
40+
&__bottom-source {
41+
margin-block-start: 4px;
42+
}
5743

44+
&__title {
45+
font-weight: 500;
5846
color: var(--g-color-text-primary);
5947
}
6048

61-
&__right-content {
49+
&__title-with-source {
50+
margin-block-end: 4px;
51+
}
52+
53+
&__content {
6254
font-size: 13px;
6355
line-height: 18px;
6456

65-
color: var(--g-color-text-secondary);
57+
color: var(--g-color-text-complementary);
6658
}
6759

6860
&_unread {
@@ -75,23 +67,23 @@ $notificationSourceIconSize: 36px;
7567
&__actions {
7668
display: flex;
7769
align-items: center;
78-
flex-wrap: wrap;
7970
}
8071

81-
&__actions_right-bottom-actions {
72+
&__actions_bottom-actions {
8273
margin-block-start: 8px;
8374
gap: 8px;
75+
flex-wrap: wrap;
8476
}
8577

86-
&__actions_right-side-actions {
78+
&__actions_side-actions {
8779
height: 28px;
8880
opacity: 0;
8981
}
90-
&:hover &__actions_right-side-actions,
91-
&__actions_right-side-actions:focus-within {
82+
&:hover &__actions_side-actions,
83+
&__actions_side-actions:focus-within {
9284
opacity: 1;
9385
}
94-
&_mobile &__actions_right-side-actions {
86+
&_mobile &__actions_side-actions {
9587
opacity: 1;
9688
}
9789

src/components/Notification/Notification.tsx

+98-34
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22

3-
import {Icon, Link, useMobile, useUniqId} from '@gravity-ui/uikit';
3+
import {Flex, Icon, Link, useMobile, useUniqId} from '@gravity-ui/uikit';
44

55
import {CnMods, block} from '../utils/cn';
66

@@ -15,13 +15,64 @@ type Props = {notification: NotificationProps};
1515
export const Notification = React.memo(function Notification(props: Props) {
1616
const mobile = useMobile();
1717
const {notification} = props;
18-
const {title, content, formattedDate, source, unread, theme} = notification;
18+
const {
19+
title,
20+
content,
21+
formattedDate,
22+
source,
23+
unread,
24+
theme,
25+
sourcePlacement = 'bottom',
26+
} = notification;
1927

2028
const modifiers: CnMods = {unread, theme, mobile, active: Boolean(notification.onClick)};
2129
const titleId = useUniqId();
2230

2331
const sourceIcon = source && renderSourceIcon(source, titleId);
2432

33+
const renderedTitle = title ? (
34+
<div className={b('title-wrapper')}>
35+
<div className={b('title')}>{title}</div>
36+
</div>
37+
) : null;
38+
39+
const renderedSideActions = (
40+
<div className={b('actions', {'side-actions': true})}>{props.notification.sideActions}</div>
41+
);
42+
43+
const renderedBottomActions = props.notification.bottomActions ? (
44+
<div className={b('actions', {'bottom-actions': true})}>
45+
{props.notification.bottomActions}
46+
</div>
47+
) : null;
48+
49+
const renderedContent = <div className={b('content')}>{content}</div>;
50+
51+
const renderedSourceText =
52+
source?.title || formattedDate ? (
53+
<Flex className={b('source-text')} gap={1}>
54+
{source?.title
55+
? renderSourceTitle({
56+
title: source.title,
57+
href: source.href,
58+
id: titleId,
59+
})
60+
: null}
61+
{source?.title && formattedDate ? <span></span> : null}
62+
{formattedDate ? <div className={b('right-date')}>{formattedDate}</div> : null}
63+
</Flex>
64+
) : null;
65+
66+
const hasSourceOnTop = renderedSourceText && sourcePlacement === 'top';
67+
const hasSourceOnBottom = renderedSourceText && sourcePlacement === 'bottom';
68+
const topPart =
69+
renderedTitle || hasSourceOnTop
70+
? withSideActions(
71+
renderTitleAndSource(renderedTitle, hasSourceOnTop ? renderedSourceText : null),
72+
renderedSideActions,
73+
)
74+
: null;
75+
2576
return (
2677
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
2778
<div
@@ -31,45 +82,56 @@ export const Notification = React.memo(function Notification(props: Props) {
3182
onClick={notification.onClick}
3283
>
3384
{sourceIcon ? <div className={b('left')}>{sourceIcon}</div> : null}
34-
<div className={b('right')}>
35-
<div className={b('right-top-part')}>
36-
<div className={b('right-meta-and-title')}>
37-
<div className={b('right-meta')}>
38-
{source?.title
39-
? renderSourceTitle({
40-
title: source.title,
41-
href: source.href,
42-
id: titleId,
43-
})
44-
: null}
45-
{source?.title && formattedDate ? <span></span> : null}
46-
{formattedDate ? (
47-
<div className={b('right-date')}>{formattedDate}</div>
48-
) : null}
49-
</div>
50-
{title ? <div className={b('right-title')}>{title}</div> : null}
51-
</div>
52-
<div className={b('actions', {'right-side-actions': true})}>
53-
{props.notification.sideActions}
54-
</div>
55-
</div>
56-
<div className={b('right-content')}>{content}</div>
57-
{props.notification.bottomActions ? (
58-
<div className={b('actions', {'right-bottom-actions': true})}>
59-
{props.notification.bottomActions}
60-
</div>
61-
) : null}
62-
</div>
85+
86+
<Flex className={b('right')} justifyContent="space-between" gap={2} overflow="hidden">
87+
<Flex direction="column" overflow="hidden" width="100%">
88+
{topPart}
89+
90+
{withSideActions(
91+
renderedContent,
92+
!renderedTitle && !hasSourceOnTop ? renderedSideActions : null,
93+
)}
94+
95+
{hasSourceOnBottom ? (
96+
<div className={b('bottom-source')}>{renderedSourceText}</div>
97+
) : null}
98+
99+
{renderedBottomActions}
100+
</Flex>
101+
</Flex>
63102
</div>
64103
);
65104
});
66105

106+
function withSideActions(content: React.ReactNode, sideActions: React.ReactNode) {
107+
return sideActions ? (
108+
<Flex alignItems="center" justifyContent="space-between" width="100%" overflow="hidden">
109+
{content}
110+
{sideActions}
111+
</Flex>
112+
) : (
113+
content
114+
);
115+
}
116+
117+
function renderTitleAndSource(title: React.ReactNode, source: React.ReactNode) {
118+
return title && source ? (
119+
<Flex className={b('title-with-source')} direction="column" overflow="hidden">
120+
{source}
121+
{title}
122+
</Flex>
123+
) : (
124+
(title ?? source)
125+
);
126+
}
127+
67128
interface RenderSourceTitleOptions {
68129
title: string;
69130
href?: string;
70131
id: string;
71132
}
72-
function renderSourceTitle({title, href, id}: RenderSourceTitleOptions): JSX.Element {
133+
134+
function renderSourceTitle({title, href, id}: RenderSourceTitleOptions): React.ReactNode {
73135
return href ? (
74136
<Link className={b('right-source-title')} href={href} target="_blank" title={title} id={id}>
75137
{title}
@@ -81,7 +143,7 @@ function renderSourceTitle({title, href, id}: RenderSourceTitleOptions): JSX.Ele
81143
);
82144
}
83145

84-
function renderSourceIcon(source: NotificationSourceProps, titleId: string): JSX.Element | null {
146+
function renderSourceIcon(source: NotificationSourceProps, titleId: string): React.ReactNode {
85147
const iconElement = getIconElement(source);
86148

87149
if (!iconElement) return null;
@@ -95,11 +157,13 @@ function renderSourceIcon(source: NotificationSourceProps, titleId: string): JSX
95157
);
96158
}
97159

98-
function getIconElement(source: NotificationSourceProps): JSX.Element | null {
160+
function getIconElement(source: NotificationSourceProps): React.ReactNode {
99161
if ('icon' in source && source.icon) {
100162
return <Icon className={b('source-icon')} size={36} data={source.icon} />;
101163
} else if ('imageSrc' in source && source.imageSrc) {
102164
return <img alt="" className={b('source-icon')} src={source.imageSrc} />;
165+
} else if ('custom' in source && source.custom) {
166+
return source.custom;
103167
} else {
104168
return null;
105169
}

src/components/Notification/definitions.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import {ButtonProps, IconData, QAProps} from '@gravity-ui/uikit';
44

55
export type NotificationTheme = 'success' | 'info' | 'warning' | 'danger';
66

7-
type SvgOrImage = {icon: IconData} | {imageSrc: string};
7+
type NotificationIcon = {icon: IconData} | {imageSrc: string} | {custom: React.ReactNode};
88

99
export type NotificationSourceProps = {
1010
title?: string;
1111
href?: string;
12-
} & Partial<SvgOrImage>;
12+
} & Partial<NotificationIcon>;
13+
14+
export type NotificationSourcePlacement = 'top' | 'bottom';
1315

1416
export type NotificationSwipeActionProps = {
1517
content: React.ReactNode;
@@ -29,6 +31,7 @@ export type NotificationProps = {
2931
unread?: boolean;
3032
archived?: boolean;
3133
source?: NotificationSourceProps;
34+
sourcePlacement?: NotificationSourcePlacement;
3235
theme?: NotificationTheme;
3336
className?: string;
3437

src/components/Notifications/__stories__/Notifications.stories.tsx

+25-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as React from 'react';
22

33
import {Bell} from '@gravity-ui/icons';
4-
import {Button, Checkbox, Flex, Icon, Popup} from '@gravity-ui/uikit';
4+
import {Button, Checkbox, Flex, Icon, Popup, SegmentedRadioGroup, Text} from '@gravity-ui/uikit';
55
import {Meta, StoryFn} from '@storybook/react';
66

77
import {delay} from '../../InfiniteScroll/__stories__/utils';
8-
import {NotificationProps} from '../../Notification/definitions';
8+
import {NotificationProps, NotificationSourcePlacement} from '../../Notification/definitions';
99
import {Notifications} from '../Notifications';
1010
import {NotificationsPopupWrapper} from '../NotificationsPopupWrapper';
1111

@@ -61,12 +61,13 @@ const Wrapper = (props: React.PropsWithChildren) => {
6161
type BooleanMap = Record<string, boolean | undefined>;
6262

6363
export const Default: StoryFn = () => {
64-
const {showNotificationsActions, showNotificationActions, renderControls} =
64+
const {showNotificationsActions, showNotificationActions, sourcePlacement, renderControls} =
6565
useNotificationsVariationsControl();
6666

6767
const {notifications, actions} = useNotificationsWithActions({
6868
showNotificationsActions,
6969
showNotificationActions,
70+
sourcePlacement,
7071
});
7172

7273
return (
@@ -163,10 +164,13 @@ export const Empty: StoryFn = () => {
163164
function useNotificationsVariationsControl() {
164165
const [showNotificationsActions, setShowNotificationsActions] = React.useState(true);
165166
const [showNotificationActions, setShowNotificationActions] = React.useState(true);
167+
const [sourcePlacement, setSourcePlacement] =
168+
React.useState<NotificationSourcePlacement>('bottom');
166169

167170
return {
168171
showNotificationsActions,
169172
showNotificationActions,
173+
sourcePlacement,
170174
renderControls: () => (
171175
<Flex gap={2} direction="column">
172176
<Checkbox
@@ -182,6 +186,17 @@ function useNotificationsVariationsControl() {
182186
>
183187
Notification actions
184188
</Checkbox>
189+
<Flex direction="column" gap={1}>
190+
<Text>Source/date placement</Text>
191+
<SegmentedRadioGroup<NotificationSourcePlacement>
192+
value={sourcePlacement}
193+
onUpdate={setSourcePlacement}
194+
options={[
195+
{value: 'bottom', content: 'Bottom'},
196+
{value: 'top', content: 'Top'},
197+
]}
198+
/>
199+
</Flex>
185200
</Flex>
186201
),
187202
};
@@ -190,6 +205,11 @@ function useNotificationsVariationsControl() {
190205
function useNotificationsWithActions({
191206
showNotificationsActions = true,
192207
showNotificationActions = true,
208+
sourcePlacement = 'bottom',
209+
}: {
210+
showNotificationsActions?: boolean;
211+
showNotificationActions?: boolean;
212+
sourcePlacement?: NotificationSourcePlacement;
193213
} = {}) {
194214
const [unreadNotifications, setUnreadNotifications] = React.useState<BooleanMap>({
195215
tracker: true,
@@ -229,9 +249,10 @@ function useNotificationsWithActions({
229249
unread,
230250
archived,
231251
sideActions: getSideActions(id, unread, archived),
252+
sourcePlacement,
232253
};
233254
}),
234-
[unreadNotifications, archivedNotifications, getSideActions],
255+
[archivedNotifications, unreadNotifications, sourcePlacement, getSideActions],
235256
);
236257

237258
const actions = showNotificationsActions ? (

0 commit comments

Comments
 (0)