Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add sticky table of contents #178

Merged
merged 37 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7a04ced
Adds remark-toc remark plugin
gingerchew Dec 24, 2024
a6b34dc
Adds the necessary content header to the content
gingerchew Dec 24, 2024
7f27f98
Undoes changes that add a table of contents to the wrong page
gingerchew Dec 24, 2024
119774f
Adds TableOfContents component to ProseLayout
gingerchew Dec 24, 2024
953a090
Removes ## Contents heading now that we're just using an astro component
gingerchew Dec 24, 2024
d99d2cb
Adds headings to the destructured render object
gingerchew Dec 24, 2024
f444920
Adds grid to ProseLayout
gingerchew Dec 24, 2024
2551cbf
Removes remark-toc devDep
gingerchew Dec 24, 2024
3bed7a3
Adds intersectionobserver based animation
gingerchew Dec 24, 2024
eb73e04
passes in headings where ProseLayout is used
gingerchew Jan 5, 2025
831ecf4
Removes the dynamic annotation from the frontmatter
gingerchew Jan 5, 2025
24d1593
Adds `<nav>` and proper aria labelling
gingerchew Jan 5, 2025
f6d209b
Removes unneccessary styling for :hover,:focus states
gingerchew Jan 5, 2025
572f84f
Updates position: sticky code to be more responsive
gingerchew Jan 5, 2025
228f616
Removes the dynamic annotation config from the clientside js
gingerchew Jan 5, 2025
4ac11bc
Fixes nit about new lines between CSS declarations
gingerchew Jan 5, 2025
6cd768f
Removes TableOfContents import
gingerchew Jan 5, 2025
414b8ac
Updates how the annotation is activated
gingerchew Jan 5, 2025
e0a8ca3
Merge branch 'main' into create-toc-in-prose-layout-167
gingerchew Jan 6, 2025
bb53940
Adds headings to pages that use ProseLayout
gingerchew Jan 6, 2025
7542ccc
Updates astro-github-file-loader
gingerchew Jan 6, 2025
c808e2c
Adds missing id to h2 inside of the toc nav element
gingerchew Jan 6, 2025
78bbc89
Merge branch 'main' into create-toc-in-prose-layout-167
gingerchew Jan 6, 2025
df141c0
Adds some scroll-margin-top to the `[id]` selector
gingerchew Jan 7, 2025
4128f7c
Fixes font size of toc title
gingerchew Jan 7, 2025
3907d59
Reworks how the annotation animations are called
gingerchew Jan 7, 2025
cedf125
Removes console.log
gingerchew Jan 7, 2025
831a729
Moves the sticky code for toc into ProseLayout
gingerchew Jan 7, 2025
b1863da
Uses new media query syntax
gingerchew Jan 7, 2025
ea0c585
Updates name of Web Component
gingerchew Jan 7, 2025
ae4d1e1
Changes #items variable name
gingerchew Jan 7, 2025
af5aa74
Removes get/set for activeIndex
gingerchew Jan 7, 2025
414c563
Updates the rest of the #items to use #links instead
gingerchew Jan 7, 2025
d0782f6
Adds spacing nit
gingerchew Jan 7, 2025
1982963
Fixes TOC annotation logic
gingerchew Jan 8, 2025
6cce883
Moves heading check logic into the TableOfContents component
gingerchew Jan 8, 2025
d93e12a
Adds extra check to the second half of the logic
gingerchew Jan 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
---
evadecker marked this conversation as resolved.
Show resolved Hide resolved
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(
evadecker marked this conversation as resolved.
Show resolved Hide resolved
"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() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧑‍🍳👌

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image
love to see well abstracted code

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";
gingerchew marked this conversation as resolved.
Show resolved Hide resolved
@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;
gingerchew marked this conversation as resolved.
Show resolved Hide resolved
}

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);
evadecker marked this conversation as resolved.
Show resolved Hide resolved
---

<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}
evadecker marked this conversation as resolved.
Show resolved Hide resolved
>
<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
Loading