blob: d2a352d242a0bb7f621923d0bd9396f5fab75420 [file] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap scrollspy.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
import type { ComponentConfig } from './util/config.js'
import {
getElement, isDisabled, isVisible
} from './util/index.js'
/**
* Constants
*/
const NAME = 'scrollspy'
const DATA_KEY = 'bs.scrollspy'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
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 CLASS_NAME_ACTIVE = 'active'
const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]'
const SELECTOR_TARGET_LINKS = '[href]'
const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'
const SELECTOR_NAV_LINKS = '.nav-link'
const SELECTOR_NAV_ITEMS = '.nav-item'
const SELECTOR_LIST_ITEMS = '.list-group-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
type ScrollSpyConfig = {
rootMargin: string | null
smoothScroll: boolean
target: string | Element | null
threshold: number[] | string
topMargin: string
}
const Default: ScrollSpyConfig = {
// `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],
// 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|null)',
smoothScroll: 'boolean',
target: 'element',
threshold: 'array',
topMargin: 'string'
}
/**
* Class definition
*/
class ScrollSpy extends BaseComponent {
protected declare _config: ScrollSpyConfig
protected declare _sections: HTMLElement[]
protected declare _linkBySection: Map<HTMLElement, HTMLElement>
protected declare _sectionByLink: Map<HTMLElement, HTMLElement>
protected declare _intersecting: Set<Element>
protected declare _activeTarget: HTMLElement | null
protected declare _lastActive: HTMLElement | null
protected declare _atBottom: boolean
protected declare _rootElement: HTMLElement | null
protected declare _observer: IntersectionObserver | null
protected declare _sentinel: HTMLElement | null
protected declare _sentinelObserver: IntersectionObserver | null
protected declare _pendingNavigation: { hash: string, section: HTMLElement } | null
protected declare _settleTimeout: number | null
protected declare _settleHandler: (() => void) | null
protected declare _scrollIdleHandler: (() => void) | null
protected declare _resizeHandler: (() => void) | null
protected declare _resizeTimeout: number | null
constructor(element?: string | Element | null, config?: Partial<ScrollSpyConfig> | null) {
super(element, config)
// this._element is the observablesContainer and config.target the menu links wrapper
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._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
}
// Getters
static override get Default(): ScrollSpyConfig {
return Default
}
static override get DefaultType(): Record<string, string> {
return DefaultType
}
static override get NAME(): string {
return NAME
}
// Public
refresh(): void {
this._initializeTargetsAndObservables()
this._maybeEnableSmoothScroll()
// (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()
}
override dispose(): void {
this._observer?.disconnect()
this._teardownSentinel()
this._disarmSettle()
this._removeResizeListener()
EventHandler.off(this._config.target as HTMLElement, EVENT_CLICK)
super.dispose()
}
// Private
protected override _configAfterMerge(config: ComponentConfig): ComponentConfig {
config.target = getElement(config.target) || document.body
if (typeof config.threshold === 'string') {
config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value))
}
return config
}
// --- Detection (IntersectionObserver-driven) -----------------------------
protected _getNewObserver(): IntersectionObserver {
const options = {
root: this._rootElement,
threshold: this._config.threshold as number[],
rootMargin: this._config.rootMargin ?? this._getDerivedRootMargin()
}
return new IntersectionObserver(entries => this._onIntersect(entries), options)
}
protected _onIntersect(entries: IntersectionObserverEntry[]): void {
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.
protected _computeActive(): void {
// 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.
protected _parseTopMargin(): { value: number, unit: string } {
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.
protected _getDerivedRootMargin(): string {
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.
protected _usesPixelMargin(): boolean {
return !this._config.rootMargin && this._parseTopMargin().unit === 'px'
}
// --- Bottom sentinel -----------------------------------------------------
protected _setUpSentinel(): void {
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)
}
protected _onSentinel(entries: IntersectionObserverEntry[]): void {
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()
}
protected _isOverflowing(): boolean {
const scroller = this._rootElement || document.scrollingElement || document.documentElement
return scroller.scrollHeight > scroller.clientHeight
}
protected _teardownSentinel(): void {
this._sentinelObserver?.disconnect()
this._sentinelObserver = null
this._sentinel?.remove()
this._sentinel = null
this._atBottom = false
}
// --- Resize (px activation lines only) -----------------------------------
protected _maybeAddResizeListener(): void {
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)
}
protected _removeResizeListener(): void {
clearTimeout(this._resizeTimeout!)
this._resizeTimeout = null
if (this._resizeHandler) {
EventHandler.off(window, EVENT_RESIZE, this._resizeHandler)
this._resizeHandler = null
}
}
protected _rebuildObserver(): void {
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) ---------------------------------
protected _maybeEnableSmoothScroll(): void {
if (!this._config.smoothScroll) {
return
}
// Unregister any previous listener so refresh() doesn't stack them.
EventHandler.off(this._config.target as HTMLElement, EVENT_CLICK)
EventHandler.on(this._config.target as HTMLElement, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => {
const link = (event.target as HTMLElement).closest<HTMLAnchorElement>(SELECTOR_TARGET_LINKS)
const section = link && this._sectionByLink.get(link)
if (!section || !this._element) {
return
}
event.preventDefault()
const root: any = 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: 'auto' })
} else {
root.scrollTop = height
}
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
}
})
}
// 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.
protected _armSettle(): void {
this._disarmSettle()
const target = this._getSettleTarget()
this._settleHandler = () => this._onSettle()
this._scrollIdleHandler = () => {
clearTimeout(this._settleTimeout!)
this._settleTimeout = setTimeout(() => this._onSettle(), SCROLL_IDLE_TIMEOUT)
}
EventHandler.on(target, EVENT_SCROLLEND, this._settleHandler)
EventHandler.on(target, EVENT_SCROLL, this._scrollIdleHandler)
}
protected _disarmSettle(): void {
clearTimeout(this._settleTimeout!)
this._settleTimeout = null
const target = this._getSettleTarget()
if (this._settleHandler) {
EventHandler.off(target, EVENT_SCROLLEND, this._settleHandler)
this._settleHandler = null
}
if (this._scrollIdleHandler) {
EventHandler.off(target, EVENT_SCROLL, this._scrollIdleHandler)
this._scrollIdleHandler = null
}
}
protected _getSettleTarget(): HTMLElement | Document {
return this._rootElement || document
}
protected _onSettle(): void {
this._disarmSettle()
if (!this._pendingNavigation) {
return
}
const { hash, section } = this._pendingNavigation
this._settleNavigation(hash, section)
}
protected _settleNavigation(hash: string, section: HTMLElement): void {
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 ----------------------------------------------
protected _initializeTargetsAndObservables(): void {
this._sections = []
this._linkBySection = new Map()
this._sectionByLink = new Map()
const targetLinks = SelectorEngine.find<HTMLAnchorElement>(SELECTOR_TARGET_LINKS, this._config.target as HTMLElement)
const seen = new Set()
for (const anchor of targetLinks) {
if (!anchor.hash || isDisabled(anchor)) {
continue
}
// 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
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)
}
protected _process(target: HTMLElement): void {
if (this._activeTarget === target) {
return
}
this._clearActiveClass(this._config.target as HTMLElement)
this._activeTarget = target
target.classList.add(CLASS_NAME_ACTIVE)
this._activateParents(target)
EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
}
protected _activateParents(target: HTMLElement): void {
// Activate menu parents
if (target.classList.contains(CLASS_NAME_MENU_ITEM)) {
const menuToggle = target.closest('.menu')?.previousElementSibling
if (menuToggle?.matches(SELECTOR_MENU_TOGGLE)) {
menuToggle.classList.add(CLASS_NAME_ACTIVE)
}
return
}
for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) {
// Set triggered links parents as active
// With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
for (const item of SelectorEngine.prev(listGroup, SELECTOR_LINK_ITEMS)) {
item.classList.add(CLASS_NAME_ACTIVE)
}
}
}
protected _clearActiveClass(parent: HTMLElement): void {
parent.classList.remove(CLASS_NAME_ACTIVE)
const activeNodes = SelectorEngine.find(`${SELECTOR_TARGET_LINKS}.${CLASS_NAME_ACTIVE}`, parent)
for (const node of activeNodes) {
node.classList.remove(CLASS_NAME_ACTIVE)
}
}
}
// Decode a URL fragment id, tolerating malformed escapes (returns it as-is).
function decodeFragment(hash: string): string {
try {
return decodeURIComponent(hash)
} catch {
return hash
}
}
/**
* Data API implementation
*/
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) {
ScrollSpy.getOrCreateInstance(spy)
}
})
export default ScrollSpy
export type { ScrollSpyConfig }