diff options
Diffstat (limited to 'toolkit/content/widgets/autocomplete-input.js')
-rw-r--r-- | toolkit/content/widgets/autocomplete-input.js | 647 |
1 files changed, 647 insertions, 0 deletions
diff --git a/toolkit/content/widgets/autocomplete-input.js b/toolkit/content/widgets/autocomplete-input.js new file mode 100644 index 0000000000..36105ba4d7 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-input.js @@ -0,0 +1,647 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + class AutocompleteInput extends HTMLInputElement { + constructor() { + super(); + + this.popupSelectedIndex = -1; + + ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "disablePopupAutohide", + "ui.popup.disable_autohide", + false + ); + + this.addEventListener("input", event => { + this.onInput(event); + }); + + this.addEventListener("keydown", event => this.handleKeyDown(event)); + + this.addEventListener( + "compositionstart", + event => { + if ( + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.handleStartComposition(); + } + }, + true + ); + + this.addEventListener( + "compositionend", + event => { + if ( + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.handleEndComposition(); + } + }, + true + ); + + this.addEventListener( + "focus", + event => { + this.attachController(); + if ( + window.gBrowser && + window.gBrowser.selectedBrowser.hasAttribute("usercontextid") + ) { + this.userContextId = parseInt( + window.gBrowser.selectedBrowser.getAttribute("usercontextid") + ); + } else { + this.userContextId = 0; + } + }, + true + ); + + this.addEventListener( + "blur", + event => { + if (!this._dontBlur) { + if (this.forceComplete && this.mController.matchCount >= 1) { + // If forceComplete is requested, we need to call the enter processing + // on blur so the input will be forced to the closest match. + // Thunderbird is the only consumer of forceComplete and this is used + // to force an recipient's email to the exact address book entry. + this.mController.handleEnter(true); + } + if (!this.ignoreBlurWhileSearching) { + this._dontClosePopup = this.disablePopupAutohide; + this.detachController(); + } + } + }, + true + ); + } + + connectedCallback() { + this.setAttribute("is", "autocomplete-input"); + this.setAttribute("autocomplete", "off"); + + this.mController = Cc[ + "@mozilla.org/autocomplete/controller;1" + ].getService(Ci.nsIAutoCompleteController); + this.mSearchNames = null; + this.mIgnoreInput = false; + this.noRollupOnEmptySearch = false; + + this._popup = null; + + this.nsIAutocompleteInput = this.getCustomInterfaceCallback( + Ci.nsIAutoCompleteInput + ); + + this.valueIsTyped = false; + } + + get popup() { + // Memoize the result in a field rather than replacing this property, + // so that it can be reset along with the binding. + if (this._popup) { + return this._popup; + } + + let popup = null; + let popupId = this.getAttribute("autocompletepopup"); + if (popupId) { + popup = document.getElementById(popupId); + } + + /* This path is only used in tests, we have the <popupset> and <panel> + in document for other usages */ + if (!popup) { + popup = document.createXULElement("panel", { + is: "autocomplete-richlistbox-popup", + }); + popup.setAttribute("type", "autocomplete-richlistbox"); + popup.setAttribute("noautofocus", "true"); + + if (!this._popupset) { + this._popupset = document.createXULElement("popupset"); + document.documentElement.appendChild(this._popupset); + } + + this._popupset.appendChild(popup); + } + popup.mInput = this; + + return (this._popup = popup); + } + + get popupElement() { + return this.popup; + } + + get controller() { + return this.mController; + } + + set popupOpen(val) { + if (val) { + this.openPopup(); + } else { + this.closePopup(); + } + } + + get popupOpen() { + return this.popup.popupOpen; + } + + set disableAutoComplete(val) { + this.setAttribute("disableautocomplete", val); + } + + get disableAutoComplete() { + return this.getAttribute("disableautocomplete") == "true"; + } + + set completeDefaultIndex(val) { + this.setAttribute("completedefaultindex", val); + } + + get completeDefaultIndex() { + return this.getAttribute("completedefaultindex") == "true"; + } + + set completeSelectedIndex(val) { + this.setAttribute("completeselectedindex", val); + } + + get completeSelectedIndex() { + return this.getAttribute("completeselectedindex") == "true"; + } + + set forceComplete(val) { + this.setAttribute("forcecomplete", val); + } + + get forceComplete() { + return this.getAttribute("forcecomplete") == "true"; + } + + set minResultsForPopup(val) { + this.setAttribute("minresultsforpopup", val); + } + + get minResultsForPopup() { + var m = parseInt(this.getAttribute("minresultsforpopup")); + return isNaN(m) ? 1 : m; + } + + set timeout(val) { + this.setAttribute("timeout", val); + } + + get timeout() { + var t = parseInt(this.getAttribute("timeout")); + return isNaN(t) ? 50 : t; + } + + set searchParam(val) { + this.setAttribute("autocompletesearchparam", val); + } + + get searchParam() { + return this.getAttribute("autocompletesearchparam") || ""; + } + + get searchCount() { + this.initSearchNames(); + return this.mSearchNames.length; + } + + get inPrivateContext() { + return this.PrivateBrowsingUtils.isWindowPrivate(window); + } + + get noRollupOnCaretMove() { + return this.popup.getAttribute("norolluponanchor") == "true"; + } + + set textValue(val) { + // "input" event is automatically dispatched by the editor if + // necessary. + this._setValueInternal(val, true); + } + + get textValue() { + return this.value; + } + /** + * =================== nsIDOMXULMenuListElement =================== + */ + get editable() { + return true; + } + + set open(val) { + if (val) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + get open() { + return this.getAttribute("open") == "true"; + } + + set value(val) { + this._setValueInternal(val, false); + } + + get value() { + return super.value; + } + + get focused() { + return this === document.activeElement; + } + /** + * maximum number of rows to display at a time when opening the popup normally + * (e.g., focus element and press the down arrow) + */ + set maxRows(val) { + this.setAttribute("maxrows", val); + } + + get maxRows() { + return parseInt(this.getAttribute("maxrows")) || 0; + } + /** + * maximum number of rows to display at a time when opening the popup by + * clicking the dropmarker (for inputs that have one) + */ + set maxdropmarkerrows(val) { + this.setAttribute("maxdropmarkerrows", val); + } + + get maxdropmarkerrows() { + return parseInt(this.getAttribute("maxdropmarkerrows"), 10) || 14; + } + /** + * option to allow scrolling through the list via the tab key, rather than + * tab moving focus out of the textbox + */ + set tabScrolling(val) { + this.setAttribute("tabscrolling", val); + } + + get tabScrolling() { + return this.getAttribute("tabscrolling") == "true"; + } + /** + * option to completely ignore any blur events while searches are + * still going on. + */ + set ignoreBlurWhileSearching(val) { + this.setAttribute("ignoreblurwhilesearching", val); + } + + get ignoreBlurWhileSearching() { + return this.getAttribute("ignoreblurwhilesearching") == "true"; + } + /** + * option to highlight entries that don't have any matches + */ + set highlightNonMatches(val) { + this.setAttribute("highlightnonmatches", val); + } + + get highlightNonMatches() { + return this.getAttribute("highlightnonmatches") == "true"; + } + + getSearchAt(aIndex) { + this.initSearchNames(); + return this.mSearchNames[aIndex]; + } + + selectTextRange(aStartIndex, aEndIndex) { + super.setSelectionRange(aStartIndex, aEndIndex); + } + + onSearchBegin() { + if (this.popup && typeof this.popup.onSearchBegin == "function") { + this.popup.onSearchBegin(); + } + } + + onSearchComplete() { + if (this.mController.matchCount == 0) { + this.setAttribute("nomatch", "true"); + } else { + this.removeAttribute("nomatch"); + } + + if (this.ignoreBlurWhileSearching && !this.focused) { + this.handleEnter(); + this.detachController(); + } + } + + onTextEntered(event) { + if (this.getAttribute("notifylegacyevents") === "true") { + let e = new CustomEvent("textEntered", { + bubbles: false, + cancelable: true, + detail: { rootEvent: event }, + }); + return !this.dispatchEvent(e); + } + return false; + } + + onTextReverted(event) { + if (this.getAttribute("notifylegacyevents") === "true") { + let e = new CustomEvent("textReverted", { + bubbles: false, + cancelable: true, + detail: { rootEvent: event }, + }); + return !this.dispatchEvent(e); + } + return false; + } + + /** + * =================== PRIVATE MEMBERS =================== + */ + + /* + * ::::::::::::: autocomplete controller ::::::::::::: + */ + + attachController() { + this.mController.input = this.nsIAutocompleteInput; + } + + detachController() { + if ( + this.mController.input && + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.input = null; + } + } + + /** + * ::::::::::::: popup opening ::::::::::::: + */ + openPopup() { + if (this.focused) { + this.popup.openAutocompletePopup(this.nsIAutocompleteInput, this); + } + } + + closePopup() { + if (this._dontClosePopup) { + delete this._dontClosePopup; + return; + } + this.popup.closePopup(); + } + + showHistoryPopup() { + // Store our "normal" maxRows on the popup, so that it can reset the + // value when the popup is hidden. + this.popup._normalMaxRows = this.maxRows; + + // Temporarily change our maxRows, since we want the dropdown to be a + // different size in this case. The popup's popupshowing/popuphiding + // handlers will take care of resetting this. + this.maxRows = this.maxdropmarkerrows; + + // Ensure that we have focus. + if (!this.focused) { + this.focus(); + } + this.attachController(); + this.mController.startSearch(""); + } + + toggleHistoryPopup() { + if (!this.popup.popupOpen) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + handleKeyDown(aEvent) { + // Re: urlbarDeferred, see the comment in urlbarBindings.xml. + if (aEvent.defaultPrevented && !aEvent.urlbarDeferred) { + return false; + } + + if ( + typeof this.onBeforeHandleKeyDown == "function" && + this.onBeforeHandleKeyDown(aEvent) + ) { + return true; + } + + const isMac = AppConstants.platform == "macosx"; + var cancel = false; + + // Catch any keys that could potentially move the caret. Ctrl can be + // used in combination with these keys on Windows and Linux; and Alt + // can be used on OS X, so make sure the unused one isn't used. + let metaKey = isMac ? aEvent.ctrlKey : aEvent.altKey; + if (!metaKey) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_LEFT: + case KeyEvent.DOM_VK_RIGHT: + case KeyEvent.DOM_VK_HOME: + cancel = this.mController.handleKeyNavigation(aEvent.keyCode); + break; + } + } + + // Handle keys that are not part of a keyboard shortcut (no Ctrl or Alt) + if (!aEvent.ctrlKey && !aEvent.altKey) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_TAB: + if (this.tabScrolling && this.popup.popupOpen) { + cancel = this.mController.handleKeyNavigation( + aEvent.shiftKey ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN + ); + } else if (this.forceComplete && this.mController.matchCount >= 1) { + this.mController.handleTab(); + } + break; + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_PAGE_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + cancel = this.mController.handleKeyNavigation(aEvent.keyCode); + break; + } + } + + // Handle readline/emacs-style navigation bindings on Mac. + if ( + isMac && + this.popup.popupOpen && + aEvent.ctrlKey && + (aEvent.key === "n" || aEvent.key === "p") + ) { + const effectiveKey = + aEvent.key === "p" ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN; + cancel = this.mController.handleKeyNavigation(effectiveKey); + } + + // Handle keys we know aren't part of a shortcut, even with Alt or + // Ctrl. + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + cancel = this.mController.handleEscape(); + break; + case KeyEvent.DOM_VK_RETURN: + if (isMac) { + // Prevent the default action, since it will beep on Mac + if (aEvent.metaKey) { + aEvent.preventDefault(); + } + } + if (this.popup.selectedIndex >= 0) { + this.popupSelectedIndex = this.popup.selectedIndex; + } + cancel = this.handleEnter(aEvent); + break; + case KeyEvent.DOM_VK_DELETE: + if (isMac && !aEvent.shiftKey) { + break; + } + cancel = this.handleDelete(); + break; + case KeyEvent.DOM_VK_BACK_SPACE: + if (isMac && aEvent.shiftKey) { + cancel = this.handleDelete(); + } + break; + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_UP: + if (aEvent.altKey) { + this.toggleHistoryPopup(); + } + break; + case KeyEvent.DOM_VK_F4: + if (!isMac) { + this.toggleHistoryPopup(); + } + break; + } + + if (cancel) { + aEvent.stopPropagation(); + aEvent.preventDefault(); + } + + return true; + } + + handleEnter(event) { + return this.mController.handleEnter(false, event || null); + } + + handleDelete() { + return this.mController.handleDelete(); + } + + /** + * ::::::::::::: miscellaneous ::::::::::::: + */ + initSearchNames() { + if (!this.mSearchNames) { + var names = this.getAttribute("autocompletesearch"); + if (!names) { + this.mSearchNames = []; + } else { + this.mSearchNames = names.split(" "); + } + } + } + + _focus() { + this._dontBlur = true; + this.focus(); + this._dontBlur = false; + } + + resetActionType() { + if (this.mIgnoreInput) { + return; + } + this.removeAttribute("actiontype"); + } + + _setValueInternal(value, isUserInput) { + this.mIgnoreInput = true; + + if (typeof this.onBeforeValueSet == "function") { + value = this.onBeforeValueSet(value); + } + + this.valueIsTyped = false; + if (isUserInput) { + super.setUserInput(value); + } else { + super.value = value; + } + + this.mIgnoreInput = false; + var event = document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + super.dispatchEvent(event); + return value; + } + + onInput(aEvent) { + if ( + !this.mIgnoreInput && + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.valueIsTyped = true; + this.mController.handleText(); + } + this.resetActionType(); + } + } + + MozHTMLElement.implementCustomInterface(AutocompleteInput, [ + Ci.nsIAutoCompleteInput, + Ci.nsIDOMXULMenuListElement, + ]); + customElements.define("autocomplete-input", AutocompleteInput, { + extends: "input", + }); +} |