Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
/**
2
 * --------------------------------------------------------------------------
1441 ariadna 3
 * Bootstrap scrollspy.js
1 efrain 4
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5
 * --------------------------------------------------------------------------
6
 */
7
 
1441 ariadna 8
import BaseComponent from './base-component'
9
import EventHandler from './dom/event-handler'
10
import SelectorEngine from './dom/selector-engine'
11
import {
12
  defineJQueryPlugin, getElement, isDisabled, isVisible
13
} from './util/index'
1 efrain 14
 
15
/**
16
 * Constants
17
 */
18
 
19
const NAME = 'scrollspy'
20
const DATA_KEY = 'bs.scrollspy'
21
const EVENT_KEY = `.${DATA_KEY}`
22
const DATA_API_KEY = '.data-api'
23
 
24
const EVENT_ACTIVATE = `activate${EVENT_KEY}`
1441 ariadna 25
const EVENT_CLICK = `click${EVENT_KEY}`
1 efrain 26
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
27
 
1441 ariadna 28
const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'
29
const CLASS_NAME_ACTIVE = 'active'
1 efrain 30
 
1441 ariadna 31
const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]'
32
const SELECTOR_TARGET_LINKS = '[href]'
1 efrain 33
const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'
34
const SELECTOR_NAV_LINKS = '.nav-link'
35
const SELECTOR_NAV_ITEMS = '.nav-item'
36
const SELECTOR_LIST_ITEMS = '.list-group-item'
1441 ariadna 37
const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`
1 efrain 38
const SELECTOR_DROPDOWN = '.dropdown'
39
const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
40
 
41
const Default = {
1441 ariadna 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]
1 efrain 47
}
48
 
49
const DefaultType = {
1441 ariadna 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'
1 efrain 55
}
56
 
57
/**
58
 * Class definition
59
 */
60
 
1441 ariadna 61
class ScrollSpy extends BaseComponent {
1 efrain 62
  constructor(element, config) {
1441 ariadna 63
    super(element, config)
64
 
65
    // this._element is the observablesContainer and config.target the menu links wrapper
66
    this._targetLinks = new Map()
67
    this._observableSections = new Map()
68
    this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element
1 efrain 69
    this._activeTarget = null
1441 ariadna 70
    this._observer = null
71
    this._previousScrollData = {
72
      visibleEntryTop: 0,
73
      parentScrollTop: 0
74
    }
75
    this.refresh() // initialize
1 efrain 76
  }
77
 
78
  // Getters
79
  static get Default() {
80
    return Default
81
  }
82
 
1441 ariadna 83
  static get DefaultType() {
84
    return DefaultType
85
  }
86
 
87
  static get NAME() {
88
    return NAME
89
  }
90
 
1 efrain 91
  // Public
92
  refresh() {
1441 ariadna 93
    this._initializeTargetsAndObservables()
94
    this._maybeEnableSmoothScroll()
1 efrain 95
 
1441 ariadna 96
    if (this._observer) {
97
      this._observer.disconnect()
98
    } else {
99
      this._observer = this._getNewObserver()
100
    }
1 efrain 101
 
1441 ariadna 102
    for (const section of this._observableSections.values()) {
103
      this._observer.observe(section)
104
    }
105
  }
1 efrain 106
 
1441 ariadna 107
  dispose() {
108
    this._observer.disconnect()
109
    super.dispose()
110
  }
1 efrain 111
 
1441 ariadna 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
1 efrain 116
 
1441 ariadna 117
    // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only
118
    config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin
1 efrain 119
 
1441 ariadna 120
    if (typeof config.threshold === 'string') {
121
      config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value))
122
    }
1 efrain 123
 
1441 ariadna 124
    return config
125
  }
1 efrain 126
 
1441 ariadna 127
  _maybeEnableSmoothScroll() {
128
    if (!this._config.smoothScroll) {
129
      return
130
    }
131
 
132
    // unregister any previous listeners
133
    EventHandler.off(this._config.target, EVENT_CLICK)
134
 
135
    EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => {
136
      const observableSection = this._observableSections.get(event.target.hash)
137
      if (observableSection) {
138
        event.preventDefault()
139
        const root = this._rootElement || window
140
        const height = observableSection.offsetTop - this._element.offsetTop
141
        if (root.scrollTo) {
142
          root.scrollTo({ top: height, behavior: 'smooth' })
143
          return
1 efrain 144
        }
145
 
1441 ariadna 146
        // Chrome 60 doesn't support `scrollTo`
147
        root.scrollTop = height
148
      }
149
    })
1 efrain 150
  }
151
 
1441 ariadna 152
  _getNewObserver() {
153
    const options = {
154
      root: this._rootElement,
155
      threshold: this._config.threshold,
156
      rootMargin: this._config.rootMargin
157
    }
1 efrain 158
 
1441 ariadna 159
    return new IntersectionObserver(entries => this._observerCallback(entries), options)
1 efrain 160
  }
161
 
1441 ariadna 162
  // The logic of selection
163
  _observerCallback(entries) {
164
    const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`)
165
    const activate = entry => {
166
      this._previousScrollData.visibleEntryTop = entry.target.offsetTop
167
      this._process(targetElement(entry))
1 efrain 168
    }
169
 
1441 ariadna 170
    const parentScrollTop = (this._rootElement || document.documentElement).scrollTop
171
    const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop
172
    this._previousScrollData.parentScrollTop = parentScrollTop
1 efrain 173
 
1441 ariadna 174
    for (const entry of entries) {
175
      if (!entry.isIntersecting) {
176
        this._activeTarget = null
177
        this._clearActiveClass(targetElement(entry))
1 efrain 178
 
1441 ariadna 179
        continue
180
      }
1 efrain 181
 
1441 ariadna 182
      const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop
183
      // if we are scrolling down, pick the bigger offsetTop
184
      if (userScrollsDown && entryIsLowerThanPrevious) {
185
        activate(entry)
186
        // if parent isn't scrolled, let's keep the first visible item, breaking the iteration
187
        if (!parentScrollTop) {
188
          return
189
        }
1 efrain 190
 
1441 ariadna 191
        continue
192
      }
1 efrain 193
 
1441 ariadna 194
      // if we are scrolling up, pick the smallest offsetTop
195
      if (!userScrollsDown && !entryIsLowerThanPrevious) {
196
        activate(entry)
197
      }
198
    }
1 efrain 199
  }
200
 
1441 ariadna 201
  _initializeTargetsAndObservables() {
202
    this._targetLinks = new Map()
203
    this._observableSections = new Map()
1 efrain 204
 
1441 ariadna 205
    const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)
1 efrain 206
 
1441 ariadna 207
    for (const anchor of targetLinks) {
208
      // ensure that the anchor has an id and is not disabled
209
      if (!anchor.hash || isDisabled(anchor)) {
210
        continue
211
      }
1 efrain 212
 
1441 ariadna 213
      const observableSection = SelectorEngine.findOne(decodeURI(anchor.hash), this._element)
1 efrain 214
 
1441 ariadna 215
      // ensure that the observableSection exists & is visible
216
      if (isVisible(observableSection)) {
217
        this._targetLinks.set(decodeURI(anchor.hash), anchor)
218
        this._observableSections.set(anchor.hash, observableSection)
1 efrain 219
      }
220
    }
1441 ariadna 221
  }
1 efrain 222
 
1441 ariadna 223
  _process(target) {
224
    if (this._activeTarget === target) {
1 efrain 225
      return
226
    }
227
 
1441 ariadna 228
    this._clearActiveClass(this._config.target)
229
    this._activeTarget = target
230
    target.classList.add(CLASS_NAME_ACTIVE)
231
    this._activateParents(target)
1 efrain 232
 
1441 ariadna 233
    EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
1 efrain 234
  }
235
 
1441 ariadna 236
  _activateParents(target) {
237
    // Activate dropdown parents
238
    if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) {
239
      SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN))
240
        .classList.add(CLASS_NAME_ACTIVE)
241
      return
242
    }
1 efrain 243
 
1441 ariadna 244
    for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) {
1 efrain 245
      // Set triggered links parents as active
246
      // With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
1441 ariadna 247
      for (const item of SelectorEngine.prev(listGroup, SELECTOR_LINK_ITEMS)) {
248
        item.classList.add(CLASS_NAME_ACTIVE)
249
      }
1 efrain 250
    }
251
  }
252
 
1441 ariadna 253
  _clearActiveClass(parent) {
254
    parent.classList.remove(CLASS_NAME_ACTIVE)
255
 
256
    const activeNodes = SelectorEngine.find(`${SELECTOR_TARGET_LINKS}.${CLASS_NAME_ACTIVE}`, parent)
257
    for (const node of activeNodes) {
258
      node.classList.remove(CLASS_NAME_ACTIVE)
259
    }
1 efrain 260
  }
261
 
262
  // Static
1441 ariadna 263
  static jQueryInterface(config) {
1 efrain 264
    return this.each(function () {
1441 ariadna 265
      const data = ScrollSpy.getOrCreateInstance(this, config)
1 efrain 266
 
1441 ariadna 267
      if (typeof config !== 'string') {
268
        return
1 efrain 269
      }
270
 
1441 ariadna 271
      if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
272
        throw new TypeError(`No method named "${config}"`)
273
      }
1 efrain 274
 
1441 ariadna 275
      data[config]()
1 efrain 276
    })
277
  }
278
}
279
 
280
/**
281
 * Data API implementation
282
 */
283
 
1441 ariadna 284
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
285
  for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) {
286
    ScrollSpy.getOrCreateInstance(spy)
1 efrain 287
  }
288
})
289
 
290
/**
291
 * jQuery
292
 */
293
 
1441 ariadna 294
defineJQueryPlugin(ScrollSpy)
1 efrain 295
 
296
export default ScrollSpy