blob: 974caddf4d9fcbb9357b37951235da45f4ce0f87 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001// Copyright 2019 The Chromium Authors
Copybara854996b2021-09-07 19:36:02 +00002// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html} from 'lit-element';
6import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
7
8/**
9 * @type {RegExp} Autocomplete options are matched at word boundaries. This
10 * Regex specifies what counts as a boundary between words.
11 */
12const DELIMITER_REGEX = /[^a-z0-9]+/i;
13
14/**
15 * Specifies what happens to the input element an autocomplete
16 * instance is attached to when a user selects an autocomplete option. This
17 * constant specifies the default behavior where a form's entire value is
18 * replaced with the selected value.
19 * @param {HTMLInputElement} input An input element.
20 * @param {string} value The value of the selected autocomplete option.
21 */
22const DEFAULT_REPLACER = (input, value) => {
23 input.value = value;
24};
25
26/**
27 * @type {number} The default maximum of completions to render at a time.
28 */
29const DEFAULT_MAX_COMPLETIONS = 200;
30
31/**
32 * @type {number} Globally shared counter for autocomplete instances to help
33 * ensure that no two <chops-autocomplete> options have the same ID.
34 */
35let idCount = 1;
36
37/**
38 * `<chops-autocomplete>` shared autocomplete UI code that inter-ops with
39 * other code.
40 *
41 * chops-autocomplete inter-ops with any input element, whether custom or
42 * native that can receive change handlers and has a 'value' property which
43 * can be read and set.
44 *
45 * NOTE: This element disables ShadowDOM for accessibility reasons: to allow
46 * aria attributes from the outside to reference features in this element.
47 *
48 * @customElement chops-autocomplete
49 */
50export class ChopsAutocomplete extends LitElement {
51 /** @override */
52 render() {
53 const completions = this.completions;
54 const currentValue = this._prefix.trim().toLowerCase();
55 const index = this._selectedIndex;
56 const currentCompletion = index >= 0 &&
57 index < completions.length ? completions[index] : '';
58
59 return html`
60 <style>
61 /*
62 * Really specific class names are necessary because ShadowDOM
63 * is disabled for this component.
64 */
65 .chops-autocomplete-container {
66 position: relative;
67 }
68 .chops-autocomplete-container table {
69 padding: 0;
70 font-size: var(--chops-main-font-size);
71 color: var(--chops-link-color);
72 position: absolute;
73 background: var(--chops-white);
74 border: var(--chops-accessible-border);
75 z-index: 999;
76 box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
77 border-spacing: 0;
78 border-collapse: collapse;
79 /* In the case when the autocomplete extends the
80 * height of the viewport, we want to make sure
81 * there's spacing. */
82 margin-bottom: 1em;
83 }
84 .chops-autocomplete-container tbody {
85 display: block;
86 min-width: 100px;
87 max-height: 500px;
88 overflow: auto;
89 }
90 .chops-autocomplete-container tr {
91 cursor: pointer;
92 transition: background 0.2s ease-in-out;
93 }
94 .chops-autocomplete-container tr[data-selected] {
95 background: var(--chops-active-choice-bg);
96 text-decoration: underline;
97 }
98 .chops-autocomplete-container td {
99 padding: 0.25em 8px;
100 white-space: nowrap;
101 }
102 .screenreader-hidden {
103 clip: rect(1px, 1px, 1px, 1px);
104 height: 1px;
105 overflow: hidden;
106 position: absolute;
107 white-space: nowrap;
108 width: 1px;
109 }
110 </style>
111 <div class="chops-autocomplete-container">
112 <span class="screenreader-hidden" aria-live="polite">
113 ${currentCompletion}
114 </span>
115 <table
116 ?hidden=${!completions.length}
117 >
118 <tbody>
119 ${completions.map((completion, i) => html`
120 <tr
121 id=${completionId(this.id, i)}
122 ?data-selected=${i === index}
123 data-index=${i}
124 data-value=${completion}
125 @mouseover=${this._hoverCompletion}
126 @mousedown=${this._clickCompletion}
127 role="option"
128 aria-selected=${completion.toLowerCase() ===
129 currentValue ? 'true' : 'false'}
130 >
131 <td class="completion">
132 ${this._renderCompletion(completion)}
133 </td>
134 <td class="docstring">
135 ${this._renderDocstring(completion)}
136 </td>
137 </tr>
138 `)}
139 </tbody>
140 </table>
141 </div>
142 `;
143 }
144
145 /**
146 * Renders a single autocomplete result.
147 * @param {string} completion The string for the currently selected
148 * autocomplete value.
149 * @return {TemplateResult}
150 */
151 _renderCompletion(completion) {
152 const matchDict = this._matchDict;
153
154 if (!(completion in matchDict)) return completion;
155
156 const {index, matchesDoc} = matchDict[completion];
157
158 if (matchesDoc) return completion;
159
160 const prefix = this._prefix;
161 const start = completion.substr(0, index);
162 const middle = completion.substr(index, prefix.length);
163 const end = completion.substr(index + prefix.length);
164
165 return html`${start}<b>${middle}</b>${end}`;
166 }
167
168 /**
169 * Finds the docstring for a given autocomplete result and renders it.
170 * @param {string} completion The autocomplete result rendered.
171 * @return {TemplateResult}
172 */
173 _renderDocstring(completion) {
174 const matchDict = this._matchDict;
175 const docDict = this.docDict;
176
177 if (!completion in docDict) return '';
178
179 const doc = docDict[completion];
180
181 if (!(completion in matchDict)) return doc;
182
183 const {index, matchesDoc} = matchDict[completion];
184
185 if (!matchesDoc) return doc;
186
187 const prefix = this._prefix;
188 const start = doc.substr(0, index);
189 const middle = doc.substr(index, prefix.length);
190 const end = doc.substr(index + prefix.length);
191
192 return html`${start}<b>${middle}</b>${end}`;
193 }
194
195 /** @override */
196 static get properties() {
197 return {
198 /**
199 * The input this element is for.
200 */
201 for: {type: String},
202 /**
203 * Generated id for the element.
204 */
205 id: {
206 type: String,
207 reflect: true,
208 },
209 /**
210 * The role attribute, set for accessibility.
211 */
212 role: {
213 type: String,
214 reflect: true,
215 },
216 /**
217 * Array of strings for possible autocompletion values.
218 */
219 strings: {type: Array},
220 /**
221 * A dictionary containing optional doc strings for each autocomplete
222 * string.
223 */
224 docDict: {type: Object},
225 /**
226 * An optional function to compute what happens when the user selects
227 * a value.
228 */
229 replacer: {type: Object},
230 /**
231 * An Array of the currently suggested autcomplte values.
232 */
233 completions: {type: Array},
234 /**
235 * Maximum number of completion values that can display at once.
236 */
237 max: {type: Number},
238 /**
239 * Dict of locations of matched substrings. Value format:
240 * {index, matchesDoc}.
241 */
242 _matchDict: {type: Object},
243 _selectedIndex: {type: Number},
244 _prefix: {type: String},
245 _forRef: {type: Object},
246 _boundToggleCompletionsOnFocus: {type: Object},
247 _boundNavigateCompletions: {type: Object},
248 _boundUpdateCompletions: {type: Object},
249 _oldAttributes: {type: Object},
250 };
251 }
252
253 /** @override */
254 constructor() {
255 super();
256
257 this.strings = [];
258 this.docDict = {};
259 this.completions = [];
260 this.max = DEFAULT_MAX_COMPLETIONS;
261
262 this.role = 'listbox';
263 this.id = `chops-autocomplete-${idCount++}`;
264
265 this._matchDict = {};
266 this._selectedIndex = -1;
267 this._prefix = '';
268 this._boundToggleCompletionsOnFocus =
269 this._toggleCompletionsOnFocus.bind(this);
270 this._boundUpdateCompletions = this._updateCompletions.bind(this);
271 this._boundNavigateCompletions = this._navigateCompletions.bind(this);
272 this._oldAttributes = {};
273 }
274
275 // Disable shadow DOM to allow aria attributes to propagate.
276 /** @override */
277 createRenderRoot() {
278 return this;
279 }
280
281 /** @override */
282 disconnectedCallback() {
283 super.disconnectedCallback();
284
285 this._disconnectAutocomplete(this._forRef);
286 }
287
288 /** @override */
289 updated(changedProperties) {
290 if (changedProperties.has('for')) {
291 const forRef = this.getRootNode().querySelector('#' + this.for);
292
293 // TODO(zhangtiff): Make this element work with custom input components
294 // in the future as well.
295 this._forRef = (forRef.tagName || '').toUpperCase() === 'INPUT' ?
296 forRef : undefined;
297 this._connectAutocomplete(this._forRef);
298 }
299 if (this._forRef) {
300 if (changedProperties.has('id')) {
301 this._forRef.setAttribute('aria-owns', this.id);
302 }
303 if (changedProperties.has('completions')) {
304 // a11y. Tell screenreaders whether the autocomplete is expanded.
305 this._forRef.setAttribute('aria-expanded',
306 this.completions.length ? 'true' : 'false');
307 }
308
309 if (changedProperties.has('_selectedIndex') ||
310 changedProperties.has('completions')) {
311 this._updateAriaActiveDescendant(this._forRef);
312
313 this._scrollCompletionIntoView(this._selectedIndex);
314 }
315 }
316 }
317
318 /**
319 * Sets the aria-activedescendant attribute of the element (ie: an input form)
320 * that the autocomplete is attached to, in order to tell screenreaders about
321 * which autocomplete option is currently selected.
322 * @param {HTMLInputElement} element
323 */
324 _updateAriaActiveDescendant(element) {
325 const i = this._selectedIndex;
326
327 if (i >= 0 && i < this.completions.length) {
328 const selectedId = completionId(this.id, i);
329
330 // a11y. Set the ID of the currently selected element.
331 element.setAttribute('aria-activedescendant', selectedId);
332
333 // Scroll the container to make sure the selected element is in view.
334 } else {
335 element.setAttribute('aria-activedescendant', '');
336 }
337 }
338
339 /**
340 * When a user moves up or down from an autocomplete option that's at the top
341 * or bottom of the autocomplete option container, we must scroll the
342 * container to make sure the user always sees the option they've selected.
343 * @param {number} i The index of the autocomplete option to put into view.
344 */
345 _scrollCompletionIntoView(i) {
346 const selectedId = completionId(this.id, i);
347
348 const container = this.querySelector('tbody');
349 const completion = this.querySelector(`#${selectedId}`);
350
351 if (!completion) return;
352
353 const distanceFromTop = completion.offsetTop - container.scrollTop;
354
355 // If the completion is above the viewport for the container.
356 if (distanceFromTop < 0) {
357 // Position the completion at the top of the container.
358 container.scrollTop = completion.offsetTop;
359 }
360
361 // If the compltion is below the viewport for the container.
362 if (distanceFromTop > (container.offsetHeight - completion.offsetHeight)) {
363 // Position the compltion at the bottom of the container.
364 container.scrollTop = completion.offsetTop - (container.offsetHeight -
365 completion.offsetHeight);
366 }
367 }
368
369 /**
370 * Changes the input's value according to the rules of the replacer function.
371 * @param {string} value - the value to swap in.
372 * @return {undefined}
373 */
374 completeValue(value) {
375 if (!this._forRef) return;
376
377 const replacer = this.replacer || DEFAULT_REPLACER;
378 replacer(this._forRef, value);
379
380 this.hideCompletions();
381 }
382
383 /**
384 * Computes autocomplete values matching the current input in the field.
385 * @return {boolean} Whether any completions were found.
386 */
387 showCompletions() {
388 if (!this._forRef) {
389 this.hideCompletions();
390 return false;
391 }
392 this._prefix = this._forRef.value.trim().toLowerCase();
393 // Always select the first completion by default when recomputing
394 // completions.
395 this._selectedIndex = 0;
396
397 const matchDict = {};
398 const accepted = [];
399 matchDict;
400 for (let i = 0; i < this.strings.length &&
401 accepted.length < this.max; i++) {
402 const s = this.strings[i];
403 let matchIndex = this._matchIndex(this._prefix, s);
404 let matches = matchIndex >= 0;
405 if (matches) {
406 matchDict[s] = {index: matchIndex, matchesDoc: false};
407 } else if (s in this.docDict) {
408 matchIndex = this._matchIndex(this._prefix, this.docDict[s]);
409 matches = matchIndex >= 0;
410 if (matches) {
411 matchDict[s] = {index: matchIndex, matchesDoc: true};
412 }
413 }
414 if (matches) {
415 accepted.push(s);
416 }
417 }
418
419 this._matchDict = matchDict;
420
421 this.completions = accepted;
422
423 return !!this.completions.length;
424 }
425
426 /**
427 * Finds where a given user input matches an autocomplete option. Note that
428 * a match is only found if the substring is at either the beginning of the
429 * string or the beginning of a delimited section of the string. Hence, we
430 * refer to the "needle" in this function a "prefix".
431 * @param {string} prefix The value that the user inputed into the form.
432 * @param {string} s The autocomplete option that's being compared.
433 * @return {number} An integer for what index the substring is found in the
434 * autocomplete option. Returns -1 if no match.
435 */
436 _matchIndex(prefix, s) {
437 const matchStart = s.toLowerCase().indexOf(prefix.toLocaleLowerCase());
438 if (matchStart === 0 ||
439 (matchStart > 0 && s[matchStart - 1].match(DELIMITER_REGEX))) {
440 return matchStart;
441 }
442 return -1;
443 }
444
445 /**
446 * Hides autocomplete options.
447 */
448 hideCompletions() {
449 this.completions = [];
450 this._prefix = '';
451 this._selectedIndex = -1;
452 }
453
454 /**
455 * Sets an autocomplete option that a user hovers over as the selected option.
456 * @param {MouseEvent} e
457 */
458 _hoverCompletion(e) {
459 const target = e.currentTarget;
460
461 if (!target.dataset || !target.dataset.index) return;
462
463 const index = Number.parseInt(target.dataset.index);
464 if (index >= 0 && index < this.completions.length) {
465 this._selectedIndex = index;
466 }
467 }
468
469 /**
470 * Sets the value of the form input that the user is editing to the
471 * autocomplete option that the user just clicked.
472 * @param {MouseEvent} e
473 */
474 _clickCompletion(e) {
475 e.preventDefault();
476 const target = e.currentTarget;
477 if (!target.dataset || !target.dataset.value) return;
478
479 this.completeValue(target.dataset.value);
480 }
481
482 /**
483 * Hides and shows the autocomplete completions when a user focuses and
484 * unfocuses a form.
485 * @param {FocusEvent} e
486 */
487 _toggleCompletionsOnFocus(e) {
488 const target = e.target;
489
490 // Check if the input is focused or not.
491 if (target.matches(':focus')) {
492 this.showCompletions();
493 } else {
494 this.hideCompletions();
495 }
496 }
497
498 /**
499 * Implements hotkeys to allow the user to navigate autocomplete options with
500 * their keyboard. ie: pressing up and down to select options or Esc to close
501 * the form.
502 * @param {KeyboardEvent} e
503 */
504 _navigateCompletions(e) {
505 const completions = this.completions;
506 if (!completions.length) return;
507
508 switch (e.key) {
509 // TODO(zhangtiff): Throttle or control keyboard navigation so the user
510 // can't navigate faster than they can can perceive.
511 case 'ArrowUp':
512 e.preventDefault();
513 this._navigateUp();
514 break;
515 case 'ArrowDown':
516 e.preventDefault();
517 this._navigateDown();
518 break;
519 case 'Enter':
520 // TODO(zhangtiff): Add Tab to this case as well once all issue detail
521 // inputs use chops-autocomplete.
522 e.preventDefault();
523 if (this._selectedIndex >= 0 &&
524 this._selectedIndex <= completions.length) {
525 this.completeValue(completions[this._selectedIndex]);
526 }
527 break;
528 case 'Escape':
529 e.preventDefault();
530 this.hideCompletions();
531 break;
532 }
533 }
534
535 /**
536 * Selects the completion option above the current one.
537 */
538 _navigateUp() {
539 const completions = this.completions;
540 this._selectedIndex -= 1;
541 if (this._selectedIndex < 0) {
542 this._selectedIndex = completions.length - 1;
543 }
544 }
545
546 /**
547 * Selects the completion option below the current one.
548 */
549 _navigateDown() {
550 const completions = this.completions;
551 this._selectedIndex += 1;
552 if (this._selectedIndex >= completions.length) {
553 this._selectedIndex = 0;
554 }
555 }
556
557 /**
558 * Recomputes autocomplete completions when the user types a new input.
559 * Ignores KeyboardEvents that don't change the input value of the form
560 * to prevent excess recomputations.
561 * @param {KeyboardEvent} e
562 */
563 _updateCompletions(e) {
564 if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
565 this.showCompletions();
566 }
567
568 /**
569 * Initializes the input element that this autocomplete instance is
570 * attached to with aria attributes required for accessibility.
571 * @param {HTMLInputElement} node The input element that the autocomplete is
572 * attached to.
573 */
574 _connectAutocomplete(node) {
575 if (!node) return;
576
577 node.addEventListener('keyup', this._boundUpdateCompletions);
578 node.addEventListener('keydown', this._boundNavigateCompletions);
579 node.addEventListener('focus', this._boundToggleCompletionsOnFocus);
580 node.addEventListener('blur', this._boundToggleCompletionsOnFocus);
581
582 this._oldAttributes = {
583 'aria-owns': node.getAttribute('aria-owns'),
584 'aria-autocomplete': node.getAttribute('aria-autocomplete'),
585 'aria-expanded': node.getAttribute('aria-expanded'),
586 'aria-haspopup': node.getAttribute('aria-haspopup'),
587 'aria-activedescendant': node.getAttribute('aria-activedescendant'),
588 };
589 node.setAttribute('aria-owns', this.id);
590 node.setAttribute('aria-autocomplete', 'both');
591 node.setAttribute('aria-expanded', 'false');
592 node.setAttribute('aria-haspopup', 'listbox');
593 node.setAttribute('aria-activedescendant', '');
594 }
595
596 /**
597 * When <chops-autocomplete> is disconnected or moved to a difference form,
598 * this function removes the side effects added by <chops-autocomplete> on the
599 * input element that <chops-autocomplete> is attached to.
600 * @param {HTMLInputElement} node The input element that the autocomplete is
601 * attached to.
602 */
603 _disconnectAutocomplete(node) {
604 if (!node) return;
605
606 node.removeEventListener('keyup', this._boundUpdateCompletions);
607 node.removeEventListener('keydown', this._boundNavigateCompletions);
608 node.removeEventListener('focus', this._boundToggleCompletionsOnFocus);
609 node.removeEventListener('blur', this._boundToggleCompletionsOnFocus);
610
611 for (const key of Object.keys(this._oldAttributes)) {
612 node.setAttribute(key, this._oldAttributes[key]);
613 }
614 this._oldAttributes = {};
615 }
616}
617
618/**
619 * Generates a unique HTML ID for a given autocomplete option, for use by
620 * aria-activedescendant. Note that because the autocomplete element has
621 * ShadowDOM disabled, we need to make sure the ID is specific enough to be
622 * globally unique across the entire application.
623 * @param {string} prefix A unique prefix to differentiate this autocomplete
624 * instance from other autocomplete instances.
625 * @param {number} i The index of the autocomplete option.
626 * @return {string} A unique HTML ID for a given autocomplete option.
627 */
628function completionId(prefix, i) {
629 return `${prefix}-option-${i}`;
630}
631
632customElements.define('chops-autocomplete', ChopsAutocomplete);