blob: 4c0bf2b3982a22af8d05449bac956c37004706bb [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001/* Copyright 2016 The Chromium Authors. All Rights Reserved.
2 *
3 * Use of this source code is governed by a BSD-style
4 * license that can be found in the LICENSE file or at
5 * https://developers.google.com/open-source/licenses/bsd
6 */
7
8/**
9 * An autocomplete library for javascript.
10 * Public API
11 * - _ac_install() install global handlers required for everything else to
12 * function.
13 * - _ac_register(SC) register a store constructor (see below)
14 * - _ac_isCompleting() true iff focus is in an auto complete box and the user
15 * has triggered completion with a keystroke, and completion has not been
16 * cancelled (programatically or otherwise).
17 * - _ac_isCompleteListShowing() true if _as_isCompleting and the complete list
18 * is visible to the user.
19 * - _ac_cancel() if completing, stop it, otherwise a no-op.
20 *
21 *
22 * A quick example
23 * // an auto complete store
24 * var myFavoritestAutoCompleteStore = new _AC_SimpleStore(
25 * ['some', 'strings', 'to', 'complete']);
26 *
27 * // a store constructor
28 * _ac_register(function (inputNode, keyEvent) {
29 * if (inputNode.id == 'my-auto-completing-check-box') {
30 * return myFavoritestAutoCompleteStore;
31 * }
32 * return null;
33 * });
34 *
35 * <html>
36 * <head>
37 * <script type=text/javascript src=ac.js></script>
38 * </head>
39 * <body onload=_ac_install()>
40 * <!-- the constructor above looks at the id. It could as easily
41 * - look at the class, name, or value.
42 * - The autocomplete=off stops browser autocomplete from
43 * - interfering with our autocomplete
44 * -->
45 * <input type=text id="my-auto-completing-check-box"
46 * autocomplete=off>
47 * </body>
48 * </html>
49 *
50 *
51 * Concepts
52 * - Store Constructor function
53 * A store constructor is a policy function with the signature
54 * _AC_Store myStoreConstructor(
55 * HtmlInputElement|HtmlTextAreaElement inputNode, Event keyEvent)
56 * When a key event is received on a text input or text area, the autocomplete
57 * library will try each of the store constructors in turn until it finds one
58 * that returns an AC_Store which will be used for auto-completion of that
59 * text box until focus is lost.
60 *
61 * - interface _AC_Store
62 * An autocomplete store encapsulates all operations that affect how a
63 * particular text node is autocompleted. It has the following operations:
64 * - String completable(String inputValue, int caret)
65 * This method returns null if not completable or the section of inputValue
66 * that is subject to completion. If autocomplete works on items in a
67 * comma separated list, then the input value "foo, ba" might yield "ba"
68 * as the completable chunk since it is separated from its predecessor by
69 * a comma.
70 * caret is the position of the text cursor (caret) in the text input.
71 * - _AC_Completion[] completions(String completable,
72 * _AC_Completion[] toFilter)
73 * This method returns null if there are no completions. If toFilter is
74 * not null or undefined, then this method may assume that toFilter was
75 * returned as a set of completions that contain completable.
76 * - String substitute(String inputValue, int caret,
77 * String completable, _AC_Completion completion)
78 * returns the inputValue with the given completion substituted for the
79 * given completable. caret has the same meaning as in the
80 * completable operation.
81 * - String oncomplete(boolean completed, String key,
82 * HTMLElement element, String text)
83 * This method is called when the user hits a completion key. The default
84 * value is to do nothing, but you can override it if you want. Note that
85 * key will be null if the user clicked on it to select
86 * - Boolean autoselectFirstRow()
87 * This method returns True by default, but subclasses can override it
88 * to make autocomplete fields that require the user to press the down
89 * arrow or do a mouseover once before any completion option is considered
90 * to be selected.
91 *
92 * - class _AC_SimpleStore
93 * An implementation of _AC_Store that completes a set of strings given at
94 * construct time in a text field with a comma separated value.
95 *
96 * - struct _AC_Completion
97 * a struct with two fields
98 * - String value : the plain text completion value
99 * - String html : the value, as html, with the completable in bold.
100 *
101 * Key Handling
102 * Several keys affect completion in an autocompleted input.
103 * ESC - the escape key cancels autocompleting. The autocompletion will have
104 * no effect on the focused textbox until it loses focus, regains it, and
105 * a key is pressed.
106 * ENTER - completes using the currently selected completion, or if there is
107 * only one, uses that completion.
108 * UP ARROW - selects the completion above the current selection.
109 * DOWN ARROW - selects the completion below the current selection.
110 *
111 *
112 * CSS styles
113 * The following CSS selector rules can be used to change the completion list
114 * look:
115 * #ac-list style of the auto-complete list
116 * #ac-list .selected style of the selected item
117 * #ac-list b style of the matching text in a candidate completion
118 *
119 * Dependencies
120 * The library depends on the following libraries:
121 * javascript:base for definition of key constants and SetCursorPos
122 * javascript:shapes for nodeBounds()
123 */
124
125/**
126 * install global handlers required for the rest of the module to function.
127 */
128function _ac_install() {
129 ac_addHandler_(document.body, 'onkeydown', ac_keyevent_);
130 ac_addHandler_(document.body, 'onkeypress', ac_keyevent_);
131}
132
133/**
134 * register a store constructor
135 * @param storeConstructor a function like
136 * _AC_Store myStoreConstructor(HtmlInputElement|HtmlTextArea, Event)
137 */
138function _ac_register(storeConstructor) {
139 // check that not already registered
140 for (let i = ac_storeConstructors.length; --i >= 0;) {
141 if (ac_storeConstructors[i] === storeConstructor) {
142 return;
143 }
144 }
145 ac_storeConstructors.push(storeConstructor);
146}
147
148/**
149 * may be attached as an onfocus handler to a text input to popup autocomplete
150 * immediately on the box gaining focus.
151 */
152function _ac_onfocus(event) {
153 ac_keyevent_(event);
154}
155
156/**
157 * true iff the autocomplete widget is currently active.
158 */
159function _ac_isCompleting() {
160 return !!ac_store && !ac_suppressCompletions;
161}
162
163/**
164 * true iff the completion list is displayed.
165 */
166function _ac_isCompleteListShowing() {
167 return !!ac_store && !ac_suppressCompletions && ac_completions &&
168 ac_completions.length;
169}
170
171/**
172 * cancel any autocomplete in progress.
173 */
174function _ac_cancel() {
175 ac_suppressCompletions = true;
176 ac_updateCompletionList(false);
177}
178
179/** add a handler without whacking any existing handler. @private */
180function ac_addHandler_(node, handlerName, handler) {
181 const oldHandler = node[handlerName];
182 if (!oldHandler) {
183 node[handlerName] = handler;
184 } else {
185 node[handlerName] = ac_fnchain_(node[handlerName], handler);
186 }
187 return oldHandler;
188}
189
190/** cancel the event. @private */
191function ac_cancelEvent_(event) {
192 if ('stopPropagation' in event) {
193 event.stopPropagation();
194 } else {
195 event.cancelBubble = true;
196 }
197
198 // This is handled in IE by returning false from the handler
199 if ('preventDefault' in event) {
200 event.preventDefault();
201 }
202}
203
204/** Call two functions, a and b, and return false if either one returns
205 false. This is used as a primitive way to attach multiple event
206 handlers to an element without using addEventListener(). This
207 library predates the availablity of addEventListener().
208 @private
209*/
210function ac_fnchain_(a, b) {
211 return function() {
212 const ar = a.apply(this, arguments);
213 const br = b.apply(this, arguments);
214
215 // NOTE 1: (undefined && false) -> undefined
216 // NOTE 2: returning FALSE from a onkeypressed cancels it,
217 // returning UNDEFINED does not.
218 // As such, we specifically look for falses here
219 if (ar === false || br === false) {
220 return false;
221 } else {
222 return true;
223 }
224 };
225}
226
227/** key press handler. @private */
228function ac_keyevent_(event) {
229 event = event || window.event;
230
231 const source = getTargetFromEvent(event);
232 const isInput = 'INPUT' == source.tagName &&
233 source.type.match(/^text|email$/i);
234 const isTextarea = 'TEXTAREA' == source.tagName;
235 if (!isInput && !isTextarea) return true;
236
237 const key = event.key;
238 const isDown = event.type == 'keydown';
239 const isShiftKey = event.shiftKey;
240 let storeFound = true;
241
242 if ((source !== ac_focusedInput) || (ac_store === null)) {
243 ac_focusedInput = source;
244 storeFound = false;
245 if (ENTER_KEYNAME !== key && ESC_KEYNAME !== key) {
246 for (let i = 0; i < ac_storeConstructors.length; ++i) {
247 const store = (ac_storeConstructors[i])(source, event);
248 if (store) {
249 ac_store = store;
250 ac_store.setAvoid(event);
251 ac_oldBlurHandler = ac_addHandler_(
252 ac_focusedInput, 'onblur', _ac_ob);
253 storeFound = true;
254 break;
255 }
256 }
257
258 // There exists an odd condition where an edit box with autocomplete
259 // attached can be removed from the DOM without blur being called
260 // In which case we are left with a store around that will try to
261 // autocomplete the next edit box to receive focus. We need to clean
262 // this up
263
264 // If we can't find a store, force a blur
265 if (!storeFound) {
266 _ac_ob(null);
267 }
268 }
269 // ac-table rows need to be removed when switching to another input.
270 ac_updateCompletionList(false);
271 }
272 // If the user typed Esc when the auto-complete menu was not shown,
273 // then blur the input text field so that the user can use keyboard
274 // shortcuts.
275 const acList = document.getElementById('ac-list');
276 if (ESC_KEYNAME == key &&
277 (!acList || acList.style.display == 'none')) {
278 ac_focusedInput.blur();
279 }
280
281 if (!storeFound) return true;
282
283 const isCompletion = ac_store.isCompletionKey(key, isDown, isShiftKey);
284 const hasResults = ac_completions && (ac_completions.length > 0);
285 let cancelEvent = false;
286
287 if (isCompletion && hasResults) {
288 // Cancel any enter keystrokes if something is selected so that the
289 // browser doesn't go submitting the form.
290 cancelEvent = (!ac_suppressCompletions && !!ac_completions &&
291 (ac_selected != -1));
292 window.setTimeout(function() {
293 if (ac_store) {
294 ac_handleKey_(key, isDown, isShiftKey);
295 }
296 }, 0);
297 } else if (!isCompletion) {
298 // Don't want to also blur the field. Up and down move the cursor (in
299 // Firefox) to the start/end of the field. We also don't want that while
300 // the list is showing.
301 cancelEvent = (key == ESC_KEYNAME ||
302 key == DOWN_KEYNAME ||
303 key == UP_KEYNAME);
304
305 window.setTimeout(function() {
306 if (ac_store) {
307 ac_handleKey_(key, isDown, isShiftKey);
308 }
309 }, 0);
310 } else { // implicit if (isCompletion && !hasResults)
311 if (ac_store.oncomplete) {
312 ac_store.oncomplete(false, key, ac_focusedInput, undefined);
313 }
314 }
315
316 if (cancelEvent) {
317 ac_cancelEvent_(event);
318 }
319
320 return !cancelEvent;
321}
322
323/** Autocomplete onblur handler. */
324function _ac_ob(event) {
325 if (ac_focusedInput) {
326 ac_focusedInput.onblur = ac_oldBlurHandler;
327 }
328 ac_store = null;
329 ac_focusedInput = null;
330 ac_everTyped = false;
331 ac_oldBlurHandler = null;
332 ac_suppressCompletions = false;
333 ac_updateCompletionList(false);
334}
335
336/** @constructor */
337function _AC_Store() {
338}
339/** returns the chunk of the input to treat as completable. */
340_AC_Store.prototype.completable = function(inputValue, caret) {
341 console.log('UNIMPLEMENTED completable');
342};
343/** returns the chunk of the input to treat as completable. */
344_AC_Store.prototype.completions = function(prefix, tofilter) {
345 console.log('UNIMPLEMENTED completions');
346};
347/** returns the chunk of the input to treat as completable. */
348_AC_Store.prototype.oncomplete = function(completed, key, element, text) {
349 // Call the onkeyup handler so that choosing an autocomplete option has
350 // the same side-effect as typing. E.g., exposing the next row of input
351 // fields.
352 element.dispatchEvent(new Event('keyup'));
353 _ac_ob();
354};
355/** substitutes a completion for a completable in a text input's value. */
356_AC_Store.prototype.substitute =
357 function(inputValue, caret, completable, completion) {
358 console.log('UNIMPLEMENTED substitute');
359 };
360/** true iff hitting a comma key should complete. */
361_AC_Store.prototype.commaCompletes = true;
362/**
363 * true iff the given keystroke should cause a completion (and be consumed in
364 * the process.
365 */
366_AC_Store.prototype.isCompletionKey = function(key, isDown, isShiftKey) {
367 if (!isDown && (ENTER_KEYNAME === key ||
368 (COMMA_KEYNAME == key && this.commaCompletes))) {
369 return true;
370 }
371 if (TAB_KEYNAME === key && !isShiftKey) {
372 // IE doesn't fire an event for tab on click in a text field, and firefox
373 // requires that the onkeypress event for tab be consumed or it navigates
374 // to next field.
375 return false;
376 // JER: return isDown == BR_IsIE();
377 }
378 return false;
379};
380
381_AC_Store.prototype.setAvoid = function(event) {
382 if (event && event.avoidValues) {
383 ac_avoidValues = event.avoidValues;
384 } else {
385 ac_avoidValues = this.computeAvoid();
386 }
387 ac_avoidValues = ac_avoidValues.map((val) => val.toLowerCase());
388};
389
390/* Subclasses may implement this to compute values to avoid
391 offering in the current input field, i.e., because those
392 values are already used. */
393_AC_Store.prototype.computeAvoid = function() {
394 return [];
395};
396
397
398function _AC_AddItemToFirstCharMap(firstCharMap, ch, s) {
399 let l = firstCharMap[ch];
400 if (!l) {
401 l = firstCharMap[ch] = [];
402 } else if (l[l.length - 1].value == s) {
403 return;
404 }
405 l.push(new _AC_Completion(s, null, ''));
406}
407
408/**
409 * an _AC_Store implementation suitable for completing lists of email
410 * addresses.
411 * @constructor
412 */
413function _AC_SimpleStore(strings, opt_docStrings) {
414 this.firstCharMap_ = {};
415
416 for (let i = 0; i < strings.length; ++i) {
417 let s = strings[i];
418 if (!s) {
419 continue;
420 }
421 if (opt_docStrings && opt_docStrings[s]) {
422 s = s + ' ' + opt_docStrings[s];
423 }
424
425 const parts = s.split(/\W+/);
426 for (let j = 0; j < parts.length; ++j) {
427 if (parts[j]) {
428 _AC_AddItemToFirstCharMap(
429 this.firstCharMap_, parts[j].charAt(0).toLowerCase(), strings[i]);
430 }
431 }
432 }
433
434 // The maximimum number of results that we are willing to show
435 this.countThreshold = 2500;
436 this.docstrings = opt_docStrings || {};
437}
438_AC_SimpleStore.prototype = new _AC_Store();
439_AC_SimpleStore.prototype.constructor = _AC_SimpleStore;
440
441_AC_SimpleStore.prototype.completable =
442 function(inputValue, caret) {
443 // complete after the last comma not inside ""s
444 let start = 0;
445 let state = 0;
446 for (let i = 0; i < caret; ++i) {
447 const ch = inputValue.charAt(i);
448 switch (state) {
449 case 0:
450 if ('"' == ch) {
451 state = 1;
452 } else if (',' == ch || ' ' == ch) {
453 start = i + 1;
454 }
455 break;
456 case 1:
457 if ('"' == ch) {
458 state = 0;
459 }
460 break;
461 }
462 }
463 while (start < caret &&
464 ' \t\r\n'.indexOf(inputValue.charAt(start)) >= 0) {
465 ++start;
466 }
467 return inputValue.substring(start, caret);
468 };
469
470
471/** Simple function to create a <span> with matching text in bold.
472 */
473function _AC_CreateSpanWithMatchHighlighted(match) {
474 const span = document.createElement('span');
475 span.appendChild(document.createTextNode(match[1] || ''));
476 const bold = document.createElement('b');
477 span.appendChild(bold);
478 bold.appendChild(document.createTextNode(match[2]));
479 span.appendChild(document.createTextNode(match[3] || ''));
480 return span;
481};
482
483
484/**
485 * Get all completions matching the given prefix.
486 * @param {string} prefix The prefix of the text to autocomplete on.
487 * @param {List.<string>?} toFilter Optional list to filter on. Otherwise will
488 * use this.firstCharMap_ using the prefix's first character.
489 * @return {List.<_AC_Completion>} The computed list of completions.
490 */
491_AC_SimpleStore.prototype.completions = function(prefix) {
492 if (!prefix) {
493 return [];
494 }
495 toFilter = this.firstCharMap_[prefix.charAt(0).toLowerCase()];
496
497 // Since we use prefix to build a regular expression, we need to escape RE
498 // characters. We match '-', '{', '$' and others in the prefix and convert
499 // them into "\-", "\{", "\$".
500 const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
501 const modifiedPrefix = prefix.replace(regexForRegexCharacters, '\\$1');
502
503 // Match the modifiedPrefix anywhere as long as it is either at the very
504 // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
505 // such as "Ga" -> "The-Great-Gatsby".
506 const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
507 const pattern = new RegExp(patternRegex, 'i' /* ignore case */);
508
509 // We keep separate lists of possible completions that were generated
510 // by matching a value or generated by matching a docstring. We return
511 // a concatenated list so that value matches all come before docstring
512 // matches.
513 const completions = [];
514 const docCompletions = [];
515
516 if (toFilter) {
517 const toFilterLength = toFilter.length;
518 for (let i = 0; i < toFilterLength; ++i) {
519 const docStr = this.docstrings[toFilter[i].value];
520 let compSpan = null;
521 let docSpan = null;
522 const matches = toFilter[i].value.match(pattern);
523 const docMatches = docStr && docStr.match(pattern);
524 if (matches) {
525 compSpan = _AC_CreateSpanWithMatchHighlighted(matches);
526 if (docStr) docSpan = document.createTextNode(docStr);
527 } else if (docMatches) {
528 compSpan = document.createTextNode(toFilter[i].value);
529 docSpan = _AC_CreateSpanWithMatchHighlighted(docMatches);
530 }
531
532 if (compSpan) {
533 const newCompletion = new _AC_Completion(
534 toFilter[i].value, compSpan, docSpan);
535
536 if (matches) {
537 completions.push(newCompletion);
538 } else {
539 docCompletions.push(newCompletion);
540 }
541 if (completions.length + docCompletions.length > this.countThreshold) {
542 break;
543 }
544 }
545 }
546 }
547
548 return completions.concat(docCompletions);
549};
550
551// Normally, when the user types a few characters, we aggressively
552// select the first possible completion (if any). When the user
553// hits ENTER, that first completion is substituted. When that
554// behavior is not desired, override this to return false.
555_AC_SimpleStore.prototype.autoselectFirstRow = function() {
556 return true;
557};
558
559// Comparison function for _AC_Completion
560function _AC_CompareACCompletion(a, b) {
561 // convert it to lower case and remove all leading junk
562 const aval = a.value.toLowerCase().replace(/^\W*/, '');
563 const bval = b.value.toLowerCase().replace(/^\W*/, '');
564
565 if (a.value === b.value) {
566 return 0;
567 } else if (aval < bval) {
568 return -1;
569 } else {
570 return 1;
571 }
572}
573
574_AC_SimpleStore.prototype.substitute =
575function(inputValue, caret, completable, completion) {
576 return inputValue.substring(0, caret - completable.length) +
577 completion.value + ', ' + inputValue.substring(caret);
578};
579
580/**
581 * a possible completion.
582 * @constructor
583 */
584function _AC_Completion(value, compSpan, docSpan) {
585 /** plain text. */
586 this.value = value;
587 if (typeof compSpan == 'string') compSpan = document.createTextNode(compSpan);
588 this.compSpan = compSpan;
589 if (typeof docSpan == 'string') docSpan = document.createTextNode(docSpan);
590 this.docSpan = docSpan;
591}
592_AC_Completion.prototype.toString = function() {
593 return '(AC_Completion: ' + this.value + ')';
594};
595
596/** registered store constructors. @private */
597var ac_storeConstructors = [];
598/**
599 * the focused text input or textarea whether store is null or not.
600 * A text input may have focus and this may be null iff no key has been typed in
601 * the text input.
602 */
603var ac_focusedInput = null;
604/**
605 * null or the autocomplete store used to complete ac_focusedInput.
606 * @private
607 */
608var ac_store = null;
609/** store handler from ac_focusedInput. @private */
610var ac_oldBlurHandler = null;
611/**
612 * true iff user has indicated completions are unwanted (via ESC key)
613 * @private
614 */
615var ac_suppressCompletions = false;
616/**
617 * chunk of completable text seen last keystroke.
618 * Used to generate ac_completions.
619 * @private
620 */
621let ac_lastCompletable = null;
622/** an array of _AC_Completions. @private */
623var ac_completions = null;
624/** -1 or in [0, _AC_Completions.length). @private */
625var ac_selected = -1;
626
627/** Maximum number of options displayed in menu. @private */
628const ac_max_options = 100;
629
630/** Don't offer these values because they are already used. @private */
631let ac_avoidValues = [];
632
633/**
634 * handles all the key strokes, updating the completion list, tracking selected
635 * element, performing substitutions, etc.
636 * @private
637 */
638function ac_handleKey_(key, isDown, isShiftKey) {
639 // check completions
640 ac_checkCompletions();
641 let show = true;
642 const numCompletions = ac_completions ? ac_completions.length : 0;
643 // handle enter and tab on key press and the rest on key down
644 if (ac_store.isCompletionKey(key, isDown, isShiftKey)) {
645 if (ac_selected < 0 && numCompletions >= 1 &&
646 ac_store.autoselectFirstRow()) {
647 ac_selected = 0;
648 }
649 if (ac_selected >= 0) {
650 const backupInput = ac_focusedInput;
651 const completeValue = ac_completions[ac_selected].value;
652 ac_complete();
653 if (ac_store.oncomplete) {
654 ac_store.oncomplete(true, key, backupInput, completeValue);
655 }
656 }
657 } else {
658 switch (key) {
659 case ESC_KEYNAME: // escape
660 // JER?? ac_suppressCompletions = true;
661 ac_selected = -1;
662 show = false;
663 break;
664 case UP_KEYNAME: // up
665 if (isDown) {
666 // firefox fires arrow events on both down and press, but IE only fires
667 // then on press.
668 ac_selected = Math.max(numCompletions >= 0 ? 0 : -1, ac_selected - 1);
669 }
670 break;
671 case DOWN_KEYNAME: // down
672 if (isDown) {
673 ac_selected = Math.min(
674 ac_max_options - 1, Math.min(numCompletions - 1, ac_selected + 1));
675 }
676 break;
677 }
678
679 if (isDown) {
680 switch (key) {
681 case ESC_KEYNAME:
682 case ENTER_KEYNAME:
683 case UP_KEYNAME:
684 case DOWN_KEYNAME:
685 case RIGHT_KEYNAME:
686 case LEFT_KEYNAME:
687 case TAB_KEYNAME:
688 case SHIFT_KEYNAME:
689 case BACKSPACE_KEYNAME:
690 case DELETE_KEYNAME:
691 break;
692 default: // User typed some new characters.
693 ac_everTyped = true;
694 }
695 }
696 }
697
698 if (ac_focusedInput) {
699 ac_updateCompletionList(show);
700 }
701}
702
703/**
704 * called when an option is clicked on to select that option.
705 */
706function _ac_select(optionIndex) {
707 ac_selected = optionIndex;
708 ac_complete();
709 if (ac_store.oncomplete) {
710 ac_store.oncomplete(true, null, ac_focusedInput, ac_focusedInput.value);
711 }
712
713 // check completions
714 ac_checkCompletions();
715 ac_updateCompletionList(true);
716}
717
718function _ac_mouseover(optionIndex) {
719 ac_selected = optionIndex;
720 ac_updateCompletionList(true);
721}
722
723/** perform the substitution of the currently selected item. */
724function ac_complete() {
725 const caret = ac_getCaretPosition_(ac_focusedInput);
726 const completion = ac_completions[ac_selected];
727
728 ac_focusedInput.value = ac_store.substitute(
729 ac_focusedInput.value, caret,
730 ac_lastCompletable, completion);
731 // When the prefix starts with '*' we want to return the complete set of all
732 // possible completions. We treat the ac_lastCompletable value as empty so
733 // that the caret is correctly calculated (i.e. the caret should not consider
734 // placeholder values like '*member').
735 let new_caret = caret + completion.value.length;
736 if (!ac_lastCompletable.startsWith('*')) {
737 // Only consider the ac_lastCompletable length if it does not start with '*'
738 new_caret = new_caret - ac_lastCompletable.length;
739 }
740 // If we inserted something ending in two quotation marks, position
741 // the cursor between the quotation marks. If we inserted a complete term,
742 // skip over the trailing space so that the user is ready to enter the next
743 // term. If we inserted just a search operator, leave the cursor immediately
744 // after the colon or equals and don't skip over the space.
745 if (completion.value.substring(completion.value.length - 2) == '""') {
746 new_caret--;
747 } else if (completion.value.substring(completion.value.length - 1) != ':' &&
748 completion.value.substring(completion.value.length - 1) != '=') {
749 new_caret++; // To account for the comma.
750 new_caret++; // To account for the space after the comma.
751 }
752 ac_selected = -1;
753 ac_completions = null;
754 ac_lastCompletable = null;
755 ac_everTyped = false;
756 SetCursorPos(window, ac_focusedInput, new_caret);
757}
758
759/**
760 * True if the user has ever typed any actual characters in the currently
761 * focused text field. False if they have only clicked, backspaced, and
762 * used the arrow keys.
763 */
764var ac_everTyped = false;
765
766/**
767 * maintains ac_completions, ac_selected, ac_lastCompletable.
768 * @private
769 */
770function ac_checkCompletions() {
771 if (ac_focusedInput && !ac_suppressCompletions) {
772 const caret = ac_getCaretPosition_(ac_focusedInput);
773 const completable = ac_store.completable(ac_focusedInput.value, caret);
774
775 // If we already have completed, then our work here is done.
776 if (completable == ac_lastCompletable) {
777 return;
778 }
779
780 ac_completions = null;
781 ac_selected = -1;
782
783 const oldSelected =
784 ((ac_selected >= 0 && ac_selected < ac_completions.length) ?
785 ac_completions[ac_selected].value : null);
786 ac_completions = ac_store.completions(completable);
787 // Don't offer options for values that the user has already used
788 // in another part of the current form.
789 ac_completions = ac_completions.filter((comp) =>
790 FindInArray(ac_avoidValues, comp.value.toLowerCase()) === -1);
791
792 ac_selected = oldSelected ? 0 : -1;
793 ac_lastCompletable = completable;
794 return;
795 }
796 ac_lastCompletable = null;
797 ac_completions = null;
798 ac_selected = -1;
799}
800
801/**
802 * maintains the completion list GUI.
803 * @private
804 */
805function ac_updateCompletionList(show) {
806 let clist = document.getElementById('ac-list');
807 const input = ac_focusedInput;
808 if (input) {
809 input.setAttribute('aria-activedescendant', 'ac-status-row-none');
810 }
811 let tableEl;
812 let tableBody;
813 if (show && ac_completions && ac_completions.length) {
814 if (!clist) {
815 clist = document.createElement('DIV');
816 clist.id = 'ac-list';
817 clist.style.position = 'absolute';
818 clist.style.display = 'none';
819 // with 'listbox' and 'option' roles, screenreader narrates total
820 // number of options eg. 'New = issue has not .... 1 of 9'
821 document.body.appendChild(clist);
822 tableEl = document.createElement('table');
823 tableEl.setAttribute('cellpadding', 0);
824 tableEl.setAttribute('cellspacing', 0);
825 tableEl.id = 'ac-table';
826 tableEl.setAttribute('role', 'presentation');
827 tableBody = document.createElement('tbody');
828 tableBody.id = 'ac-table-body';
829 tableEl.appendChild(tableBody);
830 tableBody.setAttribute('role', 'listbox');
831 clist.appendChild(tableEl);
832 input.setAttribute('aria-controls', 'ac-table');
833 input.setAttribute('aria-haspopup', 'grid');
834 } else {
835 tableEl = document.getElementById('ac-table');
836 tableBody = document.getElementById('ac-table-body');
837 while (tableBody.childNodes.length) {
838 tableBody.removeChild(tableBody.childNodes[0]);
839 }
840 }
841
842 // If no choice is selected, then select the first item, if desired.
843 if (ac_selected < 0 && ac_store && ac_store.autoselectFirstRow()) {
844 ac_selected = 0;
845 }
846
847 let headerCount= 0;
848 for (let i = 0; i < Math.min(ac_max_options, ac_completions.length); ++i) {
849 if (ac_completions[i].heading) {
850 var rowEl = document.createElement('tr');
851 tableBody.appendChild(rowEl);
852 const cellEl = document.createElement('th');
853 rowEl.appendChild(cellEl);
854 cellEl.setAttribute('colspan', 2);
855 if (headerCount) {
856 cellEl.appendChild(document.createElement('br'));
857 }
858 cellEl.appendChild(
859 document.createTextNode(ac_completions[i].heading));
860 headerCount++;
861 } else {
862 var rowEl = document.createElement('tr');
863 tableBody.appendChild(rowEl);
864 if (i == ac_selected) {
865 rowEl.className = 'selected';
866 }
867 rowEl.id = `ac-status-row-${i}`;
868 rowEl.setAttribute('data-index', i);
869 rowEl.setAttribute('role', 'option');
870 rowEl.addEventListener('mousedown', function(event) {
871 event.preventDefault();
872 });
873 rowEl.addEventListener('mouseup', function(event) {
874 let target = event.target;
875 while (target && target.tagName != 'TR') {
876 target = target.parentNode;
877 }
878 const idx = Number(target.getAttribute('data-index'));
879 try {
880 _ac_select(idx);
881 } finally {
882 return false;
883 }
884 });
885 rowEl.addEventListener('mouseover', function(event) {
886 let target = event.target;
887 while (target && target.tagName != 'TR') {
888 target = target.parentNode;
889 }
890 const idx = Number(target.getAttribute('data-index'));
891 _ac_mouseover(idx);
892 });
893 const valCellEl = document.createElement('td');
894 rowEl.appendChild(valCellEl);
895 if (ac_completions[i].compSpan) {
896 valCellEl.appendChild(ac_completions[i].compSpan);
897 }
898 const docCellEl = document.createElement('td');
899 rowEl.appendChild(docCellEl);
900 if (ac_completions[i].docSpan &&
901 ac_completions[i].docSpan.textContent) {
902 docCellEl.appendChild(document.createTextNode(' = '));
903 docCellEl.appendChild(ac_completions[i].docSpan);
904 }
905 }
906 }
907
908 // position
909 const inputBounds = nodeBounds(ac_focusedInput);
910 clist.style.left = inputBounds.x + 'px';
911 clist.style.top = (inputBounds.y + inputBounds.h) + 'px';
912
913 window.setTimeout(ac_autoscroll, 100);
914 input.setAttribute('aria-activedescendant', `ac-status-row-${ac_selected}`);
915 // Note - we use '' instead of 'block', since 'block' has odd effects on
916 // the screen in IE, and causes scrollbars to resize
917 clist.style.display = '';
918 } else {
919 tableBody = document.getElementById('ac-table-body');
920 if (clist && tableBody) {
921 clist.style.display = 'none';
922 while (tableBody.childNodes.length) {
923 tableBody.removeChild(tableBody.childNodes[0]);
924 }
925 }
926 }
927}
928
929// TODO(jrobbins): make arrow keys and mouse not conflict if they are
930// used at the same time.
931
932
933/** Scroll the autocomplete menu to show the currently selected row. */
934function ac_autoscroll() {
935 const acList = document.getElementById('ac-list');
936 const acSelRow = acList.getElementsByClassName('selected')[0];
937 const acSelRowTop = acSelRow ? acSelRow.offsetTop : 0;
938 const acSelRowHeight = acSelRow ? acSelRow.offsetHeight : 0;
939
940
941 const EXTRA = 8; // Go an extra few pixels so the next row is partly exposed.
942
943 if (!acList || !acSelRow) return;
944
945 // Autoscroll upward if the selected item is above the visible area,
946 // else autoscroll downward if the selected item is below the visible area.
947 if (acSelRowTop < acList.scrollTop) {
948 acList.scrollTop = acSelRowTop - EXTRA;
949 } else if (acSelRowTop + acSelRowHeight + EXTRA >
950 acList.scrollTop + acList.offsetHeight) {
951 acList.scrollTop = (acSelRowTop + acSelRowHeight -
952 acList.offsetHeight + EXTRA);
953 }
954}
955
956
957/** the position of the text caret in the given text field.
958 *
959 * @param textField an INPUT node with type=text or a TEXTAREA node
960 * @return an index in [0, textField.value.length]
961 */
962function ac_getCaretPosition_(textField) {
963 if ('INPUT' == textField.tagName) {
964 let caret = textField.value.length;
965
966 // chrome/firefox
967 if (undefined != textField.selectionStart) {
968 caret = textField.selectionEnd;
969
970 // JER: Special treatment for issue status field that makes all
971 // options show up more often
972 if (textField.id.startsWith('status')) {
973 caret = textField.selectionStart;
974 }
975 // ie
976 } else if (document.selection) {
977 // get an empty selection range
978 const range = document.selection.createRange();
979 const origSelectionLength = range.text.length;
980 // Force selection start to 0 position
981 range.moveStart('character', -caret);
982 // the caret end position is the new selection length
983 caret = range.text.length;
984
985 // JER: Special treatment for issue status field that makes all
986 // options show up more often
987 if (textField.id.startsWith('status')) {
988 // The amount that the selection grew when we forced start to
989 // position 0 is == the original start position.
990 caret = range.text.length - origSelectionLength;
991 }
992 }
993
994 return caret;
995 } else {
996 // a textarea
997
998 return GetCursorPos(window, textField);
999 }
1000}
1001
1002function getTargetFromEvent(event) {
1003 let targ = event.target || event.srcElement;
1004 if (targ.shadowRoot) {
1005 // Find the element within the shadowDOM.
1006 const path = event.path || event.composedPath();
1007 targ = path[0];
1008 }
1009 return targ;
1010}