| /** |
| * -------------------------------------------------------------------------- |
| * 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 } |