diff --git a/packages/cdk/utils/dom.ts b/packages/cdk/utils/dom.ts
index bba0a9a7f..8e48d6183 100644
--- a/packages/cdk/utils/dom.ts
+++ b/packages/cdk/utils/dom.ts
@@ -2,6 +2,8 @@
const trim = (s: string) => (s || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, '')
+export const raf = window.requestAnimationFrame || (cb => window.setTimeout(cb, 1000 / 60))
+
export function on(
el: HTMLElement | Document | Window,
type: string,
@@ -85,3 +87,16 @@ export function removeClass(el: HTMLElement, cls: string): void {
el.className = trim(curClass)
}
}
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+export function isHTMLElement(el: any): boolean {
+ const div = document.createElement('div')
+
+ try {
+ div.appendChild(el.cloneNode(true))
+ return el.nodeType == 1 ? true : false
+ } catch {
+ return false
+ }
+}
diff --git a/packages/components/back-top/__test__/__snapshots__/backTop.spec.ts.snap b/packages/components/back-top/__test__/__snapshots__/backTop.spec.ts.snap
new file mode 100644
index 000000000..b91616387
--- /dev/null
+++ b/packages/components/back-top/__test__/__snapshots__/backTop.spec.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`BackTop.vue render work 1`] = `"
"`;
diff --git a/packages/components/back-top/__test__/backTop.spec.ts b/packages/components/back-top/__test__/backTop.spec.ts
new file mode 100644
index 000000000..d833a06d8
--- /dev/null
+++ b/packages/components/back-top/__test__/backTop.spec.ts
@@ -0,0 +1,108 @@
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import BackTop from '../src/BackTop.vue'
+import { renderWork, waitRAF, isShow, wait } from '@tests'
+
+const mockFn = jest.fn()
+const warn = jest.spyOn(console, 'warn').mockImplementation()
+
+const backTopMount = (template: string, options = {}) =>
+ mount(
+ {
+ components: {
+ BackTop,
+ },
+ template,
+ ...options,
+ },
+ { attachTo: document.body },
+ )
+
+describe('BackTop.vue', () => {
+ renderWork(BackTop)
+
+ test('scroll work', async () => {
+ const wrapper = backTopMount(
+ `
+
+ `,
+ {
+ beforeUnmount() {
+ mockFn()
+ },
+ },
+ )
+
+ expect(isShow(wrapper.find('.ix-back-top'))).toBe(false)
+
+ wrapper.element.scrollTop = 600
+ await wrapper.trigger('scroll')
+ await wait(1000)
+ expect(isShow(wrapper.find('.ix-back-top'))).toBe(true)
+
+ await wrapper.find('.ix-back-top').trigger('click')
+ await wait(1000)
+ expect(wrapper.element.scrollTop).toBe(0)
+
+ wrapper.unmount()
+ expect(mockFn).toBeCalled()
+ })
+
+ test('props work: target is a HTMLElement', async () => {
+ const wrapper = backTopMount(
+ `
+
+
+
+ `,
+ {
+ data() {
+ return {
+ target: document.documentElement,
+ }
+ },
+ },
+ )
+
+ expect(isShow(wrapper.find('.ix-back-top'))).toBe(false)
+
+ document.documentElement.scrollTop = 600
+ window.dispatchEvent(new Event('scroll'))
+
+ await wait(1000)
+
+ expect(isShow(wrapper.find('.ix-back-top'))).toBe(true)
+ })
+
+ test('props work: target does not exist', async () => {
+ mount(BackTop, {
+ props: {
+ target: '#ix-back-top-test',
+ },
+ })
+
+ await nextTick()
+ expect(warn).toBeCalled()
+ })
+
+ test('props work: target is the default value', async () => {
+ window.scrollTo = mockFn
+ const wrapper = backTopMount(`
+
+
+
+ `)
+
+ document.documentElement.scrollTop = 600
+ window.dispatchEvent(new Event('scroll'))
+ await wait(1000)
+ await wrapper.find('.ix-back-top').trigger('click')
+
+ await waitRAF()
+ expect(mockFn).toBeCalled()
+ })
+})
diff --git a/packages/components/back-top/demo/Basic.vue b/packages/components/back-top/demo/Basic.vue
new file mode 100644
index 000000000..49fd1375b
--- /dev/null
+++ b/packages/components/back-top/demo/Basic.vue
@@ -0,0 +1,4 @@
+
+ Scroll down to see the bottom-right button.
+
+
diff --git a/packages/components/back-top/demo/Custom.vue b/packages/components/back-top/demo/Custom.vue
new file mode 100644
index 000000000..f6725c10d
--- /dev/null
+++ b/packages/components/back-top/demo/Custom.vue
@@ -0,0 +1,23 @@
+
+
+
+
Scroll down to see the bottom-right button.
+
Scroll down to see the bottom-right button.
+
Scroll down to see the bottom-right button.
+
Scroll down to see the bottom-right button.
+
Scroll down to see the bottom-right button.
+
UP
+
+
+
+
+
diff --git a/packages/components/back-top/demo/basic.md b/packages/components/back-top/demo/basic.md
new file mode 100644
index 000000000..a60d1226c
--- /dev/null
+++ b/packages/components/back-top/demo/basic.md
@@ -0,0 +1,9 @@
+---
+order: 0
+title:
+ zh: 基本
+---
+
+## zh
+
+基础用法,滑动页面即可看到右下方的按钮。
diff --git a/packages/components/back-top/demo/custom.md b/packages/components/back-top/demo/custom.md
new file mode 100644
index 000000000..8ff151be9
--- /dev/null
+++ b/packages/components/back-top/demo/custom.md
@@ -0,0 +1,9 @@
+---
+order: 1
+title:
+ zh: 自定义
+---
+
+## zh
+
+自定义显示内容或样式。
diff --git a/packages/components/back-top/docs/index.zh.md b/packages/components/back-top/docs/index.zh.md
new file mode 100644
index 000000000..24beb74ea
--- /dev/null
+++ b/packages/components/back-top/docs/index.zh.md
@@ -0,0 +1,38 @@
+---
+category: components
+type: 其他
+title: BackTop
+subtitle: 回到顶部
+cover:
+---
+
+返回页面顶部的操作按钮
+
+## 何时使用
+
+- 当页面内容区域比较长时。
+- 当用户需要频繁返回顶部查看相关内容时。
+
+## API
+
+### `ix-back-top`
+
+#### Props
+
+| 参数 | 说明 | 类型 | 默认值 | 全局配置 | 备注 |
+| -- | -- | -- | -- | -- | -- |
+| `target` | 需要监听其滚动事件的元素 | `string \| HTMLElement` | `window` | - | 传入 string 类型,会在 mounted 的时候使用 querySelector 来获取元素 |
+| `duration` | 回到顶部所需时间(ms) | `number` | `450` | ✅ | - |
+| `visibility-height` | 滚动高度达到此参数值才出现 | `number` | `400` | ✅ | - |
+
+#### Slots
+
+| 名称 | 说明 | 参数类型 | 备注 |
+| -- | -- | -- | -- |
+| `defalut` | 自定义显示内容 | - | - |
+
+#### Emits
+
+| 名称 | 说明 | 参数类型 | 备注 |
+| -- | -- | -- | -- |
+| `click` | 点击按钮触发的事件 | `event` | - |
diff --git a/packages/components/back-top/index.ts b/packages/components/back-top/index.ts
new file mode 100644
index 000000000..bc4636213
--- /dev/null
+++ b/packages/components/back-top/index.ts
@@ -0,0 +1,7 @@
+import { installComponent } from '@idux/components/core/utils'
+import IxBackTop from './src/BackTop.vue'
+
+IxBackTop.install = installComponent(IxBackTop)
+
+export { IxBackTop }
+export * from './src/types'
diff --git a/packages/components/back-top/src/BackTop.vue b/packages/components/back-top/src/BackTop.vue
new file mode 100644
index 000000000..8d46e3e4e
--- /dev/null
+++ b/packages/components/back-top/src/BackTop.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/back-top/src/types.ts b/packages/components/back-top/src/types.ts
new file mode 100644
index 000000000..38494e834
--- /dev/null
+++ b/packages/components/back-top/src/types.ts
@@ -0,0 +1,11 @@
+import type { DefineComponent } from 'vue'
+
+interface BackTopOriginalProps {
+ target?: string | HTMLElement
+ duration?: number
+ visibilityHeight?: number
+}
+
+export type BackTopProps = Readonly
+
+export type BackTopComponent = InstanceType>
diff --git a/packages/components/back-top/style/index.less b/packages/components/back-top/style/index.less
new file mode 100644
index 000000000..02f588423
--- /dev/null
+++ b/packages/components/back-top/style/index.less
@@ -0,0 +1,44 @@
+@import '../../style/default.less';
+
+@back-top-prefix: ~'@{idux-prefix}-back-top';
+
+.@{back-top-prefix} {
+ position: fixed;
+ right: @back-top-right-xl;
+ bottom: @back-top-bottom;
+ z-index: 99;
+ background-color: @back-top-background-color;
+ width: @back-top-width;
+ height: @back-top-height;
+ color: @white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: @back-top-font-size;
+ border-radius: @back-top-border-radius;
+ cursor: pointer;
+}
+
+@media screen and (min-width: @screen-lg) and (max-width: @screen-lg-max) {
+ .@{back-top-prefix} {
+ right: @back-top-right-lg;
+ }
+}
+
+@media screen and (min-width: @screen-md) and (max-width: @screen-md-max) {
+ .@{back-top-prefix} {
+ right: @back-top-right-md;
+ }
+}
+
+@media screen and (min-width: @screen-sm) and (max-width: @screen-sm-max) {
+ .@{back-top-prefix} {
+ right: @back-top-right-sm;
+ }
+}
+
+@media screen and (max-width: @screen-xs-max) {
+ .@{back-top-prefix} {
+ right: @back-top-right-xs;
+ }
+}
diff --git a/packages/components/components.less b/packages/components/components.less
index 3464f510c..5c322f742 100644
--- a/packages/components/components.less
+++ b/packages/components/components.less
@@ -11,3 +11,4 @@
@import './rate/style/index.less';
@import './checkbox/style/index.less';
@import './input/style/index.less';
+@import './back-top/style/index.less';
diff --git a/packages/components/core/config/defaultConfig.ts b/packages/components/core/config/defaultConfig.ts
index fa0800ebf..76b92a32a 100644
--- a/packages/components/core/config/defaultConfig.ts
+++ b/packages/components/core/config/defaultConfig.ts
@@ -11,6 +11,7 @@ import type {
RateConfig,
InputConfig,
TextareaConfig,
+ BackTopConfig,
} from './types'
import { shallowReactive } from 'vue'
@@ -67,6 +68,11 @@ const textarea = shallowReactive({
clearable: false,
})
+const backTop = shallowReactive({
+ duration: 450,
+ visibilityHeight: 400,
+})
+
export const defaultConfig: GlobalConfig = {
button,
icon,
@@ -79,4 +85,5 @@ export const defaultConfig: GlobalConfig = {
rate,
input,
textarea,
+ backTop,
}
diff --git a/packages/components/core/config/types.ts b/packages/components/core/config/types.ts
index 43a83d2db..5ce3ea61e 100644
--- a/packages/components/core/config/types.ts
+++ b/packages/components/core/config/types.ts
@@ -12,6 +12,7 @@ export interface GlobalConfig {
rate: RateConfig
input: InputConfig
textarea: TextareaConfig
+ backTop: BackTopConfig
}
export type ButtonMode = 'primary' | 'default' | 'dashed' | 'text' | 'link'
@@ -90,3 +91,8 @@ export interface TextareaConfig {
size: InputSize
clearable: boolean
}
+
+export interface BackTopConfig {
+ duration: number
+ visibilityHeight: number
+}
diff --git a/packages/components/core/utils/easingFunctions.ts b/packages/components/core/utils/easingFunctions.ts
index df2f1d17b..fd05aaa0b 100644
--- a/packages/components/core/utils/easingFunctions.ts
+++ b/packages/components/core/utils/easingFunctions.ts
@@ -15,6 +15,5 @@ export function easeInOutQuad(elapsed: number, initialValue: number, amountOfCha
if ((elapsed /= duration / 2) < 1) {
return (amountOfChange / 2) * elapsed * elapsed + initialValue
}
-
return (-amountOfChange / 2) * (--elapsed * (elapsed - 2) - 1) + initialValue
}
diff --git a/packages/components/core/utils/index.ts b/packages/components/core/utils/index.ts
index b6a32d542..b49d86c17 100644
--- a/packages/components/core/utils/index.ts
+++ b/packages/components/core/utils/index.ts
@@ -1,3 +1,4 @@
export * from './installComponent'
export * from './isDevMode'
export * from './useAttrs'
+export * from './easingFunctions'
diff --git a/packages/components/icon/src/icons.ts b/packages/components/icon/src/icons.ts
index 362d9778f..d52c64266 100644
--- a/packages/components/icon/src/icons.ts
+++ b/packages/components/icon/src/icons.ts
@@ -15,6 +15,7 @@ import { CloseCircle } from '../definitions/closeCircle'
import { InfoCircle } from '../definitions/infoCircle'
import { ExclamationCircle } from '../definitions/exclamationCircle'
import { Star } from '../definitions/star'
+import { VerticalAlignTop } from '../definitions/verticalAlignTop'
export const innerStaticIcons: IconDefinition[] = [
Up,
@@ -33,4 +34,5 @@ export const innerStaticIcons: IconDefinition[] = [
InfoCircle,
ExclamationCircle,
Star,
+ VerticalAlignTop,
]
diff --git a/packages/components/index.ts b/packages/components/index.ts
index 140bea821..f5a1e44d5 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -12,6 +12,7 @@ import { IxTypography } from './typography'
import { IxRate } from './rate'
import { IxCheckbox, IxCheckboxGroup } from './checkbox'
import { IxInput, IxTextarea } from './input'
+import { IxBackTop } from './back-top'
const components = [
IxButton,
@@ -29,6 +30,7 @@ const components = [
IxCheckboxGroup,
IxInput,
IxTextarea,
+ IxBackTop,
]
const directives: Record = {
@@ -67,3 +69,4 @@ export { IxTypography }
export { IxRate }
export { IxCheckbox, IxCheckboxGroup }
export { IxInput, IxTextarea }
+export { IxBackTop }
diff --git a/packages/components/style/default.less b/packages/components/style/default.less
index 2bb41921e..1cba9cd6f 100644
--- a/packages/components/style/default.less
+++ b/packages/components/style/default.less
@@ -1,5 +1,6 @@
@import './mixins/index.less';
@import './variable/index.less';
+@import './motion/index.less';
@theme: default;
@@ -241,3 +242,22 @@
@input-border-radius: @border-radius-md;
@input-transition-duration: @transition-duration-base;
+
+/* back-top -------------------------- */
+@back-top-height: @height-lg;
+@back-top-width: @height-lg;
+
+@back-top-font-size: @font-size-2xl;
+
+@back-top-border-radius: 50%;
+
+@back-top-right-gutter: 32px;
+@back-top-right-xs: (@back-top-right-gutter / 2);
+@back-top-right-sm: @back-top-right-gutter;
+@back-top-right-md: @back-top-right-gutter * 1.5;
+@back-top-right-lg: @back-top-right-gutter * 2;
+@back-top-right-xl: @back-top-right-gutter * 3;
+
+@back-top-bottom: 50px;
+
+@back-top-background-color: @primary-color;
diff --git a/packages/components/style/motion/index.less b/packages/components/style/motion/index.less
new file mode 100644
index 000000000..a8450581a
--- /dev/null
+++ b/packages/components/style/motion/index.less
@@ -0,0 +1,4 @@
+@import './fade.less';
+@import './move.less';
+@import './slide.less';
+@import './zoom.less';
diff --git a/packages/components/style/variable/index.less b/packages/components/style/variable/index.less
index db5c8c7cf..c729fc68f 100644
--- a/packages/components/style/variable/index.less
+++ b/packages/components/style/variable/index.less
@@ -4,3 +4,4 @@
@import './font.less';
@import './shadow.less';
@import './spacing.less';
+@import './screen.less';
diff --git a/packages/components/style/variable/screen.less b/packages/components/style/variable/screen.less
new file mode 100644
index 000000000..faa08d0b2
--- /dev/null
+++ b/packages/components/style/variable/screen.less
@@ -0,0 +1,16 @@
+@screen-sm: 768px;
+@screen-sm-min: @screen-sm;
+
+@screen-md: 1024px;
+@screen-md-min: @screen-md;
+
+@screen-lg: 1280px;
+@screen-lg-min: @screen-lg;
+
+@screen-xl: 1720px;
+@screen-xl-min: @screen-xl;
+
+@screen-xs-max: (@screen-sm-min - 0.01px);
+@screen-sm-max: (@screen-md-min - 0.01px);
+@screen-md-max: (@screen-lg-min - 0.01px);
+@screen-lg-max: (@screen-xl-min - 0.01px);
diff --git a/tests/utils.ts b/tests/utils.ts
index 2cb2f2565..b028659b1 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { mount } from '@vue/test-utils'
+import { mount, DOMWrapper } from '@vue/test-utils'
export const wait = (timeout?: number): Promise => {
return new Promise(resolve => setTimeout(resolve, timeout))
@@ -35,3 +35,7 @@ export const renderWork = (component: any, options = {}): void => {
}).not.toThrow()
})
}
+
+export const isShow = (wrapper: DOMWrapper): boolean => {
+ return window.getComputedStyle(wrapper.element).display !== 'none'
+}