Repository: IduxFE/components (ts)

Issue:
[cdk: overlay] add cdk overlay
# [cdk: overlay] add cdk overlay

- [x] I have searched the [issues](https://github.com/IduxFE/components/issues) of this repository and believe that this is not a duplicate.

对外暴露一个函数，允许调用方调用函数来创建一个浮层，且允许调用方还合适的范围内对浮层进行自定义。

## 特性

* 通过函数可以创建浮层并返回该浮层对应的实例；

* 调用方可以通过函数返回的浮层实例，对视图进行绑定；

* 调用方可以通过返回的实例，控制浮层的初始化、显示、隐藏和销毁；

* 浮层的滚动策略有四种：
  * `close` ：滚动时自动关闭浮层；
  * `reposition` ：默认行为，滚动时重新定位。

* 浮层定位依赖于 `@popperjs/core` 

* cdk会对外抛出以下事件，需要调用方在使用时将事件绑定到视图上，使得cdk可以对浮层行为进行控制：
  
  * `OverlayTriggerEvents`
    * `onClick`
    * `onMouseEnter`
    * `onMouseLeave`
    * `onFocus`
    * `onBlur`
  * `OverlayPopperEvents`
    * `onMouseEnter`
    * `onMouseLeave`
  
  > 注意：Trigger返回的事件与调用方传入 `trigger` 的值相关。

## API
### `overlay`
```typescript
import type { Options, Placement } from '@popperjs/core'
import type { ComponentPublicInstance, ComputedRef, Ref } from 'vue'

declare type Nullable<T> = T | null

type OverlayScrollStrategy = 'close' | 'reposition'
type OverlayTrigger = 'click' | 'hover' | 'focus'
type RefElement = Nullable<HTMLElement>
type VueElement = Nullable<ComponentPublicInstance | HTMLElement>

interface OverlayTriggerEvents {
  onClick?: (event: Event) => void
  onMouseEnter?: (event: Event) => void
  onMouseLeave?: (event: Event) => void
  onFocus?: (event: Event) => void
  onBlur?: (event: Event) => void
}

interface OverlayPopperEvents {
  onMouseEnter: () => void
  onMouseLeave: () => void
}

interface OverlayOptions {
  /* The class name of the overlay container. */
  className?: string
  /**
   * Control the visibility of the overlay
   */
  visible?: boolean
  /* Scroll strategy for overlay */
  scrollStrategy: OverlayScrollStrategy
  /* Disable the overlay */
  disable?: boolean
  /**
   * The distance between the arrow and the starting point at both ends.
   *Acting when the overlay uses border-radius.
   */
  arrowOffset?: number
  /* Whether to show arrow. */
  showArrow?: boolean
  /* Alignment of floating layer. */
  placement: Placement
  /**
   * The options of popper.
   * Used when ConnectOverlayOptions cannot meet the demand.
   * Priority is higher than ConnectOverlayOptions.
   */
  popperOptions?: Partial<Options>
  /* Trigger method. */
  trigger: OverlayTrigger
  /* Whether to allow the mouse to enter the overlay. */
  allowEnter?: boolean
  /**
   * Overlay offset.
   * [Horizontal axis offset, vertical axis offset]
   */
  offset: [number, number]
  /**
   * The delay of hiding overlay.
   * Send false if you don't need it.
   */
  hideDelay: number | false
  /**
   * The delay of showing overlay.
   * Send false if you don't need it.
   */
  showDelay: number | false
}

interface OverlayInstance {
  /**
   * Initialize the overlay.
   * The life cycle of the overlay will enter mounted.
   */
  initialize: () => void
  /**
   * Show the overlay.
   * The style of the overlay container will be set to block.
   */
  show: () => void
  /**
   * Hide the overlay.
   * The style of the overlay container will be set to none.
   */
  hide: () => void
  /**
   * Destroy the overlay.
   * The life cycle of the overlay will enter beforeDestroy.
   * To show the overlay again, please recreate.
   */
  destroy: () => void
  /**
   * TODO
   * The unique id of the overlay.
   * Provide subsequent components with markings for the specified overlay treatment.
   */
  overlayId: string
  /**
   * The display status of the current overlay.
   * Control by visible and disable.
   */
  visibility: ComputedRef<boolean>
  /**
   * The truth DOM node of the overlay.
   * The caller needs to bind the variable to the view.
   */
  overlayRef: Ref<RefElement>
  /**
   * Update overlay.
   * If the overlay has not been initialized, the overlay will be initialized first, otherwise the overlay will be update directly.
   * @param options
   */
  update: (options: Partial<OverlayOptions>) => void
 /**
   * The truth DOM node of the arrow.
   * If showArrow is false, we won't return arrowRef.
   * The caller needs to bind the variable to the view.
   */
  arrowRef?: Ref<RefElement>
  /**
   * The truth DOM node of the trigger.
   * The caller needs to bind the variable to the view.
   */
  triggerRef: Ref<VueElement>
  /**
   * Manually bind to the event on the trigger.
   */
  triggerEvents: OverlayTriggerEvents
  /**
   * Manually bind to events on the overlay.
   */
  overlayEvents: OverlayPopperEvents
}
```


### `Portal`

#### Props

| 名称      | 说明     | 类型                    | 备注                                                         |
| --------- | -------- | ----------------------- | ------------------------------------------------------------ |
| `disable` | 禁止传送 | `boolean`               |                                                              |
| `target`  | 传送位置 | `HTMLElement \| string ` | 传入类型是字符串时，会首先判断body下是否存在以该字符串为class name的元素，如果存在会直接使用该元素，否则会创建对应的元素作为容器。 |

#### Slots

| 名称      | 说明             | 备注 |
| --------- | ---------------- | ---- |
| `default` | 需要被传送的内容 | -    |

## DEMO

```vue
<template>
  <ix-button ref="triggerRef" @click="triggerEvents.onClick">Click</ix-button>
  <teleport to="body">
    <div ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter()" @mouseleave="overlayEvents.onMouseLeave()">
      tooltip
    </div>
  </teleport>
</template>

<script lang="ts">
import { defineComponent, onMounted } from 'vue'
import { useOverlay } from '@idux/cdk/overlay'

export default defineComponent({
  name: 'Basic',
  setup() {
    const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents } = useOverlay({
      visible: false,
      trigger: 'click',
      placement: 'bottom',
      offset: [0, 0],
      showDelay: false,
      hideDelay: 1000,
      allowEnter: true,
    })
    onMounted(initialize)

    return { overlayRef, triggerRef, triggerEvents, overlayEvents }
  },
})
</script>
```

Pull request:
feat(cdk: overlay): add cdk overlay
fix #113

## 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/package.json b/package.json
index 5aa05732..462cdc74 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
     "@commitlint/cli": "^11.0.0",
     "@commitlint/config-angular": "^11.0.0",
     "@ls-lint/ls-lint": "^1.9.0",
+    "@popperjs/core": "^2.6.0",
     "@types/detect-port": "^1.3.0",
     "@types/fs-extra": "^9.0.0",
     "@types/gulp": "^4.0.0",
diff --git a/packages/cdk/overlay/demo/Basic.vue b/packages/cdk/overlay/demo/Basic.vue
new file mode 100644
index 00000000..84c2d738
--- /dev/null
+++ b/packages/cdk/overlay/demo/Basic.vue
@@ -0,0 +1,46 @@
+<template>
+  <ix-button
+    ref="triggerRef"
+    @click="triggerEvents.onClick"
+    @mouseenter="triggerEvents.onMouseEnter"
+    @mouseleave="triggerEvents.onMouseLeave"
+    @focus="triggerEvents.onFocus"
+    @blur="triggerEvents.onBlur"
+    >Click</ix-button
+  >
+  <ix-button @click="handleClick">Update</ix-button>
+  <teleport to="body">
+    <div ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter()" @mouseleave="overlayEvents.onMouseLeave()">
+      tooltip
+      <div ref="arrowRef"></div>
+    </div>
+  </teleport>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted } from 'vue'
+import { useOverlay } from '@idux/cdk/overlay'
+
+export default defineComponent({
+  name: 'Basic',
+  setup() {
+    const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents, update, arrowRef } = useOverlay({
+      visible: false,
+      trigger: 'click',
+      placement: 'bottom',
+      scrollStrategy: 'reposition',
+      offset: [5, 5],
+      showDelay: 0,
+      hideDelay: 1000,
+      allowEnter: true,
+    })
+    onMounted(initialize)
+
+    const handleClick = () => {
+      update({ trigger: 'focus' })
+    }
+
+    return { overlayRef, triggerRef, triggerEvents, overlayEvents, handleClick, arrowRef }
+  },
+})
+</script>
diff --git a/packages/cdk/overlay/demo/basic.md b/packages/cdk/overlay/demo/basic.md
new file mode 100644
index 00000000..9a0348fc
--- /dev/null
+++ b/packages/cdk/overlay/demo/basic.md
@@ -0,0 +1,26 @@
+---
+order: 0
+title:
+  zh: 基本使用
+  en: Basic usage
+---
+
+## zh
+
+- 如何创建一个浮层
+- 如何初始化浮层
+- 如何更新浮层  
+- 如何在模板中绑定对应的事件
+
+另外，在组件中使用当前cdk，按照规范请配套使用组件Portal.
+
+## en
+
+- How to create an overlay
+- How to initialize the overlay
+- How to update the configuration of the overlay
+- How to bind the events in the template
+
+By the way, to use the cdk in components, please use the component Portal according to the specification.
+
+## demo
diff --git a/packages/cdk/overlay/docs/index.zh.md b/packages/cdk/overlay/docs/index.zh.md
new file mode 100644
index 00000000..24a948e7
--- /dev/null
+++ b/packages/cdk/overlay/docs/index.zh.md
@@ -0,0 +1,158 @@
+---
+category: cdk
+type:
+title: Overlay
+subtitle:
+cover:
+---
+
+参考自 [element-plus](https://github.com/element-plus/element-plus/tree/dev/packages/popper/src/use-popper)
+
+- 创建定位浮层：`useOverlay`
+
+## 何时使用
+
+- `useOverlay`：创建定位浮层
+
+## API
+
+### `useOverlay`
+
+> 创建一个浮层实例
+
+```typescript
+import type { Options, Placement } from '@popperjs/core'
+import type { ComponentPublicInstance, ComputedRef, Ref } from 'vue'
+
+type OverlayScrollStrategy = 'close' | 'reposition'
+type OverlayTrigger = 'click' | 'hover' | 'focus'
+type RefElement = Nullable<HTMLElement>
+type VueElement = Nullable<ComponentPublicInstance | HTMLElement>
+
+declare function useOverlay(options: OverlayOptions): OverlayInstance
+
+interface OverlayTriggerEvents {
+  onClick?: (event: Event) => void
+  onMouseEnter?: (event: Event) => void
+  onMouseLeave?: (event: Event) => void
+  onFocus?: (event: Event) => void
+  onBlur?: (event: Event) => void
+}
+
+interface OverlayPopperEvents {
+  onMouseEnter: () => void
+  onMouseLeave: () => void
+}
+
+interface OverlayOptions {
+  /* The class name of the overlay container. */
+  className?: string
+  /**
+   * Control the visibility of the overlay
+   */
+  visible?: boolean
+  /* Scroll strategy for overlay */
+  scrollStrategy?: OverlayScrollStrategy
+  /* Disable the overlay */
+  disable?: boolean
+  /**
+   * The distance between the arrow and the starting point at both ends.
+   *Acting when the overlay uses border-radius.
+   */
+  arrowOffset?: number
+  /* Whether to show arrow. */
+  showArrow?: boolean
+  /* Alignment of floating layer. */
+  placement: Placement
+  /**
+   * The options of popper.
+   * Used when ConnectOverlayOptions cannot meet the demand.
+   * Priority is higher than ConnectOverlayOptions.
+   */
+  popperOptions?: Partial<Options>
+  /* Trigger method. */
+  trigger: OverlayTrigger
+  /* Whether to allow the mouse to enter the overlay. */
+  allowEnter?: boolean
+  /**
+   * Overlay offset.
+   * [Horizontal axis offset, vertical axis offset]
+   */
+  offset: [number, number]
+  /**
+   * The delay of hiding overlay.
+   * Send false if you don't need it.
+   */
+  hideDelay: number | false
+  /**
+   * The delay of showing overlay.
+   * Send false if you don't need it.
+   */
+  showDelay: number | false
+}
+
+interface OverlayInstance {
+  /**
+   * Initialize the overlay.
+   * The life cycle of the overlay will enter mounted.
+   */
+  initialize: () => void
+  /**
+   * Show the overlay.
+   * The style of the overlay container will be set to block.
+   */
+  show: () => void
+  /**
+   * Hide the overlay.
+   * The style of the overlay container will be set to none.
+   */
+  hide: () => void
+  /**
+   * Destroy the overlay.
+   * The life cycle of the overlay will enter beforeDestroy.
+   * To show the overlay again, please recreate.
+   */
+  destroy: () => void
+  /**
+   * TODO
+   * The unique id of the overlay.
+   * Provide subsequent components with markings for the specified overlay treatment.
+   */
+  overlayId: string
+  /**
+   * The display status of the current overlay.
+   * Control by visible and disable.
+   */
+  visibility: ComputedRef<boolean>
+  /**
+   * The truth DOM node of the overlay.
+   * The caller needs to bind the variable to the view.
+   */
+  overlayRef: Ref<RefElement>
+  /**
+   * Update overlay.
+   * If the overlay has not been initialized, the overlay will be initialized first, otherwise the overlay will be update directly.
+   * @param options
+   */
+  update: (options: Partial<OverlayOptions>) => void
+  /**
+   * The truth DOM node of the arrow.
+   * If showArrow is false, we won't return arrowRef.
+   * The caller needs to bind the variable to the view.
+   */
+  arrowRef?: Ref<RefElement>
+  /**
+   * The truth DOM node of the trigger.
+   * The caller needs to bind the variable to the view.
+   */
+  triggerRef: Ref<VueElement>
+  /**
+   * Manually bind to the event on the trigger.
+   */
+  triggerEvents: OverlayTriggerEvents
+  /**
+   * Manually bind to events on the overlay.
+   */
+  overlayEvents: OverlayPopperEvents
+}
+```
diff --git a/packages/cdk/overlay/index.ts b/packages/cdk/overlay/index.ts
new file mode 100644
index 00000000..db46bf44
--- /dev/null
+++ b/packages/cdk/overlay/index.ts
@@ -0,0 +1,2 @@
+export * from './src/useOverlay'
+export * from './src/types'
diff --git a/packages/cdk/overlay/src/types.ts b/packages/cdk/overlay/src/types.ts
new file mode 100644
index 00000000..2f1b1329
--- /dev/null
+++ b/packages/cdk/overlay/src/types.ts
@@ -0,0 +1,130 @@
+import type { Options, Placement } from '@popperjs/core'
+import type { ComponentPublicInstance, ComputedRef, Ref } from 'vue'
+
+export type OverlayScrollStrategy = 'close' | 'reposition'
+export type OverlayTrigger = 'click' | 'hover' | 'focus'
+export type RefElement = Nullable<HTMLElement>
+export type TriggerElement = Nullable<ComponentPublicInstance | HTMLElement>
+
+export interface OverlayTriggerEvents {
+  onClick?: (event: Event) => void
+  onMouseEnter?: (event: Event) => void
+  onMouseLeave?: (event: Event) => void
+  onFocus?: (event: Event) => void
+  onBlur?: (event: Event) => void
+}
+
+export interface OverlayPopperEvents {
+  onMouseEnter: () => void
+  onMouseLeave: () => void
+}
+
+export interface OverlayOptions {
+  /**
+   * Control the visibility of the overlay
+   */
+  visible?: boolean
+  /* Scroll strategy for overlay */
+  scrollStrategy: OverlayScrollStrategy
+  /* Disable the overlay */
+  disabled?: boolean
+  /**
+   * The distance between the arrow and the starting point at both ends.
+   *Acting when the overlay uses border-radius.
+   */
+  arrowOffset?: number
+  /* Whether to show arrow. */
+  showArrow?: boolean
+  /* Alignment of floating layer. */
+  placement: Placement
+  /**
+   * The options of popper.
+   * Used when ConnectOverlayOptions cannot meet the demand.
+   * Priority is higher than ConnectOverlayOptions.
+   */
+  popperOptions?: Partial<Options>
+  /* Trigger method. */
+  trigger: OverlayTrigger
+  /* Whether to allow the mouse to enter the overlay. */
+  allowEnter?: boolean
+  /**
+   * Overlay offset.
+   * [Horizontal axis offset, vertical axis offset]
+   */
+  offset: [number, number]
+  /**
+   * The delay of hiding overlay.
+   * Send 0 if you don't need it.
+   */
+  hideDelay: number
+  /**
+   * The delay of showing overlay.
+   * Send 0 if you don't need it.
+   */
+  showDelay: number
+}
+
+export interface OverlayInstance {
+  /**
+   * Initialize the overlay.
+   * The life cycle of the overlay will enter mounted.
+   */
+  initialize: () => void
+  /**
+   * Show the overlay.
+   * The style of the overlay container will be set to block.
+   */
+  show: (immediate?: boolean) => void
+  /**
+   * Hide the overlay.
+   * The style of the overlay container will be set to none.
+   */
+  hide: (immediate?: boolean) => void
+  /**
+   * Update overlay.
+   * If the overlay has not been initialized, the overlay will be initialized first, otherwise the overlay will be update directly.
+   * @param options
+   */
+  update: (options: Partial<OverlayOptions>) => void
+  /**
+   * Destroy the overlay.
+   * The life cycle of the overlay will enter beforeDestroy.
+   * To show the overlay again, please recreate.
+   */
+  destroy: () => void
+  /**
+   * TODO
+   * The unique id of the overlay.
+   * Provide subsequent components with markings for the specified overlay treatment.
+   */
+  id: string
+  /**
+   * The display status of the current overlay.
+   * Control by visible and disable.
+   */
+  visibility: ComputedRef<boolean>
+  /**
+   * The truth DOM node of the overlay.
+   * The caller needs to bind the variable to the view.
+   */
+  overlayRef: Ref<RefElement>
+  /**
+   * The truth DOM node of the arrow.
+   * If showArrow is false, we won't return arrowRef.
+   * The caller needs to bind the variable to the view.
+   */
+  arrowRef?: Ref<RefElement>
+  /**
+   * The truth DOM node of the trigger.
+   * The caller needs to bind the variable to the view.
+   */
+  triggerRef: Ref<TriggerElement>
+  /**
+   * Manually bind to the event on the trigger.
+   */
+  triggerEvents: ComputedRef<OverlayTriggerEvents>
+  /**
+   * Manually bind to events on the overlay.
+   */
+  overlayEvents: OverlayPopperEvents
+}
diff --git a/packages/cdk/overlay/src/useModifiers.ts b/packages/cdk/overlay/src/useModifiers.ts
new file mode 100644
index 00000000..e68190fb
--- /dev/null
+++ b/packages/cdk/overlay/src/useModifiers.ts
@@ -0,0 +1,33 @@
+import type { StrictModifiers } from '@popperjs/core'
+import type { RefElement } from './types'
+
+interface ModifierProps {
+  offset: [number, number]
+  arrow?: RefElement
+  arrowOffset?: number
+  showArrow?: boolean
+}
+
+export function useModifiers(
+  { offset, arrow, arrowOffset, showArrow }: ModifierProps,
+  externalModifiers: StrictModifiers[] = [],
+): StrictModifiers[] {
+  const modifiers: StrictModifiers[] = [
+    { name: 'offset', options: { offset } },
+    { name: 'preventOverflow', options: { padding: { top: 2, bottom: 2, left: 5, right: 5 } } },
+    { name: 'flip', options: { padding: 5 } },
+  ]
+  if (showArrow) {
+    modifiers.push({
+      name: 'arrow',
+      options: {
+        element: arrow,
+        padding: arrowOffset ?? 5,
+      },
+    })
+  }
+
+  modifiers.push(...externalModifiers)
+
+  return modifiers
+}
diff --git a/packages/cdk/overlay/src/useOverlay.ts b/packages/cdk/overlay/src/useOverlay.ts
new file mode 100644
index 00000000..a6341505
--- /dev/null
+++ b/packages/cdk/overlay/src/useOverlay.ts
@@ -0,0 +1,195 @@
+import type { Instance as PopperInstance } from '@popperjs/core'
+import type { ComputedRef } from 'vue'
+
+import type {
+  OverlayInstance,
+  OverlayOptions,
+  OverlayPopperEvents,
+  OverlayTrigger,
+  OverlayTriggerEvents,
+  RefElement,
+  TriggerElement,
+} from './types'
+
+import { ComponentPublicInstance, computed, reactive, ref, watch } from 'vue'
+import { createPopper } from '@popperjs/core'
+import { isHTMLElement, uniqueId } from '@idux/cdk/utils'
+
+import { usePopperOptions } from './usePopperOptions'
+
+export const useOverlay = (options: OverlayOptions): OverlayInstance => {
+  const arrowRef = ref<RefElement>(null)
+  const triggerRef = ref<TriggerElement>(null)
+  const overlayRef = ref<RefElement>(null)
+  let popperInstance: Nullable<PopperInstance> = null
+
+  let showTimer: Nullable<number> = null
+  let hideTimer: Nullable<number> = null
+
+  const state = reactive(options)
+  const popperOptions = usePopperOptions(options, { arrow: arrowRef })
+
+  const overlayVisibility = (visible: boolean): HTMLElement | null => {
+    const overlayElement = overlayRef.value
+    if (overlayElement) {
+      overlayElement.style.display = visible ? 'block' : 'none'
+    }
+    return overlayElement
+  }
+
+  const initialize = (): void => {
+    const overlayElement = overlayVisibility(visibility.value)
+    if (!visibility.value) {
+      return
+    }
+    const unrefTrigger = triggerRef.value
+    if (!unrefTrigger) {
+      return
+    }
+    const triggerElement = isHTMLElement(unrefTrigger) ? unrefTrigger : (unrefTrigger as ComponentPublicInstance).$el
+    popperInstance = createPopper(triggerElement, overlayElement as HTMLElement, popperOptions.value)
+    popperInstance.update()
+    window.addEventListener('scroll', globalScroll)
+  }
+
+  const _toggle = (visible: boolean) => {
+    state.visible = visible
+  }
+
+  const _clearTimer = () => {
+    window.clearTimeout(showTimer as number)
+    window.clearTimeout(hideTimer as number)
+  }
+
+  const show = (immediate?: boolean): void => {
+    _clearTimer()
+    if (immediate || state.showDelay === 0) {
+      _toggle(true)
+    } else {
+      showTimer = window.setTimeout(() => {
+        _toggle(true)
+      }, state.showDelay)
+    }
+  }
+
+  const hide = (immediate?: boolean): void => {
+    _clearTimer()
+    if (immediate || state.hideDelay === 0) {
+      _toggle(false)
+    } else {
+      hideTimer = window.setTimeout(() => {
+        _toggle(false)
+      }, state.hideDelay)
+    }
+  }
+
+  const destroy = (): void => {
+    if (!popperInstance) {
+      return
+    }
+    popperInstance.destroy()
+    popperInstance = null
+    window.removeEventListener('scroll', globalScroll)
+  }
+
+  const update = (options: Partial<OverlayOptions>): void => {
+    Object.assign(state, options)
+  }
+
+  const visibility = computed<boolean>(() => !state.disabled && !!state.visible)
+
+  const onVisibilityChange = (visible: boolean) => {
+    overlayVisibility(visible)
+    if (!visible) {
+      // improve performance
+      destroy()
+    }
+    initialize()
+  }
+
+  watch(visibility, onVisibilityChange)
+
+  const overlayEventHandler = (e: Event): void => {
+    e.stopPropagation()
+    switch (e.type) {
+      case 'click': {
+        visibility.value ? hide() : show()
+        break
+      }
+      case 'mouseenter': {
+        show()
+        break
+      }
+      case 'mouseleave': {
+        hide()
+        break
+      }
+      case 'focus': {
+        show()
+        break
+      }
+      case 'blur': {
+        hide()
+        break
+      }
+    }
+  }
+
+  const mapTriggerEvents = (state: OverlayOptions): ComputedRef<OverlayTriggerEvents> => {
+    return computed<OverlayTriggerEvents>(() => {
+      const triggerToEvents: Record<OverlayTrigger, Array<keyof OverlayTriggerEvents>> = {
+        click: ['onClick'],
+        focus: ['onFocus', 'onBlur'],
+        hover: ['onMouseEnter', 'onMouseLeave'],
+      }
+      return triggerToEvents[state.trigger].reduce((obj, key) => {
+        obj[key] = overlayEventHandler
+        return obj
+      }, {} as OverlayTriggerEvents)
+    })
+  }
+
+  const triggerEvents: ComputedRef<OverlayTriggerEvents> = mapTriggerEvents(state)
+
+  const onOverlayMouseEnter = () => {
+    if (state.allowEnter && state.trigger !== 'click') {
+      clearTimeout(hideTimer as number)
+    }
+  }
+
+  const onOverlayMouseLeave = () => {
+    if (state.trigger !== 'hover') {
+      return
+    }
+    hide()
+  }
+
+  const overlayEvents: OverlayPopperEvents = {
+    onMouseEnter: onOverlayMouseEnter,
+    onMouseLeave: onOverlayMouseLeave,
+  }
+
+  const globalScroll = () => {
+    if (!visibility.value || !popperInstance) {
+      return
+    }
+    if (state.scrollStrategy === 'close') {
+      hide(true)
+    }
+  }
+
+  return {
+    arrowRef,
+    triggerRef,
+    overlayRef,
+    initialize,
+    show,
+    hide,
+    destroy,
+    id: uniqueId('ix-overlay'),
+    update,
+    visibility,
+    triggerEvents,
+    overlayEvents,
+  }
+}
diff --git a/packages/cdk/overlay/src/usePopperOptions.ts b/packages/cdk/overlay/src/usePopperOptions.ts
new file mode 100644
index 00000000..ee203922
--- /dev/null
+++ b/packages/cdk/overlay/src/usePopperOptions.ts
@@ -0,0 +1,31 @@
+import type { ComputedRef, Ref } from 'vue'
+import type { Options, Placement } from '@popperjs/core'
+import type { RefElement } from './types'
+
+import { computed } from 'vue'
+import { useModifiers } from './useModifiers'
+
+type PartialOptions = Partial<Options>
+
+interface PopperProps {
+  arrowOffset?: number
+  offset: [number, number]
+  popperOptions?: PartialOptions
+  placement: Placement
+  showArrow?: boolean
+}
+
+interface PopperState {
+  arrow: Ref<RefElement>
+}
+
+export function usePopperOptions(props: PopperProps, state: PopperState): ComputedRef<PartialOptions> {
+  return computed<PartialOptions>(() => ({
+    placement: props.placement,
+    ...props.popperOptions,
+    modifiers: useModifiers(
+      { offset: props.offset, arrow: state.arrow.value, arrowOffset: props.arrowOffset, showArrow: props.showArrow },
+      props.popperOptions?.modifiers,
+    ),
+  }))
+}
diff --git a/packages/cdk/package.json b/packages/cdk/package.json
index bda99f61..5649f9e8 100644
--- a/packages/cdk/package.json
+++ b/packages/cdk/package.json
@@ -32,6 +32,7 @@
     "url": "https://github.com/IduxFE/components/issues"
   },
   "peerDependencies": {
+    "@popperjs/core": "^2.6.0",
     "vue": "^3.0.0"
   }
-}
\ No newline at end of file
+}
diff --git a/packages/cdk/portal/demo/Basic.vue b/packages/cdk/portal/demo/Basic.vue
new file mode 100644
index 00000000..8201e29e
--- /dev/null
+++ b/packages/cdk/portal/demo/Basic.vue
@@ -0,0 +1,15 @@
+<template>
+  <ix-portal target="ix-container">
+    <div>Portal</div>
+  </ix-portal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { IxPortal } from '@idux/cdk/portal'
+
+export default defineComponent({
+  name: 'Basic',
+  components: { IxPortal },
+})
+</script>
diff --git a/packages/cdk/portal/demo/Disabled.vue b/packages/cdk/portal/demo/Disabled.vue
new file mode 100644
index 00000000..829d183e
--- /dev/null
+++ b/packages/cdk/portal/demo/Disabled.vue
@@ -0,0 +1,26 @@
+<template>
+  <ix-portal target="ix-container" :disabled="disabled">
+    <div>Portal</div>
+  </ix-portal>
+  <ix-button @click="handleDisable">Disabled</ix-button>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue'
+import { IxPortal } from '@idux/cdk/portal'
+import { IxButton } from '@idux/components'
+
+export default defineComponent({
+  name: 'Disabled',
+  components: { IxPortal, IxButton },
+  setup() {
+    const disabled = ref(false)
+
+    const handleDisable = () => {
+      disabled.value = !disabled.value
+    }
+
+    return { disabled, handleDisable }
+  },
+})
+</script>
diff --git a/packages/cdk/portal/demo/Element.vue b/packages/cdk/portal/demo/Element.vue
new file mode 100644
index 00000000..b1d284ed
--- /dev/null
+++ b/packages/cdk/portal/demo/Element.vue
@@ -0,0 +1,22 @@
+<template>
+  <ix-portal :target="container">
+    <div>Portal</div>
+  </ix-portal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { IxPortal } from '@idux/cdk/portal'
+
+export default defineComponent({
+  name: 'Element',
+  components: { IxPortal },
+  setup() {
+    const container = document.createElement('div')
+    container.classList.add('custom-container')
+    document.body.appendChild(container)
+
+    return { container }
+  },
+})
+</script>
diff --git a/packages/cdk/portal/demo/basic.md b/packages/cdk/portal/demo/basic.md
new file mode 100644
index 00000000..66fa0304
--- /dev/null
+++ b/packages/cdk/portal/demo/basic.md
@@ -0,0 +1,10 @@
+---
+order: 0
+title:
+  zh: 基本用法
+  en: Basic usage
+---
+
+## zh
+
+默认传入一个字符串，创建传送容器。
diff --git a/packages/cdk/portal/demo/disabled.md b/packages/cdk/portal/demo/disabled.md
new file mode 100644
index 00000000..15e851ce
--- /dev/null
+++ b/packages/cdk/portal/demo/disabled.md
@@ -0,0 +1,10 @@
+---
+order: 0
+title:
+  zh: 禁止传送
+  en: Disabled
+---
+
+## zh
+
+`disabled` 设置为true可以禁止传送，直接以 `v-dom` 的上下文进行渲染。
diff --git a/packages/cdk/portal/demo/element.md b/packages/cdk/portal/demo/element.md
new file mode 100644
index 00000000..8fe9dd8d
--- /dev/null
+++ b/packages/cdk/portal/demo/element.md
@@ -0,0 +1,10 @@
+---
+order: 1
+title:
+  zh: 传入HTMLElement
+  en: Send a HTMLElement
+---
+
+## zh
+
+可以传入一个HTMLElement指定传送。
diff --git a/packages/cdk/portal/docs/index.zh.md b/packages/cdk/portal/docs/index.zh.md
new file mode 100644
index 00000000..16cc144c
--- /dev/null
+++ b/packages/cdk/portal/docs/index.zh.md
@@ -0,0 +1,22 @@
+---
+category: cdk
+type:
+title: Portal
+subtitle: 传送门
+cover:
+---
+
+## 何时使用
+
+当需要将元素传送到指定位置，但是不破坏当前 `v-dom` 的结构。
+
+## API
+
+### `ix-portal`
+
+#### Props
+
+| 名称       | 说明             | 类型                    | 默认值 | 备注                                                                                                                                                                                                  |
+| ---------- | ---------------- | ----------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `disabled` | 是否禁用传送     | `boolean`               | -      | 若禁用，则会以当前 `v-dom` 结构进行渲染                                                                                                                                                               |
+| `target`   | 被传送的目标元素 | `string \| HTMLElement` | -      | 如果传入一个元素，组件直接传送到该元素上；如果是一个字符串，会根据是否创建过该元素，如果创建过直接读取缓存，否则在 `body` 的最后一个元素上创建元素，并将组件传送到该元素,传入的字符串将作为组件的类名 |
diff --git a/packages/cdk/portal/index.ts b/packages/cdk/portal/index.ts
new file mode 100644
index 00000000..c9fbb227
--- /dev/null
+++ b/packages/cdk/portal/index.ts
@@ -0,0 +1,2 @@
+export { default as IxPortal } from './src/Portal.vue'
+export * from './src/types'
diff --git a/packages/cdk/portal/src/Portal.vue b/packages/cdk/portal/src/Portal.vue
new file mode 100644
index 00000000..6e609d27
--- /dev/null
+++ b/packages/cdk/portal/src/Portal.vue
@@ -0,0 +1,32 @@
+<template>
+  <teleport :disabled="disabled" :to="to">
+    <slot />
+  </teleport>
+</template>
+
+<script lang="ts">
+import type { ComputedRef } from 'vue'
+import type { PortalProps } from './types'
+
+import { computed, defineComponent } from 'vue'
+import { PropTypes } from '@idux/cdk/utils'
+
+import { useContainer } from './useContainer'
+
+export default defineComponent({
+  name: 'IxPortal',
+  props: {
+    disabled: PropTypes.bool,
+    target: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(HTMLElement)]).isRequired,
+  },
+  setup(props: PortalProps) {
+    const to = useTarget(props)
+
+    return { to }
+  },
+})
+
+function useTarget(props: PortalProps): ComputedRef<HTMLElement> {
+  return computed(() => useContainer(props.target))
+}
+</script>
diff --git a/packages/cdk/portal/src/types.ts b/packages/cdk/portal/src/types.ts
new file mode 100644
index 00000000..81300124
--- /dev/null
+++ b/packages/cdk/portal/src/types.ts
@@ -0,0 +1,18 @@
+import type { DefineComponent } from 'vue'
+
+interface PortalOriginalProps {
+  /**
+   * Whether disable transporting to target place.
+   */
+  disabled?: boolean
+  /**
+   * The place that element will be send.
+   * If target is a string, component will first determine whether the element exists in the body.
+   * If it exists, component will transport elements to it.
+   * If it doesn't exist, component will create it and save it.
+   */
+  target: string | HTMLElement
+}
+
+export type PortalProps = Readonly<PortalOriginalProps>
+export type PortalComponent = InstanceType<DefineComponent<PortalProps>>
diff --git a/packages/cdk/portal/src/useContainer.ts b/packages/cdk/portal/src/useContainer.ts
new file mode 100644
index 00000000..4700a22d
--- /dev/null
+++ b/packages/cdk/portal/src/useContainer.ts
@@ -0,0 +1,27 @@
+import { isString } from '@idux/cdk/utils'
+
+interface UseContainer {
+  (target: string | HTMLElement): HTMLElement
+}
+
+function initializeContainer(): UseContainer {
+  const containerHashmap: Record<string, HTMLElement> = {}
+
+  function useContainer(target: string | HTMLElement): HTMLElement {
+    if (!isString(target)) {
+      return target
+    }
+    if (containerHashmap[target]) {
+      return containerHashmap[target]
+    }
+    const container = document.createElement('div')
+    container.classList.add(target)
+    document.body.appendChild(container)
+    containerHashmap[target] = container
+    return container
+  }
+
+  return useContainer
+}
+
+export const useContainer = initializeContainer()
diff --git a/packages/cdk/subject/demo/Basic.vue b/packages/cdk/subject/demo/Basic.vue
deleted file mode 100644
index a0d35990..00000000
--- a/packages/cdk/subject/demo/Basic.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<template>
-  <div />
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue'
-import { Subject } from '@idux/cdk/subject'
-
-export default defineComponent({
-  setup() {
-    const subject1 = new Subject()
-    subject1.subscribe(() => {
-      // 订阅
-    })
-
-    subject1.dispatch() // 发布
-
-    /* Subject是一个泛型类 */
-    const subject2 = new Subject<number>()
-
-    subject2.subscribe(value => {
-      // value会被推导为number
-      console.log(value)
-    })
-
-    subject2.dispatch(100) // ts类型保护，传参必须为number
-  },
-})
-</script>
diff --git a/packages/cdk/subject/demo/basic.md b/packages/cdk/subject/demo/basic.md
deleted file mode 100644
index d4a1cc2c..00000000
--- a/packages/cdk/subject/demo/basic.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-order: 0
-title:
-  zh: 基本使用
-  en: Basic usage
----
-
-## zh
-
-基本用法。
-
-## en
-
-Basic usage.
diff --git a/packages/cdk/subject/docs/index.zh.md b/packages/cdk/subject/docs/index.zh.md
index 7386bb98..d9ce6e32 100644
--- a/packages/cdk/subject/docs/index.zh.md
+++ b/packages/cdk/subject/docs/index.zh.md
@@ -2,7 +2,7 @@
 category: cdk
 type:
 title: Subject
-subtitle:
+subtitle: 观察者
 cover:
 ---
 
@@ -33,3 +33,37 @@ interface Subscription {
   unsubscribe: () => void
 }
 ````
+
+## DEMO
+
+```html
+<template>
+  <div />
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { Subject } from '@idux/cdk/subject'
+
+export default defineComponent({
+  setup() {
+    const subject1 = new Subject()
+    subject1.subscribe(() => {
+      // 订阅
+    })
+
+    subject1.dispatch() // 发布
+
+    /* Subject是一个泛型类 */
+    const subject2 = new Subject<number>()
+
+    subject2.subscribe(value => {
+      // value会被推导为number
+      console.log(value)
+    })
+
+    subject2.dispatch(100) // ts类型保护，传参必须为number
+  },
+})
+</script>
+```
diff --git a/packages/cdk/utils/index.ts b/packages/cdk/utils/index.ts
index 585328bb..54ea9cd5 100644
--- a/packages/cdk/utils/index.ts
+++ b/packages/cdk/utils/index.ts
@@ -3,3 +3,4 @@ export * from './propTypes'
 export * from './typeof'
 export * from './vNode'
 export * from './dom'
+export * from './uniqueId'
diff --git a/packages/cdk/utils/typeof.ts b/packages/cdk/utils/typeof.ts
index 5a1d1f9e..0ed3a468 100644
--- a/packages/cdk/utils/typeof.ts
+++ b/packages/cdk/utils/typeof.ts
@@ -1,5 +1,7 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
+import { toRawType } from '@vue/shared'
+
 const _toString = Object.prototype.toString
 
 /** The method checks whether the given value is a Numeric value or not and returns the corresponding boolean value. */
@@ -73,3 +75,5 @@ export function isPromise<T = any>(val: unknown): val is Promise<T> {
 export function hasOwnProperty(val: object, key: string | symbol): key is keyof typeof val {
   return Object.prototype.hasOwnProperty.call(val, key)
 }
+
+export const isHTMLElement = (val: unknown): boolean => toRawType(val).startsWith('HTML')
diff --git a/packages/cdk/utils/uniqueId.ts b/packages/cdk/utils/uniqueId.ts
new file mode 100644
index 00000000..d544e199
--- /dev/null
+++ b/packages/cdk/utils/uniqueId.ts
@@ -0,0 +1,4 @@
+let nodeId = 0
+export function uniqueId(prefix = 'ix'): string {
+  return `${prefix}-${nodeId++}`
+}
diff --git a/typings/global.d.ts b/typings/global.d.ts
index dcbd8607..1531059b 100644
--- a/typings/global.d.ts
+++ b/typings/global.d.ts
@@ -3,3 +3,5 @@ declare type IsNullable<T, K> = undefined extends T ? K : never
 declare type OptionalKeys<T> = { [K in keyof T]-?: IsNullable<T[K], K> }[keyof T]
 
 declare type RequiredKeys<T> = keyof Omit<T, OptionalKeys<T>>
+
+declare type Nullable<T> = T | null


Test patch:
diff --git a/packages/cdk/overlay/__tests__/overlay.spec.ts b/packages/cdk/overlay/__tests__/overlay.spec.ts
new file mode 100644
index 00000000..ef9d0f2b
--- /dev/null
+++ b/packages/cdk/overlay/__tests__/overlay.spec.ts
@@ -0,0 +1,278 @@
+import type { OverlayOptions, OverlayTrigger } from '@idux/cdk/overlay'
+
+import { defineComponent, onMounted, onUpdated, PropType, unref } from 'vue'
+import { mount } from '@vue/test-utils'
+import { IxButton } from '@idux/components'
+import { useOverlay } from '../src/useOverlay'
+
+const defaultOverlayOptions: OverlayOptions = {
+  placement: 'bottom',
+  scrollStrategy: 'reposition',
+  trigger: 'click',
+  offset: [0, 0],
+  hideDelay: 1000,
+  showDelay: 1000,
+}
+
+describe('useOverlay.ts', () => {
+  let options: OverlayOptions
+  let timer: (delay?: number) => Promise<void>
+
+  beforeEach(() => {
+    options = { ...defaultOverlayOptions }
+    timer = (delay = 0) => {
+      return new Promise<void>(resolve => {
+        setTimeout(resolve, delay)
+      })
+    }
+  })
+
+  test('init work', () => {
+    const instance = useOverlay(options)
+    expect(instance).toBeDefined()
+  })
+
+  test('visible work', async () => {
+    const TestComponent = defineComponent({
+      setup() {
+        const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents, visibility, show, hide } = useOverlay(
+          options,
+        )
+
+        onMounted(initialize)
+
+        const handleClick = () => {
+          unref(visibility) ? hide(true) : show(true)
+        }
+
+        return { overlayRef, triggerRef, triggerEvents, overlayEvents, handleClick }
+      },
+      template: `
+      <button id="trigger" ref="triggerRef" @click="triggerEvents.onClick">Trigger</button>
+      <button id="immediate" @click="handleClick">Immediate Toggle</button>
+      <div id="overlay" ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter" @mouseleave="overlayEvents.onMouseLeave">Overlay</div>
+      `,
+    })
+    const wrapper = mount(TestComponent)
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+
+    await wrapper.get('#trigger').trigger('click')
+    await timer(1000)
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+
+    await wrapper.get('#trigger').trigger('click')
+    await timer(1000)
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+
+    await wrapper.get('#immediate').trigger('click')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+
+    await wrapper.get('#immediate').trigger('click')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+  })
+
+  test('component trigger work', async () => {
+    const TestComponent = defineComponent({
+      components: { IxButton },
+      setup() {
+        const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents } = useOverlay({
+          ...options,
+          showDelay: 0,
+          hideDelay: 0,
+        })
+
+        onMounted(initialize)
+
+        return { overlayRef, triggerRef, triggerEvents, overlayEvents }
+      },
+      template: `
+      <ix-button id="trigger" ref="triggerRef" @click="triggerEvents.onClick">Trigger</ix-button>
+      <div id="overlay" ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter" @mouseleave="overlayEvents.onMouseLeave">Overlay</div>
+      `,
+    })
+    const wrapper = mount(TestComponent)
+    await wrapper.get('#trigger').trigger('click')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+
+    await wrapper.get('#trigger').trigger('click')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+  })
+
+  test('destroy work', async () => {
+    const TestComponent = defineComponent({
+      setup() {
+        const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents, destroy } = useOverlay({
+          ...options,
+          showDelay: 0,
+          hideDelay: 0,
+        })
+
+        onMounted(initialize)
+
+        const handleClick = () => {
+          destroy()
+          console.log('destroy')
+        }
+
+        return { overlayRef, triggerRef, triggerEvents, overlayEvents, handleClick }
+      },
+      template: `
+      <button id="trigger" ref="triggerRef" @click="triggerEvents.onClick">Trigger</button>
+      <button id="destroy" @click="handleClick">Destroy</button>
+      <div id="overlay" ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter" @mouseleave="overlayEvents.onMouseLeave">Overlay</div>
+      `,
+    })
+    const wrapper = mount(TestComponent)
+
+    const log = jest.spyOn(console, 'log')
+    await wrapper.get('#destroy').trigger('click')
+    expect(log).toBeCalled()
+  })
+
+  test('update work', async () => {
+    const TestComponent = defineComponent({
+      components: { IxButton },
+      setup() {
+        const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents, update } = useOverlay(options)
+
+        onMounted(initialize)
+
+        const handleClick = () => {
+          update({ showDelay: 0 })
+        }
+
+        return { overlayRef, triggerRef, triggerEvents, overlayEvents, handleClick }
+      },
+      template: `
+      <button id="trigger" ref="triggerRef" @click="triggerEvents.onClick">Trigger</button>
+      <button id="update" @click="handleClick">Update</button>
+      <div id="overlay" ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter" @mouseleave="overlayEvents.onMouseLeave">Overlay</div>
+      `,
+    })
+    const wrapper = mount(TestComponent)
+
+    await wrapper.get('#trigger').trigger('click')
+    await timer(1000)
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+
+    await wrapper.get('#trigger').trigger('click')
+    await timer(1000)
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+
+    await wrapper.get('#update').trigger('click')
+    await wrapper.get('#trigger').trigger('click')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+  })
+
+  test('trigger work', async () => {
+    const TestComponent = defineComponent({
+      components: { IxButton },
+      props: {
+        trigger: {
+          type: String as PropType<OverlayTrigger>,
+          default: 'click',
+        },
+      },
+      setup(props) {
+        const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents, update } = useOverlay({
+          ...options,
+          showDelay: 0,
+          hideDelay: 0,
+          trigger: props.trigger,
+        })
+
+        onMounted(initialize)
+
+        onUpdated(() => {
+          update({ trigger: props.trigger })
+        })
+
+        return { overlayRef, triggerRef, triggerEvents, overlayEvents }
+      },
+      template: `
+      <button id="trigger" ref="triggerRef" @focus='triggerEvents.onFocus' @blur='triggerEvents.onBlur' @mouseenter='triggerEvents.onMouseEnter' @mouseleave='triggerEvents.onMouseLeave' @click="triggerEvents.onClick">Trigger</button>
+      <div id="overlay" ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter" @mouseleave="overlayEvents.onMouseLeave">Overlay</div>
+      `,
+    })
+
+    const wrapper = mount(TestComponent)
+    await wrapper.get('#trigger').trigger('click')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+    await wrapper.get('#trigger').trigger('click')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+
+    await wrapper.get('#overlay').trigger('mouseleave')
+
+    await wrapper.setProps({ trigger: 'focus' })
+    await wrapper.get('#trigger').trigger('focus')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+    await wrapper.get('#trigger').trigger('focus')
+
+    await wrapper.get('#trigger').trigger('blur')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+
+    await wrapper.setProps({ trigger: 'hover' })
+    await wrapper.get('#trigger').trigger('mouseenter')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+    await wrapper.get('#trigger').trigger('mouseleave')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+  })
+
+  test('hover overlay work', async () => {
+    const TestComponent = defineComponent({
+      components: { IxButton },
+      setup() {
+        const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents } = useOverlay({
+          ...options,
+          trigger: 'hover',
+          visible: true,
+          allowEnter: true,
+          showDelay: 0,
+          hideDelay: 0,
+        })
+
+        onMounted(initialize)
+
+        return { overlayRef, triggerRef, triggerEvents, overlayEvents }
+      },
+      template: `
+      <button id="trigger" ref="triggerRef" @mouseenter='triggerEvents.onMouseEnter' @mouseleave='triggerEvents.onMouseLeave'>Trigger</button>
+      <div id="overlay" ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter" @mouseleave="overlayEvents.onMouseLeave">Overlay</div>
+      `,
+    })
+    const wrapper = mount(TestComponent)
+    await wrapper.get('#overlay').trigger('mouseenter')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: block;')
+
+    await wrapper.get('#overlay').trigger('mouseleave')
+    expect(wrapper.get('#overlay').attributes('style')).toContain('display: none;')
+  })
+
+  test('arrow work', async () => {
+    const TestComponent = defineComponent({
+      components: { IxButton },
+      setup() {
+        const { initialize, overlayRef, triggerRef, triggerEvents, overlayEvents, arrowRef } = useOverlay({
+          ...options,
+          showDelay: 0,
+          showArrow: true,
+        })
+
+        onMounted(initialize)
+
+        return { overlayRef, triggerRef, triggerEvents, overlayEvents, arrowRef }
+      },
+      template: `
+      <button id="trigger" ref="triggerRef" @click="triggerEvents.onClick">Trigger</button>
+      <div id="overlay" ref="overlayRef" @mouseenter="overlayEvents.onMouseEnter" @mouseleave="overlayEvents.onMouseLeave">Overlay
+        <div ref="arrowRef" id='arrow'></div>
+      </div>
+      `,
+    })
+    const wrapper = mount(TestComponent)
+    await wrapper.get('#trigger').trigger('click')
+    expect(wrapper.get('#arrow')).toBeDefined()
+  })
+
+  // todo global scroll
+})
diff --git a/packages/cdk/portal/__tests__/__snapshots__/portal.spec.ts.snap b/packages/cdk/portal/__tests__/__snapshots__/portal.spec.ts.snap
new file mode 100644
index 00000000..f53ee29b
--- /dev/null
+++ b/packages/cdk/portal/__tests__/__snapshots__/portal.spec.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Portal.vue render work 1`] = `"<!--teleport start--><!--teleport end-->"`;
diff --git a/packages/cdk/portal/__tests__/portal.spec.ts b/packages/cdk/portal/__tests__/portal.spec.ts
new file mode 100644
index 00000000..11b13ef1
--- /dev/null
+++ b/packages/cdk/portal/__tests__/portal.spec.ts
@@ -0,0 +1,67 @@
+import type { DefineComponent } from 'vue'
+import type { MountingOptions } from '@vue/test-utils'
+
+import type { PortalProps } from '../src/types'
+
+import { defineComponent } from 'vue'
+import { PropTypes } from '@idux/cdk/utils'
+import { mount, VueWrapper } from '@vue/test-utils'
+
+import IxPortal from '../src/Portal.vue'
+
+const TestComponent = defineComponent({
+  components: { IxPortal },
+  props: {
+    disabled: PropTypes.bool,
+    target: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(HTMLElement)]).isRequired,
+  },
+  template: `
+  <ix-portal :disabled='disabled' :target='target'>
+    <div id='overlay'>overlay</div>
+  </ix-portal>
+  `,
+})
+
+describe('Portal.vue', () => {
+  let PortalMount: (
+    options?: MountingOptions<Partial<PortalProps>>,
+  ) => VueWrapper<InstanceType<DefineComponent<PortalProps>>>
+
+  beforeEach(() => {
+    PortalMount = (options = { props: { target: 'ix-container' } }) => {
+      return mount<PortalProps>(TestComponent, {
+        ...options,
+      })
+    }
+  })
+
+  test('render work', () => {
+    const wrapper = PortalMount()
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+
+  test('disabled work', async () => {
+    const wrapper = PortalMount()
+    expect(wrapper.html()).toEqual('<!--teleport start--><!--teleport end-->')
+
+    await wrapper.setProps({ disabled: true })
+    expect(wrapper.get('#overlay')).toBeDefined()
+  })
+
+  test('target work', async () => {
+    const container = document.createElement('div')
+    container.classList.add('test-container')
+    document.body.appendChild(container)
+
+    const wrapper1 = PortalMount({ props: { target: 'ix-container' } })
+    expect(wrapper1.html()).toEqual('<!--teleport start--><!--teleport end-->')
+    expect(document.body.querySelector('.ix-container')).not.toBeNull()
+
+    const wrapper2 = PortalMount({ props: { target: 'ix-container' } })
+    expect(wrapper2.html()).toEqual('<!--teleport start--><!--teleport end-->')
+    expect(document.body.querySelectorAll('.ix-container').length).toEqual(1)
+
+    await wrapper2.setProps({ target: container })
+    expect(container.hasChildNodes()).toBeTruthy()
+  })
+})
diff --git a/packages/cdk/utils/__tests__/typeof.spec.ts b/packages/cdk/utils/__tests__/typeof.spec.ts
index adcb2bc8..1a316fef 100644
--- a/packages/cdk/utils/__tests__/typeof.spec.ts
+++ b/packages/cdk/utils/__tests__/typeof.spec.ts
@@ -17,6 +17,7 @@ import {
   isNonNil,
   isNumeric,
   hasOwnProperty,
+  isHTMLElement,
 } from '../typeof'
 
 describe('typeof.ts', () => {
@@ -172,4 +173,13 @@ describe('typeof.ts', () => {
     object2.test = undefined
     expect(hasOwnProperty(object2, 'test')).toEqual(true)
   })
+
+  test('isHTMLElement work', () => {
+    const div = document.createElement('div')
+    expect(isHTMLElement(div)).toBeTruthy()
+    expect(isHTMLElement(false)).toBeFalsy()
+    expect(isHTMLElement(10)).toBeFalsy()
+    expect(isHTMLElement('hello')).toBeFalsy()
+    expect(isHTMLElement({ key: 'Hello' })).toBeFalsy()
+  })
 })
diff --git a/packages/cdk/utils/__tests__/uniqueId.spec.ts b/packages/cdk/utils/__tests__/uniqueId.spec.ts
new file mode 100644
index 00000000..b2d7205b
--- /dev/null
+++ b/packages/cdk/utils/__tests__/uniqueId.spec.ts
@@ -0,0 +1,25 @@
+import { uniqueId } from '../uniqueId'
+
+describe('uniqueId.ts', () => {
+  test('basic', () => {
+    const id = uniqueId()
+    expect(id).toBeDefined()
+  })
+
+  test('prefix', () => {
+    const prefix = 'prefix'
+    const id = uniqueId(prefix)
+    expect(id.startsWith(prefix)).toBeTruthy()
+  })
+
+  test('unique', () => {
+    const stack: string[] = []
+    let num = 10
+    while (num) {
+      const id = uniqueId()
+      expect(stack.includes(id)).toBeFalsy()
+      stack.push(id)
+      num--
+    }
+  })
+})