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";
10 ChromeUtils.defineESModuleGetters(lazy, {
11 DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
12 LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
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 = [
32 const SUPPORTED_SELECT_PROPERTIES = [
33 ...SUPPORTED_OPTION_OPTGROUP_PROPERTIES,
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.
44 export var SelectContentHelper = function (aElement, aOptions, aActor) {
45 this.element = aElement;
46 this.initialSelection = aElement[aElement.selectedIndex] || null;
48 this.closedWithClickOn = false;
49 this.isOpenedViaTouch = aOptions.isOpenedViaTouch;
50 this._closeAfterBlur = true;
51 this._pseudoStylesSetup = false;
52 this._lockedDescendants = null;
55 this._updateTimer = new lazy.DeferredTask(this._update.bind(this), 0);
58 Object.defineProperty(SelectContentHelper, "open", {
64 SelectContentHelper.prototype = {
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, {
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();
79 this.mut.observe(this.element, {
85 XPCOMUtils.defineLazyPreferenceGetter(
87 "disablePopupAutohide",
88 "ui.popup.disable_autohide",
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, {
103 this.mut.disconnect();
104 this._updateTimer.disarm();
105 this._updateTimer = null;
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(
118 this.actor.sendAsyncMessage("Forms:ShowDropDown", {
119 isOpenedViaTouch: this.isOpenedViaTouch,
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),
128 this._clearPseudoClassStyles();
132 _setupPseudoClassStyles() {
133 if (this._pseudoStylesSetup) {
134 throw new Error("pseudo styles must not be set up yet");
136 // Do all of the things that change style at once, before we read
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);
151 _clearPseudoClassStyles() {
152 if (!this._pseudoStylesSetup) {
153 throw new Error("pseudo styles must be set up already");
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);
162 this._lockedDescendants = null;
163 this._pseudoStylesSetup = false;
166 _getBoundingContentRect() {
167 return lazy.LayoutUtils.getElementBoundingScreenRect(this.element);
171 if (!this._pseudoStylesSetup) {
172 throw new Error("pseudo styles must be set up");
174 let uniqueStyles = [];
175 let options = buildOptionListForChildren(this.element, uniqueStyles);
176 return { options, uniqueStyles };
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(
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),
198 this._clearPseudoClassStyles();
201 dispatchMouseEvent(win, target, eventName) {
202 let mouseEvent = new win.MouseEvent(eventName, {
208 target.dispatchEvent(mouseEvent);
211 receiveMessage(message) {
212 switch (message.name) {
213 case "Forms:SelectDropDownItem":
214 this.element.selectedIndex = message.data.value;
215 this.closedWithClickOn = !message.data.closedWithEnter;
218 case "Forms:DismissedDropDown": {
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");
244 // Clear active document no matter user selects via keyboard or mouse
245 InspectorUtils.removeContentState(
248 /* aClearActiveDocument */ true
251 // Fire input and change events when selected option changes
252 if (this.initialSelection !== selectedOption) {
253 let inputEvent = new win.Event("input", {
258 let changeEvent = new win.Event("change", {
262 let handlingUserInput = win.windowUtils.setHandlingUserInput(true);
264 element.dispatchEvent(inputEvent);
265 element.dispatchEvent(changeEvent);
267 handlingUserInput.destruct();
272 if (this.closedWithClickOn) {
273 this.dispatchMouseEvent(win, selectedOption, "click");
280 case "Forms:MouseOver":
281 InspectorUtils.setContentState(this.element, kStateHover);
284 case "Forms:MouseOut":
285 InspectorUtils.removeContentState(this.element, kStateHover);
288 case "Forms:MouseUp":
289 let win = this.element.ownerGlobal;
290 if (message.data.onAnchor) {
291 this.dispatchMouseEvent(win, this.element, "mouseup");
293 InspectorUtils.removeContentState(this.element, kStateActive);
294 if (message.data.onAnchor) {
295 this.dispatchMouseEvent(win, this.element, "click");
299 case "Forms:SearchFocused":
300 this._closeAfterBlur = false;
303 case "Forms:BlurDropDown-Pong":
304 if (!this._closeAfterBlur || !gOpen) {
307 this.actor.sendAsyncMessage("Forms:HideDropDown", {});
314 switch (event.type) {
316 if (this.element.ownerDocument === event.target) {
317 this.actor.sendAsyncMessage("Forms:HideDropDown", {});
322 if (this.element !== event.target || this.disablePopupAutohide) {
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", {});
332 case "mozhidedropdown":
333 if (this.element === event.target) {
334 this.actor.sendAsyncMessage("Forms:HideDropDown", {});
338 case "transitionend":
340 this.element === event.target &&
341 SUPPORTED_SELECT_PROPERTIES.includes(event.propertyName)
343 this._updateTimer.arm();
350 function getComputedStyles(element) {
351 return element.ownerGlobal.getComputedStyle(element);
354 function supportedStyles(cs, supportedProps) {
356 for (let property of supportedProps) {
357 styles[property] = cs.getPropertyValue(property);
362 function supportedStylesEqual(styles, otherStyles) {
363 for (let property in styles) {
364 if (styles[property] !== otherStyles[property]) {
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)) {
378 uniqueStyles.push(styles);
379 return uniqueStyles.length - 1;
382 function buildOptionListForChildren(node, uniqueStyles) {
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) {
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) {
404 let cs = getComputedStyles(child);
409 disabled: child.disabled,
411 tooltip: child.title,
413 ? buildOptionListForChildren(child, uniqueStyles)
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),
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 {
431 if (SelectContentHelper.open) {
432 // The SelectContentHelper object handles captured
433 // events when the <select> popup is open.
434 let contentHelper = currentSelectContentHelper.get(this);
436 contentHelper.handleEvent(event);
441 switch (event.type) {
442 case "mozshowdropdown": {
443 let contentHelper = new SelectContentHelper(
445 { isOpenedViaTouch: false },
448 currentSelectContentHelper.set(this, contentHelper);
452 case "mozshowdropdown-sourcetouch": {
453 let contentHelper = new SelectContentHelper(
455 { isOpenedViaTouch: true },
458 currentSelectContentHelper.set(this, contentHelper);
464 receiveMessage(message) {
465 let contentHelper = currentSelectContentHelper.get(this);
467 contentHelper.receiveMessage(message);