diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/content/widgets/moz-label/README.stories.md | 20 | ||||
-rw-r--r-- | toolkit/content/widgets/moz-label/moz-label.css | 8 | ||||
-rw-r--r-- | toolkit/content/widgets/moz-label/moz-label.mjs | 301 | ||||
-rw-r--r-- | toolkit/content/widgets/moz-label/moz-label.stories.mjs | 86 |
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, +}; |