| /** |
| * @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. |
| */ |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| // To implement autocomplete we use autoComplete.js, but override the |
| // navigation controller to be more accessible. |
| // |
| // Accessibility requirements are based on: |
| // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html |
| // This defines the interaction of three elements: the 'combobox', which is |
| // the parent of both the text input, and the completion list. |
| // |
| // The autoComplete.js library assumes a single element in its query |
| // selector, unfortunately, so we can't express our autocompletion behavior |
| // in terms of classes rather than IDs. See also |
| // https://github.com/TarekRaafat/autoComplete.js/issues/82 |
| const completeInput = document.querySelector('#js-AutoComplete-input'); |
| const parentForm = document.querySelector('#js-AutoComplete-parent'); |
| const hideCompletion = () => { |
| parentForm.setAttribute('aria-expanded', false); |
| // Without removing aria-activedescendant screenreaders will get confused. |
| completeInput.removeAttribute('aria-activedescendant'); |
| }; |
| const showCompletion = () => { |
| parentForm.setAttribute('aria-expanded', true); |
| }; |
| // Accessibility: hide and show the completion dialog on blur and focus. |
| completeInput.addEventListener('blur', hideCompletion); |
| completeInput.addEventListener('focus', showCompletion); |
| |
| /** |
| * See https://tarekraafat.github.io/autoComplete.js/#/?id=api-configuration |
| * for some documentation of the autoComplete.js API. In particular, it |
| * allows overriding the resultsList navigation controller. |
| * |
| * This controller is based on the default implementation here: |
| * https://github.com/TarekRaafat/autoComplete.js/blob/v7.2.0/src/views/autoCompleteView.js#L120 |
| * |
| * The primary changes are: |
| * + set aria-expanded, aria-selected, and aria-activedescendant attributes |
| * where necessary |
| * + use aria attributes for styling, rather than classes |
| * + add id attributes to the list elements |
| * + hide completion on blur, or escape |
| * + simplify the code a bit |
| * |
| * This function is evaluated each time the result list is assembled (i.e. |
| * potentially each keypress). |
| * |
| * @param input is the text input element. |
| * @param resultsList is the ul element being constructed. |
| * @param sendFeedback is the data feedback function invoked on user |
| * selection. "feedback" is the term used for this in autoComplete.js, so |
| * we preserve this for consistency. |
| * @param resultsValues is the processed data values from the data source. |
| */ |
| const navigation = (evt, input, resultsList, sendFeedback, resultsValues) => { |
| const lis = Array.from(resultsList.childNodes); |
| let liSelected = undefined; |
| const highlightSelection = next => { |
| // Clear the existing selected item. |
| if (liSelected) { |
| liSelected.removeAttribute('aria-selected'); |
| } |
| liSelected = next; |
| liSelected.setAttribute('aria-selected', 'true'); |
| const idx = lis.findIndex(li => li === liSelected); |
| input.setAttribute('aria-activedescendant', 'AutoComplete-item-' + idx); |
| }; |
| const onSelection = (event, elem) => { |
| sendFeedback({ |
| event: event, |
| query: input.value, |
| matches: resultsValues.matches, |
| results: resultsValues.list.map(record => record.value), |
| selection: resultsValues.list.find( |
| value => value.index === Number(elem.getAttribute('data-id')) |
| ), |
| }); |
| hideCompletion(); |
| }; |
| input.onkeydown = event => { |
| const keys = { |
| ENTER: 13, |
| ESCAPE: 27, |
| ARROW_UP: 38, |
| ARROW_DOWN: 40, |
| }; |
| let next = undefined; // the next item to highlight |
| if (lis.length > 0) { |
| switch (event.keyCode) { |
| case keys.ARROW_UP: |
| showCompletion(); |
| // Show the last completion item if none are currently selected, or |
| // we're at the start of the list. |
| next = lis[lis.length - 1]; |
| if (liSelected && liSelected.previousSibling) { |
| next = liSelected.previousSibling; |
| } |
| highlightSelection(next); |
| // If we don't preventDefault here, up and down arrows cause us to |
| // also jump to the start or end of the text input. |
| event.preventDefault(); |
| break; |
| case keys.ARROW_DOWN: |
| showCompletion(); |
| // Show the first completion item if none are currently selected, |
| // or if we're at the end of the list. |
| next = lis[0]; |
| if (liSelected && liSelected.nextSibling) { |
| next = liSelected.nextSibling; |
| } |
| highlightSelection(next); |
| // See note above for why this is necessary. |
| event.preventDefault(); |
| break; |
| case keys.ENTER: |
| if (liSelected) { |
| event.preventDefault(); |
| onSelection(event, liSelected); |
| } |
| break; |
| case keys.ESCAPE: |
| // From the aria guide, escape should both hide completion and |
| // clear the input. Arguably it might be better to leave the input |
| // untouched, which is what Google search does. |
| hideCompletion(); |
| input.value = ''; |
| break; |
| default: |
| // Because we've hidden the completion ourselves on escape, we need |
| // to show it on any other character. |
| showCompletion(); |
| } |
| } |
| }; |
| lis.forEach((selection, i) => { |
| // Completion items need id attributes so that they can be referenced by |
| // aria-activedescendant. |
| selection.setAttribute('id', 'AutoComplete-item-' + i); |
| selection.onmousedown = event => { |
| onSelection(event, event.currentTarget); |
| event.preventDefault(); |
| }; |
| }); |
| }; |
| |
| new autoComplete({ |
| data: { |
| src: async () => { |
| const query = completeInput.value; |
| const source = await fetch(`/autocomplete?q=${query}`); |
| return await source.json(); |
| }, |
| // The string we're completing is stored in the 'PackagePath' field of |
| // the returned JSON array elements. |
| key: ['PackagePath'], |
| cache: false, |
| }, |
| threshold: 1, // minimum number of characters before rendering results |
| debounce: 100, // in milliseconds |
| resultsList: { |
| render: true, |
| container: source => { |
| source.setAttribute('id', 'AutoComplete-list'); |
| source.classList.add('AutoComplete-list'); |
| source.setAttribute('role', 'listbox'); |
| }, |
| destination: document.querySelector('#js-AutoComplete-parent'), |
| position: 'beforeend', |
| element: 'ul', |
| navigation: navigation, |
| }, |
| highlight: true, |
| selector: '#js-AutoComplete-input', |
| onSelection: feedback => { |
| if (feedback.selection.value.PackagePath) { |
| // Navigate directly to the package. |
| // TODO (https://golang.org/issue/42955): update ARIA attributes to reflect this. |
| window.location.href = '/' + feedback.selection.value.PackagePath; |
| } |
| }, |
| }); |
| }); |