/* 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/. */ /** * An extension of the label element that provides accesskey styling and * formatting as well as click handling logic. * * @tagname moz-label * @attribute {string} accesskey - Key used for keyboard access. */ class MozTextLabel extends HTMLLabelElement { #insertSeparator = false; #alwaysAppendAccessKey = false; #lastFormattedAccessKey = null; // Default to underlining accesskeys for Windows and Linux. static #underlineAccesskey = !navigator.platform.includes("Mac"); static get observedAttributes() { return ["accesskey"]; } static stylesheetUrl = "chrome://global/content/elements/moz-label.css"; constructor() { super(); this.#register(); this.addEventListener("click", this._onClick); } #register() { if (window.IS_STORYBOOK) { MozTextLabel.#underlineAccesskey = true; } else if (typeof Services !== "undefined") { MozTextLabel.#underlineAccesskey = !!Services.prefs.getIntPref( "ui.key.menuAccessKey", Number(!navigator.platform.includes("Mac")) ); if (MozTextLabel.#underlineAccesskey) { try { const nsIPrefLocalizedString = Ci.nsIPrefLocalizedString; const prefNameInsertSeparator = "intl.menuitems.insertseparatorbeforeaccesskeys"; const prefNameAlwaysAppendAccessKey = "intl.menuitems.alwaysappendaccesskeys"; let val = Services.prefs.getComplexValue( prefNameInsertSeparator, nsIPrefLocalizedString ).data; this.#insertSeparator = val == "true"; val = Services.prefs.getComplexValue( prefNameAlwaysAppendAccessKey, nsIPrefLocalizedString ).data; this.#alwaysAppendAccessKey = val == "true"; } catch (e) { this.#insertSeparator = this.#alwaysAppendAccessKey = true; } } } } connectedCallback() { this.#setStyles(); this.formatAccessKey(); } // Bug 1820588 - we may want to generalize this into // MozHTMLElement.insertCssIfNeeded(style) #setStyles() { let root = this.getRootNode(); let container = root.head ?? root; for (let link of container.querySelectorAll("link")) { if (link.getAttribute("href") == this.constructor.stylesheetUrl) { return; } } let style = document.createElement("link"); style.rel = "stylesheet"; style.href = this.constructor.stylesheetUrl; container.appendChild(style); } set textContent(val) { super.textContent = val; this.#lastFormattedAccessKey = null; this.formatAccessKey(); } get textContent() { return super.textContent; } attributeChangedCallback(attrName, oldValue, newValue) { if (oldValue == newValue) { return; } // Note that this is only happening when "accesskey" attribute changes. this.formatAccessKey(); } _onClick() { let controlElement = this.labeledControlElement; if (!controlElement || this.disabled) { return; } controlElement.focus(); if ( (controlElement.localName == "checkbox" || controlElement.localName == "radio") && controlElement.getAttribute("disabled") == "true" ) { return; } if (controlElement.localName == "checkbox") { controlElement.checked = !controlElement.checked; } else if (controlElement.localName == "radio") { controlElement.control.selectedItem = controlElement; } } set accessKey(val) { this.setAttribute("accesskey", val); let control = this.labeledControlElement; if (control) { control.setAttribute("accesskey", val); } } get accessKey() { let accessKey = this.getAttribute("accesskey"); return accessKey ? accessKey[0] : null; } get labeledControlElement() { let control = this.control; return control ? document.getElementById(control) : null; } set control(val) { this.setAttribute("control", val); } get control() { return this.getAttribute("control"); } // This is used to match the rendering of accesskeys from nsTextBoxFrame.cpp (i.e. when the // label uses [value]). So this is just for when we have textContent. formatAccessKey() { // Skip doing any DOM manipulation whenever possible: let accessKey = this.accessKey; if ( !MozTextLabel.#underlineAccesskey || this.#lastFormattedAccessKey == accessKey || !this.textContent || !this.textContent.trim() ) { return; } this.#lastFormattedAccessKey = accessKey; if (this.accessKeySpan) { // Clear old accesskey mergeElement(this.accessKeySpan); this.accessKeySpan = null; } if (this.hiddenColon) { mergeElement(this.hiddenColon); this.hiddenColon = null; } if (this.accessKeyParens) { this.accessKeyParens.remove(); this.accessKeyParens = null; } // If we used to have an accessKey but not anymore, we're done here if (!accessKey) { return; } let labelText = this.textContent; let accessKeyIndex = -1; if (!this.#alwaysAppendAccessKey) { accessKeyIndex = labelText.indexOf(accessKey); if (accessKeyIndex < 0) { // Try again in upper case accessKeyIndex = labelText .toUpperCase() .indexOf(accessKey.toUpperCase()); } } else if (labelText.endsWith(`(${accessKey.toUpperCase()})`)) { accessKeyIndex = labelText.length - (1 + accessKey.length); // = index of accessKey. } const HTML_NS = "http://www.w3.org/1999/xhtml"; this.accessKeySpan = document.createElementNS(HTML_NS, "span"); this.accessKeySpan.className = "accesskey"; // Note that if you change the following code, see the comment of // nsTextBoxFrame::UpdateAccessTitle. // If accesskey is in the string, underline it: if (accessKeyIndex >= 0) { wrapChar(this, this.accessKeySpan, accessKeyIndex); return; } // If accesskey is not in string, append in parentheses // If end is colon, we should insert before colon. // i.e., "label:" -> "label(X):" let colonHidden = false; if (/:$/.test(labelText)) { labelText = labelText.slice(0, -1); this.hiddenColon = document.createElementNS(HTML_NS, "span"); this.hiddenColon.className = "hiddenColon"; this.hiddenColon.style.display = "none"; // Hide the last colon by using span element. // I.e., label: wrapChar(this, this.hiddenColon, labelText.length); colonHidden = true; } // If end is space(U+20), // we should not add space before parentheses. let endIsSpace = false; if (/ $/.test(labelText)) { endIsSpace = true; } this.accessKeyParens = document.createElementNS( "http://www.w3.org/1999/xhtml", "span" ); this.appendChild(this.accessKeyParens); if (this.#insertSeparator && !endIsSpace) { this.accessKeyParens.textContent = " ("; } else { this.accessKeyParens.textContent = "("; } this.accessKeySpan.textContent = accessKey.toUpperCase(); this.accessKeyParens.appendChild(this.accessKeySpan); if (!colonHidden) { this.accessKeyParens.appendChild(document.createTextNode(")")); } else { this.accessKeyParens.appendChild(document.createTextNode("):")); } } } customElements.define("moz-label", MozTextLabel, { extends: "label" }); function mergeElement(element) { // If the element has been removed already, return: if (!element.isConnected) { return; } // `isInstance` isn't available to web content (i.e. Storybook) so we need to // fallback to using `instanceof`. if ( Text.hasOwnProperty("isInstance") ? Text.isInstance(element.previousSibling) : // eslint-disable-next-line mozilla/use-isInstance element.previousSibling instanceof Text ) { element.previousSibling.appendData(element.textContent); } else { element.parentNode.insertBefore(element.firstChild, element); } element.remove(); } function wrapChar(parentNode, element, index) { let treeWalker = document.createNodeIterator( parentNode, NodeFilter.SHOW_TEXT, null ); let node = treeWalker.nextNode(); while (index >= node.length) { index -= node.length; node = treeWalker.nextNode(); } if (index) { node = node.splitText(index); } node.parentNode.insertBefore(element, node); if (node.length > 1) { node.splitText(1); } element.appendChild(node); }