blob: 6bb6b1c6bb7fb6cbef3ebb33c75394c910dfc593 [file] [log] [blame]
/**
* @license
* Copyright 2024 The Go Authors. All rights reserved.
* Use of this source code is governed by a BSD-style
* license that can be found in the LICENSE file.
*/
/**
* A treeNavController adds dynamic expansion and selection of index list
* elements based on scroll position.
*
* Use it as follows:
* - Add the .js-Tree class to a parent element of your index and content.
* - Add the .js-Tree-item class to <li> elements of your index.
* - Add the .js-Tree-heading class to <hN> heading elements of your content.
*
* Then, when you scroll content, the 'aria-selected' and 'aria-expanded'
* attributes of your tree items will be set according to the current content
* scroll position. The included treenav.css implements styling to expand and
* highlight index elements according to these attributes.
*/
export function treeNavController(el: HTMLElement) {
const headings = el.querySelectorAll<HTMLHeadingElement>(".js-Tree-heading");
const callback = () => {
// Collect heading elements above the scroll position.
let above: HTMLHeadingElement[] = [];
for (const h of headings) {
const rect = h.getBoundingClientRect();
if (rect.height && rect.top < 80) {
above.unshift(h);
}
}
// Highlight the first heading even if we're not yet scrolled below it.
if (above.length == 0 && headings[0] instanceof HTMLHeadingElement) {
above = [headings[0]];
}
// Collect the set of heading levels we're immediately below, at most one
// per heading level, by decresing level.
// e.g. [<h3 element>, <h2 element>, <h1 element>]
let threshold = Infinity;
const active: HTMLHeadingElement[] = [];
for (const h of above) {
const level = Number(h.tagName[1]);
if (level < threshold) {
threshold = level;
active.push(h);
}
}
// Update aria-selected and aria-expanded for all items, per the current
// position.
const navItems = el.querySelectorAll<HTMLElement>(".js-Tree-item");
for (const item of navItems) {
const headingId = item.dataset["headingId"];
let selected = false,
expanded = false;
for (const h of active) {
if (h.id === headingId) {
if (h === active[0]) {
selected = true;
} else {
expanded = true;
}
break;
}
}
item.setAttribute("aria-selected", selected ? "true" : "false");
item.setAttribute("aria-expanded", expanded ? "true" : "false");
}
};
// Update on changes to viewport intersection, defensively debouncing to
// guard against performance issues.
const observer = new IntersectionObserver(debounce(callback, 20));
for (const h of headings) {
observer.observe(h);
}
}
export function debounce<T extends (...args: unknown[]) => unknown>(
callback: T,
wait: number
) {
let timeout: number;
return (...args: unknown[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => callback(...args), wait);
};
}