Bug 1833114 - Simplify marking code now |stack| represents the mark stack for the...
[gecko.git] / toolkit / actors / SelectChild.sys.mjs
blobdf2dc9016e384f03e04b8b4f06153740ded19e48
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 = this.element.querySelectorAll(
141       ":checked"
142     ));
143     for (let child of lockedDescendants) {
144       // Selected options have the :checked pseudo-class, which
145       // we want to disable before calculating the computed
146       // styles since the user agent styles alter the styling
147       // based on :checked.
148       InspectorUtils.addPseudoClassLock(child, ":checked", false);
149     }
150   },
152   _clearPseudoClassStyles() {
153     if (!this._pseudoStylesSetup) {
154       throw new Error("pseudo styles must be set up already");
155     }
156     // Undo all of the things that change style at once, after we're
157     // done reading styles.
158     InspectorUtils.clearPseudoClassLocks(this.element);
159     let lockedDescendants = this._lockedDescendants;
160     for (let child of lockedDescendants) {
161       InspectorUtils.clearPseudoClassLocks(child);
162     }
163     this._lockedDescendants = null;
164     this._pseudoStylesSetup = false;
165   },
167   _getBoundingContentRect() {
168     return lazy.LayoutUtils.getElementBoundingScreenRect(this.element);
169   },
171   _buildOptionList() {
172     if (!this._pseudoStylesSetup) {
173       throw new Error("pseudo styles must be set up");
174     }
175     let uniqueStyles = [];
176     let options = buildOptionListForChildren(this.element, uniqueStyles);
177     return { options, uniqueStyles };
178   },
180   _update() {
181     // The <select> was updated while the dropdown was open.
182     // Let's send up a new list of options.
183     // Technically we might not need to set this pseudo-class
184     // during _update() since the element should organically
185     // have :focus, though it is here for belt-and-suspenders.
186     this._setupPseudoClassStyles();
187     let computedStyles = getComputedStyles(this.element);
188     let defaultStyles = this.element.ownerGlobal.getDefaultComputedStyle(
189       this.element
190     );
191     this.actor.sendAsyncMessage("Forms:UpdateDropDown", {
192       options: this._buildOptionList(),
193       custom: !this.element.nodePrincipal.isSystemPrincipal,
194       selectedIndex: this.element.selectedIndex,
195       isDarkBackground: ChromeUtils.isDarkBackground(this.element),
196       style: supportedStyles(computedStyles, SUPPORTED_SELECT_PROPERTIES),
197       defaultStyle: supportedStyles(defaultStyles, SUPPORTED_SELECT_PROPERTIES),
198     });
199     this._clearPseudoClassStyles();
200   },
202   dispatchMouseEvent(win, target, eventName) {
203     let mouseEvent = new win.MouseEvent(eventName, {
204       view: win,
205       bubbles: true,
206       cancelable: true,
207       composed: true,
208     });
209     target.dispatchEvent(mouseEvent);
210   },
212   receiveMessage(message) {
213     switch (message.name) {
214       case "Forms:SelectDropDownItem":
215         this.element.selectedIndex = message.data.value;
216         this.closedWithClickOn = !message.data.closedWithEnter;
217         break;
219       case "Forms:DismissedDropDown": {
220         if (!this.element) {
221           return;
222         }
224         let win = this.element.ownerGlobal;
226         // Running arbitrary script below (dispatching events for example) can
227         // close us, but we should still send events consistently.
228         let element = this.element;
230         let selectedOption = element.item(element.selectedIndex);
232         // For ordering of events, we're using non-e10s as our guide here,
233         // since the spec isn't exactly clear. In non-e10s:
234         // - If the user clicks on an element in the dropdown, we fire
235         //   mousedown, mouseup, input, change, and click events.
236         // - If the user uses the keyboard to select an element in the
237         //   dropdown, we only fire input and change events.
238         // - If the user pressed ESC key or clicks outside the dropdown,
239         //   we fire nothing as the selected option is unchanged.
240         if (this.closedWithClickOn) {
241           this.dispatchMouseEvent(win, selectedOption, "mousedown");
242           this.dispatchMouseEvent(win, selectedOption, "mouseup");
243         }
245         // Clear active document no matter user selects via keyboard or mouse
246         InspectorUtils.removeContentState(
247           element,
248           kStateActive,
249           /* aClearActiveDocument */ true
250         );
252         // Fire input and change events when selected option changes
253         if (this.initialSelection !== selectedOption) {
254           let inputEvent = new win.Event("input", {
255             bubbles: true,
256             composed: true,
257           });
259           let changeEvent = new win.Event("change", {
260             bubbles: true,
261           });
263           let handlingUserInput = win.windowUtils.setHandlingUserInput(true);
264           try {
265             element.dispatchEvent(inputEvent);
266             element.dispatchEvent(changeEvent);
267           } finally {
268             handlingUserInput.destruct();
269           }
270         }
272         // Fire click event
273         if (this.closedWithClickOn) {
274           this.dispatchMouseEvent(win, selectedOption, "click");
275         }
277         this.uninit();
278         break;
279       }
281       case "Forms:MouseOver":
282         InspectorUtils.setContentState(this.element, kStateHover);
283         break;
285       case "Forms:MouseOut":
286         InspectorUtils.removeContentState(this.element, kStateHover);
287         break;
289       case "Forms:MouseUp":
290         let win = this.element.ownerGlobal;
291         if (message.data.onAnchor) {
292           this.dispatchMouseEvent(win, this.element, "mouseup");
293         }
294         InspectorUtils.removeContentState(this.element, kStateActive);
295         if (message.data.onAnchor) {
296           this.dispatchMouseEvent(win, this.element, "click");
297         }
298         break;
300       case "Forms:SearchFocused":
301         this._closeAfterBlur = false;
302         break;
304       case "Forms:BlurDropDown-Pong":
305         if (!this._closeAfterBlur || !gOpen) {
306           return;
307         }
308         this.actor.sendAsyncMessage("Forms:HideDropDown", {});
309         this.uninit();
310         break;
311     }
312   },
314   handleEvent(event) {
315     switch (event.type) {
316       case "pagehide":
317         if (this.element.ownerDocument === event.target) {
318           this.actor.sendAsyncMessage("Forms:HideDropDown", {});
319           this.uninit();
320         }
321         break;
322       case "blur": {
323         if (this.element !== event.target || this.disablePopupAutohide) {
324           break;
325         }
326         this._closeAfterBlur = true;
327         // Send a ping-pong message to make sure that we wait for
328         // enough cycles to pass from the potential focusing of the
329         // search box to disable closing-after-blur.
330         this.actor.sendAsyncMessage("Forms:BlurDropDown-Ping", {});
331         break;
332       }
333       case "mozhidedropdown":
334         if (this.element === event.target) {
335           this.actor.sendAsyncMessage("Forms:HideDropDown", {});
336           this.uninit();
337         }
338         break;
339       case "transitionend":
340         if (
341           this.element === event.target &&
342           SUPPORTED_SELECT_PROPERTIES.includes(event.propertyName)
343         ) {
344           this._updateTimer.arm();
345         }
346         break;
347     }
348   },
351 function getComputedStyles(element) {
352   return element.ownerGlobal.getComputedStyle(element);
355 function supportedStyles(cs, supportedProps) {
356   let styles = {};
357   for (let property of supportedProps) {
358     styles[property] = cs.getPropertyValue(property);
359   }
360   return styles;
363 function supportedStylesEqual(styles, otherStyles) {
364   for (let property in styles) {
365     if (styles[property] !== otherStyles[property]) {
366       return false;
367     }
368   }
369   return true;
372 function uniqueStylesIndex(cs, uniqueStyles) {
373   let styles = supportedStyles(cs, SUPPORTED_OPTION_OPTGROUP_PROPERTIES);
374   for (let i = uniqueStyles.length; i--; ) {
375     if (supportedStylesEqual(uniqueStyles[i], styles)) {
376       return i;
377     }
378   }
379   uniqueStyles.push(styles);
380   return uniqueStyles.length - 1;
383 function buildOptionListForChildren(node, uniqueStyles) {
384   let result = [];
386   for (let child of node.children) {
387     let className = ChromeUtils.getClassName(child);
388     let isOption = className == "HTMLOptionElement";
389     let isOptGroup = className == "HTMLOptGroupElement";
390     if (!isOption && !isOptGroup) {
391       continue;
392     }
393     if (child.hidden) {
394       continue;
395     }
397     // The option code-path should match HTMLOptionElement::GetRenderedLabel.
398     let textContent = isOptGroup
399       ? child.getAttribute("label")
400       : child.label || child.text;
401     if (textContent == null) {
402       textContent = "";
403     }
405     let cs = getComputedStyles(child);
406     let info = {
407       index: child.index,
408       isOptGroup,
409       textContent,
410       disabled: child.disabled,
411       display: cs.display,
412       tooltip: child.title,
413       children: isOptGroup
414         ? buildOptionListForChildren(child, uniqueStyles)
415         : [],
416       // Most options have the same style. In order to reduce the size of the
417       // IPC message, coalesce them in uniqueStyles.
418       styleIndex: uniqueStylesIndex(cs, uniqueStyles),
419     };
420     result.push(info);
421   }
422   return result;
425 // Hold the instance of SelectContentHelper created
426 // when the dropdown list is opened. This variable helps
427 // re-route the received message from SelectChild to SelectContentHelper object.
428 let currentSelectContentHelper = new WeakMap();
430 export class SelectChild extends JSWindowActorChild {
431   handleEvent(event) {
432     if (SelectContentHelper.open) {
433       // The SelectContentHelper object handles captured
434       // events when the <select> popup is open.
435       let contentHelper = currentSelectContentHelper.get(this);
436       if (contentHelper) {
437         contentHelper.handleEvent(event);
438       }
439       return;
440     }
442     switch (event.type) {
443       case "mozshowdropdown": {
444         let contentHelper = new SelectContentHelper(
445           event.target,
446           { isOpenedViaTouch: false },
447           this
448         );
449         currentSelectContentHelper.set(this, contentHelper);
450         break;
451       }
453       case "mozshowdropdown-sourcetouch": {
454         let contentHelper = new SelectContentHelper(
455           event.target,
456           { isOpenedViaTouch: true },
457           this
458         );
459         currentSelectContentHelper.set(this, contentHelper);
460         break;
461       }
462     }
463   }
465   receiveMessage(message) {
466     let contentHelper = currentSelectContentHelper.get(this);
467     if (contentHelper) {
468       contentHelper.receiveMessage(message);
469     }
470   }