feat: add pagination (#195)

* feat(component): add type 'pagination' for form usage #120

* feat(pagination): optimize pagination code

* fix(pagination): optimize code

* feat(component): add type 'pagination' for form usage #120

* feat(pagination): optimize pagination code

* fix(pagination): optimize code

* feat(pagination): update component

Co-authored-by: pannana <pannana@ucloud.cn>
Co-authored-by: zouhang <zouhang@didiglobal.com>
This commit is contained in:
PannanaAlex 2020-10-09 12:04:25 +08:00 committed by GitHub
parent 37a5a9156d
commit cc2ab5c417
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 989 additions and 2 deletions

View File

@ -46,6 +46,7 @@ import ElTree from '@element-plus/tree'
import ElColorPicker from '@element-plus/color-picker'
import ElSelect from '@element-plus/select'
import ElTimeSelect from '@element-plus/time-select'
import ElPagination from '@element-plus/pagination'
export {
ElAlert,
@ -94,6 +95,7 @@ export {
ElColorPicker,
ElSelect,
ElTimeSelect,
ElPagination,
}
const install = (app: App): void => {
@ -144,6 +146,7 @@ const install = (app: App): void => {
ElColorPicker(app)
ElSelect(app)
ElTimeSelect(app)
ElPagination(app)
}
const elementUI = {

View File

@ -42,6 +42,7 @@
"@element-plus/tabs": "^0.0.0",
"@element-plus/form": "^0.0.0",
"@element-plus/tree": "^0.0.0",
"@element-plus/select": "^0.0.0"
"@element-plus/select": "^0.0.0",
"@element-plus/pagination": "^0.0.0"
}
}

View File

@ -0,0 +1,194 @@
import { mount } from '@vue/test-utils'
import { sleep } from '@element-plus/test-utils'
import Pagination from '../src/index'
const TIME_OUT = 100
describe('Pagination.vue', () => {
test('layout', () => {
const wrapper = mount(Pagination, {
props: {
layout: 'prev, pager, next',
},
})
expect(wrapper.find('button.btn-prev').exists()).toBe(true)
expect(wrapper.find('ul.el-pager').exists()).toBe(true)
expect(wrapper.find('button.btn-next').exists()).toBe(true)
expect(wrapper.find('.el-pagination__jump').exists()).toBe(false)
expect(wrapper.find('.el-pagination__rightwrapper').exists()).toBe(false)
expect(wrapper.find('.el-pagination__total').exists()).toBe(false)
})
test('slot', () => {
const TestComponent = {
template: `
<el-pagination
layout="slot, prev, pager, next"
:page-size="25"
:total="100">
<span class="slot-test">slot test</span>
</el-pagination>
`,
components: {
'el-pagination': Pagination,
},
}
const wrapper = mount(TestComponent)
expect(wrapper.find('.slot-test').exists()).toBe(true)
})
test('small', () => {
const wrapper = mount(Pagination, {
props: {
small: true,
},
})
expect(wrapper.vm.$el.classList.contains('el-pagination--small')).toBe(true)
})
test('pageSize', () => {
const wrapper = mount(Pagination, {
props: {
pageSize: 25,
total: 100,
},
})
expect(wrapper.findAll('li.number').length).toBe(4)
})
test('pageSize: NaN', () => {
const wrapper = mount(Pagination, {
props: {
pageSize: NaN,
total: 100,
},
})
expect(wrapper.findAll('li.number').length).toBe(7)
})
test('pageCount', () => {
const wrapper = mount(Pagination, {
props: {
pageSize: 25,
pagerCount: 4,
},
})
expect(wrapper.findAll('li.number').length).toBe(4)
})
test('pagerCount', () => {
const wrapper = mount(Pagination, {
props: {
pageSize: 25,
total: 1000,
pagerCount: 21,
},
})
expect(wrapper.findAll('li.number').length).toBe(21)
})
test('will work without total & page-count', async () => {
const wrapper = mount(Pagination, {
props: {
pageSize: 25,
currentPage: 2,
},
})
wrapper.find('.btn-prev').trigger('click')
await sleep(TIME_OUT)
expect(wrapper.vm.internalCurrentPage).toEqual(1)
wrapper.find('.btn-prev').trigger('click')
expect(wrapper.vm.internalCurrentPage).toEqual(1)
})
test('currentPage', () => {
const wrapper = mount(Pagination, {
props: {
pageSize: 20,
total: 200,
currentPage: 3,
},
})
expect(wrapper.find('li.number.active').text()).toEqual('3')
})
test('currentPage: NaN', () => {
const wrapper = mount(Pagination, {
props: {
pageSize: 20,
total: 200,
currentPage: NaN,
},
})
expect(wrapper.find('li.number.active').text()).toEqual('1')
expect(wrapper.vm.$el.querySelectorAll('li.number').length).toBe(7)
})
test('layout is empty', () => {
const wrapper = mount(Pagination, {
props: {
layout: '',
},
})
expect(wrapper.vm.$el.textContent).toEqual('')
})
})
describe('click pager', () => {
test('click ul', () => {
const wrapper = mount(Pagination, {
props: {
total: 1000,
},
})
wrapper.find('.el-pager').trigger('click')
expect(wrapper.vm.internalCurrentPage).toEqual(1)
})
test('click li', () => {
const wrapper = mount(Pagination, {
props: {
total: 1000,
},
})
wrapper.findAll('.el-pager li.number')[1].trigger('click')
expect(wrapper.vm.internalCurrentPage).toEqual(2)
})
test('click next icon-more', () => {
const wrapper = mount(Pagination, {
props: {
total: 1000,
},
})
wrapper.find('.btn-quicknext.more').trigger('click')
expect(wrapper.vm.internalCurrentPage).toEqual(6)
})
test('click prev icon-more', async () => {
const wrapper = mount(Pagination, {
props: {
total: 1000,
},
})
wrapper.find('.btn-quicknext.more').trigger('click')
await sleep(TIME_OUT)
expect(wrapper.find('.btn-quickprev.more').exists()).toBe(true)
wrapper.find('.btn-quickprev.more').trigger('click')
expect(wrapper.vm.internalCurrentPage).toEqual(1)
})
test('click last page', async () => {
const wrapper = mount(Pagination, {
props: {
total: 1000,
},
})
const nodes = wrapper.findAll('li.number')
nodes[nodes.length - 1].trigger('click')
await sleep(TIME_OUT)
expect(wrapper.find('.btn-quickprev.more').exists()).toBe(true)
expect(wrapper.find('.btn-quicknext.more').exists()).toBe(false)
})
})

View File

@ -0,0 +1,5 @@
import { App } from 'vue'
import Pagination from './src/index'
export default (app: App): void => {
app.component(Pagination.name, Pagination)
}

View File

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

View File

@ -0,0 +1,291 @@
import {
defineComponent,
h,
ref,
computed,
watchEffect,
watch,
nextTick,
provide,
} from 'vue'
import { IPagination } from './pagination'
import Prev from './prev.vue'
import Next from './next.vue'
import Sizes from './sizes.vue'
import Jumper from './jumper.vue'
import Total from './total.vue'
import Pager from './pager.vue'
export default defineComponent({
name: 'ElPagination',
components: {
Prev,
Next,
Sizes,
Jumper,
Total,
Pager,
},
props: {
pageSize: {
type: Number,
default: 10,
},
small: Boolean,
total: {
type: Number,
default: 1000,
},
pageCount: {
type: Number,
default: 50,
},
pagerCount: {
type: Number,
validator: (value: number) => {
return (
(value | 0) === value && value > 4 && value < 22 && value % 2 === 1
)
},
default: 7,
},
currentPage: {
type: Number,
default: 1,
},
layout: {
type: String,
default: 'prev, pager, next, jumper, ->, total',
},
pageSizes: {
type: Array,
default: () => {
return [10, 20, 30, 40, 50, 100]
},
},
popperClass: {
type: String,
default: '',
},
prevText: {
type: String,
default: '',
},
nextText: {
type: String,
default: '',
},
background: Boolean,
disabled: Boolean,
hideOnSinglePage: Boolean,
},
emits: [
'size-change',
'current-change',
'prev-click',
'next-click',
'update:currentPage',
],
setup(props, { emit }) {
const internalCurrentPage = ref(1)
const lastEmittedPage = ref(-1)
const userChangePageSize = ref(false)
const internalPageSize = ref(0)
const internalPageCount = computed<Nullable<number>>(() => {
if (typeof props.total === 'number') {
return Math.max(1, Math.ceil(props.total / internalPageSize.value))
} else if (typeof props.pageCount === 'number') {
return Math.max(1, props.pageCount)
}
return null
})
watchEffect(() => {
internalCurrentPage.value = getValidCurrentPage(props.currentPage)
})
watchEffect(() => {
internalPageSize.value = isNaN(props.pageSize) ? 10 : props.pageSize
})
watchEffect(() => {
emit('update:currentPage', internalCurrentPage.value)
lastEmittedPage.value = -1
})
watch(() => internalPageCount.value,val => {
const oldPage = internalCurrentPage.value
if (val > 0 && oldPage === 0) {
internalCurrentPage.value = 1
} else if (oldPage > val) {
internalCurrentPage.value = val === 0 ? 1 : val
userChangePageSize.value && emitChange()
}
userChangePageSize.value = false
})
function emitChange() {
nextTick(() => {
if (internalCurrentPage.value !== lastEmittedPage.value || userChangePageSize) {
emit('current-change', internalCurrentPage.value)
lastEmittedPage.value = internalCurrentPage.value
userChangePageSize.value = false
}
})
}
function handleCurrentChange(val: number) {
internalCurrentPage.value = getValidCurrentPage(val)
userChangePageSize.value = true
emitChange()
}
function handleSizesChange(val: number) {
userChangePageSize.value = true
internalPageSize.value = val
}
function prev() {
if (props.disabled) return
const newVal = internalCurrentPage.value - 1
internalCurrentPage.value = getValidCurrentPage(newVal)
emit('prev-click', internalCurrentPage)
emitChange()
}
function next() {
if (props.disabled) return
const newVal = internalCurrentPage.value + 1
internalCurrentPage.value = getValidCurrentPage(newVal)
emit('next-click', internalCurrentPage.value)
emitChange()
}
function getValidCurrentPage(value: number | string) {
if (typeof value === 'string') {
value = parseInt(value, 10)
}
let resetValue: number | undefined
const havePageCount = typeof internalPageCount.value === 'number'
if (!havePageCount) {
if (isNaN(value) || value < 1) resetValue = 1
} else {
if (value < 1) {
resetValue = 1
} else if (value > internalPageCount.value) {
resetValue = internalPageCount.value
}
}
if (resetValue === undefined && isNaN(value)) {
resetValue = 1
} else if (resetValue === 0) {
resetValue = 1
}
return resetValue === undefined ? value : resetValue
}
provide<IPagination>('pagination', {
pageCount: computed(() => props.pageCount),
disabled: computed(() => props.disabled),
currentPage: computed(() => internalCurrentPage.value),
changeEvent: handleCurrentChange,
handleSizesChange,
})
return {
internalCurrentPage,
internalPageSize,
lastEmittedPage,
userChangePageSize,
internalPageCount,
getValidCurrentPage,
emitChange,
handleCurrentChange,
prev,
next,
}
},
render() {
const layout = this.layout
if (!layout) return null
if (this.hideOnSinglePage && (!this.internalPageCount || this.internalPageCount === 1)) return null
const rootNode = h('div', { class: ['el-pagination', { 'is-background': this.background, 'el-pagination--small': this.small }] })
const rootChilds = []
const rightWrapperRoot = h('div', { class: 'el-pagination__rightwrapper' })
const rightWrapperChilds = []
const TEMPLATE_MAP = {
prev: h(Prev, {
disabled: this.disabled,
currentPage: this.internalCurrentPage,
prevText: this.prevText,
onClick: this.prev,
}),
jumper: h(Jumper),
pager: h(Pager, {
currentPage: this.internalCurrentPage,
pageCount: this.internalPageCount,
pagerCount: this.pagerCount,
onChange: this.handleCurrentChange,
disabled: this.disabled,
}),
next: h(Next, {
disabled: this.disabled,
currentPage: this.internalCurrentPage,
pageCount: this.internalPageCount,
nextText: this.nextText, onClick: this.next,
}),
sizes: h(Sizes, {
pageSize: this.pageSize,
pageSizes: this.pageSizes,
popperClass: this.popperClass,
disabled: this.disabled,
}),
slot: this.$slots?.default?.() ?? null,
total: h(Total, { total: this.total }),
}
const components = layout.split(',').map((item: string) => item.trim())
let haveRightWrapper = false
components.forEach(compo => {
if (compo === '->') {
haveRightWrapper = true
return
}
if (!haveRightWrapper) {
rootChilds.push(TEMPLATE_MAP[compo])
} else {
rightWrapperChilds.push(TEMPLATE_MAP[compo])
}
})
if (haveRightWrapper) {
rootChilds.unshift(rightWrapperRoot)
}
return h(rootNode, {}, rootChilds)
},
})

View File

@ -0,0 +1,57 @@
<template>
<span class="el-pagination__jump">
{{ t('el.pagination.goto') }}
<el-input
class="el-pagination__editor is-in-pagination"
:min="1"
:max="pageCount"
:disabled="disabled"
:model-value="innerValue"
type="number"
@update:modelValue="handleInput"
@change="handleChange"
/>
{{ t('el.pagination.pageClassifier') }}
</span>
</template>
<script lang="ts">
import {
computed,
defineComponent,
ref,
} from 'vue'
import { t } from '@element-plus/locale'
import ElInput from '@element-plus/input/src/index.vue'
import { usePagination } from './usePagination'
export default defineComponent({
components: {
ElInput,
},
setup() {
const { pagination, pageCount, disabled, currentPage } = usePagination()
const userInput = ref<Nullable<number>>(null)
const innerValue = computed(() => userInput.value ?? currentPage.value)
function handleInput(val: number | string) {
userInput.value = Number(val)
}
function handleChange(val: number | string) {
pagination?.changeEvent(Number(val))
userInput.value = null
}
return {
t,
userInput,
pageCount,
disabled,
handleInput,
handleChange,
innerValue,
}
},
})
</script>

View File

@ -0,0 +1,43 @@
<template>
<button
type="button"
class="btn-next"
:disabled="internalDisabled"
@click.self.prevent
>
<span v-if="nextText">{{ nextText }}</span>
<i v-else class="el-icon el-icon-arrow-right"></i>
</button>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue'
export default defineComponent({
name: 'Next',
props: {
disabled: Boolean,
currentPage: {
type: Number,
default: 1,
},
pageCount: {
type: Number,
default: 50,
},
nextText: {
type: String,
default: '',
},
},
setup(props) {
const internalDisabled = computed(() => props.disabled
|| props.currentPage === props.pageCount
|| props.pageCount === 0,
)
return {
internalDisabled,
}
},
})
</script>

View File

@ -0,0 +1,187 @@
<template>
<ul class="el-pager" @click="onPagerClick">
<li
v-if="pageCount > 0"
:class="{ active: currentPage === 1, disabled }"
class="number"
>
1
</li>
<li
v-if="showPrevMore"
class="el-icon more btn-quickprev"
:class="[quickprevIconClass, { disabled }]"
@mouseenter="onMouseenter('left')"
@mouseleave="quickprevIconClass = 'el-icon-more'"
>
</li>
<li
v-for="pager in pagers"
:key="pager"
:class="{ active: currentPage === pager, disabled }"
class="number"
>
{{ pager }}
</li>
<li
v-if="showNextMore"
class="el-icon more btn-quicknext"
:class="[quicknextIconClass, { disabled }]"
@mouseenter="onMouseenter('right')"
@mouseleave="quicknextIconClass = 'el-icon-more'"
>
</li>
<li
v-if="pageCount > 1"
:class="{ active: currentPage === pageCount, disabled }"
class="number"
>
{{ pageCount }}
</li>
</ul>
</template>
<script lang="ts">
import {
defineComponent,
ref,
computed,
watchEffect,
} from 'vue'
export default defineComponent({
name: 'ElPager',
props: {
currentPage: {
type: Number,
default: 1,
},
pageCount: {
type: Number,
default: 50,
},
pagerCount: {
type: Number,
default: 7,
},
disabled: Boolean,
},
emits: ['change'],
setup(props, { emit }) {
const showPrevMore = ref(false)
const showNextMore = ref(false)
const quicknextIconClass = ref('el-icon-more')
const quickprevIconClass = ref('el-icon-more')
const pagers = computed(() => {
const pagerCount = props.pagerCount
const halfPagerCount = (pagerCount - 1) / 2
const currentPage = Number(props.currentPage)
const pageCount = Number(props.pageCount)
let showPrevMore = false
let showNextMore = false
if (pageCount > pagerCount) {
if (currentPage > pagerCount - halfPagerCount) {
showPrevMore = true
}
if (currentPage < pageCount - halfPagerCount) {
showNextMore = true
}
}
const array = []
if (showPrevMore && !showNextMore) {
const startPage = pageCount - (pagerCount - 2)
for (let i = startPage; i < pageCount; i++) {
array.push(i)
}
} else if (!showPrevMore && showNextMore) {
for (let i = 2; i < pagerCount; i++) {
array.push(i)
}
} else if (showPrevMore && showNextMore) {
const offset = Math.floor(pagerCount / 2) - 1
for (let i = currentPage - offset; i <= currentPage + offset; i++) {
array.push(i)
}
} else {
for (let i = 2; i < pageCount; i++) {
array.push(i)
}
}
return array
})
watchEffect(() => {
const halfPagerCount = (props.pagerCount - 1) / 2
showPrevMore.value = false
showNextMore.value = false
if (props.pageCount > props.pagerCount) {
if (props.currentPage > props.pagerCount - halfPagerCount) {
showPrevMore.value = true
}
if (props.currentPage < props.pageCount - halfPagerCount) {
showNextMore.value = true
}
}
})
watchEffect(() => {
if(!showPrevMore.value) quickprevIconClass.value = 'el-icon-more'
})
watchEffect(() => {
if(!showNextMore.value) quicknextIconClass.value = 'el-icon-more'
})
function onMouseenter(direction: 'left' | 'right') {
if (props.disabled) return
if (direction === 'left') {
quickprevIconClass.value = 'el-icon-d-arrow-left'
} else {
quicknextIconClass.value = 'el-icon-d-arrow-right'
}
}
function onPagerClick(event: UIEvent) {
const target = event.target as HTMLElement
if (target.tagName.toLowerCase() === 'ul' || props.disabled) {
return
}
let newPage = Number(target.textContent)
const pageCount = props.pageCount
const currentPage = props.currentPage
const pagerCountOffset = props.pagerCount - 2
if (target.className.includes('more')) {
if (target.className.includes('quickprev')) {
newPage = currentPage - pagerCountOffset
} else if (target.className.includes('quicknext')) {
newPage = currentPage + pagerCountOffset
}
}
if (!isNaN(newPage)) {
if (newPage < 1) {
newPage = 1
}
if (newPage > pageCount) {
newPage = pageCount
}
}
if (newPage !== currentPage) {
emit('change', newPage)
}
}
return {
showPrevMore,
showNextMore,
quicknextIconClass,
quickprevIconClass,
pagers,
onMouseenter,
onPagerClick,
}
},
})
</script>

View File

@ -0,0 +1,35 @@
import { ComputedRef } from 'vue'
import { AnyFunction } from '@element-plus/utils/types'
export interface IPagination {
currentPage?: ComputedRef<number>
pageCount?: ComputedRef<number>
disabled?: ComputedRef<boolean>
changeEvent?: AnyFunction<any>
handleSizesChange?: AnyFunction<any>
}
export interface IPaginationProps {
pageSize: number
small: boolean
total: number
pageCount: number
pagerCount: number
currentPage: number
layout: Record<string, string | undefined>
pageSizes: Array<number>
popperClass: string
prevText: string
nextText: string
background: boolean
disabled: boolean
hideOnSinglePage: boolean
}
export interface IPaginationSetups {
currentPage: number
pageCount: number
pagerCount: number
disabled: boolean
pageSizes: Array<number>
}

View File

@ -0,0 +1,36 @@
<template>
<button
type="button"
class="btn-prev"
:disabled="internalDisabled"
@click.self.prevent
>
<span v-if="prevText ">{{ prevText }}</span>
<i v-else class="el-icon el-icon-arrow-left"></i>
</button>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue'
export default defineComponent({
name: 'Prev',
props: {
disabled: Boolean,
currentPage: {
type: Number,
default: 1,
},
prevText: {
type: String,
default: '',
},
},
setup(props) {
const internalDisabled = computed(() => props.disabled || props.currentPage <= 1)
return {
internalDisabled,
}
},
})
</script>

View File

@ -0,0 +1,80 @@
<template>
<span class="el-pagination__sizes">
<el-select
:model-value="innerPageSize"
:disabled="disabled"
:popper-class="popperClass"
size="mini"
@change="handleChange"
>
<el-option
v-for="item in innerPagesizes"
:key="item"
:value="item"
:label="item + t('el.pagination.pagesize')"
/>
</el-select>
</span>
</template>
<script lang="ts">
import { defineComponent, watch, computed, ref } from 'vue'
import ElSelect from '@element-plus/select/src/select.vue'
import ElOption from '@element-plus/select/src/option.vue'
import { t } from '@element-plus/locale'
import { isEqual } from 'lodash'
import { usePagination } from './usePagination'
export default defineComponent({
name: 'Sizes',
components: {
ElSelect,
ElOption,
},
props: {
pageSize: Number,
pageSizes: {
type: Array,
default: () => {
return [10, 20, 30, 40, 50, 100]
},
},
popperClass: {
type: String,
default: '',
},
disabled: Boolean,
},
emits: ['page-size-change'],
setup(props, { emit }) {
const { pagination } = usePagination()
const innerPageSize = ref<Nullable<number>>(props.pageSize)
watch(() => props.pageSizes, (newVal, oldVal) => {
if (isEqual(newVal, oldVal)) return
if (Array.isArray(newVal)) {
const pageSize = newVal.indexOf(props.pageSize) > -1
? props.pageSize
: props.pageSizes[0]
emit('page-size-change', pageSize)
}
})
const innerPagesizes = computed(() => props.pageSizes)
function handleChange(val: number) {
if (val !== innerPageSize.value) {
innerPageSize.value = val
pagination?.handleSizesChange(Number(val))
}
}
return {
t,
innerPagesizes,
innerPageSize,
handleChange,
}
},
})
</script>

View File

@ -0,0 +1,30 @@
<template>
<span class="el-pagination__total">
{{
t('el.pagination.total', {
total,
})
}}
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { t } from '@element-plus/locale'
export default defineComponent({
name: 'Total',
props: {
total: {
type: Number,
default: 1000,
},
},
setup() {
return {
t,
}
},
})
</script>

View File

@ -0,0 +1,13 @@
import { inject } from 'vue'
import { IPagination } from './pagination'
export const usePagination = () => {
const pagination = inject<IPagination>('pagination', {})
return {
pagination,
pageCount: pagination.pageCount,
disabled: pagination.disabled,
currentPage: pagination.currentPage,
}
}

View File

@ -209,7 +209,7 @@ export default defineComponent({
name: String,
id: String,
modelValue: {
type: [Array, String],
type: [Array, String, Number],
},
autocomplete: {
type: String,