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

twbs / bootstrap / 9856469587

09 Jul 2024 11:49AM CUT coverage: 96.07%. Remained the same
9856469587

Pull #40619

github

web-flow
Merge ac79a1794 into 7c392498f
Pull Request #40619: Docs: Fix a minor accessibility issue (checkout example missing h1)

666 of 726 branches covered (91.74%)

Branch coverage included in aggregate %.

2023 of 2073 relevant lines covered (97.59%)

249.89 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 {
12
  defineJQueryPlugin, getElement, isDisabled, isVisible
13
} from './util/index.js'
14

15
/**
16
 * Constants
17
 */
18

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

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

28
const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'
1✔
29
const CLASS_NAME_ACTIVE = 'active'
1✔
30

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

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

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

57
/**
58
 * Class definition
59
 */
60

61
class ScrollSpy extends BaseComponent {
62
  constructor(element, config) {
63
    super(element, config)
37✔
64

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

78
  // Getters
79
  static get Default() {
80
    return Default
38✔
81
  }
82

83
  static get DefaultType() {
84
    return DefaultType
37✔
85
  }
86

87
  static get NAME() {
88
    return NAME
68✔
89
  }
90

91
  // Public
92
  refresh() {
93
    this._initializeTargetsAndObservables()
38✔
94
    this._maybeEnableSmoothScroll()
38✔
95

96
    if (this._observer) {
38✔
97
      this._observer.disconnect()
1✔
98
    } else {
99
      this._observer = this._getNewObserver()
37✔
100
    }
101

102
    for (const section of this._observableSections.values()) {
38✔
103
      this._observer.observe(section)
49✔
104
    }
105
  }
106

107
  dispose() {
108
    this._observer.disconnect()
1✔
109
    super.dispose()
1✔
110
  }
111

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

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

120
    if (typeof config.threshold === 'string') {
37✔
121
      config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value))
3✔
122
    }
123

124
    return config
37✔
125
  }
126

127
  _maybeEnableSmoothScroll() {
128
    if (!this._config.smoothScroll) {
38✔
129
      return
33✔
130
    }
131

132
    // unregister any previous listeners
133
    EventHandler.off(this._config.target, EVENT_CLICK)
5✔
134

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

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

152
  _getNewObserver() {
153
    const options = {
37✔
154
      root: this._rootElement,
155
      threshold: this._config.threshold,
156
      rootMargin: this._config.rootMargin
157
    }
158

159
    return new IntersectionObserver(entries => this._observerCallback(entries), options)
55✔
160
  }
161

162
  // The logic of selection
163
  _observerCallback(entries) {
164
    const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`)
75✔
165
    const activate = entry => {
55✔
166
      this._previousScrollData.visibleEntryTop = entry.target.offsetTop
24✔
167
      this._process(targetElement(entry))
24✔
168
    }
169

170
    const parentScrollTop = (this._rootElement || document.documentElement).scrollTop
55!
171
    const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop
55✔
172
    this._previousScrollData.parentScrollTop = parentScrollTop
55✔
173

174
    for (const entry of entries) {
55✔
175
      if (!entry.isIntersecting) {
75✔
176
        this._activeTarget = null
51✔
177
        this._clearActiveClass(targetElement(entry))
51✔
178

179
        continue
51✔
180
      }
181

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

191
        continue
12✔
192
      }
193

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

201
  _initializeTargetsAndObservables() {
202
    this._targetLinks = new Map()
38✔
203
    this._observableSections = new Map()
38✔
204

205
    const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)
38✔
206

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

213
      const observableSection = SelectorEngine.findOne(decodeURI(anchor.hash), this._element)
52✔
214

215
      // ensure that the observableSection exists & is visible
216
      if (isVisible(observableSection)) {
52✔
217
        this._targetLinks.set(decodeURI(anchor.hash), anchor)
49✔
218
        this._observableSections.set(anchor.hash, observableSection)
49✔
219
      }
220
    }
221
  }
222

223
  _process(target) {
224
    if (this._activeTarget === target) {
24✔
225
      return
1✔
226
    }
227

228
    this._clearActiveClass(this._config.target)
23✔
229
    this._activeTarget = target
23✔
230
    target.classList.add(CLASS_NAME_ACTIVE)
23✔
231
    this._activateParents(target)
23✔
232

233
    EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
23✔
234
  }
235

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

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

253
  _clearActiveClass(parent) {
254
    parent.classList.remove(CLASS_NAME_ACTIVE)
74✔
255

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

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

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

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

275
      data[config]()
1✔
276
    })
277
  }
278
}
279

280
/**
281
 * Data API implementation
282
 */
283

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

290
/**
291
 * jQuery
292
 */
293

294
defineJQueryPlugin(ScrollSpy)
1✔
295

296
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