forked from mirrors/gecko-dev
Bug 1602095 - create an HTML version of xul:label textContent variation r=mstriemer,tgiles
This patch creates a custom element that extends the built-in label element to ad some custom styling and handling of accesskeys. This should align with the current functionality provided by the XUL label (which this code intetntionally copies and doesn't differ much from, since the XUL label is definitely battle tested).
More specifically, this patch:
* adds fallbacks/defaults for pref controlled values so this will mostly work as expected even in less privileged contexts (like about:logins, etc.)
* adds tests for the new label element
* creates a Storybook entry for the label element (note that activating the accesskey won't work as expected in Storybook, likely due to Bug 1819469)
We'll likely have to iterate on this a bit as we try to use it in all the places XUL label is currently used, but I think this is at a point where it can unblock the menu work happening in Bug 1801324.
Differential Revision: https://phabricator.services.mozilla.com/D171238
This commit is contained in:
parent
05098aa3e2
commit
540bf6fe91
7 changed files with 535 additions and 0 deletions
6
browser/locales-preview/moz-label.storybook.ftl
Normal file
6
browser/locales-preview/moz-label.storybook.ftl
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# 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/.
|
||||
|
||||
default-label = I love cheese 🧀
|
||||
label-with-colon = I love cheese 🧀:
|
||||
|
|
@ -96,6 +96,8 @@ toolkit.jar:
|
|||
content/global/elements/moz-button-group.css (widgets/moz-button-group/moz-button-group.css)
|
||||
content/global/elements/moz-button-group.mjs (widgets/moz-button-group/moz-button-group.mjs)
|
||||
content/global/elements/moz-input-box.js (widgets/moz-input-box.js)
|
||||
content/global/elements/moz-label.css (widgets/moz-label/moz-label.css)
|
||||
content/global/elements/moz-label.mjs (widgets/moz-label/moz-label.mjs)
|
||||
content/global/elements/moz-support-link.mjs (widgets/moz-support-link/moz-support-link.mjs)
|
||||
content/global/elements/moz-toggle.css (widgets/moz-toggle/moz-toggle.css)
|
||||
content/global/elements/moz-toggle.mjs (widgets/moz-toggle/moz-toggle.mjs)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ run-if = os == "mac" && os_version != "10.15" # Mac only feature, requires > 10.
|
|||
[test_menubar.xhtml]
|
||||
skip-if = os == 'mac'
|
||||
[test_moz_button_group.html]
|
||||
[test_moz_label.html]
|
||||
[test_moz_support_link.html]
|
||||
[test_moz_toggle.html]
|
||||
[test_panel_item_accesskey.html]
|
||||
|
|
|
|||
142
toolkit/content/tests/widgets/test_moz_label.html
Normal file
142
toolkit/content/tests/widgets/test_moz_label.html
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MozLabel tests</title>
|
||||
<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
|
||||
<link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
|
||||
<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
|
||||
<link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
|
||||
<script type="module" src="chrome://global/content/elements/moz-label.mjs"></script>
|
||||
</head>
|
||||
<body>
|
||||
<p id="display"></p>
|
||||
<div id="content">
|
||||
<label is="moz-label" for="checkbox" accesskey="c">For the checkbox:</label>
|
||||
<input type="checkbox" id="checkbox" />
|
||||
|
||||
<label is="moz-label" accesskey="n">
|
||||
For the nested checkbox:
|
||||
<input type="checkbox" />
|
||||
</label>
|
||||
|
||||
<label is="moz-label" for="radio" accesskey="r">For the radio:</label>
|
||||
<input type="radio" id="radio" />
|
||||
|
||||
<label is="moz-label" accesskey="F">
|
||||
For the nested radio:
|
||||
<input type="radio" />
|
||||
</label>
|
||||
|
||||
<label is="moz-label" for="button" accesskey="b">For the button:</label>
|
||||
<button id="button">Click me</button>
|
||||
|
||||
<label is="moz-label" accesskey="u">
|
||||
For the nested button:
|
||||
<button>Click me too</button>
|
||||
</label>
|
||||
</div>
|
||||
<pre id="test">
|
||||
<script class="testbody" type="application/javascript">
|
||||
let labels = document.querySelectorAll("label[is='moz-label']");
|
||||
let isMac = navigator.platform.includes("Mac");
|
||||
|
||||
function performAccessKey(key) {
|
||||
synthesizeKey(
|
||||
key,
|
||||
navigator.platform.includes("Mac")
|
||||
? { altKey: true, ctrlKey: true }
|
||||
: { altKey: true, shiftKey: true }
|
||||
);
|
||||
}
|
||||
|
||||
// Accesskey underlining is disabled by default on Mac.
|
||||
// Reload the window and wait for load to ensure pref is applied.
|
||||
add_setup(async function setup() {
|
||||
if (isMac && !SpecialPowers.getIntPref("ui.key.menuAccessKey")) {
|
||||
await SpecialPowers.pushPrefEnv(
|
||||
{ set: [["ui.key.menuAccessKey", 1]] },
|
||||
async () => {
|
||||
window.location.reload();
|
||||
await new Promise(resolve => {
|
||||
addEventListener("load", resolve, { once: true });
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
add_task(async function testAccesskeyUnderlined() {
|
||||
labels.forEach(label => {
|
||||
let accessKey = label.getAttribute("accesskey");
|
||||
let wrapper = label.querySelector(".accesskey");
|
||||
is(wrapper.textContent, accessKey, "The accesskey character is wrapped.")
|
||||
|
||||
let textDecoration = getComputedStyle(wrapper)["text-decoration"]
|
||||
ok(textDecoration.includes("underline"), "The accesskey character is underlined.")
|
||||
})
|
||||
});
|
||||
|
||||
add_task(async function testAccesskeyFocus() {
|
||||
labels.forEach(label => {
|
||||
let accessKey = label.getAttribute("accesskey");
|
||||
// Find the labelled element via the "for" attr if there's an ID
|
||||
// association, or select the lastElementChild for nested elements
|
||||
let element = document.getElementById(label.getAttribute("for")) || label.lastElementChild;
|
||||
|
||||
isnot(document.activeElement, element, "Focus is not on the associated element.");
|
||||
|
||||
performAccessKey(accessKey);
|
||||
|
||||
is(document.activeElement, element, "Focus moved to the associated element.")
|
||||
})
|
||||
});
|
||||
|
||||
add_task(async function testAccesskeyChange() {
|
||||
let label = labels[0];
|
||||
let nextAccesskey = "x";
|
||||
let originalAccesskey = label.getAttribute("accesskey");
|
||||
let getWrapper = () => label.querySelector(".accesskey");
|
||||
is(getWrapper().textContent, originalAccesskey, "Original accesskey character is wrapped.")
|
||||
|
||||
label.setAttribute("accesskey", nextAccesskey);
|
||||
is(getWrapper().textContent, nextAccesskey, "New accesskey character is wrapped.")
|
||||
|
||||
let elementId = label.getAttribute("for");
|
||||
let focusedEl = document.getElementById(elementId);
|
||||
|
||||
performAccessKey(originalAccesskey);
|
||||
isnot(document.activeElement.id, focusedEl.id, "Focus has not moved to the associated element.")
|
||||
|
||||
performAccessKey(nextAccesskey);
|
||||
is(document.activeElement.id, focusedEl.id, "Focus moved to the associated element.")
|
||||
});
|
||||
|
||||
add_task(async function testAccesskeyAppended() {
|
||||
let label = labels[0];
|
||||
let originalText = label.textContent;
|
||||
let accesskey = "z"; // Letter not included in the label text.
|
||||
label.setAttribute("accesskey", accesskey);
|
||||
|
||||
let expectedText = `${originalText} (Z):`;
|
||||
is(label.textContent, expectedText, "Access key is appended when not included in label text.")
|
||||
});
|
||||
|
||||
add_task(async function testLabelClick() {
|
||||
let label = labels[0];
|
||||
let input = document.getElementById(label.getAttribute("for"));
|
||||
is(input.checked, false, "The associated input is not checked.")
|
||||
|
||||
// Input state changes on label click.
|
||||
synthesizeMouseAtCenter(label, {});
|
||||
ok(input.checked, "The associated input is checked.")
|
||||
|
||||
// Input state doesn't change on label click when input is disabled.
|
||||
input.disabled = true;
|
||||
synthesizeMouseAtCenter(label, {});
|
||||
ok(input.checked, "The associated input is still checked.")
|
||||
});
|
||||
</script>
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
8
toolkit/content/widgets/moz-label/moz-label.css
Normal file
8
toolkit/content/widgets/moz-label/moz-label.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
301
toolkit/content/widgets/moz-label/moz-label.mjs
Normal file
301
toolkit/content/widgets/moz-label/moz-label.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
75
toolkit/content/widgets/moz-label/moz-label.stories.mjs
Normal file
75
toolkit/content/widgets/moz-label/moz-label.stories.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/* 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: "Design System/Experiments/MozLabel",
|
||||
component: "moz-label",
|
||||
argTypes: {
|
||||
inputType: {
|
||||
options: ["checkbox", "radio"],
|
||||
control: { type: "select" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
Loading…
Reference in a new issue