},
{
"path": "./dist/js/bootstrap.bundle.js",
- "maxSize": "83.75 kB"
+ "maxSize": "86.75 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
- "maxSize": "53.25 kB"
+ "maxSize": "54.5 kB"
},
{
"path": "./dist/js/bootstrap.js",
- "maxSize": "55.0 kB"
+ "maxSize": "58.0 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
- "maxSize": "31.25 kB"
+ "maxSize": "32.5 kB"
}
],
"ci": {
"fieldsets",
"flexbox",
"focustrap",
+ "Fpath",
"frontmatter",
"fullscreen",
"getbootstrap",
'readystatechange',
'error',
'abort',
- 'scroll'
+ 'scroll',
+ 'scrollend'
])
/**
const EVENT_ACTIVATE = `activate${EVENT_KEY}`
const EVENT_CLICK = `click${EVENT_KEY}`
+const EVENT_SCROLL = `scroll${EVENT_KEY}`
+const EVENT_SCROLLEND = `scrollend${EVENT_KEY}`
+const EVENT_RESIZE = `resize${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_MENU_ITEM = 'menu-item'
const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`
const SELECTOR_MENU_TOGGLE = '[data-bs-toggle="menu"]'
+// How long (ms) to wait after the last scroll event before settling a pending
+// smooth-scroll navigation, when the native `scrollend` event is unavailable.
+const SCROLL_IDLE_TIMEOUT = 100
+// Debounce (ms) for rebuilding the observer on resize (px activation lines only).
+const RESIZE_DEBOUNCE = 100
+
const Default = {
- rootMargin: '0px 0px -25%',
+ // `rootMargin` is the raw IntersectionObserver root-box override. When set it
+ // takes precedence over `topMargin` and is passed straight to the observer.
+ // Leave it null and use `topMargin` for everyday use.
+ rootMargin: null,
smoothScroll: false,
target: null,
- threshold: [0.1, 0.5, 1]
+ threshold: [0],
+ // Position of the activation line, measured from the top of the scroll root.
+ // The active section is the deepest one whose top has scrolled to/above it.
+ // Accepts a percentage (`12%`) or pixels (`96px`, e.g. below a sticky navbar).
+ topMargin: '12%'
}
const DefaultType = {
- rootMargin: 'string',
+ rootMargin: '(string|null)',
smoothScroll: 'boolean',
target: 'element',
- threshold: 'array'
+ threshold: 'array',
+ topMargin: 'string'
}
/**
super(element, config)
// this._element is the observablesContainer and config.target the menu links wrapper
- this._targetLinks = new Map()
- this._observableSections = new Map()
- this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element
+ this._sections = [] // observable section elements, in DOM order
+ this._linkBySection = new Map() // section element -> nav link
+ this._sectionByLink = new Map() // nav link -> section element (for smooth scroll)
+ this._intersecting = new Set() // sections currently crossing the activation line
this._activeTarget = null
+ this._lastActive = null // last activated section (keep-last across gaps)
+ this._atBottom = false
+ this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element
+
this._observer = null
- this._previousScrollData = {
- visibleEntryTop: 0,
- parentScrollTop: 0
- }
+ this._sentinel = null
+ this._sentinelObserver = null
+
+ this._pendingNavigation = null
+ this._settleTimeout = null
+ this._settleHandler = null
+ this._scrollIdleHandler = null
+
+ this._resizeHandler = null
+ this._resizeTimeout = null
+
this.refresh() // initialize
}
this._initializeTargetsAndObservables()
this._maybeEnableSmoothScroll()
- if (this._observer) {
- this._observer.disconnect()
- } else {
- this._observer = this._getNewObserver()
- }
-
- for (const section of this._observableSections.values()) {
+ // (Re)build the activation observer.
+ this._observer?.disconnect()
+ this._intersecting.clear()
+ this._observer = this._getNewObserver()
+ for (const section of this._sections) {
this._observer.observe(section)
}
+
+ // Detect the bottom-of-page case (a short last section whose top never
+ // reaches the activation line) natively, via a dedicated sentinel observer.
+ this._setUpSentinel()
+
+ // A px activation line doesn't track viewport height the way `%` does, so
+ // rebuild the observer (debounced) on resize when px units are in play.
+ this._maybeAddResizeListener()
}
dispose() {
- this._observer.disconnect()
+ this._observer?.disconnect()
+ this._teardownSentinel()
+ this._disarmSettle()
+ this._removeResizeListener()
+ EventHandler.off(this._config.target, EVENT_CLICK)
super.dispose()
}
return config
}
+ // --- Detection (IntersectionObserver-driven) -----------------------------
+
+ _getNewObserver() {
+ const options = {
+ root: this._rootElement,
+ threshold: this._config.threshold,
+ rootMargin: this._config.rootMargin ?? this._getDerivedRootMargin()
+ }
+
+ return new IntersectionObserver(entries => this._onIntersect(entries), options)
+ }
+
+ _onIntersect(entries) {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ this._intersecting.add(entry.target)
+ } else {
+ this._intersecting.delete(entry.target)
+ }
+ }
+
+ this._computeActive()
+ }
+
+ // Single source of truth for active selection, derived only from IO state —
+ // no per-frame layout reads. The active section is the deepest (DOM-order)
+ // one currently crossing the activation line; in a gap we keep the last one;
+ // above the first section the first stays active; at the very bottom the last
+ // section wins.
+ _computeActive() {
+ // Guard against observer callbacks that outlive a disposed/detached instance.
+ if (!this._element?.isConnected || this._sections.length === 0) {
+ return
+ }
+
+ let active = null
+
+ if (this._atBottom) {
+ active = this._sections.at(-1)
+ } else {
+ for (const section of this._sections) {
+ if (this._intersecting.has(section)) {
+ active = section
+ }
+ }
+
+ // No section crosses the line: keep the last active (content gap), or fall
+ // back to the first section at the top of the page.
+ active ||= this._lastActive ?? this._sections.at(0)
+ }
+
+ if (!active) {
+ return
+ }
+
+ this._lastActive = active
+ const link = this._linkBySection.get(active)
+ if (link) {
+ this._process(link)
+ }
+ }
+
+ // Single source of truth for the `topMargin` option: its numeric value and
+ // whether it's expressed as a percentage of the root height or in pixels.
+ _parseTopMargin() {
+ const value = String(this._config.topMargin)
+ return {
+ value: Number.parseFloat(value) || 0,
+ unit: value.endsWith('%') ? '%' : 'px'
+ }
+ }
+
+ // Collapse the observer root to a strip from the top down to the activation
+ // line, so a section is "intersecting" exactly while it crosses that line.
+ _getDerivedRootMargin() {
+ const { value, unit } = this._parseTopMargin()
+ let percent = value
+
+ // Express a pixel activation line as a percentage of the root height.
+ if (unit === 'px') {
+ const rootHeight = this._rootElement ?
+ this._rootElement.clientHeight :
+ (document.documentElement.clientHeight || window.innerHeight)
+ percent = rootHeight ? (value / rootHeight) * 100 : 12
+ }
+
+ // Clamp so the bottom inset stays a valid (non-negative) rootMargin even if
+ // the line sits outside the root box.
+ const bottom = Math.min(Math.max(100 - percent, 0), 100)
+ return `0px 0px -${bottom}% 0px`
+ }
+
+ // Whether the activation line is derived from a pixel `topMargin` (in which
+ // case it must be recomputed on resize). An explicit `rootMargin` is owned by
+ // the caller, and a `%` topMargin is recomputed by the browser automatically.
+ _usesPixelMargin() {
+ return !this._config.rootMargin && this._parseTopMargin().unit === 'px'
+ }
+
+ // --- Bottom sentinel -----------------------------------------------------
+
+ _setUpSentinel() {
+ this._teardownSentinel()
+
+ if (this._sections.length === 0) {
+ return
+ }
+
+ const sentinel = document.createElement('div')
+ sentinel.setAttribute('aria-hidden', 'true')
+ sentinel.style.cssText = 'position:relative;width:0;height:0;margin:0;padding:0;border:0;visibility:hidden;'
+ this._element.append(sentinel)
+ this._sentinel = sentinel
+
+ this._sentinelObserver = new IntersectionObserver(entries => this._onSentinel(entries), {
+ root: this._rootElement,
+ threshold: [0]
+ })
+ this._sentinelObserver.observe(sentinel)
+ }
+
+ _onSentinel(entries) {
+ const entry = entries.at(-1)
+ // Only treat the sentinel as "bottom reached" when content actually
+ // overflows; otherwise everything is visible and there's nothing to spy.
+ this._atBottom = Boolean(entry?.isIntersecting) && this._isOverflowing()
+ this._computeActive()
+ }
+
+ _isOverflowing() {
+ const scroller = this._rootElement || document.scrollingElement || document.documentElement
+ return scroller.scrollHeight > scroller.clientHeight
+ }
+
+ _teardownSentinel() {
+ this._sentinelObserver?.disconnect()
+ this._sentinelObserver = null
+ this._sentinel?.remove()
+ this._sentinel = null
+ this._atBottom = false
+ }
+
+ // --- Resize (px activation lines only) -----------------------------------
+
+ _maybeAddResizeListener() {
+ this._removeResizeListener()
+
+ if (!this._usesPixelMargin()) {
+ return
+ }
+
+ this._resizeHandler = () => {
+ clearTimeout(this._resizeTimeout)
+ this._resizeTimeout = setTimeout(() => this._rebuildObserver(), RESIZE_DEBOUNCE)
+ }
+
+ EventHandler.on(window, EVENT_RESIZE, this._resizeHandler)
+ }
+
+ _removeResizeListener() {
+ clearTimeout(this._resizeTimeout)
+ this._resizeTimeout = null
+
+ if (this._resizeHandler) {
+ EventHandler.off(window, EVENT_RESIZE, this._resizeHandler)
+ this._resizeHandler = null
+ }
+ }
+
+ _rebuildObserver() {
+ if (!this._observer) {
+ return
+ }
+
+ this._observer.disconnect()
+ this._intersecting.clear()
+ this._observer = this._getNewObserver()
+ for (const section of this._sections) {
+ this._observer.observe(section)
+ }
+ }
+
+ // --- Smooth-scroll settle (hash + focus) ---------------------------------
+
_maybeEnableSmoothScroll() {
if (!this._config.smoothScroll) {
return
}
- // unregister any previous listeners
+ // Unregister any previous listener so refresh() doesn't stack them.
EventHandler.off(this._config.target, EVENT_CLICK)
EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => {
- const observableSection = this._observableSections.get(event.target.hash)
- if (observableSection) {
- event.preventDefault()
- const root = this._rootElement || window
- const height = observableSection.offsetTop - this._element.offsetTop
+ const link = event.target.closest(SELECTOR_TARGET_LINKS)
+ const section = link && this._sectionByLink.get(link)
+ if (!section || !this._element) {
+ return
+ }
+
+ event.preventDefault()
+
+ const root = this._rootElement || window
+ const height = section.offsetTop - this._element.offsetTop
+ const currentTop = this._rootElement ?
+ this._rootElement.scrollTop :
+ (window.scrollY ?? window.pageYOffset)
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
+
+ // If we're already there (or motion is reduced), there will be no scroll
+ // — and thus no `scrollend` — to wait for, so settle immediately. This
+ // avoids a stuck pending navigation that never restores hash/focus.
+ if (reduceMotion || Math.abs(currentTop - height) <= 2) {
if (root.scrollTo) {
- root.scrollTo({ top: height, behavior: 'smooth' })
- return
+ root.scrollTo({ top: height, behavior: 'auto' })
+ } else {
+ root.scrollTop = height
}
- // Chrome 60 doesn't support `scrollTo`
+ this._settleNavigation(link.hash, section)
+ return
+ }
+
+ // Defer the URL-hash and focus updates until the scroll settles, so we
+ // don't thrash the address bar mid-animation (and so the native hash
+ // navigation we just prevented is restored once we arrive).
+ this._pendingNavigation = { hash: link.hash, section }
+ this._armSettle()
+
+ if (root.scrollTo) {
+ root.scrollTo({ top: height, behavior: 'smooth' })
+ } else {
root.scrollTop = height
}
})
}
- _getNewObserver() {
- const options = {
- root: this._rootElement,
- threshold: this._config.threshold,
- rootMargin: this._config.rootMargin
+ // Arm a one-shot settle for the in-flight smooth scroll. `scrollend` is the
+ // primary signal; a transient scroll-idle timer covers engines without it.
+ // Both are removed on settle, so a later unrelated scroll can't replay it.
+ _armSettle() {
+ this._disarmSettle()
+
+ const target = this._getSettleTarget()
+
+ this._settleHandler = () => this._onSettle()
+ this._scrollIdleHandler = () => {
+ clearTimeout(this._settleTimeout)
+ this._settleTimeout = setTimeout(() => this._onSettle(), SCROLL_IDLE_TIMEOUT)
}
- return new IntersectionObserver(entries => this._observerCallback(entries), options)
+ EventHandler.on(target, EVENT_SCROLLEND, this._settleHandler)
+ EventHandler.on(target, EVENT_SCROLL, this._scrollIdleHandler)
}
- // The logic of selection
- _observerCallback(entries) {
- const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`)
- const activate = entry => {
- this._previousScrollData.visibleEntryTop = entry.target.offsetTop
- this._process(targetElement(entry))
+ _disarmSettle() {
+ clearTimeout(this._settleTimeout)
+ this._settleTimeout = null
+
+ const target = this._getSettleTarget()
+ if (this._settleHandler) {
+ EventHandler.off(target, EVENT_SCROLLEND, this._settleHandler)
+ this._settleHandler = null
}
- const parentScrollTop = (this._rootElement || document.documentElement).scrollTop
- const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop
- this._previousScrollData.parentScrollTop = parentScrollTop
+ if (this._scrollIdleHandler) {
+ EventHandler.off(target, EVENT_SCROLL, this._scrollIdleHandler)
+ this._scrollIdleHandler = null
+ }
+ }
- for (const entry of entries) {
- if (!entry.isIntersecting) {
- this._activeTarget = null
- this._clearActiveClass(targetElement(entry))
+ _getSettleTarget() {
+ return this._rootElement || document
+ }
- continue
- }
+ _onSettle() {
+ this._disarmSettle()
- const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop
- // if we are scrolling down, pick the bigger offsetTop
- if (userScrollsDown && entryIsLowerThanPrevious) {
- activate(entry)
- // if parent isn't scrolled, let's keep the first visible item, breaking the iteration
- if (!parentScrollTop) {
- return
- }
+ if (!this._pendingNavigation) {
+ return
+ }
- continue
- }
+ const { hash, section } = this._pendingNavigation
+ this._settleNavigation(hash, section)
+ }
- // if we are scrolling up, pick the smallest offsetTop
- if (!userScrollsDown && !entryIsLowerThanPrevious) {
- activate(entry)
- }
+ _settleNavigation(hash, section) {
+ this._pendingNavigation = null
+
+ // Restore the URL hash (without adding a history entry) now that we've
+ // arrived, and move focus to the section for keyboard/AT users.
+ if (window.history?.replaceState) {
+ window.history.replaceState(null, '', hash)
+ }
+
+ if (!section.hasAttribute('tabindex')) {
+ section.setAttribute('tabindex', '-1')
}
+
+ section.focus({ preventScroll: true })
}
+ // --- Targets / observables ----------------------------------------------
+
_initializeTargetsAndObservables() {
- this._targetLinks = new Map()
- this._observableSections = new Map()
+ this._sections = []
+ this._linkBySection = new Map()
+ this._sectionByLink = new Map()
const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)
+ const seen = new Set()
for (const anchor of targetLinks) {
- // ensure that the anchor has an id and is not disabled
if (!anchor.hash || isDisabled(anchor)) {
continue
}
- const observableSection = SelectorEngine.findOne(decodeURI(anchor.hash), this._element)
+ // Resolve by id (decoded) rather than building a CSS selector, so any
+ // literal id works — dots, slashes, colons, and percent-encoded chars —
+ // without escaping.
+ const id = decodeFragment(anchor.hash.slice(1))
+ if (!id) {
+ continue
+ }
+
+ const section = document.getElementById(id)
+ // ensure the section exists, is scoped to this element, and is visible
+ if (!section || !this._element.contains(section) || !isVisible(section)) {
+ continue
+ }
+
+ this._sectionByLink.set(anchor, section)
+ this._linkBySection.set(section, anchor) // last link wins for a section
- // ensure that the observableSection exists & is visible
- if (isVisible(observableSection)) {
- this._targetLinks.set(decodeURI(anchor.hash), anchor)
- this._observableSections.set(anchor.hash, observableSection)
+ if (!seen.has(section)) {
+ seen.add(section)
+ this._sections.push(section)
}
}
+
+ // Keep sections in top-to-bottom order so "deepest" selection is
+ // well-defined. Read once here (refresh/resize), never on the hot path.
+ this._sections.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)
}
_process(target) {
}
}
+// Decode a URL fragment id, tolerating malformed escapes (returns it as-is).
+function decodeFragment(hash) {
+ try {
+ return decodeURIComponent(hash)
+ } catch {
+ return hash
+ }
+}
+
/**
* Data API implementation
*/
target: '#navigation'
})
- expect(scrollSpy._observableSections.size).toBe(1)
- expect(scrollSpy._targetLinks.size).toBe(1)
+ expect(scrollSpy._sections).toHaveSize(1)
+ expect(scrollSpy._sectionByLink.size).toBe(1)
})
it('should not process element without target', () => {
target: '#navigation'
})
- expect(scrollSpy._targetLinks).toHaveSize(2)
+ expect(scrollSpy._sections).toHaveSize(2)
})
it('should only switch "active" class on current target', () => {
})
})
- it('should clear selection if above the first section', () => {
+ it('should keep the first section active when scrolled above the first section', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'<div id="header" style="height: 500px;"></div>',
expect(spy).toHaveBeenCalled()
expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
- expect(active().getAttribute('id')).toEqual('two-link')
+ // With the top activation line, scrolling section one's start to the
+ // top of the viewport activates one-link (the section you're now in).
+ expect(active().getAttribute('id')).toEqual('one-link')
onScrollStop(() => {
- expect(active()).toBeNull()
+ // Scrolled back above the first section: the first link stays active
+ // rather than clearing.
+ expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
+ expect(active().getAttribute('id')).toEqual('one-link')
resolve()
}, contentEl)
scrollTo(contentEl, 0)
})
})
+ describe('active section detection', () => {
+ it('activates the deepest section whose top has crossed the line when several are visible', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar"><ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">2</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-3" href="#div-3">3</a></li>',
+ '</ul></nav>',
+ '<div class="content" style="overflow: auto; height: 100px">',
+ ' <div id="div-1" style="height: 80px">1</div>',
+ ' <div id="div-2" style="height: 80px">2</div>',
+ ' <div id="div-3" style="height: 200px">3</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ // eslint-disable-next-line no-new
+ new ScrollSpy(contentEl, { target: '.navbar' })
+
+ // At scrollTop 130, both div-2 and div-3 are visible, but only div-2's
+ // top has crossed the activation line — so div-2 (deepest crossed) wins.
+ onScrollStop(() => {
+ expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
+ expect(fixtureEl.querySelector('.active').id).toEqual('a-2')
+ resolve()
+ }, contentEl)
+
+ scrollTo(contentEl, 130)
+ })
+ })
+
+ it('activates the last section at the bottom even if its top never reaches the line', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar"><ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">2</a></li>',
+ '</ul></nav>',
+ '<div class="content" style="overflow: auto; height: 200px">',
+ ' <div id="div-1" style="height: 400px">1</div>',
+ ' <div id="div-2" style="height: 30px">2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ // eslint-disable-next-line no-new
+ new ScrollSpy(contentEl, { target: '.navbar' })
+
+ // Max scroll (230) puts div-2's top ~170px down — below the line — yet at
+ // the bottom it must still be the active section.
+ onScrollStop(() => {
+ expect(fixtureEl.querySelector('.active')?.id).toEqual('a-2')
+ resolve()
+ }, contentEl)
+
+ scrollTo(contentEl, 230)
+ })
+ })
+
+ it('does not throw and activates sections whose id contains special characters', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar"><ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#sec.one:1">1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#sec.two:2">2</a></li>',
+ '</ul></nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="sec.one:1" style="height: 100px">1</div>',
+ ' <div id="sec.two:2" style="height: 200px">2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ expect(() => new ScrollSpy(contentEl, { target: '.navbar' })).not.toThrow()
+
+ onScrollStop(() => {
+ expect(fixtureEl.querySelector('.active')?.id).toEqual('a-2')
+ resolve()
+ }, contentEl)
+
+ scrollTo(contentEl, 100)
+ })
+ })
+
+ it('updates the URL hash and moves focus to the section when a smooth-scroll settles', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar"><ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">2</a></li>',
+ '</ul></nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="div-1" style="height: 100px">1</div>',
+ ' <div id="div-2" style="height: 200px">2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const section2 = fixtureEl.querySelector('#div-2')
+ const scrollSpy = new ScrollSpy(contentEl, { target: '.navbar', smoothScroll: true })
+
+ const replaceSpy = spyOn(window.history, 'replaceState')
+ const focusSpy = spyOn(section2, 'focus').and.callThrough()
+
+ // A smooth-scroll click records the pending navigation; the settle
+ // (scrollend) then restores the hash and moves focus.
+ scrollSpy._pendingNavigation = { hash: '#div-2', section: section2 }
+ scrollSpy._onSettle()
+
+ expect(replaceSpy).toHaveBeenCalledWith(null, '', '#div-2')
+ expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true })
+ expect(section2.getAttribute('tabindex')).toEqual('-1')
+ expect(scrollSpy._pendingNavigation).toBeNull()
+ resolve()
+ })
+ })
+
+ it('keeps the first section active when nothing crosses the activation line', () => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar"><ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">2</a></li>',
+ '</ul></nav>',
+ '<div class="content" style="overflow: auto; height: 100px">',
+ ' <div id="spacer" style="height: 40px"></div>',
+ ' <div id="div-1" style="height: 100px">1</div>',
+ ' <div id="div-2" style="height: 200px">2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, { target: '.navbar' })
+
+ // No section is crossing the activation line (empty intersecting set) and
+ // we're not at the bottom — the first link stays active rather than clearing.
+ scrollSpy._intersecting.clear()
+ scrollSpy._atBottom = false
+ scrollSpy._lastActive = null
+ scrollSpy._computeActive()
+
+ expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
+ expect(fixtureEl.querySelector('.active').id).toEqual('a-1')
+ })
+
+ it('keeps the last active section while scrolling through a content gap', () => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar"><ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">2</a></li>',
+ '</ul></nav>',
+ '<div class="content" style="overflow: auto; height: 100px">',
+ ' <div id="div-1" style="height: 100px">1</div>',
+ ' <div id="div-2" style="height: 200px">2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const section2 = fixtureEl.querySelector('#div-2')
+ const scrollSpy = new ScrollSpy(contentEl, { target: '.navbar' })
+
+ // Section two crosses the line, then nothing does (a gap): two stays active.
+ scrollSpy._intersecting = new Set([section2])
+ scrollSpy._computeActive()
+ expect(fixtureEl.querySelector('.active').id).toEqual('a-2')
+
+ scrollSpy._intersecting.clear()
+ scrollSpy._computeActive()
+ expect(fixtureEl.querySelector('.active').id).toEqual('a-2')
+ })
+
+ it('parses a percentage topMargin into a collapsed rootMargin strip', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, { target: '#navBar', topMargin: '10%' })
+
+ expect(scrollSpy._parseTopMargin()).toEqual({ value: 10, unit: '%' })
+ expect(scrollSpy._getDerivedRootMargin()).toBe('0px 0px -90% 0px')
+ })
+
+ it('parses a pixel topMargin and flags it for resize rebuilds', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, { target: '#navBar', topMargin: '96px' })
+
+ expect(scrollSpy._parseTopMargin()).toEqual({ value: 96, unit: 'px' })
+ expect(scrollSpy._usesPixelMargin()).toBeTrue()
+ })
+
+ it('passes a custom rootMargin straight to the observer (taking precedence over topMargin)', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ target: '#navBar',
+ topMargin: '10%',
+ rootMargin: '0px 0px -40%'
+ })
+
+ expect(scrollSpy._observer.rootMargin).toBe('0px 0px -40% 0px')
+ expect(scrollSpy._usesPixelMargin()).toBeFalse()
+ })
+
+ it('resolves section ids via getElementById, tolerating encoded and malformed fragments', () => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar"><ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#%2Ffoo">1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#%">malformed</a></li>',
+ '</ul></nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="/foo" style="height: 100px">1</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ let scrollSpy
+ expect(() => {
+ scrollSpy = new ScrollSpy(contentEl, { target: '.navbar' })
+ }).not.toThrow()
+
+ // The encoded id (`%2Ffoo` -> `/foo`) resolves; the malformed `%` is skipped.
+ const section = fixtureEl.querySelector('[id="/foo"]')
+ expect(scrollSpy._sections).toHaveSize(1)
+ expect(scrollSpy._linkBySection.get(section).id).toEqual('a-1')
+ })
+
+ it('only adds a resize listener for a pixel activation line, and rebuilds the observer', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+ const contentEl = fixtureEl.querySelector('.content')
+
+ const percentSpy = new ScrollSpy(contentEl, { target: '#navBar', topMargin: '10%' })
+ expect(percentSpy._resizeHandler).toBeNull()
+ percentSpy.dispose()
+
+ const pixelSpy = new ScrollSpy(contentEl, { target: '#navBar', topMargin: '96px' })
+ expect(pixelSpy._resizeHandler).toEqual(jasmine.any(Function))
+
+ const firstObserver = pixelSpy._observer
+ pixelSpy._rebuildObserver()
+ expect(pixelSpy._observer).not.toBe(firstObserver)
+ })
+ })
+
describe('refresh', () => {
it('should disconnect existing observer', () => {
fixtureEl.innerHTML = getDummyFixture()
})
})
+ describe('activation line options', () => {
+ const getContainerFixture = (style = '') => {
+ fixtureEl.innerHTML = [
+ '<nav id="navBar" class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" href="#div-1">div 1</a></li>',
+ ' </ul>',
+ '</nav>',
+ `<div class="content" data-bs-target="#navBar" style="${style}">`,
+ ' <div id="div-1">div 1</div>',
+ '</div>'
+ ].join('')
+ return fixtureEl.querySelector('.content')
+ }
+
+ it('should parse a percentage topMargin', () => {
+ const scrollSpy = new ScrollSpy(getContainerFixture(), { topMargin: '10%' })
+
+ expect(scrollSpy._parseTopMargin()).toEqual({ value: 10, unit: '%' })
+ expect(scrollSpy._usesPixelMargin()).toBeFalse()
+ })
+
+ it('should parse a pixel topMargin and derive a rootMargin from an explicit scroll root', () => {
+ const scrollSpy = new ScrollSpy(getContainerFixture('overflow-y: auto; height: 200px'), { topMargin: '50px' })
+
+ expect(scrollSpy._rootElement).not.toBeNull()
+ expect(scrollSpy._parseTopMargin()).toEqual({ value: 50, unit: 'px' })
+ expect(scrollSpy._usesPixelMargin()).toBeTrue()
+ expect(scrollSpy._getDerivedRootMargin()).toMatch(/^0px 0px -\d/)
+ })
+
+ it('should derive a rootMargin from the viewport when there is no scroll root', () => {
+ // overflow:visible (default) means _rootElement is null, so viewport height is used
+ const scrollSpy = new ScrollSpy(getContainerFixture(), { topMargin: '50px' })
+
+ expect(scrollSpy._rootElement).toBeNull()
+ expect(scrollSpy._getDerivedRootMargin()).toMatch(/^0px 0px -\d/)
+ })
+
+ it('should treat a non-numeric topMargin as 0', () => {
+ const scrollSpy = new ScrollSpy(getContainerFixture(), { topMargin: 'auto' })
+
+ expect(scrollSpy._parseTopMargin().value).toEqual(0)
+ })
+
+ it('should not derive a pixel margin when an explicit rootMargin is set', () => {
+ const scrollSpy = new ScrollSpy(getContainerFixture(), { rootMargin: '0px 0px -50% 0px', topMargin: '50px' })
+
+ expect(scrollSpy._usesPixelMargin()).toBeFalse()
+ })
+
+ it('should report a boolean overflow state from the scroll root', () => {
+ const scrollSpy = new ScrollSpy(getContainerFixture('overflow-y: auto; height: 50px'))
+
+ expect(typeof scrollSpy._isOverflowing()).toEqual('boolean')
+ })
+
+ it('should no-op rebuilding the observer when there is none', () => {
+ const scrollSpy = new ScrollSpy(getContainerFixture())
+
+ scrollSpy._observer.disconnect()
+ scrollSpy._observer = null
+
+ expect(() => scrollSpy._rebuildObserver()).not.toThrow()
+ })
+
+ it('should add a resize listener for pixel margins and remove it on dispose', () => {
+ const spy = spyOn(EventHandler, 'off').and.callThrough()
+ const scrollSpy = new ScrollSpy(getContainerFixture(), { topMargin: '50px' })
+
+ expect(scrollSpy._resizeHandler).not.toBeNull()
+
+ scrollSpy.dispose()
+
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
describe('getInstance', () => {
it('should return scrollspy instance', () => {
fixtureEl.innerHTML = getDummyFixture()
})
it('should smoothScroll to the proper observable element on anchor click', done => {
- fixtureEl.innerHTML = getDummyFixture()
+ fixtureEl.innerHTML = [
+ '<nav id="navBar" class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#div-jsm-1">div 1</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" data-bs-target="#navBar" style="overflow-y: auto; height: 100px">',
+ ' <div style="height: 300px">spacer</div>',
+ ' <div id="div-jsm-1">div 1</div>',
+ '</div>'
+ ].join('')
const div = fixtureEl.querySelector('.content')
const link = fixtureEl.querySelector('[href="#div-jsm-1"]')
' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#présentation">div 1</a></li>',
' </ul>',
'</nav>',
- '<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
+ '<div class="content" data-bs-target="#navBar" style="overflow-y: auto; height: 100px">',
+ ' <div style="height: 300px">spacer</div>',
' <div id="présentation">div 1</div>',
'</div>'
].join('')
}, 100)
link.click()
})
+
+ it('should settle immediately (no smooth scroll) when already at the destination', done => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const link = fixtureEl.querySelector('[href="#div-jsm-1"]')
+ const section = fixtureEl.querySelector('#div-jsm-1')
+ const clickSpy = getElementScrollSpy(div)
+ const scrollSpy = new ScrollSpy(div, {
+ offset: 1,
+ smoothScroll: true
+ })
+
+ spyOn(window.history, 'replaceState')
+ const settleSpy = spyOn(scrollSpy, '_settleNavigation').and.callThrough()
+
+ setTimeout(() => {
+ // Clicking a link whose target is already at the top needs no scroll, so
+ // we jump with `behavior: 'auto'` and settle right away — no pending nav.
+ if (div.scrollTo) {
+ expect(clickSpy).toHaveBeenCalledWith({ top: section.offsetTop - div.offsetTop, behavior: 'auto' })
+ }
+
+ expect(settleSpy).toHaveBeenCalled()
+ expect(scrollSpy._pendingNavigation).toBeNull()
+ done()
+ }, 100)
+ link.click()
+ })
})
})
--- /dev/null
+---
+// Debug overlay for THIS page's ScrollSpy (the right-rail TOC, driven by the
+// `<body data-bs-spy="scroll">`). Toggling the switch draws fixed red guides
+// over the viewport: the activation line (top) and the bottom zone that decide
+// the active section. Positions are derived from the same `topMargin` /
+// `rootMargin` math the plugin uses.
+
+// The native `switch` boolean attribute isn't in Astro's input typings; pass it
+// through a spread so the markup still matches the Switch component docs.
+const switchAttr: Record<string, boolean> = { switch: true }
+---
+
+<div class="form-field bd-scrollspy-debug bg-subtle-danger bg-50 p-4 rounded-7">
+ <div class="switch theme-danger">
+ <input type="checkbox" id="scrollspy-debug-toggle" role="switch" {...switchAttr} data-scrollspy-debug-toggle />
+ </div>
+ <label for="scrollspy-debug-toggle">Debug overlay</label>
+</div>
+
+<script>
+ const RED = 'rgb(220, 53, 69)'
+ const ZONE_HEIGHT = 24
+ const Z_INDEX = '1090'
+
+ const toggle = document.querySelector<HTMLInputElement>('[data-scrollspy-debug-toggle]')
+
+ // The page's ScrollSpy element (the `<body>` on docs pages with a TOC).
+ const spy = document.querySelector<HTMLElement>('[data-bs-spy="scroll"]')
+
+ if (toggle && spy) {
+ let enabled = false
+
+ const labelStyle = 'position:absolute;inset-inline-end:.5rem;white-space:nowrap;'
+
+ const line = document.createElement('div')
+ line.style.cssText = `position:fixed;z-index:${Z_INDEX};pointer-events:none;border-top:1px dashed ${RED};`
+ const lineLabel = document.createElement('span')
+ lineLabel.className = 'badge theme-danger'
+ lineLabel.style.cssText = `${labelStyle}inset-block-start:.25rem;`
+ lineLabel.textContent = 'Activation line'
+ line.append(lineLabel)
+
+ const zone = document.createElement('div')
+ zone.style.cssText = `position:fixed;z-index:${Z_INDEX};pointer-events:none;border-top:1px solid ${RED};`
+ const zoneLabel = document.createElement('span')
+ zoneLabel.className = 'badge theme-danger'
+ zoneLabel.style.cssText = `${labelStyle}inset-block-end:100%;margin-block-end:.25rem;`
+ zone.append(zoneLabel)
+
+ // The scroll root: the element itself, unless its overflow is visible (then
+ // the viewport scrolls the page, e.g. `<body>`).
+ const usesViewport = () => getComputedStyle(spy).overflowY === 'visible'
+
+ const rootRect = () => {
+ if (usesViewport()) {
+ const doc = document.documentElement
+ return { top: 0, left: 0, width: doc.clientWidth, height: doc.clientHeight }
+ }
+
+ const r = spy.getBoundingClientRect()
+ return { top: r.top, left: r.left, width: spy.clientWidth, height: spy.clientHeight }
+ }
+
+ // Activation-line offset (px from the top of the scroll root), matching the
+ // plugin precedence: an explicit `rootMargin` (its bottom inset) wins,
+ // otherwise `topMargin`; both accept `%` and `px`.
+ const activationOffset = (rootHeight: number) => {
+ const rootMargin = spy.getAttribute('data-bs-root-margin')
+
+ if (rootMargin) {
+ const bottom = rootMargin.trim().split(/\s+/)[2] ?? '0'
+ const inset = bottom.endsWith('%')
+ ? (rootHeight * Math.abs(Number.parseFloat(bottom))) / 100
+ : Math.abs(Number.parseFloat(bottom))
+ return rootHeight - inset
+ }
+
+ const topMargin = spy.getAttribute('data-bs-top-margin') ?? '12%'
+ return topMargin.endsWith('%') ? (rootHeight * Number.parseFloat(topMargin)) / 100 : Number.parseFloat(topMargin)
+ }
+
+ const isAtBottom = () => {
+ const scroller = usesViewport() ? document.scrollingElement || document.documentElement : spy
+ if (scroller.scrollHeight <= scroller.clientHeight) {
+ return false
+ }
+
+ return scroller.scrollHeight - (scroller.scrollTop + scroller.clientHeight) <= 1
+ }
+
+ const reposition = () => {
+ if (!enabled) {
+ return
+ }
+
+ const rect = rootRect()
+ const offset = activationOffset(rect.height)
+
+ line.style.top = `${rect.top + offset}px`
+ line.style.left = `${rect.left}px`
+ line.style.width = `${rect.width}px`
+
+ zone.style.top = `${rect.top + rect.height - ZONE_HEIGHT}px`
+ zone.style.left = `${rect.left}px`
+ zone.style.width = `${rect.width}px`
+ zone.style.height = `${ZONE_HEIGHT}px`
+ }
+
+ const updateZone = () => {
+ const atBottom = isAtBottom()
+
+ zone.style.background = atBottom ? 'rgba(220, 53, 69, 0.35)' : 'rgba(220, 53, 69, 0.12)'
+ zoneLabel.textContent = atBottom ? 'At bottom — last section active' : 'Bottom zone'
+ }
+
+ const onScroll = () => {
+ reposition()
+ updateZone()
+ }
+
+ toggle.addEventListener('change', () => {
+ enabled = toggle.checked
+
+ if (enabled) {
+ document.body.append(line, zone)
+ window.addEventListener('scroll', onScroll, { passive: true })
+ window.addEventListener('resize', onScroll)
+ } else {
+ line.remove()
+ zone.remove()
+ window.removeEventListener('scroll', onScroll)
+ window.removeEventListener('resize', onScroll)
+ }
+
+ reposition()
+ updateZone()
+ })
+
+ spy.addEventListener('activate.bs.scrollspy', updateZone)
+ }
+</script>
export { default as NavbarPlacementPlayground } from './NavbarPlacementPlayground.astro'
export { default as Placeholder } from './Placeholder.astro'
export { default as ResizableExample } from './ResizableExample.astro'
+export { default as ScrollspyDebug } from './ScrollspyDebug.astro'
export { default as ScssDocs } from './ScssDocs.astro'
export { default as SpacingNotation } from './SpacingNotation.astro'
export { default as SpacingResponsive } from './SpacingResponsive.astro'
- Target elements that are not visible will be ignored. See the [Non-visible elements](#non-visible-elements) section below.
+- Target `id`s are matched with `getElementById`, so ids containing special or percent-encoded characters (for example `#item.1`, `#section:overview`, or `#%2Fpath`) resolve correctly.
+
+### Detection
+
+Scrollspy uses an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) to track sections without `scroll-position` polling. Detection is deterministic and driven entirely by an activation line near the top of the scroll container.
+
+- The active section is the deepest one whose top has scrolled past the activation line, like the section you’re currently reading.
+- While scrolling through the gap between two headings, the last activated section stays active until the next one crosses the line.
+- At the very top of the container, before any section crosses the line, the first section stays active.
+- At the very bottom of the container, the last section is activated even if its top never reaches the line (handy for short final sections).
+
+Set where the activation line sits with `topMargin` (a more user-friendly `%` or `px` value from the top of the scroll root, such as `96px` to sit below a stickied navbar), or take full control with the raw IntersectionObserver `rootMargin` string. See [Options](#options).
+
+Jump to the [debug overlay](#debug-overlay) section to visualize the activation line in action.
+
## Examples
### Navbar
</div>
</div>
<div class="col-8">
- <div data-bs-spy="scroll" data-bs-target="#simple-list-example" data-bs-offset="0" data-bs-smooth-scroll="true" class="scrollspy-example" tabindex="0">
+ <div data-bs-spy="scroll" data-bs-target="#simple-list-example" data-bs-smooth-scroll="true" class="scrollspy-example" tabindex="0">
<h4 id="simple-list-item-1">Item 1</h4>
<p>This is some placeholder content for the scrollspy page. Note that as you scroll down the page, the appropriate navigation link is highlighted. It’s repeated throughout the component example. We keep adding some more example copy here to emphasize the scrolling and highlighting.</p>
<h4 id="simple-list-item-2">Item 2</h4>
</div>
</div>
<div class="col-8">
- <div data-bs-spy="scroll" data-bs-target="#simple-list-example" data-bs-offset="0" data-bs-smooth-scroll="true" class="scrollspy-example" tabindex="0">
+ <div data-bs-spy="scroll" data-bs-target="#simple-list-example" data-bs-smooth-scroll="true" class="scrollspy-example" tabindex="0">
<h4 id="simple-list-item-1">Item 1</h4>
<p>...</p>
<h4 id="simple-list-item-2">Item 2</h4>
</div>
```
+## Debug overlay
+
+It can be hard to picture exactly where Scrollspy decides a section is active. Since our documentation pages use Scrollspy on our table of contents (the navigation on the right), you can enable a debug overlay to draw red guides over the viewport to illustrate the behavior.
+
+- **The red dashed line near the top is the activation line.** A section becomes active as its heading scrolls above this line. On these docs it sits at `96px`, just below the navbar with a little room to breathe.
+- **The red bottom zone lights up when you reach the bottom of the page.** This is where the last section stays active even if its heading never reaches the activation line.
+
+Scroll the page and watch the highlighted item in table of contents change as headings cross the line. Guide positions are computed from the same `topMargin`/`rootMargin` [detection](#detection) math the plugin uses.
+
+<ScrollspyDebug />
+
## Non-visible elements
Target elements that aren’t visible will be ignored and their corresponding nav items won’t receive an `.active` class. Scrollspy instances initialized in a non-visible wrapper will ignore all target elements. Use the `refresh` method to check for observable elements once the wrapper becomes visible.
<BsTable>
| Name | Type | Default | Description |
| --- | --- | --- | --- |
-| `rootMargin` | string | `0px 0px -25%` | Intersection Observer [rootMargin](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin) valid units, when calculating scroll position. |
-| `smoothScroll` | boolean | `false` | Enables smooth scrolling when a user clicks on a link that refers to ScrollSpy observables. |
+| `rootMargin` | string, null | `null` | Advanced override for the IntersectionObserver [rootMargin](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin). When set it **takes precedence over `topMargin`** and is passed straight to the observer (for example `0px 0px -40%`). Leave `null` to derive the activation line from `topMargin`. |
+| `smoothScroll` | boolean | `false` | Enables smooth scrolling when a user clicks on a link that refers to ScrollSpy observables. Once the scroll settles, the URL hash and focus are moved to the target section. |
| `target` | string, DOM element | `null` | Specifies element to apply Scrollspy plugin. |
-| `threshold` | array | `[0.1, 0.5, 1]` | `IntersectionObserver` [threshold](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver#threshold) valid input, when calculating scroll position. |
+| `threshold` | array | `[0]` | `IntersectionObserver` [threshold](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver#threshold) valid input, when calculating scroll position. |
+| `topMargin` | string | `12%` | Position of the [activation line](#detection), measured from the top of the scroll root. Accepts a percentage (`12%`) or pixels (`96px`, e.g. to sit below a sticky navbar). Ignored when `rootMargin` is set. |
</BsTable>
-<Callout type="warning">
-**Deprecated Options**
-
-Up until v5.1.3 we were using `offset` & `method` options, which are now deprecated and replaced by `rootMargin`.
-To keep backwards compatibility, we will continue to parse a given `offset` to `rootMargin`, but this feature will be removed in **v6**.
-</Callout>
-
### Methods
<BsTable>
- Removed the `jspm` configuration from `package.json`.
- Added `"sideEffects"` metadata to `package.json` to enable tree shaking in bundlers while preserving the Data API event listeners that Bootstrap’s plugins register at the top level.
- Added `"exports"` map to `package.json` for explicit subpath access to source, dist, and Sass files.
+- **Rewrote ScrollSpy to be deterministic and `IntersectionObserver`-native.** Detection is now driven entirely by an activation line (no scroll-position polling), so the active section is always the one you’re reading, with stable behavior at the top and bottom of the container.
+ - Removed the long-deprecated `offset` and `method` options (deprecated since v5.1.3). They are no longer parsed.
+ - Added the `topMargin` option (default `12%`) to position the activation line as a friendly `%` or `px` value from the top of the scroll root (for example `96px` to sit below a sticky navbar).
+ - `rootMargin` is now an advanced override that takes precedence over `topMargin` and is passed straight to the observer; its default is `null` (the activation line is derived from `topMargin`).
+ - Changed the default `threshold` from `[0.1, 0.5, 1]` to `[0]`.
+ - With `smoothScroll` enabled, clicking a link now restores the URL hash (via `history.replaceState`) and moves focus to the target section once the scroll settles, improving keyboard and assistive-technology navigation.
+ - Target `id`s are resolved with `getElementById`, so ids containing dots, colons, slashes, or percent-encoded characters now work without manual escaping.
### Components
if (frontmatter.toc) {
bodyProps['data-bs-spy'] = 'scroll'
bodyProps['data-bs-target'] = '#TableOfContents'
+ // Align the activation line with the sticky navbar offset (--bd-navbar-offset
+ // is 5.75rem ~= 92px) so a heading highlights right as it settles below the
+ // navbar, plus a small epsilon. A fixed px value avoids the viewport-height
+ // drift of a percentage-based top margin.
+ bodyProps['data-bs-top-margin'] = '96px'
}
// Find previous and next pages for navigation
- **`wrap` → `ends`.** `wrap: true` → `ends: "wrap"` (or the new default `"loop"`); `wrap: false` → `ends: "stop"`. Set via `data-bs-ends`. The `touch` option is removed (native scroll handles it).
- **Removed classes:** `.carousel-control-prev/next` (compose a `.btn-icon` + `data-bs-slide` + `.carousel-icon-prev/next`), `.carousel-caption` (use your own markup), `.carousel-dark` (use `data-bs-theme="dark"`), `.carousel-stacked` (now the default), and the transitional `.carousel-item-start/end/next/prev`. Overlaid controls now require `.carousel-overlay`. Control-icon classes renamed `.carousel-control-prev-icon` → `.carousel-icon-prev` (and `-next`).
+### ScrollSpy — rebuilt on IntersectionObserver
+
+The `data-bs-spy="scroll"` markup and the `activate.bs.scrollspy` event are unchanged, but detection is now driven by an **activation line** (no scroll-position polling), and the options changed:
+
+- **Removed the deprecated `offset` and `method` options** (deprecated since v5.1.3) — no longer parsed.
+- **Added `topMargin`** (default `12%`) — positions the activation line as a `%` or `px` value from the top of the scroll root (e.g. `96px` to sit below a sticky navbar). This is the everyday knob; use it instead of `rootMargin`.
+- **`rootMargin`** is now an advanced override (default `null`, was `'0px 0px -25%'`); when set it takes precedence over `topMargin` and is passed straight to the observer.
+- **`threshold`** default changed from `[0.1, 0.5, 1]` to `[0]`.
+- With **`smoothScroll`**, clicking a link now restores the URL hash (`history.replaceState`) and moves focus to the target once the scroll settles (better keyboard/AT nav).
+- Target `id`s are resolved with `getElementById`, so ids with dots, colons, slashes, or percent-encoding work without manual escaping.
+
### New components (didn't exist in v5)
| Component | Trigger / hook | Purpose |
- `.carousel-control-prev/next`, `.carousel-caption`, `.carousel-dark`, `.carousel-stacked` (all removed)
- `.fs-1`–`.fs-6` (should be `.fs-4xl` … `.fs-md`)
- `.link-offset-*` / `.link-underline-*` (now `.underline-offset-*` / `.underline-*`)
+ - ScrollSpy `offset` / `method` options (removed — use `topMargin`)
3. Test in browser — v6 requires support for `oklch()` and `color-mix()`.