Skip to content

Commit

Permalink
Add heading level hierarchy to table of contents (#2101)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
Closes #2099
  • Loading branch information
chenxsan authored Nov 29, 2022
1 parent 627867c commit 58e78f3
Show file tree
Hide file tree
Showing 10 changed files with 139 additions and 51 deletions.
20 changes: 11 additions & 9 deletions public/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -485,21 +485,23 @@ h2.heading {
transition: border-inline-start-color 100ms ease-out, background-color 200ms ease-out;
}

.header-link a {
a.header-link {
display: inline-flex;
gap: 0.5em;
width: 100%;
font: inherit;
padding: 0.4rem 0;
padding-top: 0.4rem;
padding-bottom: 0.4rem;
line-height: 1.3;
color: var(--theme-text-lighter);
text-decoration: none;
unicode-bidi: plaintext;
}

@media (min-width: 50em) {
.header-link a {
padding: 0.275rem 0;
a.header-link {
padding-top: 0.275rem;
padding-bottom: 0.275rem;
}
}

Expand All @@ -509,8 +511,8 @@ h2.heading {
border-inline-start-color: var(--theme-accent-secondary);
}

.header-link:hover a,
.header-link a:focus {
a.header-link:hover,
a.header-link:focus {
color: var(--theme-text);
text-decoration: underline;
}
Expand Down Expand Up @@ -544,19 +546,19 @@ h2.heading {
}

/* Highlight TOC header link matching the current scroll position */
.current-header-link {
a.current-header-link {
background-color: var(--theme-bg-accent);
/* Indicates the current heading for forced colors users in older browsers */
outline: 1px solid transparent;
}

@media (forced-colors: active) {
.current-header-link {
a.current-header-link {
border: 1px solid CanvasText;
}
}

.current-header-link a {
a.current-header-link {
color: var(--theme-text);
}

Expand Down
16 changes: 8 additions & 8 deletions src/components/RightSidebar/CommunityMenu.astro
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ const HeadingWrapper = hideOnLargerScreens ? 'summary' : 'div';
}
</HeadingWrapper>
<ul class="items">
<li class="header-link">
<a href="https://astro.build/chat" target="_blank">
<li>
<a class="header-link" href="https://astro.build/chat" target="_blank">
<svg
aria-hidden="true"
focusable="false"
Expand All @@ -58,8 +58,8 @@ const HeadingWrapper = hideOnLargerScreens ? 'summary' : 'div';
<span><UIString key="rightSidebar.joinDiscord" /></span>
</a>
</li>
<li class="header-link">
<a href="https://astro.build/blog/" target="_blank">
<li>
<a class="header-link" href="https://astro.build/blog/" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
Expand All @@ -77,8 +77,8 @@ const HeadingWrapper = hideOnLargerScreens ? 'summary' : 'div';
<span><UIString key="rightSidebar.readBlog" /></span>
</a>
</li>
<li class="header-link">
<a href="https://opencollective.com/astrodotbuild" target="_blank">
<li>
<a class="header-link" href="https://opencollective.com/astrodotbuild" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
Expand All @@ -100,8 +100,8 @@ const HeadingWrapper = hideOnLargerScreens ? 'summary' : 'div';
<span><UIString key="rightSidebar.openCollective" /></span>
</a>
</li>
<li class="header-link">
<a href="https://github.com/withastro/docs" target="_blank">
<li>
<a class="header-link" href="https://github.com/withastro/docs" target="_blank">
<svg
aria-hidden="true"
focusable="false"
Expand Down
10 changes: 7 additions & 3 deletions src/components/RightSidebar/ContributeMenu.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ const isTranslatable = (lang === 'en' || isFallback) && i18nReady;

<h2 class="heading"><UIString key="rightSidebar.contribute" /></h2>
<ul>
<li class={`header-link depth-2`}>
<li>
<EditButton editHref={editHref} />
</li>
{
isTranslatable && (
<li class={`header-link depth-2`}>
<a href="https://github.com/withastro/docs/blob/main/TRANSLATING.md" target="_blank">
<li>
<a
class={`header-link depth-2`}
href="https://github.com/withastro/docs/blob/main/TRANSLATING.md"
target="_blank"
>
<svg
aria-hidden="true"
focusable="false"
Expand Down
2 changes: 1 addition & 1 deletion src/components/RightSidebar/EditButton.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import UIString from '../UIString.astro';
const { editHref } = Astro.props;
---

<a class="edit-on-github" href={editHref} target="_blank">
<a class="edit-on-github header-link depth-2" href={editHref} target="_blank">
<svg
aria-hidden="true"
focusable="false"
Expand Down
6 changes: 4 additions & 2 deletions src/components/RightSidebar/RightSidebar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import TableOfContents from './TableOfContents';
import ContributeMenu from './ContributeMenu.astro';
import CommunityMenu from './CommunityMenu.astro';
import { useTranslations } from '../../i18n/util';
import generateToc from '../../util/generateToc';
const t = useTranslations(Astro);
const { content, githubEditUrl } = Astro.props;
const headings = content.astro?.headings;
const overview = t('rightSidebar.overview');
---

<nav aria-label={t('rightSidebar.a11yTitle')}>
{
headings && (
<TableOfContents
client:media="(min-width: 50em)"
headings={headings}
labels={{ onThisPage: t('rightSidebar.onThisPage'), overview: t('rightSidebar.overview') }}
toc={generateToc(headings, overview)}
labels={{ onThisPage: t('rightSidebar.onThisPage') }}
isMobile={false}
/>
)
Expand Down
2 changes: 1 addition & 1 deletion src/components/RightSidebar/TableOfContents.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
}
}

.toc-mobile-container ul {
.toc-mobile-container ul.toc-root {
margin-inline: var(--min-spacing-inline);
max-height: calc(
var(--cur-viewport-height) - var(--theme-navbar-height) - var(--theme-mobile-toc-height) - 1rem
Expand Down
69 changes: 47 additions & 22 deletions src/components/RightSidebar/TableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import { unescape } from 'html-escaper';
import type { FunctionalComponent } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import { useState, useEffect } from 'preact/hooks';
import './TableOfContents.css';
import type { TocItem } from '../../util/generateToc';

interface Props {
headings: { depth: number; slug: string; text: string }[];
toc: TocItem[];
labels: {
onThisPage: string;
overview: string;
};
isMobile?: boolean;
}

const TableOfContents: FunctionalComponent<Props> = ({ headings = [], labels, isMobile }) => {
headings = [{ depth: 2, slug: 'overview', text: labels.overview }, ...headings].filter(
({ depth }) => depth > 1 && depth < 4
);
const [currentID, setCurrentID] = useState('overview');
const TableOfContents: FunctionalComponent<Props> = ({
toc = [],
labels,
isMobile,
}) => {
const [currentHeading, setCurrentHeading] = useState({
slug: toc[0].slug,
text: toc[0].text
});
const [open, setOpen] = useState(!isMobile);
const onThisPageID = 'on-this-page-heading';

Expand All @@ -31,7 +35,6 @@ const TableOfContents: FunctionalComponent<Props> = ({ headings = [], labels, is
};

const HeadingContainer = ({ children }) => {
const currentHeading = headings.find(({ slug }) => slug === currentID);
return isMobile ? (
<summary class="toc-mobile-header">
<div class="toc-mobile-header-content">
Expand Down Expand Up @@ -66,7 +69,10 @@ const TableOfContents: FunctionalComponent<Props> = ({ headings = [], labels, is
if (entry.isIntersecting) {
const { id } = entry.target;
if (id === onThisPageID) continue;
setCurrentID(entry.target.id);
setCurrentHeading({
slug: entry.target.id,
text: entry.target.textContent || ''
});
break;
}
}
Expand All @@ -91,7 +97,34 @@ const TableOfContents: FunctionalComponent<Props> = ({ headings = [], labels, is
const onLinkClick = (e) => {
if (!isMobile) return;
setOpen(false);
setCurrentID(e.target.getAttribute('href').replace('#', ''));
setCurrentHeading({
slug: e.target.getAttribute('href').replace('#', ''),
text: e.target.textContent || ''
});
};

const TableOfContentsItem: FunctionalComponent<{ heading: TocItem }> = ({ heading }) => {
const { depth, slug, text, children } = heading;
return (
<li>
<a
class={`header-link depth-${depth} ${
currentHeading.slug === slug ? 'current-header-link' : ''
}`.trim()}
href={`#${slug}`}
onClick={onLinkClick}
>
{unescape(text)}
</a>
{children.length > 0 ? (
<ul>
{children.map((heading) => (
<TableOfContentsItem key={heading.slug} heading={heading} />
))}
</ul>
) : null}
</li>
);
};

return (
Expand All @@ -101,17 +134,9 @@ const TableOfContents: FunctionalComponent<Props> = ({ headings = [], labels, is
{labels.onThisPage}
</h2>
</HeadingContainer>
<ul>
{headings.map(({ depth, slug, text }) => (
<li
class={`header-link depth-${depth} ${
currentID === slug ? 'current-header-link' : ''
}`.trim()}
>
<a href={`#${slug}`} onClick={onLinkClick}>
{unescape(text)}
</a>
</li>
<ul class="toc-root">
{toc.map((heading) => (
<TableOfContentsItem key={heading.slug} heading={heading} />
))}
</ul>
</Container>
Expand Down
9 changes: 6 additions & 3 deletions src/components/tutorial/TutorialNav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ const isCurrentUnit = (unit: typeof units[number]) =>
<ol class="lessons">
{unit.lessons.map(async (lesson, index) => (
<li>
<div class="header-link">
<div>
<a
class="header-link"
href={`/${lang}/${lesson.slug}/`}
aria-current={currentUrl.endsWith(lesson.slug)}
>
Expand All @@ -53,8 +54,10 @@ const isCurrentUnit = (unit: typeof units[number]) =>
{(await lesson.getHeadings())
.filter(({ depth }) => depth <= 2)
.map((h) => (
<li class="header-link">
<a href={'#' + h.slug}>{h.text}</a>
<li>
<a class="header-link" href={'#' + h.slug}>
{h.text}
</a>
</li>
))}
</ul>
Expand Down
5 changes: 3 additions & 2 deletions src/layouts/MainLayout.astro
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---
import generateToc from '~/util/generateToc';
import PageContent from '../components/PageContent/PageContent.astro';
import RightSidebar from '../components/RightSidebar/RightSidebar.astro';
import TableOfContents from '../components/RightSidebar/TableOfContents';
Expand All @@ -12,6 +13,7 @@ const headings = content.astro?.headings;
const t = useTranslations(Astro);
const { previous, next } = await getNavLinks(Astro);
const githubEditUrl = getGithubEditUrl(Astro);
const overview = t('rightSidebar.overview')
---

<BaseLayout {...Astro.props}>
Expand All @@ -23,10 +25,9 @@ const githubEditUrl = getGithubEditUrl(Astro);
<nav>
<TableOfContents
client:media="(max-width: 72em)"
headings={headings}
toc={generateToc(headings, overview)}
labels={{
onThisPage: t('rightSidebar.onThisPage'),
overview: t('rightSidebar.overview'),
}}
isMobile={true}
/>
Expand Down
51 changes: 51 additions & 0 deletions src/util/generateToc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { MarkdownHeading } from 'astro';
export interface TocItem extends MarkdownHeading {
children: TocItem[];
}

function diveChildren(item: TocItem, depth: number): TocItem[] {
if (depth === 1) {
return item.children;
} else {
// e.g., 2
return diveChildren(item.children[item.children.length - 1], depth - 1);
}
}

export default function generateToc(headings: MarkdownHeading[], title = 'Overview') {
const overview = { depth: 2, slug: 'overview', text: title };
headings = [overview, ...headings.filter(({ depth }) => depth > 1 && depth < 4)];
const toc: Array<TocItem> = [];

for (const heading of headings) {
if (toc.length === 0) {
toc.push({
...heading,
children: [],
});
} else {
const lastItemInToc = toc[toc.length - 1];
if (heading.depth < lastItemInToc.depth) {
console.log(headings);
throw new Error(`Orphan heading found: ${heading.text}.`);
}
if (heading.depth === lastItemInToc.depth) {
// same depth
toc.push({
...heading,
children: [],
});
} else {
// higher depth
// push into children, or children' children alike
const gap = heading.depth - lastItemInToc.depth;
const target = diveChildren(lastItemInToc, gap);
target.push({
...heading,
children: [],
});
}
}
}
return toc
}

0 comments on commit 58e78f3

Please sign in to comment.