Revision control
Copy as Markdown
Other Tools
/* 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
// Globals from addressingWidgetOverlay.js. This should use import-globals-from
// but the linter is unhappy.
/* globals addressInputOnBeforeHandleKeyDown, addressInputOnBlur,
addressInputOnFocus, addressInputOnInput, addressRowAddRecipientsArray,
closeLabelOnClick, focusAddressInputOnClick, getComposeBundle,
otherHeaderInputOnKeyDown, showAndFocusAddressRow,
updateAriaLabelsAndTooltipsOfAllAddressRows, updateAriaLabelsOfAddressRow */
/* global MozXULElement */
/* global onRecipientsChanged */
const { MailServices } = ChromeUtils.importESModule(
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
/**
* 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",
);
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) => {
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 another pill (multi selection and deselection
// 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.
* @returns {boolean} true if valid.
*/
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.
const 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() {
const addresses = MailServices.headerParser.makeFromDisplayAddress(
this.emailInput.value
);
const 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.
const 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");
const 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() {
const isValid = this.isValidAddress(this.emailAddress);
const listNames = lazy.MimeParser.parseHeaderField(
this.fullAddress,
lazy.MimeParser.HEADER_ADDRESS
);
if (listNames.length > 0) {
const 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;
}
}
const 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 (const input of this.querySelectorAll(".mail-input,.news-input")) {
// Disable inbuilt autocomplete on blur to handle it with our handlers.
input._dontBlur = true;
this.#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();
const row = this.querySelector(".address-row:not(.hidden)");
const 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.
const 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 (const 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-address-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-address-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
// Input element resizing is automatically handled by its input event.
return;
}
// Pills have been dropped ("text/pills").
const 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).
const targetAddressContainer = event.target.closest(".address-container");
const 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.
const 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} size - The number of characters in the input field.
*/
resizeInputField(input, size) {
// 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", size || 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) {
const existingPills =
addressContainer.querySelectorAll("mail-address-pill");
const existingAddresses = [...existingPills].map(pill => pill.fullAddress);
const selectedAddresses = [...this.getAllSelectedPills()].map(
pill => pill.fullAddress
);
const originalTargetIndex = existingAddresses.indexOf(targetAddress);
// Remove all the duplicate existing addresses.
for (const address of selectedAddresses) {
const 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 (const pill of this.getAllSelectedPills()) {
pill.remove();
}
// Existing pills are removed before creating new ones in the right order.
for (const pill of existingPills) {
pill.remove();
}
// Create pills for all the combined addresses.
const row = addressContainer.closest(".address-row");
for (const 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) {
const row = document.createXULElement("hbox");
row.setAttribute("id", recipient.rowId);
row.classList.add("address-row");
row.dataset.recipienttype = recipient.type;
const firstCol = document.createXULElement("hbox");
firstCol.classList.add("aw-firstColBox");
row.classList.add("hidden");
const closeButton = document.createElement("button");
closeButton.classList.add("remove-field-button", "plain-button");
document.l10n.setAttributes(closeButton, "remove-address-row-button", {
type: recipient.type,
});
const closeIcon = document.createElement("img");
// 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);
const labelContainer = document.createXULElement("hbox");
labelContainer.setAttribute("align", "top");
labelContainer.setAttribute("pack", "end");
labelContainer.classList.add("address-label-container");
labelContainer.setAttribute(
"style",
getComposeBundle().getString("headersSpaceStyle")
);
const 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");
label.style.justifyContent = "end";
labelContainer.appendChild(label);
row.appendChild(labelContainer);
const 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.
const 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;
this.#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.
const 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 };
}
/**
* Set up autocomplete search parameters for address inputs of inbuilt headers.
*
* @param {Element} input - The address input of an inbuilt header field.
*/
#setupAutocompleteInput(input) {
const params = JSON.parse(input.getAttribute("autocompletesearchparam"));
params.type = input.closest(".address-row").dataset.recipienttype;
input.setAttribute("autocompletesearchparam", JSON.stringify(params));
// This method overrides the autocomplete binding's openPopup (essentially
// duplicating the logic from the autocomplete popup binding's
// openAutocompletePopup method), modifying it so that the popup is aligned
// and sized based on the parentNode of the input field.
input.openPopup = () => {
if (input.focused) {
input.popup.openAutocompletePopup(
input.nsIAutocompleteInput,
input.closest(".address-container")
);
}
};
}
/**
* Create a new recipient pill.
*
* @param {HTMLElement} element - The original autocomplete input that
* generated the pill.
* @param {msgIAddressObject[]} address - The array containing the
* recipient's info.
* @returns {Element} The newly created pill.
*/
createRecipientPill(element, address) {
const 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.
const excludedClasses = [
"mail-primary-input",
"news-primary-input",
"address-row-input",
];
for (const 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");
const 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.
const input = pill.emailInput;
const 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.
const 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": {
const 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 (const 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
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) {
const sourcePill =
event.target.tagName == "mail-address-pill" ? event.target : null;
const 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() {
const selectedAddresses = [
...document.getElementById("recipientsContainer").getAllSelectedPills(),
].map(pill => pill.fullAddress);
const 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.
const 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.
const 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.
const 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.
const 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.
const addressContainer = pill.closest(".address-container");
const 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.
const selectedPills = this.getAllSelectedPills();
for (const 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.
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 (const sPill of this.getSiblingPills(pill)) {
sPill.setAttribute("selected", "selected");
}
}
/**
* Select all pills of the <mail-recipients-area> element.
*/
selectAllPills() {
for (const pill of this.getAllPills()) {
pill.setAttribute("selected", "selected");
}
}
/**
* Deselect all the pills of the <mail-recipients-area> element.
*/
deselectAllPills() {
for (const 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) {
const 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".
const 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);
// We override the blur handling on autocomplete widgets, so we have to clean
// them up ourselves. We can do this once for all when the page unloads.
window.addEventListener("unload", () =>
document
.querySelectorAll(`[is="autocomplete-input"]`)
.forEach(input => input.detachController())
);