diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 378865bd9ee6..dbdac8054214 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -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" diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index af04559e1dc0..6745074e6a10 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -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'; diff --git a/packages/frontend/component/src/ui/notification/index.ts b/packages/frontend/component/src/ui/notification/index.ts new file mode 100644 index 000000000000..25b23f28eedf --- /dev/null +++ b/packages/frontend/component/src/ui/notification/index.ts @@ -0,0 +1,2 @@ +export * from './notification-center'; +export type { Notification } from './types'; diff --git a/packages/frontend/component/src/ui/notification/notification-card.tsx b/packages/frontend/component/src/ui/notification/notification-card.tsx new file mode 100644 index 000000000000..da66d8fde3e2 --- /dev/null +++ b/packages/frontend/component/src/ui/notification/notification-card.tsx @@ -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 { + notification: Notification; + onDismiss?: () => void; +} + +export const NotificationCard = ({ + notification, + onDismiss, +}: NotificationCardProps) => { + const { + theme = 'info', + style = 'normal', + icon = , + action, + title, + footer, + } = notification; + + const onActionClicked = useCallback(() => { + action?.onClick()?.catch(console.error); + if (action?.autoClose !== false) { + onDismiss?.(); + } + }, [action, onDismiss]); + + return ( +
+
+ {icon ? ( +
+ {icon} +
+ ) : null} +
{title}
+ + {action ? ( +
+ +
+ ) : null} +
+ + + +
+
+
{notification.message}
+
{footer}
+
+ ); +}; diff --git a/packages/frontend/component/src/ui/notification/notification-center.stories.tsx b/packages/frontend/component/src/ui/notification/notification-center.stories.tsx new file mode 100644 index 000000000000..61ad4ea07921 --- /dev/null +++ b/packages/frontend/component/src/ui/notification/notification-center.stories.tsx @@ -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) => ( + <> + +
{children}
+ +); +const Label = ({ children, ...attrs }: HTMLAttributes) => ( + + {children}:  + +); + +export const ThemeAndStyle: StoryFn = () => { + return ( + + {styles.map(style => { + return ( +
+

+ + {style} +

+
+ {themes.map(theme => { + return ( + + ); + })} +
+
+ ); + })} +
+ ); +}; + +export const CustomIcon: StoryFn = () => { + const icons = [ + { label: 'No icon', icon: null }, + { + label: 'SingleSelectIcon', + icon: , + }, + { + label: 'Icon Color', + icon: , + }, + ]; + + return ( + + {icons.map(({ label, icon }) => ( + + ))} + + ); +}; + +export const CustomRenderer: StoryFn = () => { + const CustomRender = ({ onDismiss }: NotificationCustomRendererProps) => { + return ( +
+ CustomRenderer + +
+ ); + }; + + return ( + + + + ); +}; + +export const WithAction: StoryFn = () => { + return ( + + {styles.map(style => { + return ( +
+

+ + {style} +

+
+ {themes.map(theme => { + return ( + + ); + })} +
+
+ ); + })} + +

Disable auto close

+ +
+ ); +}; + +export const ZIndexWithModal: StoryFn = () => { + const [open, setOpen] = useState(false); + + return ( + + + + + + + ); +}; diff --git a/packages/frontend/component/src/ui/notification/notification-center.tsx b/packages/frontend/component/src/ui/notification/notification-center.tsx new file mode 100644 index 000000000000..2a49867c08d4 --- /dev/null +++ b/packages/frontend/component/src/ui/notification/notification-center.tsx @@ -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 ( + + ); +} + +/** + * + * @returns {string} toastId + */ +export function notify(notification: Notification, options?: ExternalToast) { + return toast.custom(id => { + return ( + toast.dismiss(id)} + /> + ); + }, options); +} + +notify.custom = ( + Component: FC, + options?: ExternalToast +) => { + return toast.custom(id => { + return toast.dismiss(id)} />; + }, options); +}; + +notify.dismiss = toast.dismiss; diff --git a/packages/frontend/component/src/ui/notification/styles.css.ts b/packages/frontend/component/src/ui/notification/styles.css.ts new file mode 100644 index 000000000000..717457ed6637 --- /dev/null +++ b/packages/frontend/component/src/ui/notification/styles.css.ts @@ -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, + }, + }, +}); diff --git a/packages/frontend/component/src/ui/notification/types.ts b/packages/frontend/component/src/ui/notification/types.ts new file mode 100644 index 000000000000..c95e7d946200 --- /dev/null +++ b/packages/frontend/component/src/ui/notification/types.ts @@ -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); + 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; +} diff --git a/packages/frontend/component/src/ui/notification/utils.ts b/packages/frontend/component/src/ui/notification/utils.ts new file mode 100644 index 000000000000..87886d85a8ed --- /dev/null +++ b/packages/frontend/component/src/ui/notification/utils.ts @@ -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 = { + error: cssVar('backgroundErrorColor'), + info: cssVar('backgroundProcessingColor'), + success: cssVar('backgroundSuccessColor'), + warning: cssVar('backgroundWarningColor'), + }; + return map[theme]; + } + + if (style === 'alert') { + const map: Record = { + 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 = { + 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'); +}; diff --git a/yarn.lock b/yarn.lock index a33aaac22d95..17e5f89fb715 100644 --- a/yarn.lock +++ b/yarn.lock @@ -296,6 +296,7 @@ __metadata: react-transition-state: "npm:^2.1.1" react-virtuoso: "npm:^4.7.0" rxjs: "npm:^7.8.1" + sonner: "npm:^1.4.41" storybook: "npm:^7.6.17" storybook-dark-mode: "npm:^3.0.3" swr: "npm:^2.2.5" @@ -32745,6 +32746,16 @@ __metadata: languageName: node linkType: hard +"sonner@npm:^1.4.41": + version: 1.4.41 + resolution: "sonner@npm:1.4.41" + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 10/f300db8b2603e72fad94d895935c17f13c93b91396333a7f77f028c1d0f605d9929ea0c36ead1df0b4ded66dfaa7a20c58cd9bd31b70f6efcd0fbcce91e4487f + languageName: node + linkType: hard + "sortablejs@npm:^1.15.2": version: 1.15.2 resolution: "sortablejs@npm:1.15.2"