Backed out 6 changesets (bug 1921980) for causing wrench bustages. CLOSED TREE
[gecko.git] / toolkit / modules / FormLikeFactory.sys.mjs
blob972455145fa5314067ab5c56df29d01504629b70
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 /**
6  * A factory to generate FormLike objects that represent a set of related fields
7  * which aren't necessarily marked up with a <form> element. FormLike's emulate
8  * the properties of an HTMLFormElement which are relevant to form tasks.
9  */
10 export let FormLikeFactory = {
11   _propsFromForm: ["action", "autocomplete", "ownerDocument"],
13   /**
14    * Create a FormLike object from a <form>.
15    *
16    * @param {HTMLFormElement} aForm
17    * @return {FormLike}
18    * @throws Error if aForm isn't an HTMLFormElement
19    */
20   createFromForm(aForm) {
21     if (!HTMLFormElement.isInstance(aForm)) {
22       throw new Error("createFromForm: aForm must be a HTMLFormElement");
23     }
25     let formLike = {
26       elements: [...aForm.elements],
27       rootElement: aForm,
28     };
30     for (let prop of this._propsFromForm) {
31       formLike[prop] = aForm[prop];
32     }
34     this._addToJSONProperty(formLike);
36     return formLike;
37   },
39   /**
40    * Create a FormLike object from an element that is the root of the document
41    *
42    * Currently all <input> not in a <form> are one LoginForm but this
43    * shouldn't be relied upon as the heuristics may change to detect multiple
44    * "forms" (e.g. registration and login) on one page with a <form>.
45    *
46    * @param {HTMLElement} aDocumentRoot
47    * @param {Object} aOptions
48    * @param {boolean} [aOptions.ignoreForm = false]
49    *        True to always use owner document as the `form`
50    * @return {formLike}
51    * @throws Error if aDocumentRoot is null
52    */
53   createFromDocumentRoot(aDocumentRoot, aOptions = {}) {
54     if (!aDocumentRoot) {
55       throw new Error("createFromDocumentRoot: aDocumentRoot is null");
56     }
58     let formLike = {
59       action: aDocumentRoot.baseURI,
60       autocomplete: "on",
61       ownerDocument: aDocumentRoot.ownerDocument,
62       rootElement: aDocumentRoot,
63     };
65     // FormLikes can be created when fields are inserted into the DOM. When
66     // many, many fields are inserted one after the other, we create many
67     // FormLikes, and computing the elements list becomes more and more
68     // expensive. Making the elements list lazy means that it'll only
69     // be computed when it's eventually needed (if ever).
70     ChromeUtils.defineLazyGetter(formLike, "elements", function () {
71       let elements = [];
72       for (let el of aDocumentRoot.querySelectorAll("input, select")) {
73         // Exclude elements inside the rootElement that are already in a <form> as
74         // they will be handled by their own FormLike.
75         if (!el.form || aOptions.ignoreForm) {
76           elements.push(el);
77         }
78       }
80       return elements;
81     });
83     this._addToJSONProperty(formLike);
84     return formLike;
85   },
87   /**
88    * Create a FormLike object from an <input>/<select> in a document.
89    *
90    * If the field is in a <form>, construct the FormLike from the form.
91    * Otherwise, create a FormLike with a rootElement (wrapper) according to
92    * heuristics. Currently all <input>/<select> not in a <form> are one FormLike
93    * but this shouldn't be relied upon as the heuristics may change to detect
94    * multiple "forms" (e.g. registration and login) on one page with a <form>.
95    *
96    * Note that two FormLikes created from the same field won't return the same FormLike object.
97    * Use the `rootElement` property on the FormLike as a key instead.
98    *
99    * @param {HTMLInputElement|HTMLSelectElement} aField
100    *        an <input>, <select> or <iframe> field in a document
101    * @param {Object} aOptions
102    * @param {boolean} [aOptions.ignoreForm = false]
103    *        True to always use owner document as the `form`
104    * @return {FormLike}
105    * @throws Error if aField isn't a password or username field in a document
106    */
107   createFromField(aField, aOptions = {}) {
108     if (
109       (!HTMLInputElement.isInstance(aField) &&
110         !HTMLIFrameElement.isInstance(aField) &&
111         !HTMLSelectElement.isInstance(aField)) ||
112       !aField.ownerDocument
113     ) {
114       throw new Error("createFromField requires a field in a document");
115     }
117     const rootElement = this.findRootForField(aField, aOptions);
118     return HTMLFormElement.isInstance(rootElement)
119       ? this.createFromForm(rootElement)
120       : this.createFromDocumentRoot(rootElement, aOptions);
121   },
123   /**
124    * Find the closest <form> if any when aField is inside a ShadowRoot.
125    *
126    * @param {HTMLInputElement} aField - a password or username field in a document
127    * @return {HTMLFormElement|null}
128    */
129   closestFormIgnoringShadowRoots(aField) {
130     let form = aField.closest("form");
131     let current = aField;
132     while (!form) {
133       let shadowRoot = current.getRootNode();
134       if (!ShadowRoot.isInstance(shadowRoot)) {
135         break;
136       }
137       let host = shadowRoot.host;
138       form = host.closest("form");
139       current = host;
140     }
141     return form;
142   },
144   /**
145    * Determine the Element that encapsulates the related fields. For example, if
146    * a page contains a login form and a checkout form which are "submitted"
147    * separately, and the username field is passed in, ideally this would return
148    * an ancestor Element of the username and password fields which doesn't
149    * include any of the checkout fields.
150    *
151    * @param {HTMLInputElement|HTMLSelectElement} aField
152    *        a field in a document
153    * @return {HTMLElement} - the root element surrounding related fields
154    */
155   findRootForField(aField, { ignoreForm = false } = {}) {
156     if (!ignoreForm) {
157       const form = aField.form || this.closestFormIgnoringShadowRoots(aField);
158       if (form) {
159         return form;
160       }
161     }
163     return aField.ownerDocument.documentElement;
164   },
166   /**
167    * Add a `toJSON` property to a FormLike so logging which ends up going
168    * through dump doesn't include usless garbage from DOM objects.
169    */
170   _addToJSONProperty(aFormLike) {
171     function prettyElementOutput(aElement) {
172       let idText = aElement.id ? "#" + aElement.id : "";
173       let classText = "";
174       for (let className of aElement.classList) {
175         classText += "." + className;
176       }
177       return `<${aElement.nodeName + idText + classText}>`;
178     }
180     Object.defineProperty(aFormLike, "toJSON", {
181       value: () => {
182         let cleansed = {};
183         for (let key of Object.keys(aFormLike)) {
184           let value = aFormLike[key];
185           let cleansedValue = value;
187           switch (key) {
188             case "elements": {
189               cleansedValue = [];
190               for (let element of value) {
191                 cleansedValue.push(prettyElementOutput(element));
192               }
193               break;
194             }
196             case "ownerDocument": {
197               cleansedValue = {
198                 location: {
199                   href: value.location.href,
200                 },
201               };
202               break;
203             }
205             case "rootElement": {
206               cleansedValue = prettyElementOutput(value);
207               break;
208             }
209           }
211           cleansed[key] = cleansedValue;
212         }
213         return cleansed;
214       },
215     });
216   },