Repository: IduxFE/components (ts)

Issue:
[comp: space]: feature space component
- [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?
核心原理是通过 Flex 布局对组件内部的元素进行重新排布：

* 可以通过设置 `align` 设置不同的对齐方式：`start` | `center` | `end` | `baseline` ；
* 可以通过设置 `direction` 设置 **水平** 、**垂直** 两种间隔方式；
* 可以设置 `size` 设置间隔的大小，其中 size 有三种默认大小：`'small'|'middle'|'large' `，如遇到无法满足的情况，还可以传入一个 **数字** 自定义间隔大小。同时 size 允许传入一个数组，用来控制每个间隔的大小，数组的长度必须与传入的 children 长度 - 1 相等，否则会抛出警告；
* 可以以 slot 的形式传入一个 split 分隔符；
* 可以设置 `wrap` 来设置是否自动换行，仅在 `horizontal` 时有效。


### What does the proposed API look like?

#### props

| 参数 | 说明 | 类型 | 全局配置 | 默认值 | 备注 |
| ----- | ----- | ----- | ---------- | -------- | ----- |
| `align` | 对齐方式 | `start` \| `center` \| `end` \| `baseline` | - | `baseline` | - |
| `direction` | 间距方式 | `vertical` \|  `horizontal` | - | `horizontal` | - |
| `size` | 间距大小 | `Size` \| `Size[]` | `small` | - | 有三个预设间距大小，除此之外还可以传入一个数字自定义间距大小，还可以传入一个数组来控制每个间距的大小（数组长度需等于间距个数，否则组件会抛出警告） | 
| `split` | 设置拆分 | string | - | - | 设置间隔分割符，当传入 split 时，内部间距会被分隔符替代；除了这种方式，你还可以设置 `v-slot: split` 的方式设置分隔符，优先级高于 prop |
| `wrap` | 是否换行 | `boolean` | - | false | 仅在 `horizontal` 时生效 |

```typescript
type Size = 'small' | 'middle' | 'large' | number
```

#### slots
| 属性 | 说明 |
| ----- | ----- |
| `default` | 需要被间隔的内容 |
| `split` | 分隔符 |



Pull request:
feat(comp: space): add component space
fix #63 

## 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


Patch:
diff --git a/packages/cdk/utils/vNode.ts b/packages/cdk/utils/vNode.ts
index 98c81b26..06827800 100644
--- a/packages/cdk/utils/vNode.ts
+++ b/packages/cdk/utils/vNode.ts
@@ -1,6 +1,6 @@
 import type { VNode, VNodeChild } from 'vue'
 
-import { Comment, Fragment } from 'vue'
+import { Comment, Fragment, Slots } from 'vue'
 
 import { isUndefined } from './typeof'
 
@@ -20,8 +20,8 @@ function getChildren(node: VNode, depth: number): undefined | VNode {
 
 /**
  * get first valid child node (not fragment not comment)
- * @param nodes {VNode} node to be searched
- * @param maxDepth {number} depth to be searched, default is 3
+ * @param nodes node to be searched
+ * @param maxDepth depth to be searched, default is 3
  */
 export function getFirstValidNode(nodes?: VNodeChild, maxDepth = 3): ReturnType<typeof getChildren> {
   if (isUndefined(nodes)) return
@@ -33,9 +33,25 @@ export function getFirstValidNode(nodes?: VNodeChild, maxDepth = 3): ReturnType<
 
 /**
  * determine whether an element is valid (not fragment not comment)
- * @param node {VNode} node to be determined
- * @returns {boolean}
+ * @param node node to be determined
  */
 export function isValidElementNode(node: VNodeChild): boolean {
   return !(isFragment(node) || isComment(node))
 }
+
+/**
+ * get all child node (Whatever dynamic or not)
+ * @param slots slots of the component
+ * @param key key of slots, default is 'default'
+ * @param options the property of the render function
+ */
+export function getSlotNodes(slots: Slots, key = 'default', options: unknown[] = []): VNode[] {
+  if (!slots[key]) return []
+
+  let vNodes = slots[key]!(options)
+  if (vNodes.length === 1 && isFragment(vNodes[0])) {
+    vNodes = vNodes[0].dynamicChildren ?? []
+  }
+
+  return vNodes
+}
diff --git a/packages/components/components.less b/packages/components/components.less
index 0e7a84ec..f2f648dd 100644
--- a/packages/components/components.less
+++ b/packages/components/components.less
@@ -4,4 +4,4 @@
 @import './divider/style/index.less';
 @import './image/style/index.less';
 @import './spin/style/index.less';
-
+@import './space/style/index.less';
diff --git a/packages/components/core/config/defaultConfig.ts b/packages/components/core/config/defaultConfig.ts
index 00bc3989..c017b6aa 100644
--- a/packages/components/core/config/defaultConfig.ts
+++ b/packages/components/core/config/defaultConfig.ts
@@ -6,6 +6,7 @@ import type {
   DividerConfig,
   ImageConfig,
   SpinConfig,
+  SpaceConfig,
 } from './types'
 
 import { shallowReactive } from 'vue'
@@ -37,6 +38,8 @@ const spin = shallowReactive<SpinConfig>({
   size: 'small',
 })
 
+const space = shallowReactive<SpaceConfig>({ size: 'small' })
+
 export const defaultConfig: GlobalConfig = {
   button,
   icon,
@@ -44,4 +47,5 @@ export const defaultConfig: GlobalConfig = {
   divider,
   image,
   spin,
+  space,
 }
diff --git a/packages/components/core/config/types.ts b/packages/components/core/config/types.ts
index db9328ba..34f91bac 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, SpinTipAlignType } from '../types'
+import type { ComponentSize, ButtonMode, DividerPosition, DividerType, SpinTipAlignType, SpaceSize } from '../types'
 
 export type GlobalConfigKey = keyof GlobalConfig
 
@@ -9,6 +9,7 @@ export interface GlobalConfig {
   divider: DividerConfig
   image: ImageConfig
   spin: SpinConfig
+  space: SpaceConfig
 }
 
 export interface ButtonConfig {
@@ -45,3 +46,7 @@ export interface SpinConfig {
   tipAlign: SpinTipAlignType
   size: ComponentSize
 }
+
+export interface SpaceConfig {
+  size: SpaceSize
+}
diff --git a/packages/components/core/types/index.ts b/packages/components/core/types/index.ts
index 17d999fe..de990ed6 100644
--- a/packages/components/core/types/index.ts
+++ b/packages/components/core/types/index.ts
@@ -7,3 +7,5 @@ export type DividerPosition = 'left' | 'center' | 'right'
 export type DividerType = 'horizontal' | 'vertical'
 
 export type SpinTipAlignType = 'horizontal' | 'vertical'
+
+export type SpaceSize = 'small' | 'medium' | 'large' | number
diff --git a/packages/components/index.ts b/packages/components/index.ts
index c14029f8..112441db 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -5,8 +5,9 @@ import { IxBadge } from './badge'
 import { IxDivider } from './divider'
 import { IxImage } from './image'
 import { IxSpin } from './spin'
+import { IxSpace } from './space'
 
-const components = [IxButton, IxButtonGroup, IxIcon, IxBadge, IxDivider, IxImage, IxSpin]
+const components = [IxButton, IxButtonGroup, IxIcon, IxBadge, IxDivider, IxImage, IxSpin, IxSpace]
 
 const install = (app: App): void => {
   components.forEach(component => {
@@ -30,3 +31,4 @@ export * from './badge'
 export * from './divider'
 export * from './image'
 export * from './spin'
+export * from './space'
diff --git a/packages/components/space/demo/CustomSize.vue b/packages/components/space/demo/CustomSize.vue
new file mode 100644
index 00000000..3d8e45ee
--- /dev/null
+++ b/packages/components/space/demo/CustomSize.vue
@@ -0,0 +1,25 @@
+<template>
+  <ix-space :size="size">
+    Space
+    <ix-button mode="primary">Button</ix-button>
+    <ix-button>Button</ix-button>
+  </ix-space>
+  <br />
+  <input v-model="num" type="number" />
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, ref } from 'vue'
+import { IxButton, IxSpace } from '@idux/components'
+
+export default defineComponent({
+  name: 'CustomSize',
+  components: { IxButton, IxSpace },
+  setup() {
+    const num = ref(20)
+    const size = computed(() => +num.value)
+
+    return { num, size }
+  },
+})
+</script>
diff --git a/packages/components/space/demo/Size.vue b/packages/components/space/demo/Size.vue
new file mode 100644
index 00000000..e80cc78b
--- /dev/null
+++ b/packages/components/space/demo/Size.vue
@@ -0,0 +1,27 @@
+<template>
+  <ix-space :size="size">
+    Space
+    <ix-button>Button</ix-button>
+    <ix-button mode="primary">Button</ix-button>
+  </ix-space>
+  <br />
+  <select v-model="size">
+    <option value="small">small</option>
+    <option value="medium">medium</option>
+    <option value="large">large</option>
+  </select>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue'
+import { IxButton, IxSpace } from '@idux/components'
+
+export default defineComponent({
+  name: 'Size',
+  components: { IxButton, IxSpace },
+  setup() {
+    const size = ref('small')
+    return { size }
+  },
+})
+</script>
diff --git a/packages/components/space/demo/Wrap.vue b/packages/components/space/demo/Wrap.vue
new file mode 100644
index 00000000..53a567d6
--- /dev/null
+++ b/packages/components/space/demo/Wrap.vue
@@ -0,0 +1,15 @@
+<template>
+  <ix-space :wrap="true">
+    <ix-button v-for="item in 20" :key="item">Button</ix-button>
+  </ix-space>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { IxButton, IxSpace } from '@idux/components'
+
+export default defineComponent({
+  name: 'Wrap',
+  components: { IxButton, IxSpace },
+})
+</script>
diff --git a/packages/components/space/demo/basic.md b/packages/components/space/demo/basic.md
new file mode 100644
index 00000000..7bd9884c
--- /dev/null
+++ b/packages/components/space/demo/basic.md
@@ -0,0 +1,23 @@
+---
+order: 0
+title:
+  zh: 基本用法
+  en: Basic usage
+---
+
+## zh
+
+相邻组件水平间距。
+
+## demo
+
+```html
+<template>
+  <ix-space>
+    Space
+    <ix-button mode='primary'>Button</ix-button>
+    <ix-button>Button</ix-button>
+    <ix-button mode='danger'>Button</ix-button>
+  </ix-space>
+</template>
+```
diff --git a/packages/components/space/demo/custom-size.md b/packages/components/space/demo/custom-size.md
new file mode 100644
index 00000000..18993b8e
--- /dev/null
+++ b/packages/components/space/demo/custom-size.md
@@ -0,0 +1,10 @@
+---
+order: 4
+title:
+  zh: 自定义间距
+  en: Custom space size
+---
+
+## zh
+
+自定义间距大小。
diff --git a/packages/components/space/demo/direction.md b/packages/components/space/demo/direction.md
new file mode 100644
index 00000000..584c75be
--- /dev/null
+++ b/packages/components/space/demo/direction.md
@@ -0,0 +1,25 @@
+---
+order: 1 
+title:
+  zh: 间距方向 
+  en: Space direction
+---
+
+## zh
+
+相邻组件垂直间距。
+
+可以设置 `width: 100%` 独占一行。
+
+## demo
+
+```html
+
+<template>
+  <ix-space direction='vertical'>
+    Space
+    <ix-button mode='primary'>Button</ix-button>
+    <ix-button>Button</ix-button>
+  </ix-space>
+</template>
+```
diff --git a/packages/components/space/demo/size.md b/packages/components/space/demo/size.md
new file mode 100644
index 00000000..399ea5b5
--- /dev/null
+++ b/packages/components/space/demo/size.md
@@ -0,0 +1,12 @@
+---
+order: 3
+title:
+  zh: 间距大小
+  en: Space size
+---
+
+## zh
+
+间距预设大、中、小三种大小。
+
+通过设置 `size` 为 `large` `medium` 分别把间距设为大、中间距。若不设置 `size` 或设置为 `small`，则间距为小。
diff --git a/packages/components/space/demo/split.md b/packages/components/space/demo/split.md
new file mode 100644
index 00000000..dc6508e5
--- /dev/null
+++ b/packages/components/space/demo/split.md
@@ -0,0 +1,22 @@
+---
+order: 6 
+title:
+  zh: 分隔符 
+  en: Divider
+---
+
+## zh
+
+相邻组件分隔符。
+
+## demo
+
+```html
+<template>
+  <ix-space split='/'>
+    <a href=''>link</a>
+    <a href=''>link</a>
+    <a href=''>link</a>
+  </ix-space>
+</template>
+```
diff --git a/packages/components/space/demo/wrap.md b/packages/components/space/demo/wrap.md
new file mode 100644
index 00000000..ee02ad09
--- /dev/null
+++ b/packages/components/space/demo/wrap.md
@@ -0,0 +1,10 @@
+---
+order: 5
+title:
+  zh: 换行
+  en: Wrap
+---
+
+## zh
+
+自动换行
diff --git a/packages/components/space/docs/index.zh.md b/packages/components/space/docs/index.zh.md
new file mode 100644
index 00000000..6f206867
--- /dev/null
+++ b/packages/components/space/docs/index.zh.md
@@ -0,0 +1,41 @@
+---
+category: components
+type: 通用
+title: Space
+subtitle: 间距
+cover:
+---
+
+设置组件之间的间距。
+
+## 何时使用
+
+避免组件紧贴在一起，拉开统一的空间。
+
+- 适合行内元素的水平间距。
+
+- 可以设置各种水平对齐方式。
+
+## API
+
+### `ix-space`
+
+#### props
+
+| 属性 | 说明 | 类型 | 默认值 | 全局配置 | 备注 |
+| --- | --- | --- | --- | --- | --- |
+| `align` | 对齐方式 | `start \| center \| end \| baseline` | `baseline` | - | - |
+| `direction` | 间距方向 | `vertical \| horizontal` | `horizontal` | - | - |
+| `size` | 间距大小 | `SpaceSize \| SpaceSize[]` | - | ✅ | 有三个预设间距大小，除此之外还可以传入一个数字自定义间距大小，还可以传入一个数组来控制每个间距的大小（数组长度需等于间距个数，否则组件会抛出警告） |
+| `split` | 设置拆分 | `string \| v-slot: split` | - | - | 设置间隔分割符，当传入 `split` 时，内部间距会被分隔符替代；除了这种方式，你还可以设置 `v-slot: split` 的方式设置分隔符，优先级高于 prop |
+| `wrap` | 是否换行 | `boolean` | `false` | - | 仅在 `horizontal` 时生效 |
+
+```typescript
+type SpaceSize = 'small' | 'medium' | 'large' | number
+```
+
+#### slots
+
+| 属性      | 说明             | 参数类型 | 备注 |
+| --------- | ---------------- | -------- | ---- |
+| `default` | 需要被间隔的内容 | -        | -    |
diff --git a/packages/components/space/index.ts b/packages/components/space/index.ts
new file mode 100644
index 00000000..df6d1874
--- /dev/null
+++ b/packages/components/space/index.ts
@@ -0,0 +1,7 @@
+import { installComponent } from '@idux/components/core/utils'
+import IxSpace from './src/Space.vue'
+
+IxSpace.install = installComponent(IxSpace)
+
+export { IxSpace }
+export type { IxSpaceComponent } from './src/types'
diff --git a/packages/components/space/src/Space.vue b/packages/components/space/src/Space.vue
new file mode 100644
index 00000000..2a911448
--- /dev/null
+++ b/packages/components/space/src/Space.vue
@@ -0,0 +1,103 @@
+<template>
+  <div class="ix-space" :class="className">
+    <template v-for="(child, index) in spaceList" :key="child.id">
+      <div class="ix-space-item" :class="child.className" :style="child.style">
+        <component :is="child.node" />
+      </div>
+      <slot v-if="index < spaceList.length - 1" name="split">
+        <span v-if="split" class="ix-space-split">
+          {{ split }}
+        </span>
+      </slot>
+    </template>
+  </div>
+</template>
+
+<script lang="ts">
+import type { ComputedRef, SetupContext, VNode } from 'vue'
+import type { SpaceProps } from './types'
+
+import { computed, defineComponent } from 'vue'
+import { getSlotNodes, PropTypes } from '@idux/cdk/utils'
+import { Logger } from '@idux/components/core/logger'
+import { useGlobalConfig, SpaceConfig } from '@idux/components/core/config'
+
+interface SpaceItem {
+  style?: Partial<CSSStyleDeclaration>
+  className?: string
+  id: number
+  node: VNode
+}
+
+const spaceSizePropType = PropTypes.oneOf(['small', 'medium', 'large'] as const)
+export default defineComponent({
+  name: 'IxSpace',
+  props: {
+    align: PropTypes.oneOf(['start', 'center', 'end', 'baseline'] as const).def('baseline'),
+    direction: PropTypes.oneOf(['vertical', 'horizontal'] as const).def('horizontal'),
+    size: PropTypes.oneOfType([
+      PropTypes.arrayOf(PropTypes.oneOfType([spaceSizePropType, PropTypes.number])),
+      PropTypes.oneOfType([spaceSizePropType, PropTypes.number]),
+    ]),
+    split: PropTypes.string,
+    wrap: PropTypes.bool,
+  },
+  setup(props: SpaceProps, { slots }: SetupContext) {
+    const spaceConfig = useGlobalConfig('space')
+    const children = getSlotNodes(slots)
+    const withSlotSplit = computed(() => !!slots.split)
+    const spaceList = useSpaceList(props, spaceConfig, children, withSlotSplit)
+
+    const className = useClassName(props)
+
+    return { spaceList, className }
+  },
+})
+
+function useSpaceList(
+  props: SpaceProps,
+  spaceConfig: SpaceConfig,
+  children: VNode[],
+  withSlotSplit: ComputedRef<boolean>,
+) {
+  return computed(() => {
+    const size = props.size ?? spaceConfig.size
+    const split = !!props.split || withSlotSplit.value
+
+    let spaceList: SpaceItem[]
+    if (Array.isArray(size)) {
+      if (children.length - 1 !== size.length) {
+        Logger.warn('The number of split elements is inconsistent with the length of the size array')
+      }
+      spaceList = children.map((child, index) => {
+        const current: SpaceItem = { node: child, id: index }
+        if (split) return current
+        else if (typeof size[index] === 'number') {
+          current.style = { marginRight: `${size[index]}px` }
+        } else current.className = `ix-space-item-${size[index]}`
+        return current
+      })
+    } else {
+      spaceList = children.map((child, index) => {
+        const current: SpaceItem = { node: child, id: index }
+        if (split) return current
+        else if (typeof size === 'number') {
+          current.style = { marginRight: `${size}px` }
+        } else current.className = `ix-space-item-${size}`
+        return current
+      })
+    }
+    return spaceList
+  })
+}
+
+function useClassName(props: SpaceProps) {
+  return computed(() => {
+    const align = props.align
+    const direction = props.direction
+    const wrap = props.wrap
+
+    return [`ix-space-${align}`, `ix-space-${direction}`, { 'ix-space-wrap': wrap }]
+  })
+}
+</script>
diff --git a/packages/components/space/src/types.ts b/packages/components/space/src/types.ts
new file mode 100644
index 00000000..f63dd327
--- /dev/null
+++ b/packages/components/space/src/types.ts
@@ -0,0 +1,26 @@
+import type { SpaceSize } from '@idux/components/core/types'
+
+export type SpaceAlign = 'start' | 'center' | 'end' | 'baseline'
+export type SpaceDirection = 'vertical' | 'horizontal'
+
+export interface SpaceProps {
+  /* Alignment direction of container */
+  align?: SpaceAlign
+  /* Spacing direction of flex item */
+  direction?: SpaceDirection
+  /**
+   * Spacing size.
+   * You can also pass in an array to customize the size of each spacing。
+   */
+  size?: SpaceSize | SpaceSize[]
+  /**
+   * Delimiter.
+   * You can also pass in a slot to customize the delimiter.
+   */
+  split?: string
+  /* Whether to wrap */
+  wrap?: boolean
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface IxSpaceComponent extends SpaceProps {}
diff --git a/packages/components/space/style/index.less b/packages/components/space/style/index.less
new file mode 100644
index 00000000..363b8d4d
--- /dev/null
+++ b/packages/components/space/style/index.less
@@ -0,0 +1,20 @@
+@import '../../style/default.less';
+@import "./mixins.less";
+
+@space-prefix: ~'@{idux-prefix}-space';
+@space-item-prefix: ~'@{space-prefix}-item';
+
+.@{space-prefix} {
+  display: inline-flex;
+
+  &-wrap {
+    flex-wrap: wrap;
+
+    .@{space-item-prefix} {
+      margin-bottom: @space-margin-md;
+    }
+  }
+}
+
+.space-align-traverse(@space-prefix);
+.space-direction-traverse(@space-prefix);
diff --git a/packages/components/space/style/mixins.less b/packages/components/space/style/mixins.less
new file mode 100644
index 00000000..3d7f6427
--- /dev/null
+++ b/packages/components/space/style/mixins.less
@@ -0,0 +1,70 @@
+@import '../../style/default.less';
+
+/* stylelint-disable property-no-unknown */
+@space-align: {
+  start: flex-start;
+  center: center;
+  end: flex-end;
+  baseline: baseline;
+}
+
+/* stylelint-enable property-no-unknown */
+
+.space-align-traverse(@prefix) {
+  each(@space-align, .(@value, @key) {
+    @space-item: ~'@{prefix}-@{key}';
+
+    .@{space-item} {
+      align-items: @value;
+    }
+  })
+}
+
+/* stylelint-disable property-no-unknown */
+@space-direction: {
+  horizontal: row;
+  vertical: column;
+}
+
+/* stylelint-enable property-no-unknown */
+
+.space-direction-traverse(@prefix) {
+  each(@space-direction, .(@value, @key) {
+    @space-item: ~'@{prefix}-@{key}';
+
+    .@{space-item} {
+      flex-direction: @value;
+
+      .@{prefix}-split {
+        margin: if(@key=horizontal, 0 @space-margin-sm, @space-margin-sm 0);
+      }
+
+      .space-size-traverse(@prefix, @key);
+    }
+  })
+}
+
+/* stylelint-disable property-no-unknown */
+@space-size: {
+  small: @space-margin-sm;
+  medium: @space-margin-md;
+  large: @space-margin-lg;
+}
+
+/* stylelint-enable property-no-unknown */
+
+.space-size-traverse(@prefix, @direction) {
+  each(@space-size, .(@value, @key) {
+    @space-item: ~'@{prefix}-item-@{key}';
+
+    .@{space-item} {
+      margin-right: if(@direction=horizontal, @value);
+      margin-bottom: if(@direction=vertical, @value);
+
+      &:last-child{
+        margin: 0;
+      }
+    }
+  })
+}
+
diff --git a/packages/components/style/default.less b/packages/components/style/default.less
index f77e895f..fa99f80f 100644
--- a/packages/components/style/default.less
+++ b/packages/components/style/default.less
@@ -80,4 +80,9 @@
 
 @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
+@spin-font-size-lg: @font-size-lg;
+
+/* space -------------------------- */
+@space-margin-sm: @margin-sm;
+@space-margin-md: @margin-lg;
+@space-margin-lg: @margin-xl;


Test patch:
diff --git a/packages/cdk/utils/__tests__/vNode.spec.ts b/packages/cdk/utils/__tests__/vNode.spec.ts
index a666b80f..a5a5c70a 100644
--- a/packages/cdk/utils/__tests__/vNode.spec.ts
+++ b/packages/cdk/utils/__tests__/vNode.spec.ts
@@ -1,13 +1,17 @@
 import type { VNode, VNodeChild } from 'vue'
 
-import { Comment, Fragment, Text } from 'vue'
+import { Comment, Fragment, Slots, Text } from 'vue'
 
-import { getFirstValidNode, isValidElementNode } from '../vNode'
+import { getFirstValidNode, getSlotNodes, isValidElementNode } from '../vNode'
 
 const TEMPLATE = 'template'
 
 type FakeVNode = Partial<Pick<VNode, 'type'>> & { children?: FakeVNode[] }
 
+type Writable<T> = {
+  -readonly [P in keyof T]: T[P]
+}
+
 const vNode: FakeVNode = {
   type: Comment,
 }
@@ -52,4 +56,19 @@ describe('vNode.ts', () => {
     vNode.type = Text
     expect(isValidElementNode(vNode as VNodeChild)).toBeTruthy()
   })
+
+  test('getSlotNodes work', () => {
+    const slots: Writable<Slots> = {}
+    expect(getSlotNodes(slots)).toEqual([])
+
+    slots.default = () => [{ type: Fragment, dynamicChildren: [] as VNode[] }] as VNode[]
+    expect(getSlotNodes(slots)).toEqual([])
+
+    slots.default = () => [{ type: Fragment }] as VNode[]
+    expect(getSlotNodes(slots)).toEqual([])
+
+    const vNodes: VNode[] = [{ type: Text }] as VNode[]
+    slots.default = () => vNodes
+    expect(getSlotNodes(slots)).toEqual(vNodes)
+  })
 })
diff --git a/packages/components/space/__tests__/__snapshots__/space.spec.ts.snap b/packages/components/space/__tests__/__snapshots__/space.spec.ts.snap
new file mode 100644
index 00000000..5f482a3d
--- /dev/null
+++ b/packages/components/space/__tests__/__snapshots__/space.spec.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Space.vue render work 1`] = `"<div class=\\"ix-space ix-space-baseline ix-space-horizontal\\"><div class=\\"ix-space-item ix-space-item-small\\"> Space </div><!--v-if--><div class=\\"ix-space-item ix-space-item-small\\"><button class=\\"ix-button ix-button-primary\\"><!--v-if--><span>Button</span></button></div><!--v-if--><div class=\\"ix-space-item ix-space-item-small\\"><button class=\\"ix-button\\"><!--v-if--><span>Button</span></button></div><!--v-if--></div>"`;
diff --git a/packages/components/space/__tests__/space.spec.ts b/packages/components/space/__tests__/space.spec.ts
new file mode 100644
index 00000000..732249fd
--- /dev/null
+++ b/packages/components/space/__tests__/space.spec.ts
@@ -0,0 +1,185 @@
+import { mount } from '@vue/test-utils'
+import { defineComponent, PropType } from 'vue'
+import IxSpace from '../src/Space.vue'
+import { IxButton } from '../../button'
+import { IxDivider } from '../../divider'
+import { SpaceAlign, SpaceDirection } from '../src/types'
+import { SpaceSize } from '@idux/components'
+import { isNil } from '@idux/cdk/utils'
+
+const TestComponent = defineComponent({
+  components: { IxSpace, IxButton, IxDivider },
+  template: `
+    <IxSpace :align='align' :direction='direction' :size='size' :wrap='wrap' :split='split'>
+    Space
+    <IxButton mode='primary'>Button</IxButton>
+    <IxButton>Button</IxButton>
+    <template v-slot:split v-if='showSplit'>
+      <IxDivider :type='dividerType' />
+    </template>
+    </IxSpace>
+  `,
+  props: {
+    align: { type: String as PropType<SpaceAlign>, default: undefined },
+    direction: { type: String as PropType<SpaceDirection>, default: undefined },
+    size: { type: [String, Number, Array] as PropType<SpaceSize>, default: undefined },
+    split: { type: String, default: undefined },
+    wrap: { type: Boolean, default: undefined },
+  },
+  data() {
+    return {
+      showSplit: false,
+    }
+  },
+  computed: {
+    dividerType(): SpaceDirection {
+      const hashmap = {
+        horizontal: 'vertical',
+        vertical: 'horizontal',
+      }
+      return hashmap[this.direction] as SpaceDirection
+    },
+  },
+})
+
+describe('Space.vue', () => {
+  test('render work', () => {
+    const wrapper = mount(TestComponent)
+    expect(wrapper.classes()).toContain('ix-space')
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+
+  test('align work', async () => {
+    const wrapper = mount(TestComponent)
+    expect(wrapper.classes()).not.toContain('ix-space-start')
+    expect(wrapper.classes()).not.toContain('ix-space-center')
+    expect(wrapper.classes()).not.toContain('ix-space-end')
+    expect(wrapper.classes()).toContain('ix-space-baseline')
+
+    await wrapper.setProps({ align: 'start' })
+    expect(wrapper.classes()).toContain('ix-space-start')
+    expect(wrapper.classes()).not.toContain('ix-space-center')
+    expect(wrapper.classes()).not.toContain('ix-space-end')
+    expect(wrapper.classes()).not.toContain('ix-space-baseline')
+
+    await wrapper.setProps({ align: 'center' })
+    expect(wrapper.classes()).not.toContain('ix-space-start')
+    expect(wrapper.classes()).toContain('ix-space-center')
+    expect(wrapper.classes()).not.toContain('ix-space-end')
+    expect(wrapper.classes()).not.toContain('ix-space-baseline')
+
+    await wrapper.setProps({ align: 'end' })
+    expect(wrapper.classes()).not.toContain('ix-space-start')
+    expect(wrapper.classes()).not.toContain('ix-space-center')
+    expect(wrapper.classes()).toContain('ix-space-end')
+    expect(wrapper.classes()).not.toContain('ix-space-baseline')
+
+    await wrapper.setProps({ align: 'baseline' })
+    expect(wrapper.classes()).not.toContain('ix-space-start')
+    expect(wrapper.classes()).not.toContain('ix-space-center')
+    expect(wrapper.classes()).not.toContain('ix-space-end')
+    expect(wrapper.classes()).toContain('ix-space-baseline')
+  })
+
+  test('direction work', async () => {
+    const wrapper = mount(TestComponent)
+    expect(wrapper.classes()).toContain('ix-space-horizontal')
+    expect(wrapper.classes()).not.toContain('ix-space-vertical')
+
+    await wrapper.setProps({ direction: 'vertical' })
+    expect(wrapper.classes()).not.toContain('ix-space-horizontal')
+    expect(wrapper.classes()).toContain('ix-space-vertical')
+  })
+
+  test('wrap work', async () => {
+    const wrapper = mount(TestComponent)
+    expect(wrapper.classes()).not.toContain('ix-space-wrap')
+
+    await wrapper.setProps({ wrap: true })
+    expect(wrapper.classes()).toContain('ix-space-wrap')
+  })
+
+  test('split work', async () => {
+    const wrapper = mount(TestComponent)
+    expect(wrapper.find('.ix-divider').exists()).toBeFalsy()
+    expect(wrapper.find('.ix-space-split').exists()).toBeFalsy()
+
+    await wrapper.setData({ showSplit: true })
+    expect(wrapper.find('.ix-divider').exists()).toBeTruthy()
+    expect(wrapper.find('.ix-space-split').exists()).toBeFalsy()
+
+    await wrapper.setProps({ split: '/' })
+    expect(wrapper.find('.ix-divider').exists()).toBeTruthy()
+    expect(wrapper.find('.ix-space-split').exists()).toBeFalsy()
+
+    await wrapper.setData({ showSplit: false })
+    expect(wrapper.find('.ix-divider').exists()).toBeFalsy()
+    expect(wrapper.find('.ix-space-split').exists()).toBeTruthy()
+  })
+
+  test('size work', async () => {
+    const wrapper = mount(TestComponent)
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(3)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(0)
+
+    await wrapper.setProps({ size: 'small' })
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(3)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(0)
+
+    await wrapper.setProps({ size: 'medium' })
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(3)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(0)
+
+    await wrapper.setProps({ size: 'large' })
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(3)
+
+    await wrapper.setProps({ size: 20 })
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(0)
+    expect(
+      wrapper.findAll('.ix-space-item').every(item => item.attributes('style') === 'margin-right: 20px;'),
+    ).toBeTruthy()
+
+    await wrapper.setProps({ split: '/' })
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item').every(item => isNil(item.attributes('style')))).toBeTruthy()
+
+    await wrapper.setProps({ split: undefined, size: ['small', 'medium'] })
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(1)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(1)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(0)
+
+    const size = new Map([
+      [0, 20],
+      [1, 10],
+    ])
+    await wrapper.setProps({ size: Array.from(size.values()) })
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(0)
+    expect(
+      wrapper.findAll('.ix-space-item').reduce((pre, cur, index) => {
+        return pre && cur.attributes('style') === `margin-right: ${size.get(index)}px;`
+      }, true),
+    )
+
+    await wrapper.setProps({ split: '/' })
+    expect(wrapper.findAll('.ix-space-item-small').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-medium').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item-large').length).toEqual(0)
+    expect(wrapper.findAll('.ix-space-item').every(item => isNil(item.attributes('style')))).toBeTruthy()
+
+    size.set(2, 30)
+    const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
+    await wrapper.setProps({ split: undefined, size: Array.from(size.values()) })
+    expect(warn).toBeCalled()
+  })
+})