Bug 1933479 - Add tab close button on hover to vertical tabs when sidebar is collapse...
[gecko.git] / toolkit / components / formautofill / ProfileAutoCompleteResult.sys.mjs
blobc96c7255a47c4ccb797f3a1b6f262effe45cab62
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const lazy = {};
7 ChromeUtils.defineESModuleGetters(lazy, {
8   CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
9   FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
10 });
12 ChromeUtils.defineLazyGetter(
13   lazy,
14   "l10n",
15   () => new Localization(["toolkit/formautofill/formAutofill.ftl"], true)
18 class ProfileAutoCompleteResult {
19   externalEntries = [];
21   constructor(
22     searchString,
23     focusedFieldDetail,
24     allFieldNames,
25     matchingProfiles,
26     fillCategories,
27     { resultCode = null, isSecure = true, isInputAutofilled = false }
28   ) {
29     // nsISupports
30     this.QueryInterface = ChromeUtils.generateQI(["nsIAutoCompleteResult"]);
32     // The user's query string
33     this.searchString = searchString;
34     // The field name of the focused input.
35     this._focusedFieldName = focusedFieldDetail.fieldName;
36     // The content dom reference id of the focused input.
37     this._focusedElementId = focusedFieldDetail.elementId;
38     // The matching profiles contains the information for filling forms.
39     this._matchingProfiles = matchingProfiles;
40     // The default item that should be entered if none is selected
41     this.defaultIndex = 0;
42     // The reason the search failed
43     this.errorDescription = "";
44     // The value used to determine whether the form is secure or not.
45     this._isSecure = isSecure;
46     // The value to indicate whether the focused input has been autofilled or not.
47     this._isInputAutofilled = isInputAutofilled;
48     // All fillable field names in the form including the field name of the currently-focused input.
49     this._allFieldNames = [
50       ...this._matchingProfiles.reduce((fieldSet, curProfile) => {
51         for (let field of Object.keys(curProfile)) {
52           fieldSet.add(field);
53         }
55         return fieldSet;
56       }, new Set()),
57     ].filter(field => allFieldNames.includes(field));
59     this._fillCategories = fillCategories;
61     // Force return success code if the focused field is auto-filled in order
62     // to show clear form button popup.
63     if (isInputAutofilled) {
64       resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
65     }
66     // The result code of this result object.
67     if (resultCode) {
68       this.searchResult = resultCode;
69     } else {
70       this.searchResult = matchingProfiles.length
71         ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS
72         : Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
73     }
75     // An array of primary and secondary labels for each profile.
76     this._popupLabels = this._generateLabels(
77       this._focusedFieldName,
78       this._allFieldNames,
79       this._matchingProfiles,
80       this._fillCategories
81     );
82   }
84   getAt(index) {
85     for (const group of [this._popupLabels, this.externalEntries]) {
86       if (index < group.length) {
87         return group[index];
88       }
89       index -= group.length;
90     }
92     throw Components.Exception(
93       "Index out of range.",
94       Cr.NS_ERROR_ILLEGAL_VALUE
95     );
96   }
98   /**
99    * @returns {number} The number of results
100    */
101   get matchCount() {
102     return this._popupLabels.length + this.externalEntries.length;
103   }
105   /**
106    * Get the secondary label based on the focused field name and related field names
107    * in the same form.
108    *
109    * @param   {string} _focusedFieldName The field name of the focused input
110    * @param   {Array<object>} _allFieldNames The field names in the same section
111    * @param   {object} _profile The profile providing the labels to show.
112    * @returns {string} The secondary label
113    */
114   _getSecondaryLabel(_focusedFieldName, _allFieldNames, _profile) {
115     return "";
116   }
118   _generateLabels(
119     _focusedFieldName,
120     _allFieldNames,
121     _profiles,
122     _fillCategories
123   ) {}
125   /**
126    * Get the value of the result at the given index.
127    *
128    * Always return empty string for form autofill feature to suppress
129    * AutoCompleteController from autofilling, as we'll populate the
130    * fields on our own.
131    *
132    * @param   {number} index The index of the result requested
133    * @returns {string} The result at the specified index
134    */
135   getValueAt(index) {
136     this.getAt(index);
137     return "";
138   }
140   getLabelAt(index) {
141     const item = this.getAt(index);
142     return typeof item == "string" ? item : item.primary || item.label;
143   }
145   /**
146    * Retrieves a comment (metadata instance)
147    *
148    * @param   {number} index The index of the comment requested
149    * @returns {string} The comment at the specified index
150    */
151   getCommentAt(index) {
152     const item = this.getAt(index);
153     if (item.style == "status") {
154       return JSON.stringify(item);
155     }
157     const data = {
158       fillMessageData: {
159         focusElementId: this._focusedElementId,
160       },
161     };
163     const type = this.getTypeOfIndex(index);
164     switch (type) {
165       case "clear":
166         data.fillMessageName = "FormAutofill:ClearForm";
167         break;
168       case "manage":
169         data.fillMessageName = "FormAutofill:OpenPreferences";
170         break;
171       case "insecure":
172         data.noLearnMore = true;
173         break;
174       default: {
175         if (item.comment) {
176           return item.comment;
177         }
179         data.fillMessageName = "FormAutofill:FillForm";
180         data.fillMessageData.profile = this._matchingProfiles[index];
181         break;
182       }
183     }
185     return JSON.stringify({ ...item, ...data });
186   }
188   /**
189    * Retrieves a style hint specific to a particular index.
190    *
191    * @param   {number} index The index of the style hint requested
192    * @returns {string} The style hint at the specified index
193    */
194   getStyleAt(index) {
195     const itemStyle = this.getAt(index).style;
196     if (itemStyle) {
197       return itemStyle;
198     }
200     switch (this.getTypeOfIndex(index)) {
201       case "manage":
202         return "action";
203       case "clear":
204         return "action";
205       case "insecure":
206         return "insecureWarning";
207       default:
208         return "autofill";
209     }
210   }
212   /**
213    * Retrieves an image url.
214    *
215    * @param   {number} index The index of the image url requested
216    * @returns {string} The image url at the specified index
217    */
218   getImageAt(index) {
219     return this.getAt(index).image ?? "";
220   }
222   /**
223    * Retrieves a result
224    *
225    * @param   {number} index The index of the result requested
226    * @returns {string} The result at the specified index
227    */
228   getFinalCompleteValueAt(index) {
229     return this.getValueAt(index);
230   }
232   /**
233    * Returns true if the value at the given index is removable
234    *
235    * @param   {number}  _index The index of the result to remove
236    * @returns {boolean} True if the value is removable
237    */
238   isRemovableAt(_index) {
239     return false;
240   }
242   /**
243    * Removes a result from the resultset
244    *
245    * @param {number} _index The index of the result to remove
246    */
247   removeValueAt(_index) {
248     // There is no plan to support removing profiles via autocomplete.
249   }
251   /**
252    * Returns a type string that identifies te type of row at the given index.
253    *
254    * @param   {number} index The index of the result requested
255    * @returns {string} The type at the specified index
256    */
257   getTypeOfIndex(index) {
258     if (this._isInputAutofilled && index == 0) {
259       return "clear";
260     }
262     if (index == this._popupLabels.length - 1) {
263       return "manage";
264     }
266     return "item";
267   }
270 export class AddressResult extends ProfileAutoCompleteResult {
271   _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
272     // We group similar fields into the same field name so we won't pick another
273     // field in the same group as the secondary label.
274     const GROUP_FIELDS = {
275       name: ["name", "given-name", "additional-name", "family-name"],
276       "street-address": [
277         "street-address",
278         "address-line1",
279         "address-line2",
280         "address-line3",
281       ],
282       "country-name": ["country", "country-name"],
283       tel: [
284         "tel",
285         "tel-country-code",
286         "tel-national",
287         "tel-area-code",
288         "tel-local",
289         "tel-local-prefix",
290         "tel-local-suffix",
291       ],
292     };
294     const secondaryLabelOrder = [
295       "street-address", // Street address
296       "name", // Full name
297       "address-level3", // Townland / Neighborhood / Village
298       "address-level2", // City/Town
299       "organization", // Company or organization name
300       "address-level1", // Province/State (Standardized code if possible)
301       "country-name", // Country name
302       "postal-code", // Postal code
303       "tel", // Phone number
304       "email", // Email address
305     ];
307     for (let field in GROUP_FIELDS) {
308       if (GROUP_FIELDS[field].includes(focusedFieldName)) {
309         focusedFieldName = field;
310         break;
311       }
312     }
314     for (const currentFieldName of secondaryLabelOrder) {
315       if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
316         continue;
317       }
319       let matching = GROUP_FIELDS[currentFieldName]
320         ? allFieldNames.some(fieldName =>
321             GROUP_FIELDS[currentFieldName].includes(fieldName)
322           )
323         : allFieldNames.includes(currentFieldName);
325       if (matching) {
326         if (
327           currentFieldName == "street-address" &&
328           profile["-moz-street-address-one-line"]
329         ) {
330           return profile["-moz-street-address-one-line"];
331         }
332         return profile[currentFieldName];
333       }
334     }
336     return ""; // Nothing matched.
337   }
339   _generateLabels(focusedFieldName, allFieldNames, profiles, fillCategories) {
340     const manageLabel = lazy.l10n.formatValueSync(
341       "autofill-manage-addresses-label"
342     );
344     let footerItem = {
345       primary: manageLabel,
346       secondary: "",
347     };
349     if (this._isInputAutofilled) {
350       const clearLabel = lazy.l10n.formatValueSync("autofill-clear-form-label");
352       let labels = [
353         {
354           primary: clearLabel,
355         },
356       ];
357       labels.push(footerItem);
358       return labels;
359     }
361     const focusedCategory =
362       lazy.FormAutofillUtils.getCategoryFromFieldName(focusedFieldName);
364     const labels = [];
365     for (let idx = 0; idx < profiles.length; idx++) {
366       const profile = profiles[idx];
368       let primary = profile[focusedFieldName];
369       // Skip results without a primary label.
370       if (!primary) {
371         continue;
372       }
374       if (
375         focusedFieldName == "street-address" &&
376         profile["-moz-street-address-one-line"]
377       ) {
378         primary = profile["-moz-street-address-one-line"];
379       }
381       const status = this.getStatusNote(fillCategories[idx], focusedCategory);
382       const secondary = this._getSecondaryLabel(
383         focusedFieldName,
384         allFieldNames,
385         profile
386       );
387       // Exclude empty chunks.
388       const ariaLabel = [primary, secondary, status]
389         .filter(chunk => !!chunk)
390         .join(" ");
392       labels.push({
393         primary,
394         secondary,
395         status,
396         ariaLabel,
397       });
398     }
400     const allCategories =
401       lazy.FormAutofillUtils.getCategoriesFromFieldNames(allFieldNames);
403     if (allCategories?.length) {
404       const statusItem = {
405         primary: "",
406         secondary: "",
407         status: this.getStatusNote(allCategories, focusedCategory),
408         style: "status",
409       };
410       labels.push(statusItem);
411     }
413     labels.push(footerItem);
415     return labels;
416   }
418   getStatusNote(categories, focusedCategory) {
419     if (!categories || !categories.length) {
420       return "";
421     }
423     // If the length of categories is 1, that means all the fillable fields are in the same
424     // category. We will change the way to inform user according to this flag. When the value
425     // is true, we show "Also autofills ...", otherwise, show "Autofills ..." only.
426     let hasExtraCategories = categories.length > 1;
427     // Show the categories in certain order to conform with the spec.
428     let orderedCategoryList = [
429       "address",
430       "name",
431       "organization",
432       "tel",
433       "email",
434     ];
435     let showCategories = hasExtraCategories
436       ? orderedCategoryList.filter(
437           category =>
438             categories.includes(category) && category != focusedCategory
439         )
440       : [orderedCategoryList.find(category => category == focusedCategory)];
442     let formatter = new Intl.ListFormat(undefined, {
443       style: "narrow",
444     });
446     let categoriesText = showCategories.map(category =>
447       lazy.l10n.formatValueSync("autofill-category-" + category)
448     );
449     categoriesText = formatter.format(categoriesText);
451     let statusTextTmplKey = hasExtraCategories
452       ? "autofill-phishing-warningmessage-extracategory"
453       : "autofill-phishing-warningmessage";
454     return lazy.l10n.formatValueSync(statusTextTmplKey, {
455       categories: categoriesText,
456     });
457   }
460 export class CreditCardResult extends ProfileAutoCompleteResult {
461   _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
462     const GROUP_FIELDS = {
463       "cc-name": [
464         "cc-name",
465         "cc-given-name",
466         "cc-additional-name",
467         "cc-family-name",
468       ],
469       "cc-exp": ["cc-exp", "cc-exp-month", "cc-exp-year"],
470     };
472     const secondaryLabelOrder = [
473       "cc-number", // Credit card number
474       "cc-name", // Full name
475       "cc-exp", // Expiration date
476     ];
478     for (let field in GROUP_FIELDS) {
479       if (GROUP_FIELDS[field].includes(focusedFieldName)) {
480         focusedFieldName = field;
481         break;
482       }
483     }
485     for (const currentFieldName of secondaryLabelOrder) {
486       if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
487         continue;
488       }
490       let matching = GROUP_FIELDS[currentFieldName]
491         ? allFieldNames.some(fieldName =>
492             GROUP_FIELDS[currentFieldName].includes(fieldName)
493           )
494         : allFieldNames.includes(currentFieldName);
496       if (matching) {
497         if (currentFieldName == "cc-number") {
498           return lazy.CreditCard.formatMaskedNumber(profile[currentFieldName]);
499         }
500         return profile[currentFieldName];
501       }
502     }
504     return ""; // Nothing matched.
505   }
507   _generateLabels(focusedFieldName, allFieldNames, profiles, _fillCategories) {
508     if (!this._isSecure) {
509       let brandName =
510         lazy.FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
512       return [
513         lazy.FormAutofillUtils.stringBundle.formatStringFromName(
514           "insecureFieldWarningDescription",
515           [brandName]
516         ),
517       ];
518     }
520     const manageLabel = lazy.l10n.formatValueSync(
521       "autofill-manage-payment-methods-label"
522     );
524     let footerItem = {
525       primary: manageLabel,
526     };
528     if (this._isInputAutofilled) {
529       const clearLabel = lazy.l10n.formatValueSync("autofill-clear-form-label");
531       let labels = [
532         {
533           primary: clearLabel,
534         },
535       ];
536       labels.push(footerItem);
537       return labels;
538     }
540     // Skip results without a primary label.
541     let labels = profiles
542       .filter(profile => {
543         return !!profile[focusedFieldName];
544       })
545       .map(profile => {
546         let primary = profile[focusedFieldName];
548         if (focusedFieldName == "cc-number") {
549           primary = lazy.CreditCard.formatMaskedNumber(primary);
550         }
551         const secondary = this._getSecondaryLabel(
552           focusedFieldName,
553           allFieldNames,
554           profile
555         );
556         // The card type is displayed visually using an image. For a11y, we need
557         // to expose it as text. We do this using aria-label. However,
558         // aria-label overrides the text content, so we must include that also.
559         const ccType = profile["cc-type"];
560         const image = lazy.CreditCard.getCreditCardLogo(ccType);
561         const ccTypeL10nId = lazy.CreditCard.getNetworkL10nId(ccType);
562         const ccTypeName = ccTypeL10nId
563           ? lazy.l10n.formatValueSync(ccTypeL10nId)
564           : (ccType ?? ""); // Unknown card type
565         const ariaLabel = [
566           ccTypeName,
567           primary.toString().replaceAll("*", ""),
568           secondary,
569         ]
570           .filter(chunk => !!chunk) // Exclude empty chunks.
571           .join(" ");
572         return {
573           primary: primary.toString().replaceAll("*", "•"),
574           secondary: secondary.toString().replaceAll("*", "•"),
575           ariaLabel,
576           image,
577         };
578       });
580     labels.push(footerItem);
582     return labels;
583   }
585   getTypeOfIndex(index) {
586     if (!this._isSecure) {
587       return "insecure";
588     }
590     return super.getTypeOfIndex(index);
591   }