diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/actors/SelectParent.sys.mjs | 798 |
1 files changed, 798 insertions, 0 deletions
diff --git a/toolkit/actors/SelectParent.sys.mjs b/toolkit/actors/SelectParent.sys.mjs new file mode 100644 index 0000000000..6c41903994 --- /dev/null +++ b/toolkit/actors/SelectParent.sys.mjs @@ -0,0 +1,798 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "DOM_FORMS_SELECTSEARCH", + "dom.forms.selectSearch", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "CUSTOM_STYLING_ENABLED", + "dom.forms.select.customstyling", + false +); + +// Maximum number of rows to display in the select dropdown. +const MAX_ROWS = 20; + +// Minimum elements required to show select search +const SEARCH_MINIMUM_ELEMENTS = 40; + +// The properties that we should respect only when the item is not active. +const PROPERTIES_RESET_WHEN_ACTIVE = [ + "color", + "background-color", + "text-shadow", +]; + +// Duplicated in SelectChild.jsm +// Please keep these lists in sync. +const SUPPORTED_OPTION_OPTGROUP_PROPERTIES = [ + "direction", + "color", + "background-color", + "text-shadow", + "text-transform", + "font-family", + "font-weight", + "font-size", + "font-style", +]; + +const SUPPORTED_SELECT_PROPERTIES = [ + ...SUPPORTED_OPTION_OPTGROUP_PROPERTIES, + "scrollbar-width", + "scrollbar-color", +]; + +export var SelectParentHelper = { + /** + * `populate` takes the `menulist` element and a list of `items` and generates + * a popup list of options. + * + * If `CUSTOM_STYLING_ENABLED` is set to `true`, the function will also + * style the select and its popup trying to prevent the text + * and background to end up in the same color. + * + * All `ua*` variables represent the color values for the default colors + * for their respective form elements used by the user agent. + * The `select*` variables represent the color values defined for the + * particular <select> element. + * + * The `customoptionstyling` attribute controls the application of + * `-moz-appearance` on the elements and is disabled if the element is + * defining its own background-color. + * + * @param {Element} menulist + * @param {Array<Element>} items + * @param {Array<Object>} uniqueItemStyles + * @param {Number} selectedIndex + * @param {Number} zoom + * @param {Boolean} custom + * @param {Boolean} isDarkBackground + * @param {Object} uaStyle + * @param {Object} selectStyle + */ + populate( + menulist, + items, + uniqueItemStyles, + selectedIndex, + zoom, + custom, + isDarkBackground, + uaStyle, + selectStyle + ) { + let doc = menulist.ownerDocument; + + // Clear the current contents of the popup + let menupopup = menulist.menupopup; + menupopup.textContent = ""; + + let stylesheet = menulist.querySelector("#ContentSelectDropdownStylesheet"); + if (stylesheet) { + stylesheet.remove(); + } + + menupopup.setAttribute("style", ""); + menupopup.style.colorScheme = isDarkBackground ? "dark" : "light"; + menupopup.style.direction = selectStyle.direction; + + stylesheet = doc.createElementNS("http://www.w3.org/1999/xhtml", "style"); + stylesheet.setAttribute("id", "ContentSelectDropdownStylesheet"); + stylesheet.hidden = true; + stylesheet = menulist.appendChild(stylesheet); + + let sheet = stylesheet.sheet; + + if (!custom) { + selectStyle = uaStyle; + } + + if (selectStyle["background-color"] == "rgba(0, 0, 0, 0)") { + selectStyle["background-color"] = uaStyle["background-color"]; + } + + if (selectStyle.color == selectStyle["background-color"]) { + selectStyle.color = uaStyle.color; + } + + // We ensure that we set the content background if the color changes as + // well, to prevent contrast issues. + let selectBackgroundSet = + selectStyle["background-color"] != uaStyle["background-color"] || + selectStyle.color != uaStyle.color; + + if (custom) { + if (selectStyle["text-shadow"] != "none") { + sheet.insertRule( + `#ContentSelectDropdown > menupopup > :is(menuitem, menucaption)[_moz-menuactive="true"] { + text-shadow: none; + }`, + 0 + ); + } + + for (let property of SUPPORTED_SELECT_PROPERTIES) { + let shouldSkip = (function () { + if (property == "direction") { + // Handled elsewhere. + return true; + } + if (!selectStyle[property]) { + return true; + } + if (property == "background-color") { + // This also depends on whether "color" is set. + return !selectBackgroundSet; + } + return selectStyle[property] == uaStyle[property]; + })(); + + if (shouldSkip) { + continue; + } + let value = selectStyle[property]; + if (property == "scrollbar-width") { + // This needs to actually apply to the relevant scrollbox, because + // scrollbar-width doesn't inherit. + property = "--content-select-scrollbar-width"; + } + if (property == "color") { + property = "--panel-color"; + } + menupopup.style.setProperty(property, value); + } + // Some webpages set the <select> backgroundColor to transparent, + // but they don't intend to change the popup to transparent. + // So we remove the backgroundColor and turn it into an image instead. + if (selectBackgroundSet) { + // We intentionally use the parsed color to prevent color + // values like `url(..)` being injected into the + // `background-image` property. + let parsedColor = menupopup.style.backgroundColor; + menupopup.style.setProperty( + "--content-select-background-image", + `linear-gradient(${parsedColor}, ${parsedColor})` + ); + // Always drop the background color to avoid messing with the custom + // shadow on Windows 10 styling. + menupopup.style.backgroundColor = ""; + // If the background is set, we also make sure we set the color, to + // prevent contrast issues. + menupopup.style.setProperty("--panel-color", selectStyle.color); + + sheet.insertRule( + `#ContentSelectDropdown > menupopup > :is(menuitem, menucaption):not([_moz-menuactive="true"]) { + color: inherit; + }`, + 0 + ); + } + } + + for (let i = 0, len = uniqueItemStyles.length; i < len; ++i) { + sheet.insertRule( + `#ContentSelectDropdown .ContentSelectDropdown-item-${i} {}`, + 0 + ); + let style = uniqueItemStyles[i]; + let rule = sheet.cssRules[0].style; + rule.direction = style.direction; + rule.fontSize = zoom * parseFloat(style["font-size"], 10) + "px"; + + if (!custom) { + continue; + } + let optionBackgroundIsTransparent = + style["background-color"] == "rgba(0, 0, 0, 0)"; + let optionBackgroundSet = + !optionBackgroundIsTransparent || style.color != selectStyle.color; + + if (optionBackgroundIsTransparent && style.color != selectStyle.color) { + style["background-color"] = selectStyle["background-color"]; + } + + if (style.color == style["background-color"]) { + style.color = selectStyle.color; + } + + let inactiveRule = null; + for (const property of SUPPORTED_OPTION_OPTGROUP_PROPERTIES) { + let shouldSkip = (function () { + if (property == "direction" || property == "font-size") { + // Handled elsewhere. + return true; + } + if (!style[property]) { + return true; + } + if (property == "background-color" || property == "color") { + // This also depends on whether "color" is set. + return !optionBackgroundSet; + } + return style[property] == selectStyle[property]; + })(); + if (shouldSkip) { + continue; + } + if (PROPERTIES_RESET_WHEN_ACTIVE.includes(property)) { + if (!inactiveRule) { + sheet.insertRule( + `#ContentSelectDropdown .ContentSelectDropdown-item-${i}:not([_moz-menuactive="true"]) {}`, + 0 + ); + inactiveRule = sheet.cssRules[0].style; + } + inactiveRule[property] = style[property]; + } else { + rule[property] = style[property]; + } + } + style.customStyling = selectBackgroundSet || optionBackgroundSet; + } + + // We only set the `customoptionstyling` if the background has been + // manually set. This prevents the overlap between moz-appearance and + // background-color. `color` and `text-shadow` do not interfere with it. + if (custom && selectBackgroundSet) { + menulist.menupopup.setAttribute("customoptionstyling", "true"); + } else { + menulist.menupopup.removeAttribute("customoptionstyling"); + } + + this._currentZoom = zoom; + this._currentMenulist = menulist; + this.populateChildren(menulist, items, uniqueItemStyles, selectedIndex); + }, + + open(browser, menulist, rect, isOpenedViaTouch, selectParentActor) { + this._actor = selectParentActor; + menulist.hidden = false; + this._currentBrowser = browser; + this._closedWithEnter = false; + this._selectRect = rect; + this._registerListeners(menulist.menupopup); + + // Set the maximum height to show exactly MAX_ROWS items. + let menupopup = menulist.menupopup; + let firstItem = menupopup.firstElementChild; + while (firstItem && firstItem.hidden) { + firstItem = firstItem.nextElementSibling; + } + + let win = menulist.ownerGlobal; + if (firstItem) { + let itemHeight = firstItem.getBoundingClientRect().height; + + // Include the padding and border on the popup. + let cs = win.getComputedStyle(menupopup); + let bpHeight = + parseFloat(cs.borderTopWidth) + + parseFloat(cs.borderBottomWidth) + + parseFloat(cs.paddingTop) + + parseFloat(cs.paddingBottom); + menupopup.style.maxHeight = itemHeight * MAX_ROWS + bpHeight + "px"; + } + + menupopup.classList.toggle("isOpenedViaTouch", isOpenedViaTouch); + + if (browser) { + browser.constrainPopup(menupopup); + } else { + menupopup.setConstraintRect(new win.DOMRect(0, 0, 0, 0)); + } + menupopup.openPopupAtScreenRect( + AppConstants.platform == "macosx" ? "selection" : "after_start", + rect.left, + rect.top, + rect.width, + rect.height, + false, + false + ); + }, + + hide(menulist, browser) { + if (this._currentBrowser == browser) { + menulist.menupopup.hidePopup(); + } + }, + + handleEvent(event) { + switch (event.type) { + case "mouseup": + function inRect(rect, x, y) { + return ( + x >= rect.left && + x <= rect.left + rect.width && + y >= rect.top && + y <= rect.top + rect.height + ); + } + + let x = event.screenX, + y = event.screenY; + let onAnchor = + !inRect(this._currentMenulist.menupopup.getOuterScreenRect(), x, y) && + inRect(this._selectRect, x, y) && + this._currentMenulist.menupopup.state == "open"; + this._actor.sendAsyncMessage("Forms:MouseUp", { onAnchor }); + break; + + case "mouseover": + if ( + !event.relatedTarget || + !this._currentMenulist.contains(event.relatedTarget) + ) { + this._actor.sendAsyncMessage("Forms:MouseOver", {}); + } + break; + + case "mouseout": + if ( + !event.relatedTarget || + !this._currentMenulist.contains(event.relatedTarget) + ) { + this._actor.sendAsyncMessage("Forms:MouseOut", {}); + } + break; + + case "keydown": + if (event.keyCode == event.DOM_VK_RETURN) { + this._closedWithEnter = true; + } + break; + + case "command": + if (event.target.hasAttribute("value")) { + this._actor.sendAsyncMessage("Forms:SelectDropDownItem", { + value: event.target.value, + closedWithEnter: this._closedWithEnter, + }); + } + break; + + case "fullscreen": + if (this._currentMenulist) { + this._currentMenulist.menupopup.hidePopup(); + } + break; + + case "popuphidden": + this._actor.sendAsyncMessage("Forms:DismissedDropDown", {}); + let popup = event.target; + this._unregisterListeners(popup); + popup.parentNode.hidden = true; + this._currentBrowser = null; + this._currentMenulist = null; + this._selectRect = null; + this._currentZoom = 1; + this._actor = null; + break; + } + }, + + receiveMessage(browser, msg) { + // Sanity check - we'd better know what the currently opened menulist is, + // and what browser it belongs to... + if (!this._currentMenulist || this._currentBrowser != browser) { + return; + } + + if (msg.name == "Forms:UpdateDropDown") { + let scrollBox = this._currentMenulist.menupopup.scrollBox.scrollbox; + let scrollTop = scrollBox.scrollTop; + + let options = msg.data.options; + let selectedIndex = msg.data.selectedIndex; + this.populate( + this._currentMenulist, + options.options, + options.uniqueStyles, + selectedIndex, + this._currentZoom, + msg.data.custom && lazy.CUSTOM_STYLING_ENABLED, + msg.data.isDarkBackground, + msg.data.defaultStyle, + msg.data.style + ); + + // Restore scroll position to what it was prior to the update. + scrollBox.scrollTop = scrollTop; + } else if (msg.name == "Forms:BlurDropDown-Ping") { + this._actor.sendAsyncMessage("Forms:BlurDropDown-Pong", {}); + } + }, + + _registerListeners(popup) { + popup.addEventListener("command", this); + popup.addEventListener("popuphidden", this); + popup.addEventListener("mouseover", this); + popup.addEventListener("mouseout", this); + popup.ownerGlobal.addEventListener("mouseup", this, true); + popup.ownerGlobal.addEventListener("keydown", this, true); + popup.ownerGlobal.addEventListener("fullscreen", this, true); + }, + + _unregisterListeners(popup) { + popup.removeEventListener("command", this); + popup.removeEventListener("popuphidden", this); + popup.removeEventListener("mouseover", this); + popup.removeEventListener("mouseout", this); + popup.ownerGlobal.removeEventListener("mouseup", this, true); + popup.ownerGlobal.removeEventListener("keydown", this, true); + popup.ownerGlobal.removeEventListener("fullscreen", this, true); + }, + + /** + * `populateChildren` creates all <menuitem> elements for the popup menu + * based on the list of <option> elements from the <select> element. + * + * It attempts to intelligently add per-item CSS rules if the single + * item values differ from the parent menu values and attempting to avoid + * ending up with the same color of text and background. + * + * @param {Element} menulist + * @param {Array<Element>} options + * @param {Array<Object>} uniqueOptionStyles + * @param {Number} selectedIndex + * @param {Element} parentElement + * @param {Boolean} isGroupDisabled + * @param {Boolean} addSearch + * @param {Number} nthChildIndex + * @returns {Number} + */ + populateChildren( + menulist, + options, + uniqueOptionStyles, + selectedIndex, + parentElement = null, + isGroupDisabled = false, + addSearch = true, + nthChildIndex = 1 + ) { + let element = menulist.menupopup; + + let ariaOwns = ""; + for (let option of options) { + let isOptGroup = option.isOptGroup; + let item = element.ownerDocument.createXULElement( + isOptGroup ? "menucaption" : "menuitem" + ); + if (isOptGroup) { + item.setAttribute("role", "group"); + } + item.setAttribute("label", option.textContent); + item.className = `ContentSelectDropdown-item-${option.styleIndex}`; + item.hidden = + option.display == "none" || (parentElement && parentElement.hidden); + // Keep track of which options are hidden by page content, so we can avoid + // showing them on search input. + item.hiddenByContent = item.hidden; + item.setAttribute("tooltiptext", option.tooltip); + + if (uniqueOptionStyles[option.styleIndex].customStyling) { + item.setAttribute("customoptionstyling", "true"); + } else { + item.removeAttribute("customoptionstyling"); + } + + if (parentElement) { + // In the menupopup, the optgroup is a sibling of its contained options. + // For accessibility, we want to preserve the hierarchy such that the + // options are inside the optgroup. We do this using aria-owns on the + // parent. + item.id = "ContentSelectDropdownOption" + nthChildIndex; + item.setAttribute("aria-level", "2"); + ariaOwns += item.id + " "; + } + + element.appendChild(item); + nthChildIndex++; + + // A disabled optgroup disables all of its child options. + let isDisabled = isGroupDisabled || option.disabled; + if (isDisabled) { + item.setAttribute("disabled", "true"); + } + + if (isOptGroup) { + nthChildIndex = this.populateChildren( + menulist, + option.children, + uniqueOptionStyles, + selectedIndex, + item, + isDisabled, + false, + nthChildIndex + ); + } else { + if (option.index == selectedIndex) { + // We expect the parent element of the popup to be a <xul:menulist> that + // has the popuponly attribute set to "true". This is necessary in order + // for a <xul:menupopup> to act like a proper <html:select> dropdown, as + // the <xul:menulist> does things like remember state and set the + // _moz-menuactive attribute on the selected <xul:menuitem>. + menulist.selectedItem = item; + + // It's hack time. In the event that we've re-populated the menulist due + // to a mutation in the <select> in content, that means that the -moz_activemenu + // may have been removed from the selected item. Since that's normally only + // set for the initially selected on popupshowing for the menulist, and we + // don't want to close and re-open the popup, we manually set it here. + menulist.activeChild = item; + } + + item.setAttribute("value", option.index); + + if (parentElement) { + item.classList.add("contentSelectDropdown-ingroup"); + } + } + } + + if (parentElement && ariaOwns) { + parentElement.setAttribute("aria-owns", ariaOwns); + } + + // Check if search pref is enabled, if this is the first time iterating through + // the dropdown, and if the list is long enough for a search element to be added. + if ( + lazy.DOM_FORMS_SELECTSEARCH && + addSearch && + element.childElementCount > SEARCH_MINIMUM_ELEMENTS + ) { + // Add a search text field as the first element of the dropdown + let searchbox = element.ownerDocument.createXULElement("search-textbox"); + searchbox.className = "contentSelectDropdown-searchbox"; + searchbox.addEventListener("input", this.onSearchInput); + searchbox.addEventListener("focus", this.onSearchFocus.bind(this)); + searchbox.addEventListener("blur", this.onSearchBlur); + searchbox.addEventListener("command", this.onSearchInput); + + // Handle special keys for exiting search + searchbox.addEventListener( + "keydown", + event => { + this.onSearchKeydown(event, menulist); + }, + true + ); + + element.insertBefore(searchbox, element.children[0]); + } + + return nthChildIndex; + }, + + onSearchKeydown(event, menulist) { + if (event.defaultPrevented) { + return; + } + + let searchbox = event.currentTarget; + switch (event.key) { + case "Escape": + searchbox.parentElement.hidePopup(); + break; + case "ArrowDown": + case "Enter": + case "Tab": + searchbox.blur(); + if ( + searchbox.nextElementSibling.localName == "menuitem" && + !searchbox.nextElementSibling.hidden + ) { + menulist.activeChild = searchbox.nextElementSibling; + } else { + let currentOption = searchbox.nextElementSibling; + while ( + currentOption && + (currentOption.localName != "menuitem" || currentOption.hidden) + ) { + currentOption = currentOption.nextElementSibling; + } + if (currentOption) { + menulist.activeChild = currentOption; + } else { + searchbox.focus(); + } + } + break; + default: + return; + } + event.preventDefault(); + }, + + onSearchInput(event) { + let searchObj = event.currentTarget; + + // Get input from search field, set to all lower case for comparison + let input = searchObj.value.toLowerCase(); + // Get all items in dropdown (could be options or optgroups) + let menupopup = searchObj.parentElement; + let menuItems = menupopup.querySelectorAll("menuitem, menucaption"); + + // Flag used to detect any group headers with no visible options. + // These group headers should be hidden. + let allHidden = true; + // Keep a reference to the previous group header (menucaption) to go back + // and set to hidden if all options within are hidden. + let prevCaption = null; + + for (let currentItem of menuItems) { + // Make sure we don't show any options that were hidden by page content + if (!currentItem.hiddenByContent) { + // Get label and tooltip (title) from option and change to + // lower case for comparison + let itemLabel = currentItem.getAttribute("label").toLowerCase(); + let itemTooltip = currentItem.getAttribute("title").toLowerCase(); + + // If search input is empty, all options should be shown + if (!input) { + currentItem.hidden = false; + } else if (currentItem.localName == "menucaption") { + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + prevCaption = currentItem; + allHidden = true; + } else { + if ( + !currentItem.classList.contains("contentSelectDropdown-ingroup") && + currentItem.previousElementSibling.classList.contains( + "contentSelectDropdown-ingroup" + ) + ) { + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + prevCaption = null; + allHidden = true; + } + if (itemLabel.includes(input) || itemTooltip.includes(input)) { + currentItem.hidden = false; + allHidden = false; + } else { + currentItem.hidden = true; + } + } + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + } + } + }, + + onSearchFocus(event) { + let menupopup = event.target.closest("menupopup"); + menupopup.parentElement.activeChild = null; + menupopup.setAttribute("ignorekeys", "true"); + this._actor.sendAsyncMessage("Forms:SearchFocused", {}); + }, + + onSearchBlur(event) { + let menupopup = event.target.closest("menupopup"); + menupopup.setAttribute( + "ignorekeys", + AppConstants.platform == "win" ? "shortcuts" : "false" + ); + }, +}; + +export class SelectParent extends JSWindowActorParent { + get relevantBrowser() { + return this.browsingContext.top.embedderElement; + } + + get _document() { + return this.browsingContext.topChromeWindow.document; + } + + get _menulist() { + return this._document.getElementById("ContentSelectDropdown"); + } + + _createMenulist() { + let document = this._document; + let menulist = document.createXULElement("menulist"); + menulist.setAttribute("id", "ContentSelectDropdown"); + menulist.setAttribute("popuponly", "true"); + menulist.setAttribute("hidden", "true"); + + let popup = menulist.appendChild(document.createXULElement("menupopup")); + popup.setAttribute("id", "ContentSelectDropdownPopup"); + popup.setAttribute("activateontab", "true"); + popup.setAttribute("position", "after_start"); + popup.setAttribute("level", "parent"); + if (AppConstants.platform == "win") { + popup.setAttribute("consumeoutsideclicks", "false"); + popup.setAttribute("ignorekeys", "shortcuts"); + } + + let container = + document.getElementById("mainPopupSet") || + document.querySelector("popupset") || + document.documentElement.appendChild( + document.createXULElement("popupset") + ); + + container.appendChild(menulist); + return menulist; + } + + receiveMessage(message) { + switch (message.name) { + case "Forms:ShowDropDown": { + let menulist = this._menulist || this._createMenulist(); + + let data = message.data; + + SelectParentHelper.populate( + menulist, + data.options.options, + data.options.uniqueStyles, + data.selectedIndex, + // We only want to apply the full zoom. The text zoom is already + // applied in the font-size. + this.browsingContext.fullZoom, + data.custom && lazy.CUSTOM_STYLING_ENABLED, + data.isDarkBackground, + data.defaultStyle, + data.style + ); + SelectParentHelper.open( + this.relevantBrowser, + menulist, + data.rect, + data.isOpenedViaTouch, + this + ); + break; + } + + case "Forms:HideDropDown": { + SelectParentHelper.hide(this._menulist, this.relevantBrowser); + break; + } + + default: + SelectParentHelper.receiveMessage(this.relevantBrowser, message); + } + } +} |