Skip to content

Commit e327d0d

Browse files
committed
feat: add ToC web component
1 parent 79b1ec7 commit e327d0d

File tree

3 files changed

+260
-7
lines changed

3 files changed

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