refactor(component): new notification center implemented with sonner (#6416)

The Notification has been reimplemented using sooner, no longer relies on jotai, and new story has been added.

- Before
  ```ts
  import { pushNotificationAtom } from '@affine/component/notification-center';
  import { useSetAtom } from 'jotai';

  export const Component = () => {
    const pushNotification = useSetAtom(pushNotificationAtom);
    pushNotification({ ... });
  }
  ```

- After
  ```ts
  import { notify } from "@affine/component";

  export const Component = () => {
    notify({ ... });
  }
  ```
This commit is contained in:
CatsJuice
2024-04-02 03:18:56 +00:00
parent 80c7750f4a
commit a4cd51e503
10 changed files with 583 additions and 0 deletions

View File

@@ -69,6 +69,7 @@
"react-transition-state": "^2.1.1",
"react-virtuoso": "^4.7.0",
"rxjs": "^7.8.1",
"sonner": "^1.4.41",
"swr": "^2.2.5",
"uuid": "^9.0.1",
"zod": "^3.22.4"

View File

@@ -15,6 +15,7 @@ export * from './ui/lottie/collections-icon';
export * from './ui/lottie/delete-icon';
export * from './ui/menu';
export * from './ui/modal';
export * from './ui/notification';
export * from './ui/popover';
export * from './ui/scrollbar';
export * from './ui/skeleton';

View File

@@ -0,0 +1,2 @@
export * from './notification-center';
export type { Notification } from './types';

View File

@@ -0,0 +1,81 @@
import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { type HTMLAttributes, useCallback } from 'react';
import { Button, IconButton } from '../button';
import * as styles from './styles.css';
import type { Notification } from './types';
import {
getActionTextColor,
getCardBorderColor,
getCardColor,
getCardForegroundColor,
} from './utils';
export interface NotificationCardProps extends HTMLAttributes<HTMLDivElement> {
notification: Notification;
onDismiss?: () => void;
}
export const NotificationCard = ({
notification,
onDismiss,
}: NotificationCardProps) => {
const {
theme = 'info',
style = 'normal',
icon = <InformationFillDuotoneIcon />,
action,
title,
footer,
} = notification;
const onActionClicked = useCallback(() => {
action?.onClick()?.catch(console.error);
if (action?.autoClose !== false) {
onDismiss?.();
}
}, [action, onDismiss]);
return (
<div
style={assignInlineVars({
[styles.cardColor]: getCardColor(style, theme),
[styles.cardBorderColor]: getCardBorderColor(style),
[styles.cardForeground]: getCardForegroundColor(style),
[styles.actionTextColor]: getActionTextColor(style, theme),
})}
data-with-icon={icon ? '' : undefined}
className={styles.card}
>
<header className={styles.header}>
{icon ? (
<div className={clsx(styles.icon, styles.headAlignWrapper)}>
{icon}
</div>
) : null}
<div className={styles.title}>{title}</div>
{action ? (
<div className={clsx(styles.headAlignWrapper, styles.action)}>
<Button
className={styles.actionButton}
onClick={onActionClicked}
{...action.buttonProps}
>
{action.label}
</Button>
</div>
) : null}
<div className={styles.headAlignWrapper}>
<IconButton onClick={onDismiss}>
<CloseIcon className={styles.closeIcon} width={16} height={16} />
</IconButton>
</div>
</header>
<main className={styles.main}>{notification.message}</main>
<footer>{footer}</footer>
</div>
);
};

View File

@@ -0,0 +1,243 @@
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons';
import type { StoryFn } from '@storybook/react';
import { cssVar } from '@toeverything/theme';
import { type HTMLAttributes, useState } from 'react';
import { Button } from '../button';
import { Modal } from '../modal';
import { NotificationCenter, notify } from './notification-center';
import type {
NotificationCustomRendererProps,
NotificationStyle,
NotificationTheme,
} from './types';
import {
getCardBorderColor,
getCardColor,
getCardForegroundColor,
} from './utils';
export default {
title: 'UI/NotificationCenter',
};
const themes: NotificationTheme[] = ['info', 'success', 'warning', 'error'];
const styles: NotificationStyle[] = ['normal', 'information', 'alert'];
const Root = ({ children, ...attrs }: HTMLAttributes<HTMLDivElement>) => (
<>
<NotificationCenter />
<div {...attrs}>{children}</div>
</>
);
const Label = ({ children, ...attrs }: HTMLAttributes<HTMLSpanElement>) => (
<span style={{ fontWeight: 400, opacity: 0.5 }} {...attrs}>
{children}:&nbsp;
</span>
);
export const ThemeAndStyle: StoryFn = () => {
return (
<Root>
{styles.map(style => {
return (
<div key={style} style={{ marginBottom: 20 }}>
<h3 style={{ marginBottom: 8 }}>
<Label>style</Label>
{style}
</h3>
<div style={{ display: 'flex', gap: 4 }}>
{themes.map(theme => {
return (
<Button
style={{
backgroundColor: getCardColor(style, theme),
borderColor: getCardBorderColor(style),
color: getCardForegroundColor(style),
}}
key={theme}
onClick={() =>
notify({
title: `${theme} title`,
message: (
<span>
Test with <Label>style</Label>
<code>{style}</code>
&nbsp;and&nbsp;
<Label>theme</Label>
<code>{theme}</code>
</span>
),
style,
theme,
})
}
>
<Label>theme</Label> {theme}
</Button>
);
})}
</div>
</div>
);
})}
</Root>
);
};
export const CustomIcon: StoryFn = () => {
const icons = [
{ label: 'No icon', icon: null },
{
label: 'SingleSelectIcon',
icon: <SingleSelectSelectSolidIcon />,
},
{
label: 'Icon Color',
icon: <SingleSelectSelectSolidIcon color={cssVar('successColor')} />,
},
];
return (
<Root style={{ display: 'flex', gap: 4 }}>
{icons.map(({ label, icon }) => (
<Button
key={label}
onClick={() =>
notify({
title: label,
message: 'test with custom icon ' + label,
icon,
})
}
>
{label}
</Button>
))}
</Root>
);
};
export const CustomRenderer: StoryFn = () => {
const CustomRender = ({ onDismiss }: NotificationCustomRendererProps) => {
return (
<div
style={{
border: '1px solid ' + cssVar('borderColor'),
padding: 16,
borderRadius: 4,
background: cssVar('white'),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
CustomRenderer
<Button onClick={onDismiss}>Close</Button>
</div>
);
};
return (
<Root>
<Button onClick={() => notify.custom(CustomRender)}>
Open CustomRenderer
</Button>
</Root>
);
};
export const WithAction: StoryFn = () => {
return (
<Root>
{styles.map(style => {
return (
<div key={style} style={{ marginBottom: 20 }}>
<h3 style={{ marginBottom: 8 }}>
<Label>style</Label>
{style}
</h3>
<div style={{ display: 'flex', gap: 4 }}>
{themes.map(theme => {
return (
<Button
style={{
backgroundColor: getCardColor(style, theme),
borderColor: getCardBorderColor(style),
color: getCardForegroundColor(style),
}}
key={theme}
onClick={() =>
notify({
title: `${theme} title`,
message: (
<span>
Test with <Label>style</Label>
<code>{style}</code>
&nbsp;and&nbsp;
<Label>theme</Label>
<code>{theme}</code>
</span>
),
style,
theme,
action: {
label: 'UNDO',
onClick: () => console.log('undo'),
},
})
}
>
<Label>theme</Label> {theme}
</Button>
);
})}
</div>
</div>
);
})}
<h3 style={{ marginBottom: 8 }}>Disable auto close</h3>
<Button
onClick={() => {
notify(
{
title: 'Disable auto close',
message: 'Test with disable auto close',
action: {
label: 'UNDO',
onClick: () => console.log('undo'),
autoClose: false,
},
},
{ duration: 22222222 }
);
}}
>
Do not close after action clicked
</Button>
</Root>
);
};
export const ZIndexWithModal: StoryFn = () => {
const [open, setOpen] = useState(false);
return (
<Root>
<Button onClick={() => setOpen(true)}>Open modal</Button>
<Modal open={open} onOpenChange={setOpen}>
<Button
onClick={() =>
notify(
{ title: 'Notify', message: 'Test with modal' },
{ duration: 2000000 }
)
}
>
Notify
</Button>
</Modal>
</Root>
);
};

View File

@@ -0,0 +1,66 @@
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { type CSSProperties, type FC, useMemo } from 'react';
import { type ExternalToast, toast, Toaster } from 'sonner';
import { NotificationCard } from './notification-card';
import type {
Notification,
NotificationCenterProps,
NotificationCustomRendererProps,
} from './types';
export function NotificationCenter({ width = 380 }: NotificationCenterProps) {
const style = useMemo(() => {
return {
...assignInlineVars({
// override css vars inside sonner
'--width': `${width}px`,
}),
// radix-ui will lock pointer-events when dialog is open
pointerEvents: 'auto',
} satisfies CSSProperties;
}, [width]);
const toastOptions = useMemo(
() => ({
style: {
width: '100%',
},
}),
[]
);
return (
<Toaster
className="affine-notification-center"
style={style}
toastOptions={toastOptions}
/>
);
}
/**
*
* @returns {string} toastId
*/
export function notify(notification: Notification, options?: ExternalToast) {
return toast.custom(id => {
return (
<NotificationCard
notification={notification}
onDismiss={() => toast.dismiss(id)}
/>
);
}, options);
}
notify.custom = (
Component: FC<NotificationCustomRendererProps>,
options?: ExternalToast
) => {
return toast.custom(id => {
return <Component onDismiss={() => toast.dismiss(id)} />;
}, options);
};
notify.dismiss = toast.dismiss;

View File

@@ -0,0 +1,85 @@
import { cssVar } from '@toeverything/theme';
import { createVar, globalStyle, style } from '@vanilla-extract/css';
export const cardColor = createVar();
export const cardForeground = createVar();
export const cardBorderColor = createVar();
export const actionTextColor = createVar();
export const card = style({
borderRadius: 8,
borderWidth: 1,
borderStyle: 'solid',
padding: 16,
boxShadow: cssVar('shadow1'),
backgroundColor: cardColor,
borderColor: cardBorderColor,
color: cardForeground,
});
export const header = style({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
});
export const headAlignWrapper = style({
height: 24,
display: 'flex',
alignItems: 'center',
});
export const icon = style({
width: 24,
display: 'flex',
placeItems: 'center',
marginRight: 10,
});
globalStyle(`${icon} svg`, {
width: '100%',
height: '100%',
});
export const title = style({
width: 0,
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 400,
lineHeight: '24px',
fontSize: 15,
marginRight: 10,
});
export const action = style({
marginRight: 16,
});
export const actionButton = style({
color: actionTextColor,
position: 'relative',
background: 'transparent',
border: 'none',
'::before': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
backgroundColor: cssVar('black'),
opacity: 0.04,
},
':hover': {
boxShadow: 'none !important',
},
});
export const closeIcon = style({
color: `${cardForeground} !important`,
});
export const main = style({
marginTop: 5,
fontSize: 14,
lineHeight: '22px',
selectors: {
'[data-with-icon] &': {
paddingLeft: 34,
},
},
});

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from 'react';
import type { ButtonProps } from '../button';
export type NotificationStyle = 'normal' | 'information' | 'alert';
export type NotificationTheme = 'info' | 'success' | 'warning' | 'error';
export interface Notification {
style?: NotificationStyle;
theme?: NotificationTheme;
borderColor?: string;
background?: string;
foreground?: string;
action?: {
label: string;
onClick: (() => void) | (() => Promise<void>);
buttonProps?: ButtonProps;
/**
* @default true
*/
autoClose?: boolean;
};
// custom slots
title?: ReactNode;
message?: ReactNode;
icon?: ReactNode;
footer?: ReactNode;
}
export interface NotificationCenterProps {
width?: number;
}
export interface NotificationCustomRendererProps {
onDismiss?: () => void;
}

View File

@@ -0,0 +1,55 @@
import { cssVar } from '@toeverything/theme';
import type { NotificationStyle, NotificationTheme } from './types';
export const getCardColor = (
style: NotificationStyle,
theme: NotificationTheme
) => {
if (style === 'information') {
const map: Record<NotificationTheme, string> = {
error: cssVar('backgroundErrorColor'),
info: cssVar('backgroundProcessingColor'),
success: cssVar('backgroundSuccessColor'),
warning: cssVar('backgroundWarningColor'),
};
return map[theme];
}
if (style === 'alert') {
const map: Record<NotificationTheme, string> = {
error: cssVar('errorColor'),
info: cssVar('processingColor'),
success: cssVar('successColor'),
warning: cssVar('warningColor'),
};
return map[theme];
}
return cssVar('white');
};
export const getActionTextColor = (
style: NotificationStyle,
theme: NotificationTheme
) => {
if (style === 'information') {
const map: Record<NotificationTheme, string> = {
error: cssVar('errorColor'),
info: cssVar('processingColor'),
success: cssVar('successColor'),
warning: cssVar('warningColor'),
};
return map[theme];
}
return getCardForegroundColor(style);
};
export const getCardBorderColor = (style: NotificationStyle) => {
return style === 'normal' ? cssVar('borderColor') : cssVar('black10');
};
export const getCardForegroundColor = (style: NotificationStyle) => {
return style === 'alert' ? cssVar('pureWhite') : cssVar('textPrimaryColor');
};