| /** |
| * @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') |
| ); |