gecko-dev/toolkit/actors/SelectParent.sys.mjs

799 строки
25 KiB
JavaScript

/* 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);
}
}
}