blob: c816538f34454cd1a65a3ee46644d6474c8a1ba1 [file] [log] [blame]
/**
* @license
* Copyright 2019-2020 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.
*/
/**
* Possible KeyboardEvent key values.
* @private @enum {string}
*/
const Key = {
UP: 'ArrowUp',
DOWN: 'ArrowDown',
LEFT: 'ArrowLeft',
RIGHT: 'ArrowRight',
ENTER: 'Enter',
ASTERISK: '*',
SPACE: ' ',
END: 'End',
HOME: 'Home',
// Global keyboard shortcuts.
// TODO(golang.org/issue/40246): consolidate keyboard shortcut handling to avoid
// this duplication.
Y: 'y',
FORWARD_SLASH: '/',
QUESTION_MARK: '?',
};
/**
* The navigation tree component of the documentation page.
*/
class DocNavTreeController {
/**
* Instantiates a navigation tree.
* @param {!Element} el
*/
constructor(el) {
/** @private {!Element} */
this._el = el;
/**
* The currently selected element.
* @private {Element}
*/
this._selectedEl = null;
/**
* The index of the currently focused item. Used when navigating the tree
* using the keyboard.
* @private {number}
*/
this._focusedIndex = 0;
/**
* The elements currently visible (not within a collapsed node of the tree).
* @private {!Array<!Element>}
*/
this._visibleItems = [];
/**
* The current search string.
* @private {string}
*/
this._searchString = '';
/**
* The timestamp of the last keydown event. Used to track whether to use the
* current search string.
* @private {number}
*/
this._lastKeyDownTimeStamp = -Infinity;
this.addEventListeners();
this.updateVisibleItems();
this.initialize();
}
/**
* Initializes the tree. Should be called only once.
* @private
*/
initialize() {
this._el.querySelectorAll(`[role='treeitem']`).forEach((el, i) => {
el.addEventListener('click', e => this.handleItemClick(/** @type {!MouseEvent} */ (e)));
});
// TODO: remove once safehtml supports aria-owns with dynamic values.
this._el.querySelectorAll('[data-aria-owns]').forEach(el => {
el.setAttribute('aria-owns', el.getAttribute('data-aria-owns'));
});
}
/**
* @private
*/
addEventListeners() {
this._el.addEventListener('keydown', e =>
this.handleKeyDown(/** @type {!KeyboardEvent} */ (e))
);
}
/**
* Sets the visible item with the given index with the proper tabindex and
* focuses it.
* @param {!number} index
* @return {undefined}
*/
setFocusedIndex(index) {
if (index === this._focusedIndex || index === -1) {
return;
}
let itemEl = this._visibleItems[this._focusedIndex];
itemEl.setAttribute('tabindex', '-1');
itemEl = this._visibleItems[index];
itemEl.setAttribute('tabindex', '0');
itemEl.focus();
this._focusedIndex = index;
}
/**
* Marks the navigation node with the given ID as selected. If no ID is
* provided, the first visible item in the tree is used.
* @param {!string=} opt_id
* @return {undefined}
*/
setSelectedId(opt_id) {
if (this._selectedEl) {
this._selectedEl.removeAttribute('aria-selected');
this._selectedEl = null;
}
if (opt_id) {
this._selectedEl = this._el.querySelector(`[role='treeitem'][href='#${opt_id}']`);
} else if (this._visibleItems.length > 0) {
this._selectedEl = this._visibleItems[0];
}
if (!this._selectedEl) {
return;
}
if (this._selectedEl.getAttribute('aria-level') === '1') {
this._selectedEl.setAttribute('aria-expanded', 'true');
}
this._selectedEl.setAttribute('aria-selected', 'true');
this.expandAllParents(this._selectedEl);
this.scrollElementIntoView(this._selectedEl);
}
/**
* Expands all sibling items of the given element.
* @param {!Element} el
* @private
*/
expandAllSiblingItems(el) {
const level = el.getAttribute('aria-level');
this._el.querySelectorAll(`[aria-level='${level}'][aria-expanded='false']`).forEach(el => {
el.setAttribute('aria-expanded', 'true');
});
this.updateVisibleItems();
this._focusedIndex = this._visibleItems.indexOf(el);
}
/**
* Expands all parent items of the given element.
* @param {!Element} el
* @private
*/
expandAllParents(el) {
if (!this._visibleItems.includes(el)) {
let owningItemEl = this.owningItem(el);
while (owningItemEl) {
this.expandItem(owningItemEl);
owningItemEl = this.owningItem(owningItemEl);
}
}
}
/**
* Scrolls the given element into view, aligning the element in the center
* of the viewport. If the element is already in view, no scrolling occurs.
* @param {!Element} el
* @private
*/
scrollElementIntoView(el) {
const STICKY_HEADER_HEIGHT_PX = 55;
const viewportHeightPx = document.documentElement.clientHeight;
const elRect = el.getBoundingClientRect();
const verticalCenterPointPx = (viewportHeightPx - STICKY_HEADER_HEIGHT_PX) / 2;
if (elRect.top < STICKY_HEADER_HEIGHT_PX) {
// Element is occluded at top of view by header or by being offscreen.
this._el.scrollTop -=
STICKY_HEADER_HEIGHT_PX - elRect.top - elRect.height + verticalCenterPointPx;
} else if (elRect.bottom > viewportHeightPx) {
// Element is below viewport.
this._el.scrollTop = elRect.bottom - viewportHeightPx + verticalCenterPointPx;
} else {
return;
}
}
/**
* Handles when a tree item is clicked.
* @param {!MouseEvent} e
* @private
*/
handleItemClick(e) {
const el = /** @type {!Element} */ (e.target);
this.setFocusedIndex(this._visibleItems.indexOf(el));
if (el.hasAttribute('aria-expanded')) {
this.toggleItemExpandedState(el);
}
this.closeInactiveDocNavGroups(el);
}
/**
* Closes inactive top level nav groups when a new tree item clicked.
* @param {!Element} el
* @private
*/
closeInactiveDocNavGroups(el) {
if (el.hasAttribute('aria-expanded')) {
const level = el.getAttribute('aria-level');
document.querySelectorAll(`[aria-level="${level}"]`).forEach(nav => {
if (nav.getAttribute('aria-expanded') === 'true' && nav !== el) {
nav.setAttribute('aria-expanded', 'false');
}
});
this.updateVisibleItems();
this._focusedIndex = this._visibleItems.indexOf(el);
}
}
/**
* Handles when a key is pressed when the component is in focus.
* @param {!KeyboardEvent} e
* @private
*/
handleKeyDown(e) {
const targetEl = /** @type {!Element} */ (e.target);
switch (e.key) {
case Key.ASTERISK:
this.expandAllSiblingItems(targetEl);
e.stopPropagation();
e.preventDefault();
return;
// Global keyboard shortcuts.
// TODO(golang.org/issue/40246): consolidate keyboard shortcut handling
// to avoid this duplication.
case Key.FORWARD_SLASH:
case Key.QUESTION_MARK:
return;
case Key.DOWN:
this.focusNextItem();
break;
case Key.UP:
this.focusPreviousItem();
break;
case Key.LEFT:
if (e.target.getAttribute('aria-expanded') === 'true') {
this.collapseItem(targetEl);
} else {
this.focusParentItem(targetEl);
}
break;
case Key.RIGHT: {
switch (targetEl.getAttribute('aria-expanded')) {
case 'false':
this.expandItem(targetEl);
break;
case 'true':
// Select the first child.
this.focusNextItem();
break;
}
break;
}
case Key.HOME:
this.setFocusedIndex(0);
break;
case Key.END:
this.setFocusedIndex(this._visibleItems.length - 1);
break;
case Key.ENTER:
if (targetEl.tagName === 'A') {
// Enter triggers desired behavior by itself.
return;
}
// Fall through for non-anchor items to be handled the same as when
// the space key is pressed.
case Key.SPACE:
targetEl.click();
break;
default:
// Could be a typeahead search.
this.handleSearch(e);
return;
}
e.preventDefault();
e.stopPropagation();
}
/**
* Handles when a key event isn’t matched by shortcut handling, indicating
* that the user may be attempting a typeahead search.
* @param {!KeyboardEvent} e
* @private
*/
handleSearch(e) {
if (
e.metaKey ||
e.altKey ||
e.ctrlKey ||
e.isComposing ||
e.key.length > 1 ||
!e.key.match(/\S/)
) {
return;
}
// KeyDown events should be within one second of each other to be considered
// part of the same typeahead search string.
const MAX_TYPEAHEAD_THRESHOLD_MS = 1000;
if (e.timeStamp - this._lastKeyDownTimeStamp > MAX_TYPEAHEAD_THRESHOLD_MS) {
this._searchString = '';
}
this._lastKeyDownTimeStamp = e.timeStamp;
this._searchString += e.key.toLocaleLowerCase();
const focusedElementText = this._visibleItems[
this._focusedIndex
].textContent.toLocaleLowerCase();
if (this._searchString.length === 1 || !focusedElementText.startsWith(this._searchString)) {
this.focusNextItemWithPrefix(this._searchString);
}
e.stopPropagation();
e.preventDefault();
}
/**
* Focuses on the next visible tree item (after the currently focused element,
* wrapping the tree) that has a prefix equal to the given search string.
* @param {string} prefix
*/
focusNextItemWithPrefix(prefix) {
let i = this._focusedIndex + 1;
if (i > this._visibleItems.length - 1) {
i = 0;
}
while (i !== this._focusedIndex) {
if (this._visibleItems[i].textContent.toLocaleLowerCase().startsWith(prefix)) {
this.setFocusedIndex(i);
return;
}
if (i >= this._visibleItems.length - 1) {
i = 0;
} else {
i++;
}
}
}
/**
* @param {!Element} el
* @private
*/
toggleItemExpandedState(el) {
el.getAttribute('aria-expanded') === 'true' ? this.collapseItem(el) : this.expandItem(el);
}
/**
* @private
*/
focusPreviousItem() {
this.setFocusedIndex(Math.max(0, this._focusedIndex - 1));
}
/**
* @private
*/
focusNextItem() {
this.setFocusedIndex(Math.min(this._visibleItems.length - 1, this._focusedIndex + 1));
}
/**
* @param {!Element} el
* @private
*/
collapseItem(el) {
el.setAttribute('aria-expanded', 'false');
this.updateVisibleItems();
}
/**
* @param {!Element} el
* @private
*/
expandItem(el) {
el.setAttribute('aria-expanded', 'true');
this.updateVisibleItems();
}
/**
* @param {!Element} el
* @private
*/
focusParentItem(el) {
const owningItemEl = this.owningItem(el);
if (owningItemEl) {
this.setFocusedIndex(this._visibleItems.indexOf(owningItemEl));
}
}
/**
* @param {!Element} el
* @return {Element} The first parent item that “owns” the group that el is a member of,
* or null if there is none.
*/
owningItem(el) {
const groupEl = el.closest(`[role='group']`);
if (!groupEl) {
return null;
}
return groupEl.parentElement.querySelector(`[aria-owns='${groupEl.id}']`);
}
/**
* Updates which items are visible (not a child of a collapsed item).
* @private
*/
updateVisibleItems() {
const allEls = Array.from(this._el.querySelectorAll(`[role='treeitem']`));
const hiddenEls = Array.from(
this._el.querySelectorAll(`[aria-expanded='false'] + [role='group'] [role='treeitem']`)
);
this._visibleItems = allEls.filter(el => !hiddenEls.includes(el));
}
}
/**
* Primary controller for the documentation page, handling coordination between
* the navigation and content components. This class ensures that any
* documentation elements in view are properly shown/highlighted in the
* navigation components.
*
* Since navigation is essentially handled by anchor tags with fragment IDs as
* hrefs, the fragment ID (referenced in this code as simply “ID”) is used to
* look up both navigation and content nodes.
*/
class DocPageController {
/**
* Instantiates the controller, setting up the navigation controller (both
* desktop and mobile), and event listeners. This should only be called once.
* @param {Element} sideNavEl
* @param {Element} mobileNavEl
* @param {Element} contentEl
*/
constructor(sideNavEl, mobileNavEl, contentEl) {
if (!sideNavEl || !contentEl) {
console.warn('Unable to find all elements needed for navigation');
return;
}
/**
* @type {!Element}
* @private
*/
this._contentEl = contentEl;
window.addEventListener('hashchange', e =>
this.handleHashChange(/** @type {!HashChangeEvent} */ (e))
);
/**
* @type {!DocNavTreeController}
* @private
*/
this._navController = new DocNavTreeController(sideNavEl);
/**
* @type {!MobileNavController}
* @private
*/
if (mobileNavEl) {
this._mobileNavController = new MobileNavController(mobileNavEl);
}
this.updateSelectedIdFromWindowHash();
}
/**
* Handles when the location hash changes.
* @param {!HashChangeEvent} e
* @private
*/
handleHashChange(e) {
this.updateSelectedIdFromWindowHash();
}
/**
* @private
*/
updateSelectedIdFromWindowHash() {
const targetId = this.targetIdFromLocationHash();
this._navController.setSelectedId(targetId);
if (this._mobileNavController) {
this._mobileNavController.setSelectedId(targetId);
}
if (targetId !== '') {
const targetEl = this._contentEl.querySelector(`[id='${targetId}']`);
if (targetEl) {
targetEl.focus();
}
}
}
/**
* @return {!string}
*/
targetIdFromLocationHash() {
return window.location.hash && window.location.hash.substr(1);
}
}
/**
* Controller for the navigation element used on smaller viewports. It utilizes
* a native <select> element for interactivity and a styled <label> for
* displaying the selected option.
*
* It presumes a fixed header and that the container for the control will be
* sticky right below the header when scrolled enough.
*/
class MobileNavController {
/**
* @param {!Element} el
*/
constructor(el) {
/**
* @type {!Element}
* @private
*/
this._el = /** @type {!Element} */ (el);
/**
* @type {!HTMLSelectElement}
* @private
*/
this._selectEl = /** @type {!HTMLSelectElement} */ (el.querySelector('select'));
/**
* @type {!Element}
* @private
*/
this._labelTextEl = /** @type {!Element} */ (el.querySelector('.js-mobileNavSelectText'));
this._selectEl.addEventListener('change', e =>
this.handleSelectChange(/** @type {!Event} */ (e))
);
// We use a slight hack to detect if the mobile nav container is pinned to
// the bottom of the site header. The root viewport of an IntersectionObserver
// is inset by the header height plus one pixel to ensure that the container is
// considered “out of view” when in a fixed position and can be styled appropriately.
const ROOT_TOP_MARGIN = '-57px';
this._intersectionObserver = new IntersectionObserver(
(entries, observer) => this.intersectionObserverCallback(entries, observer),
{
rootMargin: `${ROOT_TOP_MARGIN} 0px 0px 0px`,
threshold: 1.0,
}
);
this._intersectionObserver.observe(this._el);
}
/**
* @param {string} id
*/
setSelectedId(id) {
this._selectEl.value = id;
this.updateLabelText();
}
/**
* @private
*/
updateLabelText() {
const selectedIndex = this._selectEl.selectedIndex;
if (selectedIndex === -1) {
this._labelTextEl.textContent = '';
return;
}
this._labelTextEl.textContent = this._selectEl.options[selectedIndex].textContent;
}
/**
* @param {!Event} e
* @private
*/
handleSelectChange(e) {
window.location.hash = `#${e.target.value}`;
this.updateLabelText();
}
/**
* @param {!Array<IntersectionObserverEntry>} entries
* @param {!IntersectionObserver} observer
* @private
*/
intersectionObserverCallback(entries, observer) {
const SHADOW_CSS_CLASS = 'DocNavMobile--withShadow';
entries.forEach(entry => {
// entry.isIntersecting isn’t reliable on Firefox.
const fullyInView = entry.intersectionRatio === 1.0;
entry.target.classList.toggle(SHADOW_CSS_CLASS, !fullyInView);
});
}
}
new DocPageController(
document.querySelector('.js-tree'),
document.querySelector('.js-mobileNav'),
document.querySelector('.js-unitDetailsContent')
);