mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(component): init notification center (#2426)
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"@popperjs/core": "^2.11.7",
|
||||
"@radix-ui/react-avatar": "^1.0.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.3",
|
||||
"@toeverything/hooks": "workspace:*",
|
||||
"@toeverything/theme": "^0.5.8",
|
||||
"@vanilla-extract/dynamic": "^2.0.3",
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// Credits to sonner
|
||||
// License on the MIT
|
||||
// https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/styles.css
|
||||
|
||||
import { keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||
|
||||
const swipeOut = keyframes({
|
||||
'0%': {
|
||||
transform:
|
||||
'translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount)))',
|
||||
opacity: 1,
|
||||
},
|
||||
'100%': {
|
||||
transform:
|
||||
'translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount) + var(--lift) * -100%))',
|
||||
opacity: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const notificationCenterViewportStyle = style({
|
||||
position: 'fixed',
|
||||
bottom: '200px',
|
||||
right: '60px',
|
||||
width: '380px',
|
||||
margin: 0,
|
||||
zIndex: 2147483647,
|
||||
outline: 'none',
|
||||
});
|
||||
|
||||
export const notificationStyle = style({
|
||||
position: 'absolute',
|
||||
borderRadius: '8px',
|
||||
transition: 'transform 0.3s,opacity 0.3s, height 0.3s',
|
||||
transform: 'var(--y)',
|
||||
zIndex: 'var(--z-index)',
|
||||
opacity: 0,
|
||||
touchAction: 'none',
|
||||
willChange: 'transform, opacity, height',
|
||||
selectors: {
|
||||
'&[data-visible=false]': {
|
||||
opacity: '0 !important',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&[data-swiping=true]::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
top: '50%',
|
||||
height: '100%',
|
||||
transform: 'scaleY(3) translateY(-50%)',
|
||||
},
|
||||
'&[data-swiping=false][data-removed=true]::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
transform: 'scaleY(2)',
|
||||
},
|
||||
'&[data-mounted=true]': {
|
||||
opacity: 1,
|
||||
vars: {
|
||||
'--y': 'translateY(0)',
|
||||
},
|
||||
},
|
||||
'&[data-expanded=false][data-front=false]': {
|
||||
opacity: 1,
|
||||
height: 'var(--front-toast-height)',
|
||||
vars: {
|
||||
'--scale': 'var(--toasts-before)* 0.05 + 1',
|
||||
'--y':
|
||||
'translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)))',
|
||||
},
|
||||
},
|
||||
'&[data-mounted=true][data-expanded=true]': {
|
||||
height: 'var(--initial-height)',
|
||||
vars: {
|
||||
'--y': 'translateY(calc(var(--lift) * var(--offset)))',
|
||||
},
|
||||
},
|
||||
'&[data-removed=true][data-front=true][data-swipe-out=false]': {
|
||||
opacity: 0,
|
||||
vars: {
|
||||
'--y': 'translateY(calc(var(--lift) * -100%))',
|
||||
},
|
||||
},
|
||||
'&[data-removed=true][data-front=false][data-swipe-out=false][data-expanded=true]':
|
||||
{
|
||||
opacity: 0,
|
||||
vars: {
|
||||
'--y':
|
||||
'translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%))',
|
||||
},
|
||||
},
|
||||
'&[data-removed=true][data-front=false][data-swipe-out=false][data-expanded=false] ':
|
||||
{
|
||||
transition: 'transform 500ms, opacity 200ms',
|
||||
opacity: 0,
|
||||
vars: {
|
||||
'--y': 'translateY(40%)',
|
||||
},
|
||||
},
|
||||
'&[data-removed=true][data-front=false]::before ': {
|
||||
height: 'calc(var(--initial-height) + 20%)',
|
||||
},
|
||||
'&[data-swiping=true]': {
|
||||
transform: 'var(--y) translateY(var(--swipe-amount, 0px))',
|
||||
transition: 'none',
|
||||
},
|
||||
'&[data-swipe-out=true]': {
|
||||
animation: `${swipeOut} 0.3s ease-in-out forwards`,
|
||||
},
|
||||
},
|
||||
vars: {
|
||||
'--y': 'translateY(100%)',
|
||||
'--lift': '-1',
|
||||
'--lift-amount': 'calc(var(--lift) * 14px)',
|
||||
},
|
||||
'::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '15px',
|
||||
left: '0',
|
||||
bottom: '100%',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
});
|
||||
export const notificationIconStyle = style({
|
||||
fontSize: '24px',
|
||||
marginLeft: '18px',
|
||||
marginRight: '12px',
|
||||
color: 'var(--affine-processing-color)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const notificationContentStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '16px 0',
|
||||
width: '100%',
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'var(--affine-shadow-1)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
background: 'var(--affine-white)',
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
export const notificationTitleContactStyle = style({
|
||||
marginRight: '22px',
|
||||
width: '200px',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
});
|
||||
export const notificationTitleStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
});
|
||||
export const notificationDescriptionStyle = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
marginBottom: '4px',
|
||||
});
|
||||
export const notificationTimeStyle = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
marginBottom: '4px',
|
||||
});
|
||||
export const closeButtonStyle = style({
|
||||
fontSize: '22px',
|
||||
marginRight: '19px',
|
||||
marginLeft: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const closeButtonWithoutUndoStyle = style({
|
||||
marginLeft: '92px',
|
||||
});
|
||||
export const undoButtonStyle = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
background: 'var(--affine-hover-color)',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--affine-processing-color)',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
export const messageStyle = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
width: '200px',
|
||||
marginLeft: '50px',
|
||||
lineHeight: '18px',
|
||||
});
|
||||
export const progressBarStyle = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
width: '100%',
|
||||
height: '10px',
|
||||
marginTop: '10px',
|
||||
padding: '0 16px',
|
||||
borderRadius: '2px',
|
||||
marginBottom: '16px',
|
||||
});
|
||||
export const darkSuccessStyle = style({
|
||||
background: 'var(--affine-success-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const darkInfoStyle = style({
|
||||
background: 'var(--affine-processing-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const darkErrorStyle = style({
|
||||
background: 'var(--affine-error-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const darkWarningStyle = style({
|
||||
background: 'var(--affine-warning-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const lightSuccessStyle = style({
|
||||
background: 'var(--affine-background-success-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const lightInfoStyle = style({
|
||||
background: 'var(--affine-background-processing-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const lightErrorStyle = style({
|
||||
background: 'var(--affine-background-error-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const lightWarningStyle = style({
|
||||
background: 'var(--affine-background-warning-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const darkColorStyle = style({
|
||||
color: 'var(--affine-white)',
|
||||
});
|
||||
export const lightInfoIconStyle = style({
|
||||
color: 'var(--affine-processing-color)',
|
||||
});
|
||||
export const defaultCollapseStyle = styleVariants({
|
||||
secondary: {
|
||||
'::after': {
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
top: '0px',
|
||||
transition: 'background-color 0.3s',
|
||||
},
|
||||
},
|
||||
tertiary: {
|
||||
'::after': {
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
top: '0px',
|
||||
transition: 'background-color 0.3s',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const lightCollapseStyle = styleVariants({
|
||||
secondary: {
|
||||
'::after': {
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
top: '0px',
|
||||
transition: 'background-color 0.3s',
|
||||
},
|
||||
},
|
||||
tertiary: {
|
||||
'::after': {
|
||||
background: 'rgba(0,0,0,0.08)',
|
||||
top: '0px',
|
||||
transition: 'background-color 0.3s',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const darkCollapseStyle = styleVariants({
|
||||
secondary: {
|
||||
'::after': {
|
||||
background: 'rgba(0,0,0,0.08)',
|
||||
top: '0px',
|
||||
transition: 'background-color 0.3s',
|
||||
},
|
||||
},
|
||||
tertiary: {
|
||||
'::after': {
|
||||
background: 'rgba(0,0,0,0.16)',
|
||||
top: '0px',
|
||||
transition: 'background-color 0.3s',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export type Notification = {
|
||||
key: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
theme?: 'light' | 'dark';
|
||||
timeout?: number;
|
||||
progressingBar?: boolean;
|
||||
// actions
|
||||
undo?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const notificationsBaseAtom = atom<Notification[]>([]);
|
||||
|
||||
const expandNotificationCenterBaseAtom = atom(false);
|
||||
const cleanupQueueAtom = atom<(() => unknown)[]>([]);
|
||||
export const expandNotificationCenterAtom = atom<boolean, [boolean], void>(
|
||||
get => get(expandNotificationCenterBaseAtom),
|
||||
(get, set, value) => {
|
||||
if (value === false) {
|
||||
get(cleanupQueueAtom).forEach(cleanup => cleanup());
|
||||
set(cleanupQueueAtom, []);
|
||||
}
|
||||
set(expandNotificationCenterBaseAtom, value);
|
||||
}
|
||||
);
|
||||
|
||||
export const notificationsAtom = atom<Notification[]>(get =>
|
||||
get(notificationsBaseAtom)
|
||||
);
|
||||
|
||||
export const removeNotificationAtom = atom(null, (get, set, key: string) => {
|
||||
set(notificationsBaseAtom, notifications =>
|
||||
notifications.filter(notification => notification.key !== key)
|
||||
);
|
||||
});
|
||||
|
||||
export const pushNotificationAtom = atom<null, [Notification], void>(
|
||||
null,
|
||||
(get, set, newNotification) => {
|
||||
const key = newNotification.key;
|
||||
const removeNotification = () =>
|
||||
set(notificationsBaseAtom, notifications =>
|
||||
notifications.filter(notification => notification.key !== key)
|
||||
);
|
||||
const undo: (() => Promise<void>) | undefined = newNotification.undo
|
||||
? (() => {
|
||||
const undo: () => Promise<void> = newNotification.undo;
|
||||
return async function undoNotificationWrapper() {
|
||||
removeNotification();
|
||||
return undo();
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
set(notificationsBaseAtom, notifications => [
|
||||
// push to the top
|
||||
{ ...newNotification, undo },
|
||||
...notifications,
|
||||
]);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
|
||||
import { NotificationCenter, pushNotificationAtom } from '.';
|
||||
import { expandNotificationCenterAtom } from './index.jotai';
|
||||
|
||||
export default {
|
||||
title: 'AFFiNE/NotificationCenter',
|
||||
component: NotificationCenter,
|
||||
} satisfies Meta<typeof NotificationCenter>;
|
||||
|
||||
let id = 0;
|
||||
export const Basic = () => {
|
||||
const push = useSetAtom(pushNotificationAtom);
|
||||
const expand = useAtomValue(expandNotificationCenterAtom);
|
||||
return (
|
||||
<>
|
||||
<div>{expand ? 'expanded' : 'collapsed'}</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: `${key} message`,
|
||||
timeout: 3000,
|
||||
progressingBar: true,
|
||||
undo: async () => {
|
||||
console.log('undo');
|
||||
},
|
||||
type: 'info',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Push timeout notification
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: `${key} message`,
|
||||
type: 'info',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Push notification with no timeout
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'info',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Push notification with no message
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'success',
|
||||
theme: 'light',
|
||||
});
|
||||
}}
|
||||
>
|
||||
light success
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'success',
|
||||
theme: 'dark',
|
||||
});
|
||||
}}
|
||||
>
|
||||
dark success
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'info',
|
||||
theme: 'light',
|
||||
});
|
||||
}}
|
||||
>
|
||||
light info
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'info',
|
||||
theme: 'dark',
|
||||
});
|
||||
}}
|
||||
>
|
||||
dark info
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'warning',
|
||||
theme: 'light',
|
||||
});
|
||||
}}
|
||||
>
|
||||
light warning
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'warning',
|
||||
theme: 'dark',
|
||||
});
|
||||
}}
|
||||
>
|
||||
dark warning
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'error',
|
||||
theme: 'light',
|
||||
});
|
||||
}}
|
||||
>
|
||||
light error
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = id++;
|
||||
push({
|
||||
key: `${key}`,
|
||||
title: `${key} title`,
|
||||
message: ``,
|
||||
type: 'error',
|
||||
theme: 'dark',
|
||||
});
|
||||
}}
|
||||
>
|
||||
dark error
|
||||
</button>
|
||||
</div>
|
||||
<NotificationCenter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
415
packages/component/src/components/notification-center/index.tsx
Normal file
415
packages/component/src/components/notification-center/index.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
// Credits to sonner
|
||||
// License on the MIT
|
||||
// https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/index.tsx
|
||||
|
||||
import { CloseIcon, InformationFillIcon } from '@blocksuite/icons';
|
||||
import * as Toast from '@radix-ui/react-toast';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { IconButton } from '../../ui/button';
|
||||
import * as styles from './index.css';
|
||||
import type { Notification } from './index.jotai';
|
||||
import {
|
||||
expandNotificationCenterAtom,
|
||||
notificationsAtom,
|
||||
pushNotificationAtom,
|
||||
removeNotificationAtom,
|
||||
} from './index.jotai';
|
||||
|
||||
// only expose necessary function atom to avoid misuse
|
||||
export { pushNotificationAtom, removeNotificationAtom };
|
||||
type Height = {
|
||||
height: number;
|
||||
notificationKey: number | string;
|
||||
};
|
||||
export type NotificationCardProps = {
|
||||
notification: Notification;
|
||||
notifications: Notification[];
|
||||
index: number;
|
||||
heights: Height[];
|
||||
setHeights: React.Dispatch<React.SetStateAction<Height[]>>;
|
||||
};
|
||||
const typeColorMap = {
|
||||
info: {
|
||||
light: styles.lightInfoStyle,
|
||||
dark: styles.darkInfoStyle,
|
||||
},
|
||||
success: {
|
||||
light: styles.lightSuccessStyle,
|
||||
dark: styles.darkSuccessStyle,
|
||||
},
|
||||
warning: {
|
||||
light: styles.lightWarningStyle,
|
||||
dark: styles.darkWarningStyle,
|
||||
},
|
||||
error: {
|
||||
light: styles.lightErrorStyle,
|
||||
dark: styles.darkErrorStyle,
|
||||
},
|
||||
};
|
||||
|
||||
function NotificationCard(props: NotificationCardProps): ReactElement {
|
||||
const removeNotification = useSetAtom(removeNotificationAtom);
|
||||
const { notification, notifications, setHeights, heights, index } = props;
|
||||
const [expand, setExpand] = useAtom(expandNotificationCenterAtom);
|
||||
// const setNotificationRemoveAnimation = useSetAtom(notificationRemoveAnimationAtom);
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
const [removed, setRemoved] = useState<boolean>(false);
|
||||
const [swiping, setSwiping] = useState<boolean>(false);
|
||||
const [swipeOut, setSwipeOut] = useState<boolean>(false);
|
||||
const [offsetBeforeRemove, setOffsetBeforeRemove] = useState<number>(0);
|
||||
const [initialHeight, setInitialHeight] = useState<number>(0);
|
||||
const [animationKey, setAnimationKey] = useState(0);
|
||||
const animationRef = useRef<SVGAnimateElement>(null);
|
||||
const notificationRef = useRef<HTMLLIElement>(null);
|
||||
const timerIdRef = useRef<NodeJS.Timeout>();
|
||||
const isFront = index === 0;
|
||||
const isVisible = index + 1 <= 3;
|
||||
const progressDuration = notification.timeout || 3000;
|
||||
const heightIndex = useMemo(
|
||||
() =>
|
||||
heights.findIndex(
|
||||
height => height.notificationKey === notification.key
|
||||
) || 0,
|
||||
[heights, notification.key]
|
||||
);
|
||||
const duration = notification.timeout || 3000;
|
||||
const offset = useRef(0);
|
||||
const pointerStartYRef = useRef<number | null>(null);
|
||||
const notificationsHeightBefore = useMemo(() => {
|
||||
return heights.reduce((prev, curr, reducerIndex) => {
|
||||
// Calculate offset up untill current notification
|
||||
if (reducerIndex >= heightIndex) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return prev + curr.height;
|
||||
}, 0);
|
||||
}, [heights, heightIndex]);
|
||||
|
||||
offset.current = useMemo(
|
||||
() => heightIndex * 14 + notificationsHeightBefore,
|
||||
[heightIndex, notificationsHeightBefore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger enter animation without using CSS animation
|
||||
setMounted(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!expand) {
|
||||
animationRef.current?.beginElement();
|
||||
}
|
||||
}, [expand]);
|
||||
|
||||
const resetAnimation = () => {
|
||||
setAnimationKey(prevKey => prevKey + 1);
|
||||
};
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!notificationRef.current) return;
|
||||
const notificationNode = notificationRef.current;
|
||||
const originalHeight = notificationNode.style.height;
|
||||
notificationNode.style.height = 'auto';
|
||||
const newHeight = notificationNode.getBoundingClientRect().height;
|
||||
notificationNode.style.height = originalHeight;
|
||||
|
||||
setInitialHeight(newHeight);
|
||||
|
||||
setHeights(heights => {
|
||||
const alreadyExists = heights.find(
|
||||
height => height.notificationKey === notification.key
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
return [
|
||||
{ notificationKey: notification.key, height: newHeight },
|
||||
...heights,
|
||||
];
|
||||
} else {
|
||||
return heights.map(height =>
|
||||
height.notificationKey === notification.key
|
||||
? { ...height, height: newHeight }
|
||||
: height
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [notification.title, notification.key, mounted, setHeights]);
|
||||
|
||||
const typeStyle =
|
||||
typeColorMap[notification.type][notification.theme || 'light'];
|
||||
|
||||
const onClickRemove = useCallback(() => {
|
||||
// Save the offset for the exit swipe animation
|
||||
setRemoved(true);
|
||||
setOffsetBeforeRemove(offset.current);
|
||||
setHeights(h =>
|
||||
h.filter(height => height.notificationKey !== notification.key)
|
||||
);
|
||||
setTimeout(() => {
|
||||
removeNotification(notification.key);
|
||||
}, 200);
|
||||
}, [setHeights, notification.key, removeNotification, offset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerIdRef.current) {
|
||||
clearTimeout(timerIdRef.current);
|
||||
}
|
||||
if (!expand) {
|
||||
timerIdRef.current = setTimeout(() => {
|
||||
onClickRemove();
|
||||
}, duration);
|
||||
}
|
||||
return () => {
|
||||
if (timerIdRef.current) {
|
||||
clearTimeout(timerIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [duration, expand, onClickRemove]);
|
||||
|
||||
const onClickUndo = useCallback(() => {
|
||||
if (notification.undo) {
|
||||
return notification.undo();
|
||||
}
|
||||
}, [notification]);
|
||||
|
||||
useEffect(() => {
|
||||
const notificationNode = notificationRef.current;
|
||||
|
||||
if (notificationNode) {
|
||||
const height = notificationNode.getBoundingClientRect().height;
|
||||
|
||||
// Add toast height tot heights array after the toast is mounted
|
||||
setInitialHeight(height);
|
||||
setHeights(h => [{ notificationKey: notification.key, height }, ...h]);
|
||||
|
||||
return () =>
|
||||
setHeights(h =>
|
||||
h.filter(height => height.notificationKey !== notification.key)
|
||||
);
|
||||
}
|
||||
}, [notification.key, setHeights]);
|
||||
return (
|
||||
<Toast.Root
|
||||
className={clsx(styles.notificationStyle, {
|
||||
[styles.lightCollapseStyle[index === 1 ? 'secondary' : 'tertiary']]:
|
||||
!isFront && !expand && notification.theme === 'light',
|
||||
[styles.darkCollapseStyle[index === 1 ? 'secondary' : 'tertiary']]:
|
||||
!isFront && !expand && notification.theme === 'dark',
|
||||
[styles.defaultCollapseStyle[index === 1 ? 'secondary' : 'tertiary']]:
|
||||
!isFront && !expand && !notification.theme,
|
||||
})}
|
||||
duration={Infinity}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
role="status"
|
||||
tabIndex={0}
|
||||
ref={notificationRef}
|
||||
data-mounted={mounted}
|
||||
data-removed={removed}
|
||||
data-visible={isVisible}
|
||||
data-index={index}
|
||||
data-front={isFront}
|
||||
data-swiping={swiping}
|
||||
data-swipe-out={swipeOut}
|
||||
data-expanded={expand}
|
||||
onMouseEnter={() => {
|
||||
setExpand(true);
|
||||
}}
|
||||
onMouseMove={() => {
|
||||
setExpand(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setExpand(false);
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--index': index,
|
||||
'--toasts-before': index,
|
||||
'--z-index': notifications.length - index,
|
||||
'--offset': `${removed ? offsetBeforeRemove : offset.current}px`,
|
||||
'--initial-height': `${initialHeight}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onPointerDown={event => {
|
||||
setOffsetBeforeRemove(offset.current);
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
if ((event.target as HTMLElement).tagName === 'BUTTON') return;
|
||||
setSwiping(true);
|
||||
pointerStartYRef.current = event.clientY;
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
if (swipeOut) return;
|
||||
const swipeAmount = Number(
|
||||
notificationRef.current?.style
|
||||
.getPropertyValue('--swipe-amount')
|
||||
.replace('px', '') || 0
|
||||
);
|
||||
if (Math.abs(swipeAmount) >= 20) {
|
||||
setOffsetBeforeRemove(offset.current);
|
||||
onClickRemove();
|
||||
setSwipeOut(true);
|
||||
return;
|
||||
}
|
||||
|
||||
notificationRef.current?.style.setProperty('--swipe-amount', '0px');
|
||||
pointerStartYRef.current = null;
|
||||
setSwiping(false);
|
||||
}}
|
||||
onPointerMove={event => {
|
||||
if (!pointerStartYRef.current) return;
|
||||
const yPosition = event.clientY - pointerStartYRef.current;
|
||||
|
||||
const isAllowedToSwipe = yPosition > 0;
|
||||
|
||||
if (!isAllowedToSwipe) {
|
||||
notificationRef.current?.style.setProperty('--swipe-amount', '0px');
|
||||
return;
|
||||
}
|
||||
|
||||
notificationRef.current?.style.setProperty(
|
||||
'--swipe-amount',
|
||||
`${yPosition}px`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.notificationContentStyle, {
|
||||
[typeStyle]: notification.theme,
|
||||
})}
|
||||
>
|
||||
<Toast.Title
|
||||
className={clsx(styles.notificationTitleStyle, {
|
||||
[styles.darkColorStyle]: notification.theme === 'dark',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.notificationIconStyle, {
|
||||
[styles.darkColorStyle]: notification.theme === 'dark',
|
||||
[styles.lightInfoIconStyle]: notification.theme !== 'dark',
|
||||
})}
|
||||
>
|
||||
<InformationFillIcon />
|
||||
</div>
|
||||
<div className={styles.notificationTitleContactStyle}>
|
||||
{notification.title}
|
||||
</div>
|
||||
{notification.undo && (
|
||||
<div
|
||||
className={clsx(styles.undoButtonStyle, {
|
||||
[styles.darkColorStyle]: notification.theme === 'dark',
|
||||
})}
|
||||
onClick={onClickUndo}
|
||||
>
|
||||
UNDO
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
className={clsx(styles.closeButtonStyle, {
|
||||
[styles.closeButtonWithoutUndoStyle]: !notification.undo,
|
||||
})}
|
||||
style={{
|
||||
color:
|
||||
notification.theme === 'dark'
|
||||
? 'var(--affine-white)'
|
||||
: 'var(--affine-icon-color)',
|
||||
}}
|
||||
>
|
||||
<CloseIcon onClick={onClickRemove} />
|
||||
</IconButton>
|
||||
</Toast.Title>
|
||||
<Toast.Description
|
||||
className={clsx(styles.messageStyle, {
|
||||
[styles.darkColorStyle]: notification.theme === 'dark',
|
||||
})}
|
||||
>
|
||||
{notification.message}
|
||||
</Toast.Description>
|
||||
{notification.progressingBar && (
|
||||
<div className={styles.progressBarStyle}>
|
||||
<svg width="100%" height="4">
|
||||
<rect
|
||||
width="100%"
|
||||
height="4"
|
||||
fill="var(--affine-hover-color)"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<rect
|
||||
width="0%"
|
||||
height="4"
|
||||
fill="var(--affine-primary-color)"
|
||||
rx="2"
|
||||
ry="2"
|
||||
>
|
||||
<animate
|
||||
key={animationKey}
|
||||
ref={animationRef}
|
||||
attributeName="width"
|
||||
from="0%"
|
||||
to="100%"
|
||||
dur={(progressDuration - 200) / 1000}
|
||||
fill="freeze"
|
||||
onAnimationEnd={resetAnimation}
|
||||
/>
|
||||
</rect>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Toast.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationCenter(): ReactElement {
|
||||
const notifications = useAtomValue(notificationsAtom);
|
||||
const [expand, setExpand] = useAtom(expandNotificationCenterAtom);
|
||||
|
||||
if (notifications.length === 0 && expand) {
|
||||
setExpand(false);
|
||||
}
|
||||
const [heights, setHeights] = useState<Height[]>([]);
|
||||
const listRef = useRef<HTMLOListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure expanded is always false when no toasts are present / only one left
|
||||
if (notifications.length <= 1) {
|
||||
setExpand(false);
|
||||
}
|
||||
}, [notifications, setExpand]);
|
||||
|
||||
if (!notifications.length) return <></>;
|
||||
return (
|
||||
<Toast.Provider swipeDirection="right">
|
||||
{notifications.map((notification, index) => (
|
||||
<NotificationCard
|
||||
notification={notification}
|
||||
index={index}
|
||||
key={notification.key}
|
||||
notifications={notifications}
|
||||
heights={heights}
|
||||
setHeights={setHeights}
|
||||
/>
|
||||
))}
|
||||
<Toast.Viewport
|
||||
tabIndex={-1}
|
||||
ref={listRef}
|
||||
style={
|
||||
{
|
||||
'--front-toast-height': `${heights[0]?.height}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={styles.notificationCenterViewportStyle}
|
||||
/>
|
||||
</Toast.Provider>
|
||||
);
|
||||
}
|
||||
96
yarn.lock
96
yarn.lock
@@ -68,6 +68,7 @@ __metadata:
|
||||
"@popperjs/core": ^2.11.7
|
||||
"@radix-ui/react-avatar": ^1.0.2
|
||||
"@radix-ui/react-collapsible": ^1.0.2
|
||||
"@radix-ui/react-toast": ^1.1.3
|
||||
"@storybook/addon-actions": ^7.0.12
|
||||
"@storybook/addon-coverage": ^0.0.8
|
||||
"@storybook/addon-essentials": ^7.0.12
|
||||
@@ -6444,6 +6445,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-collection@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@radix-ui/react-collection@npm:1.0.2"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.13.10
|
||||
"@radix-ui/react-compose-refs": 1.0.0
|
||||
"@radix-ui/react-context": 1.0.0
|
||||
"@radix-ui/react-primitive": 1.0.2
|
||||
"@radix-ui/react-slot": 1.0.1
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
checksum: f7d92f52c7f92b53c055370a5cbf077eea54366706eec9100973737577d841c0cc76a2a577fec67dd85b2853d03c20c4810058f0f511821052358439017e9e5d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-compose-refs@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@radix-ui/react-compose-refs@npm:1.0.0"
|
||||
@@ -6520,6 +6537,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-dismissable-layer@npm:1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "@radix-ui/react-dismissable-layer@npm:1.0.3"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.13.10
|
||||
"@radix-ui/primitive": 1.0.0
|
||||
"@radix-ui/react-compose-refs": 1.0.0
|
||||
"@radix-ui/react-primitive": 1.0.2
|
||||
"@radix-ui/react-use-callback-ref": 1.0.0
|
||||
"@radix-ui/react-use-escape-keydown": 1.0.2
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
checksum: cb2a38a65dd129d1fd58436bedee765f46f6a6edc2ec15d534a1499c10f768ae06ad874704e030c85869b3ee4b61103076a116dfdb7e0c761a8c8cdc30a5c951
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-focus-guards@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@radix-ui/react-focus-guards@npm:1.0.0"
|
||||
@@ -6571,6 +6605,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-portal@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@radix-ui/react-portal@npm:1.0.2"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.13.10
|
||||
"@radix-ui/react-primitive": 1.0.2
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
checksum: 1165b4bced8057021ea9ac4f568c6e0ea6f190936f07dc96780d67488b9222021444bbb8e04e506eea84d9219a5caacae8d0974e745182d4f398aa903b982e19
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-presence@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@radix-ui/react-presence@npm:1.0.0"
|
||||
@@ -6669,6 +6716,30 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-toast@npm:^1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "@radix-ui/react-toast@npm:1.1.3"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.13.10
|
||||
"@radix-ui/primitive": 1.0.0
|
||||
"@radix-ui/react-collection": 1.0.2
|
||||
"@radix-ui/react-compose-refs": 1.0.0
|
||||
"@radix-ui/react-context": 1.0.0
|
||||
"@radix-ui/react-dismissable-layer": 1.0.3
|
||||
"@radix-ui/react-portal": 1.0.2
|
||||
"@radix-ui/react-presence": 1.0.0
|
||||
"@radix-ui/react-primitive": 1.0.2
|
||||
"@radix-ui/react-use-callback-ref": 1.0.0
|
||||
"@radix-ui/react-use-controllable-state": 1.0.0
|
||||
"@radix-ui/react-use-layout-effect": 1.0.0
|
||||
"@radix-ui/react-visually-hidden": 1.0.2
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
checksum: b95baa857eea69b92a019ad511ac3da2fa2c8d6689593d6db576fe9aec2408c36f27b7ea4deda4c9b12b695103ece963fe4a270909b640416a11c0ab8115de95
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-use-callback-ref@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@radix-ui/react-use-callback-ref@npm:1.0.0"
|
||||
@@ -6704,6 +6775,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-use-escape-keydown@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.2"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.13.10
|
||||
"@radix-ui/react-use-callback-ref": 1.0.0
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
checksum: 5bec1b73ed6c38139bf1db3c626c0474ca6221ae55f154ef83f1c6429ea866280b2a0ba9436b807334d0215bb4389f0b492c65471cf565635957a8ee77cce98a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-use-layout-effect@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@radix-ui/react-use-layout-effect@npm:1.0.0"
|
||||
@@ -6715,6 +6798,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-visually-hidden@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@radix-ui/react-visually-hidden@npm:1.0.2"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.13.10
|
||||
"@radix-ui/react-primitive": 1.0.2
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
checksum: 67c4a55cfad9a8ff519a9b4ce24d2cc9d78c34d08a128a85de1a0a41228fdeb961eaeb4e50ca0d2080c5e31cef6f6e703cb06786579b44e5c66af161b941adb6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-dnd/asap@npm:^5.0.1":
|
||||
version: 5.0.2
|
||||
resolution: "@react-dnd/asap@npm:5.0.2"
|
||||
|
||||
Reference in New Issue
Block a user