/* 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.attachShadow({ mode:
"open" });
{
let slot = document.createElement(
"slot");
slot.part =
"content";
this.shadowRoot.appendChild(slot);
}
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"/>
`;
}
/**
* 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.style.removeProperty(
"--panel-width");
}
}
getNextIndex(aReverse, aAmount, aIndex, aMaxRow) {
if (aMaxRow < 0) {
return -1;
}
do {
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;
}
if (aIndex == -1) {
return -1;
}
}
while (
!
this.richlistbox.canUserSelect(
this.richlistbox.getItemAtIndex(aIndex))
);
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.style.setProperty(
"--panel-width", Math.max(width, 100) +
"px");
// 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) {
this._adjustHeightOnPopupShown =
false;
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) {
this.richlistbox.children[i].collapsed =
true;
}
}
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();
// 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.
this.richlistbox.style.maxHeight = Math.ceil(height) +
"px";
}
_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+$/,
"");
// Generic items can pack their details as JSON inside label
try {
const details = JSON.parse(label);
if (details.title) {
value = details.title;
label = details.subtitle ??
"";
}
}
catch {}
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",
"action",
"status",
"generatedPassword",
"generic",
"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":
options = { is:
"autocomplete-autofill-richlistitem" };
break;
case "action":
options = { is:
"autocomplete-action-richlistitem" };
break;
case "status":
options = { is:
"autocomplete-status-richlistitem" };
break;
case "generic":
options = { is:
"autocomplete-two-line-richlistitem" };
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", () => {
// 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", () => {
if (
this._adjustHeightOnPopupShown) {
this._adjustHeightOnPopupShown =
false;
this.adjustHeight();
}
});
this.addEventListener(
"popuphiding", () => {
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",
}
);
}