Bug 1942390 - Part 2: refactor common logic out of moz-radio-* elements r=reusable-components-reviewers,mstriemer
Differential Revision: https://phabricator.services.mozilla.com/D245620
This commit is contained in:
committed by
hjones@mozilla.com
parent
3b0be84c10
commit
d68a89bd79
@@ -159,6 +159,7 @@ toolkit.jar:
|
|||||||
content/global/elements/wizard.js (widgets/wizard.js)
|
content/global/elements/wizard.js (widgets/wizard.js)
|
||||||
content/global/vendor/lit.all.mjs (widgets/vendor/lit.all.mjs)
|
content/global/vendor/lit.all.mjs (widgets/vendor/lit.all.mjs)
|
||||||
content/global/lit-utils.mjs (widgets/lit-utils.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/aboutNetErrorCodes.js (neterror/aboutNetErrorCodes.js)
|
||||||
content/global/neterror/supportpages/connection-not-secure.html (neterror/supportpages/connection-not-secure.html)
|
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)
|
content/global/neterror/supportpages/time-errors.html (neterror/supportpages/time-errors.html)
|
||||||
|
|||||||
@@ -94,12 +94,12 @@
|
|||||||
function verifySelectedPickerItem(selectedItem, setsFocus) {
|
function verifySelectedPickerItem(selectedItem, setsFocus) {
|
||||||
ok(selectedItem.checked, "The selected picker item is checked.");
|
ok(selectedItem.checked, "The selected picker item is checked.");
|
||||||
is(
|
is(
|
||||||
selectedItem.inputEl.getAttribute("aria-checked"),
|
selectedItem.itemEl.getAttribute("aria-checked"),
|
||||||
"true",
|
"true",
|
||||||
"The checked item has the correct aria-checked value."
|
"The checked item has the correct aria-checked value."
|
||||||
);
|
);
|
||||||
is(
|
is(
|
||||||
selectedItem.inputEl.tabIndex,
|
selectedItem.itemEl.tabIndex,
|
||||||
0,
|
0,
|
||||||
"The selected picker item is focusable."
|
"The selected picker item is focusable."
|
||||||
);
|
);
|
||||||
@@ -115,12 +115,12 @@
|
|||||||
.forEach(item => {
|
.forEach(item => {
|
||||||
ok(!item.checked, "All other picker items are unchecked.");
|
ok(!item.checked, "All other picker items are unchecked.");
|
||||||
is(
|
is(
|
||||||
item.inputEl.getAttribute("aria-checked"),
|
item.itemEl.getAttribute("aria-checked"),
|
||||||
"false",
|
"false",
|
||||||
"All other items have the correct aria-checked value."
|
"All other items have the correct aria-checked value."
|
||||||
);
|
);
|
||||||
is(
|
is(
|
||||||
item.inputEl.tabIndex,
|
item.itemEl.tabIndex,
|
||||||
-1,
|
-1,
|
||||||
"All other items are not focusable."
|
"All other items are not focusable."
|
||||||
);
|
);
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
);
|
);
|
||||||
[secondItem, thirdItem].forEach(item =>
|
[secondItem, thirdItem].forEach(item =>
|
||||||
is(
|
is(
|
||||||
item.inputEl.getAttribute("tabindex"),
|
item.itemEl.getAttribute("tabindex"),
|
||||||
"-1",
|
"-1",
|
||||||
"All other items are not tab focusable."
|
"All other items are not tab focusable."
|
||||||
)
|
)
|
||||||
@@ -399,7 +399,7 @@
|
|||||||
picker.addEventListener("input", trackEvent);
|
picker.addEventListener("input", trackEvent);
|
||||||
|
|
||||||
// Verify that clicking on a item emits the right events in the correct order.
|
// Verify that clicking on a item emits the right events in the correct order.
|
||||||
synthesizeMouseAtCenter(thirdItem.inputEl, {});
|
synthesizeMouseAtCenter(thirdItem.itemEl, {});
|
||||||
await TestUtils.waitForTick();
|
await TestUtils.waitForTick();
|
||||||
|
|
||||||
verifyEvents([
|
verifyEvents([
|
||||||
|
|||||||
337
toolkit/content/widgets/lit-select-control.mjs
Normal file
337
toolkit/content/widgets/lit-select-control.mjs
Normal file
@@ -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`
|
||||||
|
<moz-fieldset
|
||||||
|
part="fieldset"
|
||||||
|
description=${ifDefined(this.description)}
|
||||||
|
support-page=${ifDefined(this.supportPage)}
|
||||||
|
role="radiogroup"
|
||||||
|
?disabled=${this.disabled}
|
||||||
|
label=${this.label}
|
||||||
|
exportparts="inputs, support-link"
|
||||||
|
aria-orientation=${ifDefined(this.constructor.orientation)}
|
||||||
|
>
|
||||||
|
${!this.supportPage
|
||||||
|
? html`<slot slot="support-link" name="support-link"></slot>`
|
||||||
|
: ""}
|
||||||
|
<slot
|
||||||
|
@slotchange=${this.handleSlotChange}
|
||||||
|
@change=${this.handleChange}
|
||||||
|
></slot>
|
||||||
|
</moz-fieldset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -36,6 +36,10 @@ legend {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-large);
|
gap: var(--space-large);
|
||||||
margin-top: var(--space-small);
|
margin-top: var(--space-small);
|
||||||
|
|
||||||
|
fieldset[aria-orientation="horizontal"] & {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a[is="moz-support-link"],
|
a[is="moz-support-link"],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default class MozFieldset extends MozLitElement {
|
|||||||
label: { type: String, fluent: true },
|
label: { type: String, fluent: true },
|
||||||
description: { type: String, fluent: true },
|
description: { type: String, fluent: true },
|
||||||
supportPage: { type: String, attribute: "support-page" },
|
supportPage: { type: String, attribute: "support-page" },
|
||||||
|
ariaOrientation: { type: String, mapped: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
descriptionTemplate() {
|
descriptionTemplate() {
|
||||||
@@ -29,6 +30,7 @@ export default class MozFieldset extends MozLitElement {
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
supportPageTemplate() {
|
supportPageTemplate() {
|
||||||
if (this.supportPage) {
|
if (this.supportPage) {
|
||||||
return html`<a
|
return html`<a
|
||||||
@@ -47,7 +49,10 @@ export default class MozFieldset extends MozLitElement {
|
|||||||
href="chrome://global/content/elements/moz-fieldset.css"
|
href="chrome://global/content/elements/moz-fieldset.css"
|
||||||
/>
|
/>
|
||||||
<fieldset
|
<fieldset
|
||||||
aria-describedby=${ifDefined(this.description ? "description" : null)}
|
aria-describedby=${ifDefined(
|
||||||
|
this.description ? "description" : undefined
|
||||||
|
)}
|
||||||
|
aria-orientation=${ifDefined(this.ariaOrientation)}
|
||||||
>
|
>
|
||||||
<legend part="label">${this.label}</legend>
|
<legend part="label">${this.label}</legend>
|
||||||
${!this.description ? this.supportPageTemplate() : ""}
|
${!this.description ? this.supportPageTemplate() : ""}
|
||||||
|
|||||||
@@ -3,35 +3,11 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import { html, ifDefined } from "../vendor/lit.all.mjs";
|
import { html, ifDefined } from "../vendor/lit.all.mjs";
|
||||||
import { MozLitElement, MozBaseInputElement } from "../lit-utils.mjs";
|
import {
|
||||||
// eslint-disable-next-line import/no-unassigned-import
|
SelectControlBaseElement,
|
||||||
import "chrome://global/content/elements/moz-label.mjs";
|
SelectControlItemMixin,
|
||||||
// eslint-disable-next-line import/no-unassigned-import
|
} from "../lit-select-control.mjs";
|
||||||
import "chrome://global/content/elements/moz-fieldset.mjs";
|
import { MozBaseInputElement } from "../lit-utils.mjs";
|
||||||
// eslint-disable-next-line import/no-unassigned-import
|
|
||||||
import "chrome://global/content/elements/moz-support-link.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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Element used to group and associate moz-radio buttons so that they function
|
* Element used to group and associate moz-radio buttons so that they function
|
||||||
@@ -50,205 +26,13 @@ const NAVIGATION_DIRECTIONS = {
|
|||||||
* @slot default - The radio group's content, intended for moz-radio elements.
|
* @slot default - The radio group's content, intended for moz-radio elements.
|
||||||
* @slot support-link - The radio group's support link intended for moz-radio elements.
|
* @slot support-link - The radio group's support link intended for moz-radio elements.
|
||||||
*/
|
*/
|
||||||
export class MozRadioGroup extends MozLitElement {
|
export class MozRadioGroup extends SelectControlBaseElement {
|
||||||
#radioButtons;
|
|
||||||
#value;
|
|
||||||
|
|
||||||
static childElementName = "moz-radio";
|
static childElementName = "moz-radio";
|
||||||
|
static orientation = "vertical";
|
||||||
|
|
||||||
static properties = {
|
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 },
|
|
||||||
parentDisabled: { type: Boolean, state: true },
|
parentDisabled: { type: Boolean, state: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
static queries = {
|
|
||||||
fieldset: "moz-fieldset",
|
|
||||||
};
|
|
||||||
|
|
||||||
set value(newValue) {
|
|
||||||
this.#value = newValue;
|
|
||||||
this.radioButtons.forEach(button => {
|
|
||||||
button.checked = this.value === button.value;
|
|
||||||
});
|
|
||||||
this.syncFocusState();
|
|
||||||
}
|
|
||||||
|
|
||||||
get value() {
|
|
||||||
return this.#value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get focusableIndex() {
|
|
||||||
if (this.#value) {
|
|
||||||
let selectedIndex = this.radioButtons.findIndex(
|
|
||||||
button => button.value === this.#value && !button.disabled
|
|
||||||
);
|
|
||||||
if (selectedIndex !== -1) {
|
|
||||||
return selectedIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.radioButtons.findIndex(button => !button.disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query for moz-radio elements the first time they are needed + ensure they
|
|
||||||
// have been upgraded so we can access properties.
|
|
||||||
get radioButtons() {
|
|
||||||
if (!this.#radioButtons) {
|
|
||||||
this.#radioButtons = (
|
|
||||||
this.shadowRoot
|
|
||||||
?.querySelector("slot:not([name])")
|
|
||||||
?.assignedElements() || [...this.children]
|
|
||||||
)?.filter(
|
|
||||||
el => el.localName === this.constructor.childElementName && !el.slot
|
|
||||||
);
|
|
||||||
this.#radioButtons.forEach(button => customElements.upgrade(button));
|
|
||||||
}
|
|
||||||
return this.#radioButtons;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.disabled = false;
|
|
||||||
this.addEventListener("keydown", e => this.handleKeydown(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
firstUpdated() {
|
|
||||||
this.syncStateToRadioButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUpdateComplete() {
|
|
||||||
await super.getUpdateComplete();
|
|
||||||
await Promise.all(this.radioButtons.map(button => button.updateComplete));
|
|
||||||
}
|
|
||||||
|
|
||||||
syncStateToRadioButtons() {
|
|
||||||
this.radioButtons.forEach(button => {
|
|
||||||
if (button.checked && this.value == undefined) {
|
|
||||||
this.value = button.value;
|
|
||||||
}
|
|
||||||
button.name = this.name;
|
|
||||||
});
|
|
||||||
this.syncFocusState();
|
|
||||||
}
|
|
||||||
|
|
||||||
syncFocusState() {
|
|
||||||
let focusableIndex = this.focusableIndex;
|
|
||||||
this.radioButtons.forEach((button, index) => {
|
|
||||||
button.inputTabIndex = focusableIndex === index ? 0 : -1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// NB: We may need to revise this to avoid bugs when we add more focusable
|
|
||||||
// elements to moz-radio-group / moz-radio.
|
|
||||||
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 indexStep = this.radioButtons.length + NAVIGATION_VALUE[direction];
|
|
||||||
|
|
||||||
for (let i = 1; i < this.radioButtons.length; i++) {
|
|
||||||
let nextIndex = (currentIndex + indexStep * i) % this.radioButtons.length;
|
|
||||||
let nextButton = this.radioButtons[nextIndex];
|
|
||||||
if (!nextButton.disabled) {
|
|
||||||
this.value = nextButton.value;
|
|
||||||
nextButton.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.radioButtons.forEach(button => {
|
|
||||||
button.requestUpdate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSetName() {
|
|
||||||
this.radioButtons.forEach(button => {
|
|
||||||
button.name = this.name;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-dispatch change event so it's re-targeted to moz-radio-group.
|
|
||||||
handleChange(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
this.dispatchEvent(new Event(event.type));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSlotChange() {
|
|
||||||
this.#radioButtons = null;
|
|
||||||
this.syncStateToRadioButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<moz-fieldset
|
|
||||||
part="fieldset"
|
|
||||||
description=${ifDefined(this.description)}
|
|
||||||
support-page=${ifDefined(this.supportPage)}
|
|
||||||
role="radiogroup"
|
|
||||||
?disabled=${this.disabled}
|
|
||||||
label=${this.label}
|
|
||||||
exportparts="inputs, support-link"
|
|
||||||
>
|
|
||||||
${!this.supportPage
|
|
||||||
? html`<slot slot="support-link" name="support-link"></slot>`
|
|
||||||
: ""}
|
|
||||||
<slot
|
|
||||||
@slotchange=${this.handleSlotChange}
|
|
||||||
@change=${this.handleChange}
|
|
||||||
></slot>
|
|
||||||
</moz-fieldset>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
customElements.define("moz-radio-group", MozRadioGroup);
|
customElements.define("moz-radio-group", MozRadioGroup);
|
||||||
|
|
||||||
@@ -260,104 +44,22 @@ customElements.define("moz-radio-group", MozRadioGroup);
|
|||||||
* @property {string} description - Description for the input.
|
* @property {string} description - Description for the input.
|
||||||
* @property {boolean} disabled - Whether or not the input is disabled.
|
* @property {boolean} disabled - Whether or not the input is disabled.
|
||||||
* @property {string} iconSrc - Path to an icon displayed next to the input.
|
* @property {string} iconSrc - Path to an icon displayed next to the input.
|
||||||
* @property {number} inputTabIndex - Tabindex of the input element.
|
* @property {number} itemTabIndex - Tabindex of the input element.
|
||||||
* @property {string} label - Label for the radio input.
|
* @property {string} label - Label for the radio input.
|
||||||
* @property {string} name
|
* @property {string} name
|
||||||
* Name of the input control, set by the associated moz-radio-group element.
|
* Name of the input control, set by the associated moz-radio-group element.
|
||||||
* @property {string} supportPage - Name of the SUMO support page to link to.
|
* @property {string} supportPage - Name of the SUMO support page to link to.
|
||||||
* @property {number} value - Value of the radio input.
|
* @property {string} value - Value of the radio input.
|
||||||
*/
|
*/
|
||||||
export class MozRadio extends MozBaseInputElement {
|
export class MozRadio extends SelectControlItemMixin(MozBaseInputElement) {
|
||||||
#controller;
|
|
||||||
|
|
||||||
static properties = {
|
|
||||||
checked: { type: Boolean, reflect: true },
|
|
||||||
inputTabIndex: { type: Number, state: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
static activatedProperty = "checked";
|
static activatedProperty = "checked";
|
||||||
|
|
||||||
get controller() {
|
|
||||||
return this.#controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isDisabled() {
|
get isDisabled() {
|
||||||
return (
|
return (
|
||||||
this.disabled ||
|
super.isDisabled || this.parentDisabled || this.controller.parentDisabled
|
||||||
this.#controller.disabled ||
|
|
||||||
this.parentDisabled ||
|
|
||||||
this.#controller.parentDisabled
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.checked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
let hostRadioGroup = this.parentElement || this.getRootNode().host;
|
|
||||||
if (!(hostRadioGroup instanceof MozRadioGroup)) {
|
|
||||||
console.error("moz-radio can only be used in moz-radio-group element.");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#controller = hostRadioGroup;
|
|
||||||
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 input is un-checked,
|
|
||||||
// the value of the associated moz-radio-group 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 radio button if containing radio-group is disabled.
|
|
||||||
if (this.disabled === false && this.#controller.disabled) {
|
|
||||||
this.disabled = true;
|
|
||||||
} else if (this.checked || !this.#controller.value) {
|
|
||||||
// Update buttons via moz-radio-group 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 moz-radio.
|
|
||||||
handleChange(e) {
|
|
||||||
this.dispatchEvent(new Event(e.type, e));
|
|
||||||
}
|
|
||||||
|
|
||||||
inputTemplate() {
|
inputTemplate() {
|
||||||
return html`<input
|
return html`<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -367,7 +69,7 @@ export class MozRadio extends MozBaseInputElement {
|
|||||||
.checked=${this.checked}
|
.checked=${this.checked}
|
||||||
aria-checked=${this.checked}
|
aria-checked=${this.checked}
|
||||||
aria-describedby="description"
|
aria-describedby="description"
|
||||||
tabindex=${this.inputTabIndex}
|
tabindex=${this.itemTabIndex}
|
||||||
?disabled=${this.isDisabled}
|
?disabled=${this.isDisabled}
|
||||||
accesskey=${ifDefined(this.accessKey)}
|
accesskey=${ifDefined(this.accessKey)}
|
||||||
@click=${this.handleClick}
|
@click=${this.handleClick}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
import { html } from "chrome://global/content/vendor/lit.all.mjs";
|
import { html } from "chrome://global/content/vendor/lit.all.mjs";
|
||||||
import {
|
import {
|
||||||
MozRadioGroup,
|
SelectControlItemMixin,
|
||||||
MozRadio,
|
SelectControlBaseElement,
|
||||||
} from "chrome://global/content/elements/moz-radio-group.mjs";
|
} from "../lit-select-control.mjs";
|
||||||
|
import { MozLitElement } from "../lit-utils.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An element that groups related items and allows a user to navigate between
|
* An element that groups related items and allows a user to navigate between
|
||||||
@@ -24,8 +25,9 @@ import {
|
|||||||
* state of moz-visual-picker-item children and vice versa.
|
* state of moz-visual-picker-item children and vice versa.
|
||||||
* @slot default - The picker's content, intended for moz-visual-picker-items.
|
* @slot default - The picker's content, intended for moz-visual-picker-items.
|
||||||
*/
|
*/
|
||||||
export class MozVisualPicker extends MozRadioGroup {
|
export class MozVisualPicker extends SelectControlBaseElement {
|
||||||
static childElementName = "moz-visual-picker-item";
|
static childElementName = "moz-visual-picker-item";
|
||||||
|
static orientation = "horizontal";
|
||||||
}
|
}
|
||||||
customElements.define("moz-visual-picker", MozVisualPicker);
|
customElements.define("moz-visual-picker", MozVisualPicker);
|
||||||
|
|
||||||
@@ -36,20 +38,28 @@ customElements.define("moz-visual-picker", MozVisualPicker);
|
|||||||
* @tagname moz-visual-picker-item
|
* @tagname moz-visual-picker-item
|
||||||
* @property {boolean} checked - Whether or not the item is selected.
|
* @property {boolean} checked - Whether or not the item is selected.
|
||||||
* @property {boolean} disabled - Whether or not the item is disabled.
|
* @property {boolean} disabled - Whether or not the item is disabled.
|
||||||
* @property {number} inputTabIndex
|
* @property {number} itemTabIndex
|
||||||
* Tabindex of the input element. Only one item is focusable at a time.
|
* Tabindex of the input element. Only one item is focusable at a time.
|
||||||
* @property {string} name
|
* @property {string} name
|
||||||
* Name of the item, set by the associated moz-visual-picker parent element.
|
* Name of the item, set by the associated moz-visual-picker parent element.
|
||||||
* @property {string} value - Value of the item.
|
* @property {string} value - Value of the item.
|
||||||
* @slot default - The item's content, used for what gets displayed.
|
* @slot default - The item's content, used for what gets displayed.
|
||||||
*/
|
*/
|
||||||
export class MozVisualPickerItem extends MozRadio {
|
export class MozVisualPickerItem extends SelectControlItemMixin(MozLitElement) {
|
||||||
static queries = {
|
static queries = {
|
||||||
itemEl: ".picker-item",
|
itemEl: ".picker-item",
|
||||||
};
|
};
|
||||||
|
|
||||||
get inputEl() {
|
click() {
|
||||||
return this.itemEl;
|
this.itemEl.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.itemEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
blur() {
|
||||||
|
this.itemEl.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeydown(event) {
|
handleKeydown(event) {
|
||||||
@@ -96,7 +106,7 @@ export class MozVisualPickerItem extends MozRadio {
|
|||||||
role="radio"
|
role="radio"
|
||||||
value=${this.value}
|
value=${this.value}
|
||||||
aria-checked=${this.checked}
|
aria-checked=${this.checked}
|
||||||
tabindex=${this.inputTabIndex}
|
tabindex=${this.itemTabIndex}
|
||||||
?checked=${this.checked}
|
?checked=${this.checked}
|
||||||
?disabled=${this.isDisabled}
|
?disabled=${this.isDisabled}
|
||||||
@click=${this.handleClick}
|
@click=${this.handleClick}
|
||||||
|
|||||||
Reference in New Issue
Block a user