Files
archicratie-edition/src/components/LocalToc.astro
Archicratia 64e56e8abc
All checks were successful
SMOKE / smoke (push) Successful in 6s
CI / build-and-anchors (push) Successful in 44s
CI / build-and-anchors (pull_request) Successful in 36s
feat(ui): harmoniser navigation pages d’entrée et recherche
2026-04-25 01:31:14 +02:00

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>