442 lines
10 KiB
Plaintext
442 lines
10 KiB
Plaintext
---
|
|
const { headings } = Astro.props;
|
|
|
|
// H2/H3 seulement
|
|
const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
|
const tocId = `toc-local-${Math.random().toString(36).slice(2, 9)}`;
|
|
---
|
|
|
|
{items.length > 0 && (
|
|
<nav class="toc-local" aria-label="Dans ce chapitre" data-toc-local data-mobile-default="closed">
|
|
<button
|
|
class="toc-local__head toc-local__toggle"
|
|
type="button"
|
|
aria-expanded="false"
|
|
aria-controls={tocId}
|
|
>
|
|
<span class="toc-local__title">Dans ce chapitre</span>
|
|
<span class="toc-local__chevron" aria-hidden="true">▾</span>
|
|
</button>
|
|
|
|
<div class="toc-local__body-clip" id={tocId} hidden>
|
|
<div class="toc-local__body">
|
|
<ol class="toc-local__list">
|
|
{items.map((h) => (
|
|
<li
|
|
class={`toc-local__item d${h.depth}`}
|
|
data-toc-item
|
|
data-depth={h.depth}
|
|
data-id={h.slug}
|
|
>
|
|
<a href={`#${h.slug}`} data-toc-link data-slug={h.slug}>
|
|
<span class="toc-local__mark" aria-hidden="true"></span>
|
|
<span class="toc-local__text">{h.text}</span>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
)}
|
|
|
|
<script is:inline>
|
|
(() => {
|
|
function init() {
|
|
const toc = document.querySelector(".toc-local[data-toc-local]");
|
|
if (!toc || toc.dataset.tocReady === "1") return;
|
|
toc.dataset.tocReady = "1";
|
|
|
|
const toggle = toc.querySelector(".toc-local__toggle");
|
|
const bodyClip = toc.querySelector(".toc-local__body-clip");
|
|
const mq = window.matchMedia("(max-width: 980px)");
|
|
const KEY = `archicratie:toc-local:${window.location.pathname}`;
|
|
|
|
if (!toggle || !bodyClip) return;
|
|
|
|
const readState = () => {
|
|
try {
|
|
const v = localStorage.getItem(KEY);
|
|
if (v === "open") return true;
|
|
if (v === "closed") return false;
|
|
} catch {}
|
|
return null;
|
|
};
|
|
|
|
const writeState = (open) => {
|
|
try { localStorage.setItem(KEY, open ? "open" : "closed"); } catch {}
|
|
};
|
|
|
|
const setOpen = (open, { persist = true, emit = true } = {}) => {
|
|
const isMobile = mq.matches;
|
|
const effectiveOpen = isMobile ? open : true;
|
|
|
|
toc.classList.toggle("is-collapsed", isMobile && !effectiveOpen);
|
|
toggle.setAttribute("aria-expanded", effectiveOpen ? "true" : "false");
|
|
|
|
if (bodyClip) {
|
|
bodyClip.hidden = isMobile && !effectiveOpen;
|
|
}
|
|
|
|
if (persist && isMobile) writeState(effectiveOpen);
|
|
|
|
if (emit && effectiveOpen && isMobile) {
|
|
window.dispatchEvent(new CustomEvent("archicratie:tocLocalOpen"));
|
|
}
|
|
};
|
|
|
|
const initAccordion = () => {
|
|
if (!mq.matches) {
|
|
setOpen(true, { persist: false, emit: false });
|
|
return;
|
|
}
|
|
const stored = readState();
|
|
setOpen(stored == null ? false : stored, { persist: false, emit: false });
|
|
};
|
|
|
|
toggle.addEventListener("click", () => {
|
|
const next = toggle.getAttribute("aria-expanded") !== "true";
|
|
setOpen(next);
|
|
});
|
|
|
|
if (mq.addEventListener) {
|
|
mq.addEventListener("change", initAccordion);
|
|
} else if (mq.addListener) {
|
|
mq.addListener(initAccordion);
|
|
}
|
|
|
|
const itemEls = Array.from(toc.querySelectorAll("[data-toc-item]"));
|
|
if (!itemEls.length) {
|
|
initAccordion();
|
|
return;
|
|
}
|
|
|
|
const ordered = itemEls
|
|
.map((li) => {
|
|
const a = li.querySelector("a[data-toc-link]");
|
|
const id = li.getAttribute("data-id") || a?.dataset.slug || "";
|
|
const depth = Number(li.getAttribute("data-depth") || "0");
|
|
const el = id ? document.getElementById(id) : null;
|
|
return (a && id && el) ? { id, depth, li, a, el } : null;
|
|
})
|
|
.filter(Boolean);
|
|
|
|
if (!ordered.length) {
|
|
initAccordion();
|
|
return;
|
|
}
|
|
|
|
const clear = () => {
|
|
for (const t of ordered) {
|
|
t.a.removeAttribute("aria-current");
|
|
t.li.classList.remove("is-current");
|
|
}
|
|
};
|
|
|
|
const openDetailsIfNeeded = (el) => {
|
|
try {
|
|
if (!el) return;
|
|
|
|
let d = el.closest?.("details") || null;
|
|
|
|
if (!d && el.classList?.contains("details-anchor")) {
|
|
const n = el.nextElementSibling;
|
|
if (n && n.tagName === "DETAILS") d = n;
|
|
}
|
|
|
|
if (!d) {
|
|
const s = el.closest?.("summary");
|
|
if (s && s.parentElement && s.parentElement.tagName === "DETAILS") d = s.parentElement;
|
|
}
|
|
|
|
if (d && d.tagName === "DETAILS" && !d.open) d.open = true;
|
|
} catch {}
|
|
};
|
|
|
|
let current = "";
|
|
|
|
const setCurrent = (id, { autoOpen = true } = {}) => {
|
|
if (!id) return;
|
|
const t = ordered.find((x) => x.id === id);
|
|
if (!t) return;
|
|
|
|
current = id;
|
|
|
|
clear();
|
|
openDetailsIfNeeded(t.el);
|
|
|
|
t.a.setAttribute("aria-current", "true");
|
|
t.li.classList.add("is-current");
|
|
|
|
// Sur mobile/tablette, le suivi actif ne doit pas rouvrir automatiquement la TOC.
|
|
if (!mq.matches && autoOpen && toc.classList.contains("is-collapsed")) {
|
|
setOpen(true);
|
|
}
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent("archicratie:tocLocalActive", { detail: { id } })
|
|
);
|
|
};
|
|
|
|
const computeActive = () => {
|
|
const visible = ordered.filter((t) => {
|
|
const d = t.el.closest?.("details");
|
|
if (d && !d.open) {
|
|
const inSummary = !!t.el.closest?.("summary");
|
|
if (!inSummary && !t.el.classList?.contains("details-anchor")) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (!visible.length) return;
|
|
|
|
const y = 120;
|
|
let best = null;
|
|
|
|
for (const t of visible) {
|
|
const r = t.el.getBoundingClientRect();
|
|
if (!r || (r.width === 0 && r.height === 0)) continue;
|
|
if (r.top <= y) best = t;
|
|
else break;
|
|
}
|
|
|
|
if (!best) best = visible[0];
|
|
if (best && best.id !== current) setCurrent(best.id, { autoOpen: true });
|
|
};
|
|
|
|
let ticking = false;
|
|
const onScroll = () => {
|
|
if (ticking) return;
|
|
ticking = true;
|
|
requestAnimationFrame(() => {
|
|
ticking = false;
|
|
computeActive();
|
|
});
|
|
};
|
|
|
|
const syncFromHash = () => {
|
|
const id = (location.hash || "").slice(1);
|
|
if (!id) {
|
|
computeActive();
|
|
return;
|
|
}
|
|
|
|
const el = document.getElementById(id);
|
|
if (el) openDetailsIfNeeded(el);
|
|
setCurrent(id, { autoOpen: false });
|
|
};
|
|
|
|
toc.addEventListener("click", (ev) => {
|
|
const a = ev.target?.closest?.("a[data-toc-link]");
|
|
if (!a) return;
|
|
const id = a.dataset.slug || "";
|
|
if (!id) return;
|
|
|
|
const el = document.getElementById(id);
|
|
if (el) openDetailsIfNeeded(el);
|
|
|
|
setCurrent(id, { autoOpen: true });
|
|
});
|
|
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
window.addEventListener("resize", onScroll);
|
|
window.addEventListener("hashchange", syncFromHash);
|
|
|
|
initAccordion();
|
|
syncFromHash();
|
|
onScroll();
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
window.addEventListener("DOMContentLoaded", init, { once: true });
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<style>
|
|
.toc-local{
|
|
margin-top: 12px;
|
|
border: 1px solid rgba(127,127,127,.25);
|
|
border-radius: 16px;
|
|
padding: 12px;
|
|
background: rgba(127,127,127,0.03);
|
|
}
|
|
|
|
.toc-local__toggle{
|
|
width: 100%;
|
|
appearance: none;
|
|
border: 0;
|
|
background: transparent;
|
|
color: inherit;
|
|
text-align: left;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.toc-local__head{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.toc-local__title{
|
|
font-size: 13px;
|
|
opacity: .85;
|
|
}
|
|
|
|
.toc-local__chevron{
|
|
font-size: 12px;
|
|
opacity: .72;
|
|
transition: transform 180ms ease;
|
|
}
|
|
|
|
.toc-local__body-clip{
|
|
display: grid;
|
|
grid-template-rows: 1fr;
|
|
transition:
|
|
grid-template-rows 220ms ease,
|
|
opacity 160ms ease,
|
|
margin-top 220ms ease;
|
|
}
|
|
|
|
.toc-local__body{
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.toc-local__list{
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
max-height: 44vh;
|
|
overflow: auto;
|
|
padding-right: 8px;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
.toc-local__item::marker{ content:""; }
|
|
.toc-local__item{ margin: 6px 0; }
|
|
|
|
.toc-local__item.d3{
|
|
margin-left: 14px;
|
|
opacity: .94;
|
|
}
|
|
|
|
.toc-local a{
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 8px;
|
|
align-items: start;
|
|
max-width: 100%;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.toc-local a:hover{
|
|
text-decoration: none;
|
|
}
|
|
|
|
.toc-local__mark{
|
|
width: 10px;
|
|
height: 10px;
|
|
margin-top: .36em;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(127,127,127,.34);
|
|
background: transparent;
|
|
opacity: .68;
|
|
}
|
|
|
|
.toc-local__text{
|
|
line-height: 1.28;
|
|
}
|
|
|
|
.toc-local__item.is-current > a{
|
|
font-weight: 760;
|
|
}
|
|
|
|
.toc-local__item.is-current > a .toc-local__mark{
|
|
background: currentColor;
|
|
border-color: currentColor;
|
|
box-shadow: 0 0 0 3px rgba(127,127,127,.10);
|
|
opacity: 1;
|
|
}
|
|
|
|
@media (max-width: 980px){
|
|
.toc-local{
|
|
padding: 10px 12px;
|
|
border-radius: 14px;
|
|
}
|
|
|
|
.toc-local__head{
|
|
margin-bottom: 0;
|
|
min-height: 28px;
|
|
}
|
|
|
|
.toc-local__body-clip{
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.toc-local.is-collapsed .toc-local__body-clip{
|
|
grid-template-rows: 0fr;
|
|
opacity: 0;
|
|
margin-top: 0;
|
|
}
|
|
|
|
.toc-local__body{
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
transition: opacity 180ms ease;
|
|
}
|
|
|
|
.toc-local.is-collapsed .toc-local__body{
|
|
opacity: 0;
|
|
}
|
|
|
|
.toc-local.is-collapsed .toc-local__chevron{
|
|
transform: rotate(-90deg);
|
|
}
|
|
|
|
.toc-local__title{
|
|
font-size: 13px;
|
|
}
|
|
|
|
.toc-local__list{
|
|
max-height: min(42vh, 360px);
|
|
padding-right: 4px;
|
|
}
|
|
|
|
.toc-local__item{
|
|
margin: 5px 0;
|
|
}
|
|
|
|
.toc-local__item.d2 > a .toc-local__text{
|
|
font-size: 12.9px;
|
|
line-height: 1.24;
|
|
font-weight: 680;
|
|
}
|
|
|
|
.toc-local__item.d3{
|
|
margin-left: 12px;
|
|
}
|
|
|
|
.toc-local__item.d3 > a .toc-local__text{
|
|
font-size: 12.1px;
|
|
line-height: 1.22;
|
|
opacity: .95;
|
|
}
|
|
|
|
.toc-local__item.d3 > a .toc-local__mark{
|
|
width: 8px;
|
|
height: 8px;
|
|
margin-top: .42em;
|
|
opacity: .55;
|
|
}
|
|
|
|
.toc-local__body-clip[hidden]{
|
|
display: none !important;
|
|
}
|
|
}
|
|
</style> |