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', () => {
'
',
'
',
'
',
- '
',
+ '
',
'
',
' Overview ',
' Detail ',
@@ -240,7 +240,7 @@ describe('ScrollSpy', () => {
const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example')
const rootEl = fixtureEl.querySelector('#root')
const scrollSpy = new ScrollSpy(scrollSpyEl, {
- target: 'ss-target'
+ target: '#ss-target'
})
const spy = spyOn(scrollSpy, '_process').and.callThrough()
@@ -300,7 +300,7 @@ describe('ScrollSpy', () => {
'',
'
',
'
',
- '
',
+ '
',
'
',
' Overview ',
' Detail ',
@@ -451,7 +451,7 @@ describe('ScrollSpy', () => {
})
})
- 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 = [
'',
@@ -483,9 +483,14 @@ describe('ScrollSpy', () => {
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)
@@ -618,6 +623,251 @@ describe('ScrollSpy', () => {
})
})
+ 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 = [
+ '',
+ ' 1 ',
+ ' 2 ',
+ ' 3 ',
+ ' ',
+ '',
+ '
1
',
+ '
2
',
+ '
3
',
+ '
'
+ ].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 = [
+ ' ',
+ ''
+ ].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 = [
+ ' ',
+ ''
+ ].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 = [
+ ' ',
+ ''
+ ].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 = [
+ ' ',
+ '',
+ '
',
+ '
1
',
+ '
2
',
+ '
'
+ ].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 = [
+ ' ',
+ ''
+ ].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 = [
+ ' ',
+ ''
+ ].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()
@@ -648,6 +898,84 @@ describe('ScrollSpy', () => {
})
})
+ describe('activation line options', () => {
+ const getContainerFixture = (style = '') => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ `'
+ ].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()
@@ -814,7 +1142,17 @@ describe('ScrollSpy', () => {
})
it('should smoothScroll to the proper observable element on anchor click', done => {
- fixtureEl.innerHTML = getDummyFixture()
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '',
+ '
spacer
',
+ '
div 1
',
+ '
'
+ ].join('')
const div = fixtureEl.querySelector('.content')
const link = fixtureEl.querySelector('[href="#div-jsm-1"]')
@@ -845,7 +1183,8 @@ describe('ScrollSpy', () => {
' div 1 ',
' ',
'',
- '
',
+ '
',
+ '
spacer
',
'
div 1
',
'
'
].join('')
@@ -871,5 +1210,34 @@ describe('ScrollSpy', () => {
}, 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()
+ })
})
})
diff --git a/site/src/components/DocsSidebar.astro b/site/src/components/DocsSidebar.astro
index cd3039328a..76dae129b3 100644
--- a/site/src/components/DocsSidebar.astro
+++ b/site/src/components/DocsSidebar.astro
@@ -1,22 +1,23 @@
---
-import { getData } from '@libs/data'
+import { getData, type DataType } from '@libs/data'
import { getConfig } from '@libs/config'
import { docsPages } from '@libs/content'
import { getSlug } from '@libs/utils'
import { getVersionedDocsPath } from '@libs/path'
+import { zSidebar } from '@libs/validation'
+import { type z } from 'zod'
const slug = Astro.url.pathname.split('/')[4]
-const sidebarMap = {
- 'getting-started': 'getting-started',
- foundation: 'foundation',
- components: 'components',
- utilities: 'utilities',
- layout: 'layout',
- about: ''
+const sidebars = ['getting-started', 'foundation', 'components', 'utilities', 'layout'] as const
+type Sidebar = z.infer
+
+let sidebar:Sidebar = []
+
+if(slug && sidebars.includes(slug as typeof sidebars[number])) {
+ sidebar = getData(`sidebar-${slug}` as DataType) as Sidebar
}
-const sidebar = sidebarMap[slug] ? getData(`sidebar-${sidebarMap[slug]}`) : []
---
@@ -65,7 +66,7 @@ const sidebar = sidebarMap[slug] ? getData(`sidebar-${sidebarMap[slug]}`) : []
// This test should not be necessary, see comments for `getSlug()` in `src/libs/utils.ts`.
if (!page.direct_url && !generatedPage) {
throw new Error(
- `The page '${page.title}' referenced in 'site/data/sidebar${sidebarMap[slug]}.yml' does not exist at '${url}'.`
+ `The page '${page.title}' referenced in 'site/data/sidebar-${slug}.yml' does not exist at '${url}'.`
)
}
diff --git a/site/src/components/header/Navigation.astro b/site/src/components/header/Navigation.astro
index 11f28ff747..f8b9900639 100644
--- a/site/src/components/header/Navigation.astro
+++ b/site/src/components/header/Navigation.astro
@@ -93,7 +93,7 @@ const { addedIn, layout, title } = Astro.props
Components
diff --git a/site/src/content/docs/components/accordion.mdx b/site/src/content/docs/components/accordion.mdx
index 2af17cf46a..3ecaee7ea9 100644
--- a/site/src/content/docs/components/accordion.mdx
+++ b/site/src/content/docs/components/accordion.mdx
@@ -2,7 +2,7 @@
title: Accordion
description: Build vertically collapsing accordions in combination with our Collapse JavaScript plugin.
aliases: "/docs/components/accordion/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/back-to-top.mdx b/site/src/content/docs/components/back-to-top.mdx
index d2233f8bd9..739b29e272 100644
--- a/site/src/content/docs/components/back-to-top.mdx
+++ b/site/src/content/docs/components/back-to-top.mdx
@@ -3,7 +3,7 @@ title: Back to top
description: Sticky back-to-top link appearing after scrolling down one viewport height.
aliases:
- "/docs/components/back-to-top/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/card.mdx b/site/src/content/docs/components/card.mdx
index c095b22e26..362c5708e9 100644
--- a/site/src/content/docs/components/card.mdx
+++ b/site/src/content/docs/components/card.mdx
@@ -3,7 +3,7 @@ title: Cards
description: OUDS Web’s cards provide a flexible and extensible content container with multiple variants and options.
aliases:
- "/docs/components/card/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/carousel.mdx b/site/src/content/docs/components/carousel.mdx
index 4256fce948..c3b717f352 100644
--- a/site/src/content/docs/components/carousel.mdx
+++ b/site/src/content/docs/components/carousel.mdx
@@ -3,7 +3,7 @@ title: Carousel
description: A slideshow component for cycling through elements—images or slides of text—like a carousel.
aliases:
- "/docs/components/carousel/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/close-button.mdx b/site/src/content/docs/components/close-button.mdx
index 297b12e206..d12f0d6776 100644
--- a/site/src/content/docs/components/close-button.mdx
+++ b/site/src/content/docs/components/close-button.mdx
@@ -3,7 +3,7 @@ title: Close button
description: A generic close button for dismissing content like modals and alerts.
aliases:
- "/docs/components/close-button/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/collapse.mdx b/site/src/content/docs/components/collapse.mdx
index 8d6cfc7237..eca8e23b83 100644
--- a/site/src/content/docs/components/collapse.mdx
+++ b/site/src/content/docs/components/collapse.mdx
@@ -3,7 +3,7 @@ title: Collapse
description: Toggle the visibility of content across your project with a few classes and our JavaScript plugins.
aliases:
- "/docs/components/collapse/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/dropdown.mdx b/site/src/content/docs/components/dropdown.mdx
index ba100f7d85..e925e985a1 100644
--- a/site/src/content/docs/components/dropdown.mdx
+++ b/site/src/content/docs/components/dropdown.mdx
@@ -4,7 +4,7 @@ description: Toggle contextual overlays for displaying lists of links and more w
aliases:
- "/docs/components/dropdowns/"
- "/docs/components/dropdown/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/list-group.mdx b/site/src/content/docs/components/list-group.mdx
index dcb017eaf1..1458133ec8 100644
--- a/site/src/content/docs/components/list-group.mdx
+++ b/site/src/content/docs/components/list-group.mdx
@@ -3,7 +3,7 @@ title: List group
description: List groups are a flexible and powerful component for displaying a series of content. Modify and extend them to support just about any content within.
aliases:
- "/docs/components/list-group/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/local-navigation.mdx b/site/src/content/docs/components/local-navigation.mdx
index c06cf39772..b3132b4f62 100644
--- a/site/src/content/docs/components/local-navigation.mdx
+++ b/site/src/content/docs/components/local-navigation.mdx
@@ -3,7 +3,7 @@ title: Local navigation
description: Use local navigation to add a navigation that will nicely wrap on small viewports.
aliases:
- "/docs/components/local-navigation/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/modal.mdx b/site/src/content/docs/components/modal.mdx
index 7dd0c42e17..d1c4369ca9 100644
--- a/site/src/content/docs/components/modal.mdx
+++ b/site/src/content/docs/components/modal.mdx
@@ -3,7 +3,7 @@ title: Modal
description: Use OUDS Web’s JavaScript modal plugin to add dialogs to your site for lightboxes, user notifications, or completely custom content.
aliases:
- "/docs/components/modal/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/nav-tab.mdx b/site/src/content/docs/components/nav-tab.mdx
index 5cc3a94697..a641119f33 100644
--- a/site/src/content/docs/components/nav-tab.mdx
+++ b/site/src/content/docs/components/nav-tab.mdx
@@ -6,7 +6,7 @@ aliases:
- "/docs/components/nav/"
- "/docs/components/navs-tabs/"
- "/docs/components/nav-tab/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/navbar.mdx b/site/src/content/docs/components/navbar.mdx
index baadaaa714..7903837c45 100644
--- a/site/src/content/docs/components/navbar.mdx
+++ b/site/src/content/docs/components/navbar.mdx
@@ -3,7 +3,7 @@ title: Navbar
description: Documentation and examples for OUDS Web’s powerful, responsive navigation header, the navbar. Includes support for branding, navigation, and more, including support for our collapse plugin.
aliases:
- "/docs/components/navbar/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/offcanvas.mdx b/site/src/content/docs/components/offcanvas.mdx
index 08ba377de5..2dbb04797d 100644
--- a/site/src/content/docs/components/offcanvas.mdx
+++ b/site/src/content/docs/components/offcanvas.mdx
@@ -3,7 +3,7 @@ title: Offcanvas
description: Build hidden sidebars into your project for navigation, shopping carts, and more with a few classes and our JavaScript plugin.
aliases:
- "/docs/components/offcanvas/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/pagination.mdx b/site/src/content/docs/components/pagination.mdx
index 6456801ce1..c4d8f7e582 100644
--- a/site/src/content/docs/components/pagination.mdx
+++ b/site/src/content/docs/components/pagination.mdx
@@ -3,7 +3,7 @@ title: Pagination
description: Documentation and examples for showing pagination to indicate a series of related content exists across multiple pages.
aliases:
- "/docs/components/pagination/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/popover.mdx b/site/src/content/docs/components/popover.mdx
index f80a26c3d2..d307599493 100644
--- a/site/src/content/docs/components/popover.mdx
+++ b/site/src/content/docs/components/popover.mdx
@@ -4,7 +4,7 @@ description: Documentation and examples for adding OUDS Web popovers, like those
aliases:
- "/docs/components/popovers/"
- "/docs/components/popover/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/progress.mdx b/site/src/content/docs/components/progress.mdx
index 819430909f..9f67ebb9ed 100644
--- a/site/src/content/docs/components/progress.mdx
+++ b/site/src/content/docs/components/progress.mdx
@@ -3,7 +3,7 @@ title: Progress
description: Documentation and examples for using OUDS Web custom progress bars featuring support for stacked bars, animated backgrounds, and text labels.
aliases:
- "/docs/components/progress/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/quantity-selector.mdx b/site/src/content/docs/components/quantity-selector.mdx
index 7e3e6fbc2b..e7cd843b12 100644
--- a/site/src/content/docs/components/quantity-selector.mdx
+++ b/site/src/content/docs/components/quantity-selector.mdx
@@ -4,7 +4,7 @@ description: Use our custom quantity selector in forms for incremental and decre
aliases:
- "/docs/forms/quantity-selector/"
- "/docs/components/quantity-selector/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/range.mdx b/site/src/content/docs/components/range.mdx
index baee621079..d085290804 100644
--- a/site/src/content/docs/components/range.mdx
+++ b/site/src/content/docs/components/range.mdx
@@ -4,7 +4,7 @@ description: Use our custom range inputs for consistent cross-browser styling an
aliases:
- "/docs/forms/range/"
- "/docs/components/range/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/scrollspy.mdx b/site/src/content/docs/components/scrollspy.mdx
index 215662af93..cff2089ac6 100644
--- a/site/src/content/docs/components/scrollspy.mdx
+++ b/site/src/content/docs/components/scrollspy.mdx
@@ -3,7 +3,7 @@ title: Scrollspy
description: Automatically update OUDS Web navigation or list group components based on scroll position to indicate which link is currently active in the viewport.
aliases:
- "/docs/components/scrollspy/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/spinner.mdx b/site/src/content/docs/components/spinner.mdx
index 2d49e33340..89e4b983d0 100644
--- a/site/src/content/docs/components/spinner.mdx
+++ b/site/src/content/docs/components/spinner.mdx
@@ -4,7 +4,7 @@ description: Indicate the loading state of a component or page with OUDS Web spi
aliases:
- "/docs/components/spinners/"
- "/docs/components/spinner/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/stepped-process.mdx b/site/src/content/docs/components/stepped-process.mdx
index 0a40473280..712641e53a 100644
--- a/site/src/content/docs/components/stepped-process.mdx
+++ b/site/src/content/docs/components/stepped-process.mdx
@@ -3,7 +3,7 @@ title: Stepped process
description: Stepped process bar used for multiple steps forms process
aliases:
- "/docs/components/stepped-process/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/sticker.mdx b/site/src/content/docs/components/sticker.mdx
index 7be2643052..465305d61b 100644
--- a/site/src/content/docs/components/sticker.mdx
+++ b/site/src/content/docs/components/sticker.mdx
@@ -3,7 +3,7 @@ title: Sticker
description: Use OUDS Web’s custom stickers to inform people about new offers.
aliases:
- "/docs/components/sticker/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/title-bar.mdx b/site/src/content/docs/components/title-bar.mdx
index 6d6e19b9ca..0925b73a8b 100644
--- a/site/src/content/docs/components/title-bar.mdx
+++ b/site/src/content/docs/components/title-bar.mdx
@@ -4,7 +4,7 @@ description: Documentation and examples for OUDS Web’s exclusive Brand respons
aliases:
- "/docs/components/title-bars/"
- "/docs/components/title-bar/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/toast.mdx b/site/src/content/docs/components/toast.mdx
index 55560787cf..fefb5ff967 100644
--- a/site/src/content/docs/components/toast.mdx
+++ b/site/src/content/docs/components/toast.mdx
@@ -4,7 +4,7 @@ description: Push notifications to your visitors with a toast, a lightweight and
aliases:
- "/docs/components/toasts/"
- "/docs/components/toast/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/components/tooltip.mdx b/site/src/content/docs/components/tooltip.mdx
index 38c8c60b0e..4c93f0ec6d 100644
--- a/site/src/content/docs/components/tooltip.mdx
+++ b/site/src/content/docs/components/tooltip.mdx
@@ -4,7 +4,7 @@ description: Documentation and examples for adding custom OUDS Web tooltips with
aliases:
- "/docs/components/tooltips/"
- "/docs/components/tooltip/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/foundation/images.mdx b/site/src/content/docs/foundation/images.mdx
index a18b6ffcd2..85a84b49f6 100644
--- a/site/src/content/docs/foundation/images.mdx
+++ b/site/src/content/docs/foundation/images.mdx
@@ -4,7 +4,7 @@ description: Documentation and examples for opting images into responsive behavi
aliases:
- "/docs/content/images/"
- "/docs/foundation/images/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/getting-started/component-versioning.mdx b/site/src/content/docs/getting-started/component-versioning.mdx
index 295d76af5e..4e5aa5c97a 100644
--- a/site/src/content/docs/getting-started/component-versioning.mdx
+++ b/site/src/content/docs/getting-started/component-versioning.mdx
@@ -3,7 +3,7 @@ title: Component versioning
description: Learn how we version our components and the relation to their design specifications.
aliases:
- "/docs/getting-started/component-versioning/"
-toc: true
+toc: false
---
import { getData } from '@libs/data'
diff --git a/site/src/content/docs/getting-started/optimize.mdx b/site/src/content/docs/getting-started/optimize.mdx
index 6bf36611db..3b39cb042f 100644
--- a/site/src/content/docs/getting-started/optimize.mdx
+++ b/site/src/content/docs/getting-started/optimize.mdx
@@ -4,7 +4,7 @@ description: Keep your projects lean, responsive, and maintainable so you can de
aliases:
- "/docs/customize/optimize/"
- "/docs/getting-started/optimize/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/getting-started/parcel.mdx b/site/src/content/docs/getting-started/parcel.mdx
index 55b87597aa..6f935713d8 100644
--- a/site/src/content/docs/getting-started/parcel.mdx
+++ b/site/src/content/docs/getting-started/parcel.mdx
@@ -3,7 +3,7 @@ title: OUDS Web and Parcel
description: The official guide for how to include and bundle OUDS Web’s CSS and JavaScript in your project using Parcel.
aliases:
- "/docs/getting-started/parcel/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/getting-started/rtl.mdx b/site/src/content/docs/getting-started/rtl.mdx
index 6535e1f481..f0a98428cb 100644
--- a/site/src/content/docs/getting-started/rtl.mdx
+++ b/site/src/content/docs/getting-started/rtl.mdx
@@ -3,7 +3,7 @@ title: RTL
description: Learn how to enable support for right-to-left text in OUDS Web across our layout, components, and utilities.
aliases:
- "/docs/getting-started/rtl/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/getting-started/vite.mdx b/site/src/content/docs/getting-started/vite.mdx
index 9818f24f52..b1d8493f29 100644
--- a/site/src/content/docs/getting-started/vite.mdx
+++ b/site/src/content/docs/getting-started/vite.mdx
@@ -3,7 +3,7 @@ title: OUDS Web and Vite
description: The official guide for how to include and bundle OUDS Web’s CSS and JavaScript in your project using Vite.
aliases:
- "/docs/getting-started/vite/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/getting-started/webpack.mdx b/site/src/content/docs/getting-started/webpack.mdx
index 62e004f549..19ae99a678 100644
--- a/site/src/content/docs/getting-started/webpack.mdx
+++ b/site/src/content/docs/getting-started/webpack.mdx
@@ -3,7 +3,7 @@ title: OUDS Web and Webpack
description: The official guide for how to include and bundle OUDS Web’s CSS and JavaScript in your project using Webpack.
aliases:
- "/docs/getting-started/webpack/"
-toc: true
+toc: false
---
diff --git a/site/src/content/docs/layout/form-layout.mdx b/site/src/content/docs/layout/form-layout.mdx
index 48edf8a2b8..a32340e703 100644
--- a/site/src/content/docs/layout/form-layout.mdx
+++ b/site/src/content/docs/layout/form-layout.mdx
@@ -4,7 +4,7 @@ description: Give your forms some structure—from inline to horizontal to custo
aliases:
- "/docs/forms/layout/"
- "/docs/layout/form-layout/"
-toc: true
+toc: false
---
diff --git a/site/src/layouts/DocsLayout.astro b/site/src/layouts/DocsLayout.astro
index 0ab33f3f1e..cc9293c897 100644
--- a/site/src/layouts/DocsLayout.astro
+++ b/site/src/layouts/DocsLayout.astro
@@ -27,6 +27,10 @@ const bodyProps: LayoutOverridesHTMLAttributes<'body'> = {}
if (frontmatter.toc) {
bodyProps['data-bs-spy'] = 'scroll'
bodyProps['data-bs-target'] = '#TableOfContents'
+ // Align the activation line with the scroll offset 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'] = '220px'
}
let currentComponentsTypesNames = [frontmatter.title]
diff --git a/site/src/libs/data.ts b/site/src/libs/data.ts
index f628604b38..4411d974aa 100644
--- a/site/src/libs/data.ts
+++ b/site/src/libs/data.ts
@@ -122,5 +122,5 @@ export function getData(type: TType): z.infer<(typeof da
}
}
-type DataType = keyof typeof dataDefinitions
+export type DataType = keyof typeof dataDefinitions
type DataSchema = z.ZodTypeAny
diff --git a/site/src/libs/icon.ts b/site/src/libs/icon.ts
index 5bff4cc9f5..6c200a38db 100644
--- a/site/src/libs/icon.ts
+++ b/site/src/libs/icon.ts
@@ -1,5 +1,5 @@
export interface SvgIconProps {
class?: string
- height: number
- width: number
+ height?: number
+ width?: number
}