refactor(component): refactor the implementation of Button and IconButton (#7716)

## Button
- Remove props withoutHoverStyle
   refactor hover impl with independent layer, so that hover-color won't affect the background even if is overridden outside
- Update `type` (renamed to `variant`):
  - remove `processing` and `warning`
  - rename `default` with `secondary`
- Remove `shape` props
- Remove `icon` and `iconPosition`, replaced with `prefix: ReactNode` and `suffix: ReactNode`
- Integrate tooltip for more convenient usage
- New Storybook document
- Focus style

## IconButton
- A Wrapper base on `<Button />`
- Override Button size and variant
  - size: `'12' | '14' | '16' | '20' | '24' | number`
     These presets size are referenced from the design system.
  - variant:  `'plain' | 'solid' | 'danger' | 'custom'`
- Inset icon via Button 's prefix

## Fix
- fix some button related issues
- close AF-1159, AF-1160, AF-1161, AF-1162, AF-1163, AF-1158, AF-1157

## Storybook

![CleanShot 2024-08-03 at 14.57.20@2x.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/f5a76110-35d0-4082-a940-efc12bed87b0.png)
This commit is contained in:
CatsJuice
2024-08-05 02:57:23 +00:00
parent 10deed94e3
commit 3d855647c7
159 changed files with 1384 additions and 1539 deletions

View File

@@ -199,21 +199,20 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
</Tooltip>
{onRemove ? (
<Tooltip
portalOptions={{ container: removeButtonDom }}
{...removeTooltipOptions}
<IconButton
tooltipOptions={{
portalOptions: { container: removeButtonDom },
...removeTooltipOptions,
}}
variant="solid"
size="12"
className={clsx(style.removeButton, removeButtonClassName)}
onClick={onRemove}
ref={setRemoveButtonDom}
{...removeButtonProps}
>
<IconButton
size="extraSmall"
type="default"
className={clsx(style.removeButton, removeButtonClassName)}
onClick={onRemove}
ref={setRemoveButtonDom}
{...removeButtonProps}
>
<CloseIcon />
</IconButton>
</Tooltip>
<CloseIcon />
</IconButton>
) : null}
</AvatarRoot>
);

View File

@@ -1,5 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { createVar, globalStyle, keyframes, style } from '@vanilla-extract/css';
import { createVar, keyframes, style } from '@vanilla-extract/css';
export const sizeVar = createVar('sizeVar');
export const blurVar = createVar('blurVar');
const bottomAnimation = keyframes({
@@ -172,7 +172,7 @@ export const hoverWrapper = style({
alignItems: 'center',
backgroundColor: 'rgba(60, 61, 63, 0.5)',
zIndex: '1',
color: cssVar('white'),
color: cssVar('pureWhite'),
opacity: 0,
transition: 'opacity .15s',
cursor: 'pointer',
@@ -189,14 +189,8 @@ export const removeButton = style({
visibility: 'hidden',
zIndex: '1',
selectors: {
'&:hover': {
background: '#f6f6f6',
[`${avatarRoot}:hover &`]: {
visibility: 'visible',
},
},
});
globalStyle(`${avatarRoot}:hover ${removeButton}`, {
visibility: 'visible',
});
globalStyle(`${avatarRoot} ${removeButton}:hover`, {
background: '#f6f6f6',
});

View File

@@ -1,371 +1,259 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, globalStyle, style } from '@vanilla-extract/css';
// Using variables can override externally, without considering the priority of selectors.
// size vars
export const hVar = createVar('height');
export const wVar = createVar('width');
export const iconSizeVar = createVar('iconSize');
const gapVar = createVar('gap');
const paddingVar = createVar('padding');
const fontSizeVar = createVar('fontSize');
const fontWeightVar = createVar('fontWeight');
const lineHeightVar = createVar('lineHeight');
const shadowVar = createVar('shadow');
// style vars
const bgVar = createVar('bg');
const textVar = createVar('fg');
const iconColorVar = createVar('icon');
const borderColorVar = createVar('border');
const borderWidthVar = createVar('borderWidth');
export const button = style({
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
userSelect: 'none',
touchAction: 'manipulation',
vars: {
// default vars
[gapVar]: '4px',
[wVar]: 'unset',
[hVar]: 'unset',
[borderWidthVar]: '1px',
},
flexShrink: 0,
outline: '0',
border: '1px solid',
padding: '0 8px',
borderRadius: '8px',
fontSize: cssVar('fontXs'),
fontWeight: 500,
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
outline: 0,
borderRadius: 8,
transition: 'all .3s',
['WebkitAppRegion' as string]: 'no-drag',
cursor: 'pointer',
// changeable
height: '28px',
background: cssVar('white'),
borderColor: cssVar('borderColor'),
color: cssVar('textPrimaryColor'),
['WebkitAppRegion' as string]: 'no-drag',
// hover layer
':before': {
content: '""',
position: 'absolute',
width: '100%',
height: '100%',
transition: 'inherit',
borderRadius: 'inherit',
opacity: 0,
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
borderColor: 'transparent',
pointerEvents: 'none',
borderWidth: 'inherit',
borderStyle: 'inherit',
},
// style
backgroundColor: bgVar,
color: textVar,
boxShadow: shadowVar,
borderWidth: borderWidthVar,
borderStyle: 'solid',
borderColor: borderColorVar,
// size
width: wVar,
height: hVar,
gap: gapVar,
padding: paddingVar,
fontSize: fontSizeVar,
fontWeight: fontWeightVar,
lineHeight: lineHeightVar,
selectors: {
'&.text-bold': {
fontWeight: 600,
},
'&:not(.without-hover):hover': {
background: cssVar('hoverColor'),
},
'&.disabled': {
opacity: '.4',
cursor: 'default',
color: cssVar('textDisableColor'),
pointerEvents: 'none',
},
'&.loading': {
cursor: 'default',
color: cssVar('textDisableColor'),
pointerEvents: 'none',
},
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
{
background: 'inherit',
'&:hover:before': { opacity: 1 },
'&[data-block]': { display: 'flex' },
// size
'&[data-size="default"]': {
vars: {
[hVar]: '28px', // line-height + paddingY * 2 (to ignore border width)
[paddingVar]: '0px 8px',
[iconSizeVar]: '16px',
[paddingVar]: '4px 12px',
[fontSizeVar]: cssVar('fontXs'),
[fontWeightVar]: '500',
[lineHeightVar]: '20px',
},
'&.block': {
display: 'flex',
},
'&[data-size="large"]': {
vars: {
[hVar]: '32px',
[paddingVar]: '0px 8px',
[iconSizeVar]: '20px',
[paddingVar]: '4px 12px',
[fontSizeVar]: '15px',
[fontWeightVar]: '500',
[lineHeightVar]: '24px',
},
},
'&[data-size="extraLarge"]': {
vars: {
[hVar]: '40px',
[paddingVar]: '0px 8px',
[iconSizeVar]: '24px',
[paddingVar]: '8px 18px',
[fontSizeVar]: '15',
[fontWeightVar]: '600',
[lineHeightVar]: '24px',
},
},
// type
'&[data-variant="primary"]': {
vars: {
[bgVar]: cssVarV2('button/primary'),
[textVar]: cssVarV2('button/pureWhiteText'),
[iconColorVar]: cssVarV2('button/pureWhiteText'),
[borderColorVar]: cssVarV2('button/innerBlackBorder'),
},
},
'&[data-variant="secondary"]': {
vars: {
[bgVar]: cssVarV2('button/secondary'),
[textVar]: cssVarV2('text/primary'),
[iconColorVar]: cssVarV2('icon/primary'),
[borderColorVar]: cssVarV2('layer/border'),
},
},
'&[data-variant="plain"]': {
vars: {
[bgVar]: 'transparent',
[textVar]: cssVarV2('text/primary'),
[iconColorVar]: cssVarV2('icon/primary'),
[borderColorVar]: 'transparent',
[borderWidthVar]: '0px',
},
},
'&[data-variant="error"]': {
vars: {
[bgVar]: cssVarV2('button/error'),
[textVar]: cssVarV2('button/pureWhiteText'),
[iconColorVar]: cssVarV2('button/pureWhiteText'),
[borderColorVar]: cssVarV2('button/innerBlackBorder'),
},
},
'&[data-variant="success"]': {
vars: {
[bgVar]: cssVarV2('button/success'),
[textVar]: cssVarV2('button/pureWhiteText'),
[iconColorVar]: cssVarV2('button/pureWhiteText'),
[borderColorVar]: cssVarV2('button/innerBlackBorder'),
},
},
// disabled
'&[data-disabled]': {
cursor: 'not-allowed',
opacity: 0.5,
},
// default keyboard focus style
'&:focus-visible::after': {
content: '""',
width: '100%',
},
'&.circle': {
borderRadius: '50%',
},
'&.round': {
borderRadius: '14px',
},
// size
'&.large': {
height: '32px',
fontSize: cssVar('fontBase'),
fontWeight: 600,
},
'&.round.large': {
borderRadius: '16px',
},
'&.extraLarge': {
height: '40px',
fontSize: cssVar('fontBase'),
fontWeight: 700,
},
'&.extraLarge.primary': {
boxShadow: `${cssVar('largeButtonEffect')} !important`,
},
'&.round.extraLarge': {
borderRadius: '20px',
},
// type
'&.plain': {
color: cssVar('textPrimaryColor'),
borderColor: 'transparent',
background: 'transparent',
},
'&.primary': {
color: cssVar('pureWhite'),
background: cssVar('primaryColor'),
borderColor: cssVar('black10'),
},
'&.primary:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'primaryColor'
)}`,
},
'&.primary.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.primary.disabled:not(.without-hover):hover': {
background: cssVar('primaryColor'),
},
'&.error': {
color: cssVar('pureWhite'),
background: cssVar('errorColor'),
borderColor: cssVar('black10'),
},
'&.error:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'errorColor'
)}`,
},
'&.error.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.error.disabled:not(.without-hover):hover': {
background: cssVar('errorColor'),
},
'&.warning': {
color: cssVar('pureWhite'),
background: cssVar('warningColor'),
borderColor: cssVar('black10'),
},
'&.warning:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'warningColor'
)}`,
},
'&.warning.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.warning.disabled:not(.without-hover):hover': {
background: cssVar('warningColor'),
},
'&.success': {
color: cssVar('pureWhite'),
background: cssVar('successColor'),
borderColor: cssVar('black10'),
},
'&.success:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'successColor'
)}`,
},
'&.success.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.success.disabled:not(.without-hover):hover': {
background: cssVar('successColor'),
},
'&.processing': {
color: cssVar('pureWhite'),
background: cssVar('processingColor'),
borderColor: cssVar('black10'),
},
'&.processing:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'processingColor'
)}`,
},
'&.processing.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.processing.disabled:not(.without-hover):hover': {
background: cssVar('processingColor'),
},
'&.danger:hover': {
color: cssVar('errorColor'),
background: cssVar('backgroundErrorColor'),
height: '100%',
position: 'absolute',
top: 0,
left: 0,
borderRadius: 'inherit',
boxShadow: `0 0 0 1px ${cssVarV2('layer/insideBorder/primary')}`,
},
},
});
globalStyle(`${button} > span`, {
// flex: 1,
lineHeight: 1,
padding: '0 4px',
export const content = style({
// in case that width is specified by parent and text is too long
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const buttonIcon = style({
export const icon = style({
flexShrink: 0,
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
color: cssVar('iconColor'),
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: cssVar('pureWhite'),
},
},
// There are two kinds of icon size:
// 1. control by props: width and height
width: iconSizeVar,
height: iconSizeVar,
// 2. width/height is set to `1em`
fontSize: iconSizeVar,
color: iconColorVar,
});
globalStyle(`${icon} > svg`, {
width: '100%',
height: '100%',
display: 'block',
});
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: cssVar('white'),
// changeable
width: '24px',
height: '24px',
fontSize: '20px',
color: cssVar('textPrimaryColor'),
borderColor: cssVar('borderColor'),
vars: {
[paddingVar]: '2px',
// TODO(@CatsJuice): Replace with theme variables when ready
'--shadow':
'0px 0px 1px 0px rgba(0, 0, 0, 0.12), 0px 1px 5px 0px rgba(0, 0, 0, 0.12)',
},
borderRadius: 4,
selectors: {
'&.without-padding': {
margin: '-2px',
},
'&.active': {
color: cssVar('primaryColor'),
},
'&:not(.without-hover):hover': {
background: cssVar('hoverColor'),
},
'&.disabled': {
opacity: '.4',
cursor: 'default',
color: cssVar('textDisableColor'),
pointerEvents: 'none',
},
'&.loading': {
cursor: 'default',
color: cssVar('textDisableColor'),
pointerEvents: 'none',
},
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
{
background: 'inherit',
'[data-theme="dark"] &': {
vars: {
'--shadow':
'0px 0px 1px 0px rgba(0, 0, 0, 0.66), 0px 1px 5px 0px rgba(0, 0, 0, 0.72)',
},
// size
'&.large': {
width: '32px',
height: '32px',
fontSize: '24px',
},
'&.large.without-padding': {
margin: '-4px',
'&[data-icon-variant="plain"]': {
vars: {
[bgVar]: 'transparent',
[iconColorVar]: cssVarV2('icon/primary'),
[borderColorVar]: 'transparent',
[borderWidthVar]: '0px',
},
},
'&.small': {
width: '20px',
height: '20px',
fontSize: '16px',
'&[data-icon-variant="danger"]': {
vars: {
[bgVar]: 'transparent',
[iconColorVar]: cssVarV2('icon/primary'),
[borderColorVar]: 'transparent',
[borderWidthVar]: '0px',
},
},
'&.extra-small': {
width: '16px',
height: '16px',
fontSize: '12px',
'&[data-icon-variant="danger"]:hover': {
vars: {
[bgVar]: cssVar('backgroundErrorColor'),
[iconColorVar]: cssVar('errorColor'),
},
},
// type
'&.plain': {
color: cssVar('iconColor'),
borderColor: 'transparent',
background: 'transparent',
// disable hover layer for danger type
'&[data-icon-variant="danger"]:hover:before': {
opacity: 0,
},
'&.plain.active': {
color: cssVar('primaryColor'),
'&[data-icon-variant="solid"]': {
vars: {
[bgVar]: cssVarV2('button/iconButtonSolid'),
[iconColorVar]: cssVarV2('icon/primary'),
[borderColorVar]: 'transparent',
[shadowVar]: 'var(--shadow)',
},
},
'&.primary': {
color: cssVar('white'),
background: cssVar('primaryColor'),
borderColor: cssVar('black10'),
},
'&.primary:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'primaryColor'
)}`,
},
'&.primary.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.primary.disabled:not(.without-hover):hover': {
background: cssVar('primaryColor'),
},
'&.error': {
color: cssVar('white'),
background: cssVar('errorColor'),
borderColor: cssVar('black10'),
},
'&.error:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'errorColor'
)}`,
},
'&.error.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.error.disabled:not(.without-hover):hover': {
background: cssVar('errorColor'),
},
'&.warning': {
color: cssVar('white'),
background: cssVar('warningColor'),
borderColor: cssVar('black10'),
},
'&.warning:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'warningColor'
)}`,
},
'&.warning.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.warning.disabled:not(.without-hover):hover': {
background: cssVar('warningColor'),
},
'&.success': {
color: cssVar('white'),
background: cssVar('successColor'),
borderColor: cssVar('black10'),
},
'&.success:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'successColor'
)}`,
},
'&.success.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.success.disabled:not(.without-hover):hover': {
background: cssVar('successColor'),
},
'&.processing': {
color: cssVar('white'),
background: cssVar('processingColor'),
borderColor: cssVar('black10'),
},
'&.processing:not(.without-hover):hover': {
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), ${cssVar(
'processingColor'
)}`,
},
'&.processing.disabled': {
opacity: '.4',
cursor: 'default',
},
'&.processing.disabled:not(.without-hover):hover': {
background: cssVar('processingColor'),
},
'&.danger:hover': {
color: cssVar('errorColor'),
background: cssVar('backgroundErrorColor'),
'&[data-icon-size="24"]': {
vars: { [paddingVar]: '4px' },
},
},
});

View File

@@ -0,0 +1,61 @@
import { globalStyle, style } from '@vanilla-extract/css';
// table
export const table = style({
vars: { '--border-color': '#974FFF' },
});
globalStyle(`${table} thead td, ${table} tbody tr td:nth-child(1)`, {
backgroundColor: '#974FFF10',
padding: '16px',
fontWeight: 600,
fontSize: 12,
color: 'var(--border-color)',
});
globalStyle(`${table} td`, {
textAlign: 'center',
border: '0.5px dashed var(--border-color)',
borderTopColor: 'transparent',
borderBottomColor: 'transparent',
padding: '16px 8px',
});
globalStyle(`${table} thead td`, {
borderTopColor: 'var(--border-color)',
});
globalStyle(`${table} tbody tr:last-child td`, {
borderBottomColor: 'var(--border-color)',
});
export const settings = style({
display: 'flex',
flexWrap: 'wrap',
gap: '8px 100px',
marginBottom: 40,
});
globalStyle(`${settings} > section`, {
display: 'flex',
alignItems: 'center',
});
globalStyle(`${settings} > section > span`, {
display: 'inline-block',
width: 200,
});
export const overrideBackground = style({
background: 'cyan',
});
export const overrideTextColor = style({
color: 'red',
});
export const overrideBorder = style({
borderColor: 'green',
});
export const overrideFontSize = style({
fontSize: 24,
});
export const overrideIconSize = style({
width: 60,
height: 60,
});
export const overrideIconColor = style({
color: 'forestgreen',
});

View File

@@ -1,47 +1,183 @@
import { InformationIcon } from '@blocksuite/icons/rc';
import type { Meta, StoryFn } from '@storybook/react';
import {
AfFiNeIcon,
ArrowRightBigIcon,
FolderIcon,
} from '@blocksuite/icons/rc';
import type { Meta } from '@storybook/react';
import clsx from 'clsx';
import { useCallback, useEffect, useState } from 'react';
import { Switch } from '../switch';
import type { ButtonProps } from './button';
import { Button } from './button';
import * as styles from './button.stories.css';
export default {
title: 'UI/Button',
component: Button,
argTypes: {
onClick: () => console.log('Click button'),
},
} satisfies Meta<ButtonProps>;
const Template: StoryFn<ButtonProps> = args => <Button {...args} />;
// const Template: StoryFn<ButtonProps> = args => <Button {...args} />;
export const Default: StoryFn<ButtonProps> = Template.bind(undefined);
Default.args = {
type: 'default',
children: 'This is a default button',
icon: <InformationIcon />,
const types: ButtonProps['variant'][] = [
'primary',
'secondary',
'plain',
'error',
'success',
];
const sizes: ButtonProps['size'][] = ['default', 'large', 'extraLarge'];
const Groups = ({
children,
...props
}: Omit<ButtonProps, 'variant' | 'size'>) => {
return (
<table className={styles.table}>
<thead>
<tr>
<td>Type/Size</td>
{sizes.map(size => (
<td key={size}>{size}</td>
))}
</tr>
</thead>
<tbody>
{types.map(type => (
<tr key={type}>
<td>{type}</td>
{sizes.map(size => (
<td key={size}>
<Button variant={type} size={size} {...props}>
{children ?? `${size} - ${type}`}
</Button>
</td>
))}
</tr>
))}
</tbody>
</table>
);
};
export const Primary: StoryFn<ButtonProps> = Template.bind(undefined);
Primary.args = {
type: 'primary',
children: 'Content',
icon: <InformationIcon />,
export const Default = () => <Groups />;
export const WithIcon = () => {
return <Groups prefix={<FolderIcon />} suffix={<span>🚀</span>} />;
};
export const Disabled: StoryFn<ButtonProps> = Template.bind(undefined);
Disabled.args = {
disabled: true,
children: 'This is a disabled button',
export const Loading = () => {
const [loading, setLoading] = useState(false);
const toggleLoading = useCallback(() => setLoading(v => !v), []);
useEffect(() => {
setInterval(toggleLoading, 1000);
}, [toggleLoading]);
return <Groups loading={loading} prefix={<FolderIcon />} />;
};
export const LargeSizeButton: StoryFn<ButtonProps> = Template.bind(undefined);
LargeSizeButton.args = {
size: 'large',
children: 'This is a large button',
export const OverrideViaClassName = () => {
const [overrideBg, setOverrideBg] = useState(false);
const [overrideTextColor, setOverrideTextColor] = useState(false);
const [overrideBorder, setOverrideBorder] = useState(false);
const [overrideFontSize, setOverrideFontSize] = useState(false);
const [overridePrefixSize, setOverridePrefixSize] = useState(false);
const [overrideSuffixSize, setOverrideSuffixSize] = useState(false);
const [overridePrefixColor, setOverridePrefixColor] = useState(false);
const [overrideSuffixColor, setOverrideSuffixColor] = useState(false);
return (
<div>
<div className={styles.settings}>
<section>
<span>Override background color</span>
<Switch checked={overrideBg} onChange={setOverrideBg} />
</section>
<section>
<span>Override text color</span>
<Switch checked={overrideTextColor} onChange={setOverrideTextColor} />
</section>
<section>
<span>Override border color</span>
<Switch checked={overrideBorder} onChange={setOverrideBorder} />
</section>
<section>
<span>Override font size</span>
<Switch checked={overrideFontSize} onChange={setOverrideFontSize} />
</section>
<section>
<span>Override prefix size</span>
<Switch
checked={overridePrefixSize}
onChange={setOverridePrefixSize}
/>
</section>
<section>
<span>Override suffix size</span>
<Switch
checked={overrideSuffixSize}
onChange={setOverrideSuffixSize}
/>
</section>
<section>
<span>Override prefix color</span>
<Switch
checked={overridePrefixColor}
onChange={setOverridePrefixColor}
/>
</section>
<section>
<span>Override suffix color</span>
<Switch
checked={overrideSuffixColor}
onChange={setOverrideSuffixColor}
/>
</section>
</div>
<Groups
prefix={<FolderIcon />}
suffix={<ArrowRightBigIcon />}
className={clsx({
[styles.overrideBackground]: overrideBg,
[styles.overrideTextColor]: overrideTextColor,
[styles.overrideBorder]: overrideBorder,
[styles.overrideFontSize]: overrideFontSize,
})}
prefixClassName={clsx({
[styles.overrideIconSize]: overridePrefixSize,
[styles.overrideIconColor]: overridePrefixColor,
})}
suffixClassName={clsx({
[styles.overrideIconSize]: overrideSuffixSize,
[styles.overrideIconColor]: overrideSuffixColor,
})}
/>
</div>
);
};
export const ExtraLargeSizeButton: StoryFn<ButtonProps> =
Template.bind(undefined);
ExtraLargeSizeButton.args = {
size: 'extraLarge',
children: 'This is a extra large button',
export const FixedWidth = () => {
const widths = [60, 100, 120, 160, 180];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{widths.map(width => (
<Button prefix={<AfFiNeIcon />} key={width} style={{ width }}>
This is a width fixed button
</Button>
))}
</div>
);
};
export const Disabled = () => {
return <Groups disabled />;
};

View File

@@ -1,172 +1,168 @@
import clsx from 'clsx';
import type {
FC,
CSSProperties,
HTMLAttributes,
PropsWithChildren,
MouseEvent,
ReactElement,
} from 'react';
import { forwardRef, useMemo } from 'react';
import { cloneElement, forwardRef, useCallback } from 'react';
import { Loading } from '../loading';
import { button, buttonIcon } from './button.css';
import { Tooltip, type TooltipProps } from '../tooltip';
import * as styles from './button.css';
export type ButtonType =
| 'default'
| 'primary'
| 'secondary'
| 'plain'
| 'error'
| 'warning'
| 'success'
| 'processing';
export type ButtonSize = 'default' | 'large' | 'extraLarge';
type BaseButtonProps = {
type?: ButtonType;
| 'custom';
export type ButtonSize = 'default' | 'large' | 'extraLarge' | 'custom';
export interface ButtonProps
extends Omit<HTMLAttributes<HTMLButtonElement>, 'type' | 'prefix'> {
/**
* Preset color scheme
* @default 'secondary'
*/
variant?: ButtonType;
disabled?: boolean;
icon?: ReactElement;
iconPosition?: 'start' | 'end';
shape?: 'default' | 'round' | 'circle';
/**
* By default, the button is `inline-flex`, set to `true` to make it `flex`
* @default false
*/
block?: boolean;
/**
* Preset size, will be overridden by `style` or `className`
* @default 'default'
*/
size?: ButtonSize;
/**
* Will show a loading spinner at `prefix` position
*/
loading?: boolean;
withoutHoverStyle?: boolean;
};
export type ButtonProps = PropsWithChildren<BaseButtonProps> &
Omit<HTMLAttributes<HTMLButtonElement>, 'type'> & {
componentProps?: {
startIcon?: Omit<IconButtonProps, 'icon' | 'iconPosition'>;
endIcon?: Omit<IconButtonProps, 'icon' | 'iconPosition'>;
};
};
/**
* By default, it is considered as an icon with preset size and color,
* can be overridden by `prefixClassName` and `prefixStyle`.
*
* If `loading` is true, will be replaced by a spinner.(`prefixClassName` and `prefixStyle` still work)
* */
prefix?: ReactElement;
prefixClassName?: string;
prefixStyle?: CSSProperties;
contentClassName?: string;
contentStyle?: CSSProperties;
type IconButtonProps = PropsWithChildren<BaseButtonProps> &
Omit<HTMLAttributes<HTMLDivElement>, 'type'>;
/**
* By default, it is considered as an icon with preset size and color,
* can be overridden by `suffixClassName` and `suffixStyle`.
* */
suffix?: ReactElement;
suffixClassName?: string;
suffixStyle?: CSSProperties;
const defaultProps = {
type: 'default',
disabled: false,
shape: 'default',
size: 'default',
iconPosition: 'start',
loading: false,
withoutHoverStyle: false,
} as const;
tooltip?: TooltipProps['content'];
tooltipOptions?: Partial<Omit<TooltipProps, 'content'>>;
}
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 && type !== 'default' && type !== 'plain',
large: size === 'large',
extraLarge: size === 'extraLarge',
end: iconPosition === 'end' && !onlyIcon,
start: iconPosition === 'start' && !onlyIcon,
loading,
})}
data-without-hover={withoutHoverStyle}
>
{icon}
const IconSlot = ({
icon,
loading,
className,
...attrs
}: {
icon?: ReactElement;
loading?: boolean;
} & HTMLAttributes<HTMLElement>) => {
const showLoadingHere = loading !== undefined;
const visible = icon || loading;
return visible ? (
<div className={clsx(styles.icon, className)} {...attrs}>
{showLoadingHere && loading ? <Loading size="100%" /> : null}
{icon && !loading
? cloneElement(icon, {
width: '100%',
height: '100%',
...icon.props,
})
: null}
</div>
);
) : null;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
(
{
variant = 'secondary',
size = 'default',
children,
type,
disabled,
shape,
size,
icon: propsIcon,
iconPosition,
block,
loading,
withoutHoverStyle,
className,
prefix,
prefixClassName,
prefixStyle,
suffix,
suffixClassName,
suffixStyle,
contentClassName,
contentStyle,
tooltip,
tooltipOptions,
onClick,
...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]);
},
ref
) => {
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (loading || disabled) return;
onClick?.(e);
},
[disabled, loading, onClick]
);
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"
<Tooltip content={tooltip} {...tooltipOptions}>
<button
{...otherProps}
ref={ref}
className={clsx(styles.button, className)}
data-loading={loading || undefined}
data-block={block || undefined}
disabled={disabled}
data-disabled={disabled || undefined}
data-size={size}
data-variant={variant}
onClick={handleClick}
>
<IconSlot
icon={prefix}
loading={loading}
className={prefixClassName}
style={prefixStyle}
/>
) : null}
<span>{children}</span>
{icon && iconPosition === 'end' ? (
<ButtonIcon
{...baseIconButtonProps}
{...props.componentProps?.endIcon}
icon={icon}
iconPosition="end"
{children ? (
<span
className={clsx(styles.content, contentClassName)}
style={contentStyle}
>
{children}
</span>
) : null}
<IconSlot
icon={suffix}
className={suffixClassName}
style={suffixStyle}
/>
) : null}
</button>
</button>
</Tooltip>
);
}
);

View File

@@ -1,49 +1,153 @@
import { InformationIcon } from '@blocksuite/icons/rc';
import type { Meta, StoryFn } from '@storybook/react';
import { AfFiNeIcon } from '@blocksuite/icons/rc';
import type { Meta } from '@storybook/react';
import clsx from 'clsx';
import { type ReactElement, useCallback, useEffect, useState } from 'react';
import { Switch } from '../switch';
import * as styles from './button.stories.css';
import type { IconButtonProps } from './icon-button';
import { IconButton } from './icon-button';
export default {
title: 'UI/IconButton',
component: IconButton,
argTypes: {
onClick: () => console.log('Click button'),
},
} satisfies Meta<IconButtonProps>;
const Template: StoryFn<IconButtonProps> = args => <IconButton {...args} />;
const types: IconButtonProps['variant'][] = ['plain', 'solid', 'danger'];
const sizes: IconButtonProps['size'][] = ['12', '14', '16', '20', '24'];
export const Plain: StoryFn<IconButtonProps> = Template.bind(undefined);
Plain.args = {
children: <InformationIcon />,
const Groups = ({
children,
...props
}: Omit<IconButtonProps, 'type' | 'size' | 'children'> & {
children?: ReactElement;
}) => {
return (
<table className={styles.table}>
<thead>
<tr>
<td>Type/Size</td>
{sizes.map(size => (
<td key={size}>{size}</td>
))}
</tr>
</thead>
<tbody>
{types.map(type => (
<tr key={type}>
<td>{type}</td>
{sizes.map(size => (
<td key={size}>
<IconButton variant={type} size={size} {...props}>
{children ?? <AfFiNeIcon />}
</IconButton>
</td>
))}
</tr>
))}
</tbody>
</table>
);
};
export const Primary: StoryFn<IconButtonProps> = Template.bind(undefined);
Primary.args = {
type: 'primary',
icon: <InformationIcon />,
export const Default = () => <Groups />;
export const Loading = () => {
const [loading, setLoading] = useState(false);
const toggleLoading = useCallback(() => setLoading(v => !v), []);
useEffect(() => {
setInterval(toggleLoading, 1000);
}, [toggleLoading]);
return <Groups loading={loading} />;
};
export const Disabled: StoryFn<IconButtonProps> = Template.bind(undefined);
Disabled.args = {
disabled: true,
icon: <InformationIcon />,
export const OverrideViaClassName = () => {
const [overrideBg, setOverrideBg] = useState(false);
const [overrideBorder, setOverrideBorder] = useState(false);
const [overridePrefixColor, setOverridePrefixColor] = useState(false);
return (
<div>
<div className={styles.settings}>
<section>
<span>Override background color</span>
<Switch checked={overrideBg} onChange={setOverrideBg} />
</section>
<section>
<span>Override border color</span>
<Switch checked={overrideBorder} onChange={setOverrideBorder} />
</section>
<section>
<span>Override icon color</span>
<Switch
checked={overridePrefixColor}
onChange={setOverridePrefixColor}
/>
</section>
</div>
<Groups
className={clsx({
[styles.overrideBackground]: overrideBg,
[styles.overrideBorder]: overrideBorder,
})}
iconClassName={clsx({
[styles.overrideIconColor]: overridePrefixColor,
})}
/>
</div>
);
};
export const ExtraSmallSizeButton: StoryFn<IconButtonProps> =
Template.bind(undefined);
ExtraSmallSizeButton.args = {
size: 'extraSmall',
icon: <InformationIcon />,
};
export const SmallSizeButton: StoryFn<IconButtonProps> =
Template.bind(undefined);
SmallSizeButton.args = {
size: 'small',
icon: <InformationIcon />,
};
export const LargeSizeButton: StoryFn<IconButtonProps> =
Template.bind(undefined);
LargeSizeButton.args = {
size: 'large',
icon: <InformationIcon />,
export const CustomSize = () => {
const sizes = [
[13, 2],
[15, 2],
[17, 2],
[19, 2],
[21, 3],
[23, 3],
[25, 3],
[27, 3],
[29, 4],
[31, 4],
[33, 4],
[35, 4],
];
return types.map(type => {
return (
<div key={type}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{sizes.map(size => (
<div
key={size[0]}
style={{
fontSize: 10,
textAlign: 'center',
color: 'rgba(100, 100, 100, 0.5)',
}}
>
<IconButton
size={size[0]}
style={{ padding: size[1] }}
variant={type}
>
<AfFiNeIcon />
</IconButton>
<div style={{ marginTop: 8 }}>Size: {size[0]}px</div>
<div style={{ marginTop: 2 }}>Padding: {size[1]}px</div>
</div>
))}
</div>
</div>
);
});
};
export const Disabled = () => <Groups disabled />;

View File

@@ -1,85 +1,78 @@
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
import { forwardRef } from 'react';
import { type CSSProperties, forwardRef, type ReactElement } from 'react';
import { Loading } from '../loading';
import type { ButtonType } from './button';
import { iconButton } from './button.css';
import { Button, type ButtonProps } from './button';
import { iconButton, iconSizeVar } 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 interface IconButtonProps
extends Omit<
ButtonProps,
| 'variant'
| 'size'
| 'prefix'
| 'suffix'
| 'children'
| 'prefixClassName'
| 'prefixStyle'
| 'suffix'
| 'suffixClassName'
| 'suffixStyle'
> {
/** Icon element */
children?: ReactElement;
/** Same as `children`, compatibility of the old API */
icon?: ReactElement;
variant?: 'plain' | 'solid' | 'danger' | 'custom';
/**
* Use preset size,
* or use custom size(px) (default padding is `2px`, have to override yourself)
*
* > These presets size are referenced from the design system.
* > The number is the size of the icon, the button size is calculated based on the icon size + padding.
* > OR, you can define `width` and `height` in `style` or `className` directly.
*/
size?: '12' | '14' | '16' | '20' | '24' | number;
iconClassName?: string;
iconStyle?: CSSProperties;
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(props, ref) => {
const {
type,
size,
withoutPadding,
children,
disabled,
loading,
active,
withoutHoverStyle,
icon: propsIcon,
(
{
variant = 'plain',
size = '20',
style,
className,
children,
icon,
iconClassName,
iconStyle,
...otherProps
} = {
...defaultProps,
...props,
};
},
ref
) => {
const validatedSize = isNaN(parseInt(size as string, 10)) ? 16 : size;
return (
<button
<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}
style={{
...style,
...assignInlineVars({
[iconSizeVar]: `${validatedSize}px`,
}),
}}
data-icon-variant={variant}
data-icon-size={validatedSize}
className={clsx(iconButton, className)}
size={'custom'}
variant={'custom'}
prefix={children ?? icon}
prefixClassName={iconClassName}
prefixStyle={iconStyle}
{...otherProps}
>
{loading ? <Loading /> : children || propsIcon}
</button>
/>
);
}
);

View File

@@ -1,26 +0,0 @@
import type {
CSSProperties,
HTMLAttributes,
PropsWithChildren,
ReactElement,
} from 'react';
export const SIZE_SMALL = 'small' as const;
export const SIZE_MIDDLE = 'middle' as const;
export const SIZE_DEFAULT = 'default' as const;
export type ButtonProps = PropsWithChildren &
Omit<HTMLAttributes<HTMLButtonElement>, 'type'> & {
size?: typeof SIZE_SMALL | typeof SIZE_MIDDLE | typeof SIZE_DEFAULT;
disabled?: boolean;
hoverBackground?: CSSProperties['background'];
hoverColor?: CSSProperties['color'];
hoverStyle?: CSSProperties;
icon?: ReactElement;
iconPosition?: 'start' | 'end';
shape?: 'default' | 'round' | 'circle';
type?: 'primary' | 'light' | 'warning' | 'danger' | 'default';
bold?: boolean;
loading?: boolean;
noBorder?: boolean;
};

View File

@@ -1,89 +0,0 @@
import type { ButtonProps } from './interface';
export const getButtonColors = (
type: ButtonProps['type'],
disabled: boolean,
extend?: {
hoverBackground: ButtonProps['hoverBackground'];
hoverColor: ButtonProps['hoverColor'];
hoverStyle: ButtonProps['hoverStyle'];
}
) => {
switch (type) {
case 'primary':
return {
background: 'var(--affine-primary-color)',
color: 'var(--affine-white)',
borderColor: 'var(--affine-primary-color)',
backgroundBlendMode: 'overlay',
opacity: disabled ? '.4' : '1',
'.affine-button-icon': {
color: 'var(--affine-white)',
},
':hover': {
background:
'linear-gradient(var(--affine-primary-color),var(--affine-primary-color)),var(--affine-hover-color)',
},
};
case 'light':
return {
background: 'var(--affine-tertiary-color)',
color: disabled
? 'var(--affine-text-disable-color)'
: 'var(--affine-text-emphasis-color)',
borderColor: 'var(--affine-tertiary-color)',
'.affine-button-icon': {
borderColor: 'var(--affine-text-emphasis-color)',
},
':hover': {
borderColor: disabled
? 'var(--affine-disable-color)'
: 'var(--affine-text-emphasis-color)',
},
};
case 'warning':
return {
background: 'var(--affine-background-warning-color)',
color: 'var(--affine-warning-color)',
borderColor: 'var(--affine-background-warning-color)',
'.affine-button-icon': {
color: 'var(--affine-warning-color)',
},
':hover': {
borderColor: 'var(--affine-warning-color)',
color: extend?.hoverColor,
background: extend?.hoverBackground,
...extend?.hoverStyle,
},
};
case 'danger':
return {
background: 'var(--affine-background-error-color)',
color: 'var(--affine-error-color)',
borderColor: 'var(--affine-background-error-color)',
'.affine-button-icon': {
color: 'var(--affine-error-color)',
},
':hover': {
borderColor: 'var(--affine-error-color)',
color: extend?.hoverColor,
background: extend?.hoverBackground,
...extend?.hoverStyle,
},
};
default:
return {
color: 'var(--affine-text-primary-color)',
borderColor: 'var(--affine-border-color)',
':hover': {
borderColor: 'var(--affine-primary-color)',
color: extend?.hoverColor ?? 'var(--affine-primary-color)',
'.affine-button-icon': {
color: extend?.hoverColor ?? 'var(--affine-primary-color)',
background: extend?.hoverBackground,
...extend?.hoverStyle,
},
},
};
}
};

View File

@@ -134,8 +134,7 @@ export const NavButtons = memo(function NavButtons({
<div className={styles.headerNavButtons} key="nav-btn-group">
<IconButton
key="nav-btn-prev"
size="small"
className={styles.focusInteractive}
size="16"
disabled={prevDisabled}
data-testid="date-picker-nav-prev"
onClick={onPrev}
@@ -147,8 +146,7 @@ export const NavButtons = memo(function NavButtons({
<IconButton
key="nav-btn-next"
size="small"
className={styles.focusInteractive}
size="16"
disabled={nextDisabled}
data-testid="date-picker-nav-next"
onClick={onNext}

View File

@@ -1,9 +1,10 @@
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { withUnit } from '../../utils/with-unit';
import { loading, speedVar } from './styles.css';
export interface LoadingProps {
size?: number;
size?: number | string;
speed?: number;
progress?: number;
}
@@ -13,11 +14,13 @@ export const Loading = ({
speed = 1.2,
progress = 0.2,
}: LoadingProps) => {
// allow `string` such as `16px` | `100%` | `1em`
const sizeWithUnit = size ? withUnit(size, 'px') : '16px';
return (
<svg
className={loading}
width={size ? `${size}px` : '16px'}
height={size ? `${size}px` : '16px'}
width={sizeWithUnit}
height={sizeWithUnit}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -61,7 +61,7 @@ const ConfirmModalTemplate: StoryFn<ConfirmModalProps> = () => {
confirmText="Confirm"
confirmButtonOptions={{
loading: loading,
type: 'primary',
variant: 'primary',
}}
>
<Input placeholder="input someting" status={inputStatus} />
@@ -82,7 +82,7 @@ const OverlayModalTemplate: StoryFn<OverlayModalProps> = () => {
title="Modal Title"
description="Modal description"
confirmButtonOptions={{
type: 'primary',
variant: 'primary',
}}
topImage={
<div

View File

@@ -130,10 +130,12 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
style: overlayStyle,
...otherOverlayOptions
} = {},
closeButtonOptions = {},
closeButtonOptions,
children,
...otherProps
} = props;
const { className: closeButtonClassName, ...otherCloseButtonProps } =
closeButtonOptions || {};
const [container, setContainer] = useState<ModalTransitionContainer | null>(
null
@@ -204,11 +206,11 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
{withoutCloseButton ? null : (
<Dialog.Close asChild>
<IconButton
className={styles.closeButton}
size="20"
className={clsx(styles.closeButton, closeButtonClassName)}
aria-label="Close"
type="plain"
data-testid="modal-close-button"
{...closeButtonOptions}
{...otherCloseButtonProps}
>
<CloseIcon />
</IconButton>