diff --git a/toolkit/content/jar.mn b/toolkit/content/jar.mn
index d2a70e7d3cf7..f94b88f8d8cf 100644
--- a/toolkit/content/jar.mn
+++ b/toolkit/content/jar.mn
@@ -159,6 +159,7 @@ toolkit.jar:
content/global/elements/wizard.js (widgets/wizard.js)
content/global/vendor/lit.all.mjs (widgets/vendor/lit.all.mjs)
content/global/lit-utils.mjs (widgets/lit-utils.mjs)
+ content/global/lit-select-control.mjs (widgets/lit-select-control.mjs)
content/global/neterror/aboutNetErrorCodes.js (neterror/aboutNetErrorCodes.js)
content/global/neterror/supportpages/connection-not-secure.html (neterror/supportpages/connection-not-secure.html)
content/global/neterror/supportpages/time-errors.html (neterror/supportpages/time-errors.html)
diff --git a/toolkit/content/tests/widgets/test_moz_visual_picker.html b/toolkit/content/tests/widgets/test_moz_visual_picker.html
index c74d411441c9..0084f816ad2d 100644
--- a/toolkit/content/tests/widgets/test_moz_visual_picker.html
+++ b/toolkit/content/tests/widgets/test_moz_visual_picker.html
@@ -94,12 +94,12 @@
function verifySelectedPickerItem(selectedItem, setsFocus) {
ok(selectedItem.checked, "The selected picker item is checked.");
is(
- selectedItem.inputEl.getAttribute("aria-checked"),
+ selectedItem.itemEl.getAttribute("aria-checked"),
"true",
"The checked item has the correct aria-checked value."
);
is(
- selectedItem.inputEl.tabIndex,
+ selectedItem.itemEl.tabIndex,
0,
"The selected picker item is focusable."
);
@@ -115,12 +115,12 @@
.forEach(item => {
ok(!item.checked, "All other picker items are unchecked.");
is(
- item.inputEl.getAttribute("aria-checked"),
+ item.itemEl.getAttribute("aria-checked"),
"false",
"All other items have the correct aria-checked value."
);
is(
- item.inputEl.tabIndex,
+ item.itemEl.tabIndex,
-1,
"All other items are not focusable."
);
@@ -316,7 +316,7 @@
);
[secondItem, thirdItem].forEach(item =>
is(
- item.inputEl.getAttribute("tabindex"),
+ item.itemEl.getAttribute("tabindex"),
"-1",
"All other items are not tab focusable."
)
@@ -399,7 +399,7 @@
picker.addEventListener("input", trackEvent);
// Verify that clicking on a item emits the right events in the correct order.
- synthesizeMouseAtCenter(thirdItem.inputEl, {});
+ synthesizeMouseAtCenter(thirdItem.itemEl, {});
await TestUtils.waitForTick();
verifyEvents([
diff --git a/toolkit/content/widgets/lit-select-control.mjs b/toolkit/content/widgets/lit-select-control.mjs
new file mode 100644
index 000000000000..a8202de96f97
--- /dev/null
+++ b/toolkit/content/widgets/lit-select-control.mjs
@@ -0,0 +1,337 @@
+/* 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";
+import { MozLitElement } from "./lit-utils.mjs";
+
+const NAVIGATION_FORWARD = "forward";
+const NAVIGATION_BACKWARD = "backward";
+
+const NAVIGATION_VALUE = {
+ [NAVIGATION_FORWARD]: 1,
+ [NAVIGATION_BACKWARD]: -1,
+};
+
+const DIRECTION_RIGHT = "Right";
+const DIRECTION_LEFT = "Left";
+
+const NAVIGATION_DIRECTIONS = {
+ LTR: {
+ FORWARD: DIRECTION_RIGHT,
+ BACKWARD: DIRECTION_LEFT,
+ },
+ RTL: {
+ FORWARD: DIRECTION_LEFT,
+ BACKWARD: DIRECTION_RIGHT,
+ },
+};
+
+/**
+ * Class that can be extended to handle managing the selected and focus states
+ * of child elements using a roving tabindex. For more information on this focus
+ * management pattern, see:
+ * https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
+ *
+ * Child elements must use SelectControlItemMixin for behavior to work as
+ * expected.
+ */
+export class SelectControlBaseElement extends MozLitElement {
+ #childElements;
+ #value;
+
+ static properties = {
+ disabled: { type: Boolean, reflect: true },
+ description: { type: String, fluent: true },
+ supportPage: { type: String, attribute: "support-page" },
+ label: { type: String, fluent: true },
+ name: { type: String },
+ value: { type: String },
+ };
+
+ static queries = {
+ fieldset: "moz-fieldset",
+ };
+
+ set value(newValue) {
+ this.#value = newValue;
+ this.childElements.forEach(item => {
+ item.checked = this.value === item.value;
+ });
+ this.syncFocusState();
+ }
+
+ get value() {
+ return this.#value;
+ }
+
+ get focusableIndex() {
+ if (this.#value) {
+ let selectedIndex = this.childElements.findIndex(
+ item => item.value === this.#value && !item.disabled
+ );
+ if (selectedIndex !== -1) {
+ return selectedIndex;
+ }
+ }
+ return this.childElements.findIndex(item => !item.disabled);
+ }
+
+ // Query for child elements the first time they are needed + ensure they
+ // have been upgraded so we can access properties.
+ get childElements() {
+ if (!this.#childElements) {
+ this.#childElements = (
+ this.shadowRoot
+ ?.querySelector("slot:not([name])")
+ ?.assignedElements() || [...this.children]
+ )?.filter(
+ el => el.localName === this.constructor.childElementName && !el.slot
+ );
+ this.#childElements.forEach(item => customElements.upgrade(item));
+ }
+ return this.#childElements;
+ }
+
+ constructor() {
+ super();
+ this.disabled = false;
+ this.addEventListener("keydown", e => this.handleKeydown(e));
+ }
+
+ firstUpdated() {
+ this.syncStateToChildElements();
+ }
+
+ async getUpdateComplete() {
+ await super.getUpdateComplete();
+ await Promise.all(this.childElements.map(item => item.updateComplete));
+ }
+
+ syncStateToChildElements() {
+ this.childElements.forEach(item => {
+ if (item.checked && this.value == undefined) {
+ this.value = item.value;
+ }
+ item.name = this.name;
+ });
+ this.syncFocusState();
+ }
+
+ syncFocusState() {
+ let focusableIndex = this.focusableIndex;
+ this.childElements.forEach((item, index) => {
+ item.itemTabIndex = focusableIndex === index ? 0 : -1;
+ });
+ }
+
+ // NB: We may need to revise this to avoid bugs when we add more focusable
+ // elements to select control base/items.
+ handleKeydown(event) {
+ let directions = this.getNavigationDirections();
+ switch (event.key) {
+ case "Down":
+ case "ArrowDown":
+ case directions.FORWARD:
+ case `Arrow${directions.FORWARD}`: {
+ event.preventDefault();
+ this.navigate(NAVIGATION_FORWARD);
+ break;
+ }
+ case "Up":
+ case "ArrowUp":
+ case directions.BACKWARD:
+ case `Arrow${directions.BACKWARD}`: {
+ event.preventDefault();
+ this.navigate(NAVIGATION_BACKWARD);
+ break;
+ }
+ }
+ }
+
+ getNavigationDirections() {
+ if (this.isDocumentRTL) {
+ return NAVIGATION_DIRECTIONS.RTL;
+ }
+ return NAVIGATION_DIRECTIONS.LTR;
+ }
+
+ get isDocumentRTL() {
+ if (typeof Services !== "undefined") {
+ return Services.locale.isAppLocaleRTL;
+ }
+ return document.dir === "rtl";
+ }
+
+ navigate(direction) {
+ let currentIndex = this.focusableIndex;
+ let children = this.childElements;
+ let indexStep = children.length + NAVIGATION_VALUE[direction];
+
+ for (let i = 1; i < children.length; i++) {
+ let nextIndex = (currentIndex + indexStep * i) % children.length;
+ let nextItem = children[nextIndex];
+ if (!nextItem.disabled) {
+ this.value = nextItem.value;
+ nextItem.focus();
+ this.dispatchEvent(new Event("input"), {
+ bubbles: true,
+ composed: true,
+ });
+ this.dispatchEvent(new Event("change"), { bubbles: true });
+ return;
+ }
+ }
+ }
+
+ willUpdate(changedProperties) {
+ if (changedProperties.has("name")) {
+ this.handleSetName();
+ }
+ if (changedProperties.has("disabled")) {
+ this.childElements.forEach(item => {
+ item.requestUpdate();
+ });
+ }
+ }
+
+ handleSetName() {
+ this.childElements.forEach(item => {
+ item.name = this.name;
+ });
+ }
+
+ // Re-dispatch change event so it's re-targeted to the custom element.
+ handleChange(event) {
+ event.stopPropagation();
+ this.dispatchEvent(new Event(event.type));
+ }
+
+ handleSlotChange() {
+ this.#childElements = null;
+ this.syncStateToChildElements();
+ }
+
+ render() {
+ return html`
+
+ ${!this.supportPage
+ ? html``
+ : ""}
+
+
+ `;
+ }
+}
+
+/**
+ * Class that can be extended by items nested in a subclass of
+ * SelectControlBaseElement to handle selection, focus management, and keyboard
+ * navigation. Implemented as a mixin to enable use with elements that inherit
+ * from something other than MozLitElement.
+ *
+ * @param {LitElement} superClass
+ * @returns LitElement
+ */
+export const SelectControlItemMixin = superClass =>
+ class extends superClass {
+ #controller;
+
+ static properties = {
+ name: { type: String },
+ value: { type: String },
+ disabled: { type: Boolean, reflect: true },
+ checked: { type: Boolean, reflect: true },
+ itemTabIndex: { type: Number, state: true },
+ };
+
+ get controller() {
+ return this.#controller;
+ }
+
+ get isDisabled() {
+ return this.disabled || this.#controller.disabled;
+ }
+
+ constructor() {
+ super();
+ this.checked = false;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+
+ let hostElement = this.parentElement || this.getRootNode().host;
+ if (!(hostElement instanceof SelectControlBaseElement)) {
+ console.error(
+ `${this.localName} should only be used in an element that extends SelectControlBaseElement.`
+ );
+ }
+
+ this.#controller = hostElement;
+ if (this.#controller.value) {
+ this.checked = this.value === this.#controller.value;
+ }
+ }
+
+ willUpdate(changedProperties) {
+ super.willUpdate(changedProperties);
+ // Handle setting checked directly via JS.
+ if (
+ changedProperties.has("checked") &&
+ this.checked &&
+ this.#controller.value &&
+ this.value !== this.#controller.value
+ ) {
+ this.#controller.value = this.value;
+ }
+ // Handle un-checking directly via JS. If the checked item is un-checked,
+ // the value of the associated focus manager parent needs to be un-set.
+ if (
+ changedProperties.has("checked") &&
+ !this.checked &&
+ this.#controller.value &&
+ this.value === this.#controller.value
+ ) {
+ this.#controller.value = "";
+ }
+
+ if (changedProperties.has("disabled")) {
+ // Prevent enabling a items if containing focus manager is disabled.
+ if (this.disabled === false && this.#controller.disabled) {
+ this.disabled = true;
+ } else if (this.checked || !this.#controller.value) {
+ // Update items via focus manager parent for proper keyboard nav behavior.
+ this.#controller.syncFocusState();
+ }
+ }
+ }
+
+ handleClick() {
+ if (this.isDisabled || this.checked) {
+ return;
+ }
+
+ this.#controller.value = this.value;
+ if (this.getRootNode().activeElement?.localName == this.localName) {
+ this.focus();
+ }
+ }
+
+ // Re-dispatch change event so it propagates out of the element.
+ handleChange(e) {
+ this.dispatchEvent(new Event(e.type, e));
+ }
+ };
diff --git a/toolkit/content/widgets/moz-fieldset/moz-fieldset.css b/toolkit/content/widgets/moz-fieldset/moz-fieldset.css
index 0a2743a922a9..77d2c4516af5 100644
--- a/toolkit/content/widgets/moz-fieldset/moz-fieldset.css
+++ b/toolkit/content/widgets/moz-fieldset/moz-fieldset.css
@@ -36,6 +36,10 @@ legend {
flex-direction: column;
gap: var(--space-large);
margin-top: var(--space-small);
+
+ fieldset[aria-orientation="horizontal"] & {
+ flex-direction: row;
+ }
}
a[is="moz-support-link"],
diff --git a/toolkit/content/widgets/moz-fieldset/moz-fieldset.mjs b/toolkit/content/widgets/moz-fieldset/moz-fieldset.mjs
index d0a36fa5677f..0fd88e14c344 100644
--- a/toolkit/content/widgets/moz-fieldset/moz-fieldset.mjs
+++ b/toolkit/content/widgets/moz-fieldset/moz-fieldset.mjs
@@ -18,6 +18,7 @@ export default class MozFieldset extends MozLitElement {
label: { type: String, fluent: true },
description: { type: String, fluent: true },
supportPage: { type: String, attribute: "support-page" },
+ ariaOrientation: { type: String, mapped: true },
};
descriptionTemplate() {
@@ -29,6 +30,7 @@ export default class MozFieldset extends MozLitElement {
}
return "";
}
+
supportPageTemplate() {
if (this.supportPage) {
return html`