blob: 974caddf4d9fcbb9357b37951235da45f4ce0f87 [file] [log] [blame]
// 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);