diff options
Diffstat (limited to 'toolkit/content/widgets/moz-label/moz-label.mjs')
-rw-r--r-- | toolkit/content/widgets/moz-label/moz-label.mjs | 301 |
1 files changed, 301 insertions, 0 deletions
diff --git a/toolkit/content/widgets/moz-label/moz-label.mjs b/toolkit/content/widgets/moz-label/moz-label.mjs new file mode 100644 index 0000000000..52f3a30fb2 --- /dev/null +++ b/toolkit/content/widgets/moz-label/moz-label.mjs @@ -0,0 +1,301 @@ +/* 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"]; + } + + // Use a relative URL in storybook to get faster reloads on style changes. + static stylesheetUrl = window.IS_STORYBOOK + ? "./moz-label/moz-label.css" + : "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(event) { + 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<span style="display:none;">:</span> + 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); +} |