Repository: IduxFE/components (ts)

Issue:
[comp:button]button components
- [x] I have searched the [issues](https://github.com/IduxFE/components/issues) of this repository and believe that this is not a duplicate.

### What problem does this feature solve?

### What does the proposed API look like?

### `ix-button`

> 当 `mode` 不为 `link` 时，除以下表格之外还支持原生 `button` 的[所有属性](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/button)。
> 当 `mode` 为 `link` 时，除以下表格之外还支持原生 `a` 的[所有属性](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/a)。

| 属性 | 说明 | 类型  | 全局配置 |
| --- | --- | --- | --- |
| `mode` | 设置按钮种类 | `'primary'|'default'|'dashed'|'text'|'link'` | `'default'` | 
| `danger` | 设置危险状态 | `boolean` | - |
| `ghost` | 设置幽灵状态 | `boolean` | - |
| `disabled` | 设置禁用状态 | `boolean` | - |
| `loading` | 设置加载中状态 | `boolean` |  - |
| `size` | 设置按钮大小 | `'large'|'medium'|'small'` | `'medium'` |
| `shape` | 设置按钮形状 | `'circle'|'round'` | - |
| `block` | 将按钮宽度调整为其父宽度的选项 | `boolean` | - |
| `icon` | 设置图标类型(loading 状态时无效) | `string` | - |

### `ix-button-group`

| 属性 | 说明 | 类型  | 全局配置 |
| --- | --- | --- | --- |
| `mode` | 设置组内按钮种类， | `'primary'|'default'|'dashed'|'text'|'link'` | - |
| `size` | 设置组内按钮大小 | `'large'|'medium'|'small'` | - |
| `shape` | 设置组内按钮形状 | `'circle'|'round'` | - |

Pull request description:
feat(comp:button): add button component
fix #11

## PR Checklist
Please check if your PR fulfills the following requirements:

- [x] The commit message follows [our guidelines](https://github.com/IduxFE/components/blob/main/docs/contributing.zh-CN.md#commit)
- [x] Tests for the changes have been added/updated or not needed
- [x] Docs and demo have been added/updated or not needed


## PR Type
What kind of change does this PR introduce?

- [ ] Bugfix
- [x] Feature
- [ ] Component style update
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Documentation content changes
- [ ] Application (the showcase website) / infrastructure changes
- [ ] Other... Please describe:


## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->


## What is the new behavior?


## Other information



Patch:
diff --git a/package.json b/package.json
index 23ebb69c..01373c94 100644
--- a/package.json
+++ b/package.json
@@ -10,14 +10,14 @@
     "postinstall": "lerna bootstrap",
     "codecov": "codecov",
     "test": "jest",
-    "lint": "npm run eslint && npm run markdownlint",
-    "lint-fix": "npm run eslint-fix && npm run markdownlint-fix",
+    "lint": "npm run eslint && npm run stylelint && npm run markdownlint",
+    "lint-fix": "npm run eslint-fix && npm run stylelint-fix && npm run markdownlint-fix",
     "eslint": "eslint ./packages --ext .vue,.js,.ts",
     "eslint-fix": "eslint --fix ./packages  --ext .vue,.js,.ts",
-    "stylelint": "stylelint \"./packages/{cdk,components}/**/*.less\" --syntax less",
-    "stylelint-fix": "stylelint --fix \"./packages/{cdk,components}/**/*.less\" --syntax less",
-    "markdownlint": "markdownlint \"./packages/{cdk,components}/**/*.md\"",
-    "markdownlint-fix": "markdownlint --fix \"./packages/{cdk,components}/**/*.md\"",
+    "stylelint": "stylelint \"./packages/components/**/*.less\" --syntax less",
+    "stylelint-fix": "stylelint --fix \"./packages/components/**/*.less\" --syntax less",
+    "markdownlint": "markdownlint \"./packages/{cdk,components,pro}/**/*.md\"",
+    "markdownlint-fix": "markdownlint --fix \"./packages/{cdk,components,pro}/**/*.md\"",
     "ls-lint": "ls-lint"
   },
   "license": "MIT",
diff --git a/packages/components/button/demo/basic.md b/packages/components/button/demo/basic.md
new file mode 100644
index 00000000..c7646f0c
--- /dev/null
+++ b/packages/components/button/demo/basic.md
@@ -0,0 +1,21 @@
+---
+order: 0
+title:
+  zh-CN: 按钮类型
+---
+
+## zh-CN
+
+按钮有 5 种类型：主按钮、默认按钮、虚线按钮和链接按钮。
+
+## demo
+
+```html
+<template>
+  <ix-button mode="primary" @click="console.log($event)">Primary Button</ix-button>
+  <ix-button>Default Button</ix-button>
+  <ix-button mode="dashed">Dashed Button</ix-button>
+  <ix-button mode="text">Text Button</ix-button>
+  <ix-button mode="link" href="https://github.com/IduxFE/components" target="_blank">Link Button</ix-button>
+</template>
+```
diff --git a/packages/components/button/docs/index.zh-CN.md b/packages/components/button/docs/index.zh-CN.md
new file mode 100644
index 00000000..c06a0f33
--- /dev/null
+++ b/packages/components/button/docs/index.zh-CN.md
@@ -0,0 +1,55 @@
+---
+category: Components
+type: 通用
+title: Button
+subtitle: 按钮
+cover: 
+---
+
+按钮用于开始一个即时操作。
+
+## 何时使用
+
+标记了一个（或封装一组）操作命令，响应用户点击行为，触发相应的业务逻辑。
+
+我们共有 5 种按钮。
+
+- 主按钮：用于主行动点，一个操作区域只能有一个主按钮。
+- 默认按钮：用于没有主次之分的一组行动点。
+- 虚线按钮：常用于添加操作。
+- 文本按钮：用于最次级的行动点。
+- 链接按钮：用于次要或外链的行动点。
+
+以及 4 种状态属性与上面配合使用。
+
+- 危险：删除/移动/修改权限等危险操作，一般需要二次确认。
+- 幽灵：用于背景色比较复杂的地方，常用在首页/产品页等展示场景。
+- 禁用：行动点不可用的时候，一般需要文案解释。
+- 加载中：用于异步操作等待反馈的时候，也可以避免多次提交。
+
+## API
+
+### `ix-button`
+
+> 当 `mode` 不为 `link` 时，除以下表格之外还支持原生 `button` 的[所有属性](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/button)。
+> 当 `mode` 为 `link` 时，除以下表格之外还支持原生 `a` 的[所有属性](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/a)。
+
+| 属性 | 说明 | 类型  | 全局配置 |
+| --- | --- | --- | --- |
+| `mode` | 设置按钮种类 | `'primary'|'default'|'dashed'|'text'|'link'` | `'default'` |
+| `danger` | 设置危险状态 | `boolean` | - |
+| `ghost` | 设置幽灵状态 | `boolean` | - |
+| `disabled` | 设置禁用状态 | `boolean` | - |
+| `loading` | 设置加载中状态 | `boolean` |  - |
+| `size` | 设置按钮大小 | `'large'|'medium'|'small'` | `'medium'` |
+| `shape` | 设置按钮形状 | `'circle'|'round'` | - |
+| `block` | 将按钮宽度调整为其父宽度的选项 | `boolean` | - |
+| `icon` | 设置图标类型(loading 状态时无效) | `string` | - |
+
+### `ix-button-group`
+
+| 属性 | 说明 | 类型  | 全局配置 |
+| --- | --- | --- | --- |
+| `mode` | 设置组内按钮种类， | `'primary'|'default'|'dashed'|'text'|'link'` | - |
+| `size` | 设置组内按钮大小 | `'large'|'medium'|'small'` | - |
+| `shape` | 设置组内按钮形状 | `'circle'|'round'` | - |
diff --git a/packages/components/button/index.ts b/packages/components/button/index.ts
new file mode 100644
index 00000000..4b8a07e1
--- /dev/null
+++ b/packages/components/button/index.ts
@@ -0,0 +1,9 @@
+import { installComponent } from '@idux/components/core/utils'
+import Button from './src/Button.vue'
+import ButtonGroup from './src/ButtonGroup.vue'
+
+Button.install = installComponent(Button)
+ButtonGroup.install = installComponent(ButtonGroup)
+
+export { Button, ButtonGroup }
+export type { ButtonComponent, ButtonGroupComponent } from './src/types'
diff --git a/packages/components/button/src/Button.vue b/packages/components/button/src/Button.vue
new file mode 100644
index 00000000..6bdc2500
--- /dev/null
+++ b/packages/components/button/src/Button.vue
@@ -0,0 +1,61 @@
+<template>
+  <component :is="tag" class="ix-button" :class="classes" :disabled="disabled || loading">
+    <i v-if="loading" class="ix-icon-loading"></i>
+    <i v-else-if="icon" :class="'ix-icon-' + icon"></i>
+    <slot></slot>
+  </component>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ButtonMode, ComponentSize } from '@idux/components/core/types'
+import type { ButtonConfig } from '@idux/components/core/config'
+import type { ButtonGroupProps, ButtonProps, ButtonShape } from './types'
+
+import { computed, defineComponent, inject } from 'vue'
+import { useGlobalConfig } from '@idux/components/core/config'
+import { buttonGroupInjectionKey } from './button'
+
+export default defineComponent({
+  name: 'IxButton',
+  props: {
+    mode: String as PropType<ButtonMode>,
+    danger: Boolean,
+    ghost: Boolean,
+    disabled: Boolean,
+    loading: Boolean,
+    size: String as PropType<ComponentSize>,
+    shape: String as PropType<ButtonShape>,
+    block: Boolean,
+    icon: String,
+  },
+  setup(props: ButtonProps) {
+    const groupProps = inject(buttonGroupInjectionKey, {})
+    const buttonConfig = useGlobalConfig('button')
+    const classes = useClasses(props, groupProps, buttonConfig)
+
+    const tag = computed(() => (props.mode === 'link' ? 'a' : 'button'))
+    return { classes, tag }
+  },
+})
+
+const useClasses = (props: ButtonProps, groupProps: ButtonGroupProps, config: ButtonConfig) => {
+  return computed(() => {
+    const mode = props.mode || groupProps.mode || config.mode
+    const size = props.size || groupProps.size || config.size
+    const shape = props.shape || groupProps.shape
+    return [
+      mode !== 'default' ? `ix-button-${mode}` : '',
+      size !== 'medium' ? `ix-button-${size}` : '',
+      shape ? `ix-button-${shape}` : '',
+      {
+        'ix-button-danger': props.danger,
+        'ix-button-ghost': props.ghost,
+        'ix-button-disabled': props.disabled,
+        'ix-button-loading': props.loading,
+        'ix-button-block': props.block,
+      },
+    ]
+  })
+}
+</script>
diff --git a/packages/components/button/src/ButtonGroup.vue b/packages/components/button/src/ButtonGroup.vue
new file mode 100644
index 00000000..db54dd97
--- /dev/null
+++ b/packages/components/button/src/ButtonGroup.vue
@@ -0,0 +1,26 @@
+<template>
+  <div class="ix-button-group">
+    <slot></slot>
+  </div>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ButtonMode, ComponentSize } from '@idux/components/core/types'
+import type { ButtonGroupProps, ButtonShape } from './types'
+
+import { defineComponent, provide } from 'vue'
+import { buttonGroupInjectionKey } from './button'
+
+export default defineComponent({
+  name: 'IxButtonGroup',
+  props: {
+    mode: String as PropType<ButtonMode>,
+    size: String as PropType<ComponentSize>,
+    shape: String as PropType<ButtonShape>,
+  },
+  setup(props: ButtonGroupProps) {
+    provide(buttonGroupInjectionKey, props)
+  },
+})
+</script>
diff --git a/packages/components/button/src/button.ts b/packages/components/button/src/button.ts
new file mode 100644
index 00000000..20daae96
--- /dev/null
+++ b/packages/components/button/src/button.ts
@@ -0,0 +1,5 @@
+import type { ButtonGroupProps } from './types'
+
+import { InjectionKey } from 'vue'
+
+export const buttonGroupInjectionKey: InjectionKey<ButtonGroupProps> = Symbol()
diff --git a/packages/components/button/src/types.ts b/packages/components/button/src/types.ts
new file mode 100644
index 00000000..c3fb0ebb
--- /dev/null
+++ b/packages/components/button/src/types.ts
@@ -0,0 +1,26 @@
+import type { ButtonMode, ComponentSize } from '@idux/components/core/types'
+
+export type ButtonShape = 'circle' | 'round'
+export interface ButtonProps {
+  readonly mode?: ButtonMode
+  readonly danger: boolean
+  readonly ghost: boolean
+  readonly disabled: boolean
+  readonly loading: boolean
+  readonly size?: ComponentSize
+  readonly shape?: ButtonShape
+  readonly block: boolean
+  readonly icon?: string
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface ButtonComponent extends ButtonProps {}
+
+export interface ButtonGroupProps {
+  readonly mode?: ButtonMode
+  readonly size?: ComponentSize
+  readonly shape?: ButtonShape
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface ButtonGroupComponent extends ButtonGroupProps {}
diff --git a/packages/components/button/style/index.less b/packages/components/button/style/index.less
new file mode 100644
index 00000000..29d15ab0
--- /dev/null
+++ b/packages/components/button/style/index.less
@@ -0,0 +1,8 @@
+@import '../../style/themes/index';
+@import '../../style/mixins/index';
+
+@button-prefix: ~'@{idux-prefix}-button';
+
+.@{button-prefix} {
+  color: #fff;
+}
diff --git a/packages/components/index.ts b/packages/components/index.ts
new file mode 100644
index 00000000..a70fd082
--- /dev/null
+++ b/packages/components/index.ts
@@ -0,0 +1,22 @@
+import type { App } from 'vue'
+import { Button, ButtonGroup } from './button'
+
+const version = '0.0.0'
+
+const components = [Button, ButtonGroup]
+
+const install = (app: App): void => {
+  components.forEach(component => {
+    app.component(component.name, component)
+  })
+}
+
+export default {
+  version,
+  install,
+}
+
+export * from './core/config'
+export * from './core/types'
+
+export * from './button'


Test patch:
diff --git a/packages/components/button/__tests__/__snapshots__/button.spec.ts.snap b/packages/components/button/__tests__/__snapshots__/button.spec.ts.snap
new file mode 100644
index 00000000..1038fdcb
--- /dev/null
+++ b/packages/components/button/__tests__/__snapshots__/button.spec.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Button.vue render work 1`] = `"<button class=\\"ix-button\\"><!--v-if--></button>"`;
diff --git a/packages/components/button/__tests__/__snapshots__/buttonGroup.spec.ts.snap b/packages/components/button/__tests__/__snapshots__/buttonGroup.spec.ts.snap
new file mode 100644
index 00000000..68624587
--- /dev/null
+++ b/packages/components/button/__tests__/__snapshots__/buttonGroup.spec.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ButtonGroup.vue render work 1`] = `"<div class=\\"ix-button-group\\"><button class=\\"ix-button\\"><!--v-if-->default</button><button class=\\"ix-button ix-button-primary\\"><!--v-if-->primary</button><button class=\\"ix-button ix-button-large\\"><!--v-if-->large</button><button class=\\"ix-button ix-button-circle\\"><!--v-if-->circle</button></div>"`;
diff --git a/packages/components/button/__tests__/button.spec.ts b/packages/components/button/__tests__/button.spec.ts
new file mode 100644
index 00000000..8de24174
--- /dev/null
+++ b/packages/components/button/__tests__/button.spec.ts
@@ -0,0 +1,136 @@
+import { mount, MountingOptions, VueWrapper } from '@vue/test-utils'
+import { DefineComponent } from 'vue'
+import Button from '../src/Button.vue'
+import { ButtonProps } from '../src/types'
+
+describe('Button.vue', () => {
+  let buttonMount: (
+    options?: MountingOptions<Partial<ButtonProps>>,
+  ) => VueWrapper<InstanceType<DefineComponent<ButtonProps>>>
+
+  beforeEach(() => {
+    buttonMount = (options = {}) => {
+      return mount<ButtonProps>(Button, {
+        ...options,
+      })
+    }
+  })
+
+  test('render work', () => {
+    const wrapper = buttonMount()
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+
+  test('mode work', async () => {
+    const wrapper = buttonMount({ props: { mode: 'primary' } })
+
+    expect(wrapper.classes()).toContain('ix-button-primary')
+    expect(wrapper.element.tagName).toEqual('BUTTON')
+
+    await wrapper.setProps({ mode: 'dashed' })
+
+    expect(wrapper.classes()).toContain('ix-button-dashed')
+    expect(wrapper.element.tagName).toEqual('BUTTON')
+
+    await wrapper.setProps({ mode: 'link' })
+
+    expect(wrapper.classes()).toContain('ix-button-link')
+    expect(wrapper.element.tagName).toEqual('A')
+  })
+
+  test('danger work', async () => {
+    const wrapper = buttonMount()
+
+    expect(wrapper.classes()).not.toContain('ix-button-danger')
+
+    await wrapper.setProps({ danger: true })
+
+    expect(wrapper.classes()).toContain('ix-button-danger')
+  })
+
+  test('ghost work', async () => {
+    const wrapper = buttonMount()
+
+    expect(wrapper.classes()).not.toContain('ix-button-ghost')
+
+    await wrapper.setProps({ ghost: true })
+
+    expect(wrapper.classes()).toContain('ix-button-ghost')
+  })
+
+  test('disabled work', async () => {
+    const wrapper = buttonMount({ props: { mode: 'primary' } })
+
+    expect(wrapper.classes()).not.toContain('ix-button-disabled')
+
+    await wrapper.setProps({ disabled: true })
+
+    expect(wrapper.classes()).toContain('ix-button-disabled')
+  })
+
+  test('loading work', async () => {
+    const wrapper = buttonMount({ props: { icon: 'up' } })
+
+    expect(wrapper.find('.ix-icon-up').exists()).toBeTruthy()
+    expect(wrapper.find('.ix-icon-loading').exists()).toBeFalsy()
+    expect(wrapper.classes()).not.toContain('ix-button-loading')
+
+    await wrapper.setProps({ loading: true })
+
+    expect(wrapper.find('.ix-icon-up').exists()).toBeFalsy()
+    expect(wrapper.find('.ix-icon-loading').exists()).toBeTruthy()
+    expect(wrapper.classes()).toContain('ix-button-loading')
+  })
+
+  test('size work', async () => {
+    const wrapper = buttonMount({ props: { size: 'large' } })
+
+    expect(wrapper.classes()).toContain('ix-button-large')
+
+    await wrapper.setProps({ size: 'small' })
+
+    expect(wrapper.classes()).not.toContain('ix-button-large')
+    expect(wrapper.classes()).toContain('ix-button-small')
+  })
+
+  test('shape work', async () => {
+    const wrapper = buttonMount({ props: { shape: 'circle' } })
+
+    expect(wrapper.classes()).toContain('ix-button-circle')
+
+    await wrapper.setProps({ shape: 'round' })
+
+    expect(wrapper.classes()).not.toContain('ix-button-circle')
+    expect(wrapper.classes()).toContain('ix-button-round')
+  })
+
+  test('block work', async () => {
+    const wrapper = buttonMount()
+
+    expect(wrapper.classes()).not.toContain('ix-button-block')
+
+    await wrapper.setProps({ block: true })
+
+    expect(wrapper.classes()).toContain('ix-button-block')
+  })
+
+  test('icon work', async () => {
+    const wrapper = buttonMount({ props: { icon: 'up' } })
+
+    expect(wrapper.find('.ix-icon-up').exists()).toBeTruthy()
+
+    await wrapper.setProps({ icon: 'down' })
+
+    expect(wrapper.find('.ix-icon-down').exists()).toBeTruthy()
+  })
+
+  test('slot work', async () => {
+    const text = 'Button'
+    const wrapper = buttonMount({
+      slots: {
+        default: text,
+      },
+    })
+    expect(wrapper.text()).toEqual(text)
+  })
+})
diff --git a/packages/components/button/__tests__/buttonGroup.spec.ts b/packages/components/button/__tests__/buttonGroup.spec.ts
new file mode 100644
index 00000000..a77e1191
--- /dev/null
+++ b/packages/components/button/__tests__/buttonGroup.spec.ts
@@ -0,0 +1,68 @@
+import { ButtonMode, ComponentSize } from '@idux/components/core/types'
+import { mount } from '@vue/test-utils'
+import { PropType } from 'vue'
+import ButtonGroup from '../src/ButtonGroup.vue'
+import Button from '../src/Button.vue'
+import { ButtonShape } from '../src/types'
+
+const TestComponent = {
+  components: { ButtonGroup, Button },
+  template: `
+  <ButtonGroup :mode="mode" :size="size" :shape="shape">
+    <Button>default</Button>
+    <Button mode="primary">primary</Button>
+    <Button size="large">large</Button>
+    <Button shape="circle">circle</Button>
+  </ButtonGroup>
+  `,
+  props: {
+    mode: String as PropType<ButtonMode>,
+    size: String as PropType<ComponentSize>,
+    shape: String as PropType<ButtonShape>,
+  },
+}
+
+describe('ButtonGroup.vue', () => {
+  test('render work', () => {
+    const wrapper = mount(TestComponent)
+    expect(wrapper.classes()).toContain('ix-button-group')
+    expect(wrapper.findAll('.ix-button').length).toBe(4)
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+
+  test('mode work', async () => {
+    const wrapper = mount(TestComponent)
+
+    expect(wrapper.findAll('.ix-button-primary').length).toBe(1)
+    expect(wrapper.findAll('.ix-button-dashed').length).toBe(0)
+
+    await wrapper.setProps({ mode: 'dashed' })
+
+    expect(wrapper.findAll('.ix-button-primary').length).toBe(1)
+    expect(wrapper.findAll('.ix-button-dashed').length).toBe(3)
+  })
+
+  test('size work', async () => {
+    const wrapper = mount(TestComponent)
+
+    expect(wrapper.findAll('.ix-button-large').length).toBe(1)
+    expect(wrapper.findAll('.ix-button-small').length).toBe(0)
+
+    await wrapper.setProps({ size: 'small' })
+
+    expect(wrapper.findAll('.ix-button-large').length).toBe(1)
+    expect(wrapper.findAll('.ix-button-small').length).toBe(3)
+  })
+
+  test('shape work', async () => {
+    const wrapper = mount(TestComponent)
+
+    expect(wrapper.findAll('.ix-button-circle').length).toBe(1)
+    expect(wrapper.findAll('.ix-button-round').length).toBe(0)
+
+    await wrapper.setProps({ shape: 'round' })
+
+    expect(wrapper.findAll('.ix-button-circle').length).toBe(1)
+    expect(wrapper.findAll('.ix-button-round').length).toBe(3)
+  })
+})