blob: b2cb27a9ffd78ffb5b9d04b71d2c4c0881e63ad6 [file] [log] [blame]
// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* An autocomplete library for javascript.
* Public API
* - _ac_install() install global handlers required for everything else to
* function.
* - _ac_register(SC) register a store constructor (see below)
* - _ac_isCompleting() true iff focus is in an auto complete box and the user
* has triggered completion with a keystroke, and completion has not been
* cancelled (programatically or otherwise).
* - _ac_isCompleteListShowing() true if _as_isCompleting and the complete list
* is visible to the user.
* - _ac_cancel() if completing, stop it, otherwise a no-op.
*
*
* A quick example
* // an auto complete store
* var myFavoritestAutoCompleteStore = new _AC_SimpleStore(
* ['some', 'strings', 'to', 'complete']);
*
* // a store constructor
* _ac_register(function (inputNode, keyEvent) {
* if (inputNode.id == 'my-auto-completing-check-box') {
* return myFavoritestAutoCompleteStore;
* }
* return null;
* });
*
* <html>
* <head>
* <script type=text/javascript src=ac.js></script>
* </head>
* <body onload=_ac_install()>
* <!-- the constructor above looks at the id. It could as easily
* - look at the class, name, or value.
* - The autocomplete=off stops browser autocomplete from
* - interfering with our autocomplete
* -->
* <input type=text id="my-auto-completing-check-box"
* autocomplete=off>
* </body>
* </html>
*
*
* Concepts
* - Store Constructor function
* A store constructor is a policy function with the signature
* _AC_Store myStoreConstructor(
* HtmlInputElement|HtmlTextAreaElement inputNode, Event keyEvent)
* When a key event is received on a text input or text area, the autocomplete
* library will try each of the store constructors in turn until it finds one
* that returns an AC_Store which will be used for auto-completion of that
* text box until focus is lost.
*
* - interface _AC_Store
* An autocomplete store encapsulates all operations that affect how a
* particular text node is autocompleted. It has the following operations:
* - String completable(String inputValue, int caret)
* This method returns null if not completable or the section of inputValue
* that is subject to completion. If autocomplete works on items in a
* comma separated list, then the input value "foo, ba" might yield "ba"
* as the completable chunk since it is separated from its predecessor by
* a comma.
* caret is the position of the text cursor (caret) in the text input.
* - _AC_Completion[] completions(String completable,
* _AC_Completion[] toFilter)
* This method returns null if there are no completions. If toFilter is
* not null or undefined, then this method may assume that toFilter was
* returned as a set of completions that contain completable.
* - String substitute(String inputValue, int caret,
* String completable, _AC_Completion completion)
* returns the inputValue with the given completion substituted for the
* given completable. caret has the same meaning as in the
* completable operation.
* - String oncomplete(boolean completed, String key,
* HTMLElement element, String text)
* This method is called when the user hits a completion key. The default
* value is to do nothing, but you can override it if you want. Note that
* key will be null if the user clicked on it to select
* - Boolean autoselectFirstRow()
* This method returns True by default, but subclasses can override it
* to make autocomplete fields that require the user to press the down
* arrow or do a mouseover once before any completion option is considered
* to be selected.
*
* - class _AC_SimpleStore
* An implementation of _AC_Store that completes a set of strings given at
* construct time in a text field with a comma separated value.
*
* - struct _AC_Completion
* a struct with two fields
* - String value : the plain text completion value
* - String html : the value, as html, with the completable in bold.
*
* Key Handling
* Several keys affect completion in an autocompleted input.
* ESC - the escape key cancels autocompleting. The autocompletion will have
* no effect on the focused textbox until it loses focus, regains it, and
* a key is pressed.
* ENTER - completes using the currently selected completion, or if there is
* only one, uses that completion.
* UP ARROW - selects the completion above the current selection.
* DOWN ARROW - selects the completion below the current selection.
*
*
* CSS styles
* The following CSS selector rules can be used to change the completion list
* look:
* #ac-list style of the auto-complete list
* #ac-list .selected style of the selected item
* #ac-list b style of the matching text in a candidate completion
*
* Dependencies
* The library depends on the following libraries:
* javascript:base for definition of key constants and SetCursorPos
* javascript:shapes for nodeBounds()
*/
/**
* install global handlers required for the rest of the module to function.
*/
function _ac_install() {
ac_addHandler_(document.body, 'onkeydown', ac_keyevent_);
ac_addHandler_(document.body, 'onkeypress', ac_keyevent_);
}
/**
* register a store constructor
* @param storeConstructor a function like
* _AC_Store myStoreConstructor(HtmlInputElement|HtmlTextArea, Event)
*/
function _ac_register(storeConstructor) {
// check that not already registered
for (let i = ac_storeConstructors.length; --i >= 0;) {
if (ac_storeConstructors[i] === storeConstructor) {
return;
}
}
ac_storeConstructors.push(storeConstructor);
}
/**
* may be attached as an onfocus handler to a text input to popup autocomplete
* immediately on the box gaining focus.
*/
function _ac_onfocus(event) {
ac_keyevent_(event);
}
/**
* true iff the autocomplete widget is currently active.
*/
function _ac_isCompleting() {
return !!ac_store && !ac_suppressCompletions;
}
/**
* true iff the completion list is displayed.
*/
function _ac_isCompleteListShowing() {
return !!ac_store && !ac_suppressCompletions && ac_completions &&
ac_completions.length;
}
/**
* cancel any autocomplete in progress.
*/
function _ac_cancel() {
ac_suppressCompletions = true;
ac_updateCompletionList(false);
}
/** add a handler without whacking any existing handler. @private */
function ac_addHandler_(node, handlerName, handler) {
const oldHandler = node[handlerName];
if (!oldHandler) {
node[handlerName] = handler;
} else {
node[handlerName] = ac_fnchain_(node[handlerName], handler);
}
return oldHandler;
}
/** cancel the event. @private */
function ac_cancelEvent_(event) {
if ('stopPropagation' in event) {
event.stopPropagation();
} else {
event.cancelBubble = true;
}
// This is handled in IE by returning false from the handler
if ('preventDefault' in event) {
event.preventDefault();
}
}
/** Call two functions, a and b, and return false if either one returns
false. This is used as a primitive way to attach multiple event
handlers to an element without using addEventListener(). This
library predates the availablity of addEventListener().
@private
*/
function ac_fnchain_(a, b) {
return function() {
const ar = a.apply(this, arguments);
const br = b.apply(this, arguments);
// NOTE 1: (undefined && false) -> undefined
// NOTE 2: returning FALSE from a onkeypressed cancels it,
// returning UNDEFINED does not.
// As such, we specifically look for falses here
if (ar === false || br === false) {
return false;
} else {
return true;
}
};
}
/** key press handler. @private */
function ac_keyevent_(event) {
event = event || window.event;
const source = getTargetFromEvent(event);
const isInput = 'INPUT' == source.tagName &&
source.type.match(/^text|email$/i);
const isTextarea = 'TEXTAREA' == source.tagName;
if (!isInput && !isTextarea) return true;
const key = event.key;
const isDown = event.type == 'keydown';
const isShiftKey = event.shiftKey;
let storeFound = true;
if ((source !== ac_focusedInput) || (ac_store === null)) {
ac_focusedInput = source;
storeFound = false;
if (ENTER_KEYNAME !== key && ESC_KEYNAME !== key) {
for (let i = 0; i < ac_storeConstructors.length; ++i) {
const store = (ac_storeConstructors[i])(source, event);
if (store) {
ac_store = store;
ac_store.setAvoid(event);
ac_oldBlurHandler = ac_addHandler_(
ac_focusedInput, 'onblur', _ac_ob);
storeFound = true;
break;
}
}
// There exists an odd condition where an edit box with autocomplete
// attached can be removed from the DOM without blur being called
// In which case we are left with a store around that will try to
// autocomplete the next edit box to receive focus. We need to clean
// this up
// If we can't find a store, force a blur
if (!storeFound) {
_ac_ob(null);
}
}
// ac-table rows need to be removed when switching to another input.
ac_updateCompletionList(false);
}
// If the user typed Esc when the auto-complete menu was not shown,
// then blur the input text field so that the user can use keyboard
// shortcuts.
const acList = document.getElementById('ac-list');
if (ESC_KEYNAME == key &&
(!acList || acList.style.display == 'none')) {
ac_focusedInput.blur();
}
if (!storeFound) return true;
const isCompletion = ac_store.isCompletionKey(key, isDown, isShiftKey);
const hasResults = ac_completions && (ac_completions.length > 0);
let cancelEvent = false;
if (isCompletion && hasResults) {
// Cancel any enter keystrokes if something is selected so that the
// browser doesn't go submitting the form.
cancelEvent = (!ac_suppressCompletions && !!ac_completions &&
(ac_selected != -1));
window.setTimeout(function() {
if (ac_store) {
ac_handleKey_(key, isDown, isShiftKey);
}
}, 0);
} else if (!isCompletion) {
// Don't want to also blur the field. Up and down move the cursor (in
// Firefox) to the start/end of the field. We also don't want that while
// the list is showing.
cancelEvent = (key == ESC_KEYNAME ||
key == DOWN_KEYNAME ||
key == UP_KEYNAME);
window.setTimeout(function() {
if (ac_store) {
ac_handleKey_(key, isDown, isShiftKey);
}
}, 0);
} else { // implicit if (isCompletion && !hasResults)
if (ac_store.oncomplete) {
ac_store.oncomplete(false, key, ac_focusedInput, undefined);
}
}
if (cancelEvent) {
ac_cancelEvent_(event);
}
return !cancelEvent;
}
/** Autocomplete onblur handler. */
function _ac_ob(event) {
if (ac_focusedInput) {
ac_focusedInput.onblur = ac_oldBlurHandler;
}
ac_store = null;
ac_focusedInput = null;
ac_everTyped = false;
ac_oldBlurHandler = null;
ac_suppressCompletions = false;
ac_updateCompletionList(false);
}
/** @constructor */
function _AC_Store() {
}
/** returns the chunk of the input to treat as completable. */
_AC_Store.prototype.completable = function(inputValue, caret) {
console.log('UNIMPLEMENTED completable');
};
/** returns the chunk of the input to treat as completable. */
_AC_Store.prototype.completions = function(prefix, tofilter) {
console.log('UNIMPLEMENTED completions');
};
/** returns the chunk of the input to treat as completable. */
_AC_Store.prototype.oncomplete = function(completed, key, element, text) {
// Call the onkeyup handler so that choosing an autocomplete option has
// the same side-effect as typing. E.g., exposing the next row of input
// fields.
element.dispatchEvent(new Event('keyup'));
_ac_ob();
};
/** substitutes a completion for a completable in a text input's value. */
_AC_Store.prototype.substitute =
function(inputValue, caret, completable, completion) {
console.log('UNIMPLEMENTED substitute');
};
/** true iff hitting a comma key should complete. */
_AC_Store.prototype.commaCompletes = true;
/**
* true iff the given keystroke should cause a completion (and be consumed in
* the process.
*/
_AC_Store.prototype.isCompletionKey = function(key, isDown, isShiftKey) {
if (!isDown && (ENTER_KEYNAME === key ||
(COMMA_KEYNAME == key && this.commaCompletes))) {
return true;
}
if (TAB_KEYNAME === key && !isShiftKey) {
// IE doesn't fire an event for tab on click in a text field, and firefox
// requires that the onkeypress event for tab be consumed or it navigates
// to next field.
return false;
// JER: return isDown == BR_IsIE();
}
return false;
};
_AC_Store.prototype.setAvoid = function(event) {
if (event && event.avoidValues) {
ac_avoidValues = event.avoidValues;
} else {
ac_avoidValues = this.computeAvoid();
}
ac_avoidValues = ac_avoidValues.map((val) => val.toLowerCase());
};
/* Subclasses may implement this to compute values to avoid
offering in the current input field, i.e., because those
values are already used. */
_AC_Store.prototype.computeAvoid = function() {
return [];
};
function _AC_AddItemToFirstCharMap(firstCharMap, ch, s) {
let l = firstCharMap[ch];
if (!l) {
l = firstCharMap[ch] = [];
} else if (l[l.length - 1].value == s) {
return;
}
l.push(new _AC_Completion(s, null, ''));
}
/**
* an _AC_Store implementation suitable for completing lists of email
* addresses.
* @constructor
*/
function _AC_SimpleStore(strings, opt_docStrings) {
this.firstCharMap_ = {};
for (let i = 0; i < strings.length; ++i) {
let s = strings[i];
if (!s) {
continue;
}
if (opt_docStrings && opt_docStrings[s]) {
s = s + ' ' + opt_docStrings[s];
}
const parts = s.split(/\W+/);
for (let j = 0; j < parts.length; ++j) {
if (parts[j]) {
_AC_AddItemToFirstCharMap(
this.firstCharMap_, parts[j].charAt(0).toLowerCase(), strings[i]);
}
}
}
// The maximimum number of results that we are willing to show
this.countThreshold = 2500;
this.docstrings = opt_docStrings || {};
}
_AC_SimpleStore.prototype = new _AC_Store();
_AC_SimpleStore.prototype.constructor = _AC_SimpleStore;
_AC_SimpleStore.prototype.completable =
function(inputValue, caret) {
// complete after the last comma not inside ""s
let start = 0;
let state = 0;
for (let i = 0; i < caret; ++i) {
const ch = inputValue.charAt(i);
switch (state) {
case 0:
if ('"' == ch) {
state = 1;
} else if (',' == ch || ' ' == ch) {
start = i + 1;
}
break;
case 1:
if ('"' == ch) {
state = 0;
}
break;
}
}
while (start < caret &&
' \t\r\n'.indexOf(inputValue.charAt(start)) >= 0) {
++start;
}
return inputValue.substring(start, caret);
};
/** Simple function to create a <span> with matching text in bold.
*/
function _AC_CreateSpanWithMatchHighlighted(match) {
const span = document.createElement('span');
span.appendChild(document.createTextNode(match[1] || ''));
const bold = document.createElement('b');
span.appendChild(bold);
bold.appendChild(document.createTextNode(match[2]));
span.appendChild(document.createTextNode(match[3] || ''));
return span;
};
/**
* Get all completions matching the given prefix.
* @param {string} prefix The prefix of the text to autocomplete on.
* @param {List.<string>?} toFilter Optional list to filter on. Otherwise will
* use this.firstCharMap_ using the prefix's first character.
* @return {List.<_AC_Completion>} The computed list of completions.
*/
_AC_SimpleStore.prototype.completions = function(prefix) {
if (!prefix) {
return [];
}
toFilter = this.firstCharMap_[prefix.charAt(0).toLowerCase()];
// Since we use prefix to build a regular expression, we need to escape RE
// characters. We match '-', '{', '$' and others in the prefix and convert
// them into "\-", "\{", "\$".
const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
const modifiedPrefix = prefix.replace(regexForRegexCharacters, '\\$1');
// Match the modifiedPrefix anywhere as long as it is either at the very
// beginning "Th" -> "The Hobbit", or comes immediately after a word separator
// such as "Ga" -> "The-Great-Gatsby".
const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
const pattern = new RegExp(patternRegex, 'i' /* ignore case */);
// We keep separate lists of possible completions that were generated
// by matching a value or generated by matching a docstring. We return
// a concatenated list so that value matches all come before docstring
// matches.
const completions = [];
const docCompletions = [];
if (toFilter) {
const toFilterLength = toFilter.length;
for (let i = 0; i < toFilterLength; ++i) {
const docStr = this.docstrings[toFilter[i].value];
let compSpan = null;
let docSpan = null;
const matches = toFilter[i].value.match(pattern);
const docMatches = docStr && docStr.match(pattern);
if (matches) {
compSpan = _AC_CreateSpanWithMatchHighlighted(matches);
if (docStr) docSpan = document.createTextNode(docStr);
} else if (docMatches) {
compSpan = document.createTextNode(toFilter[i].value);
docSpan = _AC_CreateSpanWithMatchHighlighted(docMatches);
}
if (compSpan) {
const newCompletion = new _AC_Completion(
toFilter[i].value, compSpan, docSpan);
if (matches) {
completions.push(newCompletion);
} else {
docCompletions.push(newCompletion);
}
if (completions.length + docCompletions.length > this.countThreshold) {
break;
}
}
}
}
return completions.concat(docCompletions);
};
// Normally, when the user types a few characters, we aggressively
// select the first possible completion (if any). When the user
// hits ENTER, that first completion is substituted. When that
// behavior is not desired, override this to return false.
_AC_SimpleStore.prototype.autoselectFirstRow = function() {
return true;
};
// Comparison function for _AC_Completion
function _AC_CompareACCompletion(a, b) {
// convert it to lower case and remove all leading junk
const aval = a.value.toLowerCase().replace(/^\W*/, '');
const bval = b.value.toLowerCase().replace(/^\W*/, '');
if (a.value === b.value) {
return 0;
} else if (aval < bval) {
return -1;
} else {
return 1;
}
}
_AC_SimpleStore.prototype.substitute =
function(inputValue, caret, completable, completion) {
return inputValue.substring(0, caret - completable.length) +
completion.value + ', ' + inputValue.substring(caret);
};
/**
* a possible completion.
* @constructor
*/
function _AC_Completion(value, compSpan, docSpan) {
/** plain text. */
this.value = value;
if (typeof compSpan == 'string') compSpan = document.createTextNode(compSpan);
this.compSpan = compSpan;
if (typeof docSpan == 'string') docSpan = document.createTextNode(docSpan);
this.docSpan = docSpan;
}
_AC_Completion.prototype.toString = function() {
return '(AC_Completion: ' + this.value + ')';
};
/** registered store constructors. @private */
var ac_storeConstructors = [];
/**
* the focused text input or textarea whether store is null or not.
* A text input may have focus and this may be null iff no key has been typed in
* the text input.
*/
var ac_focusedInput = null;
/**
* null or the autocomplete store used to complete ac_focusedInput.
* @private
*/
var ac_store = null;
/** store handler from ac_focusedInput. @private */
var ac_oldBlurHandler = null;
/**
* true iff user has indicated completions are unwanted (via ESC key)
* @private
*/
var ac_suppressCompletions = false;
/**
* chunk of completable text seen last keystroke.
* Used to generate ac_completions.
* @private
*/
let ac_lastCompletable = null;
/** an array of _AC_Completions. @private */
var ac_completions = null;
/** -1 or in [0, _AC_Completions.length). @private */
var ac_selected = -1;
/** Maximum number of options displayed in menu. @private */
const ac_max_options = 100;
/** Don't offer these values because they are already used. @private */
let ac_avoidValues = [];
/**
* handles all the key strokes, updating the completion list, tracking selected
* element, performing substitutions, etc.
* @private
*/
function ac_handleKey_(key, isDown, isShiftKey) {
// check completions
ac_checkCompletions();
let show = true;
const numCompletions = ac_completions ? ac_completions.length : 0;
// handle enter and tab on key press and the rest on key down
if (ac_store.isCompletionKey(key, isDown, isShiftKey)) {
if (ac_selected < 0 && numCompletions >= 1 &&
ac_store.autoselectFirstRow()) {
ac_selected = 0;
}
if (ac_selected >= 0) {
const backupInput = ac_focusedInput;
const completeValue = ac_completions[ac_selected].value;
ac_complete();
if (ac_store.oncomplete) {
ac_store.oncomplete(true, key, backupInput, completeValue);
}
}
} else {
switch (key) {
case ESC_KEYNAME: // escape
// JER?? ac_suppressCompletions = true;
ac_selected = -1;
show = false;
break;
case UP_KEYNAME: // up
if (isDown) {
// firefox fires arrow events on both down and press, but IE only fires
// then on press.
ac_selected = Math.max(numCompletions >= 0 ? 0 : -1, ac_selected - 1);
}
break;
case DOWN_KEYNAME: // down
if (isDown) {
ac_selected = Math.min(
ac_max_options - 1, Math.min(numCompletions - 1, ac_selected + 1));
}
break;
}
if (isDown) {
switch (key) {
case ESC_KEYNAME:
case ENTER_KEYNAME:
case UP_KEYNAME:
case DOWN_KEYNAME:
case RIGHT_KEYNAME:
case LEFT_KEYNAME:
case TAB_KEYNAME:
case SHIFT_KEYNAME:
case BACKSPACE_KEYNAME:
case DELETE_KEYNAME:
break;
default: // User typed some new characters.
ac_everTyped = true;
}
}
}
if (ac_focusedInput) {
ac_updateCompletionList(show);
}
}
/**
* called when an option is clicked on to select that option.
*/
function _ac_select(optionIndex) {
ac_selected = optionIndex;
ac_complete();
if (ac_store.oncomplete) {
ac_store.oncomplete(true, null, ac_focusedInput, ac_focusedInput.value);
}
// check completions
ac_checkCompletions();
ac_updateCompletionList(true);
}
function _ac_mouseover(optionIndex) {
ac_selected = optionIndex;
ac_updateCompletionList(true);
}
/** perform the substitution of the currently selected item. */
function ac_complete() {
const caret = ac_getCaretPosition_(ac_focusedInput);
const completion = ac_completions[ac_selected];
ac_focusedInput.value = ac_store.substitute(
ac_focusedInput.value, caret,
ac_lastCompletable, completion);
// When the prefix starts with '*' we want to return the complete set of all
// possible completions. We treat the ac_lastCompletable value as empty so
// that the caret is correctly calculated (i.e. the caret should not consider
// placeholder values like '*member').
let new_caret = caret + completion.value.length;
if (!ac_lastCompletable.startsWith('*')) {
// Only consider the ac_lastCompletable length if it does not start with '*'
new_caret = new_caret - ac_lastCompletable.length;
}
// If we inserted something ending in two quotation marks, position
// the cursor between the quotation marks. If we inserted a complete term,
// skip over the trailing space so that the user is ready to enter the next
// term. If we inserted just a search operator, leave the cursor immediately
// after the colon or equals and don't skip over the space.
if (completion.value.substring(completion.value.length - 2) == '""') {
new_caret--;
} else if (completion.value.substring(completion.value.length - 1) != ':' &&
completion.value.substring(completion.value.length - 1) != '=') {
new_caret++; // To account for the comma.
new_caret++; // To account for the space after the comma.
}
ac_selected = -1;
ac_completions = null;
ac_lastCompletable = null;
ac_everTyped = false;
SetCursorPos(window, ac_focusedInput, new_caret);
}
/**
* True if the user has ever typed any actual characters in the currently
* focused text field. False if they have only clicked, backspaced, and
* used the arrow keys.
*/
var ac_everTyped = false;
/**
* maintains ac_completions, ac_selected, ac_lastCompletable.
* @private
*/
function ac_checkCompletions() {
if (ac_focusedInput && !ac_suppressCompletions) {
const caret = ac_getCaretPosition_(ac_focusedInput);
const completable = ac_store.completable(ac_focusedInput.value, caret);
// If we already have completed, then our work here is done.
if (completable == ac_lastCompletable) {
return;
}
ac_completions = null;
ac_selected = -1;
const oldSelected =
((ac_selected >= 0 && ac_selected < ac_completions.length) ?
ac_completions[ac_selected].value : null);
ac_completions = ac_store.completions(completable);
// Don't offer options for values that the user has already used
// in another part of the current form.
ac_completions = ac_completions.filter((comp) =>
FindInArray(ac_avoidValues, comp.value.toLowerCase()) === -1);
ac_selected = oldSelected ? 0 : -1;
ac_lastCompletable = completable;
return;
}
ac_lastCompletable = null;
ac_completions = null;
ac_selected = -1;
}
/**
* maintains the completion list GUI.
* @private
*/
function ac_updateCompletionList(show) {
let clist = document.getElementById('ac-list');
const input = ac_focusedInput;
if (input) {
input.setAttribute('aria-activedescendant', 'ac-status-row-none');
}
let tableEl;
let tableBody;
if (show && ac_completions && ac_completions.length) {
if (!clist) {
clist = document.createElement('DIV');
clist.id = 'ac-list';
clist.style.position = 'absolute';
clist.style.display = 'none';
// with 'listbox' and 'option' roles, screenreader narrates total
// number of options eg. 'New = issue has not .... 1 of 9'
document.body.appendChild(clist);
tableEl = document.createElement('table');
tableEl.setAttribute('cellpadding', 0);
tableEl.setAttribute('cellspacing', 0);
tableEl.id = 'ac-table';
tableEl.setAttribute('role', 'presentation');
tableBody = document.createElement('tbody');
tableBody.id = 'ac-table-body';
tableEl.appendChild(tableBody);
tableBody.setAttribute('role', 'listbox');
clist.appendChild(tableEl);
input.setAttribute('aria-controls', 'ac-table');
input.setAttribute('aria-haspopup', 'grid');
} else {
tableEl = document.getElementById('ac-table');
tableBody = document.getElementById('ac-table-body');
while (tableBody.childNodes.length) {
tableBody.removeChild(tableBody.childNodes[0]);
}
}
// If no choice is selected, then select the first item, if desired.
if (ac_selected < 0 && ac_store && ac_store.autoselectFirstRow()) {
ac_selected = 0;
}
let headerCount= 0;
for (let i = 0; i < Math.min(ac_max_options, ac_completions.length); ++i) {
if (ac_completions[i].heading) {
var rowEl = document.createElement('tr');
tableBody.appendChild(rowEl);
const cellEl = document.createElement('th');
rowEl.appendChild(cellEl);
cellEl.setAttribute('colspan', 2);
if (headerCount) {
cellEl.appendChild(document.createElement('br'));
}
cellEl.appendChild(
document.createTextNode(ac_completions[i].heading));
headerCount++;
} else {
var rowEl = document.createElement('tr');
tableBody.appendChild(rowEl);
if (i == ac_selected) {
rowEl.className = 'selected';
}
rowEl.id = `ac-status-row-${i}`;
rowEl.setAttribute('data-index', i);
rowEl.setAttribute('role', 'option');
rowEl.addEventListener('mousedown', function(event) {
event.preventDefault();
});
rowEl.addEventListener('mouseup', function(event) {
let target = event.target;
while (target && target.tagName != 'TR') {
target = target.parentNode;
}
const idx = Number(target.getAttribute('data-index'));
try {
_ac_select(idx);
} finally {
return false;
}
});
rowEl.addEventListener('mouseover', function(event) {
let target = event.target;
while (target && target.tagName != 'TR') {
target = target.parentNode;
}
const idx = Number(target.getAttribute('data-index'));
_ac_mouseover(idx);
});
const valCellEl = document.createElement('td');
rowEl.appendChild(valCellEl);
if (ac_completions[i].compSpan) {
valCellEl.appendChild(ac_completions[i].compSpan);
}
const docCellEl = document.createElement('td');
rowEl.appendChild(docCellEl);
if (ac_completions[i].docSpan &&
ac_completions[i].docSpan.textContent) {
docCellEl.appendChild(document.createTextNode(' = '));
docCellEl.appendChild(ac_completions[i].docSpan);
}
}
}
// position
const inputBounds = nodeBounds(ac_focusedInput);
clist.style.left = inputBounds.x + 'px';
clist.style.top = (inputBounds.y + inputBounds.h) + 'px';
window.setTimeout(ac_autoscroll, 100);
input.setAttribute('aria-activedescendant', `ac-status-row-${ac_selected}`);
// Note - we use '' instead of 'block', since 'block' has odd effects on
// the screen in IE, and causes scrollbars to resize
clist.style.display = '';
} else {
tableBody = document.getElementById('ac-table-body');
if (clist && tableBody) {
clist.style.display = 'none';
while (tableBody.childNodes.length) {
tableBody.removeChild(tableBody.childNodes[0]);
}
}
}
}
// TODO(jrobbins): make arrow keys and mouse not conflict if they are
// used at the same time.
/** Scroll the autocomplete menu to show the currently selected row. */
function ac_autoscroll() {
const acList = document.getElementById('ac-list');
const acSelRow = acList.getElementsByClassName('selected')[0];
const acSelRowTop = acSelRow ? acSelRow.offsetTop : 0;
const acSelRowHeight = acSelRow ? acSelRow.offsetHeight : 0;
const EXTRA = 8; // Go an extra few pixels so the next row is partly exposed.
if (!acList || !acSelRow) return;
// Autoscroll upward if the selected item is above the visible area,
// else autoscroll downward if the selected item is below the visible area.
if (acSelRowTop < acList.scrollTop) {
acList.scrollTop = acSelRowTop - EXTRA;
} else if (acSelRowTop + acSelRowHeight + EXTRA >
acList.scrollTop + acList.offsetHeight) {
acList.scrollTop = (acSelRowTop + acSelRowHeight -
acList.offsetHeight + EXTRA);
}
}
/** the position of the text caret in the given text field.
*
* @param textField an INPUT node with type=text or a TEXTAREA node
* @return an index in [0, textField.value.length]
*/
function ac_getCaretPosition_(textField) {
if ('INPUT' == textField.tagName) {
let caret = textField.value.length;
// chrome/firefox
if (undefined != textField.selectionStart) {
caret = textField.selectionEnd;
// JER: Special treatment for issue status field that makes all
// options show up more often
if (textField.id.startsWith('status')) {
caret = textField.selectionStart;
}
// ie
} else if (document.selection) {
// get an empty selection range
const range = document.selection.createRange();
const origSelectionLength = range.text.length;
// Force selection start to 0 position
range.moveStart('character', -caret);
// the caret end position is the new selection length
caret = range.text.length;
// JER: Special treatment for issue status field that makes all
// options show up more often
if (textField.id.startsWith('status')) {
// The amount that the selection grew when we forced start to
// position 0 is == the original start position.
caret = range.text.length - origSelectionLength;
}
}
return caret;
} else {
// a textarea
return GetCursorPos(window, textField);
}
}
function getTargetFromEvent(event) {
let targ = event.target || event.srcElement;
if (targ.shadowRoot) {
// Find the element within the shadowDOM.
const path = event.path || event.composedPath();
targ = path[0];
}
return targ;
}