feat(affix): new component affix (#1260)

* feat(affix): new component affix

re #1247

* feat: add test

* perf: update state

n

* docs: update docs

* perf: pref the component
This commit is contained in:
kooriookami 2021-01-14 03:02:47 -06:00 committed by GitHub
parent 30f1947c47
commit 8bda98b075
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 639 additions and 2 deletions

View File

@ -0,0 +1,158 @@
import { mount } from '@vue/test-utils'
import Affix from '../src/index.vue'
import { defineGetter, makeScroll } from '@element-plus/test-utils'
let clientHeightRestore = null
const _mount = (template: string) => mount({
components: {
'el-affix': Affix,
},
template,
}, { attachTo: document.body })
const AXIOM = 'Rem is the best girl'
beforeAll(() => {
clientHeightRestore = defineGetter(window.HTMLElement.prototype, 'clientHeight', 1000, 0)
})
afterAll(() => {
clientHeightRestore()
})
describe('Affix.vue', () => {
test('render test', async () => {
const wrapper = _mount(`
<el-affix>${AXIOM}</el-affix>
`)
expect(wrapper.text()).toEqual(AXIOM)
const mockAffixRect = jest.spyOn(wrapper.find('.el-affix').element, 'getBoundingClientRect').mockReturnValue({
height: 40,
width: 1000,
top: -100,
bottom: -80,
} as DOMRect)
const mockDocumentRect = jest.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue({
height: 200,
width: 1000,
top: 0,
bottom: 200,
} as DOMRect)
expect(wrapper.find('.el-affix--fixed').exists()).toBe(false)
await makeScroll(document.documentElement, 'scrollTop', 200)
expect(wrapper.find('.el-affix--fixed').exists()).toBe(true)
mockAffixRect.mockRestore()
mockDocumentRect.mockRestore()
})
test('should render offset props', async () => {
const wrapper = _mount(`
<el-affix :offset="30">${AXIOM}</el-affix>
`)
const mockAffixRect = jest.spyOn(wrapper.find('.el-affix').element, 'getBoundingClientRect').mockReturnValue({
height: 40,
width: 1000,
top: -100,
bottom: -80,
} as DOMRect)
const mockDocumentRect = jest.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue({
height: 200,
width: 1000,
top: 0,
bottom: 200,
} as DOMRect)
await makeScroll(document.documentElement, 'scrollTop', 200)
expect(wrapper.find('.el-affix--fixed').exists()).toBe(true)
expect(wrapper.find('.el-affix--fixed').attributes('style')).toContain('top: 30px;')
mockAffixRect.mockRestore()
mockDocumentRect.mockRestore()
})
test('should render position props', async () => {
const wrapper = _mount(`
<el-affix position="bottom" :offset="20">${AXIOM}</el-affix>
`)
const mockAffixRect = jest.spyOn(wrapper.find('.el-affix').element, 'getBoundingClientRect').mockReturnValue({
height: 40,
width: 1000,
top: 2000,
bottom: 2040,
} as DOMRect)
const mockDocumentRect = jest.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue({
height: 200,
width: 1000,
top: 0,
bottom: 200,
} as DOMRect)
await makeScroll(document.documentElement, 'scrollTop', 0)
expect(wrapper.find('.el-affix--fixed').exists()).toBe(true)
expect(wrapper.find('.el-affix--fixed').attributes('style')).toContain('bottom: 20px;')
mockAffixRect.mockRestore()
mockDocumentRect.mockRestore()
})
test('should render target props', async () => {
const wrapper = _mount(`
<div class="target" style="height: 200px">
<el-affix target=".target">${AXIOM}</el-affix>
</div>
<div style="height: 1000px"></div>
`)
const mockAffixRect = jest.spyOn(wrapper.find('.el-affix').element, 'getBoundingClientRect').mockReturnValue({
height: 40,
width: 1000,
top: -100,
bottom: -60,
} as DOMRect)
const mockTargetRect = jest.spyOn(wrapper.find('.target').element, 'getBoundingClientRect').mockReturnValue({
height: 200,
width: 1000,
top: -100,
bottom: 100,
} as DOMRect)
await makeScroll(document.documentElement, 'scrollTop', 100)
expect(wrapper.find('.el-affix--fixed').exists()).toBe(true)
mockAffixRect.mockReturnValue({
height: 40,
width: 1000,
top: -300,
bottom: -260,
} as DOMRect)
mockTargetRect.mockReturnValue({
height: 40,
width: 1000,
top: -300,
bottom: -260,
} as DOMRect)
await makeScroll(document.documentElement, 'scrollTop', 300)
expect(wrapper.find('.el-affix--fixed').exists()).toBe(false)
mockAffixRect.mockRestore()
mockTargetRect.mockRestore()
})
test('should render z-index props', async () => {
const wrapper = _mount(`
<el-affix :z-index="1000">${AXIOM}</el-affix>
`)
const mockAffixRect = jest.spyOn(wrapper.find('.el-affix').element, 'getBoundingClientRect').mockReturnValue({
height: 40,
width: 1000,
top: -100,
bottom: -80,
} as DOMRect)
const mockDocumentRect = jest.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue({
height: 200,
width: 1000,
top: 0,
bottom: 200,
} as DOMRect)
await makeScroll(document.documentElement, 'scrollTop', 200)
expect(wrapper.find('.el-affix--fixed').exists()).toBe(true)
expect(wrapper.find('.el-affix--fixed').attributes('style')).toContain('z-index: 1000;')
mockAffixRect.mockRestore()
mockDocumentRect.mockRestore()
})
})

11
packages/affix/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { App } from 'vue'
import type { SFCWithInstall } from '@element-plus/utils/types'
import Affix from './src/index.vue'
Affix.install = (app: App): void => {
app.component(Affix.name, Affix)
}
const _Affix: SFCWithInstall<typeof Affix> = Affix
export default _Affix

View File

@ -0,0 +1,12 @@
{
"name": "@element-plus/affix",
"version": "0.0.0",
"main": "dist/index.js",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.5"
},
"devDependencies": {
"@vue/test-utils": "^2.0.0-beta.3"
}
}

View File

@ -0,0 +1,141 @@
<template>
<div ref="root" class="el-affix" :style="rootStyle">
<div :class="{'el-affix--fixed': state.fixed}" :style="affixStyle">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onBeforeUnmount, onMounted, PropType, reactive, ref, watch } from 'vue'
import { getScrollContainer, off, on } from '@element-plus/utils/dom'
import { addResizeListener, removeResizeListener } from '@element-plus/utils/resize-event'
type Position = 'top' | 'bottom'
export default defineComponent({
name: 'ElAffix',
props: {
zIndex: {
type: Number,
default: 100,
},
target: {
type: String,
default: '',
},
offset: {
type: Number,
default: 0,
},
position: {
type: String as PropType<Position>,
default: 'top',
},
},
emits: ['scroll', 'change'],
setup(props, { emit }) {
const target = ref(null)
const root = ref(null)
const scrollContainer = ref(null)
const state = reactive({
fixed: false,
height: 0, // height of root
width: 0, // width of root
scrollTop: 0, // scrollTop of documentElement
clientHeight: 0, // clientHeight of documentElement
transform: 0,
})
const rootStyle = computed(() => {
return {
height: state.fixed ? `${state.height}px` : '',
width: state.fixed ? `${state.width}px` : '',
}
})
const affixStyle = computed(() => {
if (!state.fixed) {
return
}
const offset = props.offset ? `${props.offset}px` : 0
const transform = state.transform ? `translateY(${state.transform}px)` : ''
return {
height: `${state.height}px`,
width: `${state.width}px`,
top: props.position === 'top' ? offset : '',
bottom: props.position === 'bottom' ? offset : '',
transform: transform,
zIndex: props.zIndex,
}
})
const updateState = () => {
const rootRect = root.value.getBoundingClientRect()
const targetRect = target.value.getBoundingClientRect()
state.height = rootRect.height
state.width = rootRect.width
state.scrollTop = scrollContainer.value === window ? document.documentElement.scrollTop : scrollContainer.value.scrollTop
state.clientHeight = document.documentElement.clientHeight
if (props.position === 'top') {
if (target.value) {
const difference = targetRect.bottom - props.offset - state.height
state.fixed = props.offset > rootRect.top && targetRect.bottom > 0
state.transform = difference < 0 ? difference : 0
} else {
state.fixed = props.offset > rootRect.top
}
} else {
if (target.value) {
const difference = state.clientHeight - targetRect.top - props.offset - state.height
state.fixed = state.clientHeight - props.offset < rootRect.bottom && state.clientHeight > targetRect.top
state.transform = difference < 0 ? -difference : 0
} else {
state.fixed = state.clientHeight - props.offset < rootRect.bottom
}
}
}
const onScroll = () => {
updateState()
emit('scroll', {
scrollTop: state.scrollTop,
fixed: state.fixed,
})
}
watch(() => state.fixed, () => {
emit('change', state.fixed)
})
onMounted(() => {
if (props.target) {
target.value = document.querySelector(props.target)
if (!target.value) {
throw new Error(`target is not existed: ${props.target}`)
}
} else {
target.value = document.documentElement
}
scrollContainer.value = getScrollContainer(root.value)
on(scrollContainer.value, 'scroll', onScroll)
addResizeListener(root.value, updateState)
})
onBeforeUnmount(() => {
off(scrollContainer.value, 'scroll', onScroll)
removeResizeListener(root.value, updateState)
})
return {
root,
state,
rootStyle,
affixStyle,
}
},
})
</script>

View File

@ -1,4 +1,5 @@
import type { App } from 'vue'
import ElAffix from '@element-plus/affix'
import ElAlert from '@element-plus/alert'
import ElAside from '@element-plus/aside'
import ElAutocomplete from '@element-plus/autocomplete'
@ -92,19 +93,20 @@ import { use } from '@element-plus/locale'
// if you encountered problems alike "Can't resolve './version'"
// please run `yarn bootstrap` first
import { version as version_ } from './version'
import { setConfig } from '@element-plus/utils/config'
import type { InstallOptions } from '@element-plus/utils/config'
import { setConfig } from '@element-plus/utils/config'
const version = version_ // version_ to fix tsc issue
const locale = use
const defaultInstallOpt: InstallOptions = {
const defaultInstallOpt: InstallOptions = {
size: '' as ComponentSize,
zIndex: 2000,
}
const components = [
ElAffix,
ElAlert,
ElAside,
ElAutocomplete,
@ -215,6 +217,7 @@ const install = (app: App, opt: InstallOptions): void => {
}
export {
ElAffix,
ElAlert,
ElAside,
ElAutocomplete,

View File

@ -0,0 +1,7 @@
@import "mixins/mixins";
@include b(affix) {
@include m(fixed) {
position: fixed;
}
}

View File

@ -85,3 +85,4 @@
@import "./skeleton.scss";
@import "./skeleton-item.scss";
@import "./empty.scss";
@import "./affix.scss";

View File

@ -0,0 +1,8 @@
.demo-block.demo-affix {
.affix-container {
text-align: center;
height: 400px;
border-radius: 4px;
background: #ECF5FF;
}
}

View File

@ -1,3 +1,4 @@
@import "./affix.scss";
@import "./alert.scss";
@import "./badge.scss";
@import "./border.scss";

View File

@ -0,0 +1,55 @@
## Affix
Fix the element to a specific visible area.
### Basic usage
Affix is fixed at the top of the page by default.
:::demo You can set `offset` attribute to change the offset topthe default value is 0。
```html
<el-affix :offset="120">
<el-button type="primary">Offset top 120px</el-button>
</el-affix>
```
:::
### Target container
You can set `target` attribute to keep the affix in the container at all times. It will be hidden if out of range.
:::demo Please notice that the container avoid having scrollbar.
```html
<div class="affix-container">
<el-affix target=".affix-container" :offset="80">
<el-button type="primary">Target container</el-button>
</el-affix>
</div>
```
:::
### Fixed position
The affix component provides two fixed positions: `top` and `bottom`.
:::demo You can set `position` attribute to change the fixed position, the default value is `top`.
```html
<el-affix position="bottom" :offset="20">
<el-button type="primary">Offset bottom 20px</el-button>
</el-affix>
```
:::
### Attributes
| Attribute | Description | Type | Accepted Values | Default |
|---------- |-------------- |---------- |-------------------------------- |-------- |
| offset | offset distance | number | — | 0 |
| position | position of affix | string | top / bottom | top |
| target | target container (CSS selector) | string | — | — |
| z-index | z-index of affix | number | — | 100 |
### Events
| Event Name | Description | Parameters |
|---------- |-------- |---------- |
| change | triggers when fixed state changed | (value: boolean) |
| scroll | triggers when scrolling | scroll top and fixed state |

55
website/docs/es/affix.md Normal file
View File

@ -0,0 +1,55 @@
## Affix
Fix the element to a specific visible area.
### Basic usage
Affix is fixed at the top of the page by default.
:::demo You can set `offset` attribute to change the offset topthe default value is 0。
```html
<el-affix :offset="120">
<el-button type="primary">Offset top 120px</el-button>
</el-affix>
```
:::
### Target container
You can set `target` attribute to keep the affix in the container at all times. It will be hidden if out of range.
:::demo Please notice that the container avoid having scrollbar.
```html
<div class="affix-container">
<el-affix target=".affix-container" :offset="80">
<el-button type="primary">Target container</el-button>
</el-affix>
</div>
```
:::
### Fixed position
The affix component provides two fixed positions: `top` and `bottom`.
:::demo You can set `position` attribute to change the fixed position, the default value is `top`.
```html
<el-affix position="bottom" :offset="20">
<el-button type="primary">Offset bottom 20px</el-button>
</el-affix>
```
:::
### Attributes
| Attribute | Description | Type | Accepted Values | Default |
|---------- |-------------- |---------- |-------------------------------- |-------- |
| offset | offset distance | number | — | 0 |
| position | position of affix | string | top / bottom | top |
| target | target container (CSS selector) | string | — | — |
| z-index | z-index of affix | number | — | 100 |
### Events
| Event Name | Description | Parameters |
|---------- |-------- |---------- |
| change | triggers when fixed state changed | (value: boolean) |
| scroll | triggers when scrolling | scroll top and fixed state |

View File

@ -0,0 +1,55 @@
## Affix
Fix the element to a specific visible area.
### Basic usage
Affix is fixed at the top of the page by default.
:::demo You can set `offset` attribute to change the offset topthe default value is 0。
```html
<el-affix :offset="120">
<el-button type="primary">Offset top 120px</el-button>
</el-affix>
```
:::
### Target container
You can set `target` attribute to keep the affix in the container at all times. It will be hidden if out of range.
:::demo Please notice that the container avoid having scrollbar.
```html
<div class="affix-container">
<el-affix target=".affix-container" :offset="80">
<el-button type="primary">Target container</el-button>
</el-affix>
</div>
```
:::
### Fixed position
The affix component provides two fixed positions: `top` and `bottom`.
:::demo You can set `position` attribute to change the fixed position, the default value is `top`.
```html
<el-affix position="bottom" :offset="20">
<el-button type="primary">Offset bottom 20px</el-button>
</el-affix>
```
:::
### Attributes
| Attribute | Description | Type | Accepted Values | Default |
|---------- |-------------- |---------- |-------------------------------- |-------- |
| offset | offset distance | number | — | 0 |
| position | position of affix | string | top / bottom | top |
| target | target container (CSS selector) | string | — | — |
| z-index | z-index of affix | number | — | 100 |
### Events
| Event Name | Description | Parameters |
|---------- |-------- |---------- |
| change | triggers when fixed state changed | (value: boolean) |
| scroll | triggers when scrolling | scroll top and fixed state |

55
website/docs/jp/affix.md Normal file
View File

@ -0,0 +1,55 @@
## Affix
Fix the element to a specific visible area.
### Basic usage
Affix is fixed at the top of the page by default.
:::demo You can set `offset` attribute to change the offset topthe default value is 0。
```html
<el-affix :offset="120">
<el-button type="primary">Offset top 120px</el-button>
</el-affix>
```
:::
### Target container
You can set `target` attribute to keep the affix in the container at all times. It will be hidden if out of range.
:::demo Please notice that the container avoid having scrollbar.
```html
<div class="affix-container">
<el-affix target=".affix-container" :offset="80">
<el-button type="primary">Target container</el-button>
</el-affix>
</div>
```
:::
### Fixed position
The affix component provides two fixed positions: `top` and `bottom`.
:::demo You can set `position` attribute to change the fixed position, the default value is `top`.
```html
<el-affix position="bottom" :offset="20">
<el-button type="primary">Offset bottom 20px</el-button>
</el-affix>
```
:::
### Attributes
| Attribute | Description | Type | Accepted Values | Default |
|---------- |-------------- |---------- |-------------------------------- |-------- |
| offset | offset distance | number | — | 0 |
| position | position of affix | string | top / bottom | top |
| target | target container (CSS selector) | string | — | — |
| z-index | z-index of affix | number | — | 100 |
### Events
| Event Name | Description | Parameters |
|---------- |-------- |---------- |
| change | triggers when fixed state changed | (value: boolean) |
| scroll | triggers when scrolling | scroll top and fixed state |

View File

@ -0,0 +1,55 @@
## Affix 固钉
将页面元素固定在特定可视区域。
### 基本用法
固钉默认固定在页面顶部。
:::demo 通过设置 `offset` 属性来改变吸顶距离,默认值为 0。
```html
<el-affix :offset="120">
<el-button type="primary">距离顶部 120px</el-button>
</el-affix>
```
:::
### 指定容器
通过设置 `target` 属性,让固钉始终保持在容器内,超过范围则隐藏。
:::demo 请注意容器避免出现滚动条。
```html
<div class="affix-container">
<el-affix target=".affix-container" :offset="80">
<el-button type="primary">指定容器</el-button>
</el-affix>
</div>
```
:::
### 固定位置
Affix 组件提供了两个固定位置:`top` 和 `bottom`
:::demo 通过设置 `position` 属性来改变固定位置,默认值为 `top`
```html
<el-affix position="bottom" :offset="20">
<el-button type="primary">距离底部 20px</el-button>
</el-affix>
```
:::
### Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|---------- |-------------- |---------- |-------------------------------- |-------- |
| offset | 偏移距离 | number | — | 0 |
| position | 固钉位置 | string | top / bottom | top |
| target | 指定容器CSS 选择器) | string | — | — |
| z-index | 固钉层级 | number | — | 100 |
### Events
| 事件名称 | 说明 | 回调参数 |
|---------- |-------- |---------- |
| change | 固钉状态改变时触发的事件 | (value: boolean) |
| scroll | 滚动时触发的事件 | 滚动距离和固钉状态 |

View File

@ -227,6 +227,10 @@
{
"groupName": "Navigation",
"list": [
{
"path": "/affix",
"title": "Affix 固钉"
},
{
"path": "/menu",
"title": "NavMenu 导航菜单"
@ -541,6 +545,10 @@
{
"groupName": "Navigation",
"list": [
{
"path": "/affix",
"title": "Affix"
},
{
"path": "/menu",
"title": "NavMenu"
@ -859,6 +867,10 @@
{
"groupName": "Navigation",
"list": [
{
"path": "/affix",
"title": "Affix"
},
{
"path": "/menu",
"title": "NavMenu"
@ -1177,6 +1189,10 @@
{
"groupName": "Navigation",
"list": [
{
"path": "/affix",
"title": "Affix"
},
{
"path": "/menu",
"title": "NavMenu"
@ -1495,6 +1511,10 @@
{
"groupName": "Navigation",
"list": [
{
"path": "/affix",
"title": "Affix"
},
{
"path": "/menu",
"title": "NavMenu"