bootstrap-vue
Version:
BootstrapVue, with over 40 plugins and more than 80 custom components, custom directives, and over 300 icons, provides one of the most comprehensive implementations of Bootstrap v4 components and grid system for Vue.js. With extensive and automated WAI-AR
670 lines (641 loc) • 20.1 kB
JavaScript
import KeyCodes from '../utils/key-codes'
import range from '../utils/range'
import { isVisible, isDisabled, selectAll, getAttr } from '../utils/dom'
import { isFunction, isNull } from '../utils/inspect'
import { toInteger } from '../utils/number'
import { toString } from '../utils/string'
import { warn } from '../utils/warn'
import normalizeSlotMixin from '../mixins/normalize-slot'
import { BLink } from '../components/link/link'
// Common props, computed, data, render function, and methods
// for `<b-pagination>` and `<b-pagination-nav>`
// --- Constants ---
// Threshold of limit size when we start/stop showing ellipsis
const ELLIPSIS_THRESHOLD = 3
// Default # of buttons limit
const DEFAULT_LIMIT = 5
// --- Helper methods ---
// Make an array of N to N+X
const makePageArray = (startNumber, numberOfPages) =>
range(numberOfPages).map((val, i) => ({ number: startNumber + i, classes: null }))
// Sanitize the provided limit value (converting to a number)
const sanitizeLimit = val => {
const limit = toInteger(val) || 1
return limit < 1 ? DEFAULT_LIMIT : limit
}
// Sanitize the provided current page number (converting to a number)
const sanitizeCurrentPage = (val, numberOfPages) => {
const page = toInteger(val) || 1
return page > numberOfPages ? numberOfPages : page < 1 ? 1 : page
}
// Links don't normally respond to SPACE, so we add that
// functionality via this handler
const onSpaceKey = evt => {
if (evt.keyCode === KeyCodes.SPACE) {
evt.preventDefault() // Stop page from scrolling
evt.stopImmediatePropagation()
evt.stopPropagation()
// Trigger the click event on the link
evt.currentTarget.click()
return false
}
}
// --- Props ---
export const props = {
disabled: {
type: Boolean,
default: false
},
value: {
type: [Number, String],
default: null,
validator(value) /* istanbul ignore next */ {
const number = toInteger(value)
if (!isNull(value) && (isNaN(number) || number < 1)) {
warn('"v-model" value must be a number greater than "0"', 'BPagination')
return false
}
return true
}
},
limit: {
type: [Number, String],
default: DEFAULT_LIMIT,
validator(value) /* istanbul ignore next */ {
const number = toInteger(value)
if (isNaN(number) || number < 1) {
warn('Prop "limit" must be a number greater than "0"', 'BPagination')
return false
}
return true
}
},
align: {
type: String,
default: 'left'
},
pills: {
type: Boolean,
default: false
},
hideGotoEndButtons: {
type: Boolean,
default: false
},
ariaLabel: {
type: String,
default: 'Pagination'
},
labelFirstPage: {
type: String,
default: 'Go to first page'
},
firstText: {
type: String,
default: '\u00AB' // '«'
},
firstNumber: {
type: Boolean,
default: false
},
firstClass: {
type: [String, Array, Object],
default: null
},
labelPrevPage: {
type: String,
default: 'Go to previous page'
},
prevText: {
type: String,
default: '\u2039' // '‹'
},
prevClass: {
type: [String, Array, Object],
default: null
},
labelNextPage: {
type: String,
default: 'Go to next page'
},
nextText: {
type: String,
default: '\u203A' // '›'
},
nextClass: {
type: [String, Array, Object],
default: null
},
labelLastPage: {
type: String,
default: 'Go to last page'
},
lastText: {
type: String,
default: '\u00BB' // '»'
},
lastNumber: {
type: Boolean,
default: false
},
lastClass: {
type: [String, Array, Object],
default: null
},
labelPage: {
type: [String, Function],
default: 'Go to page'
},
pageClass: {
type: [String, Array, Object],
default: null
},
hideEllipsis: {
type: Boolean,
default: false
},
ellipsisText: {
type: String,
default: '\u2026' // '…'
},
ellipsisClass: {
type: [String, Array, Object],
default: null
}
}
// @vue/component
export default {
mixins: [normalizeSlotMixin],
model: {
prop: 'value',
event: 'input'
},
props,
data() {
const curr = toInteger(this.value)
return {
// -1 signifies no page initially selected
currentPage: curr > 0 ? curr : -1,
localNumberOfPages: 1,
localLimit: DEFAULT_LIMIT
}
},
computed: {
btnSize() {
return this.size ? `pagination-${this.size}` : ''
},
alignment() {
const align = this.align
if (align === 'center') {
return 'justify-content-center'
} else if (align === 'end' || align === 'right') {
return 'justify-content-end'
} else if (align === 'fill') {
// The page-items will also have 'flex-fill' added
// We add text centering to make the button appearance better in fill mode
return 'text-center'
}
return ''
},
styleClass() {
return this.pills ? 'b-pagination-pills' : ''
},
computedCurrentPage() {
return sanitizeCurrentPage(this.currentPage, this.localNumberOfPages)
},
paginationParams() {
// Determine if we should show the the ellipsis
const limit = this.localLimit
const numberOfPages = this.localNumberOfPages
const currentPage = this.computedCurrentPage
const hideEllipsis = this.hideEllipsis
const firstNumber = this.firstNumber
const lastNumber = this.lastNumber
let showFirstDots = false
let showLastDots = false
let numberOfLinks = limit
let startNumber = 1
if (numberOfPages <= limit) {
// Special case: Less pages available than the limit of displayed pages
numberOfLinks = numberOfPages
} else if (currentPage < limit - 1 && limit > ELLIPSIS_THRESHOLD) {
if (!hideEllipsis || lastNumber) {
showLastDots = true
numberOfLinks = limit - (firstNumber ? 0 : 1)
}
numberOfLinks = Math.min(numberOfLinks, limit)
} else if (numberOfPages - currentPage + 2 < limit && limit > ELLIPSIS_THRESHOLD) {
if (!hideEllipsis || firstNumber) {
showFirstDots = true
numberOfLinks = limit - (lastNumber ? 0 : 1)
}
startNumber = numberOfPages - numberOfLinks + 1
} else {
// We are somewhere in the middle of the page list
if (limit > ELLIPSIS_THRESHOLD) {
numberOfLinks = limit - 2
showFirstDots = !!(!hideEllipsis || firstNumber)
showLastDots = !!(!hideEllipsis || lastNumber)
}
startNumber = currentPage - Math.floor(numberOfLinks / 2)
}
// Sanity checks
/* istanbul ignore if */
if (startNumber < 1) {
startNumber = 1
showFirstDots = false
} else if (startNumber > numberOfPages - numberOfLinks) {
startNumber = numberOfPages - numberOfLinks + 1
showLastDots = false
}
if (showFirstDots && firstNumber && startNumber < 4) {
numberOfLinks = numberOfLinks + 2
startNumber = 1
showFirstDots = false
}
const lastPageNumber = startNumber + numberOfLinks - 1
if (showLastDots && lastNumber && lastPageNumber > numberOfPages - 3) {
numberOfLinks = numberOfLinks + (lastPageNumber === numberOfPages - 2 ? 2 : 3)
showLastDots = false
}
// Special handling for lower limits (where ellipsis are never shown)
if (limit <= ELLIPSIS_THRESHOLD) {
if (firstNumber && startNumber === 1) {
numberOfLinks = Math.min(numberOfLinks + 1, numberOfPages, limit + 1)
} else if (lastNumber && numberOfPages === startNumber + numberOfLinks - 1) {
startNumber = Math.max(startNumber - 1, 1)
numberOfLinks = Math.min(numberOfPages - startNumber + 1, numberOfPages, limit + 1)
}
}
numberOfLinks = Math.min(numberOfLinks, numberOfPages - startNumber + 1)
return { showFirstDots, showLastDots, numberOfLinks, startNumber }
},
pageList() {
// Generates the pageList array
const { numberOfLinks, startNumber } = this.paginationParams
const currentPage = this.computedCurrentPage
// Generate list of page numbers
const pages = makePageArray(startNumber, numberOfLinks)
// We limit to a total of 3 page buttons on XS screens
// So add classes to page links to hide them for XS breakpoint
// Note: Ellipsis will also be hidden on XS screens
// TODO: Make this visual limit configurable based on breakpoint(s)
if (pages.length > 3) {
const idx = currentPage - startNumber
// THe following is a bootstrap-vue custom utility class
const classes = 'bv-d-xs-down-none'
if (idx === 0) {
// Keep leftmost 3 buttons visible when current page is first page
for (let i = 3; i < pages.length; i++) {
pages[i].classes = classes
}
} else if (idx === pages.length - 1) {
// Keep rightmost 3 buttons visible when current page is last page
for (let i = 0; i < pages.length - 3; i++) {
pages[i].classes = classes
}
} else {
// Hide all except current page, current page - 1 and current page + 1
for (let i = 0; i < idx - 1; i++) {
// hide some left button(s)
pages[i].classes = classes
}
for (let i = pages.length - 1; i > idx + 1; i--) {
// hide some right button(s)
pages[i].classes = classes
}
}
}
return pages
}
},
watch: {
value(newValue, oldValue) {
if (newValue !== oldValue) {
this.currentPage = sanitizeCurrentPage(newValue, this.localNumberOfPages)
}
},
currentPage(newValue, oldValue) {
if (newValue !== oldValue) {
// Emit null if no page selected
this.$emit('input', newValue > 0 ? newValue : null)
}
},
limit(newValue, oldValue) {
if (newValue !== oldValue) {
this.localLimit = sanitizeLimit(newValue)
}
}
},
created() {
// Set our default values in data
this.localLimit = sanitizeLimit(this.limit)
this.$nextTick(() => {
// Sanity check
this.currentPage =
this.currentPage > this.localNumberOfPages ? this.localNumberOfPages : this.currentPage
})
},
methods: {
handleKeyNav(evt) {
const { keyCode, shiftKey } = evt
if (keyCode === KeyCodes.LEFT || keyCode === KeyCodes.UP) {
evt.preventDefault()
shiftKey ? this.focusFirst() : this.focusPrev()
} else if (keyCode === KeyCodes.RIGHT || keyCode === KeyCodes.DOWN) {
evt.preventDefault()
shiftKey ? this.focusLast() : this.focusNext()
}
},
getButtons() {
// Return only buttons that are visible
return selectAll('a.page-link', this.$el).filter(btn => isVisible(btn))
},
setBtnFocus(btn) {
btn.focus()
},
focusCurrent() {
// We do this in `$nextTick()` to ensure buttons have finished rendering
this.$nextTick(() => {
const btn = this.getButtons().find(
el => toInteger(getAttr(el, 'aria-posinset')) === this.computedCurrentPage
)
if (btn && btn.focus) {
this.setBtnFocus(btn)
} else {
// Fallback if current page is not in button list
this.focusFirst()
}
})
},
focusFirst() {
// We do this in `$nextTick()` to ensure buttons have finished rendering
this.$nextTick(() => {
const btn = this.getButtons().find(el => !isDisabled(el))
if (btn && btn.focus && btn !== document.activeElement) {
this.setBtnFocus(btn)
}
})
},
focusLast() {
// We do this in `$nextTick()` to ensure buttons have finished rendering
this.$nextTick(() => {
const btn = this.getButtons()
.reverse()
.find(el => !isDisabled(el))
if (btn && btn.focus && btn !== document.activeElement) {
this.setBtnFocus(btn)
}
})
},
focusPrev() {
// We do this in `$nextTick()` to ensure buttons have finished rendering
this.$nextTick(() => {
const buttons = this.getButtons()
const idx = buttons.indexOf(document.activeElement)
if (idx > 0 && !isDisabled(buttons[idx - 1]) && buttons[idx - 1].focus) {
this.setBtnFocus(buttons[idx - 1])
}
})
},
focusNext() {
// We do this in `$nextTick()` to ensure buttons have finished rendering
this.$nextTick(() => {
const buttons = this.getButtons()
const idx = buttons.indexOf(document.activeElement)
const cnt = buttons.length - 1
if (idx < cnt && !isDisabled(buttons[idx + 1]) && buttons[idx + 1].focus) {
this.setBtnFocus(buttons[idx + 1])
}
})
}
},
render(h) {
const buttons = []
const numberOfPages = this.localNumberOfPages
const pageNumbers = this.pageList.map(p => p.number)
const disabled = this.disabled
const { showFirstDots, showLastDots } = this.paginationParams
const currentPage = this.computedCurrentPage
const fill = this.align === 'fill'
// Helper function and flag
const isActivePage = pageNum => pageNum === currentPage
const noCurrentPage = this.currentPage < 1
// Factory function for prev/next/first/last buttons
const makeEndBtn = (linkTo, ariaLabel, btnSlot, btnText, btnClass, pageTest, key) => {
const isDisabled =
disabled || isActivePage(pageTest) || noCurrentPage || linkTo < 1 || linkTo > numberOfPages
const pageNum = linkTo < 1 ? 1 : linkTo > numberOfPages ? numberOfPages : linkTo
const scope = { disabled: isDisabled, page: pageNum, index: pageNum - 1 }
const btnContent = this.normalizeSlot(btnSlot, scope) || toString(btnText) || h()
const inner = h(
isDisabled ? 'span' : BLink,
{
staticClass: 'page-link',
props: isDisabled ? {} : this.linkProps(linkTo),
attrs: {
role: 'menuitem',
tabindex: isDisabled ? null : '-1',
'aria-label': ariaLabel,
'aria-controls': this.ariaControls || null,
'aria-disabled': isDisabled ? 'true' : null
},
on: isDisabled
? {}
: {
click: evt => {
this.onClick(linkTo, evt)
},
keydown: onSpaceKey
}
},
[btnContent]
)
return h(
'li',
{
key,
staticClass: 'page-item',
class: [{ disabled: isDisabled, 'flex-fill': fill }, btnClass],
attrs: {
role: 'presentation',
'aria-hidden': isDisabled ? 'true' : null
}
},
[inner]
)
}
// Ellipsis factory
const makeEllipsis = isLast => {
return h(
'li',
{
key: `ellipsis-${isLast ? 'last' : 'first'}`,
staticClass: 'page-item',
class: ['disabled', 'bv-d-xs-down-none', fill ? 'flex-fill' : '', this.ellipsisClass],
attrs: { role: 'separator' }
},
[
h('span', { staticClass: 'page-link' }, [
this.normalizeSlot('ellipsis-text') || toString(this.ellipsisText) || h()
])
]
)
}
// Page button factory
const makePageButton = (page, idx) => {
const active = isActivePage(page.number) && !noCurrentPage
// Active page will have tabindex of 0, or if no current page and first page button
const tabIndex = disabled ? null : active || (noCurrentPage && idx === 0) ? '0' : '-1'
const attrs = {
role: 'menuitemradio',
'aria-disabled': disabled ? 'true' : null,
'aria-controls': this.ariaControls || null,
'aria-label': isFunction(this.labelPage)
? this.labelPage(page.number)
: `${this.labelPage} ${page.number}`,
'aria-checked': active ? 'true' : 'false',
'aria-posinset': page.number,
'aria-setsize': numberOfPages,
// ARIA "roving tabindex" method
tabindex: tabIndex
}
const btnContent = toString(this.makePage(page.number))
const scope = {
page: page.number,
index: page.number - 1,
content: btnContent,
active,
disabled
}
const inner = h(
disabled ? 'span' : BLink,
{
props: disabled ? {} : this.linkProps(page.number),
staticClass: 'page-link',
attrs,
on: disabled
? {}
: {
click: evt => {
this.onClick(page.number, evt)
},
keydown: onSpaceKey
}
},
[this.normalizeSlot('page', scope) || btnContent]
)
return h(
'li',
{
key: `page-${page.number}`,
staticClass: 'page-item',
class: [{ disabled, active, 'flex-fill': fill }, page.classes, this.pageClass],
attrs: { role: 'presentation' }
},
[inner]
)
}
// Goto first page button
// Don't render button when `hideGotoEndButtons` or `firstNumber` is set
let $firstPageBtn = h()
if (!this.firstNumber && !this.hideGotoEndButtons) {
$firstPageBtn = makeEndBtn(
1,
this.labelFirstPage,
'first-text',
this.firstText,
this.firstClass,
1,
'pagination-goto-first'
)
}
buttons.push($firstPageBtn)
// Goto previous page button
buttons.push(
makeEndBtn(
currentPage - 1,
this.labelPrevPage,
'prev-text',
this.prevText,
this.prevClass,
1,
'pagination-goto-prev'
)
)
// Show first (1) button?
buttons.push(this.firstNumber && pageNumbers[0] !== 1 ? makePageButton({ number: 1 }, 0) : h())
// First ellipsis
buttons.push(showFirstDots ? makeEllipsis(false) : h())
// Individual page links
this.pageList.forEach((page, idx) => {
const offset = showFirstDots && this.firstNumber && pageNumbers[0] !== 1 ? 1 : 0
buttons.push(makePageButton(page, idx + offset))
})
// Last ellipsis
buttons.push(showLastDots ? makeEllipsis(true) : h())
// Show last page button?
buttons.push(
this.lastNumber && pageNumbers[pageNumbers.length - 1] !== numberOfPages
? makePageButton({ number: numberOfPages }, -1)
: h()
)
// Goto next page button
buttons.push(
makeEndBtn(
currentPage + 1,
this.labelNextPage,
'next-text',
this.nextText,
this.nextClass,
numberOfPages,
'pagination-goto-next'
)
)
// Goto last page button
// Don't render button when `hideGotoEndButtons` or `lastNumber` is set
let $lastPageBtn = h()
if (!this.lastNumber && !this.hideGotoEndButtons) {
$lastPageBtn = makeEndBtn(
numberOfPages,
this.labelLastPage,
'last-text',
this.lastText,
this.lastClass,
numberOfPages,
'pagination-goto-last'
)
}
buttons.push($lastPageBtn)
// Assemble the pagination buttons
const $pagination = h(
'ul',
{
ref: 'ul',
staticClass: 'pagination',
class: ['b-pagination', this.btnSize, this.alignment, this.styleClass],
attrs: {
role: 'menubar',
'aria-disabled': disabled ? 'true' : 'false',
'aria-label': this.ariaLabel || null
},
on: { keydown: this.handleKeyNav }
},
buttons
)
// If we are `<b-pagination-nav>`, wrap in `<nav>` wrapper
if (this.isNav) {
return h(
'nav',
{
attrs: {
'aria-disabled': disabled ? 'true' : null,
'aria-hidden': disabled ? 'true' : 'false'
}
},
[$pagination]
)
}
return $pagination
}
}