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`
${this.label} ${!this.description ? this.supportPageTemplate() : ""} diff --git a/toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs b/toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs index 642371008c5a..13885e3b951b 100644 --- a/toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs +++ b/toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs @@ -3,35 +3,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { html, ifDefined } from "../vendor/lit.all.mjs"; -import { MozLitElement, MozBaseInputElement } from "../lit-utils.mjs"; -// eslint-disable-next-line import/no-unassigned-import -import "chrome://global/content/elements/moz-label.mjs"; -// eslint-disable-next-line import/no-unassigned-import -import "chrome://global/content/elements/moz-fieldset.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, - }, -}; +import { + SelectControlBaseElement, + SelectControlItemMixin, +} from "../lit-select-control.mjs"; +import { MozBaseInputElement } from "../lit-utils.mjs"; /** * 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 support-link - The radio group's support link intended for moz-radio elements. */ -export class MozRadioGroup extends MozLitElement { - #radioButtons; - #value; - +export class MozRadioGroup extends SelectControlBaseElement { static childElementName = "moz-radio"; + static orientation = "vertical"; 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 }, }; - - 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` - - ${!this.supportPage - ? html`` - : ""} - - - `; - } } customElements.define("moz-radio-group", MozRadioGroup); @@ -260,104 +44,22 @@ customElements.define("moz-radio-group", MozRadioGroup); * @property {string} description - Description for the input. * @property {boolean} disabled - Whether or not the input is disabled. * @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} name * 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 {number} value - Value of the radio input. + * @property {string} value - Value of the radio input. */ -export class MozRadio extends MozBaseInputElement { - #controller; - - static properties = { - checked: { type: Boolean, reflect: true }, - inputTabIndex: { type: Number, state: true }, - }; - +export class MozRadio extends SelectControlItemMixin(MozBaseInputElement) { static activatedProperty = "checked"; - get controller() { - return this.#controller; - } - get isDisabled() { return ( - this.disabled || - this.#controller.disabled || - this.parentDisabled || - this.#controller.parentDisabled + super.isDisabled || 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() { return html`