mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
feat: client-side TOC widget with Alpine.js scroll spy
Replace the server-side toc.njk placeholder (which never rendered because no code populated the `toc` variable) with a client-side Alpine.js component that scans .e-content headings at page load, builds a dynamic table of contents, and highlights the current section via IntersectionObserver. - Only appears on articles/notes with 3+ headings (h2-h4) - Excluded at build time for bookmarks, likes, and reposts - Scroll spy activates heading in top 30% of viewport Confab-Link: http://localhost:8080/sessions/cc343b15-8d10-43cd-a48f-ca912eb79b83
This commit is contained in:
@@ -1,19 +1,27 @@
|
||||
{# Table of Contents Widget (for articles with headings) #}
|
||||
{% if toc and toc.length %}
|
||||
<is-land on:visible>
|
||||
{# Table of Contents Widget — client-side Alpine.js heading scanner with scroll spy #}
|
||||
{# Only renders on Articles/Notes with 3+ headings (h2-h4) #}
|
||||
{% if title or (not (bookmarkOf or bookmark_of or likeOf or like_of or repostOf or repost_of)) %}
|
||||
<div x-data="tocScanner" x-show="items.length > 0" x-cloak>
|
||||
<div class="widget">
|
||||
<h3 class="widget-title">Contents</h3>
|
||||
<nav class="toc" aria-label="Table of contents">
|
||||
<ul class="space-y-1 text-sm">
|
||||
{% for item in toc %}
|
||||
<li class="{% if item.level == 3 %}ml-3{% elif item.level == 4 %}ml-6{% elif item.level == 5 %}ml-9{% elif item.level == 6 %}ml-12{% endif %}">
|
||||
<a href="#{{ item.slug }}" class="text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400 hover:underline transition-colors">
|
||||
{{ item.text }}
|
||||
</a>
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<li :class="{
|
||||
'ml-3': item.level === 3,
|
||||
'ml-6': item.level === 4
|
||||
}">
|
||||
<a :href="'#' + item.id"
|
||||
x-text="item.text"
|
||||
class="transition-colors hover:underline"
|
||||
:class="item.active
|
||||
? 'text-accent-600 dark:text-accent-400 font-medium'
|
||||
: 'text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400'"
|
||||
></a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</template>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</is-land>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
|
||||
<script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
|
||||
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
|
||||
<script src="/js/toc-scanner.js?v={{ '/js/toc-scanner.js' | hash }}" defer></script>
|
||||
<script defer src="/js/vendor/alpine-collapse.min.js?v={{ '/js/vendor/alpine-collapse.min.js' | hash }}"></script>
|
||||
<script defer src="/js/vendor/alpine.min.js?v={{ '/js/vendor/alpine.min.js' | hash }}"></script>
|
||||
<style>[x-cloak] { display: none !important; }</style>
|
||||
|
||||
46
js/toc-scanner.js
Normal file
46
js/toc-scanner.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Alpine.js TOC scanner component.
|
||||
* Scans .e-content for h2/h3/h4 headings with IDs,
|
||||
* builds a table of contents, and highlights the
|
||||
* current section via IntersectionObserver scroll spy.
|
||||
*/
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("tocScanner", () => ({
|
||||
items: [],
|
||||
_observer: null,
|
||||
|
||||
init() {
|
||||
const content = document.querySelector(".e-content");
|
||||
if (!content) return;
|
||||
|
||||
const headings = content.querySelectorAll("h2[id], h3[id], h4[id]");
|
||||
if (headings.length < 3) return;
|
||||
|
||||
this.items = Array.from(headings).map((h) => ({
|
||||
id: h.id,
|
||||
text: h.textContent.replace(/^#\s*/, "").trim(),
|
||||
level: parseInt(h.tagName[1]),
|
||||
active: false,
|
||||
}));
|
||||
|
||||
this._observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
this.items.forEach((item) => {
|
||||
item.active = item.id === entry.target.id;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -70% 0px" },
|
||||
);
|
||||
|
||||
headings.forEach((h) => this._observer.observe(h));
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this._observer) this._observer.disconnect();
|
||||
},
|
||||
}));
|
||||
});
|
||||
Reference in New Issue
Block a user