mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
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:
2
packages/frontend/component/src/ui/notification/index.ts
Normal file
2
packages/frontend/component/src/ui/notification/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './notification-center';
|
||||
export type { Notification } from './types';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}:
|
||||
</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>
|
||||
and
|
||||
<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>
|
||||
and
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
38
packages/frontend/component/src/ui/notification/types.ts
Normal file
38
packages/frontend/component/src/ui/notification/types.ts
Normal 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;
|
||||
}
|
||||
55
packages/frontend/component/src/ui/notification/utils.ts
Normal file
55
packages/frontend/component/src/ui/notification/utils.ts
Normal 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');
|
||||
};
|
||||
Reference in New Issue
Block a user