Skip to content

Commit f2b54a6

Browse files
authored
feat: table of contents (#3997)
Deze PR introduceert een table of contents web component. Nog geen styling. closes: #3996
1 parent 30d2d73 commit f2b54a6

File tree

10 files changed

+323
-4
lines changed

10 files changed

+323
-4
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
// The list can not be filled in the template since Astro is not aware of
3+
// headings added in components. Therefore the list is being filled in
4+
// middleware when the whole page is rendered
5+
---
6+
7+
<h2 id="toc-label">Inhoudsopgave</h2>
8+
<ma-table-of-contents scope="main" heading-level="2">
9+
<ul aria-labelledby="toc-label">
10+
<li><span>Definition of Done</span></li>
11+
<li><a href="#community-implementaties">Community implementaties</a></li>
12+
<li><a href="#not-there">Missing Heading</a></li>
13+
</ul>
14+
</ma-table-of-contents>
15+
16+
<script src="./table-of-content.client.ts"></script>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import * as utils from './utils.client';
2+
3+
/**
4+
* A web component that generates a table of contents based on the headings in
5+
* a provided scope element. The generated list updates itself when a heading
6+
* is is added or removed from the scope
7+
*/
8+
class MaTableOfContents extends HTMLElement {
9+
static observedAttributes = ['scope', 'heading-level'];
10+
11+
#_level: utils.HeadingLevel = '2';
12+
#headingListItemMap: utils.HeadingToListItemsMap;
13+
#headingsInScope: Set<HTMLHeadingElement>;
14+
#labelHeadings: Set<HTMLHeadingElement>;
15+
#headingsToIgnore: Set<HTMLHeadingElement>;
16+
#listElement: HTMLUListElement | HTMLOListElement;
17+
#listItems: Set<HTMLLIElement>;
18+
#scopeRoot: HTMLElement;
19+
#observer: MutationObserver;
20+
21+
/** The id of the scope element */
22+
set scope(id: string) {
23+
if (this.getAttribute('scope') !== id) {
24+
this.setAttribute('scope-level', id);
25+
return;
26+
}
27+
28+
this.#scopeRoot = document.getElementById(id) || document.querySelector('main');
29+
30+
if (!this.#scopeRoot) throw new Error(`Could not locate scope element by id ${id}`);
31+
32+
if (this.isConnected) {
33+
this.#observer?.disconnect();
34+
this.#observer?.observe(this.#scopeRoot, { childList: true, subtree: true });
35+
}
36+
37+
this.update();
38+
}
39+
40+
/** The heading level to display in the table of contents */
41+
set headingLevel(level: utils.HeadingLevel) {
42+
if (this.getAttribute('heading-level') !== level) {
43+
this.setAttribute('heading-level', level);
44+
return;
45+
}
46+
47+
this.#_level = level;
48+
this.update();
49+
}
50+
51+
/** A Set of heading elements to ignore in the table of contents */
52+
set headingsToIgnore(value: Set<HTMLHeadingElement>) {
53+
this.#headingsToIgnore = value;
54+
this.update();
55+
}
56+
get headingsToIgnore() {
57+
return this.#headingsToIgnore;
58+
}
59+
60+
constructor() {
61+
super();
62+
this.#observer = this.setupMutationObserver();
63+
this.#headingListItemMap = new Map();
64+
this.#headingsToIgnore = new Set();
65+
}
66+
67+
attributeChangedCallback(name: string, _: string, newValue: string) {
68+
switch (name) {
69+
case 'scope':
70+
this.scope = newValue;
71+
break;
72+
case 'heading-level':
73+
this.headingLevel = newValue as utils.HeadingLevel;
74+
break;
75+
}
76+
}
77+
78+
connectedCallback() {
79+
this.#observer.observe(this.#scopeRoot, { childList: true, subtree: true });
80+
81+
this.#listElement = this.querySelector('ul,ol') || document.createElement('ul');
82+
if (!this.querySelector('ul,ol')) {
83+
this.appendChild(this.#listElement);
84+
}
85+
86+
this.#labelHeadings = new Set(
87+
this.#listElement?.['ariaLabelledByElements'].filter((element) => element instanceof HTMLHeadingElement) || [],
88+
);
89+
this.#headingsToIgnore = this.headingsToIgnore.union(this.#labelHeadings);
90+
this.update();
91+
}
92+
93+
disconnectedCallback() {
94+
this.#observer.disconnect();
95+
}
96+
97+
setupMutationObserver = () => {
98+
const callback = (mutationList: MutationRecord[]) => {
99+
let headingsAdded = false;
100+
let headingsRemoved = false;
101+
102+
for (const mutation of mutationList) {
103+
headingsAdded = Array.from(mutation.addedNodes).some((node) => node instanceof HTMLHeadingElement);
104+
headingsRemoved = Array.from(mutation.removedNodes).some((node) => node instanceof HTMLHeadingElement);
105+
}
106+
107+
if (headingsAdded || headingsRemoved) {
108+
this.update();
109+
}
110+
};
111+
112+
return new MutationObserver(callback);
113+
};
114+
115+
update = () => {
116+
if (!this.#_level || !this.#scopeRoot || !this.#listElement) return;
117+
118+
// get the current headings and listItems and update the map to reflect the
119+
// current state
120+
this.#headingsInScope = utils.getHeadingsInScope(this.#scopeRoot, this.#_level, this.#headingsToIgnore);
121+
this.#listItems = utils.getListItems(this.querySelector('ul'));
122+
utils.mapHeadingsToListItems(this.#headingsInScope, this.#listItems, this.#headingListItemMap);
123+
124+
// Loop over each heading in the map and create or update its listItems
125+
this.#headingListItemMap.forEach((listCollection, heading) => {
126+
if (heading instanceof utils.MissingHeading) return;
127+
if (listCollection.length === 0) {
128+
const li = utils.createListItemsForHeading(heading);
129+
listCollection.push(li);
130+
} else {
131+
listCollection.forEach((listItem) => utils.createListItemsForHeading(heading, listItem));
132+
}
133+
});
134+
135+
// (re)add the updated listItems
136+
this.#listElement.replaceChildren();
137+
this.#headingsInScope.forEach((heading) => {
138+
const li = this.#headingListItemMap.get(heading)?.[0];
139+
this.#listElement.appendChild(li);
140+
});
141+
142+
// If there are no headings in scope, hide the label headings
143+
if (this.#headingsInScope.size === 0) {
144+
this.hidden = true;
145+
this.#labelHeadings.forEach((heading) => (heading.hidden = true));
146+
} else {
147+
this.hidden = null;
148+
this.#labelHeadings.forEach((heading) => (heading.hidden = null));
149+
}
150+
151+
// clean up list items that point to headings that are no longer in scope
152+
this.#headingListItemMap.keys().forEach((key) => {
153+
if (key instanceof utils.MissingHeading) {
154+
this.#headingListItemMap.delete(key);
155+
}
156+
});
157+
};
158+
}
159+
160+
customElements.define('ma-table-of-contents', MaTableOfContents);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* An object to map list items to that are mis configured in the original markup
3+
*/
4+
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
5+
export class MissingHeading {}
6+
export const missingHeading = new MissingHeading();
7+
8+
export type HeadingLevel = '1' | '2' | '3' | '4' | '5' | '6';
9+
export type HeadingToListItemsMap = Map<HTMLHeadingElement | MissingHeading, HTMLLIElement[]>;
10+
11+
/**
12+
* Get all headings in the provided scope that have a matching heading level.
13+
*/
14+
export function getHeadingsInScope(
15+
scopeRoot: HTMLElement,
16+
headingLevel: HeadingLevel,
17+
ignore: Set<HTMLHeadingElement> = new Set(),
18+
) {
19+
const set: Set<HTMLHeadingElement> = new Set();
20+
scopeRoot.querySelectorAll(`h${headingLevel}`).forEach((heading) => {
21+
if (ignore.has(heading)) return;
22+
set.add(heading);
23+
});
24+
return set;
25+
}
26+
27+
/**
28+
* Get all ListItems of the listElement as a Set
29+
*/
30+
export function getListItems(listElement: HTMLUListElement | HTMLOListElement) {
31+
const set: Set<HTMLLIElement> = new Set();
32+
listElement.querySelectorAll('li').forEach((li) => set.add(li));
33+
return set;
34+
}
35+
36+
/**
37+
* Find the heading element in a Set of heading elements for a given ListItem.
38+
* If the list item contains an anchor link, use it's `href` to locate the
39+
* heading based on the `heading.id`. If that fails, try to match on the
40+
* innerText of both the heading and listitem. If that also fails, giveup and
41+
* consider the heading to be missing.
42+
*/
43+
export function findHeadingForListItem(listItem: HTMLLIElement, headings: Set<HTMLHeadingElement>) {
44+
let heading: HTMLHeadingElement;
45+
46+
const linkElement: HTMLAnchorElement | null = listItem.querySelector('a');
47+
heading = headings.values().find((heading) => heading.id === linkElement?.href.replace('#', ''));
48+
if (heading) return heading;
49+
50+
heading = headings.values().find((heading) => heading.innerText === listItem.innerText);
51+
if (heading) return heading;
52+
53+
return missingHeading;
54+
}
55+
56+
/**
57+
* Build a Map of heading elements to an array of ListItems. Useful for looking
58+
* up list items for a given heading element
59+
*/
60+
export function mapHeadingsToListItems(
61+
headings: Set<HTMLHeadingElement>,
62+
listItems: Set<HTMLLIElement>,
63+
map: HeadingToListItemsMap,
64+
) {
65+
[...headings, missingHeading].forEach((heading) => {
66+
if (map.has(heading) === false) {
67+
map.set(heading, []);
68+
}
69+
});
70+
71+
listItems.values().forEach((listItem) => {
72+
const heading = findHeadingForListItem(listItem, headings);
73+
map.get(heading).push(listItem);
74+
});
75+
}
76+
77+
/**
78+
* Create (or update) a ListItem based on a heading. If a listItem is provided,
79+
* update its properties to reflect the content of the heading.
80+
*/
81+
export function createListItemsForHeading(heading: HTMLHeadingElement, listItem?: HTMLLIElement) {
82+
const li: HTMLLIElement = listItem || document.createElement('li');
83+
84+
if (heading.id) {
85+
const link = li.querySelector('a') || document.createElement('a');
86+
link.href = `#${heading.id}`;
87+
link.innerText = heading.innerText;
88+
li.replaceChildren(link);
89+
} else {
90+
li.innerText = heading.innerText;
91+
}
92+
93+
return li;
94+
}

packages/website/src/middleware/csp.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export async function onRequest(context, next) {
3232
}),
3333
);
3434

35-
if (hashes.size) {
35+
if (cspRuleElement.length > 0 && hashes.size) {
3636
const cspRules = cspRuleElement
3737
.attr('content')
3838
.split(';')
@@ -41,7 +41,6 @@ export async function onRequest(context, next) {
4141
cspRule.startsWith('style-src') ? `${cspRule} ${[...hashes.values()].join(' ')} 'unsafe-hashes'` : cspRule,
4242
)
4343
.join('; ');
44-
4544
$(cspRuleElement).attr('content', cspRules);
4645
}
4746

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { sequence } from 'astro/middleware';
22
import { onRequest as csp } from './csp';
3+
import { onRequest as toc } from './table-of-contents';
34

4-
export const onRequest = sequence(csp);
5+
export const onRequest = sequence(csp, toc);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as cheerio from 'cheerio';
2+
3+
interface Item {
4+
label: string;
5+
id?: string;
6+
}
7+
8+
function buildTocItem(item: Item) {
9+
return item.id ? `<li><a href="#${item.id}">${item.label}</a></li>` : `<li>${item.label}</li>`;
10+
}
11+
12+
function buildToc(items: Item[]) {
13+
return items.map(buildTocItem).join('');
14+
}
15+
16+
export async function onRequest(context, next) {
17+
const response = await next();
18+
const html = await response.text();
19+
const $ = cheerio.load(html);
20+
21+
const tableOfContentsElement = $('ma-table-of-contents');
22+
23+
if (tableOfContentsElement.length) {
24+
const h2List = [];
25+
26+
$('main h2').each((index, element) => {
27+
const label = $(element).text();
28+
const id = $(element).attr('id');
29+
if (label.toLowerCase() !== 'inhoudsopgave') {
30+
h2List.push({ label, id });
31+
}
32+
});
33+
34+
$(tableOfContentsElement).find('ul').append(buildToc(h2List));
35+
}
36+
37+
return new Response($.html(), {
38+
status: 200,
39+
headers: response.headers,
40+
});
41+
}

packages/website/src/pages/[...component].astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getCollection } from 'astro:content';
33
import { render } from 'astro:content';
44
import Document from '@layouts/document.astro';
55
import { MDXComponents } from '@utils/mdx-components.astro';
6+
import TableOfContents from '@components/table-of-content/table-of-content.astro';
67
78
export async function getStaticPaths() {
89
const components = await getCollection('components');
@@ -30,5 +31,6 @@ const description =
3031
keywords={['component', page.data.title?.toLowerCase() || '', ...(page.data.keywords || [])]}
3132
index={page.data.unlisted !== true}
3233
>
34+
<TableOfContents />
3335
<Content components={MDXComponents} />
3436
</Document>

packages/website/src/pages/[...slug].astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getCollection } from 'astro:content';
33
import { render } from 'astro:content';
44
import Document from '@layouts/document.astro';
55
import { MDXComponents } from '@utils/mdx-components.astro';
6+
import TableOfContents from '@components/table-of-content/table-of-content.astro';
67
78
export async function getStaticPaths() {
89
const docs = await getCollection('docs');
@@ -27,5 +28,6 @@ const { Content } = await render(page);
2728
keywords={page.data.keywords}
2829
index={page.data.unlisted !== true}
2930
>
31+
<TableOfContents />
3032
<Content components={MDXComponents} />
3133
</Document>

packages/website/src/pages/[...wcag].astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getCollection } from 'astro:content';
33
import { render } from 'astro:content';
44
import Document from '@layouts/document.astro';
55
import { MDXComponents } from '@utils/mdx-components.astro';
6+
import TableOfContents from '@components/table-of-content/table-of-content.astro';
67
78
export async function getStaticPaths() {
89
const wcag = await getCollection('wcag');
@@ -30,5 +31,6 @@ const description =
3031
keywords={['wcag', page.data.title?.toLowerCase() || '', ...(page.data.keywords || [])]}
3132
index={page.data.unlisted !== true}
3233
>
34+
<TableOfContents />
3335
<Content components={MDXComponents} />
3436
</Document>

src/components/ComponentPage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ export const HelpImproveComponent = ({ component, headingLevel }: ComponentPageS
159159
return (
160160
component && (
161161
<>
162-
<Heading level={headingLevel}>Help om deze component te verbeteren</Heading>
162+
<Heading id="help-component-verbeteren" level={headingLevel}>
163+
Help om deze component te verbeteren
164+
</Heading>
163165
<Paragraph>
164166
We vinden het belangrijk dat de component {component.title} goed te gebruiken is door iedereen. Help je mee?
165167
</Paragraph>

0 commit comments

Comments
 (0)