From d38e461246c854c0a623ad31edc74586f9b99619 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:19:39 -0800 Subject: [PATCH] Adds responsive inline TOC with safe scrollspy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves navigation on narrow viewports by moving the TOC into the article and collapsing long lists with a show more/less toggle. Prevents duplicate TOCs and reduces scroll “fighting” by disposing/reinitializing scrollspy on resize and keeping auto-scrolling behavior limited to the right-side desktop panel. --- docs/_includes/head_custom.html | 85 +++++++++++-- docs/assets/js/page-toc.js | 213 +++++++++++++++++++++++++------- 2 files changed, 243 insertions(+), 55 deletions(-) diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html index 83ffddb..9dba5a4 100644 --- a/docs/_includes/head_custom.html +++ b/docs/_includes/head_custom.html @@ -192,13 +192,84 @@ text-decoration: underline; } - .page-toc__link.is-active { - font-weight: 600; - color: #1a1a1a; - border-left-color: #2563eb; - } - } - + .page-toc__link.is-active { + font-weight: 600; + color: #1a1a1a; + border-left-color: #2563eb; + } + } + + /* Inline "In this article" TOC (narrow viewports; Learn-like) */ + .page-toc.page-toc--inline { + margin: 1.5rem 0; + padding-left: 1rem; + border-left: 1px solid #eeebee; + font-size: 0.875rem; + } + + .page-toc--inline .page-toc__title { + font-weight: 600; + color: #27262b; + margin-bottom: 0.75rem; + } + + .page-toc--inline .page-toc__list { + list-style: none !important; + padding-left: 0; + margin: 0; + } + + .page-toc--inline .page-toc__item { + list-style: none !important; + margin: 0.4rem 0; + } + + .page-toc--inline .page-toc__item::marker { + content: ""; + } + + .page-toc--inline .page-toc__item--h3 { + padding-left: 0.75rem; + } + + .page-toc--inline .page-toc__link { + color: inherit; + text-decoration: none; + display: block; + padding: 0.125rem 0 0.125rem 0.75rem; + border-left: 3px solid transparent; + } + + .page-toc--inline .page-toc__link:hover { + text-decoration: underline; + } + + .page-toc__item.is-hidden { + display: none; + } + + /* Just-the-Docs renders UL bullets via li::before; disable for inline TOC */ + .page-toc--inline ul > li::before, + .page-toc--inline .page-toc__list > li::before, + .page-toc--inline .page-toc__item::before { + content: "" !important; + display: none !important; + } + + .page-toc__toggle { + background: none; + border: 0; + padding: 0.25rem 0 0 0.75rem; + font: inherit; + color: inherit; + cursor: pointer; + text-decoration: none; + } + + .page-toc__toggle:hover { + text-decoration: underline; + } + diff --git a/docs/assets/js/page-toc.js b/docs/assets/js/page-toc.js index d51b6db..1fd6563 100644 --- a/docs/assets/js/page-toc.js +++ b/docs/assets/js/page-toc.js @@ -1,6 +1,10 @@ (function () { 'use strict'; + var scrollSpyDispose = null; + var resizeReinitTimerId = null; + var inlineMaxVisibleItems = 4; + function IsRightTocEnabled() { var meta = document.querySelector('meta[name="ffu-right-toc"]'); if (meta && meta.content && meta.content.toLowerCase() === 'false') { @@ -18,6 +22,47 @@ } } + function RemoveExistingToc() { + if (scrollSpyDispose) { + scrollSpyDispose(); + scrollSpyDispose = null; + } + + var existingTocs = document.querySelectorAll('.page-toc'); + for (var i = 0; i < existingTocs.length; i++) { + existingTocs[i].remove(); + } + + var wrap = document.querySelector('.main-content-wrap'); + if (wrap) { + wrap.classList.remove('has-page-toc'); + } + } + + function InsertInlineToc(main, toc) { + if (!main || !toc) { + return; + } + + var title = main.querySelector('h1'); + if (title && title.parentNode === main) { + if (title.nextSibling) { + main.insertBefore(toc, title.nextSibling); + return; + } + + main.appendChild(toc); + return; + } + + if (main.firstChild) { + main.insertBefore(toc, main.firstChild); + return; + } + + main.appendChild(toc); + } + function GetHeadings(container) { var headings = container.querySelectorAll('h2, h3'); var results = []; @@ -49,9 +94,13 @@ return results; } - function BuildToc(headings) { + function BuildToc(headings, options) { + var variant = (options && options.variant) ? options.variant : 'right'; + var maxVisible = (options && options.maxVisible) ? options.maxVisible : 0; + var isInline = 'inline' === variant; + var nav = document.createElement('nav'); - nav.className = 'page-toc'; + nav.className = 'page-toc' + (isInline ? ' page-toc--inline' : ''); nav.setAttribute('aria-label', 'On this page'); var title = document.createElement('div'); @@ -61,6 +110,7 @@ var list = document.createElement('ul'); list.className = 'page-toc__list'; + list.id = 'page-toc-list'; for (var i = 0; i < headings.length; i++) { var item = headings[i]; @@ -75,13 +125,61 @@ li.appendChild(a); list.appendChild(li); + + if (isInline && maxVisible > 0 && i >= maxVisible) { + li.classList.add('is-hidden'); + } } nav.appendChild(list); + + if (isInline && maxVisible > 0 && headings.length > maxVisible) { + var hiddenCount = headings.length - maxVisible; + var isExpanded = false; + + var toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'page-toc__toggle'; + toggle.setAttribute('aria-controls', list.id); + toggle.setAttribute('aria-expanded', 'false'); + + function SetToggleText() { + if (isExpanded) { + toggle.textContent = 'Show less'; + } else { + toggle.textContent = 'Show ' + hiddenCount + ' more'; + } + } + + function SetHiddenState() { + var items = list.querySelectorAll('.page-toc__item'); + for (var j = 0; j < items.length; j++) { + if (j >= maxVisible) { + if (isExpanded) { + items[j].classList.remove('is-hidden'); + } else { + items[j].classList.add('is-hidden'); + } + } + } + + toggle.setAttribute('aria-expanded', isExpanded ? 'true' : 'false'); + SetToggleText(); + } + + toggle.addEventListener('click', function () { + isExpanded = !isExpanded; + SetHiddenState(); + }); + + SetHiddenState(); + nav.appendChild(toggle); + } + return nav; } - function SetActiveTocLink(toc, activeId) { + function SetActiveTocLink(toc, activeId, keepVisibleInPanel) { if (!toc) { return; } @@ -95,11 +193,13 @@ if (isActive) { link.classList.add('is-active'); - /* Keep the active item visible inside the TOC panel */ - try { - link.scrollIntoView({ block: 'nearest' }); - } catch (e) { - link.scrollIntoView(); + if (keepVisibleInPanel) { + /* Keep the active item visible inside the TOC panel (desktop/right TOC only) */ + try { + link.scrollIntoView({ block: 'nearest' }); + } catch (e) { + link.scrollIntoView(); + } } } else { link.classList.remove('is-active'); @@ -109,12 +209,12 @@ function SetupScrollSpy(main, toc, headings) { if (!main || !toc || !headings || headings.length < 1) { - return; + return null; } - /* Scrollspy is desktop-only; on mobile it can cause "fighting" scroll behavior */ + /* Scrollspy is desktop-only */ if (!IsDesktopViewport()) { - return; + return null; } var headingElements = []; @@ -126,7 +226,7 @@ } if (headingElements.length < 1) { - return; + return null; } var activeId = null; @@ -143,7 +243,7 @@ } function GetCurrentHeadingId() { - /* If we're at the bottom, force the last heading active (Learn-like behavior) */ + /* If we're at the bottom, force the last heading active */ if (IsNearBottomOfPage()) { return headingElements[headingElements.length - 1].getAttribute('id'); } @@ -177,6 +277,11 @@ function Update() { ticking = false; + /* If the viewport becomes narrow after load, avoid scroll fighting */ + if (!IsDesktopViewport()) { + return; + } + if (Date.now() < lockActiveUntilMs) { return; } @@ -187,7 +292,7 @@ } activeId = currentId; - SetActiveTocLink(toc, activeId); + SetActiveTocLink(toc, activeId, true); } function OnScrollOrResize() { @@ -199,11 +304,7 @@ window.requestAnimationFrame(Update); } - window.addEventListener('scroll', OnScrollOrResize, { passive: true }); - window.addEventListener('resize', OnScrollOrResize); - - /* Update immediately and also when clicking TOC links */ - toc.addEventListener('click', function (evt) { + function OnTocClick(evt) { var target = evt.target; if (!target || !target.classList || !target.classList.contains('page-toc__link')) { return; @@ -223,29 +324,25 @@ lockActiveUntilMs = Date.now() + 800; activeId = id; - SetActiveTocLink(toc, activeId); - }); + SetActiveTocLink(toc, activeId, true); + } + + window.addEventListener('scroll', OnScrollOrResize, { passive: true }); + window.addEventListener('resize', OnScrollOrResize); + toc.addEventListener('click', OnTocClick); Update(); + + return function DisposeScrollSpy() { + window.removeEventListener('scroll', OnScrollOrResize); + window.removeEventListener('resize', OnScrollOrResize); + toc.removeEventListener('click', OnTocClick); + }; } function InitRightToc() { if (!IsRightTocEnabled()) { - return; - } - - /* Desktop-only TOC: on mobile it interferes with scrolling */ - if (!IsDesktopViewport()) { - var existingWrap = document.querySelector('.main-content-wrap'); - if (existingWrap) { - var existingToc = existingWrap.querySelector('.page-toc'); - if (existingToc) { - existingToc.remove(); - } - - existingWrap.classList.remove('has-page-toc'); - } - + RemoveExistingToc(); return; } @@ -256,31 +353,51 @@ var headings = GetHeadings(main); if (headings.length < 2) { + RemoveExistingToc(); return; } - var wrap = document.querySelector('.main-content-wrap'); - var content = document.querySelector('.main-content'); - if (!wrap || !content) { + if (IsDesktopViewport()) { + RemoveExistingToc(); + + var wrap = document.querySelector('.main-content-wrap'); + if (!wrap) { + return; + } + + wrap.classList.add('has-page-toc'); + + var toc = BuildToc(headings, { variant: 'right' }); + wrap.appendChild(toc); + + scrollSpyDispose = SetupScrollSpy(main, toc, headings); return; } - if (wrap.querySelector('.page-toc')) { - return; + /* Narrow viewports: place TOC at the top of the article (Learn-like) */ + RemoveExistingToc(); + + var inlineToc = BuildToc(headings, { variant: 'inline', maxVisible: inlineMaxVisibleItems }); + InsertInlineToc(main, inlineToc); + } + + function OnViewportResize() { + if (null !== resizeReinitTimerId) { + window.clearTimeout(resizeReinitTimerId); } - wrap.classList.add('has-page-toc'); - - var toc = BuildToc(headings); - wrap.appendChild(toc); - - SetupScrollSpy(main, toc, headings); + resizeReinitTimerId = window.setTimeout(InitRightToc, 150); } if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', InitRightToc); + document.addEventListener('DOMContentLoaded', function () { + InitRightToc(); + window.addEventListener('resize', OnViewportResize); + }); + return; } InitRightToc(); + window.addEventListener('resize', OnViewportResize); })(); \ No newline at end of file