UNPKG

nly-adminlte-vue

Version:
485 lines (437 loc) 13.6 kB
import observeDom from "../../utils/observe-dom"; import { addClass, closest, getAttr, getBCR, hasClass, isElement, isVisible, matches, offset, position, removeClass, select, selectAll } from "../../utils/dom"; import { EVENT_OPTIONS_NO_CAPTURE, eventOn, eventOff } from "../../utils/events"; import { isString, isUndefined } from "../../utils/inspect"; import { toInteger } from "../../utils/number"; import { toString as objectToString } from "../../utils/object"; import { warn } from "../../utils/warn"; const NAME = "v-ny-scrollspy"; const ACTIVATE_EVENT = "nlya::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" }; const HREF_REGEX = /^.*(#[^#]+)$/; const TransitionEndEvents = [ "webkitTransitionEnd", "transitionend", "otransitionend", "oTransitionEnd" ]; const toType = obj => { return objectToString(obj) .match(/\s([a-zA-Z]+)/)[1] .toLowerCase(); }; const typeCheckConfig = (componentName, config, configTypes) => { 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); valueType = value && value._isVue ? "component" : valueType; if (!new RegExp(expectedTypes).test(valueType)) { warn( `${componentName}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}"` ); } } } }; class ScrollSpy { constructor(element, config, $root) { 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, EVENT_OPTIONS_NO_CAPTURE); } eventOn(window, "scroll", this, EVENT_OPTIONS_NO_CAPTURE); eventOn(window, "resize", this, EVENT_OPTIONS_NO_CAPTURE); eventOn(window, "orientationchange", this, EVENT_OPTIONS_NO_CAPTURE); TransitionEndEvents.forEach(evtName => { eventOn(window, evtName, this, EVENT_OPTIONS_NO_CAPTURE); }); 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, EVENT_OPTIONS_NO_CAPTURE); } eventOff(window, "scroll", this, EVENT_OPTIONS_NO_CAPTURE); eventOff(window, "resize", this, EVENT_OPTIONS_NO_CAPTURE); eventOff(window, "orientationchange", this, EVENT_OPTIONS_NO_CAPTURE); TransitionEndEvents.forEach(evtName => { eventOff(window, evtName, this, EVENT_OPTIONS_NO_CAPTURE); }); } 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: toInteger(methodFn(el).top, 0) + 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;