diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/addrbook/content/vcard-edit | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/addrbook/content/vcard-edit')
16 files changed, 3067 insertions, 0 deletions
diff --git a/comm/mail/components/addrbook/content/vcard-edit/adr.mjs b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs new file mode 100644 index 0000000000..2f395173f3 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs @@ -0,0 +1,149 @@ +/* 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 { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 ADR + */ +export class VCardAdrComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("adr", {}, "text", [ + "", + "", + "", + "", + "", + "", + "", + ]); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-adr"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.streetEl = this.querySelector('textarea[name="street"]'); + this.assignIds(this.streetEl, this.querySelector('label[for="street"]')); + this.streetEl.addEventListener("input", () => { + this.resizeStreetEl(); + }); + + this.localityEl = this.querySelector('input[name="locality"]'); + this.assignIds( + this.localityEl, + this.querySelector('label[for="locality"]') + ); + + this.regionEl = this.querySelector('input[name="region"]'); + this.assignIds(this.regionEl, this.querySelector('label[for="region"]')); + + this.codeEl = this.querySelector('input[name="code"]'); + this.assignIds(this.regionEl, this.querySelector('label[for="code"]')); + + this.countryEl = this.querySelector('input[name="country"]'); + this.assignIds(this.countryEl, this.querySelector('label[for="country"]')); + + // Create the adr type selection. + this.vCardType = this.querySelector("vcard-type"); + this.vCardType.createTypeSelection(this.vCardPropertyEntry, { + createLabel: true, + }); + + this.fromVCardPropertyEntryToUI(); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + } + + fromVCardPropertyEntryToUI() { + if (Array.isArray(this.vCardPropertyEntry.value[2])) { + this.streetEl.value = this.vCardPropertyEntry.value[2].join("\n"); + } else { + this.streetEl.value = this.vCardPropertyEntry.value[2] || ""; + } + // Per RFC 6350, post office box and extended address SHOULD be empty. + let pobox = this.vCardPropertyEntry.value[0] || ""; + let extendedAddr = this.vCardPropertyEntry.value[1] || ""; + if (extendedAddr) { + this.streetEl.value = this.streetEl.value + "\n" + extendedAddr.trim(); + delete this.vCardPropertyEntry.value[1]; + } + if (pobox) { + this.streetEl.value = pobox.trim() + "\n" + this.streetEl.value; + delete this.vCardPropertyEntry.value[0]; + } + + this.resizeStreetEl(); + this.localityEl.value = this.vCardPropertyEntry.value[3] || ""; + this.regionEl.value = this.vCardPropertyEntry.value[4] || ""; + this.codeEl.value = this.vCardPropertyEntry.value[5] || ""; + this.countryEl.value = this.vCardPropertyEntry.value[6] || ""; + } + + fromUIToVCardPropertyEntry() { + let streetValue = this.streetEl.value || ""; + streetValue = streetValue.trim(); + if (streetValue.includes("\n")) { + streetValue = streetValue.replaceAll("\r", ""); + streetValue = streetValue.split("\n"); + } + + this.vCardPropertyEntry.value = [ + "", + "", + streetValue, + this.localityEl.value || "", + this.regionEl.value || "", + this.codeEl.value || "", + this.countryEl.value || "", + ]; + } + + valueIsEmpty() { + return [ + this.streetEl, + this.localityEl, + this.regionEl, + this.codeEl, + this.countryEl, + ].every(e => !e.value); + } + + assignIds(inputEl, labelEl) { + let labelInputId = vCardIdGen.next().value; + inputEl.id = labelInputId; + labelEl.htmlFor = labelInputId; + } + + resizeStreetEl() { + this.streetEl.rows = Math.max(1, this.streetEl.value.split("\n").length); + } +} + +customElements.define("vcard-adr", VCardAdrComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/custom.mjs b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs new file mode 100644 index 0000000000..bcdb1f6531 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs @@ -0,0 +1,60 @@ +/* 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 { vCardIdGen } from "./id-gen.mjs"; + +export class VCardCustomComponent extends HTMLElement { + /** @type {VCardPropertyEntry[]} */ + vCardPropertyEntries = null; + /** @type {HTMLInputElement[]} */ + inputEls = null; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-custom"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.inputEls = this.querySelectorAll("input"); + let labelEls = this.querySelectorAll("label"); + for (let i = 0; i < 4; i++) { + let inputId = vCardIdGen.next().value; + document.l10n.setAttributes( + labelEls[i], + `about-addressbook-entry-name-custom${i + 1}` + ); + labelEls[i].htmlFor = inputId; + this.inputEls[i].id = inputId; + } + this.fromVCardPropertyEntryToUI(); + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + document.getElementById("vcard-add-custom").hidden = false; + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + } + + fromVCardPropertyEntryToUI() { + for (let i = 0; i < 4; i++) { + this.inputEls[i].value = this.vCardPropertyEntries[i].value; + } + } + + fromUIToVCardPropertyEntry() { + for (let i = 0; i < 4; i++) { + this.vCardPropertyEntries[i].value = this.inputEls[i].value; + } + } +} + +customElements.define("vcard-custom", VCardCustomComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/edit.mjs b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs new file mode 100644 index 0000000000..90463e33bb --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs @@ -0,0 +1,1094 @@ +/* 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 { vCardIdGen } from "./id-gen.mjs"; +import { VCardAdrComponent } from "./adr.mjs"; +import { VCardCustomComponent } from "./custom.mjs"; +import { VCardEmailComponent } from "./email.mjs"; +import { VCardIMPPComponent } from "./impp.mjs"; +import { VCardNComponent } from "./n.mjs"; +import { VCardFNComponent } from "./fn.mjs"; +import { VCardNickNameComponent } from "./nickname.mjs"; +import { VCardNoteComponent } from "./note.mjs"; +import { + VCardOrgComponent, + VCardRoleComponent, + VCardTitleComponent, +} from "./org.mjs"; +import { VCardSpecialDateComponent } from "./special-date.mjs"; +import { VCardTelComponent } from "./tel.mjs"; +import { VCardTZComponent } from "./tz.mjs"; +import { VCardURLComponent } from "./url.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardProperties", + "resource:///modules/VCardUtils.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +class VCardEdit extends HTMLElement { + constructor() { + super(); + + this.contactNameHeading = document.getElementById("editContactHeadingName"); + this.contactNickNameHeading = document.getElementById( + "editContactHeadingNickName" + ); + this.contactEmailHeading = document.getElementById( + "editContactHeadingEmail" + ); + } + + connectedCallback() { + if (this.isConnected) { + this.updateView(); + + this.addEventListener("vcard-remove-property", e => { + if (e.target.vCardPropertyEntries) { + for (let entry of e.target.vCardPropertyEntries) { + this.vCardProperties.removeEntry(entry); + } + } else { + this.vCardProperties.removeEntry(e.target.vCardPropertyEntry); + } + + // Move the focus to the first available valid element of the fieldset. + let sibling = + e.target.nextElementSibling || e.target.previousElementSibling; + // If we got a button, focus it since it's the "add row" button. + if (sibling?.type == "button") { + sibling.focus(); + return; + } + + // Otherwise we have a row field, so try to find a focusable element. + if (sibling && this.moveFocusIntoElement(sibling)) { + return; + } + + // If we reach this point, the markup was unpredictable and we should + // move the focus to a valid element to avoid focus lost. + e.target + .closest("fieldset") + .querySelector(".add-property-button") + .focus(); + }); + } + } + + disconnectedCallback() { + this.replaceChildren(); + } + + get vCardString() { + return this._vCardProperties.toVCard(); + } + + set vCardString(value) { + if (value) { + try { + this.vCardProperties = lazy.VCardProperties.fromVCard(value); + return; + } catch (ex) { + console.error(ex); + } + } + this.vCardProperties = new lazy.VCardProperties("4.0"); + } + + get vCardProperties() { + return this._vCardProperties; + } + + set vCardProperties(value) { + this._vCardProperties = value; + // If no n property is present set one. + if (!this._vCardProperties.getFirstEntry("n")) { + this._vCardProperties.addEntry(VCardNComponent.newVCardPropertyEntry()); + } + // If no fn property is present set one. + if (!this._vCardProperties.getFirstEntry("fn")) { + this._vCardProperties.addEntry(VCardFNComponent.newVCardPropertyEntry()); + } + // If no nickname property is present set one. + if (!this._vCardProperties.getFirstEntry("nickname")) { + this._vCardProperties.addEntry( + VCardNickNameComponent.newVCardPropertyEntry() + ); + } + // If no email property is present set one. + if (!this._vCardProperties.getFirstEntry("email")) { + let emailEntry = VCardEmailComponent.newVCardPropertyEntry(); + emailEntry.params.pref = "1"; // Set as default email. + this._vCardProperties.addEntry(emailEntry); + } + // If one of the organizational properties is present, + // make sure they all are. + let title = this._vCardProperties.getFirstEntry("title"); + let role = this._vCardProperties.getFirstEntry("role"); + let org = this._vCardProperties.getFirstEntry("org"); + if (title || role || org) { + if (!title) { + this._vCardProperties.addEntry( + VCardTitleComponent.newVCardPropertyEntry() + ); + } + if (!role) { + this._vCardProperties.addEntry( + VCardRoleComponent.newVCardPropertyEntry() + ); + } + if (!org) { + this._vCardProperties.addEntry( + VCardOrgComponent.newVCardPropertyEntry() + ); + } + } + + for (let i = 1; i <= 4; i++) { + if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) { + this._vCardProperties.addEntry( + new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "") + ); + } + } + + this.updateView(); + } + + updateView() { + // Create new DOM and replacing other vCardProperties. + let template = document.getElementById("template-addr-book-edit"); + let clonedTemplate = template.content.cloneNode(true); + // Making the next two calls in one go causes a console error to be logged. + this.replaceChildren(); + this.append(clonedTemplate); + + if (!this.vCardProperties) { + return; + } + + this.addFieldsetActions(); + + // Insert the vCard property entries. + for (let vCardPropertyEntry of this.vCardProperties.entries) { + this.insertVCardElement(vCardPropertyEntry, false); + } + + let customProperties = ["x-custom1", "x-custom2", "x-custom3", "x-custom4"]; + if (customProperties.some(key => this.vCardProperties.getFirstValue(key))) { + // If one of these properties has a value, display all of them. + let customFieldset = this.querySelector("#addr-book-edit-custom"); + let customEl = + customFieldset.querySelector("vcard-custom") || + new VCardCustomComponent(); + customEl.vCardPropertyEntries = customProperties.map(key => + this._vCardProperties.getFirstEntry(key) + ); + let addCustom = document.getElementById("vcard-add-custom"); + customFieldset.insertBefore(customEl, addCustom); + addCustom.hidden = true; + } + + let nameEl = this.querySelector("vcard-n"); + this.firstName = nameEl.firstNameEl.querySelector("input"); + this.lastName = nameEl.lastNameEl.querySelector("input"); + this.prefixName = nameEl.prefixEl.querySelector("input"); + this.middleName = nameEl.middleNameEl.querySelector("input"); + this.suffixName = nameEl.suffixEl.querySelector("input"); + this.displayName = this.querySelector("vcard-fn").displayEl; + + [ + this.firstName, + this.lastName, + this.prefixName, + this.middleName, + this.suffixName, + this.displayName, + ].forEach(element => { + element.addEventListener("input", event => + this.generateContactName(event) + ); + }); + + // Only set the strings and define this selector if we're inside the + // address book edit panel. + if (document.getElementById("detailsPane")) { + this.preferDisplayName = this.querySelector("vcard-fn").preferDisplayEl; + document.l10n.setAttributes( + this.preferDisplayName.closest(".vcard-checkbox").querySelector("span"), + "about-addressbook-prefer-display-name" + ); + } + + this.nickName = this.querySelector("vcard-nickname").nickNameEl; + this.nickName.addEventListener("input", () => this.updateNickName()); + + if (this.vCardProperties) { + this.toggleDefaultEmailView(); + this.checkForBdayOccurrences(); + } + + this.updateNickName(); + this.updateEmailHeading(); + this.generateContactName(); + } + + /** + * Update the contact name to reflect the users' choice. + * + * @param {?Event} event - The DOM event if we have one. + */ + async generateContactName(event = null) { + // Don't generate any preview if the contact name element is not available, + // which it might happen since this component is used in other areas outside + // the address book UI. + if (!this.contactNameHeading) { + return; + } + + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + let result = ""; + let pref = Services.prefs.getIntPref("mail.addr_book.lastnamefirst"); + switch (pref) { + case Ci.nsIAbCard.GENERATE_DISPLAY_NAME: + result = this.buildDefaultName(); + break; + + case Ci.nsIAbCard.GENERATE_LAST_FIRST_ORDER: + if (this.lastName.value) { + result = bundle.formatStringFromName("lastFirstFormat", [ + this.lastName.value, + [ + this.prefixName.value, + this.firstName.value, + this.middleName.value, + this.suffixName.value, + ] + .filter(Boolean) + .join(" "), + ]); + } else { + // Get the generic name if we don't have a last name. + result = this.buildDefaultName(); + } + break; + + default: + result = bundle.formatStringFromName("firstLastFormat", [ + [this.prefixName.value, this.firstName.value, this.middleName.value] + .filter(Boolean) + .join(" "), + [this.lastName.value, this.suffixName.value] + .filter(Boolean) + .join(" "), + ]); + break; + } + + if (result == "" || result == ", ") { + // We don't have anything to show as a contact name, so let's find the + // default email and show that, if we have it, otherwise pass an empty + // string to remove any leftover data. + let email = this.getDefaultEmail(); + result = email ? email.split("@", 1)[0] : ""; + } + + this.contactNameHeading.textContent = result; + this.fillDisplayName(event); + } + + /** + * Returns the name to show for this contact if the display name is available + * or it generates one from the available N data. + * + * @returns {string} - The name to show for this contact. + */ + buildDefaultName() { + return this.displayName.isDirty + ? this.displayName.value + : [ + this.prefixName.value, + this.firstName.value, + this.middleName.value, + this.lastName.value, + this.suffixName.value, + ] + .filter(Boolean) + .join(" "); + } + + /** + * Update the nickname value of the contact header. + */ + updateNickName() { + // Don't generate any preview if the contact nickname element is not + // available, which it might happen since this component is used in other + // areas outside the address book UI. + if (!this.contactNickNameHeading) { + return; + } + + let value = this.nickName.value.trim(); + this.contactNickNameHeading.hidden = !value; + this.contactNickNameHeading.textContent = value; + } + + /** + * Update the email value of the contact header. + * + * @param {?string} email - The email value the user is currently typing. + */ + updateEmailHeading(email = null) { + // Don't generate any preview if the contact nickname email is not + // available, which it might happen since this component is used in other + // areas outside the address book UI. + if (!this.contactEmailHeading) { + return; + } + + // If no email string was passed, it means this method was called when the + // view or edit pane refreshes, therefore we need to fetch the correct + // default email address. + let value = email ?? this.getDefaultEmail(); + this.contactEmailHeading.hidden = !value; + this.contactEmailHeading.textContent = value; + } + + /** + * Find the default email used for this contact. + * + * @returns {VCardEmailComponent} + */ + getDefaultEmail() { + let emails = document.getElementById("vcard-email").children; + if (emails.length == 1) { + return emails[0].emailEl.value; + } + + let defaultEmail = [...emails].find( + el => el.vCardPropertyEntry.params.pref === "1" + ); + + // If no email is marked as preferred, use the first one. + if (!defaultEmail) { + defaultEmail = emails[0]; + } + + return defaultEmail.emailEl.value; + } + + /** + * Auto fill the display name only if the pref is set, the user is not + * editing the display name field, and the field was never edited. + * The intention is to prefill while entering a new contact. Don't fill + * if we don't have a proper default name to show, but only a placeholder. + * + * @param {?Event} event - The DOM event if we have one. + */ + fillDisplayName(event = null) { + if ( + Services.prefs.getBoolPref("mail.addr_book.displayName.autoGeneration") && + event?.originalTarget.id != "vCardDisplayName" && + !this.displayName.isDirty && + this.buildDefaultName() + ) { + this.displayName.value = this.contactNameHeading.textContent; + } + } + + /** + * Inserts a custom element for a {VCardPropertyEntry} + * + * - Assigns rich data (not bind to a html attribute) and therefore + * the reference. + * - Inserts the element in the form at the correct position. + * + * @param {VCardPropertyEntry} entry + * @param {boolean} addEntry Adds the entry to the vCardProperties. + * @returns {VCardPropertyEntryView | undefined} + */ + insertVCardElement(entry, addEntry) { + // Add the entry to the vCardProperty data. + if (addEntry) { + this.vCardProperties.addEntry(entry); + } + + let fieldset; + let addButton; + switch (entry.name) { + case "n": + let n = new VCardNComponent(); + n.vCardPropertyEntry = entry; + fieldset = document.getElementById("addr-book-edit-n"); + let displayNicknameContainer = this.querySelector( + "#addr-book-edit-n .addr-book-edit-display-nickname" + ); + fieldset.insertBefore(n, displayNicknameContainer); + return n; + case "fn": + let fn = new VCardFNComponent(); + fn.vCardPropertyEntry = entry; + fieldset = this.querySelector( + "#addr-book-edit-n .addr-book-edit-display-nickname" + ); + fieldset.insertBefore(fn, fieldset.firstElementChild); + return fn; + case "nickname": + let nickname = new VCardNickNameComponent(); + nickname.vCardPropertyEntry = entry; + fieldset = this.querySelector( + "#addr-book-edit-n .addr-book-edit-display-nickname" + ); + fieldset.insertBefore( + nickname, + fieldset.firstElementChild?.nextElementSibling + ); + return nickname; + case "email": + let email = document.createElement("tr", { is: "vcard-email" }); + email.vCardPropertyEntry = entry; + document.getElementById("vcard-email").appendChild(email); + return email; + case "url": + let url = new VCardURLComponent(); + url.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-url"); + addButton = document.getElementById("vcard-add-url"); + fieldset.insertBefore(url, addButton); + return url; + case "tel": + let tel = new VCardTelComponent(); + tel.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-tel"); + addButton = document.getElementById("vcard-add-tel"); + fieldset.insertBefore(tel, addButton); + return tel; + case "tz": + let tz = new VCardTZComponent(); + tz.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-tz"); + addButton = document.getElementById("vcard-add-tz"); + fieldset.insertBefore(tz, addButton); + addButton.hidden = true; + return tz; + case "impp": + let impp = new VCardIMPPComponent(); + impp.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-impp"); + addButton = document.getElementById("vcard-add-impp"); + fieldset.insertBefore(impp, addButton); + return impp; + case "anniversary": + let anniversary = new VCardSpecialDateComponent(); + anniversary.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-bday-anniversary"); + addButton = document.getElementById("vcard-add-bday-anniversary"); + fieldset.insertBefore(anniversary, addButton); + return anniversary; + case "bday": + let bday = new VCardSpecialDateComponent(); + bday.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-bday-anniversary"); + addButton = document.getElementById("vcard-add-bday-anniversary"); + fieldset.insertBefore(bday, addButton); + return bday; + case "adr": + let address = new VCardAdrComponent(); + address.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-address"); + addButton = document.getElementById("vcard-add-adr"); + fieldset.insertBefore(address, addButton); + return address; + case "note": + let note = new VCardNoteComponent(); + note.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-note"); + addButton = document.getElementById("vcard-add-note"); + fieldset.insertBefore(note, addButton); + // Only one note is allowed via UI. + addButton.hidden = true; + return note; + case "title": + let title = new VCardTitleComponent(); + title.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-org"); + addButton = document.getElementById("vcard-add-org"); + fieldset.insertBefore( + title, + fieldset.querySelector("vcard-role, vcard-org, #vcard-add-org") + ); + this.querySelector( + "#addr-book-edit-org .remove-property-button" + ).hidden = false; + // Only one title is allowed via UI. + addButton.hidden = true; + return title; + case "role": + let role = new VCardRoleComponent(); + role.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-org"); + addButton = document.getElementById("vcard-add-org"); + fieldset.insertBefore( + role, + fieldset.querySelector("vcard-org, #vcard-add-org") + ); + this.querySelector( + "#addr-book-edit-org .remove-property-button" + ).hidden = false; + // Only one role is allowed via UI. + addButton.hidden = true; + return role; + case "org": + let org = new VCardOrgComponent(); + org.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-org"); + addButton = document.getElementById("vcard-add-org"); + fieldset.insertBefore(org, addButton); + this.querySelector( + "#addr-book-edit-org .remove-property-button" + ).hidden = false; + // Only one org is allowed via UI. + addButton.hidden = true; + return org; + default: + return undefined; + } + } + + /** + * Creates a VCardPropertyEntry with a matching + * name to the vCard spec. + * + * @param {string} entryName - A name which should be a vCard spec property. + * @returns {VCardPropertyEntry | undefined} + */ + static createVCardProperty(entryName) { + switch (entryName) { + case "n": + return VCardNComponent.newVCardPropertyEntry(); + case "fn": + return VCardFNComponent.newVCardPropertyEntry(); + case "nickname": + return VCardNickNameComponent.newVCardPropertyEntry(); + case "email": + return VCardEmailComponent.newVCardPropertyEntry(); + case "url": + return VCardURLComponent.newVCardPropertyEntry(); + case "tel": + return VCardTelComponent.newVCardPropertyEntry(); + case "tz": + return VCardTZComponent.newVCardPropertyEntry(); + case "impp": + return VCardIMPPComponent.newVCardPropertyEntry(); + case "bday": + return VCardSpecialDateComponent.newBdayVCardPropertyEntry(); + case "anniversary": + return VCardSpecialDateComponent.newAnniversaryVCardPropertyEntry(); + case "adr": + return VCardAdrComponent.newVCardPropertyEntry(); + case "note": + return VCardNoteComponent.newVCardPropertyEntry(); + case "title": + return VCardTitleComponent.newVCardPropertyEntry(); + case "role": + return VCardRoleComponent.newVCardPropertyEntry(); + case "org": + return VCardOrgComponent.newVCardPropertyEntry(); + default: + return undefined; + } + } + + /** + * Mutates the referenced vCardPropertyEntry(s). + * If the value of a VCardPropertyEntry is empty, the entry gets + * removed from the vCardProperty. + */ + saveVCard() { + for (let node of [ + ...this.querySelectorAll("vcard-adr"), + ...this.querySelectorAll("vcard-custom"), + ...document.getElementById("vcard-email").children, + ...this.querySelectorAll("vcard-fn"), + ...this.querySelectorAll("vcard-impp"), + ...this.querySelectorAll("vcard-n"), + ...this.querySelectorAll("vcard-nickname"), + ...this.querySelectorAll("vcard-note"), + ...this.querySelectorAll("vcard-org"), + ...this.querySelectorAll("vcard-role"), + ...this.querySelectorAll("vcard-title"), + ...this.querySelectorAll("vcard-special-date"), + ...this.querySelectorAll("vcard-tel"), + ...this.querySelectorAll("vcard-tz"), + ...this.querySelectorAll("vcard-url"), + ]) { + if (typeof node.fromUIToVCardPropertyEntry === "function") { + node.fromUIToVCardPropertyEntry(); + } + + // Filter out empty fields. + if (typeof node.valueIsEmpty === "function" && node.valueIsEmpty()) { + this.vCardProperties.removeEntry(node.vCardPropertyEntry); + } + } + + // If no email has a pref value of 1, set it to the first email. + let emailEntries = this.vCardProperties.getAllEntries("email"); + if ( + emailEntries.length >= 1 && + emailEntries.every(entry => entry.params.pref !== "1") + ) { + emailEntries[0].params.pref = "1"; + } + + for (let i = 1; i <= 4; i++) { + let entry = this._vCardProperties.getFirstEntry(`x-custom${i}`); + if (entry && !entry.value) { + this._vCardProperties.removeEntry(entry); + } + } + } + + /** + * Move focus into the form. + */ + setFocus() { + this.querySelector("vcard-n input:not([hidden])").focus(); + } + + /** + * Move focus to the first visible form element below the given element. + * + * @param {Element} element - The element to move focus into. + * @returns {boolean} - If the focus was moved into the element. + */ + moveFocusIntoElement(element) { + for (let child of element.querySelectorAll( + "select,input,textarea,button" + )) { + // Make sure it is visible. + if (child.clientWidth != 0 && child.clientHeight != 0) { + child.focus(); + return true; + } + } + return false; + } + + /** + * Add buttons and further actions of the groupings for vCard property + * entries. + */ + addFieldsetActions() { + // Add email button. + let addEmail = document.getElementById("vcard-add-email"); + this.registerAddButton(addEmail, "email", () => { + this.toggleDefaultEmailView(); + }); + + // Add listener to update the email written in the contact header. + this.addEventListener("vcard-email-default-changed", event => { + this.updateEmailHeading( + event.target.querySelector('input[type="email"]').value + ); + }); + + // Add listener to be sure that only one checkbox from the emails is ticked. + this.addEventListener("vcard-email-default-checkbox", event => { + // Show the newly selected default email in the contact header. + this.updateEmailHeading( + event.target.querySelector('input[type="email"]').value + ); + for (let vCardEmailComponent of document.getElementById("vcard-email") + .children) { + if (event.target !== vCardEmailComponent) { + vCardEmailComponent.checkboxEl.checked = false; + } + } + }); + + // Handling the VCardPropertyEntry change with the select. + let specialDatesFieldset = document.getElementById( + "addr-book-edit-bday-anniversary" + ); + specialDatesFieldset.addEventListener( + "vcard-bday-anniversary-change", + event => { + let newVCardPropertyEntry = new lazy.VCardPropertyEntry( + event.detail.name, + event.target.vCardPropertyEntry.params, + event.target.vCardPropertyEntry.type, + event.target.vCardPropertyEntry.value + ); + this.vCardProperties.removeEntry(event.target.vCardPropertyEntry); + event.target.vCardPropertyEntry = newVCardPropertyEntry; + this.vCardProperties.addEntry(newVCardPropertyEntry); + this.checkForBdayOccurrences(); + } + ); + + // Add special date button. + let addSpecialDate = document.getElementById("vcard-add-bday-anniversary"); + addSpecialDate.addEventListener("click", e => { + let newVCardProperty; + if (!this.vCardProperties.getFirstEntry("bday")) { + newVCardProperty = VCardEdit.createVCardProperty("bday"); + } else { + newVCardProperty = VCardEdit.createVCardProperty("anniversary"); + } + let el = this.insertVCardElement(newVCardProperty, true); + this.checkForBdayOccurrences(); + this.moveFocusIntoElement(el); + }); + + // Organizational Properties. + let addOrg = document.getElementById("vcard-add-org"); + addOrg.addEventListener("click", event => { + let title = VCardEdit.createVCardProperty("title"); + let role = VCardEdit.createVCardProperty("role"); + let org = VCardEdit.createVCardProperty("org"); + + let titleEl = this.insertVCardElement(title, true); + this.insertVCardElement(role, true); + this.insertVCardElement(org, true); + + this.moveFocusIntoElement(titleEl); + addOrg.hidden = true; + }); + + let addAddress = document.getElementById("vcard-add-adr"); + this.registerAddButton(addAddress, "adr"); + + let addURL = document.getElementById("vcard-add-url"); + this.registerAddButton(addURL, "url"); + + let addTel = document.getElementById("vcard-add-tel"); + this.registerAddButton(addTel, "tel"); + + let addTZ = document.getElementById("vcard-add-tz"); + this.registerAddButton(addTZ, "tz", () => { + addTZ.hidden = true; + }); + + let addIMPP = document.getElementById("vcard-add-impp"); + this.registerAddButton(addIMPP, "impp"); + + let addNote = document.getElementById("vcard-add-note"); + this.registerAddButton(addNote, "note", () => { + addNote.hidden = true; + }); + + let addCustom = document.getElementById("vcard-add-custom"); + addCustom.addEventListener("click", event => { + let el = new VCardCustomComponent(); + + // When the custom properties are deleted and added again ensure that + // the properties are set. + for (let i = 1; i <= 4; i++) { + if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) { + this._vCardProperties.addEntry( + new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "") + ); + } + } + + el.vCardPropertyEntries = [ + this._vCardProperties.getFirstEntry("x-custom1"), + this._vCardProperties.getFirstEntry("x-custom2"), + this._vCardProperties.getFirstEntry("x-custom3"), + this._vCardProperties.getFirstEntry("x-custom4"), + ]; + addCustom.parentNode.insertBefore(el, addCustom); + + this.moveFocusIntoElement(el); + addCustom.hidden = true; + }); + + // Delete button for Organization Properties. This property has multiple + // fields, so we should dispatch the remove event only once after everything + // has been removed. + this.querySelector( + "#addr-book-edit-org .remove-property-button" + ).addEventListener("click", event => { + this.querySelector("vcard-title").remove(); + this.querySelector("vcard-role").remove(); + let org = this.querySelector("vcard-org"); + // Reveal the "Add" button so we can focus it. + document.getElementById("vcard-add-org").hidden = false; + // Dispatch the event before removing the element so we can handle focus. + org.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + org.remove(); + event.target.hidden = true; + }); + } + + /** + * Registers a click event for addButton which creates a new vCardProperty + * and inserts it. + * + * @param {HTMLButtonElement} addButton + * @param {string} VCardPropertyName RFC6350 vCard property name. + * @param {(vCardElement) => {}} callback For further refinement. + * Like different focus instead of an input field. + */ + registerAddButton(addButton, VCardPropertyName, callback) { + addButton.addEventListener("click", event => { + let newVCardProperty = VCardEdit.createVCardProperty(VCardPropertyName); + let el = this.insertVCardElement(newVCardProperty, true); + + this.moveFocusIntoElement(el); + if (callback) { + callback(el); + } + }); + } + + /** + * If one BDAY vCardPropertyEntry is present disable + * the option to change an Anniversary to a BDAY. + * + * @see VCardSpecialDateComponent + */ + checkForBdayOccurrences() { + let bdayOccurrence = this.vCardProperties.getFirstEntry("bday"); + this.querySelectorAll("vcard-special-date").forEach(specialDate => { + specialDate.birthdayAvailability({ hasBday: !!bdayOccurrence }); + }); + } + + /** + * Hide the default checkbox if we only have one email field. + */ + toggleDefaultEmailView() { + let hideDefault = + document.getElementById("vcard-email").children.length <= 1; + let defaultColumn = this.querySelector(".default-column"); + if (defaultColumn) { + defaultColumn.hidden = hideDefault; + } + document.getElementById("addr-book-edit-email-default").hidden = + hideDefault; + + // Add class to position legend absolute. + document + .getElementById("addr-book-edit-email") + .classList.toggle("default-table-header", !hideDefault); + } + + /** + * Validate the form with the minimum required data to save or update a + * contact. We can't use the built-in checkValidity() since our fields + * are not handled properly by the form element. + * + * @returns {boolean} - If the form is valid or not. + */ + checkMinimumRequirements() { + let hasEmail = [...document.getElementById("vcard-email").children].find( + s => { + let field = s.querySelector(`input[type="email"]`); + return field.value.trim() && field.checkValidity(); + } + ); + let hasOrg = [...this.querySelectorAll("vcard-org")].find(n => + n.orgEl.value.trim() + ); + + return ( + this.firstName.value.trim() || + this.lastName.value.trim() || + this.displayName.value.trim() || + hasEmail || + hasOrg + ); + } + + /** + * Validate the special date fields making sure that we have a valid + * DATE-AND-OR-TIME. See date, date-noreduc. + * That is, valid if any of the fields are valid, but the combination of + * only year and day is not valid. + * + * @returns {boolean} - True all created special date fields are valid. + * @see https://datatracker.ietf.org/doc/html/rfc6350#section-4.3.4 + */ + validateDates() { + for (let field of document.querySelectorAll("vcard-special-date")) { + let y = field.querySelector(`input[type="number"][name="year"]`); + let m = field.querySelector(`select[name="month"]`); + let d = field.querySelector(`select[name="day"]`); + if (!y.checkValidity()) { + y.focus(); + return false; + } + if (y.value && d.value && !m.value) { + m.required = true; + m.focus(); + return false; + } + } + return true; + } +} +customElements.define("vcard-edit", VCardEdit); + +/** + * Responsible for the type selection of a vCard property. + * + * Couples the given vCardPropertyEntry with a <select> element. + * This is safe because contact editing always creates a new contact, even + * when an existing contact is selected for editing. + * + * @see RFC6350 TYPE + */ +class VCardTypeSelectionComponent extends HTMLElement { + /** + * The select element created by this custom element. + * + * @type {HTMLSelectElement} + */ + selectEl; + + /** + * Initializes the type selector elements to control the given + * vCardPropertyEntry. + * + * @param {VCardPropertyEntry} vCardPropertyEntry - The VCardPropertyEntry + * this element should control. + * @param {boolean} [options.createLabel] - Whether a Type label should be + * created for the selectEl element. If this is not `true`, then the label + * for the selectEl should be provided through some other means, such as the + * labelledBy property. + * @param {string} [options.labelledBy] - Optional `id` of the element that + * should label the selectEl element (through aria-labelledby). + * @param {string} [options.propertyType] - Specifies the set of types that + * should be available and shown for the corresponding property. Set as + * "tel" to use the set of telephone types. Otherwise defaults to only using + * the `home`, `work` and `(None)` types. + */ + createTypeSelection(vCardPropertyEntry, options) { + let template; + let types; + switch (options.propertyType) { + case "tel": + types = ["work", "home", "cell", "fax", "pager"]; + template = document.getElementById("template-vcard-edit-type-tel"); + break; + default: + types = ["work", "home"]; + template = document.getElementById("template-vcard-edit-type"); + break; + } + + let clonedTemplate = template.content.cloneNode(true); + this.replaceChildren(clonedTemplate); + + this.selectEl = this.querySelector("select"); + let selectId = vCardIdGen.next().value; + this.selectEl.id = selectId; + + // Just abandon any values we don't have UI for. We don't have any way to + // know whether to keep them or not, and they're very rarely used. + let paramsType = vCardPropertyEntry.params.type; + // toLowerCase is called because other vCard sources are saving the type + // in upper case. E.g. from Google. + if (Array.isArray(paramsType)) { + let lowerCaseTypes = paramsType.map(type => type.toLowerCase()); + this.selectEl.value = lowerCaseTypes.find(t => types.includes(t)) || ""; + } else if (paramsType && types.includes(paramsType.toLowerCase())) { + this.selectEl.value = paramsType.toLowerCase(); + } + + // Change the value on the vCardPropertyEntry. + this.selectEl.addEventListener("change", e => { + if (this.selectEl.value) { + vCardPropertyEntry.params.type = this.selectEl.value; + } else { + delete vCardPropertyEntry.params.type; + } + }); + + // Set an aria-labelledyby on the select. + if (options.labelledBy) { + if (!document.getElementById(options.labelledBy)) { + throw new Error(`No such label element with id ${options.labelledBy}`); + } + this.querySelector("select").setAttribute( + "aria-labelledby", + options.labelledBy + ); + } + + // Create a label element for the select. + if (options.createLabel) { + let labelEl = document.createElement("label"); + labelEl.htmlFor = selectId; + labelEl.setAttribute("data-l10n-id", "vcard-entry-type-label"); + labelEl.classList.add("screen-reader-only"); + this.insertBefore(labelEl, this.selectEl); + } + } +} + +customElements.define("vcard-type", VCardTypeSelectionComponent); + +/** + * Interface for vCard Fields in the edit view. + * + * @interface VCardPropertyEntryView + */ + +/** + * Getter/Setter for rich data do not use HTMLAttributes for this. + * Keep the reference intact through vCardProperties for proper saving. + * + * @property + * @name VCardPropertyEntryView#vCardPropertyEntry + */ + +/** + * fromUIToVCardPropertyEntry should directly change data with the reference + * through vCardPropertyEntry. + * It's there for an action to read the user input values into the + * vCardPropertyEntry. + * + * @function + * @name VCardPropertyEntryView#fromUIToVCardPropertyEntry + * @returns {void} + */ + +/** + * Updates the UI accordingly to the vCardPropertyEntry. + * + * @function + * @name VCardPropertyEntryView#fromVCardPropertyEntryToUI + * @returns {void} + */ + +/** + * Checks if the value of VCardPropertyEntry is empty. + * + * @function + * @name VCardPropertyEntryView#valueIsEmpty + * @returns {boolean} + */ + +/** + * Creates a new VCardPropertyEntry for usage in the a new Field. + * + * @function + * @name VCardPropertyEntryView#newVCardPropertyEntry + * @static + * @returns {VCardPropertyEntry} + */ diff --git a/comm/mail/components/addrbook/content/vcard-edit/email.mjs b/comm/mail/components/addrbook/content/vcard-edit/email.mjs new file mode 100644 index 0000000000..751399ac6c --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/email.mjs @@ -0,0 +1,135 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 EMAIL + */ +export class VCardEmailComponent extends HTMLTableRowElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + emailEl; + /** @type {HTMLInputElement} */ + checkboxEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("email", {}, "text", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-email"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.emailEl = this.querySelector('input[type="email"]'); + this.checkboxEl = this.querySelector('input[type="checkbox"]'); + + this.emailEl.addEventListener("input", () => { + // Dispatch the event only if this field is the currently selected + // default/preferred email address. + if (this.checkboxEl.checked) { + this.dispatchEvent(VCardEmailComponent.EmailEvent()); + } + }); + + // Uncheck the checkbox of other VCardEmailComponents if this one is + // checked. + this.checkboxEl.addEventListener("change", event => { + if (event.target.checked === true) { + this.dispatchEvent(VCardEmailComponent.CheckboxEvent()); + } + }); + + // Create the email type selection. + this.vCardType = this.querySelector("vcard-type"); + this.vCardType.createTypeSelection(this.vCardPropertyEntry, { + labelledBy: "addr-book-edit-email-type", + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + document.querySelector("vcard-edit").toggleDefaultEmailView(); + } + ); + + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + this.emailEl.value = this.vCardPropertyEntry.value; + + let pref = this.vCardPropertyEntry.params.pref; + if (pref === "1") { + this.checkboxEl.checked = true; + } + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.emailEl.value; + + if (this.checkboxEl.checked) { + this.vCardPropertyEntry.params.pref = "1"; + } else if ( + this.vCardPropertyEntry.params.pref && + this.vCardPropertyEntry.params.pref === "1" + ) { + // Only delete the pref if a pref of 1 is set and the checkbox is not + // checked. The pref mechanic is not fully supported yet. Leave all other + // prefs untouched. + delete this.vCardPropertyEntry.params.pref; + } + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } + + /** + * This event is fired when the checkbox is checked and we need to uncheck the + * other checkboxes from each VCardEmailComponent. + * FIXME: This should be a radio button part of radiogroup. + * + * @returns {CustomEvent} + */ + static CheckboxEvent() { + return new CustomEvent("vcard-email-default-checkbox", { + detail: {}, + bubbles: true, + }); + } + + /** + * This event is fired when the value of an email input field is changed. The + * event is fired only if the current email si set as default/preferred. + * + * @returns {CustomEvent} + */ + static EmailEvent() { + return new CustomEvent("vcard-email-default-changed", { + detail: {}, + bubbles: true, + }); + } +} + +customElements.define("vcard-email", VCardEmailComponent, { extends: "tr" }); diff --git a/comm/mail/components/addrbook/content/vcard-edit/fn.mjs b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs new file mode 100644 index 0000000000..446a262f28 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs @@ -0,0 +1,71 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 FN + */ +export class VCardFNComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLElement} */ + displayEl; + /** @type {HTMLElement} */ + preferDisplayEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("fn", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-fn"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.displayEl = this.querySelector("#vCardDisplayName"); + this.displayEl.addEventListener( + "input", + () => { + this.displayEl.isDirty = true; + }, + { once: true } + ); + this.preferDisplayEl = this.querySelector("#vCardPreferDisplayName"); + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.displayEl = null; + this.vCardPropertyEntry = null; + } + } + + fromVCardPropertyEntryToUI() { + this.displayEl.value = this.vCardPropertyEntry.value; + this.displayEl.isDirty = !!this.displayEl.value.trim(); + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.displayEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} +customElements.define("vcard-fn", VCardFNComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs new file mode 100644 index 0000000000..b4ce37bfda --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs @@ -0,0 +1,12 @@ +/* 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/. */ + +function* vCardHtmlIdGen() { + let internalId = 0; + while (true) { + yield `vcard-id-${internalId++}`; + } +} + +export let vCardIdGen = vCardHtmlIdGen(); diff --git a/comm/mail/components/addrbook/content/vcard-edit/impp.mjs b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs new file mode 100644 index 0000000000..232925942e --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs @@ -0,0 +1,97 @@ +/* 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 { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 IMPP + */ +export class VCardIMPPComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + imppEl; + /** @type {HTMLSelectElement} */ + protocolEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("impp", {}, "uri", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-impp"); + this.appendChild(template.content.cloneNode(true)); + + this.imppEl = this.querySelector('input[name="impp"]'); + document.l10n + .formatValue("vcard-impp-input-title") + .then(t => (this.imppEl.title = t)); + + this.protocolEl = this.querySelector('select[name="protocol"]'); + this.protocolEl.id = vCardIdGen.next().value; + + let protocolLabel = this.querySelector('label[for="protocol"]'); + protocolLabel.htmlFor = this.protocolEl.id; + + this.protocolEl.addEventListener("change", event => { + let entered = this.imppEl.value.split(":", 1)[0]?.toLowerCase(); + if (entered) { + this.protocolEl.value = + [...this.protocolEl.options].find(o => o.value.startsWith(entered)) + ?.value || ""; + } + this.imppEl.placeholder = this.protocolEl.value; + this.imppEl.pattern = this.protocolEl.selectedOptions[0].dataset.pattern; + }); + + this.imppEl.id = vCardIdGen.next().value; + let imppLabel = this.querySelector('label[for="impp"]'); + imppLabel.htmlFor = this.imppEl.id; + document.l10n.setAttributes(imppLabel, "vcard-impp-label"); + this.imppEl.addEventListener("change", event => { + this.protocolEl.dispatchEvent(new CustomEvent("change")); + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fromVCardPropertyEntryToUI(); + this.imppEl.dispatchEvent(new CustomEvent("change")); + } + + fromVCardPropertyEntryToUI() { + this.imppEl.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.imppEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} + +customElements.define("vcard-impp", VCardIMPPComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/n.mjs b/comm/mail/components/addrbook/content/vcard-edit/n.mjs new file mode 100644 index 0000000000..ae5d386d93 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/n.mjs @@ -0,0 +1,186 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 N + */ +export class VCardNComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLElement} */ + prefixEl; + /** @type {HTMLElement} */ + firstNameEl; + /** @type {HTMLElement} */ + middleNameEl; + /** @type {HTMLElement} */ + lastNameEl; + /** @type {HTMLElement} */ + suffixEl; + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-n"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.registerListComponents(); + this.fromVCardPropertyEntryToUI(); + this.sortAsOrder(); + } + } + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("n", {}, "text", ["", "", "", "", ""]); + } + + /** + * Assigns the vCardPropertyEntry values to the individual + * NListComponentText elements. + * + * @TODO sort-as param should be used for the order. + * The use-case is that not every language has the order of + * prefix, firstName, middleName, lastName, suffix. + * Aswell that the user is able to change the sorting as he like + * on a per contact base. + */ + sortAsOrder() { + if (!this.vCardPropertyEntry.params["sort-as"]) { + // eslint-disable-next-line no-useless-return + return; + } + /** + * @TODO + * The sort-as DOM Mutation + */ + } + + fromVCardPropertyEntryToUI() { + let prefixVal = this.vCardPropertyEntry.value[3] || ""; + let prefixInput = this.prefixEl.querySelector("input"); + prefixInput.value = prefixVal; + if (prefixVal) { + this.prefixEl.querySelector("button").hidden = true; + } else { + this.prefixEl.classList.add("hasButton"); + this.prefixEl.querySelector("label").hidden = true; + prefixInput.hidden = true; + } + + // First Name is always shown. + this.firstNameEl.querySelector("input").value = + this.vCardPropertyEntry.value[1] || ""; + + let middleNameVal = this.vCardPropertyEntry.value[2] || ""; + let middleNameInput = this.middleNameEl.querySelector("input"); + middleNameInput.value = middleNameVal; + if (middleNameVal) { + this.middleNameEl.querySelector("button").hidden = true; + } else { + this.middleNameEl.classList.add("hasButton"); + this.middleNameEl.querySelector("label").hidden = true; + middleNameInput.hidden = true; + } + + // Last Name is always shown. + this.lastNameEl.querySelector("input").value = + this.vCardPropertyEntry.value[0] || ""; + + let suffixVal = this.vCardPropertyEntry.value[4] || ""; + let suffixInput = this.suffixEl.querySelector("input"); + suffixInput.value = suffixVal; + if (suffixVal) { + this.suffixEl.querySelector("button").hidden = true; + } else { + this.suffixEl.classList.add("hasButton"); + this.suffixEl.querySelector("label").hidden = true; + suffixInput.hidden = true; + } + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = [ + this.lastNameEl.querySelector("input").value, + this.firstNameEl.querySelector("input").value, + this.middleNameEl.querySelector("input").value, + this.prefixEl.querySelector("input").value, + this.suffixEl.querySelector("input").value, + ]; + } + + valueIsEmpty() { + let noEmptyStrings = [ + this.prefixEl, + this.firstNameEl, + this.middleNameEl, + this.lastNameEl, + this.suffixEl, + ].filter(node => { + return node.querySelector("input").value !== ""; + }); + return noEmptyStrings.length === 0; + } + + registerListComponents() { + this.prefixEl = this.querySelector("#n-list-component-prefix"); + let prefixInput = this.prefixEl.querySelector("input"); + let prefixButton = this.prefixEl.querySelector("button"); + prefixButton.addEventListener("click", e => { + this.prefixEl.querySelector("label").hidden = false; + prefixInput.hidden = false; + prefixButton.hidden = true; + this.prefixEl.classList.remove("hasButton"); + prefixInput.focus(); + }); + + this.firstNameEl = this.querySelector("#n-list-component-firstname"); + + this.middleNameEl = this.querySelector("#n-list-component-middlename"); + let middleNameInput = this.middleNameEl.querySelector("input"); + let middleNameButton = this.middleNameEl.querySelector("button"); + middleNameButton.addEventListener("click", e => { + this.middleNameEl.querySelector("label").hidden = false; + middleNameInput.hidden = false; + middleNameButton.hidden = true; + this.middleNameEl.classList.remove("hasButton"); + middleNameInput.focus(); + }); + + this.lastNameEl = this.querySelector("#n-list-component-lastname"); + + this.suffixEl = this.querySelector("#n-list-component-suffix"); + let suffixInput = this.suffixEl.querySelector("input"); + let suffixButton = this.suffixEl.querySelector("button"); + suffixButton.addEventListener("click", e => { + this.suffixEl.querySelector("label").hidden = false; + suffixInput.hidden = false; + suffixButton.hidden = true; + this.suffixEl.classList.remove("hasButton"); + suffixInput.focus(); + }); + } + + disconnectedCallback() { + if (!this.isConnected) { + this.prefixEl = null; + this.firstNameEl = null; + this.middleNameEl = null; + this.lastNameEl = null; + this.suffixEl = null; + } + } +} +customElements.define("vcard-n", VCardNComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs new file mode 100644 index 0000000000..3622b28997 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs @@ -0,0 +1,59 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 NICKNAME + */ +export class VCardNickNameComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + /** @type {HTMLElement} */ + nickNameEl; + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-nickname"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("nickname", {}, "text", ""); + } + + connectedCallback() { + if (this.isConnected) { + this.nickNameEl = this.querySelector("#vCardNickName"); + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.nickNameEl = null; + this.vCardPropertyEntry = null; + } + } + + fromVCardPropertyEntryToUI() { + this.nickNameEl.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.nickNameEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} +customElements.define("vcard-nickname", VCardNickNameComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/note.mjs b/comm/mail/components/addrbook/content/vcard-edit/note.mjs new file mode 100644 index 0000000000..f78f4a16d8 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/note.mjs @@ -0,0 +1,82 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 Note + */ +export class VCardNoteComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLTextAreaElement} */ + textAreaEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("note", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-note"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.textAreaEl = this.querySelector("textarea"); + this.textAreaEl.addEventListener("input", () => { + this.resizeTextAreaEl(); + }); + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + document.getElementById("vcard-add-note").hidden = false; + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.textAreaEl = null; + this.vCardPropertyEntry = null; + } + } + + fromVCardPropertyEntryToUI() { + this.textAreaEl.value = this.vCardPropertyEntry.value; + this.resizeTextAreaEl(); + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.textAreaEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } + + resizeTextAreaEl() { + this.textAreaEl.rows = Math.min( + 15, + Math.max(5, this.textAreaEl.value.split("\n").length) + ); + } +} + +customElements.define("vcard-note", VCardNoteComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/org.mjs b/comm/mail/components/addrbook/content/vcard-edit/org.mjs new file mode 100644 index 0000000000..fb788c3043 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/org.mjs @@ -0,0 +1,197 @@ +/* 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 { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 TITLE + */ +export class VCardTitleComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + titleEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("title", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-title"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.titleEl = this.querySelector('input[name="title"]'); + this.assignIds(this.titleEl, this.querySelector('label[for="title"]')); + + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.vCardPropertyEntry = null; + this.titleEl = null; + } + } + + fromVCardPropertyEntryToUI() { + this.titleEl.value = this.vCardPropertyEntry.value || ""; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.titleEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } + + assignIds(inputEl, labelEl) { + let labelInputId = vCardIdGen.next().value; + inputEl.id = labelInputId; + labelEl.htmlFor = labelInputId; + } +} +customElements.define("vcard-title", VCardTitleComponent); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 ROLE + */ +export class VCardRoleComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + roleEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("role", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-role"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.roleEl = this.querySelector('input[name="role"]'); + this.assignIds(this.roleEl, this.querySelector('label[for="role"]')); + + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.vCardPropertyEntry = null; + this.roleEl = null; + } + } + + fromVCardPropertyEntryToUI() { + this.roleEl.value = this.vCardPropertyEntry.value || ""; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.roleEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } + + assignIds(inputEl, labelEl) { + let labelInputId = vCardIdGen.next().value; + inputEl.id = labelInputId; + labelEl.htmlFor = labelInputId; + } +} +customElements.define("vcard-role", VCardRoleComponent); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 ORG + */ +export class VCardOrgComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + /** @type {HTMLInputElement} */ + orgEl; + /** @type {HTMLInputElement} */ + unitEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("org", {}, "text", ["", ""]); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-org"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.orgEl = this.querySelector('input[name="org"]'); + this.orgEl.id = vCardIdGen.next().value; + this.querySelector('label[for="org"]').htmlFor = this.orgEl.id; + + this.unitEl = this.querySelector('input[name="orgUnit"]'); + this.unitEl.id = vCardIdGen.next().value; + this.querySelector('label[for="orgUnit"]').htmlFor = this.unitEl.id; + + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + let values = this.vCardPropertyEntry.value; + if (!values) { + this.orgEl.value = ""; + this.unitEl.value = ""; + return; + } + if (!Array.isArray(values)) { + values = [values]; + } + this.orgEl.value = values.shift() || ""; + // In case data had more levels of units, just pull them together. + this.unitEl.value = values.join(", "); + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = [this.orgEl.value.trim()]; + if (this.unitEl.value.trim()) { + this.vCardPropertyEntry.value.push(this.unitEl.value.trim()); + } + } + + valueIsEmpty() { + return ( + !this.vCardPropertyEntry.value || + (Array.isArray(this.vCardPropertyEntry.value) && + this.vCardPropertyEntry.value.every(v => v === "")) + ); + } +} +customElements.define("vcard-org", VCardOrgComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs new file mode 100644 index 0000000000..17c7df493b --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs @@ -0,0 +1,269 @@ +/* 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 { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm"); + +/** + * ANNIVERSARY and BDAY both have a cardinality of + * 1 ("Exactly one instance per vCard MAY be present."). + * + * For Anniversary we changed the cardinality to + * ("One or more instances per vCard MAY be present.")". + * + * @implements {VCardPropertyEntryView} + * @see RFC6350 ANNIVERSARY and BDAY + */ +export class VCardSpecialDateComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLSelectElement} */ + selectEl; + /** @type {HTMLInputElement} */ + year; + /** @type {HTMLSelectElement} */ + month; + /** @type {HTMLSelectElement} */ + day; + + /** + * Object containing the available days for each month. + * + * @type {object} + */ + monthDays = { + 1: 31, + 2: 28, + 3: 31, + 4: 30, + 5: 31, + 6: 30, + 7: 31, + 8: 31, + 9: 30, + 10: 31, + 11: 30, + 12: 31, + }; + + static newAnniversaryVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("anniversary", {}, "date", ""); + } + + static newBdayVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("bday", {}, "date", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById( + "template-vcard-edit-bday-anniversary" + ); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.selectEl = this.querySelector(".vcard-type-selection"); + let selectId = vCardIdGen.next().value; + this.selectEl.id = selectId; + this.querySelector(".vcard-type-label").htmlFor = selectId; + + this.selectEl.addEventListener("change", event => { + this.dispatchEvent( + VCardSpecialDateComponent.ChangeVCardPropertyEntryEvent( + event.target.value + ) + ); + }); + + this.month = this.querySelector("#month"); + let monthId = vCardIdGen.next().value; + this.month.id = monthId; + this.querySelector('label[for="month"]').htmlFor = monthId; + this.month.addEventListener("change", () => { + this.fillDayOptions(); + }); + + this.day = this.querySelector("#day"); + let dayId = vCardIdGen.next().value; + this.day.id = dayId; + this.querySelector('label[for="day"]').htmlFor = dayId; + + this.year = this.querySelector("#year"); + let yearId = vCardIdGen.next().value; + this.year.id = yearId; + this.querySelector('label[for="year"]').htmlFor = yearId; + this.year.addEventListener("input", () => { + this.fillDayOptions(); + }); + + document.l10n.formatValues([{ id: "vcard-date-year" }]).then(yearLabel => { + this.year.placeholder = yearLabel; + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fillMonthOptions(); + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + this.selectEl.value = this.vCardPropertyEntry.name; + if (this.vCardPropertyEntry.type === "text") { + // TODO: support of text type for special-date + this.hidden = true; + return; + } + // Default value is date-and-or-time. + let dateValue; + try { + dateValue = ICAL.VCardTime.fromDateAndOrTimeString( + this.vCardPropertyEntry.value || "", + "date-and-or-time" + ); + } catch (ex) { + console.error(ex); + } + // Always set the month first since that controls the available days. + this.month.value = dateValue?.month || ""; + this.fillDayOptions(); + this.day.value = dateValue?.day || ""; + this.year.value = dateValue?.year || ""; + } + + fromUIToVCardPropertyEntry() { + if (this.vCardPropertyEntry.type === "text") { + // TODO: support of text type for special-date + return; + } + // Default value is date-and-or-time. + let dateValue = new ICAL.VCardTime({}, null, "date"); + // Set the properties directly instead of using the VCardTime + // constructor argument, which causes null values to become 0. + dateValue.year = this.year.value ? Number(this.year.value) : null; + dateValue.month = this.month.value ? Number(this.month.value) : null; + dateValue.day = this.day.value ? Number(this.day.value) : null; + this.vCardPropertyEntry.value = dateValue.toString(); + } + + valueIsEmpty() { + return !this.year.value && !this.month.value && !this.day.value; + } + + /** + * @param {"bday" | "anniversary"} entryName + * @returns {CustomEvent} + */ + static ChangeVCardPropertyEntryEvent(entryName) { + return new CustomEvent("vcard-bday-anniversary-change", { + detail: { + name: entryName, + }, + bubbles: true, + }); + } + + /** + * Check if the specified year is a leap year in order to add or remove the + * extra day to February. + * + * @returns {boolean} True if the currently specified year is a leap year, + * or if no valid year value is available. + */ + isLeapYear() { + // If the year is empty, we can't know if it's a leap year so must assume + // it is. Otherwise year-less dates can't show Feb 29. + if (!this.year.checkValidity() || this.year.value === "") { + return true; + } + + let year = parseInt(this.year.value); + return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + } + + fillMonthOptions() { + let formatter = Intl.DateTimeFormat(undefined, { month: "long" }); + for (let m = 1; m <= 12; m++) { + let option = document.createElement("option"); + option.setAttribute("value", m); + option.setAttribute("label", formatter.format(new Date(2000, m - 1, 2))); + this.month.appendChild(option); + } + } + + /** + * Update the Day select element to reflect the available days of the selected + * month. + */ + fillDayOptions() { + let prevDay = 0; + // Save the previously selected day if we have one. + if (this.day.childNodes.length > 1) { + prevDay = this.day.value; + } + + // Always clear old options. + let defaultOption = document.createElement("option"); + defaultOption.value = ""; + document.l10n + .formatValues([{ id: "vcard-date-day" }]) + .then(([dayLabel]) => { + defaultOption.textContent = dayLabel; + }); + this.day.replaceChildren(defaultOption); + + let monthValue = this.month.value || 1; + // Add a day to February if this is a leap year and we're in February. + if (monthValue === "2") { + this.monthDays["2"] = this.isLeapYear() ? 29 : 28; + } + + let formatter = Intl.DateTimeFormat(undefined, { day: "numeric" }); + for (let d = 1; d <= this.monthDays[monthValue]; d++) { + let option = document.createElement("option"); + option.setAttribute("value", d); + option.setAttribute("label", formatter.format(new Date(2000, 0, d))); + this.day.appendChild(option); + } + // Reset the previously selected day, if it's available in the currently + // selected month. + this.day.value = prevDay <= this.monthDays[monthValue] ? prevDay : ""; + } + + /** + * @param {boolean} options.hasBday + */ + birthdayAvailability(options) { + if (this.vCardPropertyEntry.name === "bday") { + return; + } + Array.from(this.selectEl.options).forEach(option => { + if (option.value === "bday") { + option.disabled = options.hasBday; + } + }); + } +} + +customElements.define("vcard-special-date", VCardSpecialDateComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/tel.mjs b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs new file mode 100644 index 0000000000..a5eb30c6d5 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs @@ -0,0 +1,83 @@ +/* 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 { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 TEL + * + * @TODO missing type-param-tel support. + * "text, voice, video, textphone" + */ +export class VCardTelComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + inputElement; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("tel", {}, "text", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-tel"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.inputElement = this.querySelector('input[type="text"]'); + let urlId = vCardIdGen.next().value; + this.inputElement.id = urlId; + let urlLabel = this.querySelector('label[for="text"]'); + urlLabel.htmlFor = urlId; + document.l10n.setAttributes(urlLabel, "vcard-tel-label"); + this.inputElement.type = "tel"; + + // Create the tel type selection. + this.vCardType = this.querySelector("vcard-type"); + this.vCardType.createTypeSelection(this.vCardPropertyEntry, { + createLabel: true, + propertyType: "tel", + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + this.inputElement.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.inputElement.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} + +customElements.define("vcard-tel", VCardTelComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/tz.mjs b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs new file mode 100644 index 0000000000..cf77114db6 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs @@ -0,0 +1,86 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "cal", + "resource:///modules/calendar/calUtils.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 URL + */ +export class VCardTZComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLSelectElement} */ + selectEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("tz", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-tz"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.selectEl = this.querySelector("select"); + for (let tzid of lazy.cal.timezoneService.timezoneIds) { + let option = this.selectEl.appendChild( + document.createElement("option") + ); + option.value = tzid; + option.textContent = + lazy.cal.timezoneService.getTimezone(tzid).displayName; + } + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + document.getElementById("vcard-add-tz").hidden = false; + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.selectEl = null; + this.vCardPropertyEntry = null; + } + } + + fromVCardPropertyEntryToUI() { + this.selectEl.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.selectEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} + +customElements.define("vcard-tz", VCardTZComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/url.mjs b/comm/mail/components/addrbook/content/vcard-edit/url.mjs new file mode 100644 index 0000000000..98a1b42951 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/url.mjs @@ -0,0 +1,89 @@ +/* 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 { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 URL + */ +export class VCardURLComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + urlEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("url", {}, "uri", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-type-text"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.urlEl = this.querySelector('input[type="text"]'); + let urlId = vCardIdGen.next().value; + this.urlEl.id = urlId; + let urlLabel = this.querySelector('label[for="text"]'); + urlLabel.htmlFor = urlId; + this.urlEl.type = "url"; + document.l10n.setAttributes(urlLabel, "vcard-url-label"); + + this.urlEl.addEventListener("input", () => { + // Auto add https:// if the url is missing scheme. + if ( + this.urlEl.value.length > "https://".length && + !/^https?:\/\//.test(this.urlEl.value) + ) { + this.urlEl.value = "https://" + this.urlEl.value; + } + }); + + // Create the url type selection. + this.vCardType = this.querySelector("vcard-type"); + this.vCardType.createTypeSelection(this.vCardPropertyEntry, { + createLabel: true, + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + this.urlEl.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.urlEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} + +customElements.define("vcard-url", VCardURLComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml new file mode 100644 index 0000000000..56d53f57f1 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml @@ -0,0 +1,398 @@ +# 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/. + +<!-- Styles --> +<link rel="stylesheet" href="chrome://messenger/skin/vcard.css" /> + +<!-- Scripts --> +<script type="module" src="chrome://messenger/content/addressbook/edit/edit.mjs"></script> + +<!-- Localization --> +<link rel="localization" href="messenger/addressbook/vcard.ftl" /> + +<!-- Edit View --> +<template id="template-addr-book-edit"> + <!-- Name --> + <fieldset id="addr-book-edit-n" class="addr-book-edit-fieldset fieldset-reset"> + <legend class="screen-reader-only" data-l10n-id="vcard-name-header"/> + <div class="addr-book-edit-display-nickname"> + </div> + </fieldset> + <fieldset id="addr-book-edit-email" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-email-header"/> + <table> + <thead> + <tr> + <th id="addr-book-edit-email-type" scope="col"> + <!-- NOTE: We use the <span> so we can apply the screen-reader-only + - class to the <span> rather than the <th> element. If we apply + - the class to the <th> element directly it causes problems with + - Orca's "browse mode" table navigation. See bug 1776644. --> + <span class="screen-reader-only" + data-l10n-id="vcard-entry-type-label"> + </span> + </th> + <th id="addr-book-edit-email-label" scope="col"> + <span class="screen-reader-only" + data-l10n-id="vcard-email-label"> + </span> + </th> + <th id="addr-book-edit-email-default" scope="col"> + <span data-l10n-id="vcard-primary-email-label"></span> + </th> + </tr> + </thead> + <tbody id="vcard-email"></tbody> + </table> + <button id="vcard-add-email" + data-l10n-id="vcard-email-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- URL --> + <fieldset id="addr-book-edit-url" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-url-header"/> + <button id="vcard-add-url" + data-l10n-id="vcard-url-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Address --> + <fieldset id="addr-book-edit-address" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-adr-header"/> + <button id="vcard-add-adr" + data-l10n-id="vcard-adr-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Tel --> + <fieldset id="addr-book-edit-tel" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-tel-header"/> + <button id="vcard-add-tel" + data-l10n-id="vcard-tel-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Time Zone --> + <fieldset id="addr-book-edit-tz" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-tz-header"/> + <button id="vcard-add-tz" + data-l10n-id="vcard-tz-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- IMPP (Chat) --> + <fieldset id="addr-book-edit-impp" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-impp2-header"/> + <button id="vcard-add-impp" + data-l10n-id="vcard-impp-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Birthday and Anniversary (Special dates) --> + <fieldset id="addr-book-edit-bday-anniversary" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-bday-anniversary-header"/> + <button id="vcard-add-bday-anniversary" + data-l10n-id="vcard-bday-anniversary-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Notes --> + <fieldset id="addr-book-edit-note" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-note-header"/> + <button id="vcard-add-note" + data-l10n-id="vcard-note-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Organization Info --> + <fieldset id="addr-book-edit-org" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-org-header"/> + <button id="vcard-add-org" + data-l10n-id="vcard-org-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button" + hidden="hidden"></button> + </fieldset> + <!-- Custom --> + <fieldset id="addr-book-edit-custom" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-custom-header"/> + <button id="vcard-add-custom" + data-l10n-id="vcard-custom-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> +</template> + +<!-- Individual fields --> + +<!-- N field --> +<template id="template-vcard-edit-n"> + <div id="n-list-component-prefix" class="n-list-component"> + <label for="vcard-n-prefix" data-l10n-id="vcard-n-prefix" /> + <input id="vcard-n-prefix" + type="text" + autocomplete="off" /> + <button class="primary" data-l10n-id="vcard-n-add-prefix" + type="button"> + <img src="chrome://global/skin/icons/add.svg" alt="" /> + </button> + </div> + <div id="n-list-component-firstname" class="n-list-component"> + <label for="vcard-n-firstname" data-l10n-id="vcard-n-firstname" /> + <input id="vcard-n-firstname" + type="text" + autocomplete="off" /> + </div> + <div id="n-list-component-middlename" class="n-list-component"> + <label for="vcard-n-middlename" data-l10n-id="vcard-n-middlename" /> + <input id="vcard-n-middlename" + type="text" + autocomplete="off" /> + <button class="primary" data-l10n-id="vcard-n-add-middlename" + type="button"> + <img src="chrome://global/skin/icons/add.svg" alt="" /> + </button> + </div> + <div id="n-list-component-lastname" class="n-list-component"> + <label for="vcard-n-lastname" data-l10n-id="vcard-n-lastname" /> + <input id="vcard-n-lastname" + type="text" + autocomplete="off" /> + </div> + <div id="n-list-component-suffix" class="n-list-component"> + <label for="vcard-n-suffix" data-l10n-id="vcard-n-suffix" /> + <button class="primary" data-l10n-id="vcard-n-add-suffix" + type="button"> + <img src="chrome://global/skin/icons/add.svg" alt="" /> + </button> + <input id="vcard-n-suffix" + type="text" + autocomplete="off" /> + </div> +</template> + +<!-- FN field. --> +<template id="template-vcard-edit-fn"> + <label for="vCardDisplayName" data-l10n-id="vcard-displayname"></label> + <input id="vCardDisplayName" type="text"/> + <label id="vCardDisplayNameCheckbox" class="vcard-checkbox"> + <!-- There is no l10n ID on this element because the vCard edit form is + also used in other sections that don't use this checkbox and don't have + access to the fluent string. The string is added when needed by the + address book edit.js file. --> + <input type="checkbox" id="vCardPreferDisplayName" checked="checked" /> + <!-- SPAN element needed for fluent string. --> + <span></span> + </label> +</template> + +<!-- NICKNAME field. --> +<template id="template-vcard-edit-nickname"> + <label for="vCardNickName" data-l10n-id="vcard-nickname"></label> + <input id="vCardNickName" type="text"/> +</template> + +<!-- Email --> +<template id="template-vcard-edit-email"> + <td> + <vcard-type></vcard-type> + </td> + <td class="email-column"> + <input type="email" + aria-labelledby="addr-book-edit-email-label" /> + </td> + <td class="default-column"> + <input type="checkbox" + aria-labelledby="addr-book-edit-email-default" /> + </td> + <td> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> + </td> +</template> + +<!-- Phone --> +<template id="template-vcard-edit-tel"> + <vcard-type></vcard-type> + <label class="screen-reader-only" for="text"/> + <input type="text"/> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> +</template> + +<!-- Field with type and text --> +<template id="template-vcard-edit-type-text"> + <vcard-type></vcard-type> + <label class="screen-reader-only" for="text"/> + <input type="text"/> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> +</template> + +<!-- Time Zone --> +<template id="template-vcard-edit-tz"> + <select> + <option value=""></option> + </select> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button"></button> +</template> + +<!-- IMPP --> +<template id="template-vcard-edit-impp"> + <label class="screen-reader-only" for="protocol" data-l10n-id="vcard-impp-select"></label> + <select name="protocol" class="vcard-type-selection"> + <option value="matrix:u/john:example.org" data-pattern="matrix:.+/.+:.+">Matrix</option> + <option value="xmpp:john@example.org" data-pattern="xmpp:.+@.+">XMPP</option> + <option value="ircs://irc.example.org/john,isuser" data-pattern="ircs?://.+/.+,.+">IRC</option> + <option value="sip:1-555-123-4567@voip.example.org" data-pattern="sip:.+@.+">SIP</option> + <option value="skype:johndoe" data-pattern="skype:[A-Za-z\d\-\._]{6,32}">Skype</option> + <option value="" data-l10n-id="vcard-impp-option-other" data-pattern="..+:..+"></option> + </select> + <label class="screen-reader-only" for="impp" data-l10n-id="vcard-impp-input-label"></label> + <input type="text" name="impp" pattern="..+:..+" /> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> +</template> + +<!-- Birthday and Anniversary --> +<template id="template-vcard-edit-bday-anniversary"> + <label class="vcard-type-label screen-reader-only" + data-l10n-id="vcard-entry-type-label"></label> + <select class="vcard-type-selection"> + <option value="bday" data-l10n-id="vcard-bday-label" selected="selected"/> + <option value="anniversary" data-l10n-id="vcard-anniversary-label"/> + </select> + + <div class="vcard-year-month-day-container"> + <label class="screen-reader-only" for="year" data-l10n-id="vcard-date-year"></label> + <input id="year" name="year" type="number" min="1000" max="9999" pattern="[0-9]{4}" class="size5" /> + + <label class="screen-reader-only" for="month" data-l10n-id="vcard-date-month"></label> + <select id="month" name="month" class="vcard-month-select"> + <option value="" data-l10n-id="vcard-date-month" selected="selected"></option> + </select> + + <label class="screen-reader-only" for="day" data-l10n-id="vcard-date-day"></label> + <select id="day" name="day" class="vcard-day-select"> + <option value="" data-l10n-id="vcard-date-day" selected="selected"></option> + </select> + + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> + </div> +</template> + +<!-- Address --> +<template id="template-vcard-edit-adr"> + <fieldset class="fieldset-grid fieldset-reset"> + <legend class="screen-reader-only" data-l10n-id="vcard-adr-label"/> + <vcard-type></vcard-type> + <div class="vcard-adr-inputs"> + <label for="street" data-l10n-id="vcard-adr-street"/> + <textarea name="street"></textarea> + </div> + <div class="vcard-adr-inputs"> + <label for="locality" data-l10n-id="vcard-adr-locality"/> + <input type="text" name="locality"/> + </div> + <div class="vcard-adr-inputs"> + <label for="region" data-l10n-id="vcard-adr-region"/> + <input type="text" name="region"/> + </div> + <div class="vcard-adr-inputs"> + <label for="code" data-l10n-id="vcard-adr-code"/> + <input type="text" name="code"/> + </div> + <div class="vcard-adr-inputs"> + <label for="country" data-l10n-id="vcard-adr-country"/> + <input type="text" name="country"/> + </div> + </fieldset> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button"></button> +</template> + +<!-- Notes --> +<template id="template-vcard-edit-note"> + <textarea></textarea> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button"></button> +</template> + +<!-- Organization Info --> +<template id="template-vcard-edit-title"> + <div class="vcard-adr-inputs"> + <label for="title" data-l10n-id="vcard-org-title"/> + <input type="text" data-l10n-id="vcard-org-title-input" name="title" /> + </div> +</template> +<template id="template-vcard-edit-role"> + <div class="vcard-adr-inputs"> + <label for="role" data-l10n-id="vcard-org-role"/> + <input type="text" data-l10n-id="vcard-org-role-input" name="role" /> + </div> +</template> +<template id="template-vcard-edit-org"> + <div class="vcard-adr-inputs"> + <label for="org" data-l10n-id="vcard-org-org" /> + <input type="text" name="org" data-l10n-id="vcard-org-org-input" /> + <label for="orgUnit" data-l10n-id="vcard-org-org-unit" class="screen-reader-only"/> + <input type="text" name="orgUnit" data-l10n-id="vcard-org-org-unit-input" /> + </div> +</template> + +<!-- Custom --> +<template id="template-vcard-edit-custom"> + <div class="vcard-adr-inputs"> + <label for="custom1"/> + <input type="text"/> + </div> + <div class="vcard-adr-inputs"> + <label for="custom2"/> + <input type="text"/> + </div> + <div class="vcard-adr-inputs"> + <label for="custom3"/> + <input type="text"/> + </div> + <div class="vcard-adr-inputs"> + <label for="custom4"/> + <input type="text"/> + </div> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button"></button> +</template> + +<template id="template-vcard-edit-type"> + <select class="vcard-type-selection"> + <option value="work" data-l10n-id="vcard-entry-type-work"/> + <option value="home" data-l10n-id="vcard-entry-type-home"/> + <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/> + </select> +</template> + +<template id="template-vcard-edit-type-tel"> + <select class="vcard-type-selection"> + <option value="work" data-l10n-id="vcard-entry-type-work"/> + <option value="home" data-l10n-id="vcard-entry-type-home"/> + <option value="cell" data-l10n-id="vcard-entry-type-cell"/> + <option value="fax" data-l10n-id="vcard-entry-type-fax"/> + <option value="pager" data-l10n-id="vcard-entry-type-pager"/> + <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/> + </select> +</template> |