Skip to content

Commit 9fb26a6

Browse files
committed
feat: add ToC web component
1 parent 7d295ec commit 9fb26a6

File tree

3 files changed

+267
-7
lines changed

3 files changed

+267
-7
lines changed
Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +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+
17
<h2 id="toc-label">Inhoudsopgave</h2>
2-
<ma-table-of-contents>
3-
<!--
4-
The list can not be filled in the template since Astro is not aware of
5-
headings added in components. Therefore the list is being filled in
6-
middleware when the whole page is rendered
7-
-->
8-
<ul aria-labelledby="toc-label"></ul>
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>
914
</ma-table-of-contents>
15+
16+
<script src="./table-of-content.client.ts"></script>
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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.#observer) {
33+
this.#observer.observe(this.#scopeRoot, { childList: true, subtree: true });
34+
}
35+
36+
this.update();
37+
}
38+
39+
/** The heading level to display in the table of contents */
40+
set headingLevel(level: utils.HeadingLevel) {
41+
if (this.getAttribute('heading-level') !== level) {
42+
this.setAttribute('heading-level', level);
43+
return;
44+
}
45+
46+
this.#_level = level;
47+
this.update();
48+
}
49+
50+
/** A Set of heading elements to ignore in the table of contents */
51+
set headingsToIgnore(value: Set<HTMLHeadingElement>) {
52+
this.#headingsToIgnore = value;
53+
this.update();
54+
}
55+
get headingsToIgnore() {
56+
return this.#headingsToIgnore;
57+
}
58+
59+
constructor() {
60+
super();
61+
this.#headingListItemMap = new Map();
62+
this.#headingsToIgnore = new Set();
63+
}
64+
65+
attributeChangedCallback(name: string, _: string, newValue: string) {
66+
switch (name) {
67+
case 'scope':
68+
this.scope = newValue;
69+
break;
70+
case 'heading-level':
71+
this.headingLevel = newValue as utils.HeadingLevel;
72+
break;
73+
}
74+
}
75+
76+
connectedCallback() {
77+
this.#observer = this.setupMutationObserver();
78+
this.#observer.observe(this.#scopeRoot, { childList: true, subtree: true });
79+
80+
this.#listElement = this.querySelector('ul,ol') || document.createElement('ul');
81+
if (!this.querySelector('ul,ol')) {
82+
this.appendChild(this.#listElement);
83+
}
84+
85+
this.#labelHeadings = new Set(
86+
this.#listElement?.['ariaLabelledByElements'].filter((element) => element instanceof HTMLHeadingElement) || [],
87+
);
88+
this.#headingsToIgnore = this.headingsToIgnore.union(this.#labelHeadings);
89+
this.update();
90+
}
91+
92+
disconnectedCallback() {
93+
this.#observer.disconnect();
94+
}
95+
96+
setupMutationObserver = () => {
97+
const callback = (mutationList: MutationRecord[]) => {
98+
let headingsAdded = false;
99+
let headingsRemoved = false;
100+
101+
for (const mutation of mutationList) {
102+
headingsAdded = Array.from(mutation.addedNodes).some((node) => node instanceof HTMLHeadingElement);
103+
headingsRemoved = Array.from(mutation.removedNodes).some((node) => node instanceof HTMLHeadingElement);
104+
}
105+
106+
if (headingsAdded || headingsRemoved) {
107+
this.update();
108+
}
109+
};
110+
111+
return new MutationObserver(callback);
112+
};
113+
114+
update = () => {
115+
if (!this.#_level || !this.#scopeRoot || !this.#listElement) return;
116+
117+
// get the current headings and listItems and update the map to reflect the
118+
// current state
119+
this.#headingsInScope = utils.getHeadingsInScope(this.#scopeRoot, this.#_level, this.#headingsToIgnore);
120+
this.#listItems = utils.getListItems(this.querySelector('ul'));
121+
utils.mapHeadingsToListItems(this.#headingsInScope, this.#listItems, this.#headingListItemMap);
122+
123+
// Loop over each heading in the map and create or update its listItems
124+
this.#headingListItemMap.forEach((listCollection, heading) => {
125+
if (heading instanceof utils.MissingHeading) return;
126+
if (listCollection.length === 0) {
127+
const li = utils.createListItemsForHeading(heading);
128+
listCollection.push(li);
129+
} else {
130+
listCollection.forEach((listItem) => utils.createListItemsForHeading(heading, listItem));
131+
}
132+
});
133+
134+
// (re)add the updated listItems
135+
this.#listElement.replaceChildren();
136+
this.#headingsInScope.forEach((heading) => {
137+
const li = this.#headingListItemMap.get(heading)?.[0];
138+
this.#listElement.appendChild(li);
139+
});
140+
141+
// If there are no headings in scope, hide the label headings
142+
if (this.#headingsInScope.size === 0) {
143+
this.hidden = true;
144+
this.#labelHeadings.forEach((heading) => (heading.hidden = true));
145+
} else {
146+
this.hidden = null;
147+
this.#labelHeadings.forEach((heading) => (heading.hidden = null));
148+
}
149+
150+
// clean up list items that point to headings that are no longer in scope
151+
this.#headingListItemMap.keys().forEach((key) => {
152+
if (key instanceof utils.MissingHeading) {
153+
this.#headingListItemMap.delete(key);
154+
}
155+
});
156+
};
157+
}
158+
159+
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+
}

0 commit comments

Comments
 (0)