• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

twbs / bootstrap / 4658914237

10 Apr 2023 03:49PM CUT coverage: 96.059%. Remained the same
4658914237

Pull #38219

github

GitHub
Merge b371d6eef into 3d84e60d6
Pull Request #38219: Docs: consistent usage of CSS sections in /helpers, /layout

662 of 722 branches covered (91.69%)

Branch coverage included in aggregate %.

2019 of 2069 relevant lines covered (97.58%)

205.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.42
/js/src/scrollspy.js
1
/**
2
 * --------------------------------------------------------------------------
3
 * Bootstrap scrollspy.js
4
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5
 * --------------------------------------------------------------------------
6
 */
7

8
import BaseComponent from './base-component.js'
9
import EventHandler from './dom/event-handler.js'
10
import SelectorEngine from './dom/selector-engine.js'
11
import { defineJQueryPlugin, getElement, isDisabled, isVisible } from './util/index.js'
12

13
/**
14
 * Constants
15
 */
16

17
const NAME = 'scrollspy'
1✔
18
const DATA_KEY = 'bs.scrollspy'
1✔
19
const EVENT_KEY = `.${DATA_KEY}`
1✔
20
const DATA_API_KEY = '.data-api'
1✔
21

22
const EVENT_ACTIVATE = `activate${EVENT_KEY}`
1✔
23
const EVENT_CLICK = `click${EVENT_KEY}`
1✔
24
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
1✔
25

26
const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'
1✔
27
const CLASS_NAME_ACTIVE = 'active'
1✔
28

29
const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]'
1✔
30
const SELECTOR_TARGET_LINKS = '[href]'
1✔
31
const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'
1✔
32
const SELECTOR_NAV_LINKS = '.nav-link'
1✔
33
const SELECTOR_NAV_ITEMS = '.nav-item'
1✔
34
const SELECTOR_LIST_ITEMS = '.list-group-item'
1✔
35
const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`
1✔
36
const SELECTOR_DROPDOWN = '.dropdown'
1✔
37
const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
1✔
38

39
const Default = {
1✔
40
  offset: null, // TODO: v6 @deprecated, keep it for backwards compatibility reasons
41
  rootMargin: '0px 0px -25%',
42
  smoothScroll: false,
43
  target: null,
44
  threshold: [0.1, 0.5, 1]
45
}
46

47
const DefaultType = {
1✔
48
  offset: '(number|null)', // TODO v6 @deprecated, keep it for backwards compatibility reasons
49
  rootMargin: 'string',
50
  smoothScroll: 'boolean',
51
  target: 'element',
52
  threshold: 'array'
53
}
54

55
/**
56
 * Class definition
57
 */
58

59
class ScrollSpy extends BaseComponent {
60
  constructor(element, config) {
61
    super(element, config)
36✔
62

63
    // this._element is the observablesContainer and config.target the menu links wrapper
64
    this._targetLinks = new Map()
36✔
65
    this._observableSections = new Map()
36✔
66
    this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element
36✔
67
    this._activeTarget = null
36✔
68
    this._observer = null
36✔
69
    this._previousScrollData = {
36✔
70
      visibleEntryTop: 0,
71
      parentScrollTop: 0
72
    }
73
    this.refresh() // initialize
36✔
74
  }
75

76
  // Getters
77
  static get Default() {
78
    return Default
37✔
79
  }
80

81
  static get DefaultType() {
82
    return DefaultType
36✔
83
  }
84

85
  static get NAME() {
86
    return NAME
67✔
87
  }
88

89
  // Public
90
  refresh() {
91
    this._initializeTargetsAndObservables()
37✔
92
    this._maybeEnableSmoothScroll()
37✔
93

94
    if (this._observer) {
37✔
95
      this._observer.disconnect()
1✔
96
    } else {
97
      this._observer = this._getNewObserver()
36✔
98
    }
99

100
    for (const section of this._observableSections.values()) {
37✔
101
      this._observer.observe(section)
48✔
102
    }
103
  }
104

105
  dispose() {
106
    this._observer.disconnect()
1✔
107
    super.dispose()
1✔
108
  }
109

110
  // Private
111
  _configAfterMerge(config) {
112
    // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case
113
    config.target = getElement(config.target) || document.body
36✔
114

115
    // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only
116
    config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin
36✔
117

118
    if (typeof config.threshold === 'string') {
36✔
119
      config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value))
3✔
120
    }
121

122
    return config
36✔
123
  }
124

125
  _maybeEnableSmoothScroll() {
126
    if (!this._config.smoothScroll) {
37✔
127
      return
33✔
128
    }
129

130
    // unregister any previous listeners
131
    EventHandler.off(this._config.target, EVENT_CLICK)
4✔
132

133
    EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => {
4✔
134
      const observableSection = this._observableSections.get(event.target.hash)
3✔
135
      if (observableSection) {
3✔
136
        event.preventDefault()
2✔
137
        const root = this._rootElement || window
2!
138
        const height = observableSection.offsetTop - this._element.offsetTop
2✔
139
        if (root.scrollTo) {
2✔
140
          root.scrollTo({ top: height, behavior: 'smooth' })
2✔
141
          return
2✔
142
        }
143

144
        // Chrome 60 doesn't support `scrollTo`
145
        root.scrollTop = height
×
146
      }
147
    })
148
  }
149

150
  _getNewObserver() {
151
    const options = {
36✔
152
      root: this._rootElement,
153
      threshold: this._config.threshold,
154
      rootMargin: this._config.rootMargin
155
    }
156

157
    return new IntersectionObserver(entries => this._observerCallback(entries), options)
53✔
158
  }
159

160
  // The logic of selection
161
  _observerCallback(entries) {
162
    const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`)
73✔
163
    const activate = entry => {
53✔
164
      this._previousScrollData.visibleEntryTop = entry.target.offsetTop
23✔
165
      this._process(targetElement(entry))
23✔
166
    }
167

168
    const parentScrollTop = (this._rootElement || document.documentElement).scrollTop
53!
169
    const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop
53✔
170
    this._previousScrollData.parentScrollTop = parentScrollTop
53✔
171

172
    for (const entry of entries) {
53✔
173
      if (!entry.isIntersecting) {
73✔
174
        this._activeTarget = null
50✔
175
        this._clearActiveClass(targetElement(entry))
50✔
176

177
        continue
50✔
178
      }
179

180
      const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop
23✔
181
      // if we are scrolling down, pick the bigger offsetTop
182
      if (userScrollsDown && entryIsLowerThanPrevious) {
23✔
183
        activate(entry)
18✔
184
        // if parent isn't scrolled, let's keep the first visible item, breaking the iteration
185
        if (!parentScrollTop) {
18✔
186
          return
6✔
187
        }
188

189
        continue
12✔
190
      }
191

192
      // if we are scrolling up, pick the smallest offsetTop
193
      if (!userScrollsDown && !entryIsLowerThanPrevious) {
5✔
194
        activate(entry)
5✔
195
      }
196
    }
197
  }
198

199
  _initializeTargetsAndObservables() {
200
    this._targetLinks = new Map()
37✔
201
    this._observableSections = new Map()
37✔
202

203
    const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)
37✔
204

205
    for (const anchor of targetLinks) {
37✔
206
      // ensure that the anchor has an id and is not disabled
207
      if (!anchor.hash || isDisabled(anchor)) {
55✔
208
        continue
4✔
209
      }
210

211
      const observableSection = SelectorEngine.findOne(anchor.hash, this._element)
51✔
212

213
      // ensure that the observableSection exists & is visible
214
      if (isVisible(observableSection)) {
51✔
215
        this._targetLinks.set(anchor.hash, anchor)
48✔
216
        this._observableSections.set(anchor.hash, observableSection)
48✔
217
      }
218
    }
219
  }
220

221
  _process(target) {
222
    if (this._activeTarget === target) {
23✔
223
      return
1✔
224
    }
225

226
    this._clearActiveClass(this._config.target)
22✔
227
    this._activeTarget = target
22✔
228
    target.classList.add(CLASS_NAME_ACTIVE)
22✔
229
    this._activateParents(target)
22✔
230

231
    EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
22✔
232
  }
233

234
  _activateParents(target) {
235
    // Activate dropdown parents
236
    if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) {
22!
237
      SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN))
×
238
        .classList.add(CLASS_NAME_ACTIVE)
239
      return
×
240
    }
241

242
    for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) {
22✔
243
      // Set triggered links parents as active
244
      // With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
245
      for (const item of SelectorEngine.prev(listGroup, SELECTOR_LINK_ITEMS)) {
18✔
246
        item.classList.add(CLASS_NAME_ACTIVE)
×
247
      }
248
    }
249
  }
250

251
  _clearActiveClass(parent) {
252
    parent.classList.remove(CLASS_NAME_ACTIVE)
72✔
253

254
    const activeNodes = SelectorEngine.find(`${SELECTOR_TARGET_LINKS}.${CLASS_NAME_ACTIVE}`, parent)
72✔
255
    for (const node of activeNodes) {
72✔
256
      node.classList.remove(CLASS_NAME_ACTIVE)
7✔
257
    }
258
  }
259

260
  // Static
261
  static jQueryInterface(config) {
262
    return this.each(function () {
7✔
263
      const data = ScrollSpy.getOrCreateInstance(this, config)
7✔
264

265
      if (typeof config !== 'string') {
7✔
266
        return
3✔
267
      }
268

269
      if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
4✔
270
        throw new TypeError(`No method named "${config}"`)
3✔
271
      }
272

273
      data[config]()
1✔
274
    })
275
  }
276
}
277

278
/**
279
 * Data API implementation
280
 */
281

282
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
1✔
283
  for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) {
4✔
284
    ScrollSpy.getOrCreateInstance(spy)
1✔
285
  }
286
})
287

288
/**
289
 * jQuery
290
 */
291

292
defineJQueryPlugin(ScrollSpy)
1✔
293

294
export default ScrollSpy
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc