|
|
--- |
|
|
export interface Props { tableOfContentAutoCollapse?: boolean } |
|
|
const { tableOfContentAutoCollapse = false } = Astro.props as Props; |
|
|
--- |
|
|
<nav class="table-of-contents" aria-label="Table of Contents" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}> |
|
|
<div class="title">Table of Contents</div> |
|
|
<div id="article-toc-placeholder"></div> |
|
|
</nav> |
|
|
<details class="table-of-contents-mobile"> |
|
|
<summary>Table of Contents</summary> |
|
|
<div id="article-toc-mobile-placeholder"></div> |
|
|
</details> |
|
|
|
|
|
<script is:inline> |
|
|
|
|
|
const buildTOC = () => { |
|
|
const holder = document.getElementById('article-toc-placeholder'); |
|
|
const holderMobile = document.getElementById('article-toc-mobile-placeholder'); |
|
|
|
|
|
if (holder) holder.innerHTML = ''; |
|
|
if (holderMobile) holderMobile.innerHTML = ''; |
|
|
const articleRoot = document.querySelector('section.content-grid main'); |
|
|
if (!articleRoot) return; |
|
|
const headings = articleRoot.querySelectorAll('h2, h3, h4'); |
|
|
if (!headings.length) return; |
|
|
|
|
|
|
|
|
const headingsArr = Array.from(headings); |
|
|
if (!headingsArr.length) return; |
|
|
|
|
|
|
|
|
const usedIds = new Set(); |
|
|
const slugify = (s) => String(s || '') |
|
|
.toLowerCase() |
|
|
.trim() |
|
|
.replace(/\s+/g, '_') |
|
|
.replace(/[^a-z0-9_\-]/g, ''); |
|
|
headingsArr.forEach((h) => { |
|
|
let id = (h.id || '').trim(); |
|
|
if (!id) { |
|
|
const base = slugify(h.textContent || ''); |
|
|
id = base || 'section'; |
|
|
} |
|
|
let candidate = id; |
|
|
let n = 2; |
|
|
while (usedIds.has(candidate)) { |
|
|
candidate = `${id}-${n++}`; |
|
|
} |
|
|
if (h.id !== candidate) h.id = candidate; |
|
|
usedIds.add(candidate); |
|
|
}); |
|
|
|
|
|
const nav = document.createElement('nav'); |
|
|
let ulStack = [document.createElement('ul')]; |
|
|
nav.appendChild(ulStack[0]); |
|
|
|
|
|
const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4; |
|
|
let prev = 2; |
|
|
let h2Count = -1; |
|
|
const h2List = headingsArr.filter(h => h.tagName === 'H2'); |
|
|
headingsArr.forEach((h) => { |
|
|
const lvl = levelOf(h.tagName); |
|
|
|
|
|
while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; } |
|
|
while (lvl < prev) { ulStack.pop(); prev--; } |
|
|
const li = document.createElement('li'); |
|
|
const a = document.createElement('a'); |
|
|
a.href = '#' + h.id; a.textContent = h.textContent; a.target = '_self'; |
|
|
li.appendChild(a); |
|
|
if (lvl === 2) { |
|
|
h2Count += 1; |
|
|
li.setAttribute('data-h2-idx', String(h2Count)); |
|
|
} |
|
|
ulStack[ulStack.length-1].appendChild(li); |
|
|
}); |
|
|
|
|
|
if (holder) holder.appendChild(nav); |
|
|
const navClone = nav.cloneNode(true); |
|
|
if (holderMobile) holderMobile.appendChild(navClone); |
|
|
|
|
|
|
|
|
const links = [ |
|
|
...(holder ? holder.querySelectorAll('a') : []), |
|
|
...(holderMobile ? holderMobile.querySelectorAll('a') : []) |
|
|
]; |
|
|
|
|
|
const getCollapsePx = () => { |
|
|
const root = document.documentElement; |
|
|
const raw = getComputedStyle(root).getPropertyValue('--bp-content-collapse').trim(); |
|
|
return raw || '1100px'; |
|
|
}; |
|
|
const mq = window.matchMedia(`(max-width: ${getCollapsePx()})`); |
|
|
const attrEnabled = (document.querySelector('.table-of-contents')?.getAttribute('data-auto-collapse') === '1'); |
|
|
let autoCollapse = attrEnabled && !mq.matches; |
|
|
|
|
|
|
|
|
const ensureStyles = () => { |
|
|
if (document.getElementById('toc-collapse-style')) return; |
|
|
const style = document.createElement('style'); |
|
|
style.id = 'toc-collapse-style'; |
|
|
style.textContent = ` |
|
|
.table-of-contents nav.table-of-contents-collapsible > ul > li > ul, |
|
|
details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; } |
|
|
.table-of-contents nav.table-of-contents-collapsible > ul > li.collapsed > ul, |
|
|
details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li.collapsed > ul { display: block; } |
|
|
`; |
|
|
document.head.appendChild(style); |
|
|
}; |
|
|
ensureStyles(); |
|
|
|
|
|
const getTopLevelItems = () => { |
|
|
const sideNav = holder ? holder.querySelector('nav') : null; |
|
|
const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null; |
|
|
const q = (navEl) => navEl ? Array.from(navEl.querySelectorAll(':scope > ul > li[data-h2-idx]')) : []; |
|
|
return { sideNav, mobileNav, sideTop: q(sideNav), mobileTop: q(mobileNav) }; |
|
|
}; |
|
|
|
|
|
const setNavCollapsible = () => { |
|
|
const sideNav = holder ? holder.querySelector('nav') : null; |
|
|
const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null; |
|
|
if (sideNav) sideNav.classList.add('table-of-contents-collapsible'); |
|
|
if (mobileNav) mobileNav.classList.add('table-of-contents-collapsible'); |
|
|
}; |
|
|
|
|
|
const measure = (el) => { |
|
|
if (!el) return 0; |
|
|
|
|
|
const prev = el.style.height; |
|
|
el.style.height = 'auto'; |
|
|
const h = el.scrollHeight; |
|
|
el.style.height = prev || ''; |
|
|
return h; |
|
|
}; |
|
|
|
|
|
const animateTo = (el, target) => { |
|
|
if (!el) return; |
|
|
const current = parseFloat(getComputedStyle(el).height) || 0; |
|
|
if (Math.abs(current - target) < 1) { |
|
|
el.style.height = target ? 'auto' : '0px'; |
|
|
return; |
|
|
} |
|
|
el.style.height = current + 'px'; |
|
|
|
|
|
void el.offsetHeight; |
|
|
el.style.height = target + 'px'; |
|
|
const onEnd = (e) => { |
|
|
if (e.propertyName !== 'height') return; |
|
|
el.removeEventListener('transitionend', onEnd); |
|
|
if (target > 0) el.style.height = 'auto'; |
|
|
}; |
|
|
el.addEventListener('transitionend', onEnd); |
|
|
}; |
|
|
|
|
|
let prevActiveIdx = -1; |
|
|
const setCollapsedState = (activeIdx) => { |
|
|
if (!autoCollapse) return; |
|
|
if (activeIdx == null || activeIdx < 0) activeIdx = 0; |
|
|
const { sideTop, mobileTop } = getTopLevelItems(); |
|
|
const update = (items) => items.forEach((li) => { |
|
|
const idx = Number(li.getAttribute('data-h2-idx') || '-1'); |
|
|
const sub = li.querySelector(':scope > ul'); |
|
|
if (!sub) return; |
|
|
if (idx === activeIdx) { |
|
|
li.classList.remove('collapsed'); |
|
|
const target = measure(sub); |
|
|
animateTo(sub, target); |
|
|
} else { |
|
|
li.classList.add('collapsed'); |
|
|
animateTo(sub, 0); |
|
|
} |
|
|
}); |
|
|
update(sideTop); |
|
|
update(mobileTop); |
|
|
setNavCollapsible(); |
|
|
prevActiveIdx = activeIdx; |
|
|
}; |
|
|
|
|
|
|
|
|
const expandAll = () => { |
|
|
const { sideTop, mobileTop } = getTopLevelItems(); |
|
|
const expand = (items) => items.forEach((li) => { |
|
|
li.classList.remove('collapsed'); |
|
|
const sub = li.querySelector(':scope > ul'); |
|
|
if (sub) sub.style.height = 'auto'; |
|
|
}); |
|
|
expand(sideTop); |
|
|
expand(mobileTop); |
|
|
}; |
|
|
|
|
|
const onMqChange = () => { |
|
|
autoCollapse = attrEnabled && !mq.matches; |
|
|
if (!autoCollapse) { |
|
|
expandAll(); |
|
|
} else { |
|
|
setCollapsedState(prevActiveIdx); |
|
|
} |
|
|
}; |
|
|
if (mq.addEventListener) mq.addEventListener('change', onMqChange); |
|
|
else if (mq.addListener) mq.addListener(onMqChange); |
|
|
|
|
|
const onScroll = () => { |
|
|
|
|
|
let activeIdx = -1; |
|
|
for (let i = headingsArr.length - 1; i >= 0; i--) { |
|
|
const top = headingsArr[i].getBoundingClientRect().top; |
|
|
if (top - 60 <= 0) { |
|
|
links.forEach(l => l.classList.remove('active')); |
|
|
const id = '#' + headingsArr[i].id; |
|
|
const actives = Array.from(links).filter(l => l.getAttribute('href') === id); |
|
|
actives.forEach(a => a.classList.add('active')); |
|
|
if (headingsArr[i].tagName === 'H2') { |
|
|
activeIdx = h2List.indexOf(headingsArr[i]); |
|
|
} else { |
|
|
for (let j = i; j >= 0; j--) { |
|
|
if (headingsArr[j].tagName === 'H2') { activeIdx = h2List.indexOf(headingsArr[j]); break; } |
|
|
} |
|
|
} |
|
|
break; |
|
|
} |
|
|
} |
|
|
if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx); |
|
|
}; |
|
|
|
|
|
|
|
|
if (autoCollapse) setCollapsedState(0); |
|
|
|
|
|
window.addEventListener('scroll', onScroll); |
|
|
|
|
|
onScroll(); |
|
|
|
|
|
|
|
|
if (holderMobile) { |
|
|
const details = holderMobile.closest('details'); |
|
|
holderMobile.addEventListener('click', (ev) => { |
|
|
const target = ev.target; |
|
|
const anchor = target && 'closest' in target ? target.closest('a') : null; |
|
|
if (anchor instanceof HTMLAnchorElement && details && details.open) { |
|
|
details.open = false; |
|
|
} |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
if (document.readyState === 'loading') { |
|
|
document.addEventListener('DOMContentLoaded', buildTOC, { once: true }); |
|
|
} else { buildTOC(); } |
|
|
</script> |
|
|
|
|
|
<style is:global> |
|
|
|
|
|
.table-of-contents { |
|
|
position: sticky; |
|
|
top: 32px; |
|
|
margin-top: 12px; |
|
|
} |
|
|
|
|
|
.table-of-contents nav { |
|
|
border-left: 1px solid var(--border-color); |
|
|
padding-left: 16px; |
|
|
font-size: 13px; |
|
|
} |
|
|
|
|
|
.table-of-contents .title { |
|
|
font-weight: 600; |
|
|
font-size: 14px; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
|
|
|
.table-of-contents nav ul { |
|
|
margin: 0 0 6px; |
|
|
padding-left: 1em; |
|
|
} |
|
|
|
|
|
.table-of-contents nav li { |
|
|
list-style: none; |
|
|
margin: .25em 0; |
|
|
} |
|
|
|
|
|
.table-of-contents nav a, |
|
|
.table-of-contents nav a:link, |
|
|
.table-of-contents nav a:visited { |
|
|
color: var(--text-color); |
|
|
text-decoration: none; |
|
|
border-bottom: none; |
|
|
} |
|
|
|
|
|
.table-of-contents nav > ul > li > a { |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.table-of-contents nav a:hover { |
|
|
text-decoration: underline solid var(--muted-color); |
|
|
} |
|
|
|
|
|
.table-of-contents nav a.active { |
|
|
text-decoration: underline; |
|
|
} |
|
|
|
|
|
|
|
|
.table-of-contents-mobile { |
|
|
display: none; |
|
|
margin: 8px 0 16px; |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile > summary { |
|
|
cursor: pointer; |
|
|
list-style: none; |
|
|
padding: var(--spacing-3) var(--spacing-4); |
|
|
border: 1px solid var(--border-color); |
|
|
border-radius: 8px; |
|
|
color: var(--text-color); |
|
|
font-weight: 600; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile[open] > summary { |
|
|
border-bottom-left-radius: 0; |
|
|
border-bottom-right-radius: 0; |
|
|
} |
|
|
|
|
|
|
|
|
.table-of-contents-mobile > summary::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
right: var(--spacing-4); |
|
|
top: 50%; |
|
|
width: 8px; |
|
|
height: 8px; |
|
|
border-right: 2px solid currentColor; |
|
|
border-bottom: 2px solid currentColor; |
|
|
transform: translateY(-70%) rotate(45deg); |
|
|
transition: transform 150ms ease; |
|
|
opacity: .7; |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile[open] > summary::after { |
|
|
transform: translateY(-30%) rotate(-135deg); |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile nav { |
|
|
border-left: none; |
|
|
padding: 10px 12px; |
|
|
font-size: 14px; |
|
|
border: 1px solid var(--border-color); |
|
|
border-top: none; |
|
|
border-bottom-left-radius: 8px; |
|
|
border-bottom-right-radius: 8px; |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile nav ul { |
|
|
margin: 0 0 6px; |
|
|
padding-left: 1em; |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile nav li { |
|
|
list-style: none; |
|
|
margin: .25em 0; |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile nav a, |
|
|
.table-of-contents-mobile nav a:link, |
|
|
.table-of-contents-mobile nav a:visited { |
|
|
color: var(--text-color); |
|
|
text-decoration: none; |
|
|
border-bottom: none; |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile nav > ul > li > a { |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile nav a:hover { |
|
|
text-decoration: underline solid var(--muted-color); |
|
|
} |
|
|
|
|
|
.table-of-contents-mobile nav a.active { |
|
|
text-decoration: underline; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
</style> |
|
|
|