scrollspy.js 9.26 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
import Data from './dom/data'
import EventHandler from './dom/eventHandler'
import Manipulator from './dom/manipulator'
import SelectorEngine from './dom/selectorEngine'
12
13
import Util from './util'

Johann-S's avatar
Johann-S committed
14
15
16
17
18
/**
 * ------------------------------------------------------------------------
 * Constants
 * ------------------------------------------------------------------------
 */
fat's avatar
fat committed
19

Johann-S's avatar
Johann-S committed
20
const NAME               = 'scrollspy'
XhmikosR's avatar
XhmikosR committed
21
const VERSION            = '4.3.1'
Johann-S's avatar
Johann-S committed
22
23
24
25
26
27
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
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
66

Johann-S's avatar
Johann-S committed
67
68
69
70
71
/**
 * ------------------------------------------------------------------------
 * Class Definition
 * ------------------------------------------------------------------------
 */
fat's avatar
fat committed
72

Johann-S's avatar
Johann-S committed
73
74
75
76
77
78
79
80
81
82
83
84
85
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

86
    EventHandler.on(this._scrollElement, Event.SCROLL, (event) => this._process(event))
Johann-S's avatar
Johann-S committed
87
88
89

    this.refresh()
    this._process()
90
91

    Data.setData(element, DATA_KEY, this)
fat's avatar
fat committed
92
93
  }

Johann-S's avatar
Johann-S committed
94
  // Getters
fat's avatar
fat committed
95

Johann-S's avatar
Johann-S committed
96
97
  static get VERSION() {
    return VERSION
98
99
  }

Johann-S's avatar
Johann-S committed
100
101
102
  static get Default() {
    return Default
  }
fat's avatar
fat committed
103

Johann-S's avatar
Johann-S committed
104
  // Public
fat's avatar
fat committed
105

Johann-S's avatar
Johann-S committed
106
107
108
  refresh() {
    const autoMethod = this._scrollElement === this._scrollElement.window
      ? OffsetMethod.OFFSET : OffsetMethod.POSITION
109

Johann-S's avatar
Johann-S committed
110
111
    const offsetMethod = this._config.method === 'auto'
      ? autoMethod : this._config.method
fat's avatar
fat committed
112

Johann-S's avatar
Johann-S committed
113
114
    const offsetBase = offsetMethod === OffsetMethod.POSITION
      ? this._getScrollTop() : 0
fat's avatar
fat committed
115

Johann-S's avatar
Johann-S committed
116
117
    this._offsets = []
    this._targets = []
fat's avatar
fat committed
118

Johann-S's avatar
Johann-S committed
119
    this._scrollHeight = this._getScrollHeight()
fat's avatar
fat committed
120

121
    const targets = Util.makeArray(document.querySelectorAll(this._selector))
fat's avatar
fat committed
122

Johann-S's avatar
Johann-S committed
123
124
125
126
    targets
      .map((element) => {
        let target
        const targetSelector = Util.getSelectorFromElement(element)
fat's avatar
fat committed
127

Johann-S's avatar
Johann-S committed
128
129
130
        if (targetSelector) {
          target = document.querySelector(targetSelector)
        }
fat's avatar
fat committed
131

Johann-S's avatar
Johann-S committed
132
133
134
135
136
        if (target) {
          const targetBCR = target.getBoundingClientRect()
          if (targetBCR.width || targetBCR.height) {
            // TODO (fat): remove sketch reliance on jQuery position/offset
            return [
137
              Manipulator[offsetMethod](target).top + offsetBase,
Johann-S's avatar
Johann-S committed
138
139
              targetSelector
            ]
fat's avatar
fat committed
140
          }
fat's avatar
fat committed
141
        }
Johann-S's avatar
Johann-S committed
142
143
144
145
146
147
148
149
150
        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
151

Johann-S's avatar
Johann-S committed
152
  dispose() {
153
154
    Data.removeData(this._element, DATA_KEY)
    EventHandler.off(this._scrollElement, EVENT_KEY)
Johann-S's avatar
Johann-S committed
155
156
157
158
159
160
161
162
163
164

    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
165

Johann-S's avatar
Johann-S committed
166
  // Private
fat's avatar
fat committed
167

Johann-S's avatar
Johann-S committed
168
169
170
171
  _getConfig(config) {
    config = {
      ...Default,
      ...typeof config === 'object' && config ? config : {}
fat's avatar
fat committed
172
173
    }

Johann-S's avatar
Johann-S committed
174
    if (typeof config.target !== 'string') {
175
      let id = config.target.id
Johann-S's avatar
Johann-S committed
176
177
      if (!id) {
        id = Util.getUID(NAME)
178
        config.target.id = id
Johann-S's avatar
Johann-S committed
179
180
      }
      config.target = `#${id}`
fat's avatar
fat committed
181
182
    }

Johann-S's avatar
Johann-S committed
183
    Util.typeCheckConfig(NAME, config, DefaultType)
184

Johann-S's avatar
Johann-S committed
185
186
    return config
  }
fat's avatar
fat committed
187

Johann-S's avatar
Johann-S committed
188
189
190
191
  _getScrollTop() {
    return this._scrollElement === window
      ? this._scrollElement.pageYOffset : this._scrollElement.scrollTop
  }
fat's avatar
fat committed
192

Johann-S's avatar
Johann-S committed
193
194
195
196
197
198
  _getScrollHeight() {
    return this._scrollElement.scrollHeight || Math.max(
      document.body.scrollHeight,
      document.documentElement.scrollHeight
    )
  }
fat's avatar
fat committed
199

Johann-S's avatar
Johann-S committed
200
201
202
203
  _getOffsetHeight() {
    return this._scrollElement === window
      ? window.innerHeight : this._scrollElement.getBoundingClientRect().height
  }
fat's avatar
fat committed
204

Johann-S's avatar
Johann-S committed
205
206
207
208
209
210
  _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
211

Johann-S's avatar
Johann-S committed
212
213
214
    if (this._scrollHeight !== scrollHeight) {
      this.refresh()
    }
fat's avatar
fat committed
215

Johann-S's avatar
Johann-S committed
216
217
218
219
220
    if (scrollTop >= maxScroll) {
      const target = this._targets[this._targets.length - 1]

      if (this._activeTarget !== target) {
        this._activate(target)
fat's avatar
fat committed
221
      }
Johann-S's avatar
Johann-S committed
222
      return
fat's avatar
fat committed
223
224
    }

Johann-S's avatar
Johann-S committed
225
226
    if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) {
      this._activeTarget = null
fat's avatar
fat committed
227
      this._clear()
Johann-S's avatar
Johann-S committed
228
229
      return
    }
fat's avatar
fat committed
230

Johann-S's avatar
Johann-S committed
231
232
233
234
235
236
    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
237

Johann-S's avatar
Johann-S committed
238
239
      if (isActiveTarget) {
        this._activate(this._targets[i])
fat's avatar
fat committed
240
241
      }
    }
Johann-S's avatar
Johann-S committed
242
  }
fat's avatar
fat committed
243

Johann-S's avatar
Johann-S committed
244
245
246
247
248
  _activate(target) {
    this._activeTarget = target

    this._clear()

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

252
253
254
255
256
257
    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
258

259
      link.classList.add(ClassName.ACTIVE)
Johann-S's avatar
Johann-S committed
260
261
    } else {
      // Set triggered link as active
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
      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
279
280
    }

281
    EventHandler.trigger(this._scrollElement, Event.ACTIVATE, {
Johann-S's avatar
Johann-S committed
282
283
284
      relatedTarget: target
    })
  }
fat's avatar
fat committed
285

Johann-S's avatar
Johann-S committed
286
  _clear() {
287
    Util.makeArray(document.querySelectorAll(this._selector))
288
289
      .filter((node) => node.classList.contains(ClassName.ACTIVE))
      .forEach((node) => node.classList.remove(ClassName.ACTIVE))
Johann-S's avatar
Johann-S committed
290
  }
fat's avatar
fat committed
291

Johann-S's avatar
Johann-S committed
292
  // Static
fat's avatar
fat committed
293

Johann-S's avatar
Johann-S committed
294
295
  static _jQueryInterface(config) {
    return this.each(function () {
296
      let data = Data.getData(this, DATA_KEY)
Johann-S's avatar
Johann-S committed
297
298
299
300
301
302
303
304
305
      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
306
        }
Johann-S's avatar
Johann-S committed
307
308
309
        data[config]()
      }
    })
fat's avatar
fat committed
310
  }
311
312
313
314

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

Johann-S's avatar
Johann-S committed
317
318
319
320
321
/**
 * ------------------------------------------------------------------------
 * Data Api implementation
 * ------------------------------------------------------------------------
 */
fat's avatar
fat committed
322

323
324
EventHandler.on(window, Event.LOAD_DATA_API, () => {
  Util.makeArray(SelectorEngine.find(Selector.DATA_SPY))
325
    .forEach((spy) => new ScrollSpy(spy, Manipulator.getDataAttributes(spy)))
Johann-S's avatar
Johann-S committed
326
327
328
329
330
331
332
})

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

334
335
336
337
338
339
340
341
342
const $ = Util.jQuery
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
343
}
fat's avatar
fat committed
344
345

export default ScrollSpy