blob: fbacbe0119197b82b65e319d3ce07b349f0cdbd4 [file] [log] [blame]
/**
* @license
* Copyright 2021 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.
*/
/**
* TreeNavController is the navigation tree component of the documentation page.
* It adds accessiblity attributes to a tree, observes the heading elements
* focus the topmost link for headings visible on the page, and implements the
* WAI-ARIA Treeview Design Pattern with full
* [keyboard support](https://www.w3.org/TR/wai-aria-practices/examples/treeview/treeview-2/treeview-2a.html#kbd_label).
*/
export class TreeNavController {
treeitems: TreeItem[];
/**
* firstChars is the first character of each treeitem in the same order
* as this.treeitems. We use this array to set focus by character when
* navigating the tree with a keyboard.
*/
private firstChars: string[];
private firstTreeitem: TreeItem | null;
private lastTreeitem: TreeItem | null;
private observerCallbacks: ((t: TreeItem) => void)[];
constructor(private el: HTMLElement) {
this.treeitems = [];
this.firstChars = [];
this.firstTreeitem = null;
this.lastTreeitem = null;
this.observerCallbacks = [];
this.init();
}
private init(): void {
this.handleResize();
window.addEventListener('resize', this.handleResize);
this.findTreeItems();
this.updateVisibleTreeitems();
this.observeTargets();
if (this.firstTreeitem) {
this.firstTreeitem.el.tabIndex = 0;
}
}
private handleResize = (): void => {
this.el.style.setProperty('--js-tree-height', '100vh');
this.el.style.setProperty('--js-tree-height', this.el.clientHeight + 'px');
};
private observeTargets() {
this.addObserver(treeitem => {
this.expandTreeitem(treeitem);
this.setSelected(treeitem);
// TODO: Fix scroll issue in https://golang.org/issue/47450.
// treeitem.el.scrollIntoView({ block: 'nearest' });
});
const targets = new Map<string, boolean>();
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
targets.set(entry.target.id, entry.isIntersecting || entry.intersectionRatio === 1);
}
for (const [id, isIntersecting] of targets) {
if (isIntersecting) {
const active = this.treeitems.find(t =>
(t.el as HTMLAnchorElement)?.href.endsWith(`#${id}`)
);
if (active) {
for (const fn of this.observerCallbacks) {
fn(active);
}
}
break;
}
}
},
{
threshold: 1.0,
rootMargin: '-60px 0px 0px 0px',
}
);
for (const href of this.treeitems.map(t => t.el.getAttribute('href'))) {
if (href) {
const id = href.replace(window.location.origin, '').replace('/', '').replace('#', '');
const target = document.getElementById(id);
if (target) {
observer.observe(target);
}
}
}
}
addObserver(fn: (t: TreeItem) => void, delay = 200): void {
this.observerCallbacks.push(debounce(fn, delay));
}
setFocusToNextItem(currentItem: TreeItem): void {
let nextItem = null;
for (let i = currentItem.index + 1; i < this.treeitems.length; i++) {
const ti = this.treeitems[i];
if (ti.isVisible) {
nextItem = ti;
break;
}
}
if (nextItem) {
this.setFocusToItem(nextItem);
}
}
setFocusToPreviousItem(currentItem: TreeItem): void {
let prevItem = null;
for (let i = currentItem.index - 1; i > -1; i--) {
const ti = this.treeitems[i];
if (ti.isVisible) {
prevItem = ti;
break;
}
}
if (prevItem) {
this.setFocusToItem(prevItem);
}
}
setFocusToParentItem(currentItem: TreeItem): void {
if (currentItem.groupTreeitem) {
this.setFocusToItem(currentItem.groupTreeitem);
}
}
setFocusToFirstItem(): void {
this.firstTreeitem && this.setFocusToItem(this.firstTreeitem);
}
setFocusToLastItem(): void {
this.lastTreeitem && this.setFocusToItem(this.lastTreeitem);
}
setSelected(currentItem: TreeItem): void {
for (const l1 of this.el.querySelectorAll('[aria-expanded="true"]')) {
if (l1 === currentItem.el) continue;
if (!l1.nextElementSibling?.contains(currentItem.el)) {
l1.setAttribute('aria-expanded', 'false');
}
}
for (const l1 of this.el.querySelectorAll('[aria-selected]')) {
if (l1 !== currentItem.el) {
l1.setAttribute('aria-selected', 'false');
}
}
currentItem.el.setAttribute('aria-selected', 'true');
this.updateVisibleTreeitems();
this.setFocusToItem(currentItem, false);
}
expandTreeitem(treeitem: TreeItem): void {
let currentItem: TreeItem | null = treeitem;
while (currentItem) {
if (currentItem.isExpandable) {
currentItem.el.setAttribute('aria-expanded', 'true');
}
currentItem = currentItem.groupTreeitem;
}
this.updateVisibleTreeitems();
}
expandAllSiblingItems(currentItem: TreeItem): void {
for (const ti of this.treeitems) {
if (ti.groupTreeitem === currentItem.groupTreeitem && ti.isExpandable) {
this.expandTreeitem(ti);
}
}
}
collapseTreeitem(currentItem: TreeItem): void {
let groupTreeitem = null;
if (currentItem.isExpanded()) {
groupTreeitem = currentItem;
} else {
groupTreeitem = currentItem.groupTreeitem;
}
if (groupTreeitem) {
groupTreeitem.el.setAttribute('aria-expanded', 'false');
this.updateVisibleTreeitems();
this.setFocusToItem(groupTreeitem);
}
}
setFocusByFirstCharacter(currentItem: TreeItem, char: string): void {
let start: number, index: number;
char = char.toLowerCase();
// Get start index for search based on position of currentItem
start = currentItem.index + 1;
if (start === this.treeitems.length) {
start = 0;
}
// Check remaining slots in the menu
index = this.getIndexFirstChars(start, char);
// If not found in remaining slots, check from beginning
if (index === -1) {
index = this.getIndexFirstChars(0, char);
}
// If match was found...
if (index > -1) {
this.setFocusToItem(this.treeitems[index]);
}
}
private findTreeItems() {
const findItems = (el: HTMLElement, group: TreeItem | null) => {
let ti = group;
let curr = el.firstElementChild as HTMLElement;
while (curr) {
if (curr.tagName === 'A' || curr.tagName === 'SPAN') {
ti = new TreeItem(curr, this, group);
this.treeitems.push(ti);
this.firstChars.push(ti.label.substring(0, 1).toLowerCase());
}
if (curr.firstElementChild) {
findItems(curr, ti);
}
curr = curr.nextElementSibling as HTMLElement;
}
};
findItems(this.el as HTMLElement, null);
this.treeitems.map((ti, idx) => (ti.index = idx));
}
private updateVisibleTreeitems(): void {
this.firstTreeitem = this.treeitems[0];
for (const ti of this.treeitems) {
let parent = ti.groupTreeitem;
ti.isVisible = true;
while (parent && parent.el !== this.el) {
if (!parent.isExpanded()) {
ti.isVisible = false;
}
parent = parent.groupTreeitem;
}
if (ti.isVisible) {
this.lastTreeitem = ti;
}
}
}
private setFocusToItem(treeitem: TreeItem, focusEl = true) {
treeitem.el.tabIndex = 0;
if (focusEl) {
treeitem.el.focus();
}
for (const ti of this.treeitems) {
if (ti !== treeitem) {
ti.el.tabIndex = -1;
}
}
}
private getIndexFirstChars(startIndex: number, char: string): number {
for (let i = startIndex; i < this.firstChars.length; i++) {
if (this.treeitems[i].isVisible && char === this.firstChars[i]) {
return i;
}
}
return -1;
}
}
class TreeItem {
el: HTMLElement;
groupTreeitem: TreeItem | null;
label: string;
isExpandable: boolean;
isVisible: boolean;
depth: number;
index: number;
private tree: TreeNavController;
private isInGroup: boolean;
constructor(el: HTMLElement, treeObj: TreeNavController, group: TreeItem | null) {
el.tabIndex = -1;
this.el = el;
this.groupTreeitem = group;
this.label = el.textContent?.trim() ?? '';
this.tree = treeObj;
this.depth = (group?.depth || 0) + 1;
this.index = 0;
const parent = el.parentElement;
if (parent?.tagName.toLowerCase() === 'li') {
parent?.setAttribute('role', 'none');
}
el.setAttribute('aria-level', this.depth + '');
if (el.getAttribute('aria-label')) {
this.label = el?.getAttribute('aria-label')?.trim() ?? '';
}
this.isExpandable = false;
this.isVisible = false;
this.isInGroup = !!group;
let curr = el.nextElementSibling;
while (curr) {
if (curr.tagName.toLowerCase() == 'ul') {
const groupId = `${group?.label ?? ''} nav group ${this.label}`.replace(/[\W_]+/g, '_');
el.setAttribute('aria-owns', groupId);
el.setAttribute('aria-expanded', 'false');
curr.setAttribute('role', 'group');
curr.setAttribute('id', groupId);
this.isExpandable = true;
break;
}
curr = curr.nextElementSibling;
}
this.init();
}
private init() {
this.el.tabIndex = -1;
if (!this.el.getAttribute('role')) {
this.el.setAttribute('role', 'treeitem');
}
this.el.addEventListener('keydown', this.handleKeydown.bind(this));
this.el.addEventListener('click', this.handleClick.bind(this));
this.el.addEventListener('focus', this.handleFocus.bind(this));
this.el.addEventListener('blur', this.handleBlur.bind(this));
}
isExpanded() {
if (this.isExpandable) {
return this.el.getAttribute('aria-expanded') === 'true';
}
return false;
}
isSelected() {
return this.el.getAttribute('aria-selected') === 'true';
}
private handleClick(event: MouseEvent) {
// only process click events that directly happened on this treeitem
if (event.target !== this.el && event.target !== this.el.firstElementChild) {
return;
}
if (this.isExpandable) {
if (this.isExpanded() && this.isSelected()) {
this.tree.collapseTreeitem(this);
} else {
this.tree.expandTreeitem(this);
}
event.stopPropagation();
}
this.tree.setSelected(this);
}
private handleFocus() {
let el = this.el;
if (this.isExpandable) {
el = (el.firstElementChild as HTMLElement) ?? el;
}
el.classList.add('focus');
}
private handleBlur() {
let el = this.el;
if (this.isExpandable) {
el = (el.firstElementChild as HTMLElement) ?? el;
}
el.classList.remove('focus');
}
private handleKeydown(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.metaKey) {
return;
}
let captured = false;
switch (event.key) {
case ' ':
case 'Enter':
if (this.isExpandable) {
if (this.isExpanded() && this.isSelected()) {
this.tree.collapseTreeitem(this);
} else {
this.tree.expandTreeitem(this);
}
captured = true;
} else {
event.stopPropagation();
}
this.tree.setSelected(this);
break;
case 'ArrowUp':
this.tree.setFocusToPreviousItem(this);
captured = true;
break;
case 'ArrowDown':
this.tree.setFocusToNextItem(this);
captured = true;
break;
case 'ArrowRight':
if (this.isExpandable) {
if (this.isExpanded()) {
this.tree.setFocusToNextItem(this);
} else {
this.tree.expandTreeitem(this);
}
}
captured = true;
break;
case 'ArrowLeft':
if (this.isExpandable && this.isExpanded()) {
this.tree.collapseTreeitem(this);
captured = true;
} else {
if (this.isInGroup) {
this.tree.setFocusToParentItem(this);
captured = true;
}
}
break;
case 'Home':
this.tree.setFocusToFirstItem();
captured = true;
break;
case 'End':
this.tree.setFocusToLastItem();
captured = true;
break;
default:
if (event.key.length === 1 && event.key.match(/\S/)) {
if (event.key == '*') {
this.tree.expandAllSiblingItems(this);
} else {
this.tree.setFocusByFirstCharacter(this, event.key);
}
captured = true;
}
break;
}
if (captured) {
event.stopPropagation();
event.preventDefault();
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => any>(func: T, wait: number) {
let timeout: ReturnType<typeof setTimeout> | null;
return (...args: Parameters<T>) => {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}