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
496 lines (443 loc) • 13.5 kB
JavaScript
/*
* ScrollSpy class definition
*/
import observeDom from '../../utils/observe-dom'
import {
isElement,
isVisible,
closest,
matches,
getBCR,
offset,
position,
selectAll,
select,
hasClass,
addClass,
removeClass,
getAttr,
eventOn,
eventOff
} from '../../utils/dom'
import { isString, isUndefined } from '../../utils/inspect'
import { toString as objectToString } from '../../utils/object'
import { warn } from '../../utils/warn'
/*
* Constants / Defaults
*/
const NAME = 'v-b-scrollspy'
const ACTIVATE_EVENT = 'bv::scrollspy::activate'
const Default = {
element: 'body',
offset: 10,
method: 'auto',
throttle: 75
}
const DefaultType = {
element: '(string|element|component)',
offset: 'number',
method: 'string',
throttle: 'number'
}
const ClassName = {
DROPDOWN_ITEM: 'dropdown-item',
ACTIVE: 'active'
}
const Selector = {
ACTIVE: '.active',
NAV_LIST_GROUP: '.nav, .list-group',
NAV_LINKS: '.nav-link',
NAV_ITEMS: '.nav-item',
LIST_ITEMS: '.list-group-item',
DROPDOWN: '.dropdown, .dropup',
DROPDOWN_ITEMS: '.dropdown-item',
DROPDOWN_TOGGLE: '.dropdown-toggle'
}
const OffsetMethod = {
OFFSET: 'offset',
POSITION: 'position'
}
// HREFs must end with a hash followed by at least one non-hash character.
// HREFs in the links are assumed to point to non-external links.
// Comparison to the current page base URL is not performed!
const HREF_REGEX = /^.*(#[^#]+)$/
// Transition Events
const TransitionEndEvents = [
'webkitTransitionEnd',
'transitionend',
'otransitionend',
'oTransitionEnd'
]
// Options for events
const EventOptions = { passive: true, capture: false }
/*
* Utility Methods
*/
// Better var type detection
const toType = obj => /* istanbul ignore next: not easy to test */ {
return objectToString(obj)
.match(/\s([a-zA-Z]+)/)[1]
.toLowerCase()
}
// Check config properties for expected types
const typeCheckConfig = (
componentName,
config,
configTypes
) => /* istanbul ignore next: not easy to test */ {
for (const property in configTypes) {
if (Object.prototype.hasOwnProperty.call(configTypes, property)) {
const expectedTypes = configTypes[property]
const value = config[property]
let valueType = value && isElement(value) ? 'element' : toType(value)
// handle Vue instances
valueType = value && value._isVue ? 'component' : valueType
if (!new RegExp(expectedTypes).test(valueType)) {
/* istanbul ignore next */
warn(
`${componentName}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}"`
)
}
}
}
}
/*
* ------------------------------------------------------------------------
* Class Definition
* ------------------------------------------------------------------------
*/
/* istanbul ignore next: not easy to test */
class ScrollSpy /* istanbul ignore next: not easy to test */ {
constructor(element, config, $root) {
// The element we activate links in
this.$el = element
this.$scroller = null
this.$selector = [Selector.NAV_LINKS, Selector.LIST_ITEMS, Selector.DROPDOWN_ITEMS].join(',')
this.$offsets = []
this.$targets = []
this.$activeTarget = null
this.$scrollHeight = 0
this.$resizeTimeout = null
this.$obs_scroller = null
this.$obs_targets = null
this.$root = $root || null
this.$config = null
this.updateConfig(config)
}
static get Name() {
return NAME
}
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
updateConfig(config, $root) {
if (this.$scroller) {
// Just in case out scroll element has changed
this.unlisten()
this.$scroller = null
}
const cfg = { ...this.constructor.Default, ...config }
if ($root) {
this.$root = $root
}
typeCheckConfig(this.constructor.Name, cfg, this.constructor.DefaultType)
this.$config = cfg
if (this.$root) {
const self = this
this.$root.$nextTick(() => {
self.listen()
})
} else {
this.listen()
}
}
dispose() {
this.unlisten()
clearTimeout(this.$resizeTimeout)
this.$resizeTimeout = null
this.$el = null
this.$config = null
this.$scroller = null
this.$selector = null
this.$offsets = null
this.$targets = null
this.$activeTarget = null
this.$scrollHeight = null
}
listen() {
const scroller = this.getScroller()
if (scroller && scroller.tagName !== 'BODY') {
eventOn(scroller, 'scroll', this, EventOptions)
}
eventOn(window, 'scroll', this, EventOptions)
eventOn(window, 'resize', this, EventOptions)
eventOn(window, 'orientationchange', this, EventOptions)
TransitionEndEvents.forEach(evtName => {
eventOn(window, evtName, this, EventOptions)
})
this.setObservers(true)
// Schedule a refresh
this.handleEvent('refresh')
}
unlisten() {
const scroller = this.getScroller()
this.setObservers(false)
if (scroller && scroller.tagName !== 'BODY') {
eventOff(scroller, 'scroll', this, EventOptions)
}
eventOff(window, 'scroll', this, EventOptions)
eventOff(window, 'resize', this, EventOptions)
eventOff(window, 'orientationchange', this, EventOptions)
TransitionEndEvents.forEach(evtName => {
eventOff(window, evtName, this, EventOptions)
})
}
setObservers(on) {
// We observe both the scroller for content changes, and the target links
if (this.$obs_scroller) {
this.$obs_scroller.disconnect()
this.$obs_scroller = null
}
if (this.$obs_targets) {
this.$obs_targets.disconnect()
this.$obs_targets = null
}
if (on) {
this.$obs_targets = observeDom(
this.$el,
() => {
this.handleEvent('mutation')
},
{
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['href']
}
)
this.$obs_scroller = observeDom(
this.getScroller(),
() => {
this.handleEvent('mutation')
},
{
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['id', 'style', 'class']
}
)
}
}
// General event handler
handleEvent(evt) {
const type = isString(evt) ? evt : evt.type
const self = this
const resizeThrottle = () => {
if (!self.$resizeTimeout) {
self.$resizeTimeout = setTimeout(() => {
self.refresh()
self.process()
self.$resizeTimeout = null
}, self.$config.throttle)
}
}
if (type === 'scroll') {
if (!this.$obs_scroller) {
// Just in case we are added to the DOM before the scroll target is
// We re-instantiate our listeners, just in case
this.listen()
}
this.process()
} else if (/(resize|orientationchange|mutation|refresh)/.test(type)) {
// Postpone these events by throttle time
resizeThrottle()
}
}
// Refresh the list of target links on the element we are applied to
refresh() {
const scroller = this.getScroller()
if (!scroller) {
return
}
const autoMethod = scroller !== scroller.window ? OffsetMethod.POSITION : OffsetMethod.OFFSET
const method = this.$config.method === 'auto' ? autoMethod : this.$config.method
const methodFn = method === OffsetMethod.POSITION ? position : offset
const offsetBase = method === OffsetMethod.POSITION ? this.getScrollTop() : 0
this.$offsets = []
this.$targets = []
this.$scrollHeight = this.getScrollHeight()
// Find all the unique link HREFs that we will control
selectAll(this.$selector, this.$el)
// Get HREF value
.map(link => getAttr(link, 'href'))
// Filter out HREFs that do not match our RegExp
.filter(href => href && HREF_REGEX.test(href || ''))
// Find all elements with ID that match HREF hash
.map(href => {
// Convert HREF into an ID (including # at beginning)
const id = href.replace(HREF_REGEX, '$1').trim()
if (!id) {
return null
}
// Find the element with the ID specified by id
const el = select(id, scroller)
if (el && isVisible(el)) {
return {
offset: parseInt(methodFn(el).top, 10) + offsetBase,
target: id
}
}
return null
})
.filter(Boolean)
// Sort them by their offsets (smallest first)
.sort((a, b) => a.offset - b.offset)
// record only unique targets/offsets
.reduce((memo, item) => {
if (!memo[item.target]) {
this.$offsets.push(item.offset)
this.$targets.push(item.target)
memo[item.target] = true
}
return memo
}, {})
// Return this for easy chaining
return this
}
// Handle activating/clearing
process() {
const scrollTop = this.getScrollTop() + this.$config.offset
const scrollHeight = this.getScrollHeight()
const maxScroll = this.$config.offset + scrollHeight - this.getOffsetHeight()
if (this.$scrollHeight !== scrollHeight) {
this.refresh()
}
if (scrollTop >= maxScroll) {
const target = this.$targets[this.$targets.length - 1]
if (this.$activeTarget !== target) {
this.activate(target)
}
return
}
if (this.$activeTarget && scrollTop < this.$offsets[0] && this.$offsets[0] > 0) {
this.$activeTarget = null
this.clear()
return
}
for (let i = this.$offsets.length; i--; ) {
const isActiveTarget =
this.$activeTarget !== this.$targets[i] &&
scrollTop >= this.$offsets[i] &&
(isUndefined(this.$offsets[i + 1]) || scrollTop < this.$offsets[i + 1])
if (isActiveTarget) {
this.activate(this.$targets[i])
}
}
}
getScroller() {
if (this.$scroller) {
return this.$scroller
}
let scroller = this.$config.element
if (!scroller) {
return null
} else if (isElement(scroller.$el)) {
scroller = scroller.$el
} else if (isString(scroller)) {
scroller = select(scroller)
}
if (!scroller) {
return null
}
this.$scroller = scroller.tagName === 'BODY' ? window : scroller
return this.$scroller
}
getScrollTop() {
const scroller = this.getScroller()
return scroller === window ? scroller.pageYOffset : scroller.scrollTop
}
getScrollHeight() {
return (
this.getScroller().scrollHeight ||
Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
)
}
getOffsetHeight() {
const scroller = this.getScroller()
return scroller === window ? window.innerHeight : getBCR(scroller).height
}
activate(target) {
this.$activeTarget = target
this.clear()
// Grab the list of target links (<a href="{$target}">)
const links = selectAll(
this.$selector
// Split out the base selectors
.split(',')
// Map to a selector that matches links with HREF ending in the ID (including '#')
.map(selector => `${selector}[href$="${target}"]`)
// Join back into a single selector string
.join(','),
this.$el
)
links.forEach(link => {
if (hasClass(link, ClassName.DROPDOWN_ITEM)) {
// This is a dropdown item, so find the .dropdown-toggle and set its state
const dropdown = closest(Selector.DROPDOWN, link)
if (dropdown) {
this.setActiveState(select(Selector.DROPDOWN_TOGGLE, dropdown), true)
}
// Also set this link's state
this.setActiveState(link, true)
} else {
// Set triggered link as active
this.setActiveState(link, true)
if (matches(link.parentElement, Selector.NAV_ITEMS)) {
// Handle nav-link inside nav-item, and set nav-item active
this.setActiveState(link.parentElement, true)
}
// Set triggered links parents as active
// With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
let el = link
while (el) {
el = closest(Selector.NAV_LIST_GROUP, el)
const sibling = el ? el.previousElementSibling : null
if (sibling && matches(sibling, `${Selector.NAV_LINKS}, ${Selector.LIST_ITEMS}`)) {
this.setActiveState(sibling, true)
}
// Handle special case where nav-link is inside a nav-item
if (sibling && matches(sibling, Selector.NAV_ITEMS)) {
this.setActiveState(select(Selector.NAV_LINKS, sibling), true)
// Add active state to nav-item as well
this.setActiveState(sibling, true)
}
}
}
})
// Signal event to via $root, passing ID of activated target and reference to array of links
if (links && links.length > 0 && this.$root) {
this.$root.$emit(ACTIVATE_EVENT, target, links)
}
}
clear() {
selectAll(`${this.$selector}, ${Selector.NAV_ITEMS}`, this.$el)
.filter(el => hasClass(el, ClassName.ACTIVE))
.forEach(el => this.setActiveState(el, false))
}
setActiveState(el, active) {
if (!el) {
return
}
if (active) {
addClass(el, ClassName.ACTIVE)
} else {
removeClass(el, ClassName.ACTIVE)
}
}
}
export default ScrollSpy