Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / toolkit / actors / SelectChild.sys.mjs
blobadd2024093e48946387a967cf25dd6444dc13c35
1 /* vim: set ts=2 sw=2 sts=2 et tw=80: */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
8 const lazy = {};
10 ChromeUtils.defineESModuleGetters(lazy, {
11   DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
12   LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
13 });
15 const kStateActive = 0x00000001; // ElementState::ACTIVE
16 const kStateHover = 0x00000004; // ElementState::HOVER
18 // Duplicated in SelectParent.jsm
19 // Please keep these lists in sync.
20 const SUPPORTED_OPTION_OPTGROUP_PROPERTIES = [
21   "direction",
22   "color",
23   "background-color",
24   "text-shadow",
25   "text-transform",
26   "font-family",
27   "font-weight",
28   "font-size",
29   "font-style",
32 const SUPPORTED_SELECT_PROPERTIES = [
33   ...SUPPORTED_OPTION_OPTGROUP_PROPERTIES,
34   "scrollbar-width",
35   "scrollbar-color",
38 // A process global state for whether or not content thinks
39 // that a <select> dropdown is open or not. This is managed
40 // entirely within this module, and is read-only accessible
41 // via SelectContentHelper.open.
42 var gOpen = false;
44 export var SelectContentHelper = function (aElement, aOptions, aActor) {
45   this.element = aElement;
46   this.initialSelection = aElement[aElement.selectedIndex] || null;
47   this.actor = aActor;
48   this.closedWithClickOn = false;
49   this.isOpenedViaTouch = aOptions.isOpenedViaTouch;
50   this._closeAfterBlur = true;
51   this._pseudoStylesSetup = false;
52   this._lockedDescendants = null;
53   this.init();
54   this.showDropDown();
55   this._updateTimer = new lazy.DeferredTask(this._update.bind(this), 0);
58 Object.defineProperty(SelectContentHelper, "open", {
59   get() {
60     return gOpen;
61   },
62 });
64 SelectContentHelper.prototype = {
65   init() {
66     let win = this.element.ownerGlobal;
67     win.addEventListener("pagehide", this, { mozSystemGroup: true });
68     this.element.addEventListener("blur", this, { mozSystemGroup: true });
69     this.element.addEventListener("transitionend", this, {
70       mozSystemGroup: true,
71     });
72     let MutationObserver = this.element.ownerGlobal.MutationObserver;
73     this.mut = new MutationObserver(mutations => {
74       // Something changed the <select> while it was open, so
75       // we'll poke a DeferredTask to update the parent sometime
76       // in the very near future.
77       this._updateTimer.arm();
78     });
79     this.mut.observe(this.element, {
80       childList: true,
81       subtree: true,
82       attributes: true,
83     });
85     XPCOMUtils.defineLazyPreferenceGetter(
86       this,
87       "disablePopupAutohide",
88       "ui.popup.disable_autohide",
89       false
90     );
91   },
93   uninit() {
94     this.element.openInParentProcess = false;
95     let win = this.element.ownerGlobal;
96     win.removeEventListener("pagehide", this, { mozSystemGroup: true });
97     this.element.removeEventListener("blur", this, { mozSystemGroup: true });
98     this.element.removeEventListener("transitionend", this, {
99       mozSystemGroup: true,
100     });
101     this.element = null;
102     this.actor = null;
103     this.mut.disconnect();
104     this._updateTimer.disarm();
105     this._updateTimer = null;
106     gOpen = false;
107   },
109   showDropDown() {
110     this.element.openInParentProcess = true;
111     this._setupPseudoClassStyles();
112     let rect = this._getBoundingContentRect();
113     let computedStyles = getComputedStyles(this.element);
114     let options = this._buildOptionList();
115     let defaultStyles = this.element.ownerGlobal.getDefaultComputedStyle(
116       this.element
117     );
118     this.actor.sendAsyncMessage("Forms:ShowDropDown", {
119       isOpenedViaTouch: this.isOpenedViaTouch,
120       options,
121       rect,
122       custom: !this.element.nodePrincipal.isSystemPrincipal,
123       selectedIndex: this.element.selectedIndex,
124       isDarkBackground: ChromeUtils.isDarkBackground(this.element),
125       style: supportedStyles(computedStyles, SUPPORTED_SELECT_PROPERTIES),
126       defaultStyle: supportedStyles(defaultStyles, SUPPORTED_SELECT_PROPERTIES),
127     });
128     this._clearPseudoClassStyles();
129     gOpen = true;
130   },
132   _setupPseudoClassStyles() {
133     if (this._pseudoStylesSetup) {
134       throw new Error("pseudo styles must not be set up yet");
135     }
136     // Do all of the things that change style at once, before we read
137     // any styles.
138     this._pseudoStylesSetup = true;
139     InspectorUtils.addPseudoClassLock(this.element, ":focus");
140     let lockedDescendants = (this._lockedDescendants =
141       this.element.querySelectorAll(":checked"));
142     for (let child of lockedDescendants) {
143       // Selected options have the :checked pseudo-class, which
144       // we want to disable before calculating the computed
145       // styles since the user agent styles alter the styling
146       // based on :checked.
147       InspectorUtils.addPseudoClassLock(child, ":checked", false);
148     }
149   },
151   _clearPseudoClassStyles() {
152     if (!this._pseudoStylesSetup) {
153       throw new Error("pseudo styles must be set up already");
154     }
155     // Undo all of the things that change style at once, after we're
156     // done reading styles.
157     InspectorUtils.clearPseudoClassLocks(this.element);
158     let lockedDescendants = this._lockedDescendants;
159     for (let child of lockedDescendants) {
160       InspectorUtils.clearPseudoClassLocks(child);
161     }
162     this._lockedDescendants = null;
163     this._pseudoStylesSetup = false;
164   },
166   _getBoundingContentRect() {
167     return lazy.LayoutUtils.getElementBoundingScreenRect(this.element);
168   },
170   _buildOptionList() {
171     if (!this._pseudoStylesSetup) {
172       throw new Error("pseudo styles must be set up");
173     }
174     let uniqueStyles = [];
175     let options = buildOptionListForChildren(this.element, uniqueStyles);
176     return { options, uniqueStyles };
177   },
179   _update() {
180     // The <select> was updated while the dropdown was open.
181     // Let's send up a new list of options.
182     // Technically we might not need to set this pseudo-class
183     // during _update() since the element should organically
184     // have :focus, though it is here for belt-and-suspenders.
185     this._setupPseudoClassStyles();
186     let computedStyles = getComputedStyles(this.element);
187     let defaultStyles = this.element.ownerGlobal.getDefaultComputedStyle(
188       this.element
189     );
190     this.actor.sendAsyncMessage("Forms:UpdateDropDown", {
191       options: this._buildOptionList(),
192       custom: !this.element.nodePrincipal.isSystemPrincipal,
193       selectedIndex: this.element.selectedIndex,
194       isDarkBackground: ChromeUtils.isDarkBackground(this.element),
195       style: supportedStyles(computedStyles, SUPPORTED_SELECT_PROPERTIES),
196       defaultStyle: supportedStyles(defaultStyles, SUPPORTED_SELECT_PROPERTIES),
197     });
198     this._clearPseudoClassStyles();
199   },
201   dispatchMouseEvent(win, target, eventName) {
202     let mouseEvent = new win.MouseEvent(eventName, {
203       view: win,
204       bubbles: true,
205       cancelable: true,
206       composed: true,
207     });
208     target.dispatchEvent(mouseEvent);
209   },
211   receiveMessage(message) {
212     switch (message.name) {
213       case "Forms:SelectDropDownItem":
214         this.element.selectedIndex = message.data.value;
215         this.closedWithClickOn = !message.data.closedWithEnter;
216         break;
218       case "Forms:DismissedDropDown": {
219         if (!this.element) {
220           return;
221         }
223         let win = this.element.ownerGlobal;
225         // Running arbitrary script below (dispatching events for example) can
226         // close us, but we should still send events consistently.
227         let element = this.element;
229         let selectedOption = element.item(element.selectedIndex);
231         // For ordering of events, we're using non-e10s as our guide here,
232         // since the spec isn't exactly clear. In non-e10s:
233         // - If the user clicks on an element in the dropdown, we fire
234         //   mousedown, mouseup, input, change, and click events.
235         // - If the user uses the keyboard to select an element in the
236         //   dropdown, we only fire input and change events.
237         // - If the user pressed ESC key or clicks outside the dropdown,
238         //   we fire nothing as the selected option is unchanged.
239         if (this.closedWithClickOn) {
240           this.dispatchMouseEvent(win, selectedOption, "mousedown");
241           this.dispatchMouseEvent(win, selectedOption, "mouseup");
242         }
244         // Clear active document no matter user selects via keyboard or mouse
245         InspectorUtils.removeContentState(
246           element,
247           kStateActive,
248           /* aClearActiveDocument */ true
249         );
251         // Fire input and change events when selected option changes
252         if (this.initialSelection !== selectedOption) {
253           let inputEvent = new win.Event("input", {
254             bubbles: true,
255             composed: true,
256           });
258           let changeEvent = new win.Event("change", {
259             bubbles: true,
260           });
262           let handlingUserInput = win.windowUtils.setHandlingUserInput(true);
263           try {
264             element.dispatchEvent(inputEvent);
265             element.dispatchEvent(changeEvent);
266           } finally {
267             handlingUserInput.destruct();
268           }
269         }
271         // Fire click event
272         if (this.closedWithClickOn) {
273           this.dispatchMouseEvent(win, selectedOption, "click");
274         }
276         this.uninit();
277         break;
278       }
280       case "Forms:MouseOver":
281         InspectorUtils.setContentState(this.element, kStateHover);
282         break;
284       case "Forms:MouseOut":
285         InspectorUtils.removeContentState(this.element, kStateHover);
286         break;
288       case "Forms:MouseUp":
289         let win = this.element.ownerGlobal;
290         if (message.data.onAnchor) {
291           this.dispatchMouseEvent(win, this.element, "mouseup");
292         }
293         InspectorUtils.removeContentState(this.element, kStateActive);
294         if (message.data.onAnchor) {
295           this.dispatchMouseEvent(win, this.element, "click");
296         }
297         break;
299       case "Forms:SearchFocused":
300         this._closeAfterBlur = false;
301         break;
303       case "Forms:BlurDropDown-Pong":
304         if (!this._closeAfterBlur || !gOpen) {
305           return;
306         }
307         this.actor.sendAsyncMessage("Forms:HideDropDown", {});
308         this.uninit();
309         break;
310     }
311   },
313   handleEvent(event) {
314     switch (event.type) {
315       case "pagehide":
316         if (this.element.ownerDocument === event.target) {
317           this.actor.sendAsyncMessage("Forms:HideDropDown", {});
318           this.uninit();
319         }
320         break;
321       case "blur": {
322         if (this.element !== event.target || this.disablePopupAutohide) {
323           break;
324         }
325         this._closeAfterBlur = true;
326         // Send a ping-pong message to make sure that we wait for
327         // enough cycles to pass from the potential focusing of the
328         // search box to disable closing-after-blur.
329         this.actor.sendAsyncMessage("Forms:BlurDropDown-Ping", {});
330         break;
331       }
332       case "mozhidedropdown":
333         if (this.element === event.target) {
334           this.actor.sendAsyncMessage("Forms:HideDropDown", {});
335           this.uninit();
336         }
337         break;
338       case "transitionend":
339         if (
340           this.element === event.target &&
341           SUPPORTED_SELECT_PROPERTIES.includes(event.propertyName)
342         ) {
343           this._updateTimer.arm();
344         }
345         break;
346     }
347   },
350 function getComputedStyles(element) {
351   return element.ownerGlobal.getComputedStyle(element);
354 function supportedStyles(cs, supportedProps) {
355   let styles = {};
356   for (let property of supportedProps) {
357     styles[property] = cs.getPropertyValue(property);
358   }
359   return styles;
362 function supportedStylesEqual(styles, otherStyles) {
363   for (let property in styles) {
364     if (styles[property] !== otherStyles[property]) {
365       return false;
366     }
367   }
368   return true;
371 function uniqueStylesIndex(cs, uniqueStyles) {
372   let styles = supportedStyles(cs, SUPPORTED_OPTION_OPTGROUP_PROPERTIES);
373   for (let i = uniqueStyles.length; i--; ) {
374     if (supportedStylesEqual(uniqueStyles[i], styles)) {
375       return i;
376     }
377   }
378   uniqueStyles.push(styles);
379   return uniqueStyles.length - 1;
382 function buildOptionListForChildren(node, uniqueStyles) {
383   let result = [];
385   for (let child of node.children) {
386     let className = ChromeUtils.getClassName(child);
387     let isOption = className == "HTMLOptionElement";
388     let isOptGroup = className == "HTMLOptGroupElement";
389     if (!isOption && !isOptGroup) {
390       continue;
391     }
392     if (child.hidden) {
393       continue;
394     }
396     // The option code-path should match HTMLOptionElement::GetRenderedLabel.
397     let textContent = isOptGroup
398       ? child.getAttribute("label")
399       : child.label || child.text;
400     if (textContent == null) {
401       textContent = "";
402     }
404     let cs = getComputedStyles(child);
405     let info = {
406       index: child.index,
407       isOptGroup,
408       textContent,
409       disabled: child.disabled,
410       display: cs.display,
411       tooltip: child.title,
412       children: isOptGroup
413         ? buildOptionListForChildren(child, uniqueStyles)
414         : [],
415       // Most options have the same style. In order to reduce the size of the
416       // IPC message, coalesce them in uniqueStyles.
417       styleIndex: uniqueStylesIndex(cs, uniqueStyles),
418     };
419     result.push(info);
420   }
421   return result;
424 // Hold the instance of SelectContentHelper created
425 // when the dropdown list is opened. This variable helps
426 // re-route the received message from SelectChild to SelectContentHelper object.
427 let currentSelectContentHelper = new WeakMap();
429 export class SelectChild extends JSWindowActorChild {
430   handleEvent(event) {
431     if (SelectContentHelper.open) {
432       // The SelectContentHelper object handles captured
433       // events when the <select> popup is open.
434       let contentHelper = currentSelectContentHelper.get(this);
435       if (contentHelper) {
436         contentHelper.handleEvent(event);
437       }
438       return;
439     }
441     switch (event.type) {
442       case "mozshowdropdown": {
443         let contentHelper = new SelectContentHelper(
444           event.target,
445           { isOpenedViaTouch: false },
446           this
447         );
448         currentSelectContentHelper.set(this, contentHelper);
449         break;
450       }
452       case "mozshowdropdown-sourcetouch": {
453         let contentHelper = new SelectContentHelper(
454           event.target,
455           { isOpenedViaTouch: true },
456           this
457         );
458         currentSelectContentHelper.set(this, contentHelper);
459         break;
460       }
461     }
462   }
464   receiveMessage(message) {
465     let contentHelper = currentSelectContentHelper.get(this);
466     if (contentHelper) {
467       contentHelper.receiveMessage(message);
468     }
469   }