Adds responsive inline TOC with safe scrollspy

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.
This commit is contained in:
rbalsleyMSFT
2026-02-05 13:19:39 -08:00
parent edc9901e7e
commit d38e461246
2 changed files with 243 additions and 55 deletions
+78 -7
View File
@@ -192,13 +192,84 @@
text-decoration: underline;
}
.page-toc__link.is-active {
font-weight: 600;
color: #1a1a1a;
border-left-color: #2563eb;
}
}
</style>
.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;
}
</style>
<meta name="ffu-right-toc" content="{% if page.right_toc == false %}false{% else %}true{% endif %}">
+165 -48
View File
@@ -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);
})();