summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/widgets/mailWidgets.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/widgets/mailWidgets.js')
-rw-r--r--comm/mail/base/content/widgets/mailWidgets.js2477
1 files changed, 2477 insertions, 0 deletions
diff --git a/comm/mail/base/content/widgets/mailWidgets.js b/comm/mail/base/content/widgets/mailWidgets.js
new file mode 100644
index 0000000000..6ad566b742
--- /dev/null
+++ b/comm/mail/base/content/widgets/mailWidgets.js
@@ -0,0 +1,2477 @@
+/**
+ * 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-globals-from ../../../components/compose/content/addressingWidgetOverlay.js */
+/* import-globals-from ../../../components/compose/content/MsgComposeCommands.js */
+
+/* global MozElements */
+/* global MozXULElement */
+/* global gFolderDisplay */
+/* global PluralForm */
+/* global onRecipientsChanged */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ const LazyModules = {};
+
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "DBViewWrapper",
+ "resource:///modules/DBViewWrapper.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "MimeParser",
+ "resource:///modules/mimeParser.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "TagUtils",
+ "resource:///modules/TagUtils.jsm"
+ );
+
+ // NOTE: Icon column headers should have their "label" attribute set to
+ // describe the icon for the accessibility tree.
+ //
+ // NOTE: Ideally we could listen for the "alt" attribute and pass it on to the
+ // contained <img>, but the accessibility tree only seems to read the "label"
+ // for a <treecol>, and ignores the alt text.
+ class MozTreecolImage extends customElements.get("treecol") {
+ static get observedAttributes() {
+ return ["src"];
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+ this.image = document.createElement("img");
+ this.image.classList.add("treecol-icon");
+
+ this.appendChild(this.image);
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.image) {
+ return;
+ }
+
+ const src = this.getAttribute("src");
+
+ if (src != null) {
+ this.image.setAttribute("src", src);
+ } else {
+ this.image.removeAttribute("src");
+ }
+ }
+ }
+ customElements.define("treecol-image", MozTreecolImage, {
+ extends: "treecol",
+ });
+
+ /**
+ * Class extending treecols. This features a customized treecolpicker that
+ * features a menupopup with more items than the standard one.
+ *
+ * @augments {MozTreecols}
+ */
+ class MozThreadPaneTreecols extends customElements.get("treecols") {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ let treecolpicker = this.querySelector("treecolpicker:not([is]");
+
+ // Can't change the super treecolpicker by setting
+ // is="thread-pane-treecolpicker" since that needs to be there at the
+ // parsing stage to take effect.
+ // So, remove the existing treecolpicker, and add a new one.
+ if (treecolpicker) {
+ treecolpicker.remove();
+ }
+ if (!this.querySelector("treecolpicker[is=thread-pane-treecolpicker]")) {
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <treecolpicker is="thread-pane-treecolpicker"
+ class="thread-tree-col-picker"
+ tooltiptext="&columnChooser2.tooltip;"
+ fixed="true">
+ </treecolpicker>
+ `,
+ ["chrome://messenger/locale/messenger.dtd"]
+ )
+ );
+ }
+ // Exceptionally apply super late, so we get the other goodness from there
+ // now that the treecolpicker is corrected.
+ super.connectedCallback();
+ }
+ }
+ customElements.define("thread-pane-treecols", MozThreadPaneTreecols, {
+ extends: "treecols",
+ });
+
+ /**
+ * Class extending treecolpicker. This implements UI to apply column settings
+ * of the current thread pane to other mail folders too.
+ *
+ * @augments {MozTreecolPicker}
+ */
+ class MozThreadPaneTreeColpicker extends customElements.get("treecolpicker") {
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ MozXULElement.insertFTLIfNeeded("messenger/mailWidgets.ftl");
+ let popup = this.querySelector(`menupopup[anonid="popup"]`);
+
+ // We'll add an "Apply columns to..." menu
+ popup.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <menu class="applyTo-menu" label="&columnPicker.applyTo.label;">
+ <menupopup>
+ <menu class="applyToFolder-menu"
+ label="&columnPicker.applyToFolder.label;">
+ <menupopup is="folder-menupopup"
+ class="applyToFolder"
+ showFileHereLabel="true"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyToFolderAndChildren-menu"
+ label="&columnPicker.applyToFolderAndChildren.label;">
+ <menupopup is="folder-menupopup"
+ class="applyToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </menu>
+ <menu class="applyViewTo-menu" data-l10n-id="apply-current-view-to-menu">
+ <menupopup>
+ <menu class="applyViewToFolder-menu"
+ label="&columnPicker.applyToFolder.label;">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolder"
+ showFileHereLabel="true"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyViewToFolderAndChildren-menu"
+ label="&columnPicker.applyToFolderAndChildren.label;">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </menu>
+ `,
+ ["chrome://messenger/locale/messenger.dtd"]
+ )
+ );
+
+ let confirmApplyCols = (destFolder, useChildren) => {
+ // Confirm the action with the user.
+ let bundle = document.getElementById("bundle_messenger");
+ let title = useChildren
+ ? "threadPane.columnPicker.confirmFolder.withChildren.title"
+ : "threadPane.columnPicker.confirmFolder.noChildren.title";
+ let message = useChildren
+ ? "threadPane.columnPicker.confirmFolder.withChildren.message"
+ : "threadPane.columnPicker.confirmFolder.noChildren.message";
+ let confirmed = Services.prompt.confirm(
+ null,
+ bundle.getString(title),
+ bundle.getFormattedString(message, [destFolder.prettyName])
+ );
+ if (confirmed) {
+ this._applyColumns(destFolder, useChildren);
+ }
+ };
+
+ this.querySelector(".applyToFolder-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyCols(event.target._folder, false);
+ }
+ );
+
+ this.querySelector(".applyToFolderAndChildren-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyCols(event.target._folder, true);
+ }
+ );
+
+ let confirmApplyView = async (destFolder, useChildren) => {
+ let msgId = useChildren
+ ? "threadpane-apply-changes-prompt-with-children-text"
+ : "threadpane-apply-changes-prompt-no-children-text";
+ let [title, message] = await document.l10n.formatValues([
+ { id: "threadpane-apply-changes-prompt-title" },
+ { id: msgId, args: { name: destFolder.prettyName } },
+ ]);
+ if (Services.prompt.confirm(null, title, message)) {
+ this._applyView(destFolder, useChildren);
+ }
+ };
+
+ this.querySelector(".applyViewToFolder-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyView(event.target._folder, false);
+ }
+ );
+
+ this.querySelector(".applyViewToFolderAndChildren-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyView(event.target._folder, true);
+ }
+ );
+ }
+
+ _applyColumns(destFolder, useChildren) {
+ // Get the current folder's column state, plus the "swapped" column
+ // state, which swaps "From" and "Recipient" if only one is shown.
+ // This is useful for copying an incoming folder's columns to an
+ // outgoing folder, or vice versa.
+ let colState = gFolderDisplay.getColumnStates();
+
+ let myColStateString = JSON.stringify(colState);
+ let swappedColStateString;
+ if (colState.senderCol.visible != colState.recipientCol.visible) {
+ let tmp = colState.senderCol;
+ colState.senderCol = colState.recipientCol;
+ colState.recipientCol = tmp;
+ swappedColStateString = JSON.stringify(colState);
+ } else {
+ swappedColStateString = myColStateString;
+ }
+
+ let isOutgoing = function (folder) {
+ return folder.isSpecialFolder(
+ LazyModules.DBViewWrapper.prototype.OUTGOING_FOLDER_FLAGS,
+ true
+ );
+ };
+
+ let amIOutgoing = isOutgoing(gFolderDisplay.displayedFolder);
+
+ let colStateString = function (folder) {
+ return isOutgoing(folder) == amIOutgoing
+ ? myColStateString
+ : swappedColStateString;
+ };
+
+ // Now propagate appropriately...
+ const propName = gFolderDisplay.PERSISTED_COLUMN_PROPERTY_NAME;
+ if (useChildren) {
+ LazyModules.MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ folder => {
+ folder.setStringProperty(propName, colStateString(folder));
+ // Force the reference to be forgotten.
+ folder.msgDatabase = null;
+ }
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gFolderDisplay.displayedFolder,
+ "msg-folder-columns-propagated"
+ );
+ });
+ } else {
+ destFolder.setStringProperty(propName, colStateString(destFolder));
+ // null out to avoid memory bloat.
+ destFolder.msgDatabase = null;
+ }
+ }
+
+ _applyView(destFolder, useChildren) {
+ let viewFlags =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.viewFlags;
+ let sortType =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortType;
+ let sortOrder =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortOrder;
+ if (useChildren) {
+ LazyModules.MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ folder => {
+ folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags;
+ folder.msgDatabase.dBFolderInfo.sortType = sortType;
+ folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder;
+ folder.msgDatabase = null;
+ }
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gFolderDisplay.displayedFolder,
+ "msg-folder-views-propagated"
+ );
+ });
+ } else {
+ destFolder.msgDatabase.dBFolderInfo.viewFlags = viewFlags;
+ destFolder.msgDatabase.dBFolderInfo.sortType = sortType;
+ destFolder.msgDatabase.dBFolderInfo.sortOrder = sortOrder;
+ // null out to avoid memory bloat
+ destFolder.msgDatabase = null;
+ }
+ }
+ }
+ customElements.define(
+ "thread-pane-treecolpicker",
+ MozThreadPaneTreeColpicker,
+ { extends: "treecolpicker" }
+ );
+
+ // The menulist CE is defined lazily. Create one now to get menulist defined,
+ // allowing us to inherit from it.
+ if (!customElements.get("menulist")) {
+ delete document.createXULElement("menulist");
+ }
+ {
+ /**
+ * MozMenulistEditable is a menulist widget that can be made editable by setting editable="true".
+ * With an additional type="description" the list also contains an additional label that can hold
+ * for instance, a description of a menu item.
+ * It is typically used e.g. for the "Custom From Address..." feature to let the user chose and
+ * edit the address to send from.
+ *
+ * @augments {MozMenuList}
+ */
+ class MozMenulistEditable extends customElements.get("menulist") {
+ static get markup() {
+ // Accessibility information of these nodes will be
+ // presented on XULComboboxAccessible generated from <menulist>;
+ // hide these nodes from the accessibility tree.
+ return `
+ <html:link rel="stylesheet" href="chrome://global/skin/menulist.css"/>
+ <html:input part="text-input" type="text" allowevents="true"/>
+ <hbox id="label-box" part="label-box" flex="1" role="none">
+ <label id="label" part="label" crop="end" flex="1" role="none"/>
+ <label id="highlightable-label" part="label" crop="end" flex="1" role="none"/>
+ </hbox>
+ <dropmarker part="dropmarker" exportparts="icon: dropmarker-icon" type="menu" role="none"/>
+ <html:slot/>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.shadowRoot.appendChild(this.constructor.fragment);
+ this._inputField = this.shadowRoot.querySelector("input");
+ this._labelBox = this.shadowRoot.getElementById("label-box");
+ this._dropmarker = this.shadowRoot.querySelector("dropmarker");
+
+ if (this.getAttribute("type") == "description") {
+ this._description = document.createXULElement("label");
+ this._description.id = this._description.part = "description";
+ this._description.setAttribute("crop", "end");
+ this._description.setAttribute("role", "none");
+ this.shadowRoot.getElementById("label").after(this._description);
+ }
+
+ this.initializeAttributeInheritance();
+
+ this.mSelectedInternal = null;
+ this.setInitialSelection();
+
+ this._handleMutation = mutations => {
+ this.editable = this.getAttribute("editable") == "true";
+ };
+ this.mAttributeObserver = new MutationObserver(this._handleMutation);
+ this.mAttributeObserver.observe(this, {
+ attributes: true,
+ attributeFilter: ["editable"],
+ });
+
+ this._keypress = event => {
+ if (event.key == "ArrowDown") {
+ this.open = true;
+ }
+ };
+ this._inputField.addEventListener("keypress", this._keypress);
+ this._change = event => {
+ event.stopPropagation();
+ this.selectedItem = null;
+ this.setAttribute("value", this._inputField.value);
+ // Start the event again, but this time with the menulist as target.
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ };
+ this._inputField.addEventListener("change", this._change);
+
+ this._popupHiding = event => {
+ // layerX is 0 if the user clicked outside the popup.
+ if (this.editable && event.layerX > 0) {
+ this._inputField.select();
+ }
+ };
+ if (!this.menupopup) {
+ this.appendChild(MozXULElement.parseXULToFragment(`<menupopup />`));
+ }
+ this.menupopup.addEventListener("popuphiding", this._popupHiding);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this.mAttributeObserver.disconnect();
+ this._inputField.removeEventListener("keypress", this._keypress);
+ this._inputField.removeEventListener("change", this._change);
+ this.menupopup.removeEventListener("popuphiding", this._popupHiding);
+
+ for (let prop of [
+ "_inputField",
+ "_labelBox",
+ "_dropmarker",
+ "_description",
+ ]) {
+ if (this[prop]) {
+ this[prop].remove();
+ this[prop] = null;
+ }
+ }
+ }
+
+ static get inheritedAttributes() {
+ let attrs = super.inheritedAttributes;
+ attrs.input = "value,disabled";
+ attrs["#description"] = "value=description";
+ return attrs;
+ }
+
+ set editable(val) {
+ if (val == this.editable) {
+ return;
+ }
+
+ if (!val) {
+ // If we were focused and transition from editable to not editable,
+ // focus the parent menulist so that the focus does not get stuck.
+ if (this._inputField == document.activeElement) {
+ window.setTimeout(() => this.focus(), 0);
+ }
+ }
+
+ this.setAttribute("editable", val);
+ }
+
+ get editable() {
+ return this.getAttribute("editable") == "true";
+ }
+
+ set value(val) {
+ this._inputField.value = val;
+ this.setAttribute("value", val);
+ this.setAttribute("label", val);
+ }
+
+ get value() {
+ if (this.editable) {
+ return this._inputField.value;
+ }
+ return super.value;
+ }
+
+ get label() {
+ if (this.editable) {
+ return this._inputField.value;
+ }
+ return super.label;
+ }
+
+ set placeholder(val) {
+ this._inputField.placeholder = val;
+ }
+
+ get placeholder() {
+ return this._inputField.placeholder;
+ }
+
+ set selectedItem(val) {
+ if (val) {
+ this._inputField.value = val.getAttribute("value");
+ }
+ super.selectedItem = val;
+ }
+
+ get selectedItem() {
+ return super.selectedItem;
+ }
+
+ focus() {
+ if (this.editable) {
+ this._inputField.focus();
+ } else {
+ super.focus();
+ }
+ }
+
+ select() {
+ if (this.editable) {
+ this._inputField.select();
+ }
+ }
+ }
+
+ const MenuBaseControl = MozElements.BaseControlMixin(
+ MozElements.MozElementMixin(XULMenuElement)
+ );
+ MenuBaseControl.implementCustomInterface(MozMenulistEditable, [
+ Ci.nsIDOMXULMenuListElement,
+ Ci.nsIDOMXULSelectControlElement,
+ ]);
+
+ customElements.define("menulist-editable", MozMenulistEditable, {
+ extends: "menulist",
+ });
+ }
+
+ /**
+ * The MozAttachmentlist widget lists attachments for a mail. This is typically used to show
+ * attachments while writing a new mail as well as when reading mails.
+ *
+ * @augments {MozElements.RichListBox}
+ */
+ class MozAttachmentlist extends MozElements.RichListBox {
+ constructor() {
+ super();
+
+ this.messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ this.addEventListener("keypress", event => {
+ switch (event.key) {
+ case " ":
+ // Allow plain spacebar to select the focused item.
+ if (!event.shiftKey && !event.ctrlKey) {
+ this.addItemToSelection(this.currentItem);
+ }
+ // Prevent inbuilt scrolling.
+ event.preventDefault();
+ break;
+
+ case "Enter":
+ if (this.currentItem && !event.ctrlKey && !event.shiftKey) {
+ this.addItemToSelection(this.currentItem);
+ let evt = document.createEvent("XULCommandEvent");
+ evt.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ event.ctrlKey,
+ event.altKey,
+ event.shiftKey,
+ event.metaKey,
+ null
+ );
+ this.currentItem.dispatchEvent(evt);
+ }
+ break;
+ }
+ });
+
+ // Make sure we keep the focus.
+ this.addEventListener("mousedown", event => {
+ if (event.button != 0) {
+ return;
+ }
+
+ if (document.commandDispatcher.focusedElement != this) {
+ this.focus();
+ }
+ });
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ let children = Array.from(this._childNodes);
+
+ children
+ .filter(child => child.getAttribute("selected") == "true")
+ .forEach(this.selectedItems.append, this.selectedItems);
+
+ children
+ .filter(child => !child.hasAttribute("context"))
+ .forEach(child =>
+ child.setAttribute("context", this.getAttribute("itemcontext"))
+ );
+ }
+
+ get itemCount() {
+ return this._childNodes.length;
+ }
+
+ /**
+ * Get the preferred height (the height that would allow us to fit
+ * everything without scrollbars) of the attachmentlist's bounding
+ * rectangle. Add 3px to account for item's margin.
+ */
+ get preferredHeight() {
+ return this.scrollHeight + this.getBoundingClientRect().height + 3;
+ }
+
+ get _childNodes() {
+ return this.querySelectorAll("richlistitem.attachmentItem");
+ }
+
+ getIndexOfItem(item) {
+ for (let i = 0; i < this._childNodes.length; i++) {
+ if (this._childNodes[i] === item) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ getItemAtIndex(index) {
+ if (index >= 0 && index < this._childNodes.length) {
+ return this._childNodes[index];
+ }
+ return null;
+ }
+
+ getRowCount() {
+ return this._childNodes.length;
+ }
+
+ getIndexOfFirstVisibleRow() {
+ if (this._childNodes.length == 0) {
+ return -1;
+ }
+
+ // First try to estimate which row is visible, assuming they're all the same height.
+ let box = this;
+ let estimatedRow = Math.floor(
+ box.scrollTop / this._childNodes[0].getBoundingClientRect().height
+ );
+ let estimatedIndex = estimatedRow * this._itemsPerRow();
+ let offset = this._childNodes[estimatedIndex].screenY - box.screenY;
+
+ if (offset > 0) {
+ // We went too far! Go back until we find an item totally off-screen, then return the one
+ // after that.
+ for (let i = estimatedIndex - 1; i >= 0; i--) {
+ let childBoxObj = this._childNodes[i].getBoundingClientRect();
+ if (childBoxObj.screenY + childBoxObj.height <= box.screenY) {
+ return i + 1;
+ }
+ }
+
+ // If we get here, we must have gone back to the beginning of the list, so just return 0.
+ return 0;
+ }
+
+ // We didn't go far enough! Keep going until we find an item at least partially on-screen.
+ for (let i = estimatedIndex; i < this._childNodes.length; i++) {
+ let childBoxObj = this._childNodes[i].getBoundingClientRect();
+ if (childBoxObj.screenY + childBoxObj.height > box.screenY > 0) {
+ return i;
+ }
+ }
+
+ return null;
+ }
+
+ ensureIndexIsVisible(index) {
+ this.ensureElementIsVisible(this.getItemAtIndex(index));
+ }
+
+ ensureElementIsVisible(item) {
+ let box = this;
+
+ // Are we too far down?
+ if (item.screenY < box.screenY) {
+ box.scrollTop =
+ item.getBoundingClientRect().y - box.getBoundingClientRect().y;
+ } else if (
+ item.screenY + item.getBoundingClientRect().height >
+ box.screenY + box.getBoundingClientRect().height
+ ) {
+ // ... or not far enough?
+ box.scrollTop =
+ item.getBoundingClientRect().y +
+ item.getBoundingClientRect().height -
+ box.getBoundingClientRect().y -
+ box.getBoundingClientRect().height;
+ }
+ }
+
+ scrollToIndex(index) {
+ let box = this;
+ let item = this.getItemAtIndex(index);
+ if (!item) {
+ return;
+ }
+ box.scrollTop =
+ item.getBoundingClientRect().y - box.getBoundingClientRect().y;
+ }
+
+ appendItem(attachment, name) {
+ // -1 appends due to the way getItemAtIndex is implemented.
+ return this.insertItemAt(-1, attachment, name);
+ }
+
+ insertItemAt(index, attachment, name) {
+ let item = this.ownerDocument.createXULElement("richlistitem");
+ item.classList.add("attachmentItem");
+ item.setAttribute("role", "option");
+
+ item.addEventListener("dblclick", event => {
+ let evt = document.createEvent("XULCommandEvent");
+ evt.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ event.ctrlKey,
+ event.altKey,
+ event.shiftKey,
+ event.metaKey,
+ null
+ );
+ item.dispatchEvent(evt);
+ });
+
+ let makeDropIndicator = placementClass => {
+ let img = document.createElement("img");
+ img.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/tab-drag-indicator.svg"
+ );
+ img.setAttribute("alt", "");
+ img.classList.add("attach-drop-indicator", placementClass);
+ return img;
+ };
+
+ item.appendChild(makeDropIndicator("before"));
+
+ let icon = this.ownerDocument.createElement("img");
+ icon.setAttribute("alt", "");
+ icon.setAttribute("draggable", "false");
+ // Allow the src to be invalid.
+ icon.classList.add("attachmentcell-icon", "invisible-on-broken");
+ item.appendChild(icon);
+
+ let textLabel = this.ownerDocument.createElement("span");
+ textLabel.classList.add("attachmentcell-name");
+ item.appendChild(textLabel);
+
+ let extensionLabel = this.ownerDocument.createElement("span");
+ extensionLabel.classList.add("attachmentcell-extension");
+ item.appendChild(extensionLabel);
+
+ let sizeLabel = this.ownerDocument.createElement("span");
+ sizeLabel.setAttribute("role", "note");
+ sizeLabel.classList.add("attachmentcell-size");
+ item.appendChild(sizeLabel);
+
+ item.appendChild(makeDropIndicator("after"));
+
+ item.setAttribute("context", this.getAttribute("itemcontext"));
+
+ item.attachment = attachment;
+ this.invalidateItem(item, name);
+ this.insertBefore(item, this.getItemAtIndex(index));
+ return item;
+ }
+
+ /**
+ * Set the attachment icon source.
+ *
+ * @param {MozRichlistitem} item - The attachment item to set the icon of.
+ * @param {string|null} src - The src to set.
+ */
+ setAttachmentIconSrc(item, src) {
+ let icon = item.querySelector(".attachmentcell-icon");
+ icon.setAttribute("src", src);
+ }
+
+ /**
+ * Refresh the attachment icon using the attachment details.
+ *
+ * @param {MozRichlistitem} item - The attachment item to refresh the icon
+ * for.
+ */
+ refreshAttachmentIcon(item) {
+ let src;
+ let attachment = item.attachment;
+ let type = attachment.contentType;
+ if (type == "text/x-moz-deleted") {
+ src = "chrome://messenger/skin/icons/attachment-deleted.svg";
+ } else if (!item.loaded || item.uploading) {
+ src = "chrome://global/skin/icons/loading.png";
+ } else if (item.cloudIcon) {
+ src = item.cloudIcon;
+ } else {
+ let iconName = attachment.name;
+ if (iconName.toLowerCase().endsWith(".eml")) {
+ // Discard file names derived from subject headers with special
+ // characters.
+ iconName = "message.eml";
+ } else if (attachment.url) {
+ // For local file urls, we are better off using the full file url
+ // because moz-icon will actually resolve the file url and get the
+ // right icon from the file url. All other urls, we should try to
+ // extract the file name from them. This fixes issues where an icon
+ // wasn't showing up if you dragged a web url that had a query or
+ // reference string after the file name and for mailnews urls where
+ // the filename is hidden in the url as a &filename= part.
+ let url = Services.io.newURI(attachment.url);
+ if (
+ url instanceof Ci.nsIURL &&
+ url.fileName &&
+ !url.schemeIs("file")
+ ) {
+ iconName = url.fileName;
+ }
+ }
+ src = `moz-icon://${iconName}?size=16&contentType=${type}`;
+ }
+
+ this.setAttachmentIconSrc(item, src);
+ }
+
+ /**
+ * Get whether the attachment list is fully loaded.
+ *
+ * @returns {boolean} - Whether all the attachments in the list are fully
+ * loaded.
+ */
+ isLoaded() {
+ // Not loaded if at least one loading.
+ for (let item of this.querySelectorAll(".attachmentItem")) {
+ if (!item.loaded) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Set the attachment item's loaded state.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {boolean} loaded - Whether the attachment is fully loaded.
+ */
+ setAttachmentLoaded(item, loaded) {
+ item.loaded = loaded;
+ this.refreshAttachmentIcon(item);
+ }
+
+ /**
+ * Set the attachment item's cloud icon, if any.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {?string} cloudIcon - The icon of the cloud provider where the
+ * attachment was uploaded. Will be used as file type icon in the list of
+ * attachments, if specified.
+ */
+ setCloudIcon(item, cloudIcon) {
+ item.cloudIcon = cloudIcon;
+ this.refreshAttachmentIcon(item);
+ }
+
+ /**
+ * Set the attachment item's displayed name.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {string} name - The name to display for the attachment.
+ */
+ setAttachmentName(item, name) {
+ item.setAttribute("name", name);
+ // Extract what looks like the file extension so we can always show it,
+ // even if the full name would overflow.
+ // NOTE: This is a convenience feature rather than a security feature
+ // since the content type of an attachment need not match the extension.
+ let found = name.match(/^(.+)(\.[a-zA-Z0-9_#$!~+-]{1,16})$/);
+ item.querySelector(".attachmentcell-name").textContent =
+ found?.[1] || name;
+ item.querySelector(".attachmentcell-extension").textContent =
+ found?.[2] || "";
+ }
+
+ /**
+ * Set the attachment item's displayed size.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {string} size - The size to display for the attachment.
+ */
+ setAttachmentSize(item, size) {
+ item.setAttribute("size", size);
+ let sizeEl = item.querySelector(".attachmentcell-size");
+ sizeEl.textContent = size;
+ sizeEl.hidden = !size;
+ }
+
+ invalidateItem(item, name) {
+ let attachment = item.attachment;
+
+ this.setAttachmentName(item, name || attachment.name);
+ let size =
+ attachment.size == null || attachment.size == -1
+ ? ""
+ : this.messenger.formatFileSize(attachment.size);
+ if (size && item.cloudHtmlFileSize > 0) {
+ size = `${this.messenger.formatFileSize(
+ item.cloudHtmlFileSize
+ )} (${size})`;
+ }
+ this.setAttachmentSize(item, size);
+
+ // By default, items are considered loaded.
+ item.loaded = true;
+ this.refreshAttachmentIcon(item);
+ return item;
+ }
+
+ /**
+ * Find the attachmentitem node for the specified nsIMsgAttachment.
+ */
+ findItemForAttachment(aAttachment) {
+ for (let i = 0; i < this.itemCount; i++) {
+ let item = this.getItemAtIndex(i);
+ if (item.attachment == aAttachment) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ _fireOnSelect() {
+ if (!this._suppressOnSelect && !this.suppressOnSelect) {
+ this.dispatchEvent(
+ new Event("select", { bubbles: false, cancelable: true })
+ );
+ }
+ }
+
+ _itemsPerRow() {
+ // For 0 or 1 children, we can assume that they all fit in one row.
+ if (this._childNodes.length < 2) {
+ return this._childNodes.length;
+ }
+
+ let itemWidth =
+ this._childNodes[1].getBoundingClientRect().x -
+ this._childNodes[0].getBoundingClientRect().x;
+
+ // Each item takes up a full row
+ if (itemWidth == 0) {
+ return 1;
+ }
+ return Math.floor(this.clientWidth / itemWidth);
+ }
+
+ _itemsPerCol(aItemsPerRow) {
+ let itemsPerRow = aItemsPerRow || this._itemsPerRow();
+
+ if (this._childNodes.length == 0) {
+ return 0;
+ }
+
+ if (this._childNodes.length <= itemsPerRow) {
+ return 1;
+ }
+
+ let itemHeight =
+ this._childNodes[itemsPerRow].getBoundingClientRect().y -
+ this._childNodes[0].getBoundingClientRect().y;
+
+ return Math.floor(this.clientHeight / itemHeight);
+ }
+
+ /**
+ * Set the width of each child to the largest width child to create a
+ * grid-like effect for the flex-wrapped attachment list.
+ */
+ setOptimumWidth() {
+ if (this._childNodes.length == 0) {
+ return;
+ }
+
+ let width = 0;
+ for (let child of this._childNodes) {
+ // Unset the width, then the child will expand or shrink to its
+ // "natural" size in the flex-wrapped container. I.e. its preferred
+ // width bounded by the width of the container's content space.
+ child.style.width = null;
+ width = Math.max(width, child.getBoundingClientRect().width);
+ }
+ for (let child of this._childNodes) {
+ child.style.width = `${width}px`;
+ }
+ }
+ }
+
+ customElements.define("attachment-list", MozAttachmentlist, {
+ extends: "richlistbox",
+ });
+
+ /**
+ * The MailAddressPill widget is used to display the email addresses in the
+ * messengercompose.xhtml window.
+ *
+ * @augments {MozXULElement}
+ */
+ class MailAddressPill extends MozXULElement {
+ static get inheritedAttributes() {
+ return {
+ ".pill-label": "crop,value=label",
+ };
+ }
+
+ /**
+ * Indicates whether the address of this pill is for a mail list.
+ *
+ * @type {boolean}
+ */
+ isMailList = false;
+
+ /**
+ * If this pill is for a mail list, this provides the URI.
+ *
+ * @type {?string}
+ */
+ listURI = null;
+
+ /**
+ * If this pill is for a mail list, this provides the total count of
+ * its addresses.
+ *
+ * @type {number}
+ */
+ listAddressCount = 0;
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.classList.add("address-pill");
+ this.setAttribute("context", "emailAddressPillPopup");
+ this.setAttribute("allowevents", "true");
+
+ this.labelView = document.createXULElement("hbox");
+ this.labelView.setAttribute("flex", "1");
+
+ this.pillLabel = document.createXULElement("label");
+ this.pillLabel.classList.add("pill-label");
+ this.pillLabel.setAttribute("crop", "center");
+
+ this.pillIndicator = document.createElement("img");
+ this.pillIndicator.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/pill-indicator.svg"
+ );
+ this.pillIndicator.setAttribute("alt", "");
+ this.pillIndicator.classList.add("pill-indicator");
+ this.pillIndicator.hidden = true;
+
+ this.labelView.appendChild(this.pillLabel);
+ this.labelView.appendChild(this.pillIndicator);
+
+ this.appendChild(this.labelView);
+ this._setupEmailInput();
+
+ this._setupEventListeners();
+ this.initializeAttributeInheritance();
+
+ // @implements {nsIObserver}
+ this.inputObserver = {
+ observe: (subject, topic, data) => {
+ if (topic == "autocomplete-did-enter-text" && this.isEditing) {
+ this.updatePill();
+ }
+ },
+ };
+
+ Services.obs.addObserver(
+ this.inputObserver,
+ "autocomplete-did-enter-text"
+ );
+
+ // Remove the observer on window unload as the disconnectedCallback()
+ // will never be called when closing a window, so we might therefore
+ // leak if XPCOM isn't smart enough.
+ window.addEventListener(
+ "unload",
+ () => {
+ this.removeObserver();
+ },
+ { once: true }
+ );
+ }
+
+ get emailAddress() {
+ return this.getAttribute("emailAddress");
+ }
+
+ set emailAddress(val) {
+ this.setAttribute("emailAddress", val);
+ }
+
+ get label() {
+ return this.getAttribute("label");
+ }
+
+ set label(val) {
+ this.setAttribute("label", val);
+ }
+
+ get fullAddress() {
+ return this.getAttribute("fullAddress");
+ }
+
+ set fullAddress(val) {
+ this.setAttribute("fullAddress", val);
+ }
+
+ get displayName() {
+ return this.getAttribute("displayName");
+ }
+
+ set displayName(val) {
+ this.setAttribute("displayName", val);
+ }
+
+ get emailInput() {
+ return this.querySelector(`input[is="autocomplete-input"]`);
+ }
+
+ /**
+ * Get the main addressing input field the pill belongs to.
+ */
+ get rowInput() {
+ return this.closest(".address-container").querySelector(
+ ".address-row-input"
+ );
+ }
+
+ /**
+ * Check if the pill is currently in "Edit Mode", meaning the label is
+ * hidden and the html:input field is visible.
+ *
+ * @returns {boolean} true if the pill is currently being edited.
+ */
+ get isEditing() {
+ return !this.emailInput.hasAttribute("hidden");
+ }
+
+ get fragment() {
+ if (!this.constructor.hasOwnProperty("_fragment")) {
+ this.constructor._fragment = MozXULElement.parseXULToFragment(`
+ <html:input is="autocomplete-input"
+ type="text"
+ class="input-pill"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ hidden="hidden"/>
+ `);
+ }
+ return document.importNode(this.constructor._fragment, true);
+ }
+
+ _setupEmailInput() {
+ this.appendChild(this.fragment);
+ this.emailInput.value = this.fullAddress;
+ }
+
+ _setupEventListeners() {
+ this.addEventListener("blur", event => {
+ // Prevent deselecting a pill on blur if:
+ // - The related target is null (context menu was opened, bug 1729741).
+ // - The related target is another pill (multi selection and deslection
+ // are handled by the click event listener added on pill creation).
+ if (
+ !event.relatedTarget ||
+ event.relatedTarget.tagName == "mail-address-pill"
+ ) {
+ return;
+ }
+
+ this.closest("mail-recipients-area").deselectAllPills();
+ });
+
+ this.emailInput.addEventListener("keypress", event => {
+ if (this.hasAttribute("disabled")) {
+ return;
+ }
+ this.onEmailInputKeyPress(event);
+ });
+
+ // Disable the inbuilt autocomplete on blur as we handle it here.
+ this.emailInput._dontBlur = true;
+
+ this.emailInput.addEventListener("blur", () => {
+ // If the input is still the active element after blur (when switching
+ // to another window), return to prevent autocompletion and
+ // pillification and let the user continue editing the address later.
+ if (document.activeElement == this.emailInput) {
+ return;
+ }
+
+ if (
+ this.emailInput.forceComplete &&
+ this.emailInput.mController.matchCount >= 1
+ ) {
+ // If input.forceComplete is true and there are autocomplete matches,
+ // we need to call the inbuilt Enter handler to force the input text
+ // to the best autocomplete match because we've set input._dontBlur.
+ this.emailInput.mController.handleEnter(true);
+ return;
+ }
+
+ this.updatePill();
+ });
+ }
+
+ /**
+ * Simple email address validation.
+ *
+ * @param {string} address - An email address.
+ */
+ isValidAddress(address) {
+ return /^[^\s@]+@[^\s@]+$/.test(address);
+ }
+
+ /**
+ * Convert the pill into "Edit Mode" by hiding the label and showing the
+ * html:input element.
+ */
+ startEditing() {
+ // Record the intention of editing a pill as a change in the recipient
+ // even if the text is not actually changed in order to prevent accidental
+ // data loss.
+ onRecipientsChanged();
+
+ // We need to set the min and max width before hiding and showing the
+ // child nodes in order to prevent unwanted jumps in the resizing of the
+ // edited pill. Both properties are necessary to handle flexbox.
+ this.style.setProperty("max-width", `${this.clientWidth}px`);
+ this.style.setProperty("min-width", `${this.clientWidth}px`);
+
+ this.classList.add("editing");
+ this.labelView.setAttribute("hidden", "true");
+ this.emailInput.removeAttribute("hidden");
+ this.emailInput.focus();
+
+ // Account for pill padding.
+ let inputWidth = this.emailInput.clientWidth + 15;
+
+ // In case the original address is shorter than the input field child node
+ // force resize the pill container to prevent overflows.
+ if (inputWidth > this.clientWidth) {
+ this.style.setProperty("max-width", `${inputWidth}px`);
+ this.style.setProperty("min-width", `${inputWidth}px`);
+ }
+ }
+
+ /**
+ * Revert the pill UI to a regular selectable element, meaning the label is
+ * visible and the html:input field is hidden.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ onEmailInputKeyPress(event) {
+ switch (event.key) {
+ case "Escape":
+ this.emailInput.value = this.fullAddress;
+ this.resetPill();
+ break;
+ case "Delete":
+ case "Backspace":
+ if (!this.emailInput.value.trim() && !event.repeat) {
+ this.rowInput.focus();
+ this.remove();
+ }
+ break;
+ }
+ }
+
+ async updatePill() {
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(
+ this.emailInput.value
+ );
+ let row = this.closest(".address-row");
+
+ if (!addresses[0]) {
+ this.rowInput.focus();
+ this.remove();
+ // Update aria labels of all pills in the row, as pill count changed.
+ updateAriaLabelsOfAddressRow(row);
+ onRecipientsChanged();
+ return;
+ }
+
+ this.label = addresses[0].toString();
+ this.emailAddress = addresses[0].email || "";
+ this.fullAddress = addresses[0].toString();
+ this.displayName = addresses[0].name || "";
+ // We need to detach the autocomplete Controller to prevent the input
+ // to be filled with the previously selected address when the "blur"
+ // event gets triggered.
+ this.emailInput.detachController();
+ // Attach it again to enable autocomplete.
+ this.emailInput.attachController();
+
+ this.resetPill();
+
+ // Update the aria label of edited pill only, as pill count didn't change.
+ // Unfortunately, we still need to get the row's pills for counting once.
+ let pills = row.querySelectorAll("mail-address-pill");
+ this.setAttribute(
+ "aria-label",
+ await document.l10n.formatValue("pill-aria-label", {
+ email: this.fullAddress,
+ count: pills.length,
+ })
+ );
+
+ onRecipientsChanged();
+ }
+
+ resetPill() {
+ this.updatePillStatus();
+ this.style.removeProperty("max-width");
+ this.style.removeProperty("min-width");
+ this.classList.remove("editing");
+ this.labelView.removeAttribute("hidden");
+ this.emailInput.setAttribute("hidden", "hidden");
+ let textLength = this.emailInput.value.length;
+ this.emailInput.setSelectionRange(textLength, textLength);
+ this.rowInput.focus();
+ }
+
+ /**
+ * Check if an address is valid or it exists in the address book and update
+ * the helper icons accordingly.
+ */
+ async updatePillStatus() {
+ let isValid = this.isValidAddress(this.emailAddress);
+ let listNames = LazyModules.MimeParser.parseHeaderField(
+ this.fullAddress,
+ LazyModules.MimeParser.HEADER_ADDRESS
+ );
+
+ if (listNames.length > 0) {
+ let mailList = MailServices.ab.getMailListFromName(listNames[0].name);
+ this.isMailList = !!mailList;
+ if (this.isMailList) {
+ this.listURI = mailList.URI;
+ this.listAddressCount = mailList.childCards.length;
+ } else {
+ this.listURI = "";
+ this.listAddressCount = 0;
+ }
+ }
+
+ let isNewsgroup = this.emailInput.classList.contains("news-input");
+
+ if (!isValid && !this.isMailList && !isNewsgroup) {
+ this.classList.add("invalid-address");
+ this.setAttribute(
+ "tooltiptext",
+ await document.l10n.formatValue("pill-tooltip-invalid-address", {
+ email: this.fullAddress,
+ })
+ );
+ this.pillIndicator.hidden = true;
+
+ // Interrupt if the address is not valid as we don't need to check for
+ // other conditions.
+ return;
+ }
+
+ this.classList.remove("invalid-address");
+ this.removeAttribute("tooltiptext");
+ this.pillIndicator.hidden = true;
+
+ // Check if the address is not in the Address Book only if it's not a
+ // mail list or a newsgroup.
+ if (
+ !isNewsgroup &&
+ !this.isMailList &&
+ !MailServices.ab.cardForEmailAddress(this.emailAddress)
+ ) {
+ this.setAttribute(
+ "tooltiptext",
+ await document.l10n.formatValue("pill-tooltip-not-in-address-book", {
+ email: this.fullAddress,
+ })
+ );
+ this.pillIndicator.hidden = false;
+ }
+ }
+
+ /**
+ * Get the nearest sibling pill which is not selected.
+ *
+ * @param {("next"|"previous")} [siblingsType="next"] - Iterate next or
+ * previous siblings.
+ * @returns {HTMLElement} - The nearest unselected sibling element, or null.
+ */
+ getUnselectedSiblingPill(siblingsType = "next") {
+ if (siblingsType == "next") {
+ // Check for next siblings.
+ let element = this.nextElementSibling;
+ while (element) {
+ if (!element.hasAttribute("selected")) {
+ return element;
+ }
+ element = element.nextElementSibling;
+ }
+
+ return null;
+ }
+
+ // Check for previous siblings.
+ let element = this.previousElementSibling;
+ while (element) {
+ if (!element.hasAttribute("selected")) {
+ return element;
+ }
+ element = element.previousElementSibling;
+ }
+
+ return null;
+ }
+
+ removeObserver() {
+ Services.obs.removeObserver(
+ this.inputObserver,
+ "autocomplete-did-enter-text"
+ );
+ }
+ }
+
+ customElements.define("mail-address-pill", MailAddressPill);
+
+ /**
+ * The MailRecipientsArea widget is used to display the recipient rows in the
+ * header area of the messengercompose.xul window.
+ *
+ * @augments {MozXULElement}
+ */
+ class MailRecipientsArea extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ for (let input of this.querySelectorAll(".mail-input,.news-input")) {
+ // Disable inbuilt autocomplete on blur to handle it with our handlers.
+ input._dontBlur = true;
+
+ setupAutocompleteInput(input);
+
+ input.addEventListener("keypress", event => {
+ // Ctrl+Shift+Tab is handled by moveFocusToNeighbouringArea.
+ if (event.key != "Tab" || !event.shiftKey || event.ctrlKey) {
+ return;
+ }
+ event.preventDefault();
+ this.moveFocusToPreviousElement(input);
+ });
+
+ input.addEventListener("input", event => {
+ addressInputOnInput(event, false);
+ });
+ }
+
+ // Force the focus on the first available input field if Tab is
+ // pressed on the extraAddressRowsMenuButton label.
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .addEventListener("keypress", event => {
+ if (event.key == "Tab" && !event.shiftKey) {
+ event.preventDefault();
+ let row = this.querySelector(".address-row:not(.hidden)");
+ let removeFieldButton = row.querySelector(".remove-field-button");
+ // If the close button is hidden, focus on the input field.
+ if (removeFieldButton.hidden) {
+ row.querySelector(".address-row-input").focus();
+ return;
+ }
+ // Focus on the close button.
+ removeFieldButton.focus();
+ }
+ });
+
+ this.addEventListener("dragstart", event => {
+ // Check if we're dragging a pill, as the drag target might be another
+ // element like row or pill <input> when dragging selected plain text.
+ let targetPill = event.target.closest(
+ "mail-address-pill:not(.editing)"
+ );
+ if (!targetPill) {
+ return;
+ }
+ if (!targetPill.hasAttribute("selected")) {
+ // If the drag action starts from a non-selected pill,
+ // deselect all selected pills and select only the target pill.
+ for (let pill of this.getAllSelectedPills()) {
+ pill.removeAttribute("selected");
+ }
+ targetPill.toggleAttribute("selected");
+ }
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.dropEffect = "move";
+ event.dataTransfer.setData("text/pills", "pills");
+ event.dataTransfer.setDragImage(targetPill, 50, 12);
+ });
+
+ this.addEventListener("dragover", event => {
+ event.preventDefault();
+ });
+
+ this.addEventListener("dragenter", event => {
+ if (!event.dataTransfer.getData("text/pills")) {
+ return;
+ }
+
+ // If the current drop target is a pill, add drop indicator style to it.
+ event.target
+ .closest("mail-address-pill")
+ ?.classList.add("drop-indicator");
+
+ // If the current drop target is inside an address row, add the
+ // indicator style for the row's address container.
+ event.target
+ .closest(".address-row")
+ ?.querySelector(".address-container")
+ .classList.add("drag-address-container");
+ });
+
+ this.addEventListener("dragleave", event => {
+ if (!event.dataTransfer.getData("text/pills")) {
+ return;
+ }
+ // If dragleave from pill, remove its drop indicator style.
+ event.target
+ .closest("mail-address-pill")
+ ?.classList.remove("drop-indicator");
+
+ // If dragleave from address row, remove the indicator style of its
+ // address container.
+ event.target
+ .closest(".address-row")
+ ?.querySelector(".address-container")
+ .classList.remove("drag-address-container");
+ });
+
+ this.addEventListener("drop", event => {
+ // First handle cases where the dropped data is not pills.
+ if (!event.dataTransfer.getData("text/pills")) {
+ // Bail out if the dropped data comes from the contacts sidebar.
+ // Those addresses will be added immediately as pills without going
+ // through the input field as plain text.
+ if (event.dataTransfer.types.includes("moz/abcard")) {
+ return;
+ }
+
+ // Dropped data should be plain text (images are handled elsewhere).
+ // We currently only support dropping text directly into the row input
+ // (Bug 1706187), which is inbuilt: no further handling required here.
+ // Input element resizing is automatically handled by its input event.
+ return;
+ }
+
+ // Pills have been dropped ("text/pills").
+ let targetAddressRow = event.target.closest(".address-row");
+ // Return if pills have been dropped outside an address row.
+ if (
+ !targetAddressRow ||
+ targetAddressRow.classList.contains("address-row-raw")
+ ) {
+ return;
+ }
+
+ // Pills have been dropped somewhere inside an address row.
+ // If they have been dropped directly on an address container, use that.
+ // Otherwise ensure having an addressContainer for drop targets inside
+ // the row, but outside the address container (e.g. the row label).
+ let targetAddressContainer = event.target.closest(".address-container");
+ let addressContainer =
+ targetAddressContainer ||
+ targetAddressRow.querySelector(".address-container");
+
+ // Recreate pills in the target address container.
+ // If dropped on a pill, append pills before that pill. Otherwise if
+ // dropped into an address container, append pills after existing pills.
+ // Otherwise if dropped elsewhere on the row (e.g. on the row label),
+ // append pills before existing pills.
+ let targetPill = event.target.closest("mail-address-pill");
+ this.createDNDPills(
+ addressContainer,
+ targetPill || !targetAddressContainer,
+ targetPill ? targetPill.fullAddress : null
+ );
+ addressContainer.classList.remove("drag-address-container");
+ });
+ }
+
+ /**
+ * Check if the current size of the recipient input field doesn't exceed its
+ * container width. This might happen if the user pastes a very long string
+ * with multiple addresses when pills are already present.
+ *
+ * @param {Element} input - The HTML input field.
+ * @param {integer} length - The amount of characters in the input field.
+ */
+ resizeInputField(input, length) {
+ // Set a minimum size of 1 in case no characters were written in the field
+ // in order to force the smallest size possible and avoid blank rows when
+ // multiple pills fill the entire recipient row.
+ input.setAttribute("size", length || 1);
+
+ // If the previously set size causes the input field to grow beyond 80% of
+ // its parent container, we remove the size attribute to let the CSS flex
+ // attribute let it grow naturally to fill the available space.
+ if (
+ input.clientWidth >
+ input.closest(".address-container").clientWidth * 0.8
+ ) {
+ input.removeAttribute("size");
+ }
+ }
+
+ /**
+ * Move the dragged pills to another address row.
+ *
+ * @param {string} addressContainer - The address container on which pills
+ * have been dropped.
+ * @param {boolean} [appendStart] - If the selected addresses should be
+ * appended at the start or at the end of existing addresses.
+ * Specifying targetAddress will override this.
+ * @param {string} [targetAddress] - The existing address before which all
+ * selected addresses should be appended.
+ */
+ createDNDPills(addressContainer, appendStart, targetAddress) {
+ let existingPills =
+ addressContainer.querySelectorAll("mail-address-pill");
+ let existingAddresses = [...existingPills].map(pill => pill.fullAddress);
+ let selectedAddresses = [...this.getAllSelectedPills()].map(
+ pill => pill.fullAddress
+ );
+ let originalTargetIndex = existingAddresses.indexOf(targetAddress);
+
+ // Remove all the duplicate existing addresses.
+ for (let address of selectedAddresses) {
+ let index = existingAddresses.indexOf(address);
+ if (index > -1) {
+ existingAddresses.splice(index, 1);
+ }
+ }
+
+ let combinedAddresses;
+ // If selected pills have been dropped on another pill, they should be
+ // inserted before that pill, otherwise use appendStart.
+ if (targetAddress) {
+ // Merge the two arrays in the right order. If the target address has
+ // been removed by deduplication above, use its original index.
+ existingAddresses.splice(
+ existingAddresses.includes(targetAddress)
+ ? existingAddresses.indexOf(targetAddress)
+ : originalTargetIndex,
+ 0,
+ ...selectedAddresses
+ );
+ combinedAddresses = existingAddresses;
+ } else {
+ combinedAddresses = appendStart
+ ? selectedAddresses.concat(existingAddresses)
+ : existingAddresses.concat(selectedAddresses);
+ }
+
+ // Remove all selected pills.
+ for (let pill of this.getAllSelectedPills()) {
+ pill.remove();
+ }
+
+ // Existing pills are removed before creating new ones in the right order.
+ for (let pill of existingPills) {
+ pill.remove();
+ }
+
+ // Create pills for all the combined addresses.
+ let row = addressContainer.closest(".address-row");
+ for (let address of combinedAddresses) {
+ addressRowAddRecipientsArray(
+ row,
+ [address],
+ selectedAddresses.includes(address)
+ );
+ }
+
+ // Move the focus to the first selected pill.
+ this.getAllSelectedPills()[0].focus();
+ }
+
+ /**
+ * Create a new address row and a menuitem for revealing it.
+ *
+ * @param {object} recipient - An object for various element attributes.
+ * @param {boolean} rawInput - A flag to disable pills and autocompletion.
+ * @returns {object} - The newly created elements.
+ * @property {Element} row - The address row.
+ * @property {Element} showRowMenuItem - The menu item that shows the row.
+ */
+ // NOTE: This is currently never called with rawInput = false, so it may be
+ // out of date if used.
+ buildRecipientRow(recipient, rawInput = false) {
+ let row = document.createXULElement("hbox");
+ row.setAttribute("id", recipient.rowId);
+ row.classList.add("address-row");
+ row.dataset.recipienttype = recipient.type;
+
+ let firstCol = document.createXULElement("hbox");
+ firstCol.classList.add("aw-firstColBox");
+
+ row.classList.add("hidden");
+
+ let closeButton = document.createElement("button");
+ closeButton.classList.add("remove-field-button", "plain-button");
+ document.l10n.setAttributes(closeButton, "remove-address-row-button", {
+ type: recipient.type,
+ });
+ let closeIcon = document.createElement("img");
+ closeIcon.setAttribute("src", "chrome://global/skin/icons/close.svg");
+ // Button's title is the accessible name.
+ closeIcon.setAttribute("alt", "");
+ closeButton.appendChild(closeIcon);
+
+ closeButton.addEventListener("click", event => {
+ closeLabelOnClick(event);
+ });
+ firstCol.appendChild(closeButton);
+ row.appendChild(firstCol);
+
+ let labelContainer = document.createXULElement("hbox");
+ labelContainer.setAttribute("align", "top");
+ labelContainer.setAttribute("pack", "end");
+ labelContainer.setAttribute("flex", 1);
+ labelContainer.classList.add("address-label-container");
+ labelContainer.setAttribute(
+ "style",
+ getComposeBundle().getString("headersSpaceStyle")
+ );
+
+ let label = document.createXULElement("label");
+ label.setAttribute("id", recipient.labelId);
+ label.setAttribute("value", recipient.type);
+ label.setAttribute("control", recipient.inputId);
+ label.setAttribute("flex", 1);
+ label.setAttribute("crop", "end");
+ labelContainer.appendChild(label);
+ row.appendChild(labelContainer);
+
+ let inputContainer = document.createXULElement("hbox");
+ inputContainer.setAttribute("id", recipient.containerId);
+ inputContainer.setAttribute("flex", 1);
+ inputContainer.setAttribute("align", "center");
+ inputContainer.classList.add(
+ "input-container",
+ "wrap-container",
+ "address-container"
+ );
+ inputContainer.addEventListener("click", focusAddressInputOnClick);
+
+ // Set up the row input for the row.
+ let input = document.createElement(
+ "input",
+ rawInput
+ ? undefined
+ : {
+ is: "autocomplete-input",
+ }
+ );
+ input.setAttribute("id", recipient.inputId);
+ input.setAttribute("size", 1);
+ input.setAttribute("type", "text");
+ input.setAttribute("disableonsend", true);
+ input.classList.add("plain", "address-input", "address-row-input");
+
+ if (!rawInput) {
+ // Regular autocomplete address input, not other header with raw input.
+ // Set various attributes for autocomplete.
+ input.setAttribute("autocompletesearch", "mydomain addrbook ldap news");
+ input.setAttribute("autocompletesearchparam", "{}");
+ input.setAttribute("timeout", 200);
+ input.setAttribute("maxrows", 6);
+ input.setAttribute("completedefaultindex", true);
+ input.setAttribute("forcecomplete", true);
+ input.setAttribute("completeselectedindex", true);
+ input.setAttribute("minresultsforpopup", 2);
+ input.setAttribute("ignoreblurwhilesearching", true);
+ // Disable the inbuilt autocomplete on blur as we handle it below.
+ input._dontBlur = true;
+
+ setupAutocompleteInput(input);
+
+ // Handle keydown event in autocomplete address input of row with pills.
+ // input.onBeforeHandleKeyDown() gets called by the toolkit autocomplete
+ // before going into autocompletion.
+ input.onBeforeHandleKeyDown = event => {
+ addressInputOnBeforeHandleKeyDown(event);
+ };
+ } else {
+ // Handle keydown event in other header input (rawInput), which does not
+ // have autocomplete and its associated keydown handling.
+ row.classList.add("address-row-raw");
+ input.addEventListener("keydown", otherHeaderInputOnKeyDown);
+ input.addEventListener("input", event => {
+ addressInputOnInput(event, true);
+ });
+ }
+
+ input.addEventListener("blur", () => {
+ addressInputOnBlur(input);
+ });
+ input.addEventListener("focus", () => {
+ addressInputOnFocus(input);
+ });
+
+ inputContainer.appendChild(input);
+ row.appendChild(inputContainer);
+
+ // Create the menuitem that shows the row on selection.
+ let showRowMenuItem = document.createXULElement("menuitem");
+ showRowMenuItem.classList.add("subviewbutton", "menuitem-iconic");
+ showRowMenuItem.setAttribute("id", recipient.showRowMenuItemId);
+ showRowMenuItem.setAttribute("disableonsend", true);
+ showRowMenuItem.setAttribute("label", recipient.type);
+
+ showRowMenuItem.addEventListener("command", () =>
+ showAndFocusAddressRow(row.id)
+ );
+
+ row.dataset.showSelfMenuitem = showRowMenuItem.id;
+
+ return { row, showRowMenuItem };
+ }
+
+ /**
+ * Create a new recipient pill.
+ *
+ * @param {HTMLElement} element - The original autocomplete input that
+ * generated the pill.
+ * @param {Array} address - The array containing the recipient's info.
+ * @returns {Element} The newly created pill.
+ */
+ createRecipientPill(element, address) {
+ let pill = document.createXULElement("mail-address-pill");
+
+ pill.label = address.toString();
+ pill.emailAddress = address.email || "";
+ pill.fullAddress = address.toString();
+ pill.displayName = address.name || "";
+
+ pill.addEventListener("click", event => {
+ if (pill.hasAttribute("disabled")) {
+ return;
+ }
+ // Remove pills on middle mouse button click, but not with selection
+ // modifier keys.
+ if (
+ event.button == 1 &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey
+ ) {
+ if (!pill.hasAttribute("selected")) {
+ this.deselectAllPills();
+ pill.setAttribute("selected", "selected");
+ }
+ this.removeSelectedPills();
+ return;
+ }
+
+ // Edit pill on unmodified single left-click on single selected pill,
+ // which also fires for unmodified double-click ("dblclick") on a pill.
+ if (
+ event.button == 0 &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey &&
+ !pill.isEditing &&
+ pill.hasAttribute("selected") &&
+ this.getAllSelectedPills().length == 1
+ ) {
+ this.startEditing(pill, event);
+ return;
+ }
+
+ // Handle selection, especially with Ctrl/Cmd and/or Shift modifiers.
+ this.checkSelected(pill, event);
+ });
+
+ pill.addEventListener("keydown", event => {
+ if (!pill.isEditing || pill.hasAttribute("disabled")) {
+ return;
+ }
+ this.handleKeyDown(pill, event);
+ });
+
+ pill.addEventListener("keypress", event => {
+ if (pill.hasAttribute("disabled")) {
+ return;
+ }
+ this.handleKeyPress(pill, event);
+ });
+
+ element.closest(".address-container").insertBefore(pill, element);
+
+ // The emailInput attribute is accessible only after the pill has been
+ // appended to the DOM.
+ let excludedClasses = [
+ "mail-primary-input",
+ "news-primary-input",
+ "address-row-input",
+ ];
+ for (let cssClass of element.classList) {
+ if (excludedClasses.includes(cssClass)) {
+ continue;
+ }
+ pill.emailInput.classList.add(cssClass);
+ }
+ pill.emailInput.setAttribute(
+ "aria-labelledby",
+ element.getAttribute("aria-labelledby")
+ );
+ element.removeAttribute("aria-labelledby");
+
+ let params = JSON.parse(
+ pill.emailInput.getAttribute("autocompletesearchparam")
+ );
+ params.type = element.closest(".address-row").dataset.recipienttype;
+ pill.emailInput.setAttribute(
+ "autocompletesearchparam",
+ JSON.stringify(params)
+ );
+
+ pill.updatePillStatus();
+
+ return pill;
+ }
+
+ /**
+ * Handle keydown event on a pill in the mail-recipients-area.
+ *
+ * @param {Element} pill - The mail-address-pill element where Event fired.
+ * @param {Event} event - The DOM Event.
+ */
+ handleKeyDown(pill, event) {
+ switch (event.key) {
+ case " ":
+ case ",":
+ // Behaviour consistent with row input:
+ // If keydown would normally replace all of the current trimmed input,
+ // including if the current input is empty, then suppress the key and
+ // clear the input instead.
+ let input = pill.emailInput;
+ let selection = input.value.substring(
+ input.selectionStart,
+ input.selectionEnd
+ );
+ if (selection.includes(input.value.trim())) {
+ event.preventDefault();
+ input.value = "";
+ }
+ break;
+ }
+ }
+
+ /**
+ * Handle keypress event on a pill in the mail-recipients-area.
+ *
+ * @param {Element} pill - The mail-address-pill element where Event fired.
+ * @param {Event} event - The DOM Event.
+ */
+ handleKeyPress(pill, event) {
+ if (pill.isEditing) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Enter":
+ case "F2": // For Windows users
+ this.startEditing(pill, event);
+ break;
+
+ case "Delete":
+ case "Backspace":
+ // We must never delete a focused pill which is not selected.
+ // If no pills selected, just select the focused pill.
+ // For rapid repeated deletions (esp. from holding BACKSPACE),
+ // stop before selecting another focused pill for deletion.
+ if (!this.hasSelectedPills() && !event.repeat) {
+ pill.setAttribute("selected", "selected");
+ break;
+ }
+ // Delete selected pills, handle focus and select another pill
+ // where applicable.
+ let focusType = event.key == "Delete" ? "next" : "previous";
+ this.removeSelectedPills(focusType, true);
+ break;
+
+ case "ArrowLeft":
+ if (pill.previousElementSibling) {
+ this.checkKeyboardSelected(event, pill.previousElementSibling);
+ }
+ break;
+
+ case "ArrowRight":
+ this.checkKeyboardSelected(event, pill.nextElementSibling);
+ break;
+
+ case " ":
+ this.checkSelected(pill, event);
+ break;
+
+ case "Home":
+ let firstPill = pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill");
+ if (!event.ctrlKey) {
+ // Unmodified navigation: select only first pill and focus it below.
+ // ### Todo: We can't handle Shift+Home yet, so it ends up here.
+ this.deselectAllPills();
+ firstPill.setAttribute("selected", "selected");
+ }
+ firstPill.focus();
+ break;
+
+ case "End":
+ if (!event.ctrlKey) {
+ // Unmodified navigation: focus row input.
+ // ### Todo: We can't handle Shift+End yet, so it ends up here.
+ pill.rowInput.focus();
+ break;
+ }
+ // Navigation with Ctrl modifier key: focus last pill.
+ pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill:last-of-type")
+ .focus();
+ break;
+
+ case "Tab":
+ for (let item of this.getSiblingPills(pill)) {
+ item.removeAttribute("selected");
+ }
+ // Ctrl+Tab is handled by moveFocusToNeighbouringArea.
+ if (event.ctrlKey) {
+ return;
+ }
+ event.preventDefault();
+ if (event.shiftKey) {
+ this.moveFocusToPreviousElement(pill);
+ return;
+ }
+ pill.rowInput.focus();
+ break;
+
+ case "a":
+ if (
+ !(event.ctrlKey || event.metaKey) ||
+ event.repeat ||
+ event.shiftKey
+ ) {
+ // Bail out if it's not Ctrl+A or Cmd+A, if the Shift key is
+ // pressed, or if repeated keypress.
+ break;
+ }
+ if (
+ pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill:not([selected])")
+ ) {
+ // For non-repeated Ctrl+A, if there's at least one unselected pill,
+ // first select all pills of the same .address-container.
+ this.selectSiblingPills(pill);
+ break;
+ }
+ // For non-repeated Ctrl+A, if pills in same container are already
+ // selected, select all pills of the entire <mail-recipients-area>.
+ this.selectAllPills();
+ break;
+
+ case "c":
+ if (event.ctrlKey || event.metaKey) {
+ this.copySelectedPills();
+ }
+ break;
+
+ case "x":
+ if (event.ctrlKey || event.metaKey) {
+ this.cutSelectedPills();
+ }
+ break;
+ }
+ }
+
+ /**
+ * Handle the selection and focus of recipient pill elements on mouse click
+ * and spacebar keypress events.
+ *
+ * @param {HTMLElement} pill - The <mail-address-pill> element, event target.
+ * @param {Event} event - A DOM click or keypress Event.
+ */
+ checkSelected(pill, event) {
+ // Interrupt if the pill is in edit mode or a right click was detected.
+ // Selecting pills on right click will be handled by the opening of the
+ // context menu.
+ if (pill.isEditing || event.button == 2) {
+ return;
+ }
+
+ if (!event.ctrlKey && !event.metaKey && event.key != " ") {
+ this.deselectAllPills();
+ }
+
+ pill.toggleAttribute("selected");
+
+ // We need to force the focus on a pill that receives a click event
+ // (or a spacebar keypress), as macOS doesn't automatically move the focus
+ // on this custom element (bug 1645643, bug 1645916).
+ pill.focus();
+ }
+
+ /**
+ * Handle the selection and focus of the pill elements on keyboard
+ * navigation.
+ *
+ * @param {Event} event - A DOM keyboard event.
+ * @param {HTMLElement} targetElement - A mail-address-pill or address input
+ * element navigated to.
+ */
+ checkKeyboardSelected(event, targetElement) {
+ let sourcePill =
+ event.target.tagName == "mail-address-pill" ? event.target : null;
+ let targetPill =
+ targetElement.tagName == "mail-address-pill" ? targetElement : null;
+
+ if (event.shiftKey) {
+ if (sourcePill) {
+ sourcePill.setAttribute("selected", "selected");
+ }
+ if (event.key == "Home" && !sourcePill) {
+ // Shift+Home from address input.
+ this.selectSiblingPills(targetPill);
+ }
+ if (targetPill) {
+ targetPill.setAttribute("selected", "selected");
+ }
+ } else if (!event.ctrlKey) {
+ // Non-modified navigation keys must select the target pill and deselect
+ // all others. Also some other keys like Backspace from rowInput.
+ this.deselectAllPills();
+ if (targetPill) {
+ targetPill.setAttribute("selected", "selected");
+ } else {
+ // Focus the input navigated to.
+ targetElement.focus();
+ }
+ }
+
+ // If targetElement is a pill, focus it.
+ if (targetPill) {
+ targetPill.focus();
+ }
+ }
+
+ /**
+ * Trigger the pill.startEditing() method.
+ *
+ * @param {XULElement} pill - The mail-address-pill element.
+ * @param {Event} event - The DOM Event.
+ */
+ startEditing(pill, event) {
+ if (pill.isEditing) {
+ event.stopPropagation();
+ return;
+ }
+
+ pill.startEditing();
+ }
+
+ /**
+ * Copy the selected pills to clipboard.
+ */
+ copySelectedPills() {
+ let selectedAddresses = [
+ ...document.getElementById("recipientsContainer").getAllSelectedPills(),
+ ].map(pill => pill.fullAddress);
+
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(selectedAddresses.join(", "));
+ }
+
+ /**
+ * Cut the selected pills to clipboard.
+ */
+ cutSelectedPills() {
+ this.copySelectedPills();
+ this.removeSelectedPills();
+ }
+
+ /**
+ * Move the selected email address pills to another address row.
+ *
+ * @param {Element} row - The address row to move the pills to.
+ */
+ moveSelectedPills(row) {
+ // Store all the selected addresses inside an array.
+ let selectedAddresses = [...this.getAllSelectedPills()].map(
+ pill => pill.fullAddress
+ );
+
+ // Return if no pills selected.
+ if (!selectedAddresses.length) {
+ return;
+ }
+
+ // Remove the selected pills.
+ this.removeSelectedPills("next", false, true);
+
+ // Create new address pills inside the target address row and
+ // maintain the current selection.
+ addressRowAddRecipientsArray(row, selectedAddresses, true);
+
+ // Move focus to the last selected pill.
+ let selectedPills = this.getAllSelectedPills();
+ selectedPills[selectedPills.length - 1].focus();
+ }
+
+ /**
+ * Delete all selected pills and handle focus and selection smartly as needed.
+ *
+ * @param {("next"|"previous")} [focusType="next"] - How to move focus after
+ * removing pills: try to focus one of the next siblings (for DEL etc.)
+ * or one of the previous siblings (for BACKSPACE).
+ * @param {boolean} [select=false] - After deletion, whether to select the
+ * focused pill where applicable.
+ * @param {boolean} [moved=false] - Whether the method was originally called
+ * from moveSelectedPills().
+ */
+ removeSelectedPills(focusType = "next", select = false, moved = false) {
+ // Return if no pills selected.
+ let firstSelectedPill = this.querySelector("mail-address-pill[selected]");
+ if (!firstSelectedPill) {
+ return;
+ }
+ // Get the pill which has focus before we start removing selected pills,
+ // which may or may not include the focused pill. If no pill has focus,
+ // consider the first selected pill as focused pill for our purposes.
+ let pill =
+ this.querySelector("mail-address-pill:focus") || firstSelectedPill;
+
+ // We'll look hard for an appropriate element to focus after the removal.
+ let focusElement = null;
+ // Get addressContainer and rowInput now as pill might be deleted later.
+ let addressContainer = pill.closest(".address-container");
+ let rowInput = pill.rowInput;
+ let unselectedSourcePill = false;
+
+ if (pill.hasAttribute("selected")) {
+ // Find focus (1): Focused pill is selected and will be deleted;
+ // try nearest sibling, observing focusType direction.
+ focusElement = pill.getUnselectedSiblingPill(focusType);
+ } else {
+ // The source pill isn't selected; keep it focused ("satellite focus").
+ unselectedSourcePill = true;
+ focusElement = pill;
+ }
+
+ // Remove selected pills.
+ let selectedPills = this.getAllSelectedPills();
+ for (let sPill of selectedPills) {
+ sPill.remove();
+ }
+
+ // Find focus (2): When deleting backwards, if no previous sibling found,
+ // this means that the first pill was deleted. Try the first remaining pill,
+ // but don't auto-select it because it's in the opposite direction.
+ if (!focusElement && focusType == "previous") {
+ focusElement = addressContainer.querySelector("mail-address-pill");
+ } else if (
+ select &&
+ focusElement &&
+ selectedPills.length == 1 &&
+ !unselectedSourcePill
+ ) {
+ // If select = true (DEL or BACKSPACE), and we found a pill to focus in
+ // round (1), and we have removed a single pill only, and it's not a
+ // case of "satellite focus" (see above):
+ // Conveniently select the nearest pill for rapid consecutive deletions.
+ focusElement.setAttribute("selected", "selected");
+ }
+ // Find focus (3): If all else fails (no pills left in addressContainer,
+ // or last pill deleted forwards): Focus rowInput.
+ if (!focusElement) {
+ focusElement = rowInput;
+ }
+ focusElement.focus();
+
+ // Update aria labels for all rows as we allow cross-row pill removal.
+ // This may not yet be micro-performance optimized; see bug 1671261.
+ updateAriaLabelsAndTooltipsOfAllAddressRows();
+
+ // Don't trigger some methods if the pills were removed automatically
+ // during the move to another addressing widget.
+ if (!moved) {
+ onRecipientsChanged();
+ }
+ }
+
+ /**
+ * Select all pills of the same address row (.address-container).
+ *
+ * @param {Element} pill - A <mail-address-pill> element. All pills in the
+ * same .address-container will be selected.
+ */
+ selectSiblingPills(pill) {
+ for (let sPill of this.getSiblingPills(pill)) {
+ sPill.setAttribute("selected", "selected");
+ }
+ }
+
+ /**
+ * Select all pills of the <mail-recipients-area> element.
+ */
+ selectAllPills() {
+ for (let pill of this.getAllPills()) {
+ pill.setAttribute("selected", "selected");
+ }
+ }
+
+ /**
+ * Deselect all the pills of the <mail-recipients-area> element.
+ */
+ deselectAllPills() {
+ for (let pill of this.querySelectorAll(`mail-address-pill[selected]`)) {
+ pill.removeAttribute("selected");
+ }
+ }
+
+ /**
+ * Return all pills of the same address row (.address-container).
+ *
+ * @param {Element} pill - A <mail-address-pill> element. All pills in the
+ * same .address-container will be returned.
+ * @returns {NodeList} NodeList of <mail-address-pill> elements in same field.
+ */
+ getSiblingPills(pill) {
+ return pill
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill");
+ }
+
+ /**
+ * Return all pills of the <mail-recipients-area> element.
+ *
+ * @returns {NodeList} NodeList of all <mail-address-pill> elements.
+ */
+ getAllPills() {
+ return this.querySelectorAll("mail-address-pill");
+ }
+
+ /**
+ * Return all currently selected pills in the <mail-recipients-area>.
+ *
+ * @returns {NodeList} NodeList of all selected <mail-address-pill> elements.
+ */
+ getAllSelectedPills() {
+ return this.querySelectorAll("mail-address-pill[selected]");
+ }
+
+ /**
+ * Check if any pill in the <mail-recipients-area> is selected.
+ *
+ * @returns {boolean} true if any pill is selected.
+ */
+ hasSelectedPills() {
+ return Boolean(this.querySelector("mail-address-pill[selected]"));
+ }
+
+ /**
+ * Move the focus to the previous focusable element.
+ *
+ * @param {Element} element - The element where the event was triggered.
+ */
+ moveFocusToPreviousElement(element) {
+ let row = element.closest(".address-row");
+ // Move focus on the close label if not collapsed.
+ if (!row.querySelector(".remove-field-button").hidden) {
+ row.querySelector(".remove-field-button").focus();
+ return;
+ }
+ // If a previous address row is available and not hidden,
+ // focus on the autocomplete input field.
+ let previousRow = row.previousElementSibling;
+ while (previousRow) {
+ if (!previousRow.classList.contains("hidden")) {
+ previousRow.querySelector(".address-row-input").focus();
+ return;
+ }
+ previousRow = previousRow.previousElementSibling;
+ }
+ // Move the focus on the previous button: either the
+ // extraAddressRowsMenuButton, or one of "<type>ShowAddressRowButton".
+ let buttons = document.querySelectorAll(
+ "#extraAddressRowsArea button:not([hidden])"
+ );
+ if (buttons.length) {
+ // Select the last available label.
+ buttons[buttons.length - 1].focus();
+ return;
+ }
+ // Move the focus on the msgIdentity if no extra recipients are available.
+ document.getElementById("msgIdentity").focus();
+ }
+ }
+
+ customElements.define("mail-recipients-area", MailRecipientsArea);
+}