feat: refactor button with new design (#3343)

This commit is contained in:
Qi
2023-07-21 19:07:28 +08:00
committed by GitHub
parent a4f60f22cf
commit 439ef1ba90
42 changed files with 665 additions and 473 deletions
+110 -56
View File
@@ -1,71 +1,125 @@
import { Children, cloneElement, forwardRef } from 'react';
import clsx from 'clsx';
import {
type FC,
forwardRef,
type HTMLAttributes,
type PropsWithChildren,
type ReactElement,
useMemo,
} from 'react';
import type { ButtonProps } from './interface';
import { Loading } from './loading';
import { StyledButton } from './styles';
import { getSize } from './utils';
export type { ButtonProps };
import { Loading } from '../loading';
import { button, buttonIcon } from './style.css';
export type ButtonType =
| 'default'
| 'primary'
| 'plain'
| 'error'
| 'warning'
| 'success'
| 'processing';
export type ButtonSize = 'default' | 'large' | 'extraLarge';
export type ButtonProps = PropsWithChildren &
Omit<HTMLAttributes<HTMLButtonElement>, 'type'> & {
type?: ButtonType;
disabled?: boolean;
icon?: ReactElement;
iconPosition?: 'start' | 'end';
shape?: 'default' | 'round' | 'circle';
block?: boolean;
size?: ButtonSize;
loading?: boolean;
};
const defaultProps = {
type: 'default',
disabled: false,
shape: 'default',
size: 'default',
iconPosition: 'start',
loading: false,
};
const ButtonIcon: FC<ButtonProps> = props => {
const {
size,
icon,
iconPosition = 'start',
children,
type,
} = {
...defaultProps,
...props,
};
const onlyIcon = icon && !children;
return (
<div
className={clsx(buttonIcon, {
'color-white': type !== 'default' && type !== 'plain',
large: size === 'large',
extraLarge: size === 'extraLarge',
end: iconPosition === 'end' && !onlyIcon,
start: iconPosition === 'start' && !onlyIcon,
})}
>
{icon}
</div>
);
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
size = 'default',
disabled = false,
hoverBackground,
hoverColor,
hoverStyle,
shape = 'default',
icon,
iconPosition = 'start',
type = 'default',
(props, ref) => {
const {
children,
bold = false,
loading = false,
noBorder = false,
...props
},
ref
) => {
const { iconSize } = getSize(size);
type,
disabled,
shape,
size,
icon: propsIcon,
iconPosition,
block,
loading,
...otherProps
} = {
...defaultProps,
...props,
};
const iconElement =
icon &&
cloneElement(Children.only(icon), {
width: iconSize,
height: iconSize,
className: `affine-button-icon ${icon.props.className ?? ''}`,
});
const icon = useMemo(() => {
if (loading) {
return <Loading />;
}
return propsIcon;
}, [propsIcon, loading]);
return (
<StyledButton
<button
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,
})}
disabled={disabled}
size={size}
shape={shape}
hoverBackground={hoverBackground}
hoverColor={hoverColor}
hoverStyle={hoverStyle}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type={type}
bold={bold}
noBorder={noBorder}
{...props}
data-disabled={disabled}
{...otherProps}
>
{loading ? (
<Loading type={type}></Loading>
) : (
<>
{iconPosition === 'start' && iconElement}
{children && <span>{children}</span>}
{iconPosition === 'end' && iconElement}
</>
)}
</StyledButton>
{icon && iconPosition === 'start' ? (
<ButtonIcon {...props} icon={icon} />
) : null}
<span>{children}</span>
{icon && iconPosition === 'end' ? <ButtonIcon {...props} /> : null}
</button>
);
}
);
Button.displayName = 'Button';
export default Button;
@@ -1,73 +1,76 @@
import type { CSSProperties, HTMLAttributes, ReactElement } from 'react';
import clsx from 'clsx';
import type { HTMLAttributes, PropsWithChildren } from 'react';
import { forwardRef } from 'react';
import { StyledIconButton } from './styles';
const SIZE_SMALL = 'small' as const;
const SIZE_MIDDLE = 'middle' as const;
const SIZE_NORMAL = 'normal' as const;
// TODO: IconButton should merge into Button, but it has not been designed yet
const SIZE_CONFIG = {
[SIZE_SMALL]: {
iconSize: 16,
areaSize: 20,
},
[SIZE_MIDDLE]: {
iconSize: 20,
areaSize: 24,
},
[SIZE_NORMAL]: {
iconSize: 24,
areaSize: 32,
},
} as const;
import { Loading } from '../loading';
import type { ButtonType } from './button';
import { iconButton } from './style.css';
export type IconButtonProps = {
size?:
| typeof SIZE_SMALL
| typeof SIZE_MIDDLE
| typeof SIZE_NORMAL
| [number, number];
iconSize?:
| typeof SIZE_SMALL
| typeof SIZE_MIDDLE
| typeof SIZE_NORMAL
| [number, number];
disabled?: boolean;
hoverBackground?: CSSProperties['background'];
hoverColor?: string;
hoverStyle?: CSSProperties;
children: ReactElement<HTMLAttributes<SVGElement>, 'svg'>;
darker?: boolean;
} & HTMLAttributes<HTMLButtonElement>;
export type IconButtonSize = 'default' | 'large' | 'small' | 'extraSmall';
export type IconButtonProps = PropsWithChildren &
Omit<HTMLAttributes<HTMLButtonElement>, 'type'> & {
type?: ButtonType;
disabled?: boolean;
size?: IconButtonSize;
loading?: boolean;
withoutPadding?: boolean;
active?: boolean;
};
const defaultProps = {
type: 'plain',
disabled: false,
size: 'default',
loading: false,
withoutPadding: false,
active: false,
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(
{ size = 'normal', iconSize, disabled = false, children, ...props },
ref
) => {
iconSize = size;
const [width, height] = Array.isArray(size)
? size
: [SIZE_CONFIG[size]['areaSize'], SIZE_CONFIG[size]['areaSize']];
const [iconWidth] = Array.isArray(iconSize)
? iconSize
: [SIZE_CONFIG[iconSize]['iconSize'], SIZE_CONFIG[iconSize]['iconSize']];
(props, ref) => {
const {
type,
size,
withoutPadding,
children,
disabled,
loading,
active,
...otherProps
} = {
...defaultProps,
...props,
};
return (
<StyledIconButton
<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,
})}
disabled={disabled}
width={width}
height={height}
borderRadius={iconWidth / 4}
fontSize={iconWidth}
{...props}
data-disabled={disabled}
{...otherProps}
>
{children}
</StyledIconButton>
{loading ? <Loading /> : children}
</button>
);
}
);
IconButton.displayName = 'IconButton';
IconButton.displayName = 'IconButton';
export default IconButton;
@@ -2,4 +2,3 @@ export * from './button';
export * from './dropdown';
export * from './icon-button';
export * from './radio';
export * from './text-button';
@@ -0,0 +1,349 @@
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-base)',
transition: 'all .3s',
['WebkitAppRegion' as string]: 'no-drag',
fontWeight: 600,
// changeable
height: '28px',
background: 'var(--affine-white)',
borderColor: 'var(--affine-border-color)',
color: 'var(--affine-text-primary-color)',
selectors: {
'&.text-bold': {
fontWeight: 600,
},
'&:hover': {
background: 'var(--affine-hover-color)',
},
'&.disabled, &.loading': {
opacity: '.4',
cursor: 'default',
color: 'var(--affine-disable-color)',
pointerEvents: 'none',
},
'&.disabled:hover, &.loading:hover': {
background: 'inherit',
},
'&.block': { display: 'flex', width: '100%' },
'&.circle': {
borderRadius: '50%',
},
'&.round': {
borderRadius: '14px',
},
// size
'&.large': {
height: '32px',
},
'&.round.large': {
borderRadius: '16px',
},
'&.extraLarge': {
height: '40px',
},
'&.round.extraLarge': {
borderRadius: '20px',
},
// type
'&.plain': {
color: 'var(--affine-text-primary-color)',
borderColor: 'transparent',
},
'&.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: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: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: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: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: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: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: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: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: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: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-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',
// 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)',
},
'&:hover': {
background: 'var(--affine-hover-color)',
},
'&.disabled, &.loading': {
opacity: '.4',
cursor: 'default',
color: 'var(--affine-disable-color)',
pointerEvents: 'none',
},
'&.disabled:hover, &.loading: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',
},
'&.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: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: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: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: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: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: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: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: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: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:hover': {
background: 'var(--affine-processing-color)',
},
},
});
-116
View File
@@ -1,123 +1,7 @@
import type { CSSProperties } from 'react';
import { displayInlineFlex, styled } from '../../styles';
import type { ButtonProps } from './interface';
import { getButtonColors, getSize } from './utils';
export const StyledIconButton = styled('button', {
shouldForwardProp: prop => {
return ![
'borderRadius',
'top',
'right',
'width',
'height',
'hoverBackground',
'hoverColor',
'hoverStyle',
'fontSize',
].includes(prop as string);
},
})<{
width: number;
height: number;
borderRadius: number;
disabled?: boolean;
hoverBackground?: CSSProperties['background'];
hoverColor?: string;
hoverStyle?: CSSProperties;
// In some cases, button is in a normal hover status, it should be darkened
fontSize?: CSSProperties['fontSize'];
}>(({
width,
height,
borderRadius,
disabled,
hoverBackground,
hoverColor,
hoverStyle,
fontSize,
}) => {
return {
width,
height,
fontSize,
WebkitAppRegion: 'no-drag',
color: 'var(--affine-icon-color)',
...displayInlineFlex('center', 'center'),
...(disabled ? { cursor: 'not-allowed', pointerEvents: 'none' } : {}),
transition: 'background .15s',
borderRadius,
':hover': {
color: hoverColor ?? 'var(--affine-icon-color)',
background: hoverBackground || 'var(--affine-hover-color)',
...(hoverStyle ?? {}),
},
};
});
export const StyledTextButton = styled('button', {
shouldForwardProp: prop => {
return ![
'borderRadius',
'top',
'right',
'width',
'height',
'hoverBackground',
'hoverColor',
'hoverStyle',
'bold',
].includes(prop as string);
},
})<
Pick<
ButtonProps,
| 'size'
| 'disabled'
| 'hoverBackground'
| 'hoverColor'
| 'hoverStyle'
| 'shape'
| 'type'
| 'bold'
>
>(({
size = 'default',
disabled,
hoverBackground,
hoverColor,
hoverStyle,
bold = false,
shape = 'default',
// TODO: Implement type
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// type = 'default',
}) => {
const { fontSize, borderRadius, padding, height } = getSize(size);
return {
height,
paddingLeft: padding,
paddingRight: padding,
...displayInlineFlex('flex-start', 'center'),
position: 'relative',
...(disabled ? { cursor: 'not-allowed', pointerEvents: 'none' } : {}),
transition: 'background .15s',
// TODO: Implement circle shape
borderRadius: shape === 'default' ? borderRadius : height / 2,
fontSize,
fontWeight: bold ? '500' : '400',
':hover': {
color: hoverColor ?? 'var(--affine-primary-color)',
background: hoverBackground ?? 'var(--affine-hover-color)',
...(hoverStyle ?? {}),
},
};
});
export const StyledButton = styled('button', {
shouldForwardProp: prop => {
return ![
@@ -1,53 +0,0 @@
import { Children, cloneElement, forwardRef } from 'react';
import type { ButtonProps } from './interface';
import { StyledTextButton } from './styles';
import { getSize } from './utils';
export const TextButton = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
size = 'default',
disabled = false,
hoverBackground,
hoverColor,
hoverStyle,
shape = 'default',
icon,
type = 'default',
children,
bold = false,
...props
},
ref
) => {
const { iconSize } = getSize(size);
return (
<StyledTextButton
ref={ref}
disabled={disabled}
size={size}
shape={shape}
hoverBackground={hoverBackground}
hoverColor={hoverColor}
hoverStyle={hoverStyle}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type={type}
bold={bold}
{...props}
>
{icon &&
cloneElement(Children.only(icon), {
width: iconSize,
height: iconSize,
className: `affine-button-icon ${icon.props.className ?? ''}`,
})}
{children && <span>{children}</span>}
</StyledTextButton>
);
}
);
TextButton.displayName = 'TextButton';
export default TextButton;