/* 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 } from "../vendor/lit.all.mjs"; import { MozLitElement } from "../lit-utils.mjs"; // eslint-disable-next-line import/no-unassigned-import import "chrome://global/content/elements/moz-label.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 * as a single form-control element. * * @tagname moz-radio-group * @property {boolean} disabled - Whether or not the fieldset is disabled. * @property {string} label - Label for the group of moz-radio elements. * @property {string} name * Input name of the radio group. Propagates to moz-radio children. * @property {string} value * Selected value for the group. Changing the value updates the checked * state of moz-radio children and vice versa. * @slot default - The radio group's content, intended for moz-radio elements. */ export class MozRadioGroup extends MozLitElement { #radioButtons = []; #value; static properties = { disabled: { type: Boolean, reflect: true }, label: { type: String }, name: { type: String }, }; static queries = { defaultSlot: "slot:not([name])", fieldset: "fieldset", legend: "legend", }; 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) { return this.#radioButtons.findIndex(button => !button.disabled); } return this.#radioButtons.findIndex( button => button.value === this.#value && !button.disabled ); } constructor() { super(); this.disabled = false; this.addEventListener("keydown", e => this.handleKeydown(e)); } connectedCallback() { super.connectedCallback(); this.dataset.l10nAttrs = "label"; } firstUpdated() { this.syncStateToRadioButtons(); } async getUpdateComplete() { await super.getUpdateComplete(); await Promise.all(this.#radioButtons.map(button => button.updateComplete)); } syncStateToRadioButtons() { this.#radioButtons = this.defaultSlot ?.assignedElements() .filter(el => el.localName === "moz-radio"); 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; if (!this.#radioButtons[nextIndex].disabled) { this.#radioButtons[nextIndex].click(); 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)); } render() { return html`
${this.label}
`; } } customElements.define("moz-radio-group", MozRadioGroup); /** * Input element that allows a user to select one option from a group of options. * * @tagname moz-radio * @property {boolean} checked - Whether or not the input is selected. * @property {boolean} disabled - Whether or not the input is disabled. * @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 {number} inputTabIndex - Tabindex of the input element. * @property {number} value - Value of the radio input. */ export class MozRadio extends MozLitElement { #controller; static properties = { checked: { type: Boolean, reflect: true }, disabled: { type: Boolean, reflect: true }, iconSrc: { type: String }, label: { type: String }, name: { type: String, attribute: false }, inputTabIndex: { type: Number, state: true }, value: { type: String }, }; static queries = { radioButton: "#radio-button", labelEl: "label", icon: ".icon", }; constructor() { super(); this.checked = false; this.disabled = false; } connectedCallback() { super.connectedCallback(); this.dataset.l10nAttrs = "label"; 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; } 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() { this.#controller.value = this.value; this.focus(); } // Re-dispatch change event so it propagates out of moz-radio. handleChange(e) { this.dispatchEvent(new Event(e.type, e)); } // Delegate click to the input element. click() { this.radioButton.click(); this.focus(); } // Delegate focus to the input element. focus() { this.radioButton.focus(); } iconTemplate() { if (this.iconSrc) { return html``; } return ""; } render() { return html` `; } } customElements.define("moz-radio", MozRadio);