refactor(component): migrate design components (#5000)

```[tasklist]
### Tasks
- [x] Migrate components from [design](https://github.com/toeverything/design)
- [x] Replace all imports from `@toeverything/components`
- [x] Clean up `@toeverything/components` dependencies
- [x] Storybook
```

### Influence

Here are all the components that are influenced by `@toeverything/components`

- `@affine/component`
    - App update `Button` `Tooltip`
    - App sidebar header `IconButton`, `Tooltip`
    - Back `Button`
    - Auth
      - Change email page save `Button`
      - Change password page all `Button`s (Save, Later, Open)
      - Confirm change email `Button`
      - Set password page `Button`
      - Sign in success page `Button`
      - Sign up page `Button`
      - Auth `Modal`
    - Workspace card `Avatar`, `Divider`, `Tooltip`, `IconButton`
    - Share
      - Disable shared public link `Modal`
    - Import page `IconButton`, `Tooltip`
    - Accept invite page `Avatar`, `Button`
    - Invite member `Modal`
    - 404 Page `Avatar`, `Button`, `IconButton`, `Tooltip`
    - Notification center `IconButton`
    - Page list
      - operation cell `IconButton`, `Menu`, `ConfirmModal`, `Tooltip`
      - tags more `Menu`
      - favorite `IconButton`, `Tooltip`
      - new page dropdown `Menu`
      - filter `Menu`, `Button`, `IconButton`
    - Page operation `Menu`
      - export `MenuItem`
      - move to trash `MenuItem`, `ConfirmModal`
    - Workspace header filter `Menu`, `Button`
    - Collection bar `Button`, `Tooltip` (*⚠️ seems not used*)
    - Collection operation `Menu`, `MenuItem`
      - Create collection `Modal`, `Button`
      - Edit collection `Modal`, `Button`
      - Page mode filter `Menu`
      - Page mode `Button`, `Menu`
    - Setting modal
      - storage usage progress `Button`, `Tooltip`
    - On boarding tour `Modal`
- `@affine/core`
  - Bookmark `Menu`
  - Affine error boundary `Button`
  - After sign in send email `Button`
  - After sign up send email `Button`
  - Send email `Button`
  - Sign in `Button`
  - Subscription redirect `Loading`, `Button`
  - Setting `Modal`
    - User plan button `Tooltip`
    - Members `Avatar`, `Button`, `IconButton`, `Loading`, `Tooltip`, `Menu`
    - Profile `Button`, `Avatar`
    - Workspace
      - publish panel `Button`, `Tooltip`
      - export panel `Button`
      - storage panel `Button`, `Tooltip`
      - delete `ConfirmModal`
    - Language `Menu`
    - Account setting `Avatar`, `Button`
    - Date format setting `Menu`
    - Billing `Button`, `IconButton`, `Loading`
    - Payment plans `Button`, `ConfirmModal`, `Modal`, `Tooltip`
  - Create workspace `Modal`, `ConfirmModal`, `Button`
  - Payment disabled `ConfirmModal`
  - Share/Export `Menu`, `Button`, `Divider`
  - Sign out `ConfirmModal`
  - Temp disable affine cloud `Modal`
  - Page detail operation `Menu`
  - Blocksuite mode switch `Tooltip`
  - Login card `Avatar`
  - Help island `Tooltip`
- `plugin`
  - copilot
  - hello world
  - image preview
  - outline
This commit is contained in:
Cats Juice
2023-12-04 08:32:12 +00:00
parent 33c53217c3
commit 0abadbe7bb
34 changed files with 2080 additions and 3 deletions

View File

@@ -29,11 +29,14 @@
"@popperjs/core": "^2.11.8",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@toeverything/hooks": "workspace:*",
"@toeverything/infra": "workspace:*",
"@toeverything/theme": "^0.7.24",

View File

@@ -1,13 +1,20 @@
// TODO: Check `input` , `loading`, not migrated from `design`
export * from './styles';
export * from './ui/avatar';
export * from './ui/button';
export * from './ui/checkbox';
export * from './ui/divider';
export * from './ui/empty';
export * from './ui/input';
export * from './ui/layout';
export * from './ui/lottie/collections-icon';
export * from './ui/lottie/delete-icon';
export * from './ui/menu';
export * from './ui/modal';
export * from './ui/popover';
export * from './ui/scrollbar';
export * from './ui/skeleton';
export * from './ui/switch';
export * from './ui/table';
export * from './ui/toast';
export * from './ui/tooltip';

View File

@@ -0,0 +1,143 @@
import { CloseIcon } from '@blocksuite/icons';
import {
type AvatarFallbackProps,
type AvatarImageProps,
type AvatarProps as RadixAvatarProps,
Fallback as AvatarFallback,
Image as AvatarImage,
Root as AvatarRoot,
} from '@radix-ui/react-avatar';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import type { CSSProperties, HTMLAttributes, MouseEvent } from 'react';
import { forwardRef, type ReactElement, useMemo, useState } from 'react';
import { IconButton } from '../button';
import { Tooltip, type TooltipProps } from '../tooltip';
import { ColorfulFallback } from './colorful-fallback';
import * as style from './style.css';
import { sizeVar } from './style.css';
export type AvatarProps = {
size?: number;
url?: string | null;
name?: string;
className?: string;
style?: CSSProperties;
colorfulFallback?: boolean;
hoverIcon?: ReactElement;
onRemove?: (e: MouseEvent<HTMLButtonElement>) => void;
avatarTooltipOptions?: Omit<TooltipProps, 'children'>;
removeTooltipOptions?: Omit<TooltipProps, 'children'>;
fallbackProps?: AvatarFallbackProps;
imageProps?: Omit<AvatarImageProps, 'src'>;
avatarProps?: RadixAvatarProps;
hoverWrapperProps?: HTMLAttributes<HTMLDivElement>;
removeButtonProps?: HTMLAttributes<HTMLButtonElement>;
} & HTMLAttributes<HTMLSpanElement>;
export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
(
{
size = 20,
style: propsStyles = {},
url,
name,
className,
colorfulFallback = false,
hoverIcon,
fallbackProps: { className: fallbackClassName, ...fallbackProps } = {},
imageProps,
avatarProps,
onRemove,
hoverWrapperProps: {
className: hoverWrapperClassName,
...hoverWrapperProps
} = {},
avatarTooltipOptions,
removeTooltipOptions,
removeButtonProps: {
className: removeButtonClassName,
...removeButtonProps
} = {},
...props
},
ref
) => {
const firstCharOfName = useMemo(() => {
return name?.slice(0, 1) || 'A';
}, [name]);
const [imageDom, setImageDom] = useState<HTMLDivElement | null>(null);
const [removeButtonDom, setRemoveButtonDom] =
useState<HTMLButtonElement | null>(null);
return (
<AvatarRoot className={style.avatarRoot} {...avatarProps} ref={ref}>
<Tooltip
portalOptions={{ container: imageDom }}
{...avatarTooltipOptions}
>
<div
ref={setImageDom}
className={clsx(style.avatarWrapper, className)}
style={{
...assignInlineVars({
[sizeVar]: size ? `${size}px` : '20px',
}),
...propsStyles,
}}
{...props}
>
<AvatarImage
className={style.avatarImage}
src={url || ''}
alt={name}
{...imageProps}
/>
<AvatarFallback
className={clsx(style.avatarFallback, fallbackClassName)}
delayMs={url ? 600 : undefined}
{...fallbackProps}
>
{colorfulFallback ? (
<ColorfulFallback char={firstCharOfName} />
) : (
firstCharOfName
)}
</AvatarFallback>
{hoverIcon ? (
<div
className={clsx(style.hoverWrapper, hoverWrapperClassName)}
{...hoverWrapperProps}
>
{hoverIcon}
</div>
) : null}
</div>
</Tooltip>
{onRemove ? (
<Tooltip
portalOptions={{ container: removeButtonDom }}
{...removeTooltipOptions}
>
<IconButton
size="extraSmall"
type="default"
className={clsx(style.removeButton, removeButtonClassName)}
onClick={onRemove}
ref={setRemoveButtonDom}
{...removeButtonProps}
>
<CloseIcon />
</IconButton>
</Tooltip>
) : null}
</AvatarRoot>
);
}
);
Avatar.displayName = 'Avatar';

View File

@@ -0,0 +1,67 @@
import clsx from 'clsx';
import { useMemo, useRef, useState } from 'react';
import {
DefaultAvatarBottomItemStyle,
DefaultAvatarBottomItemWithAnimationStyle,
DefaultAvatarContainerStyle,
DefaultAvatarMiddleItemStyle,
DefaultAvatarMiddleItemWithAnimationStyle,
DefaultAvatarTopItemStyle,
} from './style.css';
const colorsSchema = [
['#FF0000', '#FF00E5', '#FFAE73'],
['#FF5C00', '#FFC700', '#FFE073'],
['#FFDA16', '#FFFBA6', '#FFBE73'],
['#8CD317', '#FCFF5C', '#67CAE9'],
['#28E19F', '#89FFC6', '#39A880'],
['#35B7E0', '#77FFCE', '#5076FF'],
['#3D39FF', '#77BEFF', '#3502FF'],
['#BD08EB', '#755FFF', '#6967E4'],
];
export const ColorfulFallback = ({ char }: { char: string }) => {
const colors = useMemo(() => {
const index = char.toUpperCase().charCodeAt(0);
return colorsSchema[index % colorsSchema.length];
}, [char]);
const timer = useRef<ReturnType<typeof setTimeout>>();
const [topColor, middleColor, bottomColor] = colors;
const [isHover, setIsHover] = useState(false);
return (
<div
className={DefaultAvatarContainerStyle}
onMouseEnter={() => {
timer.current = setTimeout(() => {
setIsHover(true);
}, 300);
}}
onMouseLeave={() => {
clearTimeout(timer.current);
setIsHover(false);
}}
>
<div
className={DefaultAvatarTopItemStyle}
style={{ background: bottomColor }}
></div>
<div
className={clsx(DefaultAvatarMiddleItemStyle, {
[DefaultAvatarMiddleItemWithAnimationStyle]: isHover,
})}
style={{ background: middleColor }}
></div>
<div
className={clsx(DefaultAvatarBottomItemStyle, {
[DefaultAvatarBottomItemWithAnimationStyle]: isHover,
})}
style={{ background: topColor }}
></div>
</div>
);
};
export default ColorfulFallback;

View File

@@ -0,0 +1 @@
export * from './avatar';

View File

@@ -0,0 +1,210 @@
import { createVar, globalStyle, keyframes, style } from '@vanilla-extract/css';
export const sizeVar = createVar('sizeVar');
const bottomAnimation = keyframes({
'0%': {
top: '-44%',
left: '-11%',
transform: 'matrix(-0.29, -0.96, 0.94, -0.35, 0, 0)',
},
'16%': {
left: '-18%',
top: '-51%',
transform: 'matrix(-0.73, -0.69, 0.64, -0.77, 0, 0)',
},
'32%': {
left: '-7%',
top: '-40%',
transform: 'matrix(-0.97, -0.23, 0.16, -0.99, 0, 0)',
},
'48%': {
left: '-15%',
top: '-39%',
transform: 'matrix(-0.88, 0.48, -0.6, -0.8, 0, 0)',
},
'64%': {
left: '-7%',
top: '-40%',
transform: 'matrix(-0.97, -0.23, 0.16, -0.99, 0, 0)',
},
'80%': {
left: '-18%',
top: '-51%',
transform: 'matrix(-0.73, -0.69, 0.64, -0.77, 0, 0)',
},
'100%': {
top: '-44%',
left: '-11%',
transform: 'matrix(-0.29, -0.96, 0.94, -0.35, 0, 0)',
},
});
const middleAnimation = keyframes({
'0%': {
left: '-30px',
top: '-30px',
transform: 'matrix(-0.48, -0.88, 0.8, -0.6, 0, 0)',
},
'16%': {
left: '-37px',
top: '-37px',
transform: 'matrix(-0.86, -0.52, 0.39, -0.92, 0, 0)',
},
'32%': {
left: '-20px',
top: '-10px',
transform: 'matrix(-1, -0.02, -0.12, -0.99, 0, 0)',
},
'48%': {
left: '-27px',
top: '-2px',
transform: 'matrix(-0.88, 0.48, -0.6, -0.8, 0, 0)',
},
'64%': {
left: '-20px',
top: '-10px',
transform: 'matrix(-1, -0.02, -0.12, -0.99, 0, 0)',
},
'80%': {
left: '-37px',
top: '-37px',
transform: 'matrix(-0.86, -0.52, 0.39, -0.92, 0, 0)',
},
'100%': {
left: '-30px',
top: '-30px',
transform: 'matrix(-0.48, -0.88, 0.8, -0.6, 0, 0)',
},
});
export const DefaultAvatarContainerStyle = style({
width: '100%',
height: '100%',
position: 'relative',
borderRadius: '50%',
overflow: 'hidden',
});
export const DefaultAvatarMiddleItemStyle = style({
width: '83%',
height: '81%',
position: 'absolute',
left: '-30%',
top: '-30%',
transform: 'matrix(-0.48, -0.88, 0.8, -0.6, 0, 0)',
opacity: '0.8',
filter: 'blur(12px)',
transformOrigin: 'center center',
animation: `${middleAnimation} 3s ease-in-out forwards infinite`,
animationPlayState: 'paused',
});
export const DefaultAvatarMiddleItemWithAnimationStyle = style({
animationPlayState: 'running',
});
export const DefaultAvatarBottomItemStyle = style({
width: '98%',
height: '97%',
position: 'absolute',
top: '-44%',
left: '-11%',
transform: 'matrix(-0.29, -0.96, 0.94, -0.35, 0, 0)',
opacity: '0.8',
filter: 'blur(12px)',
transformOrigin: 'center center',
willChange: 'left, top, transform',
animation: `${bottomAnimation} 3s ease-in-out forwards infinite`,
animationPlayState: 'paused',
});
export const DefaultAvatarBottomItemWithAnimationStyle = style({
animationPlayState: 'running',
});
export const DefaultAvatarTopItemStyle = style({
width: '104%',
height: '94%',
position: 'absolute',
right: '-30%',
top: '-30%',
opacity: '0.8',
filter: 'blur(12px)',
transform: 'matrix(-0.28, -0.96, 0.93, -0.37, 0, 0)',
transformOrigin: 'center center',
});
export const avatarRoot = style({
position: 'relative',
display: 'inline-flex',
flexShrink: 0,
});
export const avatarWrapper = style({
vars: {
[sizeVar]: 'unset',
},
width: sizeVar,
height: sizeVar,
fontSize: `calc(${sizeVar} / 2)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
verticalAlign: 'middle',
userSelect: 'none',
position: 'relative',
});
export const avatarImage = style({
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: '50%',
});
export const avatarFallback = style({
width: '100%',
height: '100%',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--affine-primary-color)',
color: 'var(--affine-white)',
lineHeight: '1',
fontWeight: '500',
});
export const hoverWrapper = style({
width: '100%',
height: '100%',
borderRadius: '50%',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(60, 61, 63, 0.5)',
zIndex: '1',
color: 'var(--affine-white)',
opacity: 0,
transition: 'opacity .15s',
cursor: 'pointer',
selectors: {
'&:hover': {
opacity: 1,
},
},
});
export const removeButton = style({
position: 'absolute',
right: '-8px',
top: '-2px',
visibility: 'hidden',
zIndex: '1',
selectors: {
'&:hover': {
background: '#f6f6f6',
},
},
});
globalStyle(`${avatarRoot}:hover ${removeButton}`, {
visibility: 'visible',
});
globalStyle(`${avatarRoot} ${removeButton}:hover`, {
background: '#f6f6f6',
});

View File

@@ -0,0 +1,373 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const button = style({
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
userSelect: 'none',
touchAction: 'manipulation',
outline: '0',
border: '1px solid',
padding: '0 18px',
borderRadius: '8px',
fontSize: 'var(--affine-font-xs)',
fontWeight: 500,
transition: 'all .3s',
['WebkitAppRegion' as string]: 'no-drag',
cursor: 'pointer',
// changeable
height: '28px',
background: 'var(--affine-white)',
borderColor: 'var(--affine-border-color)',
color: 'var(--affine-text-primary-color)',
selectors: {
'&.text-bold': {
fontWeight: 600,
},
'&:not(.without-hover):hover': {
background: 'var(--affine-hover-color)',
},
'&.disabled': {
opacity: '.4',
cursor: 'default',
color: 'var(--affine-disable-color)',
pointerEvents: 'none',
},
'&.loading': {
cursor: 'default',
color: 'var(--affine-disable-color)',
pointerEvents: 'none',
},
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
{
background: 'inherit',
},
'&.block': { display: 'flex', width: '100%' },
'&.circle': {
borderRadius: '50%',
},
'&.round': {
borderRadius: '14px',
},
// size
'&.large': {
height: '32px',
fontSize: 'var(--affine-font-base)',
fontWeight: 600,
},
'&.round.large': {
borderRadius: '16px',
},
'&.extraLarge': {
height: '40px',
fontSize: 'var(--affine-font-base)',
fontWeight: 700,
},
'&.extraLarge.primary': {
boxShadow: 'var(--affine-large-button-effect) !important',
},
'&.round.extraLarge': {
borderRadius: '20px',
},
// type
'&.plain': {
color: 'var(--affine-text-primary-color)',
borderColor: 'transparent',
background: 'transparent',
},
'&.primary': {
color: 'var(--affine-pure-white)',
background: 'var(--affine-primary-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: 'var(--affine-button-inner-shadow)',
},
'&.primary:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-primary-color)',
},
'&.primary.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.primary.disabled:not(.without-hover):hover': {
background: 'var(--affine-primary-color)',
},
'&.error': {
color: 'var(--affine-pure-white)',
background: 'var(--affine-error-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: 'var(--affine-button-inner-shadow)',
},
'&.error:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-error-color)',
},
'&.error.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.error.disabled:not(.without-hover):hover': {
background: 'var(--affine-error-color)',
},
'&.warning': {
color: 'var(--affine-pure-white)',
background: 'var(--affine-warning-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: 'var(--affine-button-inner-shadow)',
},
'&.warning:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-warning-color)',
},
'&.warning.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.warning.disabled:not(.without-hover):hover': {
background: 'var(--affine-warning-color)',
},
'&.success': {
color: 'var(--affine-pure-white)',
background: 'var(--affine-success-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: 'var(--affine-button-inner-shadow)',
},
'&.success:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-success-color)',
},
'&.success.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.success.disabled:not(.without-hover):hover': {
background: 'var(--affine-success-color)',
},
'&.processing': {
color: 'var(--affine-pure-white)',
background: 'var(--affine-processing-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: 'var(--affine-button-inner-shadow)',
},
'&.processing:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-processing-color)',
},
'&.processing.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.processing.disabled:not(.without-hover):hover': {
background: 'var(--affine-processing-color)',
},
},
});
globalStyle(`${button} > span`, {
// flex: 1,
lineHeight: 1,
padding: '0 4px',
});
export const buttonIcon = style({
flexShrink: 0,
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
color: 'var(--affine-icon-color)',
fontSize: '16px',
width: '16px',
height: '16px',
selectors: {
'&.start': {
marginRight: '4px',
},
'&.end': {
marginLeft: '4px',
},
'&.large': {
fontSize: '20px',
width: '20px',
height: '20px',
},
'&.extraLarge': {
fontSize: '20px',
width: '20px',
height: '20px',
},
'&.color-white': {
color: 'var(--affine-pure-white)',
},
},
});
export const iconButton = style({
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
userSelect: 'none',
touchAction: 'manipulation',
outline: '0',
border: '1px solid',
borderRadius: '4px',
transition: 'all .3s',
['WebkitAppRegion' as string]: 'no-drag',
cursor: 'pointer',
background: 'var(--affine-white)',
// changeable
width: '24px',
height: '24px',
fontSize: '20px',
color: 'var(--affine-text-primary-color)',
borderColor: 'var(--affine-border-color)',
selectors: {
'&.without-padding': {
margin: '-2px',
},
'&.active': {
color: 'var(--affine-primary-color)',
},
'&:not(.without-hover):hover': {
background: 'var(--affine-hover-color)',
},
'&.disabled': {
opacity: '.4',
cursor: 'default',
color: 'var(--affine-disable-color)',
pointerEvents: 'none',
},
'&.loading': {
cursor: 'default',
color: 'var(--affine-disable-color)',
pointerEvents: 'none',
},
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
{
background: 'inherit',
},
// size
'&.large': {
width: '32px',
height: '32px',
fontSize: '24px',
},
'&.large.without-padding': {
margin: '-4px',
},
'&.small': { width: '20px', height: '20px', fontSize: '16px' },
'&.extra-small': { width: '16px', height: '16px', fontSize: '12px' },
// type
'&.plain': {
color: 'var(--affine-icon-color)',
borderColor: 'transparent',
background: 'transparent',
},
'&.plain.active': {
color: 'var(--affine-primary-color)',
},
'&.primary': {
color: 'var(--affine-white)',
background: 'var(--affine-primary-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
},
'&.primary:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-primary-color)',
},
'&.primary.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.primary.disabled:not(.without-hover):hover': {
background: 'var(--affine-primary-color)',
},
'&.error': {
color: 'var(--affine-white)',
background: 'var(--affine-error-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
},
'&.error:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-error-color)',
},
'&.error.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.error.disabled:not(.without-hover):hover': {
background: 'var(--affine-error-color)',
},
'&.warning': {
color: 'var(--affine-white)',
background: 'var(--affine-warning-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
},
'&.warning:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-warning-color)',
},
'&.warning.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.warning.disabled:not(.without-hover):hover': {
background: 'var(--affine-warning-color)',
},
'&.success': {
color: 'var(--affine-white)',
background: 'var(--affine-success-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
},
'&.success:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-success-color)',
},
'&.success.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.success.disabled:not(.without-hover):hover': {
background: 'var(--affine-success-color)',
},
'&.processing': {
color: 'var(--affine-white)',
background: 'var(--affine-processing-color)',
borderColor: 'var(--affine-black-10)',
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
},
'&.processing:not(.without-hover):hover': {
background:
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-processing-color)',
},
'&.processing.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.processing.disabled:not(.without-hover):hover': {
background: 'var(--affine-processing-color)',
},
},
});

View File

@@ -0,0 +1,175 @@
import clsx from 'clsx';
import {
type FC,
forwardRef,
type HTMLAttributes,
type PropsWithChildren,
type ReactElement,
useMemo,
} from 'react';
import { Loading } from '../loading';
import { button, buttonIcon } from './button.css';
export type ButtonType =
| 'default'
| 'primary'
| 'plain'
| 'error'
| 'warning'
| 'success'
| 'processing';
export type ButtonSize = 'default' | 'large' | 'extraLarge';
type BaseButtonProps = {
type?: ButtonType;
disabled?: boolean;
icon?: ReactElement;
iconPosition?: 'start' | 'end';
shape?: 'default' | 'round' | 'circle';
block?: boolean;
size?: ButtonSize;
loading?: boolean;
withoutHoverStyle?: boolean;
};
export type ButtonProps = PropsWithChildren<BaseButtonProps> &
Omit<HTMLAttributes<HTMLButtonElement>, 'type'> & {
componentProps?: {
startIcon?: Omit<IconButtonProps, 'icon' | 'iconPosition'>;
endIcon?: Omit<IconButtonProps, 'icon' | 'iconPosition'>;
};
};
type IconButtonProps = PropsWithChildren<BaseButtonProps> &
Omit<HTMLAttributes<HTMLDivElement>, 'type'>;
const defaultProps = {
type: 'default',
disabled: false,
shape: 'default',
size: 'default',
iconPosition: 'start',
loading: false,
withoutHoverStyle: false,
} as const;
const ButtonIcon: FC<IconButtonProps> = props => {
const {
size,
icon,
iconPosition = 'start',
children,
type,
loading,
withoutHoverStyle,
...otherProps
} = {
...defaultProps,
...props,
};
const onlyIcon = icon && !children;
return (
<div
{...otherProps}
className={clsx(buttonIcon, {
'color-white': type !== 'default' && type !== 'plain',
large: size === 'large',
extraLarge: size === 'extraLarge',
end: iconPosition === 'end' && !onlyIcon,
start: iconPosition === 'start' && !onlyIcon,
loading,
})}
data-without-hover={withoutHoverStyle}
>
{icon}
</div>
);
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
children,
type,
disabled,
shape,
size,
icon: propsIcon,
iconPosition,
block,
loading,
withoutHoverStyle,
className,
...otherProps
} = {
...defaultProps,
...props,
} satisfies ButtonProps;
const icon = useMemo(() => {
if (loading) {
return <Loading />;
}
return propsIcon;
}, [propsIcon, loading]);
const baseIconButtonProps = useMemo(() => {
return {
size,
iconPosition,
icon,
type,
disabled,
loading,
} as const;
}, [disabled, icon, iconPosition, loading, size, type]);
return (
<button
{...otherProps}
ref={ref}
className={clsx(
button,
{
primary: type === 'primary',
plain: type === 'plain',
error: type === 'error',
warning: type === 'warning',
success: type === 'success',
processing: type === 'processing',
large: size === 'large',
extraLarge: size === 'extraLarge',
disabled,
circle: shape === 'circle',
round: shape === 'round',
block,
loading,
'without-hover': withoutHoverStyle,
},
className
)}
disabled={disabled}
data-disabled={disabled}
>
{icon && iconPosition === 'start' ? (
<ButtonIcon
{...baseIconButtonProps}
{...props.componentProps?.startIcon}
icon={icon}
iconPosition="start"
/>
) : null}
<span>{children}</span>
{icon && iconPosition === 'end' ? (
<ButtonIcon
{...baseIconButtonProps}
{...props.componentProps?.endIcon}
icon={icon}
iconPosition="end"
/>
) : null}
</button>
);
}
);
Button.displayName = 'Button';
export default Button;

View File

@@ -0,0 +1,88 @@
import clsx from 'clsx';
import type { HTMLAttributes, PropsWithChildren } from 'react';
import { forwardRef, type ReactElement } from 'react';
import { Loading } from '../loading';
import type { ButtonType } from './button';
import { iconButton } from './button.css';
export type IconButtonSize = 'default' | 'large' | 'small' | 'extraSmall';
export type IconButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'type'> &
PropsWithChildren<{
type?: ButtonType;
disabled?: boolean;
size?: IconButtonSize;
loading?: boolean;
withoutPadding?: boolean;
active?: boolean;
withoutHoverStyle?: boolean;
icon?: ReactElement;
}>;
const defaultProps = {
type: 'plain',
disabled: false,
size: 'default',
loading: false,
withoutPadding: false,
active: false,
withoutHoverStyle: false,
} as const;
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(props, ref) => {
const {
type,
size,
withoutPadding,
children,
disabled,
loading,
active,
withoutHoverStyle,
icon: propsIcon,
className,
...otherProps
} = {
...defaultProps,
...props,
};
return (
<button
ref={ref}
className={clsx(
iconButton,
{
'without-padding': withoutPadding,
primary: type === 'primary',
plain: type === 'plain',
error: type === 'error',
warning: type === 'warning',
success: type === 'success',
processing: type === 'processing',
large: size === 'large',
small: size === 'small',
'extra-small': size === 'extraSmall',
disabled,
loading,
active,
'without-hover': withoutHoverStyle,
},
className
)}
disabled={disabled}
data-disabled={disabled}
{...otherProps}
>
{loading ? <Loading /> : children || propsIcon}
</button>
);
}
);
IconButton.displayName = 'IconButton';
export default IconButton;

View File

@@ -1,2 +1,4 @@
export * from './button';
export * from './dropdown-button';
export * from './icon-button';
export * from './radio';

View File

@@ -0,0 +1,52 @@
import clsx from 'clsx';
import type { HTMLAttributes, PropsWithChildren } from 'react';
import { forwardRef } from 'react';
import * as styles from './style.css';
export type DividerOrientation = 'horizontal' | 'vertical';
export type DividerProps = PropsWithChildren &
Omit<HTMLAttributes<HTMLDivElement>, 'type'> & {
orientation?: DividerOrientation;
size?: 'thinner' | 'default';
dividerColor?: string;
};
const defaultProps = {
orientation: 'horizontal',
size: 'default',
};
export const Divider = forwardRef<HTMLDivElement, DividerProps>(
(props, ref) => {
const { orientation, className, size, dividerColor, style, ...otherProps } =
{
...defaultProps,
...props,
};
return (
<div
ref={ref}
className={clsx(
styles.divider,
{
[styles.verticalDivider]: orientation === 'vertical',
[styles.thinner]:
size === 'thinner' && orientation === 'horizontal',
[styles.verticalThinner]:
size === 'thinner' && orientation === 'vertical',
},
className
)}
style={{
backgroundColor: dividerColor ? dividerColor : undefined,
...style,
}}
{...otherProps}
/>
);
}
);
Divider.displayName = 'Divider';
export default Divider;

View File

@@ -0,0 +1 @@
export * from './divider';

View File

@@ -0,0 +1,21 @@
import { style } from '@vanilla-extract/css';
export const divider = style({
height: '1px',
backgroundColor: 'var(--affine-border-color)',
borderRadius: '8px',
margin: '8px 0',
width: '100%',
});
export const thinner = style({
height: '0.5px',
});
export const verticalDivider = style({
width: '1px',
borderRadius: '8px',
height: '100%',
margin: '0 2px',
});
export const verticalThinner = style({
width: '0.5px',
});

View File

@@ -0,0 +1,7 @@
export * from './menu';
export * from './menu.types';
export * from './menu-icon';
export * from './menu-item';
export * from './menu-separator';
export * from './menu-sub';
export * from './menu-trigger';

View File

@@ -0,0 +1,39 @@
import clsx from 'clsx';
import type { PropsWithChildren, ReactNode } from 'react';
import { forwardRef, type HTMLAttributes, useMemo } from 'react';
import { menuItemIcon } from './styles.css';
export interface MenuIconProps
extends PropsWithChildren,
HTMLAttributes<HTMLDivElement> {
icon?: ReactNode;
position?: 'start' | 'end';
}
export const MenuIcon = forwardRef<HTMLDivElement, MenuIconProps>(
({ children, icon, position = 'start', className, ...otherProps }, ref) => {
return (
<div
ref={ref}
className={useMemo(
() =>
clsx(
menuItemIcon,
{
end: position === 'end',
start: position === 'start',
},
className
),
[className, position]
)}
{...otherProps}
>
{icon || children}
</div>
);
}
);
MenuIcon.displayName = 'MenuIcon';

View File

@@ -0,0 +1,33 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import type { MenuItemProps } from './menu.types';
import { useMenuItem } from './use-menu-item';
export const MenuItem = ({
children: propsChildren,
type = 'default',
className: propsClassName,
preFix,
endFix,
checked,
selected,
block,
...otherProps
}: MenuItemProps) => {
const { className, children } = useMenuItem({
children: propsChildren,
className: propsClassName,
type,
preFix,
endFix,
checked,
selected,
block,
});
return (
<DropdownMenu.Item className={className} {...otherProps}>
{children}
</DropdownMenu.Item>
);
};

View File

@@ -0,0 +1,21 @@
import type { MenuSeparatorProps } from '@radix-ui/react-dropdown-menu';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import { useMemo } from 'react';
import * as styles from './styles.css';
export const MenuSeparator = ({
className,
...otherProps
}: MenuSeparatorProps) => {
return (
<DropdownMenu.Separator
className={useMemo(
() => clsx(styles.menuSeparator, className),
[className]
)}
{...otherProps}
/>
);
};

View File

@@ -0,0 +1,72 @@
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import type {
DropdownMenuSubProps,
MenuPortalProps,
MenuSubContentProps,
} from '@radix-ui/react-dropdown-menu';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import type { MenuItemProps } from './menu.types';
import { MenuIcon } from './menu-icon';
import * as styles from './styles.css';
import { useMenuItem } from './use-menu-item';
export interface MenuSubProps {
children: ReactNode;
items: ReactNode;
triggerOptions?: Omit<MenuItemProps, 'onSelect' | 'children'>;
portalOptions?: Omit<MenuPortalProps, 'children'>;
subOptions?: Omit<DropdownMenuSubProps, 'children'>;
subContentOptions?: Omit<MenuSubContentProps, 'children'>;
}
export const MenuSub = ({
children: propsChildren,
items,
portalOptions,
subOptions,
triggerOptions: {
className: propsClassName,
preFix,
endFix,
type,
...otherTriggerOptions
} = {},
subContentOptions: {
className: subContentClassName = '',
...otherSubContentOptions
} = {},
}: MenuSubProps) => {
const { className, children } = useMenuItem({
children: propsChildren,
className: propsClassName,
type,
preFix,
endFix,
});
return (
<DropdownMenu.Sub {...subOptions}>
<DropdownMenu.SubTrigger className={className} {...otherTriggerOptions}>
{children}
<MenuIcon position="end">
<ArrowRightSmallIcon />
</MenuIcon>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal {...portalOptions}>
<DropdownMenu.SubContent
sideOffset={10}
className={useMemo(
() => clsx(styles.menuContent, subContentClassName),
[subContentClassName]
)}
{...otherSubContentOptions}
>
{items}
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
);
};

View File

@@ -0,0 +1,87 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import {
type CSSProperties,
forwardRef,
type HTMLAttributes,
type ReactNode,
} from 'react';
import { MenuIcon } from './menu-icon';
import * as styles from './styles.css';
import { triggerWidthVar } from './styles.css';
export interface MenuTriggerProps
extends PropsWithChildren,
HTMLAttributes<HTMLButtonElement> {
width?: CSSProperties['width'];
disabled?: boolean;
noBorder?: boolean;
status?: 'error' | 'success' | 'warning' | 'default';
size?: 'default' | 'large' | 'extraLarge';
preFix?: ReactNode;
endFix?: ReactNode;
block?: boolean;
}
export const MenuTrigger = forwardRef<HTMLButtonElement, MenuTriggerProps>(
(
{
disabled,
noBorder = false,
className,
status = 'default',
size = 'default',
preFix,
endFix,
block = false,
children,
width,
style = {},
...otherProps
},
ref
) => {
return (
<button
ref={ref}
style={{
...assignInlineVars({
[triggerWidthVar]: width
? typeof width === 'number'
? `${width}px`
: width
: 'auto',
}),
...style,
}}
className={clsx(styles.menuTrigger, className, {
// status
block,
disabled: disabled,
'no-border': noBorder,
// color
error: status === 'error',
success: status === 'success',
warning: status === 'warning',
default: status === 'default',
// size
large: size === 'large',
'extra-large': size === 'extraLarge',
})}
{...otherProps}
>
{preFix}
{children}
{endFix}
<MenuIcon position="end">
<ArrowDownSmallIcon />
</MenuIcon>
</button>
);
}
);
MenuTrigger.displayName = 'MenuTrigger';

View File

@@ -0,0 +1,52 @@
import type {
DropdownMenuProps,
MenuContentProps,
MenuPortalProps,
} from '@radix-ui/react-dropdown-menu';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import * as styles from './styles.css';
export interface MenuProps {
children: ReactNode;
items: ReactNode;
portalOptions?: Omit<MenuPortalProps, 'children'>;
rootOptions?: Omit<DropdownMenuProps, 'children'>;
contentOptions?: Omit<MenuContentProps, 'children'>;
}
export const Menu = ({
children,
items,
portalOptions,
rootOptions,
contentOptions: {
className = '',
style: contentStyle = {},
...otherContentOptions
} = {},
}: MenuProps) => {
return (
<DropdownMenu.Root {...rootOptions}>
<DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger>
<DropdownMenu.Portal {...portalOptions}>
<DropdownMenu.Content
className={useMemo(
() => clsx(styles.menuContent, className),
[className]
)}
sideOffset={5}
align="start"
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherContentOptions}
>
{items}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@@ -0,0 +1,11 @@
import type { MenuItemProps as MenuItemPropsPrimitive } from '@radix-ui/react-dropdown-menu';
export interface MenuItemProps
extends Omit<MenuItemPropsPrimitive, 'asChild' | 'textValue'> {
type?: 'default' | 'warning' | 'danger';
preFix?: React.ReactNode;
endFix?: React.ReactNode;
checked?: boolean;
selected?: boolean;
block?: boolean;
}

View File

@@ -0,0 +1,157 @@
import { createVar, style } from '@vanilla-extract/css';
export const triggerWidthVar = createVar('triggerWidthVar');
export const menuContent = style({
minWidth: '180px',
color: 'var(--affine-text-primary-color)',
borderRadius: '8px',
padding: '8px',
fontSize: 'var(--affine-font-sm)',
fontWeight: '400',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
boxShadow: 'var(--affine-menu-shadow)',
userSelect: 'none',
});
export const menuItem = style({
maxWidth: '296px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 12px',
borderRadius: '4px',
lineHeight: '22px',
border: 'none',
outline: 'none',
cursor: 'pointer',
boxSizing: 'border-box',
selectors: {
'&:not(:last-of-type)': {
marginBottom: '4px',
},
'&.block': { maxWidth: '100%' },
'&[data-disabled]': {
color: 'var(--affine-text-disable-color)',
pointerEvents: 'none',
cursor: 'not-allowed',
},
'&[data-highlighted]': {
backgroundColor: 'var(--affine-hover-color)',
},
'&:hover': {
backgroundColor: 'var(--affine-hover-color)',
},
'&.danger:hover': {
color: 'var(--affine-error-color)',
backgroundColor: 'var(--affine-background-error-color)',
},
'&.warning:hover': {
color: 'var(--affine-warning-color)',
backgroundColor: 'var(--affine-background-warning-color)',
},
'&.selected, &.checked': {
backgroundColor: 'var(--affine-hover-color)',
color: 'var(--affine-primary-color)',
},
},
});
export const menuSpan = style({
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'left',
});
export const menuItemIcon = style({
display: 'flex',
flexShrink: 0,
fontSize: 'var(--affine-font-h-5)',
color: 'var(--affine-icon-color)',
selectors: {
'&.start': { marginRight: '8px' },
'&.end': { marginLeft: '8px' },
'&.selected, &.checked': {
color: 'var(--affine-primary-color)',
},
[`${menuItem}.danger:hover &`]: {
color: 'var(--affine-error-color)',
},
[`${menuItem}.warning:hover &`]: {
color: 'var(--affine-warning-color)',
},
},
});
export const menuSeparator = style({
height: '1px',
backgroundColor: 'var(--affine-border-color)',
marginTop: '12px',
marginBottom: '8px',
});
export const menuTrigger = style({
vars: {
[triggerWidthVar]: 'auto',
},
width: triggerWidthVar,
height: 28,
lineHeight: '22px',
padding: '0 10px',
color: 'var(--affine-text-primary-color)',
border: '1px solid',
backgroundColor: 'var(--affine-white)',
borderRadius: 8,
display: 'inline-flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 'var(--affine-font-xs)',
cursor: 'pointer',
['WebkitAppRegion' as string]: 'no-drag',
borderColor: 'var(--affine-border-color)',
outline: 'none',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
'&.no-border': {
border: 'unset',
},
'&.block': {
display: 'flex',
width: '100%',
},
// size
'&.large': {
height: 32,
},
'&.extra-large': {
height: 40,
fontWeight: 600,
},
// color
'&.disabled': {
cursor: 'default',
color: 'var(--affine-disable-color)',
pointerEvents: 'none',
},
// TODO: wait for design
'&.error': {
// borderColor: 'var(--affine-error-color)',
},
'&.success': {
// borderColor: 'var(--affine-success-color)',
},
'&.warning': {
// borderColor: 'var(--affine-warning-color)',
},
'&.default': {
// borderColor: 'var(--affine-border-color)',
},
},
});

View File

@@ -0,0 +1,73 @@
import { DoneIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useMemo } from 'react';
import { type MenuItemProps } from './menu.types';
import { MenuIcon } from './menu-icon';
import * as styles from './styles.css';
interface useMenuItemProps {
children: MenuItemProps['children'];
type: MenuItemProps['type'];
className: MenuItemProps['className'];
preFix: MenuItemProps['preFix'];
endFix: MenuItemProps['endFix'];
checked?: MenuItemProps['checked'];
selected?: MenuItemProps['selected'];
block?: MenuItemProps['block'];
}
export const useMenuItem = ({
children: propsChildren,
type = 'default',
className: propsClassName,
preFix,
endFix,
checked,
selected,
block,
}: useMenuItemProps) => {
const className = useMemo(
() =>
clsx(
styles.menuItem,
{
danger: type === 'danger',
warning: type === 'warning',
checked,
selected,
block,
},
propsClassName
),
[block, checked, propsClassName, selected, type]
);
const children = useMemo(
() => (
<>
{preFix}
<span className={styles.menuSpan}>{propsChildren}</span>
{endFix}
{checked || selected ? (
<MenuIcon
position="end"
className={clsx({
selected,
checked,
})}
>
<DoneIcon />
</MenuIcon>
) : null}
</>
),
[checked, endFix, preFix, propsChildren, selected]
);
return {
children,
className,
};
};

View File

@@ -0,0 +1,47 @@
import { DialogTrigger } from '@radix-ui/react-dialog';
import clsx from 'clsx';
import type { ButtonProps } from '../button';
import { Button } from '../button';
import { Modal, type ModalProps } from './modal';
import * as styles from './styles.css';
export interface ConfirmModalProps extends ModalProps {
confirmButtonOptions?: ButtonProps;
onConfirm?: () => void;
cancelText?: string;
cancelButtonOptions?: ButtonProps;
}
export const ConfirmModal = ({
children,
confirmButtonOptions,
// FIXME: we need i18n
cancelText = 'Cancel',
cancelButtonOptions,
onConfirm,
width = 480,
...props
}: ConfirmModalProps) => {
return (
<Modal
contentOptions={{ className: styles.confirmModalContainer }}
width={width}
{...props}
>
{children ? (
<div className={styles.confirmModalContent}>{children}</div>
) : null}
<div
className={clsx(styles.modalFooter, {
modalFooterWithChildren: !!children,
})}
>
<DialogTrigger asChild>
<Button {...cancelButtonOptions}>{cancelText}</Button>
</DialogTrigger>
<Button onClick={onConfirm} {...confirmButtonOptions}></Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,2 @@
export * from './confirm-modal';
export * from './modal';

View File

@@ -0,0 +1,111 @@
import { CloseIcon } from '@blocksuite/icons';
import type {
DialogContentProps,
DialogOverlayProps,
DialogPortalProps,
DialogProps,
} from '@radix-ui/react-dialog';
import * as Dialog from '@radix-ui/react-dialog';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { type CSSProperties, forwardRef } from 'react';
import { IconButton, type IconButtonProps } from '../button';
import * as styles from './styles.css';
export interface ModalProps extends DialogProps {
width?: CSSProperties['width'];
height?: CSSProperties['height'];
minHeight?: CSSProperties['minHeight'];
title?: string;
description?: string;
withoutCloseButton?: boolean;
portalOptions?: DialogPortalProps;
contentOptions?: DialogContentProps;
overlayOptions?: DialogOverlayProps;
closeButtonOptions?: IconButtonProps;
}
const getVar = (style: number | string = '', defaultValue = '') => {
return style
? typeof style === 'number'
? `${style}px`
: style
: defaultValue;
};
export const Modal = forwardRef<HTMLDivElement, ModalProps>(
(
{
width,
height,
minHeight = 194,
title,
description,
withoutCloseButton = false,
portalOptions,
contentOptions: {
style: contentStyle,
className: contentClassName,
...otherContentOptions
} = {},
overlayOptions: {
className: overlayClassName,
...otherOverlayOptions
} = {},
closeButtonOptions = {},
children,
...props
},
ref
) => (
<Dialog.Root {...props}>
<Dialog.Portal {...portalOptions}>
<Dialog.Overlay
className={clsx(styles.modalOverlay, overlayClassName)}
{...otherOverlayOptions}
/>
<Dialog.Content
className={clsx(styles.modalContent, contentClassName)}
style={{
...assignInlineVars({
[styles.widthVar]: getVar(width, '50vw'),
[styles.heightVar]: getVar(height, 'unset'),
[styles.minHeightVar]: getVar(minHeight, '26px'),
}),
...contentStyle,
}}
{...otherContentOptions}
ref={ref}
>
{withoutCloseButton ? null : (
<Dialog.Close asChild>
<IconButton
className={styles.closeButton}
aria-label="Close"
type="plain"
{...closeButtonOptions}
>
<CloseIcon />
</IconButton>
</Dialog.Close>
)}
{title ? (
<Dialog.Title className={styles.modalHeader}>{title}</Dialog.Title>
) : null}
{description ? (
<Dialog.Description className={styles.modalDescription}>
{description}
</Dialog.Description>
) : null}
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
);
Modal.displayName = 'Modal';

View File

@@ -0,0 +1,79 @@
import { createVar, style } from '@vanilla-extract/css';
export const widthVar = createVar('widthVar');
export const heightVar = createVar('heightVar');
export const minHeightVar = createVar('minHeightVar');
export const modalOverlay = style({
position: 'fixed',
inset: 0,
backgroundColor: 'var(--affine-background-modal-color)',
zIndex: 'var(--affine-z-index-modal)',
});
export const modalContent = style({
vars: {
[widthVar]: '',
[heightVar]: '',
[minHeightVar]: '',
},
width: widthVar,
height: heightVar,
minHeight: minHeightVar,
boxSizing: 'border-box',
fontSize: 'var(--affine-font-base)',
fontWeight: '400',
lineHeight: '1.6',
padding: '20px 24px',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
boxShadow: 'var(--affine-popover-shadow)',
borderRadius: '12px',
maxHeight: 'calc(100vh - 32px)',
// :focus-visible will set outline
outline: 'none',
position: 'fixed',
zIndex: 'var(--affine-z-index-modal)',
top: ' 50%',
left: '50%',
transform: 'translate(-50%, -50%)',
});
export const closeButton = style({
position: 'absolute',
top: '22px',
right: '20px',
});
export const modalHeader = style({
fontSize: 'var(--affine-font-h-6)',
fontWeight: '600',
lineHeight: '1.45',
marginBottom: '12px',
});
export const modalDescription = style({
// marginBottom: '20px',
});
export const modalFooter = style({
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
paddingTop: '40px',
marginTop: 'auto',
gap: '20px',
selectors: {
'&.modalFooterWithChildren': {
paddingTop: '20px',
},
},
});
export const confirmModalContent = style({
marginTop: '12px',
marginBottom: '20px',
});
export const confirmModalContainer = style({
display: 'flex',
flexDirection: 'column',
});

View File

@@ -0,0 +1 @@
export * from './popover';

View File

@@ -0,0 +1,50 @@
import type {
PopoverContentProps,
PopoverPortalProps,
PopoverProps as PopoverPrimitiveProps,
} from '@radix-ui/react-popover';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import clsx from 'clsx';
import { type ReactNode, useMemo } from 'react';
import * as styles from './styles.css';
export interface PopoverProps extends PopoverPrimitiveProps {
content?: ReactNode;
portalOptions?: PopoverPortalProps;
contentOptions?: PopoverContentProps;
}
export const Popover = ({
content,
children,
portalOptions,
contentOptions: {
className: contentClassName,
style: contentStyle,
...otherContentOptions
} = {},
...props
}: PopoverProps) => {
return (
<PopoverPrimitive.Root {...props}>
<PopoverPrimitive.Trigger asChild>{children}</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal {...portalOptions}>
<PopoverPrimitive.Content
className={useMemo(
() => clsx(styles.popoverContent, contentClassName),
[contentClassName]
)}
sideOffset={5}
align="start"
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherContentOptions}
>
{content}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
};
Popover.displayName = 'Popover';

View File

@@ -0,0 +1,13 @@
import { style } from '@vanilla-extract/css';
export const popoverContent = style({
minWidth: '180px',
color: 'var(--affine-text-primary-color)',
borderRadius: '8px',
padding: '8px',
fontSize: 'var(--affine-font-sm)',
fontWeight: '400',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
boxShadow: 'var(--affine-menu-shadow)',
userSelect: 'none',
});

View File

@@ -0,0 +1,5 @@
import { Tooltip } from './tooltip';
export * from './tooltip';
export default Tooltip;

View File

@@ -0,0 +1,11 @@
import { style } from '@vanilla-extract/css';
export const tooltipContent = style({
backgroundColor: 'var(--affine-tooltip)',
color: 'var(--affine-white)',
padding: '5px 12px',
fontSize: 'var(--affine-font-sm)',
lineHeight: '22px',
borderRadius: '4px',
maxWidth: '280px',
});

View File

@@ -0,0 +1,60 @@
import type {
TooltipContentProps,
TooltipPortalProps,
TooltipProps as RootProps,
} from '@radix-ui/react-tooltip';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import type { ReactElement, ReactNode } from 'react';
import * as styles from './styles.css';
export interface TooltipProps {
// `children` can not be string, number or even undefined
children: ReactElement;
content?: ReactNode;
side?: TooltipContentProps['side'];
align?: TooltipContentProps['align'];
rootOptions?: Omit<RootProps, 'children'>;
portalOptions?: TooltipPortalProps;
options?: Omit<TooltipContentProps, 'side' | 'align'>;
}
export const Tooltip = ({
children,
content,
side = 'top',
align = 'center',
options,
rootOptions,
portalOptions,
}: TooltipProps) => {
if (!content) {
return children;
}
return (
<TooltipPrimitive.Provider>
<TooltipPrimitive.Root delayDuration={500} {...rootOptions}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal {...portalOptions}>
<TooltipPrimitive.Content
className={styles.tooltipContent}
side={side}
align={align}
sideOffset={5}
style={{ zIndex: 'var(--affine-z-index-popover)' }}
{...options}
>
{content}
<TooltipPrimitive.Arrow
height={6}
width={10}
fill="var(--affine-tooltip)"
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
};