summaryrefslogtreecommitdiffstats
path: root/toolkit/content/widgets/moz-label/moz-label.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/content/widgets/moz-label/moz-label.mjs')
-rw-r--r--toolkit/content/widgets/moz-label/moz-label.mjs301
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);
+}