diff --git a/src/components/Notification/Notification.scss b/src/components/Notification/Notification.scss index c2985d02..9b8f53c9 100644 --- a/src/components/Notification/Notification.scss +++ b/src/components/Notification/Notification.scss @@ -3,113 +3,277 @@ $block: '.#{variables.$ns}notification'; $notificationSourceIconSize: 36px; +$notificationSourceIconGap: var(--g-spacing-3); +$notificationLeftOffset: $notificationSourceIconSize + 12px; +$notificationSourceLineHeight: 18px; +$notificationSourceGap: var(--g-spacing-1); +$notificationSourceBlockSize: $notificationSourceLineHeight + 4px; +$notificationSideActionsWidth: 36px; +$notificationSideActionsGap: var(--g-spacing-2); +$notificationSideActionsOffset: $notificationSideActionsWidth + 8px; #{$block} { - display: flex; - padding: 12px; - gap: 12px; - border-radius: 4px; + position: relative; + display: block; + border-radius: var(--g-spacing-1); box-sizing: border-box; width: 100%; font: inherit; color: inherit; - text-decoration: none; + + background-color: var(--g-color-base-background); + + &__clickable { + display: block; + width: 100%; + padding: var(--g-spacing-3); + box-sizing: border-box; + color: inherit; + text-decoration: none; + border-radius: var(--g-spacing-1); + + appearance: none; + background: transparent; + border: 0; + margin: 0; + font: inherit; + text-align: inherit; + outline-offset: var(--g-spacing-half); + } + + &__clickable_active { + cursor: pointer; + } &_active:hover { background: var(--g-color-base-simple-hover); - cursor: pointer; } - &__right { - flex: 1; + &__clickable_has-left { + padding-inline-start: calc(var(--g-spacing-3) + #{$notificationLeftOffset}); } - &__title-wrapper { - flex: 1; - min-width: 0; - overflow-x: hidden; + &__clickable_has-side-actions { + padding-inline-end: calc(var(--g-spacing-3) + #{$notificationSideActionsOffset}); } - &__source-text, - &__title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + &__clickable_has-source-top { + padding-block: calc(var(--g-spacing-2) + #{$notificationSourceBlockSize}) var(--g-spacing-4); } - &__source-text { - color: var(--g-color-text-secondary); + &__clickable_has-source-bottom { + padding-block-end: calc(var(--g-spacing-3) + #{$notificationSourceBlockSize}); } - &__bottom-source { - margin-block-start: 4px; + &__clickable_has-bottom-actions { + padding-block-end: calc(var(--g-spacing-3) + 36px + var(--g-spacing-2)); + } + + &__clickable_has-source-bottom#{&}__clickable_has-bottom-actions { + padding-block-end: calc( + var(--g-spacing-3) + #{$notificationSourceBlockSize} + 28px + var(--g-spacing-2) + ); + } + + &__clickable_has-source-top#{&}__clickable_has-bottom-actions { + padding-block-end: calc(var(--g-spacing-6) + #{$notificationSourceBlockSize}); + } + + &__main-content { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__title-wrapper { + display: flex; + align-items: center; + min-width: 0; + height: var(--g-spacing-7); + } + + &_has-source-top &__title-wrapper { + height: auto; + + margin: var(--g-spacing-1) 0 var(--g-spacing-1) 0; } &__title { font-weight: 500; - color: var(--g-color-text-primary); + margin: 0; + padding: 0; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - &__title-with-source { - margin-block-end: 4px; + &__content-wrapper { + min-height: var(--g-spacing-7); + + display: flex; + align-items: center; + min-width: 0; } &__content { font-size: 13px; line-height: 18px; - color: var(--g-color-text-complementary); - } - &_unread { - background: var(--g-color-base-selection); - &:hover { - background: var(--g-color-base-selection-hover); - } + word-break: break-word; + overflow-wrap: anywhere; } - &__actions { + &__left { + position: absolute; + inset-block-start: var(--g-spacing-3); + inset-inline-start: var(--g-spacing-3); display: flex; align-items: center; + min-height: var(--g-spacing-7); + z-index: 1; } - &__actions_bottom-actions { - margin-block-start: 8px; - gap: 8px; - flex-wrap: wrap; + &__top-source, + &__bottom-source { + position: absolute; + inset-inline: var(--g-spacing-3) var(--g-spacing-3); + z-index: 1; + width: fit-content; + } + + &__top-source { + inset-block-start: var(--g-spacing-3); + } + + &__bottom-source { + inset-block-end: var(--g-spacing-3); + } + + &__source-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--g-color-text-secondary); + + display: inline-flex; + gap: var(--g-spacing-1); + } + + &__right-source-title { + pointer-events: auto; + position: relative; } &__actions_side-actions { - height: 28px; + position: absolute; + inset-block-start: var(--g-spacing-2); + inset-inline-end: var(--g-spacing-3); + z-index: 1; + + display: flex; + flex-direction: column; + align-items: center; + gap: var(--g-spacing-1); + opacity: 0; + transition: opacity 0.1s ease-in-out; } - &:hover &__actions_side-actions, + + #{$block}:hover &__actions_side-actions, + #{$block}:focus-within &__actions_side-actions, &__actions_side-actions:focus-within { opacity: 1; } + + &__actions_side-actions, + &__actions_bottom-actions { + background: transparent; + } + &_mobile &__actions_side-actions { opacity: 1; } - &__action_icon { - color: var(--g-color-text-secondary); + &__actions_bottom-actions { + position: absolute; + inset-inline: var(--g-spacing-3) var(--g-spacing-3); + z-index: 1; + + display: flex; + align-items: center; + gap: var(--g-spacing-2); + flex-wrap: wrap; + } + + &_has-left &__top-source, + &_has-left &__bottom-source, + &_has-left &__actions_bottom-actions { + inset-inline-start: calc(var(--g-spacing-3) + #{$notificationLeftOffset}); + } + + &_has-source-top &__actions_bottom-actions { + inset-block-end: var(--g-spacing-3); + } + + &_has-side-actions &__top-source { + inset-inline-end: calc(var(--g-spacing-3) + #{$notificationSideActionsOffset}); + } + + &_has-bottom-actions &__bottom-source { + inset-block-end: calc(var(--g-spacing-2) + var(--g-spacing-10)); + } + + &_has-source-bottom &__actions_bottom-actions { + inset-block-end: var(--g-spacing-3); + } + + &_unread { + background: var(--g-color-base-selection); + } + + &_unread#{&}_active:hover { + background: var(--g-color-base-selection-hover); } &_theme_success { - border-inline-start: 4px solid var(--g-color-line-positive); + border-inline-start: var(--g-spacing-1) solid var(--g-color-line-positive); } &_theme_info { - border-inline-start: 4px solid var(--g-color-line-info); + border-inline-start: var(--g-spacing-1) solid var(--g-color-line-info); } &_theme_warning { - border-inline-start: 4px solid var(--g-color-line-warning); + border-inline-start: var(--g-spacing-1) solid var(--g-color-line-warning); } &_theme_danger { - border-inline-start: 4px solid var(--g-color-line-danger); + border-inline-start: var(--g-spacing-1) solid var(--g-color-line-danger); } - &_active { - cursor: pointer; + &__action_icon { + color: var(--g-color-text-secondary); + } + + &__actions { + display: flex; + align-items: center; + } + + &__source-icon { + width: 36px; + height: 36px; + } + + &__visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; } &__swipe-wrap { @@ -142,7 +306,7 @@ $notificationSourceIconSize: 36px; &__swipe-action { display: flex; - gap: 8px; + gap: var(--g-spacing-2); align-items: center; justify-content: center; height: 100%; @@ -180,7 +344,7 @@ $notificationSourceIconSize: 36px; } &__swipe-action-icon { - padding: 8px; + padding: var(--g-spacing-2); border-radius: 100%; color: var(--g-color-base-background); } @@ -188,9 +352,4 @@ $notificationSourceIconSize: 36px; &__swipe-action-text { font-size: 16px; } - - &__source-icon { - width: 36px; - height: 36px; - } } diff --git a/src/components/Notification/Notification.tsx b/src/components/Notification/Notification.tsx index 1be47050..51c7bfa3 100644 --- a/src/components/Notification/Notification.tsx +++ b/src/components/Notification/Notification.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; -import {Flex, Icon, Link, useMobile, useUniqId} from '@gravity-ui/uikit'; +import {Icon, Link, Text, useMobile, useUniqId} from '@gravity-ui/uikit'; import {CnMods, block} from '../utils/cn'; import {NotificationProps, NotificationSourceProps} from './definitions'; +import {i18n} from './i18n'; import './Notification.scss'; @@ -13,6 +14,7 @@ const b = block('notification'); type Props = {notification: NotificationProps}; export const Notification = React.memo(function Notification(props: Props) { + const {t} = i18n.useTranslation(); const mobile = useMobile(); const {notification} = props; const { @@ -25,37 +27,40 @@ export const Notification = React.memo(function Notification(props: Props) { sourcePlacement = 'bottom', } = notification; - const modifiers: CnMods = { - unread, - theme, - mobile, - active: Boolean(notification.onClick || notification.href), - }; + const isInteractive = Boolean(notification.onClick || notification.href); const titleId = useUniqId(); const sourceIcon = source && renderSourceIcon(source, titleId); const renderedTitle = title ? (
-
{title}
+ + {title} +
) : null; - const renderedSideActions = ( -
{props.notification.sideActions}
- ); + const hasSideActions = Boolean(notification.sideActions); + const hasBottomActions = Boolean(notification.bottomActions); + const hasLeft = Boolean(sourceIcon); - const renderedBottomActions = props.notification.bottomActions ? ( -
- {props.notification.bottomActions} -
+ const renderedSideActions = notification.sideActions ? ( +
{notification.sideActions}
+ ) : null; + + const renderedBottomActions = notification.bottomActions ? ( +
{notification.bottomActions}
) : null; - const renderedContent =
{content}
; + const renderedContent = ( +
+
{content}
+
+ ); const renderedSourceText = source?.title || formattedDate ? ( - + {source?.title ? renderSourceTitle({ title: source.title, @@ -64,102 +69,91 @@ export const Notification = React.memo(function Notification(props: Props) { }) : null} {source?.title && formattedDate ? : null} - {formattedDate ?
{formattedDate}
: null} -
+ {formattedDate ? : null} + ) : null; - const hasSourceOnTop = renderedSourceText && sourcePlacement === 'top'; - const hasSourceOnBottom = renderedSourceText && sourcePlacement === 'bottom'; - const topPart = - renderedTitle || hasSourceOnTop - ? withSideActions( - renderTitleAndSource(renderedTitle, hasSourceOnTop ? renderedSourceText : null), - renderedSideActions, - ) - : null; - - const notificationContent = ( - - {sourceIcon ?
{sourceIcon}
: null} - - - - {topPart} + const hasSourceOnTop = Boolean(renderedSourceText && sourcePlacement === 'top'); + const hasSourceOnBottom = Boolean(renderedSourceText && sourcePlacement === 'bottom'); - {withSideActions( - renderedContent, - !renderedTitle && !hasSourceOnTop ? renderedSideActions : null, - )} - - {hasSourceOnBottom ? ( -
{renderedSourceText}
- ) : null} + const layoutModifiers: CnMods = { + unread, + theme, + mobile, + active: isInteractive, + 'has-left': hasLeft, + 'has-side-actions': hasSideActions, + 'has-bottom-actions': hasBottomActions, + 'has-source-top': hasSourceOnTop, + 'has-source-bottom': hasSourceOnBottom, + }; - {renderedBottomActions} -
-
+ const clickableContent = ( + + {unread ? {t('unread-label')} : null} +
+ {renderedTitle} + {renderedContent} +
); - if (notification.href) { - const handleLinkClick: React.MouseEventHandler = (event) => { - if (event.target instanceof Element && event.target.closest('button')) { - event.preventDefault(); - return; - } - - notification.onClick?.(event); - }; + const clickableClassName = b('clickable', { + active: isInteractive, + 'has-left': hasLeft, + 'has-side-actions': hasSideActions, + 'has-bottom-actions': hasBottomActions, + 'has-source-top': hasSourceOnTop, + 'has-source-bottom': hasSourceOnBottom, + }); - return ( + let clickableElement: React.ReactNode; + if (notification.href) { + clickableElement = ( } href={notification.href} target={notification.target ?? '_blank'} rel="noreferrer" > - {notificationContent} + {clickableContent} ); + } else if (notification.onClick) { + clickableElement = ( + + ); + } else { + clickableElement =
{clickableContent}
; } return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
- {notificationContent} -
- ); -}); + {clickableElement} -function withSideActions(content: React.ReactNode, sideActions: React.ReactNode) { - return sideActions ? ( - - {content} - {sideActions} - - ) : ( - content - ); -} + {sourceIcon ?
{sourceIcon}
: null} -function renderTitleAndSource(title: React.ReactNode, source: React.ReactNode) { - return title && source ? ( - - {source} - {title} - - ) : ( - (title ?? source) + {hasSourceOnTop ?
{renderedSourceText}
: null} + {hasSourceOnBottom ? ( + {renderedSourceText} + ) : null} + + {renderedSideActions} + {renderedBottomActions} + ); -} +}); interface RenderSourceTitleOptions { title: string; diff --git a/src/components/Notification/NotificationAction.tsx b/src/components/Notification/NotificationAction.tsx index 9594970d..625a121e 100644 --- a/src/components/Notification/NotificationAction.tsx +++ b/src/components/Notification/NotificationAction.tsx @@ -23,6 +23,7 @@ export const NotificationAction = React.memo(function NotificationAction({action href={action.href as any} target={action.target} onClick={action.onClick} + aria-label={action.text} > {content} diff --git a/src/components/Notification/i18n/en.json b/src/components/Notification/i18n/en.json new file mode 100644 index 00000000..66233aac --- /dev/null +++ b/src/components/Notification/i18n/en.json @@ -0,0 +1,3 @@ +{ + "unread-label": "Unread notification" +} diff --git a/src/components/Notification/i18n/index.ts b/src/components/Notification/i18n/index.ts new file mode 100644 index 00000000..9c996a24 --- /dev/null +++ b/src/components/Notification/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '@gravity-ui/uikit/i18n'; + +import {NAMESPACE} from '../../utils/cn'; + +import en from './en.json'; +import ru from './ru.json'; + +export const i18n = addComponentKeysets({en, ru}, `${NAMESPACE}notification`); diff --git a/src/components/Notification/i18n/ru.json b/src/components/Notification/i18n/ru.json new file mode 100644 index 00000000..ffefa6bc --- /dev/null +++ b/src/components/Notification/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "unread-label": "Непрочитанное уведомление" +} diff --git a/src/components/Notifications/Notifications.scss b/src/components/Notifications/Notifications.scss index ed9b8aa0..a4d146a5 100644 --- a/src/components/Notifications/Notifications.scss +++ b/src/components/Notifications/Notifications.scss @@ -24,6 +24,7 @@ $block: '.#{variables.$ns}notifications'; line-height: 24px; color: var(--g-color-text-primary); + margin: 0; } &__body { @@ -32,6 +33,12 @@ $block: '.#{variables.$ns}notifications'; overflow-y: auto; } + &__list { + list-style: none; + padding: 0; + margin: 0; + } + &__empty { height: 100%; gap: 16px; @@ -64,7 +71,7 @@ $block: '.#{variables.$ns}notifications'; height: 28px; } - &__notification-wrapper:not(:first-child)::before { + &__item:not(:first-child) &__notification-wrapper::before { content: ''; display: block; border-block-start: 1px solid var(--g-color-line-generic); @@ -72,11 +79,11 @@ $block: '.#{variables.$ns}notifications'; } // :hover - &__notification-wrapper_active:hover:not(:first-child)::before, - &__notification-wrapper_active:hover + &__notification-wrapper::before, + &__item:not(:first-child):has(&__notification-wrapper_active:hover) &__notification-wrapper::before, + &__item:has(&__notification-wrapper_active:hover) + &__item &__notification-wrapper::before, // .unread - &__notification-wrapper_unread:not(:first-child)::before, - &__notification-wrapper_unread + &__notification-wrapper::before { + &__item:not(:first-child):has(&__notification-wrapper_unread) &__notification-wrapper::before, + &__item:has(&__notification-wrapper_unread) + &__item &__notification-wrapper::before { content: ''; display: block; border-block-start: 1px solid transparent; diff --git a/src/components/Notifications/Notifications.tsx b/src/components/Notifications/Notifications.tsx index 1486d221..1f8b2d20 100644 --- a/src/components/Notifications/Notifications.tsx +++ b/src/components/Notifications/Notifications.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; +import {Text} from '@gravity-ui/uikit'; + import {InfiniteScroll} from '../InfiniteScroll'; import {block} from '../utils/cn'; @@ -55,7 +57,11 @@ export const Notifications = React.memo(function Notifications(props: Notificati ); } - const title =
{props.title || t('title')}
; + const title = ( + + {props.title || t('title')} + + ); return (
diff --git a/src/components/Notifications/NotificationsList.tsx b/src/components/Notifications/NotificationsList.tsx index aa0013d4..aa416011 100644 --- a/src/components/Notifications/NotificationsList.tsx +++ b/src/components/Notifications/NotificationsList.tsx @@ -16,14 +16,16 @@ type Props = { export const NotificationsList = React.memo(function NotificationsList(props: Props) { return ( -
+
    {props.notifications.map((notification) => ( - +
  • + +
  • ))} -
+ ); });