Repository: IduxFE/components
Pull request #101 (2021-01-03 02:47:03)

feat(comp: spin):  add spin component
## 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.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


codecov[bot]: # [Codecov](https://codecov.io/gh/IduxFE/components/pull/101?src=pr&el=h1) Report
> Merging [#101](https://codecov.io/gh/IduxFE/components/pull/101?src=pr&el=desc) (fe511e3) into [main](https://codecov.io/gh/IduxFE/components/commit/1877c65d37695f87e2a87c871f9f1684126904d4?el=desc) (1877c65) will **increase** coverage by `0.05%`.
> The diff coverage is `95.65%`.

[![Impacted file tree graph](https://codecov.io/gh/IduxFE/components/pull/101/graphs/tree.svg?width=650&height=150&src=pr&token=PGAUXP06V3)](https://codecov.io/gh/IduxFE/components/pull/101?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##             main     #101      +/-   ##
==========================================
+ Coverage   94.46%   94.52%   +0.05%     
==========================================
  Files          41       42       +1     
  Lines         488      511      +23     
  Branches       85       89       +4     
==========================================
+ Hits          461      483      +22     
  Misses         10       10              
- Partials       17       18       +1     
```


| [Impacted Files](https://codecov.io/gh/IduxFE/components/pull/101?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/components/spin/src/Spin.vue](https://codecov.io/gh/IduxFE/components/pull/101/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9zcGluL3NyYy9TcGluLnZ1ZQ==) | `95.45% <95.45%> (ø)` | |
| [packages/components/core/config/useGlobalConfig.ts](https://codecov.io/gh/IduxFE/components/pull/101/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9jb3JlL2NvbmZpZy91c2VHbG9iYWxDb25maWcudHM=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/IduxFE/components/pull/101?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/IduxFE/components/pull/101?src=pr&el=footer). Last update [1877c65...fe511e3](https://codecov.io/gh/IduxFE/components/pull/101?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

danranVm: PR 的 title 不符合规范，
commit 需要做一下合并，使用 rebase 而不是 merge 操作
另外 lint 的 CI 挂掉了，还需要在本地执行一下 `npm run lint-fix` 看看是哪里不对
danranVm: @liuzaijiang 感谢你的 PR， LGTM.

Patch:
diff --git a/jest.config.js b/jest.config.js
index 69b23647..f35ff5c7 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,7 +2,7 @@ module.exports = {
   // u can change this option to a more specific folder for test single component or util when dev
   // for example, ['<rootDir>/packages/components/button']
   roots: ['<rootDir>'],
-
+  
   testEnvironment: 'jsdom',
   transform: {
     '^.+\\.vue$': 'vue-jest',
diff --git a/packages/components/components.less b/packages/components/components.less
index 1c6a206f..0e7a84ec 100644
--- a/packages/components/components.less
+++ b/packages/components/components.less
@@ -3,4 +3,5 @@
 @import './badge/style/index.less';
 @import './divider/style/index.less';
 @import './image/style/index.less';
+@import './spin/style/index.less';
 
diff --git a/packages/components/core/config/types.ts b/packages/components/core/config/types.ts
index baeb2291..e5c19b19 100644
--- a/packages/components/core/config/types.ts
+++ b/packages/components/core/config/types.ts
@@ -1,4 +1,4 @@
-import type { ComponentSize, ButtonMode, DividerPosition, DividerType } from '../types'
+import type { ComponentSize, ButtonMode, DividerPosition, DividerType, SpinTipAlignType } from '../types'
 
 export type GlobalConfigKey = keyof GlobalConfig
 
@@ -8,6 +8,7 @@ export interface GlobalConfig {
   badge: BadgeConfig
   divider: DividerConfig
   image: ImageConfig
+  spin: SpinConfig
 }
 
 export interface ButtonConfig {
@@ -36,3 +37,10 @@ export interface ImageConfig {
   height: string | number
   fallback: string
 }
+
+export interface SpinConfig {
+  icon: string
+  tip: string
+  tipAlign: SpinTipAlignType
+  size: ComponentSize
+}
diff --git a/packages/components/core/config/useGlobalConfig.ts b/packages/components/core/config/useGlobalConfig.ts
index 37de7c66..94dff69e 100644
--- a/packages/components/core/config/useGlobalConfig.ts
+++ b/packages/components/core/config/useGlobalConfig.ts
@@ -8,6 +8,7 @@ import type {
   IconConfig,
   DividerConfig,
   ImageConfig,
+  SpinConfig,
 } from './types'
 
 const button = shallowReactive<ButtonConfig>({ mode: 'default', size: 'medium' })
@@ -27,12 +28,20 @@ const image: ImageConfig = shallowReactive({
     'data:image/png;base64,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',
 })
 
+const spin = shallowReactive<SpinConfig>({
+  icon: 'loading',
+  tip: '',
+  tipAlign: 'vertical',
+  size: 'small',
+})
+
 const defaultConfig: GlobalConfig = {
   button,
   icon,
   badge,
   divider,
   image,
+  spin,
 }
 
 const globalConfigToken: Record<GlobalConfigKey, symbol> = {
@@ -41,6 +50,7 @@ const globalConfigToken: Record<GlobalConfigKey, symbol> = {
   badge: Symbol(),
   divider: Symbol(),
   image: Symbol(),
+  spin: Symbol(),
 }
 
 /**
diff --git a/packages/components/core/types/index.ts b/packages/components/core/types/index.ts
index b3a02837..17d999fe 100644
--- a/packages/components/core/types/index.ts
+++ b/packages/components/core/types/index.ts
@@ -5,3 +5,5 @@ export type ButtonMode = 'primary' | 'default' | 'dashed' | 'text' | 'link'
 export type DividerPosition = 'left' | 'center' | 'right'
 
 export type DividerType = 'horizontal' | 'vertical'
+
+export type SpinTipAlignType = 'horizontal' | 'vertical'
diff --git a/packages/components/index.ts b/packages/components/index.ts
index d2af7851..55bd79f5 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -4,8 +4,9 @@ import { IxIcon } from './icon'
 import { IxBadge } from './badge'
 import { IxDivider } from './divider'
 import { IxImage } from './image'
+import { IxSpin } from './spin'
 
-const components = [IxButton, IxButtonGroup, IxIcon, IxBadge, IxDivider, IxImage]
+const components = [IxButton, IxButtonGroup, IxIcon, IxBadge, IxDivider, IxImage, IxSpin]
 
 const install = (app: App): void => {
   components.forEach(component => {
diff --git a/packages/components/spin/demo/Basic.vue b/packages/components/spin/demo/Basic.vue
new file mode 100644
index 00000000..9c081b00
--- /dev/null
+++ b/packages/components/spin/demo/Basic.vue
@@ -0,0 +1,43 @@
+<template>
+  <div>
+    <ix-spin :spinning="spinning">
+      <div class="content">content</div>
+    </ix-spin>
+    <div class="operation-area">
+      <ix-button mode="primary" @click="changeSpinning()">切换状态</ix-button>
+      <span class="status-text">spinning: {{ spinning }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from 'vue'
+
+export default defineComponent({
+  setup() {
+    const spinning = ref(true)
+
+    const changeSpinning = () => (spinning.value = !spinning.value)
+
+    return {
+      spinning,
+      changeSpinning,
+    }
+  },
+})
+</script>
+<style lang="less" scoped>
+.content {
+  text-align: center;
+  padding: 30px;
+  background: #6e8ef1;
+}
+
+.operation-area {
+  margin-top: 30px;
+  .status-text {
+    margin: 10px 0;
+    display: block;
+  }
+}
+</style>
diff --git a/packages/components/spin/demo/Icon.vue b/packages/components/spin/demo/Icon.vue
new file mode 100644
index 00000000..fb230e75
--- /dev/null
+++ b/packages/components/spin/demo/Icon.vue
@@ -0,0 +1,39 @@
+<template>
+  <div>
+    <ix-spin :icon="icon">
+      <div class="content">content</div>
+    </ix-spin>
+    <div class="operation-area">
+      <span class="status-text">icon: {{ icon }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from 'vue'
+
+export default defineComponent({
+  setup() {
+    const icon = ref('up')
+
+    return {
+      icon,
+    }
+  },
+})
+</script>
+<style lang="less" scoped>
+.content {
+  text-align: center;
+  padding: 30px;
+  background: #6e8ef1;
+}
+
+.operation-area {
+  margin-top: 30px;
+  .status-text {
+    margin: 10px 0;
+    display: block;
+  }
+}
+</style>
diff --git a/packages/components/spin/demo/Size.vue b/packages/components/spin/demo/Size.vue
new file mode 100644
index 00000000..968a7f66
--- /dev/null
+++ b/packages/components/spin/demo/Size.vue
@@ -0,0 +1,51 @@
+<template>
+  <div>
+    <ix-spin :tip="tip" :tipAlign="tipAlign" :size="size">
+      <div class="content">content</div>
+    </ix-spin>
+    <div class="operation-area">
+      <ix-button mode="primary" @click="size = 'small'">small</ix-button>
+      <ix-button mode="primary" @click="size = 'medium'">medium</ix-button>
+      <ix-button mode="primary" @click="size = 'large'">large</ix-button>
+      <span class="status-text">size: {{ size }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent, computed } from 'vue'
+
+export default defineComponent({
+  setup() {
+    let tipAlignIsHorizontal = ref(true)
+
+    const tip = ref('数据加载中，请等待')
+    const tipAlign = computed(() => (tipAlignIsHorizontal.value ? 'horizontal' : 'vertical'))
+    const size = ref('small')
+
+    return {
+      tip,
+      tipAlign,
+      size,
+    }
+  },
+})
+</script>
+<style lang="less" scoped>
+.content {
+  text-align: center;
+  padding: 30px;
+  background: #6e8ef1;
+}
+
+.operation-area {
+  margin-top: 30px;
+  .status-text {
+    margin: 10px 0;
+    display: block;
+  }
+  /deep/.ix-button {
+    margin-right: 4px;
+  }
+}
+</style>
diff --git a/packages/components/spin/demo/Tip.vue b/packages/components/spin/demo/Tip.vue
new file mode 100644
index 00000000..d524deb3
--- /dev/null
+++ b/packages/components/spin/demo/Tip.vue
@@ -0,0 +1,46 @@
+<template>
+  <div>
+    <ix-spin :tip="tip" :tipAlign="tipAlign">
+      <div class="content">content</div>
+    </ix-spin>
+    <div class="operation-area">
+      <ix-button mode="primary" @click="changeTipAlign()">切换提示文字排列方式</ix-button>
+      <span class="status-text">tipAlign: {{ tipAlign }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent, computed } from 'vue'
+
+export default defineComponent({
+  setup() {
+    let tipAlignIsHorizontal = ref(true)
+
+    const tip = ref('数据加载中，请等待')
+    const tipAlign = computed(() => (tipAlignIsHorizontal.value ? 'horizontal' : 'vertical'))
+    const changeTipAlign = () => (tipAlignIsHorizontal.value = !tipAlignIsHorizontal.value)
+
+    return {
+      tip,
+      tipAlign,
+      changeTipAlign,
+    }
+  },
+})
+</script>
+<style lang="less" scoped>
+.content {
+  text-align: center;
+  padding: 30px;
+  background: #6e8ef1;
+}
+
+.operation-area {
+  margin-top: 30px;
+  .status-text {
+    margin: 10px 0;
+    display: block;
+  }
+}
+</style>
diff --git a/packages/components/spin/demo/basic.md b/packages/components/spin/demo/basic.md
new file mode 100644
index 00000000..c4283d73
--- /dev/null
+++ b/packages/components/spin/demo/basic.md
@@ -0,0 +1,12 @@
+---
+order: 0
+title:
+  zh: 基本使用
+  en: Basic usage
+---
+
+## zh
+
+## en
+
+## demo
diff --git a/packages/components/spin/demo/icon.md b/packages/components/spin/demo/icon.md
new file mode 100644
index 00000000..028c53fc
--- /dev/null
+++ b/packages/components/spin/demo/icon.md
@@ -0,0 +1,14 @@
+---
+order: 2
+title:
+  zh: 图标
+  en: icon
+---
+
+## zh
+与icon相关属性使用
+
+## en
+Use of icon related attributes
+
+## demo
diff --git a/packages/components/spin/demo/icon.vue b/packages/components/spin/demo/icon.vue
new file mode 100644
index 00000000..fb230e75
--- /dev/null
+++ b/packages/components/spin/demo/icon.vue
@@ -0,0 +1,39 @@
+<template>
+  <div>
+    <ix-spin :icon="icon">
+      <div class="content">content</div>
+    </ix-spin>
+    <div class="operation-area">
+      <span class="status-text">icon: {{ icon }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from 'vue'
+
+export default defineComponent({
+  setup() {
+    const icon = ref('up')
+
+    return {
+      icon,
+    }
+  },
+})
+</script>
+<style lang="less" scoped>
+.content {
+  text-align: center;
+  padding: 30px;
+  background: #6e8ef1;
+}
+
+.operation-area {
+  margin-top: 30px;
+  .status-text {
+    margin: 10px 0;
+    display: block;
+  }
+}
+</style>
diff --git a/packages/components/spin/demo/size.md b/packages/components/spin/demo/size.md
new file mode 100644
index 00000000..2e461213
--- /dev/null
+++ b/packages/components/spin/demo/size.md
@@ -0,0 +1,14 @@
+---
+order: 3
+title:
+  zh: 尺寸
+  en: size
+---
+
+## zh
+与size相关属性使用, 用来控制tip和icon的大小，支持`small \| medium \| large` 三种选项
+
+## en
+Use of size related attributes
+
+## demo
diff --git a/packages/components/spin/demo/size.vue b/packages/components/spin/demo/size.vue
new file mode 100644
index 00000000..968a7f66
--- /dev/null
+++ b/packages/components/spin/demo/size.vue
@@ -0,0 +1,51 @@
+<template>
+  <div>
+    <ix-spin :tip="tip" :tipAlign="tipAlign" :size="size">
+      <div class="content">content</div>
+    </ix-spin>
+    <div class="operation-area">
+      <ix-button mode="primary" @click="size = 'small'">small</ix-button>
+      <ix-button mode="primary" @click="size = 'medium'">medium</ix-button>
+      <ix-button mode="primary" @click="size = 'large'">large</ix-button>
+      <span class="status-text">size: {{ size }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent, computed } from 'vue'
+
+export default defineComponent({
+  setup() {
+    let tipAlignIsHorizontal = ref(true)
+
+    const tip = ref('数据加载中，请等待')
+    const tipAlign = computed(() => (tipAlignIsHorizontal.value ? 'horizontal' : 'vertical'))
+    const size = ref('small')
+
+    return {
+      tip,
+      tipAlign,
+      size,
+    }
+  },
+})
+</script>
+<style lang="less" scoped>
+.content {
+  text-align: center;
+  padding: 30px;
+  background: #6e8ef1;
+}
+
+.operation-area {
+  margin-top: 30px;
+  .status-text {
+    margin: 10px 0;
+    display: block;
+  }
+  /deep/.ix-button {
+    margin-right: 4px;
+  }
+}
+</style>
diff --git a/packages/components/spin/demo/tip.md b/packages/components/spin/demo/tip.md
new file mode 100644
index 00000000..1a84d26e
--- /dev/null
+++ b/packages/components/spin/demo/tip.md
@@ -0,0 +1,12 @@
+---
+order: 1
+title:
+  zh: 提示文字
+  en: tip
+---
+
+## zh
+
+## en
+
+## demo
diff --git a/packages/components/spin/demo/tip.vue b/packages/components/spin/demo/tip.vue
new file mode 100644
index 00000000..d524deb3
--- /dev/null
+++ b/packages/components/spin/demo/tip.vue
@@ -0,0 +1,46 @@
+<template>
+  <div>
+    <ix-spin :tip="tip" :tipAlign="tipAlign">
+      <div class="content">content</div>
+    </ix-spin>
+    <div class="operation-area">
+      <ix-button mode="primary" @click="changeTipAlign()">切换提示文字排列方式</ix-button>
+      <span class="status-text">tipAlign: {{ tipAlign }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent, computed } from 'vue'
+
+export default defineComponent({
+  setup() {
+    let tipAlignIsHorizontal = ref(true)
+
+    const tip = ref('数据加载中，请等待')
+    const tipAlign = computed(() => (tipAlignIsHorizontal.value ? 'horizontal' : 'vertical'))
+    const changeTipAlign = () => (tipAlignIsHorizontal.value = !tipAlignIsHorizontal.value)
+
+    return {
+      tip,
+      tipAlign,
+      changeTipAlign,
+    }
+  },
+})
+</script>
+<style lang="less" scoped>
+.content {
+  text-align: center;
+  padding: 30px;
+  background: #6e8ef1;
+}
+
+.operation-area {
+  margin-top: 30px;
+  .status-text {
+    margin: 10px 0;
+    display: block;
+  }
+}
+</style>
diff --git a/packages/components/spin/docs/index.zh.md b/packages/components/spin/docs/index.zh.md
new file mode 100644
index 00000000..777cdbf0
--- /dev/null
+++ b/packages/components/spin/docs/index.zh.md
@@ -0,0 +1,42 @@
+---
+category: components
+type: 通用
+title: Spin
+subtitle: 加载中遮罩层
+cover:
+---
+提供了数据在加载等待过程中的遮罩层
+
+## 何时使用
+
+当部分区域正在等待异步数据或在呈现过程中，让用户能更好感知数据请求和界面渲染，而且也能阻止用户在数据请求过程中对视图层有其他操作导致一些不必要的错误；
+
+## API
+
+### `ix-spin`
+
+#### props
+
+| 参数 | 说明 |  类型  | 默认值 | 全局配置 | 备注 |
+| --- | --- | --- | --- | --- | --- |
+|`size` | 控制 icon 和 tip大小 |  `small \| medium \| large`  | `small` | ✅ | - |
+|`spinning` | 是否显示加载遮罩层|`boolean`| `true` | - | - |
+| `icon`| 加载图标名称 | `string` | `loading`| ✅ | - |
+| `tip`| 加载提示文字描述 |  `string`  | `''`| ✅ | - |
+| `tipAlign`| 文字描述与加载图标的排列方式 | `vertical \| horizontal` | `vertical`| ✅ | vertical：文字排列在图标下方；horizontal： 文字排列在图标同水平方向的后方 |horizontal： 文字排列在图标同水平方向的后方)|
+
+#### slots
+
+|名称 | 说明 | 参数类型 | 备注 |
+| --- | --- | --- | --- |
+|`defalut` | 需要遮罩的内容区域 | - | - |
+
+### 主题变量
+
+| 变量名 | default 主题| 说明 |
+| --- | --- | --- |
+| @spin-tip-color | @primary-color | - |
+| @spin-icon-color | @primary-color | - |
+| @spin-font-size-sm | @font-size-sm | - |
+| @spin-font-size-md | @font-size-md | - |
+| @spin-font-size-lg | @font-size-lg | - |
diff --git a/packages/components/spin/index.ts b/packages/components/spin/index.ts
new file mode 100644
index 00000000..a7b17cf9
--- /dev/null
+++ b/packages/components/spin/index.ts
@@ -0,0 +1,7 @@
+import { installComponent } from '@idux/components/core/utils'
+import IxSpin from './src/Spin.vue'
+
+IxSpin.install = installComponent(IxSpin)
+
+export { IxSpin }
+export type { IxSpinComponent } from './src/types'
diff --git a/packages/components/spin/src/Spin.vue b/packages/components/spin/src/Spin.vue
new file mode 100644
index 00000000..2e304bb4
--- /dev/null
+++ b/packages/components/spin/src/Spin.vue
@@ -0,0 +1,61 @@
+<template>
+  <div class="ix-spin">
+    <div v-if="spinning" class="ix-spin-spinner" :class="spinnerClassName">
+      <ix-icon :name="icon$$" class="ix-spin-spinner-icon" rotate></ix-icon>
+      <div v-if="tip$$" class="ix-spin-spinner-tip">
+        {{ tip$$ }}
+      </div>
+    </div>
+    <div v-if="hasDefaultSlot" class="ix-spin-container" :class="containerClassName">
+      <slot></slot>
+    </div>
+  </div>
+</template>
+<script lang="ts">
+import { computed, ref, onUpdated, defineComponent } from 'vue'
+import { SpinProps } from './types'
+import { useGlobalConfig } from '@idux/components/core/config'
+import { PropTypes } from '@idux/cdk/utils'
+
+import { IxIcon } from '@idux/components/icon'
+
+export default defineComponent({
+  name: 'IxSpin',
+  components: { IxIcon },
+  props: {
+    spinning: PropTypes.bool.def(true),
+    icon: PropTypes.string,
+    tip: PropTypes.string,
+    tipAlign: PropTypes.oneOf(['horizontal', 'vertical'] as const),
+    size: PropTypes.oneOf(['large', 'medium', 'small'] as const),
+  },
+
+  setup(props: SpinProps, { slots }) {
+    const spinConfig = useGlobalConfig('spin')
+
+    const icon$$ = computed(() => props.icon ?? spinConfig.icon)
+    const tip$$ = computed(() => props.tip ?? spinConfig.tip)
+
+    const hasDefaultSlot = ref(!!slots.default)
+    onUpdated(() => {
+      hasDefaultSlot.value = !!slots.default
+    })
+
+    const spinnerClassName = computed(() => {
+      const size = props.size ?? spinConfig.size
+      const tipAlign = props.tipAlign ?? spinConfig.tipAlign
+      return [`ix-spin-spinner-tip-${tipAlign}`, size !== 'medium' ? `ix-spin-spinner-${size}` : '']
+    })
+
+    const containerClassName = computed(() => [hasDefaultSlot.value && props.spinning && 'ix-spin-container-blur'])
+
+    return {
+      tip$$,
+      icon$$,
+      spinnerClassName,
+      containerClassName,
+      hasDefaultSlot,
+    }
+  },
+})
+</script>
diff --git a/packages/components/spin/src/types.ts b/packages/components/spin/src/types.ts
new file mode 100644
index 00000000..731a8f26
--- /dev/null
+++ b/packages/components/spin/src/types.ts
@@ -0,0 +1,11 @@
+export interface SpinProps {
+  // please add readonly for every prop
+  readonly spinning?: boolean
+  readonly icon?: string
+  readonly tip?: string
+  readonly tipAlign?: 'vertical' | 'horizontal'
+  readonly size?: 'large' | 'medium' | 'small'
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface IxSpinComponent extends SpinProps {}
diff --git a/packages/components/spin/style/index.less b/packages/components/spin/style/index.less
new file mode 100644
index 00000000..80bb602c
--- /dev/null
+++ b/packages/components/spin/style/index.less
@@ -0,0 +1,89 @@
+@import '../../style/default.less';
+
+@spin-prefix: ~'@{idux-prefix}-spin';
+
+@spin-mask: {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  user-select: none;
+}
+
+.@{spin-prefix} {
+  position: relative;
+
+  &-spinner {
+    @spin-mask();
+    z-index: 10;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    &-tip-vertical {
+      flex-direction: column;
+      width: 100%;
+      text-align: center;
+
+
+      .@{spin-prefix}-spinner-tip {
+        margin-top: @margin-xs;
+        min-height: @height-md;
+        line-height: @height-md;
+      }
+    }
+
+    &-tip-horizontal {
+      .@{spin-prefix}-spinner-tip {
+        margin-left: @margin-xs;
+      }
+    }
+
+    &-small {
+      .@{spin-prefix}-spinner {
+
+        &-icon,
+        &-tip {
+          font-size: @spin-font-size-sm;
+        }
+      }
+    }
+
+    &-large {
+      .@{spin-prefix}-spinner {
+
+        &-icon,
+        &-tip {
+          font-size: @spin-font-size-lg;
+        }
+      }
+    }
+
+    &-tip {
+      color: @spin-tip-color;
+      font-size: @spin-font-size-md;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap
+    }
+
+    &-icon {
+      color: @spin-icon-color;
+      font-size: @spin-font-size-md;
+    }
+  }
+
+  &-container {
+    position: relative;
+    transition: opacity .5s;
+
+    &-blur {
+      opacity: 0.5;
+      user-select: none;
+      clear: both;
+      overflow: hidden;
+      background-color: #ddd;
+    }
+  }
+}
diff --git a/packages/components/style/default.less b/packages/components/style/default.less
index 0c2e2d3e..f77e895f 100644
--- a/packages/components/style/default.less
+++ b/packages/components/style/default.less
@@ -72,3 +72,12 @@
 
 @button-text-color: @text-color;
 @button-text-hover-bg: rgba(0, 0, 0, 0.12);
+
+
+/* spin -------------------------- */
+@spin-tip-color: @primary-color;
+@spin-icon-color: @primary-color;
+
+@spin-font-size-sm: @font-size-sm;
+@spin-font-size-md: @font-size-md;
+@spin-font-size-lg: @font-size-lg;
\ No newline at end of file


Test patch:
diff --git a/packages/components/spin/__tests__/__snapshots__/spin.spec.ts.snap b/packages/components/spin/__tests__/__snapshots__/spin.spec.ts.snap
new file mode 100644
index 00000000..144def30
--- /dev/null
+++ b/packages/components/spin/__tests__/__snapshots__/spin.spec.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Spin.vue render work 1`] = `"<div class=\\"ix-spin\\"><div class=\\"ix-spin-spinner ix-spin-spinner-tip-vertical ix-spin-spinner-small\\"><i class=\\"ix-icon ix-icon-loading ix-icon-spin ix-spin-spinner-icon\\" role=\\"img\\" arialabel=\\"loading\\"></i><!--v-if--></div><!--v-if--></div>"`;
diff --git a/packages/components/spin/__tests__/spin.spec.ts b/packages/components/spin/__tests__/spin.spec.ts
new file mode 100644
index 00000000..283e0479
--- /dev/null
+++ b/packages/components/spin/__tests__/spin.spec.ts
@@ -0,0 +1,146 @@
+import { mount, MountingOptions, VueWrapper } from '@vue/test-utils'
+import { DefineComponent } from 'vue'
+import IxSpin from '../src/Spin.vue'
+import { SpinProps } from '../src/types'
+
+describe('Spin.vue', () => {
+  let SpinMount: (options?: MountingOptions<Partial<SpinProps>>) => VueWrapper<InstanceType<DefineComponent<SpinProps>>>
+
+  beforeEach(() => {
+    SpinMount = (options = {}) => {
+      return mount<SpinProps>(IxSpin, {
+        ...options,
+      })
+    }
+  })
+
+  test('render work', () => {
+    const wrapper = SpinMount()
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+
+  test('spinning work', async () => {
+    const wrapper = SpinMount({ props: { spinning: false } })
+
+    expect(wrapper.find('.ix-spin-spinner').exists()).toBeFalsy()
+
+    await wrapper.setProps({ spinning: true })
+
+    expect(wrapper.find('.ix-spin-spinner').exists()).toBeTruthy()
+  })
+
+  test('size work', async () => {
+    const wrapper = SpinMount()
+
+    expect(wrapper.find('.ix-spin-spinner').classes()).toContain('ix-spin-spinner-small')
+
+    await wrapper.setProps({ size: 'medium' })
+
+    expect(wrapper.find('.ix-spin-spinner').classes()).not.toContain('ix-spin-spinner-small')
+    expect(wrapper.find('.ix-spin-spinner').classes()).not.toContain('ix-spin-spinner-medium')
+
+    await wrapper.setProps({ size: 'large' })
+
+    expect(wrapper.find('.ix-spin-spinner').classes()).toContain('ix-spin-spinner-large')
+  })
+
+  test('icon work', async () => {
+    const wrapper = SpinMount()
+
+    expect(wrapper.find('.ix-icon-loading').exists()).toBeTruthy()
+
+    await wrapper.setProps({ icon: 'up' })
+
+    expect(wrapper.find('.ix-icon-up').exists()).toBeTruthy()
+  })
+
+  test('tip work', async () => {
+    const wrapper = SpinMount()
+    const tip = 'loading'
+    expect(wrapper.find('.ix-spin-spinner-tip').exists()).toBeFalsy()
+
+    await wrapper.setProps({ tip })
+
+    const spinTipDom = wrapper.find('.ix-spin-spinner-tip')
+    expect(spinTipDom.exists()).toBeTruthy()
+
+    expect(spinTipDom.text()).toEqual(tip)
+  })
+
+  test('tipAlign work', async () => {
+    const wrapper = SpinMount({ props: { tip: 'loading' } })
+
+    expect(wrapper.find('.ix-spin-spinner-tip-vertical').exists()).toBeTruthy()
+
+    await wrapper.setProps({ tipAlign: 'horizontal' })
+
+    expect(wrapper.find('.ix-spin-spinner-tip-horizontal').exists()).toBeTruthy()
+  })
+
+  test('slot work', async () => {
+    const wrapper = SpinMount({
+      slots: {
+        default: '<div class="content">content</div>',
+      },
+    })
+    const defaultSlot = wrapper.find('.content')
+    const containerDom = wrapper.find('.ix-spin-container')
+
+    expect(containerDom.exists()).toBeTruthy()
+
+    expect(containerDom.classes()).toContain('ix-spin-container-blur')
+
+    expect(defaultSlot.exists()).toBeTruthy()
+  })
+
+  test('spinning with icon work', async () => {
+    const wrapper = SpinMount({
+      props: {
+        spinning: false,
+        icon: 'up',
+      },
+    })
+
+    expect(wrapper.find('.ix-icon-up').exists()).toBeFalsy()
+  })
+
+  test('spinning with size work', async () => {
+    const wrapper = SpinMount({
+      props: {
+        spinning: false,
+      },
+    })
+
+    expect(wrapper.find('.ix-spin-spinner').exists()).toBeFalsy()
+
+    expect(wrapper.find('.ix-spin-spinner-small').exists()).toBeFalsy()
+  })
+
+  test('spinning with tip work', async () => {
+    const wrapper = SpinMount({
+      props: {
+        spinning: false,
+        tip: 'loading',
+      },
+    })
+
+    expect(wrapper.find('.ix-spin-spinner-tip').exists()).toBeFalsy()
+
+    expect(wrapper.find('.ix-spin-spinner-tip-vertical').exists()).toBeFalsy()
+  })
+
+  test('spinning with slot work', async () => {
+    const wrapper = SpinMount({
+      props: {
+        spinning: false,
+      },
+      slots: {
+        default: '<div class="content">content</div>',
+      },
+    })
+
+    const containerDom = wrapper.find('.ix-spin-container')
+
+    expect(containerDom.classes()).not.toContain('ix-spin-container-blur')
+  })
+})