diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 0e4240e0d0..46bc72ba96 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -42,27 +42,27 @@ }, { "path": "./dist/js/ouds-web.bundle.js", - "maxSize": "44.25 kB" + "maxSize": "47.5 kB" }, { "path": "./dist/js/ouds-web.bundle.min.js", - "maxSize": "24 kB" + "maxSize": "25 kB" }, { "path": "./dist/js/ouds-web.esm.js", - "maxSize": "29.5 kB" + "maxSize": "32.5 kB" }, { "path": "./dist/js/ouds-web.esm.min.js", - "maxSize": "19 kB" + "maxSize": "20 kB" }, { "path": "./dist/js/ouds-web.js", - "maxSize": "30 kB" + "maxSize": "33.25 kB" }, { "path": "./dist/js/ouds-web.min.js", - "maxSize": "17 kB" + "maxSize": "18 kB" } ], "ci": { @@ -70,4 +70,4 @@ "main" ] } -} \ No newline at end of file +} diff --git a/js/src/dom/event-handler.js b/js/src/dom/event-handler.js index a61ec5db0c..db04b29b8c 100644 --- a/js/src/dom/event-handler.js +++ b/js/src/dom/event-handler.js @@ -65,7 +65,8 @@ const nativeEvents = new Set([ 'readystatechange', 'error', 'abort', - 'scroll' + 'scroll', + 'scrollend' ]) /** diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js index 0378d3e3d0..4d37175a4d 100644 --- a/js/src/scrollspy.js +++ b/js/src/scrollspy.js @@ -23,35 +23,52 @@ 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_DROPDOWN_ITEM = 'dropdown-item' +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' +// OUDS mod @todo https://github.com/Orange-OpenSource/Orange-Boosted-Bootstrap/issues/3711 +// we set these to ul and a to handle our TOC structure but for the final ScrollSpy +// we should determine what is the best way to handle this +const SELECTOR_NAV_LIST_GROUP = 'ul, .nav, .list-group' +const SELECTOR_NAV_LINKS = 'a, .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_DROPDOWN = '.dropdown' -const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle' +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 = { - offset: null, // TODO: Bootstrap v6 @deprecated, keep it for backwards compatibility reasons - 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 = { - offset: '(number|null)', // TODO Bootstrap v6 @deprecated, keep it for backwards compatibility reasons - rootMargin: 'string', + rootMargin: '(string|null)', smoothScroll: 'boolean', target: 'element', - threshold: 'array' + threshold: 'array', + topMargin: 'string' } /** @@ -63,15 +80,27 @@ class ScrollSpy extends BaseComponent { 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 } @@ -93,29 +122,39 @@ class ScrollSpy extends BaseComponent { 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() } // Private _configAfterMerge(config) { - // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case - config.target = getElement(config.target) || document.body + config.target = getElement(config.target) - // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only - config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin + if (!config.target) { + throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "target" is required and must be an existing element.`) + } if (typeof config.threshold === 'string') { config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value)) @@ -124,100 +163,348 @@ class ScrollSpy extends BaseComponent { 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:1px;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 + } - // ensure that the observableSection exists & is visible - if (isVisible(observableSection)) { - this._targetLinks.set(decodeURI(anchor.hash), anchor) - this._observableSections.set(anchor.hash, observableSection) + 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) } _process(target) { @@ -234,10 +521,13 @@ class ScrollSpy extends BaseComponent { } _activateParents(target) { - // Activate dropdown parents - if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { - SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN)) - .classList.add(CLASS_NAME_ACTIVE) + // 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 } @@ -260,6 +550,15 @@ class ScrollSpy extends BaseComponent { } } +// 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 */ diff --git a/js/tests/unit/scrollspy.spec.js b/js/tests/unit/scrollspy.spec.js index 8e54bcfc76..a072f11992 100644 --- a/js/tests/unit/scrollspy.spec.js +++ b/js/tests/unit/scrollspy.spec.js @@ -100,9 +100,9 @@ describe('ScrollSpy', () => { const sSpyEl = fixtureEl.querySelector('.content') const sSpyBySelector = new ScrollSpy('.content') - const sSpyByElement = new ScrollSpy(sSpyEl) - expect(sSpyBySelector._element).toEqual(sSpyEl) + + const sSpyByElement = new ScrollSpy(sSpyEl) expect(sSpyByElement._element).toEqual(sSpyEl) }) @@ -190,8 +190,8 @@ describe('ScrollSpy', () => { 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', () => { @@ -213,7 +213,7 @@ describe('ScrollSpy', () => { target: '#navigation' }) - expect(scrollSpy._targetLinks).toHaveSize(2) + expect(scrollSpy._sections).toHaveSize(2) }) it('should only switch "active" class on current target', () => { @@ -222,7 +222,7 @@ describe('ScrollSpy', () => { '
', '
', '
', - '
', + '
', '