Skip to content

Commit

Permalink
feat: Add sticky table of contents (#178)
Browse files Browse the repository at this point in the history
  • Loading branch information
gingerchew authored Jan 8, 2025
1 parent 7c9775e commit d34b440
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 15 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@types/react-dom": "^19.0.2",
"astro": "^5.1.2",
"astro-font": "^0.1.81",
"astro-github-file-loader": "^1.0.2",
"astro-github-file-loader": "^1.1.0",
"dayjs": "^1.11.13",
"fathom-client": "^3.7.2",
"react": "^19.0.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 158 additions & 0 deletions src/components/TableOfContents.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
---
import type { MarkdownHeading } from "astro";
interface Props {
headings?: MarkdownHeading[];
}
const { headings } = Astro.props;
const showTOC = headings && headings.length > 2;
---

{
showTOC && (
<nav class="toc" aria-labelledby="tocHeader">
<h2 class="toc-header" id="tocHeader">
On this page
</h2>
<table-of-contents>
<ul>
{headings.map(({ depth, slug, text }) => (
<li style={"--depth: " + depth}>
<a href={`#${slug}`}>{text}</a>
</li>
))}
</ul>
</table-of-contents>
</nav>
)
}

<script>
import { annotate } from "rough-notation";
import type { RoughAnnotation } from "rough-notation/lib/model";

customElements.define(
"table-of-contents",
class extends HTMLElement {
#io: IntersectionObserver;
#links: HTMLAnchorElement[];
#headings: HTMLHeadingElement[];
#annotations: Map<HTMLAnchorElement, RoughAnnotation>;
#activeIndex = -1;

constructor() {
super();
this.#annotations = new Map();

// 90% of the viewport, to expand subtract from this value to bring the bottom threshold lower
const ioBottomThreshold = window.innerHeight * 0.9;
this.#io = new IntersectionObserver(
(entries, _obs) => {
for (const entry of entries) {
const target = entry.target as HTMLHeadingElement;
const { boundingClientRect, intersectionRect, isIntersecting } =
entry;

if (
boundingClientRect.top < intersectionRect.bottom &&
isIntersecting
) {
this.#activeIndex = this.#headings.indexOf(target);
this.updateAnnotation();
/**
* Make sure it isn't intersecting so we aren't calling this
* when the heading is scrolling off screen
*/
} else if (
boundingClientRect.top > intersectionRect.bottom &&
!isIntersecting
) {
this.#activeIndex = this.#headings.indexOf(target) - 1;
this.updateAnnotation();
}
}
},
{
threshold: 0,
rootMargin: `0px 0px ${-ioBottomThreshold}px 0px`,
},
);

this.#headings = [];
this.#links = Array.from(this.querySelectorAll("a"), (link) => {
const { hash } = new URL(link.href);
const heading = document.querySelector<HTMLHeadingElement>(hash)!;

this.#headings.push(heading);

return link;
});
/**
* Observing the element runs the callback,
* this leads to the second to last item being annotated
*
* here we reverse the array, and observe them from bottom
* to top and avoid any annotations rendering when the
* user is at the top of the page
*/
this.#headings.toReversed().forEach((h) => this.#io.observe(h));
}

updateAnnotation() {
const activeHeading = this.#headings[this.#activeIndex];

this.#headings.forEach((h, i) => {
const annotation = this.getAnnotationFromIndex(i);

Object.is(h, activeHeading) ? annotation?.show() : annotation?.hide();
});
}

getAnnotationFromIndex(i: number): RoughAnnotation | null {
if (i < 0 || i > this.#links.length - 1) return null;
const item = this.#links[i];

if (!this.#annotations.has(item)) {
this.#annotations.set(
item,
annotate(item, {
type: "underline",
animate: false,
iterations: 1,
multiline: true,
padding: 0,
}),
);
}

return this.#annotations.get(item)!;
}
},
);
</script>

<style>
h2 {
font-size: var(--step-0);
}

ul {
list-style-type: none;
padding-inline-start: 0;
padding-block: var(--space-l);
display: flex;
flex-flow: column;
gap: var(--space-2xs);
}

li {
font-size: var(--step--1);
margin-inline-start: calc((var(--depth) - 2) * var(--space-m));
}

a {
text-decoration: 1px solid transparent;
}
</style>
76 changes: 73 additions & 3 deletions src/layouts/ProseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import PageHeader from "~/components/PageHeader.astro";
import type { NamesakeColor } from "~/data/colors";
import { smartquotes } from "../helpers/helpers";
import BaseLayout from "./BaseLayout.astro";
import type { MarkdownHeading } from "astro";
import TableOfContents from "~/components/TableOfContents.astro";
interface Props {
title: string;
Expand All @@ -13,10 +15,19 @@ interface Props {
ogAlt?: string;
annotation?: RoughAnnotationType;
color?: NamesakeColor;
headings?: MarkdownHeading[];
}
const { title, date, description, ogImage, ogAlt, annotation, color } =
Astro.props;
const {
title,
date,
description,
ogImage,
ogAlt,
annotation,
color,
headings,
} = Astro.props;
---

<BaseLayout
Expand All @@ -26,14 +37,19 @@ const { title, date, description, ogImage, ogAlt, annotation, color } =
ogAlt={ogAlt}
color={color}
>
<article itemscope itemtype="https://schema.org/BlogPosting">
<article
class="article-grid"
itemscope
itemtype="https://schema.org/BlogPosting"
>
<PageHeader
title={title}
date={date}
description={description && smartquotes(description)}
annotation={annotation}
/>
<slot name="after-header" />
<TableOfContents headings={headings} />
<section itemprop="articleBody" class="prose">
<slot />
</section>
Expand All @@ -42,9 +58,63 @@ const { title, date, description, ogImage, ogAlt, annotation, color } =
</BaseLayout>

<style lang="scss" is:global>
.article-grid {
display: grid;
grid-auto-rows: minmax(min-content, max-content);
column-gap: var(--space-m);
grid-template-columns: 1fr;
grid-template-areas:
"head"
"authors"
"toc"
"prose"
"bios";
@media (width >= 900px) {
grid-template-columns: 3fr 1fr;
grid-template-areas:
"head head"
"authors toc"
"prose toc"
"bios .";

.toc {
position: sticky;
top: var(--space-xl);
right: 0;
/* `display: grid` will extend the height of this past it's content, let's stop that */
max-height: fit-content;
}
}

.bios {
grid-area: bios;
}

.prose:not(.not-content) {
grid-area: prose;
}

.toc {
grid-area: toc;
}

.authors {
grid-area: authors;
}

.page-head {
grid-area: head;
}
}

.prose:not(.not-content) {
max-width: 720px;
margin-inline-end: auto;
grid-area: prose;

[id] {
scroll-margin-top: 1ex;
}

h2,
h3 {
Expand Down
3 changes: 2 additions & 1 deletion src/pages/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const { page } = Astro.props;
if (!page) return Astro.redirect("/404");
const { data } = page;
const { Content } = await render(page);
const { Content, headings } = await render(page);
---

<ProseLayout
Expand All @@ -30,6 +30,7 @@ const { Content } = await render(page);
ogAlt={data.ogImage?.alt}
annotation={data.annotation}
color={data.color}
headings={headings}
>
<Content />
</ProseLayout>
3 changes: 2 additions & 1 deletion src/pages/abuse.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import ProseLayout from "~/layouts/ProseLayout.astro";
const entry = await getEntry("policy", "abuse");
if (entry === undefined) return Astro.redirect("/404");
const { Content } = await render(entry);
const { Content, headings } = await render(entry);
---

<ProseLayout
title="Use Restrictions Policy"
description="It is not okay to use Namesake for these restricted purposes."
color="brown"
annotation="crossed-off"
headings={headings}
>
<Content />
</ProseLayout>
3 changes: 2 additions & 1 deletion src/pages/blog/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const { post } = Astro.props;
if (!post) return Astro.redirect("/404");
const { data } = post;
const { Content } = await render(post);
const { Content, headings } = await render(post);
const authors = await getEntries(data.authors);
---
Expand All @@ -37,6 +37,7 @@ const authors = await getEntries(data.authors);
description={data.description}
color="blue"
annotation="highlight"
headings={headings}
>
<div class="authors" slot="after-header">
{
Expand Down
3 changes: 2 additions & 1 deletion src/pages/privacy.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import ProseLayout from "~/layouts/ProseLayout.astro";
const entry = await getEntry("policy", "privacy");
if (entry === undefined) return Astro.redirect("/404");
const { Content } = await render(entry);
const { Content, headings } = await render(entry);
---

<ProseLayout
title="Privacy"
description="The privacy of your data—and it is your data, not ours!—is a big deal to us. Here’s the rundown of what we collect and why, when we access your information, and your rights."
color="brown"
headings={headings}
>
<Content />
</ProseLayout>
3 changes: 2 additions & 1 deletion src/pages/subprocessors.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import ProseLayout from "~/layouts/ProseLayout.astro";
const entry = await getEntry("policy", "subprocessors");
if (entry === undefined) return Astro.redirect("/404");
const { Content } = await render(entry);
const { Content, headings } = await render(entry);
---

<ProseLayout
title="Subprocessors"
description="All the third-party subprocessors that we use to run Namesake."
color="brown"
headings={headings}
>
<Content />
</ProseLayout>
Loading

0 comments on commit d34b440

Please sign in to comment.