| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {LitElement, html} from 'lit-element'; |
| import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js'; |
| |
| /** |
| * @type {RegExp} Autocomplete options are matched at word boundaries. This |
| * Regex specifies what counts as a boundary between words. |
| */ |
| const DELIMITER_REGEX = /[^a-z0-9]+/i; |
| |
| /** |
| * Specifies what happens to the input element an autocomplete |
| * instance is attached to when a user selects an autocomplete option. This |
| * constant specifies the default behavior where a form's entire value is |
| * replaced with the selected value. |
| * @param {HTMLInputElement} input An input element. |
| * @param {string} value The value of the selected autocomplete option. |
| */ |
| const DEFAULT_REPLACER = (input, value) => { |
| input.value = value; |
| }; |
| |
| /** |
| * @type {number} The default maximum of completions to render at a time. |
| */ |
| const DEFAULT_MAX_COMPLETIONS = 200; |
| |
| /** |
| * @type {number} Globally shared counter for autocomplete instances to help |
| * ensure that no two <chops-autocomplete> options have the same ID. |
| */ |
| let idCount = 1; |
| |
| /** |
| * `<chops-autocomplete>` shared autocomplete UI code that inter-ops with |
| * other code. |
| * |
| * chops-autocomplete inter-ops with any input element, whether custom or |
| * native that can receive change handlers and has a 'value' property which |
| * can be read and set. |
| * |
| * NOTE: This element disables ShadowDOM for accessibility reasons: to allow |
| * aria attributes from the outside to reference features in this element. |
| * |
| * @customElement chops-autocomplete |
| */ |
| export class ChopsAutocomplete extends LitElement { |
| /** @override */ |
| render() { |
| const completions = this.completions; |
| const currentValue = this._prefix.trim().toLowerCase(); |
| const index = this._selectedIndex; |
| const currentCompletion = index >= 0 && |
| index < completions.length ? completions[index] : ''; |
| |
| return html` |
| <style> |
| /* |
| * Really specific class names are necessary because ShadowDOM |
| * is disabled for this component. |
| */ |
| .chops-autocomplete-container { |
| position: relative; |
| } |
| .chops-autocomplete-container table { |
| padding: 0; |
| font-size: var(--chops-main-font-size); |
| color: var(--chops-link-color); |
| position: absolute; |
| background: var(--chops-white); |
| border: var(--chops-accessible-border); |
| z-index: 999; |
| box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3); |
| border-spacing: 0; |
| border-collapse: collapse; |
| /* In the case when the autocomplete extends the |
| * height of the viewport, we want to make sure |
| * there's spacing. */ |
| margin-bottom: 1em; |
| } |
| .chops-autocomplete-container tbody { |
| display: block; |
| min-width: 100px; |
| max-height: 500px; |
| overflow: auto; |
| } |
| .chops-autocomplete-container tr { |
| cursor: pointer; |
| transition: background 0.2s ease-in-out; |
| } |
| .chops-autocomplete-container tr[data-selected] { |
| background: var(--chops-active-choice-bg); |
| text-decoration: underline; |
| } |
| .chops-autocomplete-container td { |
| padding: 0.25em 8px; |
| white-space: nowrap; |
| } |
| .screenreader-hidden { |
| clip: rect(1px, 1px, 1px, 1px); |
| height: 1px; |
| overflow: hidden; |
| position: absolute; |
| white-space: nowrap; |
| width: 1px; |
| } |
| </style> |
| <div class="chops-autocomplete-container"> |
| <span class="screenreader-hidden" aria-live="polite"> |
| ${currentCompletion} |
| </span> |
| <table |
| ?hidden=${!completions.length} |
| > |
| <tbody> |
| ${completions.map((completion, i) => html` |
| <tr |
| id=${completionId(this.id, i)} |
| ?data-selected=${i === index} |
| data-index=${i} |
| data-value=${completion} |
| @mouseover=${this._hoverCompletion} |
| @mousedown=${this._clickCompletion} |
| role="option" |
| aria-selected=${completion.toLowerCase() === |
| currentValue ? 'true' : 'false'} |
| > |
| <td class="completion"> |
| ${this._renderCompletion(completion)} |
| </td> |
| <td class="docstring"> |
| ${this._renderDocstring(completion)} |
| </td> |
| </tr> |
| `)} |
| </tbody> |
| </table> |
| </div> |
| `; |
| } |
| |
| /** |
| * Renders a single autocomplete result. |
| * @param {string} completion The string for the currently selected |
| * autocomplete value. |
| * @return {TemplateResult} |
| */ |
| _renderCompletion(completion) { |
| const matchDict = this._matchDict; |
| |
| if (!(completion in matchDict)) return completion; |
| |
| const {index, matchesDoc} = matchDict[completion]; |
| |
| if (matchesDoc) return completion; |
| |
| const prefix = this._prefix; |
| const start = completion.substr(0, index); |
| const middle = completion.substr(index, prefix.length); |
| const end = completion.substr(index + prefix.length); |
| |
| return html`${start}<b>${middle}</b>${end}`; |
| } |
| |
| /** |
| * Finds the docstring for a given autocomplete result and renders it. |
| * @param {string} completion The autocomplete result rendered. |
| * @return {TemplateResult} |
| */ |
| _renderDocstring(completion) { |
| const matchDict = this._matchDict; |
| const docDict = this.docDict; |
| |
| if (!completion in docDict) return ''; |
| |
| const doc = docDict[completion]; |
| |
| if (!(completion in matchDict)) return doc; |
| |
| const {index, matchesDoc} = matchDict[completion]; |
| |
| if (!matchesDoc) return doc; |
| |
| const prefix = this._prefix; |
| const start = doc.substr(0, index); |
| const middle = doc.substr(index, prefix.length); |
| const end = doc.substr(index + prefix.length); |
| |
| return html`${start}<b>${middle}</b>${end}`; |
| } |
| |
| /** @override */ |
| static get properties() { |
| return { |
| /** |
| * The input this element is for. |
| */ |
| for: {type: String}, |
| /** |
| * Generated id for the element. |
| */ |
| id: { |
| type: String, |
| reflect: true, |
| }, |
| /** |
| * The role attribute, set for accessibility. |
| */ |
| role: { |
| type: String, |
| reflect: true, |
| }, |
| /** |
| * Array of strings for possible autocompletion values. |
| */ |
| strings: {type: Array}, |
| /** |
| * A dictionary containing optional doc strings for each autocomplete |
| * string. |
| */ |
| docDict: {type: Object}, |
| /** |
| * An optional function to compute what happens when the user selects |
| * a value. |
| */ |
| replacer: {type: Object}, |
| /** |
| * An Array of the currently suggested autcomplte values. |
| */ |
| completions: {type: Array}, |
| /** |
| * Maximum number of completion values that can display at once. |
| */ |
| max: {type: Number}, |
| /** |
| * Dict of locations of matched substrings. Value format: |
| * {index, matchesDoc}. |
| */ |
| _matchDict: {type: Object}, |
| _selectedIndex: {type: Number}, |
| _prefix: {type: String}, |
| _forRef: {type: Object}, |
| _boundToggleCompletionsOnFocus: {type: Object}, |
| _boundNavigateCompletions: {type: Object}, |
| _boundUpdateCompletions: {type: Object}, |
| _oldAttributes: {type: Object}, |
| }; |
| } |
| |
| /** @override */ |
| constructor() { |
| super(); |
| |
| this.strings = []; |
| this.docDict = {}; |
| this.completions = []; |
| this.max = DEFAULT_MAX_COMPLETIONS; |
| |
| this.role = 'listbox'; |
| this.id = `chops-autocomplete-${idCount++}`; |
| |
| this._matchDict = {}; |
| this._selectedIndex = -1; |
| this._prefix = ''; |
| this._boundToggleCompletionsOnFocus = |
| this._toggleCompletionsOnFocus.bind(this); |
| this._boundUpdateCompletions = this._updateCompletions.bind(this); |
| this._boundNavigateCompletions = this._navigateCompletions.bind(this); |
| this._oldAttributes = {}; |
| } |
| |
| // Disable shadow DOM to allow aria attributes to propagate. |
| /** @override */ |
| createRenderRoot() { |
| return this; |
| } |
| |
| /** @override */ |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| |
| this._disconnectAutocomplete(this._forRef); |
| } |
| |
| /** @override */ |
| updated(changedProperties) { |
| if (changedProperties.has('for')) { |
| const forRef = this.getRootNode().querySelector('#' + this.for); |
| |
| // TODO(zhangtiff): Make this element work with custom input components |
| // in the future as well. |
| this._forRef = (forRef.tagName || '').toUpperCase() === 'INPUT' ? |
| forRef : undefined; |
| this._connectAutocomplete(this._forRef); |
| } |
| if (this._forRef) { |
| if (changedProperties.has('id')) { |
| this._forRef.setAttribute('aria-owns', this.id); |
| } |
| if (changedProperties.has('completions')) { |
| // a11y. Tell screenreaders whether the autocomplete is expanded. |
| this._forRef.setAttribute('aria-expanded', |
| this.completions.length ? 'true' : 'false'); |
| } |
| |
| if (changedProperties.has('_selectedIndex') || |
| changedProperties.has('completions')) { |
| this._updateAriaActiveDescendant(this._forRef); |
| |
| this._scrollCompletionIntoView(this._selectedIndex); |
| } |
| } |
| } |
| |
| /** |
| * Sets the aria-activedescendant attribute of the element (ie: an input form) |
| * that the autocomplete is attached to, in order to tell screenreaders about |
| * which autocomplete option is currently selected. |
| * @param {HTMLInputElement} element |
| */ |
| _updateAriaActiveDescendant(element) { |
| const i = this._selectedIndex; |
| |
| if (i >= 0 && i < this.completions.length) { |
| const selectedId = completionId(this.id, i); |
| |
| // a11y. Set the ID of the currently selected element. |
| element.setAttribute('aria-activedescendant', selectedId); |
| |
| // Scroll the container to make sure the selected element is in view. |
| } else { |
| element.setAttribute('aria-activedescendant', ''); |
| } |
| } |
| |
| /** |
| * When a user moves up or down from an autocomplete option that's at the top |
| * or bottom of the autocomplete option container, we must scroll the |
| * container to make sure the user always sees the option they've selected. |
| * @param {number} i The index of the autocomplete option to put into view. |
| */ |
| _scrollCompletionIntoView(i) { |
| const selectedId = completionId(this.id, i); |
| |
| const container = this.querySelector('tbody'); |
| const completion = this.querySelector(`#${selectedId}`); |
| |
| if (!completion) return; |
| |
| const distanceFromTop = completion.offsetTop - container.scrollTop; |
| |
| // If the completion is above the viewport for the container. |
| if (distanceFromTop < 0) { |
| // Position the completion at the top of the container. |
| container.scrollTop = completion.offsetTop; |
| } |
| |
| // If the compltion is below the viewport for the container. |
| if (distanceFromTop > (container.offsetHeight - completion.offsetHeight)) { |
| // Position the compltion at the bottom of the container. |
| container.scrollTop = completion.offsetTop - (container.offsetHeight - |
| completion.offsetHeight); |
| } |
| } |
| |
| /** |
| * Changes the input's value according to the rules of the replacer function. |
| * @param {string} value - the value to swap in. |
| * @return {undefined} |
| */ |
| completeValue(value) { |
| if (!this._forRef) return; |
| |
| const replacer = this.replacer || DEFAULT_REPLACER; |
| replacer(this._forRef, value); |
| |
| this.hideCompletions(); |
| } |
| |
| /** |
| * Computes autocomplete values matching the current input in the field. |
| * @return {boolean} Whether any completions were found. |
| */ |
| showCompletions() { |
| if (!this._forRef) { |
| this.hideCompletions(); |
| return false; |
| } |
| this._prefix = this._forRef.value.trim().toLowerCase(); |
| // Always select the first completion by default when recomputing |
| // completions. |
| this._selectedIndex = 0; |
| |
| const matchDict = {}; |
| const accepted = []; |
| matchDict; |
| for (let i = 0; i < this.strings.length && |
| accepted.length < this.max; i++) { |
| const s = this.strings[i]; |
| let matchIndex = this._matchIndex(this._prefix, s); |
| let matches = matchIndex >= 0; |
| if (matches) { |
| matchDict[s] = {index: matchIndex, matchesDoc: false}; |
| } else if (s in this.docDict) { |
| matchIndex = this._matchIndex(this._prefix, this.docDict[s]); |
| matches = matchIndex >= 0; |
| if (matches) { |
| matchDict[s] = {index: matchIndex, matchesDoc: true}; |
| } |
| } |
| if (matches) { |
| accepted.push(s); |
| } |
| } |
| |
| this._matchDict = matchDict; |
| |
| this.completions = accepted; |
| |
| return !!this.completions.length; |
| } |
| |
| /** |
| * Finds where a given user input matches an autocomplete option. Note that |
| * a match is only found if the substring is at either the beginning of the |
| * string or the beginning of a delimited section of the string. Hence, we |
| * refer to the "needle" in this function a "prefix". |
| * @param {string} prefix The value that the user inputed into the form. |
| * @param {string} s The autocomplete option that's being compared. |
| * @return {number} An integer for what index the substring is found in the |
| * autocomplete option. Returns -1 if no match. |
| */ |
| _matchIndex(prefix, s) { |
| const matchStart = s.toLowerCase().indexOf(prefix.toLocaleLowerCase()); |
| if (matchStart === 0 || |
| (matchStart > 0 && s[matchStart - 1].match(DELIMITER_REGEX))) { |
| return matchStart; |
| } |
| return -1; |
| } |
| |
| /** |
| * Hides autocomplete options. |
| */ |
| hideCompletions() { |
| this.completions = []; |
| this._prefix = ''; |
| this._selectedIndex = -1; |
| } |
| |
| /** |
| * Sets an autocomplete option that a user hovers over as the selected option. |
| * @param {MouseEvent} e |
| */ |
| _hoverCompletion(e) { |
| const target = e.currentTarget; |
| |
| if (!target.dataset || !target.dataset.index) return; |
| |
| const index = Number.parseInt(target.dataset.index); |
| if (index >= 0 && index < this.completions.length) { |
| this._selectedIndex = index; |
| } |
| } |
| |
| /** |
| * Sets the value of the form input that the user is editing to the |
| * autocomplete option that the user just clicked. |
| * @param {MouseEvent} e |
| */ |
| _clickCompletion(e) { |
| e.preventDefault(); |
| const target = e.currentTarget; |
| if (!target.dataset || !target.dataset.value) return; |
| |
| this.completeValue(target.dataset.value); |
| } |
| |
| /** |
| * Hides and shows the autocomplete completions when a user focuses and |
| * unfocuses a form. |
| * @param {FocusEvent} e |
| */ |
| _toggleCompletionsOnFocus(e) { |
| const target = e.target; |
| |
| // Check if the input is focused or not. |
| if (target.matches(':focus')) { |
| this.showCompletions(); |
| } else { |
| this.hideCompletions(); |
| } |
| } |
| |
| /** |
| * Implements hotkeys to allow the user to navigate autocomplete options with |
| * their keyboard. ie: pressing up and down to select options or Esc to close |
| * the form. |
| * @param {KeyboardEvent} e |
| */ |
| _navigateCompletions(e) { |
| const completions = this.completions; |
| if (!completions.length) return; |
| |
| switch (e.key) { |
| // TODO(zhangtiff): Throttle or control keyboard navigation so the user |
| // can't navigate faster than they can can perceive. |
| case 'ArrowUp': |
| e.preventDefault(); |
| this._navigateUp(); |
| break; |
| case 'ArrowDown': |
| e.preventDefault(); |
| this._navigateDown(); |
| break; |
| case 'Enter': |
| // TODO(zhangtiff): Add Tab to this case as well once all issue detail |
| // inputs use chops-autocomplete. |
| e.preventDefault(); |
| if (this._selectedIndex >= 0 && |
| this._selectedIndex <= completions.length) { |
| this.completeValue(completions[this._selectedIndex]); |
| } |
| break; |
| case 'Escape': |
| e.preventDefault(); |
| this.hideCompletions(); |
| break; |
| } |
| } |
| |
| /** |
| * Selects the completion option above the current one. |
| */ |
| _navigateUp() { |
| const completions = this.completions; |
| this._selectedIndex -= 1; |
| if (this._selectedIndex < 0) { |
| this._selectedIndex = completions.length - 1; |
| } |
| } |
| |
| /** |
| * Selects the completion option below the current one. |
| */ |
| _navigateDown() { |
| const completions = this.completions; |
| this._selectedIndex += 1; |
| if (this._selectedIndex >= completions.length) { |
| this._selectedIndex = 0; |
| } |
| } |
| |
| /** |
| * Recomputes autocomplete completions when the user types a new input. |
| * Ignores KeyboardEvents that don't change the input value of the form |
| * to prevent excess recomputations. |
| * @param {KeyboardEvent} e |
| */ |
| _updateCompletions(e) { |
| if (NON_EDITING_KEY_EVENTS.has(e.key)) return; |
| this.showCompletions(); |
| } |
| |
| /** |
| * Initializes the input element that this autocomplete instance is |
| * attached to with aria attributes required for accessibility. |
| * @param {HTMLInputElement} node The input element that the autocomplete is |
| * attached to. |
| */ |
| _connectAutocomplete(node) { |
| if (!node) return; |
| |
| node.addEventListener('keyup', this._boundUpdateCompletions); |
| node.addEventListener('keydown', this._boundNavigateCompletions); |
| node.addEventListener('focus', this._boundToggleCompletionsOnFocus); |
| node.addEventListener('blur', this._boundToggleCompletionsOnFocus); |
| |
| this._oldAttributes = { |
| 'aria-owns': node.getAttribute('aria-owns'), |
| 'aria-autocomplete': node.getAttribute('aria-autocomplete'), |
| 'aria-expanded': node.getAttribute('aria-expanded'), |
| 'aria-haspopup': node.getAttribute('aria-haspopup'), |
| 'aria-activedescendant': node.getAttribute('aria-activedescendant'), |
| }; |
| node.setAttribute('aria-owns', this.id); |
| node.setAttribute('aria-autocomplete', 'both'); |
| node.setAttribute('aria-expanded', 'false'); |
| node.setAttribute('aria-haspopup', 'listbox'); |
| node.setAttribute('aria-activedescendant', ''); |
| } |
| |
| /** |
| * When <chops-autocomplete> is disconnected or moved to a difference form, |
| * this function removes the side effects added by <chops-autocomplete> on the |
| * input element that <chops-autocomplete> is attached to. |
| * @param {HTMLInputElement} node The input element that the autocomplete is |
| * attached to. |
| */ |
| _disconnectAutocomplete(node) { |
| if (!node) return; |
| |
| node.removeEventListener('keyup', this._boundUpdateCompletions); |
| node.removeEventListener('keydown', this._boundNavigateCompletions); |
| node.removeEventListener('focus', this._boundToggleCompletionsOnFocus); |
| node.removeEventListener('blur', this._boundToggleCompletionsOnFocus); |
| |
| for (const key of Object.keys(this._oldAttributes)) { |
| node.setAttribute(key, this._oldAttributes[key]); |
| } |
| this._oldAttributes = {}; |
| } |
| } |
| |
| /** |
| * Generates a unique HTML ID for a given autocomplete option, for use by |
| * aria-activedescendant. Note that because the autocomplete element has |
| * ShadowDOM disabled, we need to make sure the ID is specific enough to be |
| * globally unique across the entire application. |
| * @param {string} prefix A unique prefix to differentiate this autocomplete |
| * instance from other autocomplete instances. |
| * @param {number} i The index of the autocomplete option. |
| * @return {string} A unique HTML ID for a given autocomplete option. |
| */ |
| function completionId(prefix, i) { |
| return `${prefix}-option-${i}`; |
| } |
| |
| customElements.define('chops-autocomplete', ChopsAutocomplete); |