gecko-dev/toolkit/content/widgets/autocomplete-popup.js

619 строки
20 KiB
JavaScript

/* 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 MozPopupElement = MozElements.MozElementMixin(XULPopupElement);
MozElements.MozAutocompleteRichlistboxPopup = class MozAutocompleteRichlistboxPopup extends MozPopupElement {
constructor() {
super();
this.mInput = null;
this.mPopupOpen = false;
this._currentIndex = 0;
this._disabledItemClicked = false;
this.setListeners();
}
initialize() {
this.setAttribute("ignorekeys", "true");
this.setAttribute("level", "top");
this.setAttribute("consumeoutsideclicks", "never");
this.textContent = "";
this.appendChild(this.constructor.fragment);
/**
* This is the default number of rows that we give the autocomplete
* popup when the textbox doesn't have a "maxrows" attribute
* for us to use.
*/
this.defaultMaxRows = 6;
/**
* In some cases (e.g. when the input's dropmarker button is clicked),
* the input wants to display a popup with more rows. In that case, it
* should increase its maxRows property and store the "normal" maxRows
* in this field. When the popup is hidden, we restore the input's
* maxRows to the value stored in this field.
*
* This field is set to -1 between uses so that we can tell when it's
* been set by the input and when we need to set it in the popupshowing
* handler.
*/
this._normalMaxRows = -1;
this._previousSelectedIndex = -1;
this.mLastMoveTime = Date.now();
this.mousedOverIndex = -1;
this._richlistbox = this.querySelector(".autocomplete-richlistbox");
if (!this.listEvents) {
this.listEvents = {
handleEvent: event => {
if (!this.parentNode) {
return;
}
switch (event.type) {
case "mousedown":
this._disabledItemClicked = !!event.target.closest(
"richlistitem"
)?.disabled;
break;
case "mouseup":
// Don't call onPopupClick for the scrollbar buttons, thumb,
// slider, etc. If we hit the richlistbox and not a
// richlistitem, we ignore the event.
if (
event.target.closest("richlistbox,richlistitem").localName ==
"richlistitem" &&
!this._disabledItemClicked
) {
this.onPopupClick(event);
}
this._disabledItemClicked = false;
break;
case "mousemove":
if (Date.now() - this.mLastMoveTime <= 30) {
return;
}
let item = event.target.closest("richlistbox,richlistitem");
// If we hit the richlistbox and not a richlistitem, we ignore
// the event.
if (item.localName == "richlistbox") {
return;
}
let index = this.richlistbox.getIndexOfItem(item);
this.mousedOverIndex = index;
if (item.selectedByMouseOver) {
this.richlistbox.selectedIndex = index;
}
this.mLastMoveTime = Date.now();
break;
}
},
};
}
this.richlistbox.addEventListener("mousedown", this.listEvents);
this.richlistbox.addEventListener("mouseup", this.listEvents);
this.richlistbox.addEventListener("mousemove", this.listEvents);
}
get richlistbox() {
if (!this._richlistbox) {
this.initialize();
}
return this._richlistbox;
}
static get markup() {
return `
<richlistbox class="autocomplete-richlistbox" flex="1"/>
`;
}
/**
* nsIAutoCompletePopup
*/
get input() {
return this.mInput;
}
get overrideValue() {
return null;
}
get popupOpen() {
return this.mPopupOpen;
}
get maxRows() {
return (this.mInput && this.mInput.maxRows) || this.defaultMaxRows;
}
set selectedIndex(val) {
if (val != this.richlistbox.selectedIndex) {
this._previousSelectedIndex = this.richlistbox.selectedIndex;
}
this.richlistbox.selectedIndex = val;
// Since ensureElementIsVisible may cause an expensive Layout flush,
// invoke it only if there may be a scrollbar, so if we could fetch
// more results than we can show at once.
// maxResults is the maximum number of fetched results, maxRows is the
// maximum number of rows we show at once, without a scrollbar.
if (this.mPopupOpen && this.maxResults > this.maxRows) {
// when clearing the selection (val == -1, so selectedItem will be
// null), we want to scroll back to the top. see bug #406194
this.richlistbox.ensureElementIsVisible(
this.richlistbox.selectedItem || this.richlistbox.firstElementChild
);
}
}
get selectedIndex() {
return this.richlistbox.selectedIndex;
}
get maxResults() {
// This is how many richlistitems will be kept around.
// Note, this getter may be overridden, or instances
// can have the nomaxresults attribute set to have no
// limit.
if (this.getAttribute("nomaxresults") == "true") {
return Infinity;
}
return 20;
}
get matchCount() {
return Math.min(this.mInput.controller.matchCount, this.maxResults);
}
get overflowPadding() {
return Number(this.getAttribute("overflowpadding"));
}
set view(val) {}
get view() {
return this.mInput.controller;
}
closePopup() {
if (this.mPopupOpen) {
this.hidePopup();
this.removeAttribute("width");
}
}
getNextIndex(aReverse, aAmount, aIndex, aMaxRow) {
if (aMaxRow < 0) {
return -1;
}
var newIdx = aIndex + (aReverse ? -1 : 1) * aAmount;
if (
(aReverse && aIndex == -1) ||
(newIdx > aMaxRow && aIndex != aMaxRow)
) {
newIdx = aMaxRow;
} else if ((!aReverse && aIndex == -1) || (newIdx < 0 && aIndex != 0)) {
newIdx = 0;
}
if (
(newIdx < 0 && aIndex == 0) ||
(newIdx > aMaxRow && aIndex == aMaxRow)
) {
aIndex = -1;
} else {
aIndex = newIdx;
}
return aIndex;
}
onPopupClick(aEvent) {
this.input.controller.handleEnter(true, aEvent);
}
onSearchBegin() {
this.mousedOverIndex = -1;
if (typeof this._onSearchBegin == "function") {
this._onSearchBegin();
}
}
openAutocompletePopup(aInput, aElement) {
// until we have "baseBinding", (see bug #373652) this allows
// us to override openAutocompletePopup(), but still call
// the method on the base class
this._openAutocompletePopup(aInput, aElement);
}
_openAutocompletePopup(aInput, aElement) {
if (!this._richlistbox) {
this.initialize();
}
if (!this.mPopupOpen) {
// It's possible that the panel is hidden initially
// to avoid impacting startup / new window performance
aInput.popup.hidden = false;
this.mInput = aInput;
// clear any previous selection, see bugs 400671 and 488357
this.selectedIndex = -1;
var width = aElement.getBoundingClientRect().width;
this.setAttribute("width", width > 100 ? width : 100);
// invalidate() depends on the width attribute
this._invalidate();
this.openPopup(aElement, "after_start", 0, 0, false, false);
}
}
invalidate(reason) {
// Don't bother doing work if we're not even showing
if (!this.mPopupOpen) {
return;
}
this._invalidate(reason);
}
_invalidate(reason) {
// collapsed if no matches
this.richlistbox.collapsed = this.matchCount == 0;
// Update the richlistbox height.
if (this._adjustHeightRAFToken) {
cancelAnimationFrame(this._adjustHeightRAFToken);
this._adjustHeightRAFToken = null;
}
if (this.mPopupOpen) {
delete this._adjustHeightOnPopupShown;
this._adjustHeightRAFToken = requestAnimationFrame(() =>
this.adjustHeight()
);
} else {
this._adjustHeightOnPopupShown = true;
}
this._currentIndex = 0;
if (this._appendResultTimeout) {
clearTimeout(this._appendResultTimeout);
}
this._appendCurrentResult(reason);
}
_collapseUnusedItems() {
let existingItemsCount = this.richlistbox.children.length;
for (let i = this.matchCount; i < existingItemsCount; ++i) {
let item = this.richlistbox.children[i];
item.collapsed = true;
if (typeof item._onCollapse == "function") {
item._onCollapse();
}
}
}
adjustHeight() {
// Figure out how many rows to show
let rows = this.richlistbox.children;
let numRows = Math.min(this.matchCount, this.maxRows, rows.length);
// Default the height to 0 if we have no rows to show
let height = 0;
if (numRows) {
let firstRowRect = rows[0].getBoundingClientRect();
if (this._rlbPadding == undefined) {
let style = window.getComputedStyle(this.richlistbox);
let paddingTop = parseInt(style.paddingTop) || 0;
let paddingBottom = parseInt(style.paddingBottom) || 0;
this._rlbPadding = paddingTop + paddingBottom;
}
// The class `forceHandleUnderflow` is for the item might need to
// handle OverUnderflow or Overflow when the height of an item will
// be changed dynamically.
for (let i = 0; i < numRows; i++) {
if (rows[i].classList.contains("forceHandleUnderflow")) {
rows[i].handleOverUnderflow();
}
}
let lastRowRect = rows[numRows - 1].getBoundingClientRect();
// Calculate the height to have the first row to last row shown
height = lastRowRect.bottom - firstRowRect.top + this._rlbPadding;
}
this._collapseUnusedItems();
this.richlistbox.style.removeProperty("height");
// We need to get the ceiling of the calculated value to ensure that the box fully contains
// all of its contents and doesn't cause a scrollbar since nsIBoxObject only expects a
// `long`. e.g. if `height` is 99.5 the richlistbox would render at height 99px with a
// scrollbar for the extra 0.5px.
this.richlistbox.height = Math.ceil(height);
}
_appendCurrentResult(invalidateReason) {
var controller = this.mInput.controller;
var matchCount = this.matchCount;
var existingItemsCount = this.richlistbox.children.length;
// Process maxRows per chunk to improve performance and user experience
for (let i = 0; i < this.maxRows; i++) {
if (this._currentIndex >= matchCount) {
break;
}
let item;
let itemExists = this._currentIndex < existingItemsCount;
let originalValue, originalText, originalType;
let style = controller.getStyleAt(this._currentIndex);
let value =
style && style.includes("autofill")
? controller.getFinalCompleteValueAt(this._currentIndex)
: controller.getValueAt(this._currentIndex);
let label = controller.getLabelAt(this._currentIndex);
let comment = controller.getCommentAt(this._currentIndex);
let image = controller.getImageAt(this._currentIndex);
// trim the leading/trailing whitespace
let trimmedSearchString = controller.searchString
.replace(/^\s+/, "")
.replace(/\s+$/, "");
let reusable = false;
if (itemExists) {
item = this.richlistbox.children[this._currentIndex];
// Url may be a modified version of value, see _adjustAcItem().
originalValue =
item.getAttribute("url") || item.getAttribute("ac-value");
originalText = item.getAttribute("ac-text");
originalType = item.getAttribute("originaltype");
// The styles on the list which have different <content> structure and overrided
// _adjustAcItem() are unreusable.
const UNREUSEABLE_STYLES = [
"autofill-profile",
"autofill-footer",
"autofill-clear-button",
"autofill-insecureWarning",
"generatedPassword",
"importableLearnMore",
"importableLogins",
"insecureWarning",
"loginsFooter",
"loginWithOrigin",
];
// Reuse the item when its style is exactly equal to the previous style or
// neither of their style are in the UNREUSEABLE_STYLES.
reusable =
originalType === style ||
!(
UNREUSEABLE_STYLES.includes(style) ||
UNREUSEABLE_STYLES.includes(originalType)
);
}
// If no reusable item available, then create a new item.
if (!reusable) {
let options = null;
switch (style) {
case "autofill-profile":
options = { is: "autocomplete-profile-listitem" };
break;
case "autofill-footer":
options = { is: "autocomplete-profile-listitem-footer" };
break;
case "autofill-clear-button":
options = { is: "autocomplete-profile-listitem-clear-button" };
break;
case "autofill-insecureWarning":
options = { is: "autocomplete-creditcard-insecure-field" };
break;
case "importableLearnMore":
options = {
is: "autocomplete-importable-learn-more-richlistitem",
};
break;
case "importableLogins":
options = { is: "autocomplete-importable-logins-richlistitem" };
break;
case "generatedPassword":
options = { is: "autocomplete-generated-password-richlistitem" };
break;
case "insecureWarning":
options = { is: "autocomplete-richlistitem-insecure-warning" };
break;
case "loginsFooter":
options = { is: "autocomplete-richlistitem-logins-footer" };
break;
case "loginWithOrigin":
options = { is: "autocomplete-login-richlistitem" };
break;
default:
options = { is: "autocomplete-richlistitem" };
}
item = document.createXULElement("richlistitem", options);
item.className = "autocomplete-richlistitem";
}
item.setAttribute("dir", this.style.direction);
item.setAttribute("ac-image", image);
item.setAttribute("ac-value", value);
item.setAttribute("ac-label", label);
item.setAttribute("ac-comment", comment);
item.setAttribute("ac-text", trimmedSearchString);
// Completely reuse the existing richlistitem for invalidation
// due to new results, but only when: the item is the same, *OR*
// we are about to replace the currently moused-over item, to
// avoid surprising the user.
let iface = Ci.nsIAutoCompletePopup;
if (
reusable &&
originalText == trimmedSearchString &&
invalidateReason == iface.INVALIDATE_REASON_NEW_RESULT &&
(originalValue == value ||
this.mousedOverIndex === this._currentIndex)
) {
// try to re-use the existing item
item._reuseAcItem();
this._currentIndex++;
continue;
} else {
if (typeof item._cleanup == "function") {
item._cleanup();
}
item.setAttribute("originaltype", style);
}
if (reusable) {
// Adjust only when the result's type is reusable for existing
// item's. Otherwise, we might insensibly call old _adjustAcItem()
// as new binding has not been attached yet.
// We don't need to worry about switching to new binding, since
// _adjustAcItem() will fired by its own constructor accordingly.
item._adjustAcItem();
item.collapsed = false;
} else if (itemExists) {
let oldItem = this.richlistbox.children[this._currentIndex];
this.richlistbox.replaceChild(item, oldItem);
} else {
this.richlistbox.appendChild(item);
}
this._currentIndex++;
}
if (typeof this.onResultsAdded == "function") {
// The items bindings may not be attached yet, so we must delay this
// before we can properly handle items properly without breaking
// the richlistbox.
Services.tm.dispatchToMainThread(() => this.onResultsAdded());
}
if (this._currentIndex < matchCount) {
// yield after each batch of items so that typing the url bar is
// responsive
this._appendResultTimeout = setTimeout(
() => this._appendCurrentResult(),
0
);
}
}
selectBy(aReverse, aPage) {
try {
var amount = aPage ? 5 : 1;
// because we collapsed unused items, we can't use this.richlistbox.getRowCount(), we need to use the matchCount
this.selectedIndex = this.getNextIndex(
aReverse,
amount,
this.selectedIndex,
this.matchCount - 1
);
if (this.selectedIndex == -1) {
this.input._focus();
}
} catch (ex) {
// do nothing - occasionally timer-related js errors happen here
// e.g. "this.selectedIndex has no properties", when you type fast and hit a
// navigation key before this popup has opened
}
}
disconnectedCallback() {
if (this.listEvents) {
this.richlistbox.removeEventListener("mousedown", this.listEvents);
this.richlistbox.removeEventListener("mouseup", this.listEvents);
this.richlistbox.removeEventListener("mousemove", this.listEvents);
delete this.listEvents;
}
}
setListeners() {
this.addEventListener("popupshowing", event => {
// If normalMaxRows wasn't already set by the input, then set it here
// so that we restore the correct number when the popup is hidden.
// Null-check this.mInput; see bug 1017914
if (this._normalMaxRows < 0 && this.mInput) {
this._normalMaxRows = this.mInput.maxRows;
}
this.mPopupOpen = true;
});
this.addEventListener("popupshown", event => {
if (this._adjustHeightOnPopupShown) {
delete this._adjustHeightOnPopupShown;
this.adjustHeight();
}
});
this.addEventListener("popuphiding", event => {
var isListActive = true;
if (this.selectedIndex == -1) {
isListActive = false;
}
this.input.controller.stopSearch();
this.mPopupOpen = false;
// Reset the maxRows property to the cached "normal" value (if there's
// any), and reset normalMaxRows so that we can detect whether it was set
// by the input when the popupshowing handler runs.
// Null-check this.mInput; see bug 1017914
if (this.mInput && this._normalMaxRows > 0) {
this.mInput.maxRows = this._normalMaxRows;
}
this._normalMaxRows = -1;
// If the list was being navigated and then closed, make sure
// we fire accessible focus event back to textbox
// Null-check this.mInput; see bug 1017914
if (isListActive && this.mInput) {
this.mInput.mIgnoreFocus = true;
this.mInput._focus();
this.mInput.mIgnoreFocus = false;
}
});
}
};
MozPopupElement.implementCustomInterface(
MozElements.MozAutocompleteRichlistboxPopup,
[Ci.nsIAutoCompletePopup]
);
customElements.define(
"autocomplete-richlistbox-popup",
MozElements.MozAutocompleteRichlistboxPopup,
{
extends: "panel",
}
);
}