summaryrefslogtreecommitdiffstats
path: root/toolkit/content/widgets/moz-label
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/content/widgets/moz-label')
-rw-r--r--toolkit/content/widgets/moz-label/README.stories.md20
-rw-r--r--toolkit/content/widgets/moz-label/moz-label.css8
-rw-r--r--toolkit/content/widgets/moz-label/moz-label.mjs301
-rw-r--r--toolkit/content/widgets/moz-label/moz-label.stories.mjs86
4 files changed, 415 insertions, 0 deletions
diff --git a/toolkit/content/widgets/moz-label/README.stories.md b/toolkit/content/widgets/moz-label/README.stories.md
new file mode 100644
index 0000000000..a3492ebefa
--- /dev/null
+++ b/toolkit/content/widgets/moz-label/README.stories.md
@@ -0,0 +1,20 @@
+# MozLabel
+
+`moz-label` is an extension of the built-in `HTMLLabelElement` that provides accesskey styling and formatting as well as some click handling logic.
+
+```html story
+<label is="moz-label" accesskey="c" for="check">
+ This is a label with an accesskey:
+</label>
+<input id="check" type="checkbox" defaultChecked />
+```
+
+Accesskey underlining is enabled by default on Windows and Linux. It is also enabled in Storybook on Mac for demonstrative purposes, but is usually controlled by the `ui.key.menuAccessKey` preference.
+
+## Component status
+
+At this time `moz-label` may not be suitable for general use in Firefox.
+
+`moz-label` is currently only used in the `moz-toggle` custom element. There are no instances in Firefox where we set an accesskey on a toggle, so it is still largely untested in the wild.
+
+Additionally there is at least [one outstanding bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1819469) related to accesskey handling in the shadow DOM.
diff --git a/toolkit/content/widgets/moz-label/moz-label.css b/toolkit/content/widgets/moz-label/moz-label.css
new file mode 100644
index 0000000000..8e0576075a
--- /dev/null
+++ b/toolkit/content/widgets/moz-label/moz-label.css
@@ -0,0 +1,8 @@
+/* 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/. */
+
+label span.accesskey {
+ text-decoration: underline;
+ text-decoration-skip-ink: none;
+}
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);
+}
diff --git a/toolkit/content/widgets/moz-label/moz-label.stories.mjs b/toolkit/content/widgets/moz-label/moz-label.stories.mjs
new file mode 100644
index 0000000000..f954d4fe3a
--- /dev/null
+++ b/toolkit/content/widgets/moz-label/moz-label.stories.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/. */
+
+import { html, ifDefined } from "../vendor/lit.all.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "./moz-label.mjs";
+
+MozXULElement.insertFTLIfNeeded("locales-preview/moz-label.storybook.ftl");
+
+export default {
+ title: "UI Widgets/Label",
+ component: "moz-label",
+ argTypes: {
+ inputType: {
+ options: ["checkbox", "radio"],
+ control: { type: "select" },
+ },
+ },
+ parameters: {
+ status: {
+ type: "unstable",
+ links: [
+ {
+ title: "Learn more",
+ href: "?path=/docs/ui-widgets-label-readme--page#component-status",
+ },
+ ],
+ },
+ },
+};
+
+const Template = ({
+ accesskey,
+ inputType,
+ disabled,
+ "data-l10n-id": dataL10nId,
+}) => html`
+ <style>
+ div {
+ display: flex;
+ align-items: center;
+ }
+
+ label {
+ margin-inline-end: 8px;
+ }
+ </style>
+ <div>
+ <label
+ is="moz-label"
+ accesskey=${ifDefined(accesskey)}
+ data-l10n-id=${ifDefined(dataL10nId)}
+ for="cheese"
+ >
+ </label>
+ <input
+ type=${inputType}
+ name="cheese"
+ id="cheese"
+ ?disabled=${disabled}
+ checked
+ />
+ </div>
+`;
+
+export const AccessKey = Template.bind({});
+AccessKey.args = {
+ accesskey: "c",
+ inputType: "checkbox",
+ disabled: false,
+ "data-l10n-id": "default-label",
+};
+
+export const AccessKeyNotInLabel = Template.bind({});
+AccessKeyNotInLabel.args = {
+ ...AccessKey.args,
+ accesskey: "x",
+ "data-l10n-id": "label-with-colon",
+};
+
+export const DisabledCheckbox = Template.bind({});
+DisabledCheckbox.args = {
+ ...AccessKey.args,
+ disabled: true,
+};