scrollspy.js 9.28 KB
Newer Older
fat's avatar
fat committed
1
2
/**
 * --------------------------------------------------------------------------
XhmikosR's avatar
XhmikosR committed
3
 * Bootstrap (v4.3.1): scrollspy.js
fat's avatar
fat committed
4
5
6
7
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
 * --------------------------------------------------------------------------
 */

8
9
10
11
12
13
14
import {
  jQuery as $,
  getSelectorFromElement,
  getUID,
  makeArray,
  typeCheckConfig
} from './util/index'
15
16
17
18
import Data from './dom/data'
import EventHandler from './dom/eventHandler'
import Manipulator from './dom/manipulator'
import SelectorEngine from './dom/selectorEngine'
19

Johann-S's avatar
Johann-S committed
20
21
22
23
24
/**
 * ------------------------------------------------------------------------
 * Constants
 * ------------------------------------------------------------------------
 */
fat's avatar
fat committed
25

Johann-S's avatar
Johann-S committed
26
const NAME               = 'scrollspy'
XhmikosR's avatar
XhmikosR committed
27
const VERSION            = '4.3.1'
Johann-S's avatar
Johann-S committed
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
const DATA_KEY           = 'bs.scrollspy'
const EVENT_KEY          = `.${DATA_KEY}`
const DATA_API_KEY       = '.data-api'

const Default = {
  offset : 10,
  method : 'auto',
  target : ''
}

const DefaultType = {
  offset : 'number',
  method : 'string',
  target : '(string|element)'
}

const Event = {
  ACTIVATE      : `activate${EVENT_KEY}`,
  SCROLL        : `scroll${EVENT_KEY}`,
  LOAD_DATA_API : `load${EVENT_KEY}${DATA_API_KEY}`
}

const ClassName = {
  DROPDOWN_ITEM : 'dropdown-item',
  DROPDOWN_MENU : 'dropdown-menu',
  ACTIVE        : 'active'
}

const Selector = {
  DATA_SPY        : '[data-spy="scroll"]',
  ACTIVE          : '.active',
  NAV_LIST_GROUP  : '.nav, .list-group',
  NAV_LINKS       : '.nav-link',
  NAV_ITEMS       : '.nav-item',
  LIST_ITEMS      : '.list-group-item',
  DROPDOWN        : '.dropdown',
  DROPDOWN_ITEMS  : '.dropdown-item',
  DROPDOWN_TOGGLE : '.dropdown-toggle'
}

const OffsetMethod = {
  OFFSET   : 'offset',
  POSITION : 'position'
}
fat's avatar
fat committed
72

Johann-S's avatar
Johann-S committed
73
74
75
76
77
/**
 * ------------------------------------------------------------------------
 * Class Definition
 * ------------------------------------------------------------------------
 */
fat's avatar
fat committed
78

Johann-S's avatar
Johann-S committed
79
80
81
82
83
84
85
86
87
88
89
90
91
class ScrollSpy {
  constructor(element, config) {
    this._element       = element
    this._scrollElement = element.tagName === 'BODY' ? window : element
    this._config        = this._getConfig(config)
    this._selector      = `${this._config.target} ${Selector.NAV_LINKS},` +
                          `${this._config.target} ${Selector.LIST_ITEMS},` +
                          `${this._config.target} ${Selector.DROPDOWN_ITEMS}`
    this._offsets       = []
    this._targets       = []
    this._activeTarget  = null
    this._scrollHeight  = 0

92
    EventHandler.on(this._scrollElement, Event.SCROLL, (event) => this._process(event))
Johann-S's avatar
Johann-S committed
93
94
95

    this.refresh()
    this._process()
96
97

    Data.setData(element, DATA_KEY, this)
fat's avatar
fat committed
98
99
  }

Johann-S's avatar
Johann-S committed
100
  // Getters
fat's avatar
fat committed
101

Johann-S's avatar
Johann-S committed
102
103
  static get VERSION() {
    return VERSION
104
105
  }

Johann-S's avatar
Johann-S committed
106
107
108
  static get Default() {
    return Default
  }
fat's avatar
fat committed
109

Johann-S's avatar
Johann-S committed
110
  // Public
fat's avatar
fat committed
111

Johann-S's avatar
Johann-S committed
112
113
114
  refresh() {
    const autoMethod = this._scrollElement === this._scrollElement.window
      ? OffsetMethod.OFFSET : OffsetMethod.POSITION
115

Johann-S's avatar
Johann-S committed
116
117
    const offsetMethod = this._config.method === 'auto'
      ? autoMethod : this._config.method
fat's avatar
fat committed
118

Johann-S's avatar
Johann-S committed
119
120
    const offsetBase = offsetMethod === OffsetMethod.POSITION
      ? this._getScrollTop() : 0
fat's avatar
fat committed
121

Johann-S's avatar
Johann-S committed
122
123
    this._offsets = []
    this._targets = []
fat's avatar
fat committed
124

Johann-S's avatar
Johann-S committed
125
    this._scrollHeight = this._getScrollHeight()
fat's avatar
fat committed
126

127
    const targets = makeArray(SelectorEngine.find(this._selector))
fat's avatar
fat committed
128

Johann-S's avatar
Johann-S committed
129
130
131
    targets
      .map((element) => {
        let target
132
        const targetSelector = getSelectorFromElement(element)
fat's avatar
fat committed
133

Johann-S's avatar
Johann-S committed
134
        if (targetSelector) {
135
          target = SelectorEngine.findOne(targetSelector)
Johann-S's avatar
Johann-S committed
136
        }
fat's avatar
fat committed
137

Johann-S's avatar
Johann-S committed
138
139
140
141
142
        if (target) {
          const targetBCR = target.getBoundingClientRect()
          if (targetBCR.width || targetBCR.height) {
            // TODO (fat): remove sketch reliance on jQuery position/offset
            return [
143
              Manipulator[offsetMethod](target).top + offsetBase,
Johann-S's avatar
Johann-S committed
144
145
              targetSelector
            ]
fat's avatar
fat committed
146
          }
fat's avatar
fat committed
147
        }
Johann-S's avatar
Johann-S committed
148
149
150
151
152
153
154
155
156
        return null
      })
      .filter((item) => item)
      .sort((a, b) => a[0] - b[0])
      .forEach((item) => {
        this._offsets.push(item[0])
        this._targets.push(item[1])
      })
  }
fat's avatar
fat committed
157

Johann-S's avatar
Johann-S committed
158
  dispose() {
159
160
    Data.removeData(this._element, DATA_KEY)
    EventHandler.off(this._scrollElement, EVENT_KEY)
Johann-S's avatar
Johann-S committed
161
162
163
164
165
166
167
168
169
170

    this._element       = null
    this._scrollElement = null
    this._config        = null
    this._selector      = null
    this._offsets       = null
    this._targets       = null
    this._activeTarget  = null
    this._scrollHeight  = null
  }
fat's avatar
fat committed
171

Johann-S's avatar
Johann-S committed
172
  // Private
fat's avatar
fat committed
173

Johann-S's avatar
Johann-S committed
174
175
176
177
  _getConfig(config) {
    config = {
      ...Default,
      ...typeof config === 'object' && config ? config : {}
fat's avatar
fat committed
178
179
    }

Johann-S's avatar
Johann-S committed
180
    if (typeof config.target !== 'string') {
181
      let id = config.target.id
Johann-S's avatar
Johann-S committed
182
      if (!id) {
183
        id = getUID(NAME)
184
        config.target.id = id
Johann-S's avatar
Johann-S committed
185
186
      }
      config.target = `#${id}`
fat's avatar
fat committed
187
188
    }

189
    typeCheckConfig(NAME, config, DefaultType)
190

Johann-S's avatar
Johann-S committed
191
192
    return config
  }
fat's avatar
fat committed
193

Johann-S's avatar
Johann-S committed
194
195
196
197
  _getScrollTop() {
    return this._scrollElement === window
      ? this._scrollElement.pageYOffset : this._scrollElement.scrollTop
  }
fat's avatar
fat committed
198

Johann-S's avatar
Johann-S committed
199
200
201
202
203
204
  _getScrollHeight() {
    return this._scrollElement.scrollHeight || Math.max(
      document.body.scrollHeight,
      document.documentElement.scrollHeight
    )
  }
fat's avatar
fat committed
205

Johann-S's avatar
Johann-S committed
206
207
208
209
  _getOffsetHeight() {
    return this._scrollElement === window
      ? window.innerHeight : this._scrollElement.getBoundingClientRect().height
  }
fat's avatar
fat committed
210

Johann-S's avatar
Johann-S committed
211
212
213
214
215
216
  _process() {
    const scrollTop    = this._getScrollTop() + this._config.offset
    const scrollHeight = this._getScrollHeight()
    const maxScroll    = this._config.offset +
      scrollHeight -
      this._getOffsetHeight()
fat's avatar
fat committed
217

Johann-S's avatar
Johann-S committed
218
219
220
    if (this._scrollHeight !== scrollHeight) {
      this.refresh()
    }
fat's avatar
fat committed
221

Johann-S's avatar
Johann-S committed
222
223
224
225
226
    if (scrollTop >= maxScroll) {
      const target = this._targets[this._targets.length - 1]

      if (this._activeTarget !== target) {
        this._activate(target)
fat's avatar
fat committed
227
      }
Johann-S's avatar
Johann-S committed
228
      return
fat's avatar
fat committed
229
230
    }

Johann-S's avatar
Johann-S committed
231
232
    if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) {
      this._activeTarget = null
fat's avatar
fat committed
233
      this._clear()
Johann-S's avatar
Johann-S committed
234
235
      return
    }
fat's avatar
fat committed
236

Johann-S's avatar
Johann-S committed
237
238
239
240
241
242
    const offsetLength = this._offsets.length
    for (let i = offsetLength; i--;) {
      const isActiveTarget = this._activeTarget !== this._targets[i] &&
          scrollTop >= this._offsets[i] &&
          (typeof this._offsets[i + 1] === 'undefined' ||
              scrollTop < this._offsets[i + 1])
fat's avatar
fat committed
243

Johann-S's avatar
Johann-S committed
244
245
      if (isActiveTarget) {
        this._activate(this._targets[i])
fat's avatar
fat committed
246
247
      }
    }
Johann-S's avatar
Johann-S committed
248
  }
fat's avatar
fat committed
249

Johann-S's avatar
Johann-S committed
250
251
252
253
254
  _activate(target) {
    this._activeTarget = target

    this._clear()

255
    const queries = this._selector.split(',')
256
      .map((selector) => `${selector}[data-target="${target}"],${selector}[href="${target}"]`)
Johann-S's avatar
Johann-S committed
257

258
259
260
261
262
263
    const link = SelectorEngine.findOne(queries.join(','))

    if (link.classList.contains(ClassName.DROPDOWN_ITEM)) {
      SelectorEngine
        .findOne(Selector.DROPDOWN_TOGGLE, SelectorEngine.closest(link, Selector.DROPDOWN))
        .classList.add(ClassName.ACTIVE)
Johann-S's avatar
Johann-S committed
264

265
      link.classList.add(ClassName.ACTIVE)
Johann-S's avatar
Johann-S committed
266
267
    } else {
      // Set triggered link as active
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
      link.classList.add(ClassName.ACTIVE)

      SelectorEngine
        .parents(link, Selector.NAV_LIST_GROUP)
        .forEach((listGroup) => {
          // Set triggered links parents as active
          // With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
          SelectorEngine.prev(listGroup, `${Selector.NAV_LINKS}, ${Selector.LIST_ITEMS}`)
            .forEach((item) => item.classList.add(ClassName.ACTIVE))

          // Handle special case when .nav-link is inside .nav-item
          SelectorEngine.prev(listGroup, Selector.NAV_ITEMS)
            .forEach((navItem) => {
              SelectorEngine.children(navItem, Selector.NAV_LINKS)
                .forEach((item) => item.classList.add(ClassName.ACTIVE))
            })
        })
fat's avatar
fat committed
285
286
    }

287
    EventHandler.trigger(this._scrollElement, Event.ACTIVATE, {
Johann-S's avatar
Johann-S committed
288
289
290
      relatedTarget: target
    })
  }
fat's avatar
fat committed
291

Johann-S's avatar
Johann-S committed
292
  _clear() {
293
    makeArray(SelectorEngine.find(this._selector))
294
295
      .filter((node) => node.classList.contains(ClassName.ACTIVE))
      .forEach((node) => node.classList.remove(ClassName.ACTIVE))
Johann-S's avatar
Johann-S committed
296
  }
fat's avatar
fat committed
297

Johann-S's avatar
Johann-S committed
298
  // Static
fat's avatar
fat committed
299

Johann-S's avatar
Johann-S committed
300
301
  static _jQueryInterface(config) {
    return this.each(function () {
302
      let data = Data.getData(this, DATA_KEY)
Johann-S's avatar
Johann-S committed
303
304
305
306
307
308
309
310
311
      const _config = typeof config === 'object' && config

      if (!data) {
        data = new ScrollSpy(this, _config)
      }

      if (typeof config === 'string') {
        if (typeof data[config] === 'undefined') {
          throw new TypeError(`No method named "${config}"`)
fat's avatar
fat committed
312
        }
Johann-S's avatar
Johann-S committed
313
314
315
        data[config]()
      }
    })
fat's avatar
fat committed
316
  }
317
318
319
320

  static _getInstance(element) {
    return Data.getData(element, DATA_KEY)
  }
Johann-S's avatar
Johann-S committed
321
}
fat's avatar
fat committed
322

Johann-S's avatar
Johann-S committed
323
324
325
326
327
/**
 * ------------------------------------------------------------------------
 * Data Api implementation
 * ------------------------------------------------------------------------
 */
fat's avatar
fat committed
328

329
EventHandler.on(window, Event.LOAD_DATA_API, () => {
330
  makeArray(SelectorEngine.find(Selector.DATA_SPY))
331
    .forEach((spy) => new ScrollSpy(spy, Manipulator.getDataAttributes(spy)))
Johann-S's avatar
Johann-S committed
332
333
334
335
336
337
338
})

/**
 * ------------------------------------------------------------------------
 * jQuery
 * ------------------------------------------------------------------------
 */
fat's avatar
fat committed
339

340
341
342
343
344
345
346
347
if (typeof $ !== 'undefined') {
  const JQUERY_NO_CONFLICT = $.fn[NAME]
  $.fn[NAME]               = ScrollSpy._jQueryInterface
  $.fn[NAME].Constructor   = ScrollSpy
  $.fn[NAME].noConflict    = () => {
    $.fn[NAME] = JQUERY_NO_CONFLICT
    return ScrollSpy._jQueryInterface
  }
Johann-S's avatar
Johann-S committed
348
}
fat's avatar
fat committed
349
350

export default ScrollSpy