Skip to content

Commit

Permalink
feat(button): add button component (#1358)
Browse files Browse the repository at this point in the history
* feat(button): add button component

* test(button): add test cascs

* fix(button): fix style

* fix(button): fix style

* style(button): code style lint

* style(button): code style lint

* test(all): fix test cases that use the Button Component

* test(all): fix test cases that use the Button Component
  • Loading branch information
nnmax committed Oct 18, 2021
1 parent c4281f8 commit 046fa58
Show file tree
Hide file tree
Showing 25 changed files with 874 additions and 482 deletions.
192 changes: 48 additions & 144 deletions src/button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,168 +1,72 @@
/* eslint-disable react/jsx-props-no-spreading */
import * as React from 'react';
import classNames from 'classnames';
import React from 'react';
import { LoadingOutlined } from '@gio-design/icons';
import { usePrefixCls, useSize } from '@gio-design/utils';
import { ButtonProps } from './interface';
import { cloneElement } from '../utils/reactNode';
import usePrefixCls from '../utils/hooks/use-prefix-cls';

const rxTwoCNChar = /^[\u4e00-\u9fa5]{2}$/;
const isTwoCNChar = rxTwoCNChar.test.bind(rxTwoCNChar);

function isString(str: unknown) {
return typeof str === 'string';
}

function insertSpace(child: React.ReactChild, needInserted: boolean) {
if (child === null || child === undefined) {
return null;
}
const SPACE = needInserted ? ' ' : '';
if (
typeof child !== 'string' &&
typeof child !== 'number' &&
isString(child.type) &&
isTwoCNChar(child.props.children)
) {
return cloneElement(child, {
children: child.props.children.split('').join(SPACE),
});
}
if (typeof child === 'string') {
if (isTwoCNChar(child)) {
return <span>{child.split('').join(SPACE)}</span>;
}
return <span>{child}</span>;
}
return child;
}

function spaceChildren(children: React.ReactNode, needInserted: boolean) {
let isPrevChildPure = false;
const childList: React.ReactNode[] = [];
React.Children.forEach(children, (child) => {
const type = typeof child;
const isCurrentChildPure = type === 'string' || type === 'number';
if (isPrevChildPure && isCurrentChildPure) {
const lastIndex = childList.length - 1;
const lastChild = childList[lastIndex];
childList[lastIndex] = `${lastChild}${child}`;
} else {
childList.push(child);
}

isPrevChildPure = isCurrentChildPure;
});

return React.Children.map(childList, (child) => insertSpace(child as React.ReactChild, needInserted));
}

interface CompoundedComponent extends React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<HTMLElement>> {
__GIO_BUTTON: boolean;
}

const InternalButton: React.ForwardRefRenderFunction<unknown, ButtonProps> = (props, ref) => {
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const {
loading,
prefixCls: customizePrefixCls,
type = 'primary',
size: customizeSize,
size = 'normal',
loading = false,
disabled = false,
htmlType = 'button',
prefix,
suffix,
className,
children,
icon,
block,
mini,
autoInsertSpace = true,
...rest
...restProps
} = props;

const size = useSize();
const [innerLoading, setLoading] = React.useState<boolean | undefined>(loading);
const [hasTwoCNChar, setHasTwoCNChar] = React.useState(false);
const prefixCls = usePrefixCls('btn', customizePrefixCls);
const buttonRef = (ref as any) || React.createRef<HTMLElement>();

const isNeedInserted = () => React.Children.count(children) === 1 && !icon;

const fixTwoCNChar = () => {
if (!buttonRef || !buttonRef.current || autoInsertSpace === false) {
return;
}
const buttonText = buttonRef.current.textContent;

if (isNeedInserted() && isTwoCNChar(buttonText)) {
if (!hasTwoCNChar) {
setHasTwoCNChar(true);
}
} else if (hasTwoCNChar) {
setHasTwoCNChar(false);
}
};

React.useEffect(() => {
setLoading(loading);
}, [loading]);

React.useEffect(() => {
fixTwoCNChar();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [buttonRef]);

const handleClick = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement, MouseEvent>) => {
const { onClick } = props;
if (innerLoading) {
return;
}
if (onClick) {
(onClick as React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>)(e);
}
};

const iconType = innerLoading ? 'loading' : icon;
const sizeCls = mini && !!(!children && children !== 0 && iconType) ? 'mini' : customizeSize || size;

const classes = classNames(prefixCls, className, {
[`${prefixCls}-${type}`]: type,
[`${prefixCls}-${sizeCls}`]: sizeCls,
[`${prefixCls}-icon-only`]: !children && children !== 0 && iconType,
[`${prefixCls}-loading`]: innerLoading,
[`${prefixCls}-two-chinese-chars`]: hasTwoCNChar && autoInsertSpace,
[`${prefixCls}-block`]: block,
const prefixCls = usePrefixCls('button');
const classes = classNames([className, prefixCls], {
[`${prefixCls}_${type}`]: type,
[`${prefixCls}_${size}`]: size,
[`${prefixCls}_loading`]: loading,
});

let iconNode = null;
const prefixIcon = loading ? (
<span className={`${prefixCls}-prefix-icon`}>
<LoadingOutlined rotating />
</span>
) : (
prefix && <span className={`${prefixCls}-prefix-icon`}>{prefix}</span>
);

if (icon && !innerLoading) {
iconNode = icon;
} else if (innerLoading) {
iconNode = <LoadingOutlined rotating />;
}
const suffixIcon = suffix && <span className={`${prefixCls}-suffix-icon`}>{suffix}</span>;

const kids = children || children === 0 ? spaceChildren(children, isNeedInserted() && autoInsertSpace) : null;
const other: typeof restProps = {
role: 'button',
'aria-disabled': disabled,
};

const { htmlType, ...otherProps } = rest;
const buttonNode = (
// eslint-disable-next-line react/button-has-type
<button {...otherProps} ref={buttonRef} type={htmlType} className={classes} onClick={handleClick}>
{iconNode}
{kids}
return (
<button
ref={ref}
// eslint-disable-next-line react/button-has-type
type={htmlType || 'button'}
className={classes}
disabled={disabled || loading}
// eslint-disable-next-line react/jsx-props-no-spreading
{...other}
// eslint-disable-next-line react/jsx-props-no-spreading
{...restProps}
>
{prefixIcon}
{children}
{suffixIcon}
</button>
);
});

return buttonNode;
};

export const Button = React.forwardRef<unknown, ButtonProps>(InternalButton) as CompoundedComponent;
Button.displayName = 'Button';

Button.defaultProps = {
type: 'primary',
size: 'normal',
loading: false,
block: false,
htmlType: 'button' as ButtonProps['htmlType'],
disabled: false,
htmlType: 'button',
};

Button.displayName = 'Button';

// eslint-disable-next-line no-underscore-dangle
Button.__GIO_BUTTON = true;

export default Button;
22 changes: 22 additions & 0 deletions src/button/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import classNames from 'classnames';
import { IconButtonProps } from './interface';
import Button from './Button';
import usePrefixCls from '../utils/hooks/use-prefix-cls';

const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>((props, ref) => {
const { children, className, ...restProps } = props;

const prefixCls = usePrefixCls('icon-button');
const classes = classNames(prefixCls, className);

return <Button ref={ref} className={classes} prefix={children} {...restProps} />;
});

IconButton.displayName = 'IconButton';

IconButton.defaultProps = {
...Button.defaultProps,
};

export default IconButton;
94 changes: 13 additions & 81 deletions src/button/demos/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,98 +1,30 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import { PlusCircleFilled, FilterOutlined } from '@gio-design/icons';
import { withDesign } from 'storybook-addon-designs';
import Docs from './ButtonPage';
import { PlusCircleFilled, FilterOutlined, DeleteOutlined } from '@gio-design/icons';
import Button from '../index';
import { ButtonProps } from '../interface';
import IconButton from '../IconButton';
import { ButtonProps, IconButtonProps } from '../interface';
import '../style';

export default {
title: 'General/Button',
title: 'Upgraded/Button',
component: Button,
decorators: [withDesign],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/kP3A6S2fLUGVVMBgDuUx0f/GrowingIO-Design-Components?node-id=1%3A1097',
allowFullscreen: true,
},
docs: {
page: Docs,
},
},
args: {
type: 'primary',
size: 'middle',
},
} as Meta;

const Wrapper = (props: { children?: React.ReactNode }) => {
const { children } = props;
return <section style={{ backgroundColor: '#F0F8FF', boxSizing: 'border-box', padding: 30 }}>{children}</section>;
};

const Template: Story<ButtonProps> = (args) => (
<Wrapper>
<Button {...args} />
<Button {...args} loading />
<Button {...args} disabled />
</Wrapper>
<Button {...args} suffix={<FilterOutlined />} prefix={<PlusCircleFilled />} />
);

export const Default = Template.bind({});
Default.args = {
children: '默认按钮',
style: {
margin: '0 20px 0 0',
},
};

export const PrimaryButton = Template.bind({});
PrimaryButton.args = {
type: 'primary',
children: '主要按钮',
icon: <PlusCircleFilled />,
style: {
margin: '0 20px 0 0',
},
};

export const SecondaryButton = Template.bind({});
SecondaryButton.args = {
children: '次要按钮',
type: 'secondary',
icon: <PlusCircleFilled />,
style: {
margin: '0 20px 0 0',
},
children: 'Button',
};

export const TextButton = Template.bind({});
TextButton.args = {
children: '文字按钮',
type: 'text',
icon: <PlusCircleFilled />,
style: {
margin: '0 20px 0 0',
},
};

export const BlockButton = Template.bind({});
BlockButton.args = {
children: 'Block按钮',
block: true,
icon: <PlusCircleFilled />,
style: {
margin: '0 0 20px 0',
},
};
const IconButtonTemplate: Story<IconButtonProps> = (args) => (
<IconButton {...args}>
<DeleteOutlined />
</IconButton>
);

export const IconButton = Template.bind({});
IconButton.args = {
mini: false,
icon: <FilterOutlined />,
style: {
margin: '0 20px 0 0',
},
};
export const IconButtonDemo = IconButtonTemplate.bind({});
IconButtonDemo.args = {};
32 changes: 1 addition & 31 deletions src/button/demos/ButtonPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Canvas, Title, Heading, Story, Subheading, ArgsTable } from '@storybook/addon-docs';
import { Title, Heading, ArgsTable } from '@storybook/addon-docs';
import { useIntl } from 'react-intl';
import Button from '../index';

Expand All @@ -16,36 +16,6 @@ export default function ButtonPage() {
</p>
<Heading>{formatMessage({ defaultMessage: '代码演示' })}</Heading>

<Subheading>{formatMessage({ defaultMessage: '默认按钮' })}</Subheading>
<Canvas>
<Story id="basic-components-button--default" />
</Canvas>

<Subheading>{formatMessage({ defaultMessage: '主要按钮' })}</Subheading>
<Canvas>
<Story id="basic-components-button--primary-button" />
</Canvas>

<Subheading>{formatMessage({ defaultMessage: '次要按钮' })}</Subheading>
<Canvas>
<Story id="basic-components-button--secondary-button" />
</Canvas>

<Subheading>{formatMessage({ defaultMessage: '文本按钮' })}</Subheading>
<Canvas>
<Story id="basic-components-button--text-button" />
</Canvas>

<Subheading>{formatMessage({ defaultMessage: 'Block宽度按钮' })}</Subheading>
<Canvas>
<Story id="basic-components-button--block-button" />
</Canvas>

<Subheading>{formatMessage({ defaultMessage: '图标按钮' })}</Subheading>
<Canvas>
<Story id="basic-components-button--icon-button" />
</Canvas>

<Heading>{formatMessage({ defaultMessage: '参数说明' })}</Heading>
<ArgsTable of={Button} />
</>
Expand Down

1 comment on commit 046fa58

@vercel
Copy link

@vercel vercel bot commented on 046fa58 Oct 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.