diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 118be62427..717ee513ff 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -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", diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 25218e5bc3..bd9d76dc72 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -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'; diff --git a/packages/frontend/component/src/ui/avatar/avatar.tsx b/packages/frontend/component/src/ui/avatar/avatar.tsx new file mode 100644 index 0000000000..a1efd89949 --- /dev/null +++ b/packages/frontend/component/src/ui/avatar/avatar.tsx @@ -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) => void; + avatarTooltipOptions?: Omit; + removeTooltipOptions?: Omit; + + fallbackProps?: AvatarFallbackProps; + imageProps?: Omit; + avatarProps?: RadixAvatarProps; + hoverWrapperProps?: HTMLAttributes; + removeButtonProps?: HTMLAttributes; +} & HTMLAttributes; + +export const Avatar = forwardRef( + ( + { + 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(null); + const [removeButtonDom, setRemoveButtonDom] = + useState(null); + + return ( + + +
+ + + + {colorfulFallback ? ( + + ) : ( + firstCharOfName + )} + + {hoverIcon ? ( +
+ {hoverIcon} +
+ ) : null} +
+
+ + {onRemove ? ( + + + + + + ) : null} +
+ ); + } +); + +Avatar.displayName = 'Avatar'; diff --git a/packages/frontend/component/src/ui/avatar/colorful-fallback.tsx b/packages/frontend/component/src/ui/avatar/colorful-fallback.tsx new file mode 100644 index 0000000000..0318e60fbc --- /dev/null +++ b/packages/frontend/component/src/ui/avatar/colorful-fallback.tsx @@ -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>(); + + const [topColor, middleColor, bottomColor] = colors; + const [isHover, setIsHover] = useState(false); + + return ( +
{ + timer.current = setTimeout(() => { + setIsHover(true); + }, 300); + }} + onMouseLeave={() => { + clearTimeout(timer.current); + setIsHover(false); + }} + > +
+
+
+
+ ); +}; +export default ColorfulFallback; diff --git a/packages/frontend/component/src/ui/avatar/index.ts b/packages/frontend/component/src/ui/avatar/index.ts new file mode 100644 index 0000000000..a7424ca311 --- /dev/null +++ b/packages/frontend/component/src/ui/avatar/index.ts @@ -0,0 +1 @@ +export * from './avatar'; diff --git a/packages/frontend/component/src/ui/avatar/style.css.ts b/packages/frontend/component/src/ui/avatar/style.css.ts new file mode 100644 index 0000000000..46753c359a --- /dev/null +++ b/packages/frontend/component/src/ui/avatar/style.css.ts @@ -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', +}); diff --git a/packages/frontend/component/src/ui/button/button.css.ts b/packages/frontend/component/src/ui/button/button.css.ts new file mode 100644 index 0000000000..f74f0a2661 --- /dev/null +++ b/packages/frontend/component/src/ui/button/button.css.ts @@ -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)', + }, + }, +}); diff --git a/packages/frontend/component/src/ui/button/button.tsx b/packages/frontend/component/src/ui/button/button.tsx new file mode 100644 index 0000000000..dd8dbaf91f --- /dev/null +++ b/packages/frontend/component/src/ui/button/button.tsx @@ -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 & + Omit, 'type'> & { + componentProps?: { + startIcon?: Omit; + endIcon?: Omit; + }; + }; + +type IconButtonProps = PropsWithChildren & + Omit, 'type'>; + +const defaultProps = { + type: 'default', + disabled: false, + shape: 'default', + size: 'default', + iconPosition: 'start', + loading: false, + withoutHoverStyle: false, +} as const; + +const ButtonIcon: FC = props => { + const { + size, + icon, + iconPosition = 'start', + children, + type, + loading, + withoutHoverStyle, + ...otherProps + } = { + ...defaultProps, + ...props, + }; + const onlyIcon = icon && !children; + return ( +
+ {icon} +
+ ); +}; +export const Button = forwardRef( + (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 ; + } + return propsIcon; + }, [propsIcon, loading]); + + const baseIconButtonProps = useMemo(() => { + return { + size, + iconPosition, + icon, + type, + disabled, + loading, + } as const; + }, [disabled, icon, iconPosition, loading, size, type]); + + return ( + + ); + } +); +Button.displayName = 'Button'; +export default Button; diff --git a/packages/frontend/component/src/ui/button/icon-button.tsx b/packages/frontend/component/src/ui/button/icon-button.tsx new file mode 100644 index 0000000000..880bed1414 --- /dev/null +++ b/packages/frontend/component/src/ui/button/icon-button.tsx @@ -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, '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( + (props, ref) => { + const { + type, + size, + withoutPadding, + children, + disabled, + loading, + active, + withoutHoverStyle, + icon: propsIcon, + className, + ...otherProps + } = { + ...defaultProps, + ...props, + }; + + return ( + + ); + } +); + +IconButton.displayName = 'IconButton'; +export default IconButton; diff --git a/packages/frontend/component/src/ui/button/index.ts b/packages/frontend/component/src/ui/button/index.ts index d0456a6981..a8957d85da 100644 --- a/packages/frontend/component/src/ui/button/index.ts +++ b/packages/frontend/component/src/ui/button/index.ts @@ -1,2 +1,4 @@ +export * from './button'; export * from './dropdown-button'; +export * from './icon-button'; export * from './radio'; diff --git a/packages/frontend/component/src/ui/divider/divider.tsx b/packages/frontend/component/src/ui/divider/divider.tsx new file mode 100644 index 0000000000..56f7a57b57 --- /dev/null +++ b/packages/frontend/component/src/ui/divider/divider.tsx @@ -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, 'type'> & { + orientation?: DividerOrientation; + size?: 'thinner' | 'default'; + dividerColor?: string; + }; + +const defaultProps = { + orientation: 'horizontal', + size: 'default', +}; + +export const Divider = forwardRef( + (props, ref) => { + const { orientation, className, size, dividerColor, style, ...otherProps } = + { + ...defaultProps, + ...props, + }; + + return ( +
+ ); + } +); + +Divider.displayName = 'Divider'; +export default Divider; diff --git a/packages/frontend/component/src/ui/divider/index.ts b/packages/frontend/component/src/ui/divider/index.ts new file mode 100644 index 0000000000..bf4ed01967 --- /dev/null +++ b/packages/frontend/component/src/ui/divider/index.ts @@ -0,0 +1 @@ +export * from './divider'; diff --git a/packages/frontend/component/src/ui/divider/style.css.ts b/packages/frontend/component/src/ui/divider/style.css.ts new file mode 100644 index 0000000000..48f1dd1346 --- /dev/null +++ b/packages/frontend/component/src/ui/divider/style.css.ts @@ -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', +}); diff --git a/packages/frontend/component/src/ui/menu/index.ts b/packages/frontend/component/src/ui/menu/index.ts new file mode 100644 index 0000000000..405ef1e04c --- /dev/null +++ b/packages/frontend/component/src/ui/menu/index.ts @@ -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'; diff --git a/packages/frontend/component/src/ui/menu/menu-icon.tsx b/packages/frontend/component/src/ui/menu/menu-icon.tsx new file mode 100644 index 0000000000..f0b1497f01 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/menu-icon.tsx @@ -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 { + icon?: ReactNode; + position?: 'start' | 'end'; +} + +export const MenuIcon = forwardRef( + ({ children, icon, position = 'start', className, ...otherProps }, ref) => { + return ( +
+ clsx( + menuItemIcon, + { + end: position === 'end', + start: position === 'start', + }, + className + ), + [className, position] + )} + {...otherProps} + > + {icon || children} +
+ ); + } +); + +MenuIcon.displayName = 'MenuIcon'; diff --git a/packages/frontend/component/src/ui/menu/menu-item.tsx b/packages/frontend/component/src/ui/menu/menu-item.tsx new file mode 100644 index 0000000000..a6278f9648 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/menu-item.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/packages/frontend/component/src/ui/menu/menu-separator.tsx b/packages/frontend/component/src/ui/menu/menu-separator.tsx new file mode 100644 index 0000000000..dc306549e0 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/menu-separator.tsx @@ -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 ( + clsx(styles.menuSeparator, className), + [className] + )} + {...otherProps} + /> + ); +}; diff --git a/packages/frontend/component/src/ui/menu/menu-sub.tsx b/packages/frontend/component/src/ui/menu/menu-sub.tsx new file mode 100644 index 0000000000..6b947feece --- /dev/null +++ b/packages/frontend/component/src/ui/menu/menu-sub.tsx @@ -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; + portalOptions?: Omit; + subOptions?: Omit; + subContentOptions?: Omit; +} + +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 ( + + + {children} + + + + + + clsx(styles.menuContent, subContentClassName), + [subContentClassName] + )} + {...otherSubContentOptions} + > + {items} + + + + ); +}; diff --git a/packages/frontend/component/src/ui/menu/menu-trigger.tsx b/packages/frontend/component/src/ui/menu/menu-trigger.tsx new file mode 100644 index 0000000000..483e83c8d0 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/menu-trigger.tsx @@ -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 { + 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( + ( + { + disabled, + noBorder = false, + className, + status = 'default', + size = 'default', + preFix, + endFix, + block = false, + children, + width, + style = {}, + ...otherProps + }, + ref + ) => { + return ( + + ); + } +); + +MenuTrigger.displayName = 'MenuTrigger'; diff --git a/packages/frontend/component/src/ui/menu/menu.tsx b/packages/frontend/component/src/ui/menu/menu.tsx new file mode 100644 index 0000000000..620e3e9efe --- /dev/null +++ b/packages/frontend/component/src/ui/menu/menu.tsx @@ -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; + rootOptions?: Omit; + contentOptions?: Omit; +} + +export const Menu = ({ + children, + items, + portalOptions, + rootOptions, + contentOptions: { + className = '', + style: contentStyle = {}, + ...otherContentOptions + } = {}, +}: MenuProps) => { + return ( + + {children} + + + clsx(styles.menuContent, className), + [className] + )} + sideOffset={5} + align="start" + style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }} + {...otherContentOptions} + > + {items} + + + + ); +}; diff --git a/packages/frontend/component/src/ui/menu/menu.types.ts b/packages/frontend/component/src/ui/menu/menu.types.ts new file mode 100644 index 0000000000..658f2b02d7 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/menu.types.ts @@ -0,0 +1,11 @@ +import type { MenuItemProps as MenuItemPropsPrimitive } from '@radix-ui/react-dropdown-menu'; + +export interface MenuItemProps + extends Omit { + type?: 'default' | 'warning' | 'danger'; + preFix?: React.ReactNode; + endFix?: React.ReactNode; + checked?: boolean; + selected?: boolean; + block?: boolean; +} diff --git a/packages/frontend/component/src/ui/menu/styles.css.ts b/packages/frontend/component/src/ui/menu/styles.css.ts new file mode 100644 index 0000000000..8a95ad1ac8 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/styles.css.ts @@ -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)', + }, + }, +}); diff --git a/packages/frontend/component/src/ui/menu/use-menu-item.tsx b/packages/frontend/component/src/ui/menu/use-menu-item.tsx new file mode 100644 index 0000000000..a3b4a3a296 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/use-menu-item.tsx @@ -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} + {propsChildren} + {endFix} + + {checked || selected ? ( + + + + ) : null} + + ), + [checked, endFix, preFix, propsChildren, selected] + ); + + return { + children, + className, + }; +}; diff --git a/packages/frontend/component/src/ui/modal/confirm-modal.tsx b/packages/frontend/component/src/ui/modal/confirm-modal.tsx new file mode 100644 index 0000000000..64ea4e2ae8 --- /dev/null +++ b/packages/frontend/component/src/ui/modal/confirm-modal.tsx @@ -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 ( + + {children ? ( +
{children}
+ ) : null} +
+ + + + +
+
+ ); +}; diff --git a/packages/frontend/component/src/ui/modal/index.ts b/packages/frontend/component/src/ui/modal/index.ts new file mode 100644 index 0000000000..1d7b6892d3 --- /dev/null +++ b/packages/frontend/component/src/ui/modal/index.ts @@ -0,0 +1,2 @@ +export * from './confirm-modal'; +export * from './modal'; diff --git a/packages/frontend/component/src/ui/modal/modal.tsx b/packages/frontend/component/src/ui/modal/modal.tsx new file mode 100644 index 0000000000..d34de8e6e7 --- /dev/null +++ b/packages/frontend/component/src/ui/modal/modal.tsx @@ -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( + ( + { + width, + height, + minHeight = 194, + title, + description, + withoutCloseButton = false, + + portalOptions, + contentOptions: { + style: contentStyle, + className: contentClassName, + ...otherContentOptions + } = {}, + overlayOptions: { + className: overlayClassName, + ...otherOverlayOptions + } = {}, + closeButtonOptions = {}, + children, + ...props + }, + ref + ) => ( + + + + + {withoutCloseButton ? null : ( + + + + + + )} + {title ? ( + {title} + ) : null} + {description ? ( + + {description} + + ) : null} + + {children} + + + + ) +); + +Modal.displayName = 'Modal'; diff --git a/packages/frontend/component/src/ui/modal/styles.css.ts b/packages/frontend/component/src/ui/modal/styles.css.ts new file mode 100644 index 0000000000..f5e6ef18ec --- /dev/null +++ b/packages/frontend/component/src/ui/modal/styles.css.ts @@ -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', +}); diff --git a/packages/frontend/component/src/ui/popover/index.ts b/packages/frontend/component/src/ui/popover/index.ts new file mode 100644 index 0000000000..1f4904cff9 --- /dev/null +++ b/packages/frontend/component/src/ui/popover/index.ts @@ -0,0 +1 @@ +export * from './popover'; diff --git a/packages/frontend/component/src/ui/popover/popover.tsx b/packages/frontend/component/src/ui/popover/popover.tsx new file mode 100644 index 0000000000..e9031c79c8 --- /dev/null +++ b/packages/frontend/component/src/ui/popover/popover.tsx @@ -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 ( + + {children} + + + clsx(styles.popoverContent, contentClassName), + [contentClassName] + )} + sideOffset={5} + align="start" + style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }} + {...otherContentOptions} + > + {content} + + + + ); +}; + +Popover.displayName = 'Popover'; diff --git a/packages/frontend/component/src/ui/popover/styles.css.ts b/packages/frontend/component/src/ui/popover/styles.css.ts new file mode 100644 index 0000000000..af9e7b509c --- /dev/null +++ b/packages/frontend/component/src/ui/popover/styles.css.ts @@ -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', +}); diff --git a/packages/frontend/component/src/ui/tooltip/index.ts b/packages/frontend/component/src/ui/tooltip/index.ts new file mode 100644 index 0000000000..d605d91ad0 --- /dev/null +++ b/packages/frontend/component/src/ui/tooltip/index.ts @@ -0,0 +1,5 @@ +import { Tooltip } from './tooltip'; + +export * from './tooltip'; + +export default Tooltip; diff --git a/packages/frontend/component/src/ui/tooltip/styles.css.ts b/packages/frontend/component/src/ui/tooltip/styles.css.ts new file mode 100644 index 0000000000..0de1af2cf6 --- /dev/null +++ b/packages/frontend/component/src/ui/tooltip/styles.css.ts @@ -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', +}); diff --git a/packages/frontend/component/src/ui/tooltip/tooltip.tsx b/packages/frontend/component/src/ui/tooltip/tooltip.tsx new file mode 100644 index 0000000000..4dc827865c --- /dev/null +++ b/packages/frontend/component/src/ui/tooltip/tooltip.tsx @@ -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; + portalOptions?: TooltipPortalProps; + options?: Omit; +} + +export const Tooltip = ({ + children, + content, + side = 'top', + align = 'center', + options, + rootOptions, + portalOptions, +}: TooltipProps) => { + if (!content) { + return children; + } + return ( + + + {children} + + + + {content} + + + + + + ); +}; diff --git a/yarn.lock b/yarn.lock index c4ebddf134..b87736ed45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -238,11 +238,14 @@ __metadata: "@popperjs/core": "npm:^2.11.8" "@radix-ui/react-avatar": "npm:^1.0.4" "@radix-ui/react-collapsible": "npm:^1.0.3" + "@radix-ui/react-dialog": "npm:^1.0.5" + "@radix-ui/react-dropdown-menu": "npm:^2.0.6" "@radix-ui/react-popover": "npm:^1.0.7" "@radix-ui/react-radio-group": "npm:^1.1.3" "@radix-ui/react-scroll-area": "npm:^1.0.5" "@radix-ui/react-toast": "npm:^1.1.5" "@radix-ui/react-toolbar": "npm:^1.0.4" + "@radix-ui/react-tooltip": "npm:^1.0.7" "@storybook/jest": "npm:^0.2.3" "@storybook/testing-library": "npm:^0.2.2" "@testing-library/react": "npm:^14.0.0" @@ -9726,7 +9729,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dialog@npm:^1.0.4": +"@radix-ui/react-dialog@npm:^1.0.4, @radix-ui/react-dialog@npm:^1.0.5": version: 1.0.5 resolution: "@radix-ui/react-dialog@npm:1.0.5" dependencies: @@ -9833,7 +9836,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dropdown-menu@npm:^2.0.5": +"@radix-ui/react-dropdown-menu@npm:^2.0.5, @radix-ui/react-dropdown-menu@npm:^2.0.6": version: 2.0.6 resolution: "@radix-ui/react-dropdown-menu@npm:2.0.6" dependencies: @@ -10510,7 +10513,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-tooltip@npm:^1.0.6": +"@radix-ui/react-tooltip@npm:^1.0.6, @radix-ui/react-tooltip@npm:^1.0.7": version: 1.0.7 resolution: "@radix-ui/react-tooltip@npm:1.0.7" dependencies: