/* 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 { createRef, html, ifDefined, literal, ref, repeat, staticHtml, unsafeStatic, } from "chrome://global/content/vendor/lit.all.mjs"; import { SettingElement, spread, } from "chrome://browser/content/preferences/widgets/setting-element.mjs"; import MozInputFolder from "chrome://global/content/elements/moz-input-folder.mjs"; /** @import { LitElement, Ref, TemplateResult } from "chrome://global/content/vendor/lit.all.mjs" */ /** @import { SettingElementConfig } from "chrome://browser/content/preferences/widgets/setting-element.mjs" */ /** @import { Setting } from "chrome://global/content/preferences/Setting.mjs" */ /** * @typedef {object} SettingNestedConfig * @property {SettingControlConfig[]} [items] Additional nested SettingControls to render. * @property {SettingOptionConfig[]} [options] * Additional nested plain elements to render (may have SettingControls nested within them, though). */ /** * @typedef {object} SettingOptionConfigExtensions * @property {string} [control] * The element tag to render, default assumed based on parent control. * @property {any} [value] A value to set on the option. * @property {boolean} [disabled] If the option should be disabled. * @property {boolean} [hidden] If the option should be hidden. * @property {any} [key] A key to identify this option. */ /** * @typedef {object} SettingControlConfigExtensions * @property {string} id * The ID for the Setting, also set in the DOM unless overridden with controlAttrs.id * @property {string} [control] The element to render, default to "moz-checkbox". * @property {string} [controllingExtensionInfo] * ExtensionSettingStore id for checking if a setting is controlled by an extension. */ /** * @typedef {SettingOptionConfigExtensions & SettingElementConfig & SettingNestedConfig} SettingOptionConfig * @typedef {SettingControlConfigExtensions & SettingElementConfig & SettingNestedConfig} SettingControlConfig * @typedef {{ control: SettingControl } & HTMLElement} SettingControlChild */ /** * @template T=Event * @typedef {T & { target: SettingControlChild }} SettingControlEvent * SettingControlEvent simplifies the types in this file, but causes issues when * doing more involved work when used in Setting.mjs. When casting the * `event.target` to a more specific type like MozButton (or even * HTMLButtonElement) it gets flagged as being too different from SettingControlChild. */ /** * Mapping of parent control tag names to the literal tag name for their * expected children. eg. "moz-radio-group"->literal`moz-radio`. */ const KNOWN_OPTIONS = new Map([ ["moz-radio-group", literal`moz-radio`], ["moz-select", literal`moz-option`], ["moz-visual-picker", literal`moz-visual-picker-item`], ]); /** * Mapping of parent control tag names to the expected slot for their children. * If there's no entry here for a control then it's expected that its children * should go in the default slot. * * @type Map */ const ITEM_SLOT_BY_PARENT = new Map([ ["moz-checkbox", "nested"], ["moz-input-email", "nested"], ["moz-input-folder", "nested"], ["moz-input-number", "nested"], ["moz-input-password", "nested"], ["moz-input-search", "nested"], ["moz-input-tel", "nested"], ["moz-input-text", "nested"], ["moz-input-url", "nested"], ["moz-radio", "nested"], ["moz-radio-group", "nested"], // NOTE: moz-select does not support the nested slot. ["moz-toggle", "nested"], ]); export class SettingNotDefinedError extends Error { /** @param {string} settingId */ constructor(settingId) { super( `No Setting with id "${settingId}". Did you register it with Preferences.addSetting()?` ); this.name = "SettingNotDefinedError"; this.settingId = settingId; } } export class SettingControl extends SettingElement { static SettingNotDefinedError = SettingNotDefinedError; static properties = { setting: { type: Object }, config: { type: Object }, value: {}, parentDisabled: { type: Boolean }, tabIndex: { type: Number, reflect: true }, showEnableExtensionMessage: { type: Boolean, state: true }, isDisablingExtension: { type: Boolean, state: true }, }; /** * @type {Setting | undefined} */ #lastSetting; constructor() { super(); /** @type {Ref} */ this.controlRef = createRef(); /** @type {Ref} */ this.controlledMessageBarRef = createRef(); /** @type {Ref} */ this.enableMessageBarRef = createRef(); /** * @type {Preferences['getSetting'] | undefined} */ this.getSetting = undefined; /** * @type {Setting | undefined} */ this.setting = undefined; /** * @type {SettingControlConfig | undefined} */ this.config = undefined; /** * @type {boolean | undefined} */ this.parentDisabled = undefined; /** * @type {boolean} */ this.showEnableExtensionMessage = false; /** * @type {boolean} */ this.isDisablingExtension = false; } createRenderRoot() { return this; } focus() { this.controlEl.focus(); } get controlEl() { return this.controlRef.value; } async getUpdateComplete() { let result = await super.getUpdateComplete(); await this.controlEl?.updateComplete; return result; } onSettingChange = () => { this.setValue(); this.requestUpdate(); }; /** * @type {SettingElement['willUpdate']} */ willUpdate(changedProperties) { if (changedProperties.has("setting")) { if (this.#lastSetting) { this.#lastSetting.off("change", this.onSettingChange); } this.#lastSetting = this.setting; this.setValue(); this.setting.on("change", this.onSettingChange); } if (!this.setting) { throw new SettingNotDefinedError(this.config.id); } this.id = `setting-control-${this.config.id}`; let prevHidden = this.hidden; this.hidden = !this.setting.visible; if (prevHidden != this.hidden) { this.dispatchEvent(new Event("visibility-change", { bubbles: true })); } } updated() { const control = this.controlRef?.value; if (!control) { return; } // Set the value based on the control's API. if ("checked" in control) { control.checked = this.value; } else if ("pressed" in control) { control.pressed = this.value; } else if ("value" in control) { control.value = this.value; } control.requestUpdate?.(); } /** * The default properties that controls and options accept. * Note: for the disabled property, a setting can either be locked, * or controlled by an extension but not both. * * @override * @param {SettingElementConfig} config */ getCommonPropertyMapping(config) { return { ...super.getCommonPropertyMapping(config), ".setting": this.setting, ".control": this, }; } /** * The default properties for an option. * * @param {SettingOptionConfig} config */ getOptionPropertyMapping(config) { return { ...this.getCommonPropertyMapping(config), ".value": config.value, ".disabled": config.disabled, ".hidden": config.hidden, }; } /** * The default properties for this control. * * @param {SettingControlConfig} config */ getControlPropertyMapping(config) { return { ...this.getCommonPropertyMapping(config), ".parentDisabled": this.parentDisabled, "?disabled": this.setting.disabled || this.setting.locked || this.isDisabledByExtension(), // Hide moz-message-bar directly to maintain the role=alert functionality. // This setting-control will be visually hidden in CSS. ".hidden": config.control == "moz-message-bar" && this.hidden, }; } getValue() { return this.setting.value; } setValue = () => { this.value = this.setting.value; }; /** * @param {HTMLElement} el * @returns {any} */ controlValue(el) { let Cls = el.constructor; if ( "activatedProperty" in Cls && Cls.activatedProperty && el.localName != "moz-radio" ) { return el[/** @type {keyof typeof el} */ (Cls.activatedProperty)]; } if (el instanceof MozInputFolder) { return el.folder; } return "value" in el ? el.value : null; } /** * Called by our parent when our input changed. * * @param {SettingControlChild} el */ onChange(el) { this.setting.userChange(this.controlValue(el)); } /** * Called by our parent when our input is clicked. * * @param {MouseEvent} event */ onClick(event) { this.setting.userClick(event); } /** * Called by our parent when moz-message-bar is dismissed. * * @param {CustomEvent} event */ onMessageBarDismiss(event) { this.setting.messageBarDismiss(event); } /** * Called by our parent when items are reordered. The reorder event is * a CustomEvent that bubbles from reorderable moz-box-group elements when * items are reordered via drag-and-drop or keyboard shortcuts. * * The detail object of the reorder event contains the following properties: * * - `draggedElement`: The element that was reordered. * - `targetElement`: The element that the dragged element was reordered relative to. * - `position`: The position of the drop relative to the target element. -1 * means before, 0 means after. * - `draggedIndex`: The original index of the element being reordered. * - `targetIndex`: The new index of the draggedElement after reordering. * * @param {CustomEvent} event */ onReorder(event) { this.setting.userReorder(event); } async disableExtension() { this.isDisablingExtension = true; this.showEnableExtensionMessage = true; await this.setting.disableControllingExtension(); this.isDisablingExtension = false; } isControlledByExtension() { return ( this.setting.controllingExtensionInfo?.id && this.setting.controllingExtensionInfo?.name ); } isDisabledByExtension() { return ( this.isControlledByExtension() && !this.setting.controllingExtensionInfo.allowControl ); } handleEnableExtensionDismiss() { this.showEnableExtensionMessage = false; } /** * @param {MouseEvent} event */ navigateToAddons(event) { let link = /** @type {HTMLAnchorElement} */ (event.target); if (link.matches("a[data-l10n-name='addons-link']")) { event.preventDefault(); // @ts-ignore let mainWindow = window.browsingContext.topChromeWindow; mainWindow.BrowserAddonUI.openAddonsMgr("addons://list/extension"); } } get extensionName() { return this.setting.controllingExtensionInfo.name; } get extensionMessageId() { return this.setting.controllingExtensionInfo.l10nId; } /** * Prepare nested item config and settings. * * @param {SettingControlConfig | SettingOptionConfig} config * @returns {TemplateResult[]} */ itemsTemplate(config) { if (!config.items) { return []; } const itemArgs = config.items.map(i => ({ config: i, setting: this.getSetting(i.id), })); let control = config.control || "moz-checkbox"; return repeat( itemArgs, item => item.config.key || item.config.id, item => html`` ); } /** * Prepares any children (and any of its children's children) that this element may need. * * @param {SettingOptionConfig} config * @returns {TemplateResult[]} */ optionsTemplate(config) { if (!config.options) { return []; } let control = config.control || "moz-checkbox"; return repeat( config.options, opt => opt.key, opt => { let optionTag = opt.control ? unsafeStatic(opt.control) : KNOWN_OPTIONS.get(control); let spreadValues = spread(this.getOptionPropertyMapping(opt)); let children = "items" in opt ? this.itemsTemplate(opt) : this.optionsTemplate(opt); if (opt.control == "a" && opt.controlAttrs?.is == "moz-support-link") { // The `is` attribute must be set when the element is first added to the // DOM. We need to mark that up manually, since `spread()` uses // `el.setAttribute()` to set attributes it receives. return html`${children}`; } return staticHtml`<${optionTag} ${spreadValues}>${children}`; } ); } get extensionSupportPage() { return this.setting.controllingExtensionInfo.supportPage; } render() { // Allow the Setting to override the static config if necessary. this.config = this.setting.getControlConfig(this.config); let { config } = this; let control = config.control || "moz-checkbox"; let nestedSettings = "items" in config ? this.itemsTemplate(config) : this.optionsTemplate(config); // Get the properties for this element: id, fluent, disabled, etc. // These will be applied to the control using the spread directive. let controlProps = this.getControlPropertyMapping(config); let tag = unsafeStatic(control); let messageBar; // NOTE: the showEnableMessage message bar should ONLY appear when // there are no extensions controlling the setting. if (this.isControlledByExtension()) { let args = { name: this.extensionName }; let supportPage = this.extensionSupportPage; messageBar = html` ${supportPage ? html`` : ""} `; } else if (this.showEnableExtensionMessage) { messageBar = html` `; } return staticHtml` ${messageBar} <${tag} ${spread(controlProps)} ${ref(this.controlRef)} tabindex=${ifDefined(this.tabIndex)} >${nestedSettings}`; } } customElements.define("setting-control", SettingControl);