Source code

Revision control

Copy as Markdown

Other Tools

/* 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,
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.
*/
/**
* @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<string, string>
*/
const ITEM_SLOT_BY_PARENT = new Map([
["moz-checkbox", "nested"],
["moz-input-text", "nested"],
["moz-input-search", "nested"],
["moz-input-folder", "nested"],
["moz-input-password", "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 },
showEnableExtensionMessage: { type: Boolean },
tabIndex: { type: Number, reflect: true },
};
/**
* @type {Setting | undefined}
*/
#lastSetting;
constructor() {
super();
/** @type {Ref<LitElement>} */
this.controlRef = 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;
}
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);
}
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
* @returns {ReturnType<SettingElement['getCommonPropertyMapping']>}
*/
getCommonPropertyMapping(config) {
return {
...super.getCommonPropertyMapping(config),
".setting": this.setting,
".control": this,
};
}
/**
* The default properties for an option.
*
* @param {SettingOptionConfig} config
*/
getOptionPropertyMapping(config) {
const props = this.getCommonPropertyMapping(config);
props[".value"] = config.value;
props[".disabled"] = config.disabled;
props[".hidden"] = config.hidden;
return props;
}
/**
* The default properties for this control.
*
* @param {SettingControlConfig} config
*/
getControlPropertyMapping(config) {
const props = this.getCommonPropertyMapping(config);
props[".parentDisabled"] = this.parentDisabled;
props["?disabled"] =
this.setting.disabled ||
this.setting.locked ||
this.isControlledByExtension();
return props;
}
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);
}
async disableExtension() {
await this.setting.disableControllingExtension();
this.showEnableExtensionMessage = true;
}
isControlledByExtension() {
return (
this.setting.controllingExtensionInfo?.id &&
this.setting.controllingExtensionInfo?.name
);
}
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/theme");
}
}
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 itemArgs.map(
item =>
html`<setting-control
.config=${item.config}
.setting=${item.setting}
.getSetting=${this.getSetting}
slot=${ifDefined(ITEM_SLOT_BY_PARENT.get(control))}
></setting-control>`
);
}
/**
* 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 config.options.map(opt => {
let optionTag = opt.control
? unsafeStatic(opt.control)
: KNOWN_OPTIONS.get(control);
let children =
"items" in opt ? this.itemsTemplate(opt) : this.optionsTemplate(opt);
return staticHtml`<${optionTag}
${spread(this.getOptionPropertyMapping(opt))}
>${children}</${optionTag}>`;
});
}
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`<moz-message-bar
class="extension-controlled-message-bar"
.messageL10nId=${this.extensionMessageId}
.messageL10nArgs=${args}
>
${supportPage
? html`<a
is="moz-support-link"
slot="support-link"
support-page=${supportPage}
></a>`
: ""}
<moz-button
slot="actions"
@click=${this.disableExtension}
data-l10n-id="disable-extension"
></moz-button>
</moz-message-bar>`;
} else if (this.showEnableExtensionMessage) {
messageBar = html`<moz-message-bar
class="reenable-extensions-message-bar"
dismissable=""
@message-bar:user-dismissed=${this.handleEnableExtensionDismiss}
>
<span
@click=${this.navigateToAddons}
slot="message"
data-l10n-id="extension-controlled-enable-2"
>
<a data-l10n-name="addons-link" href="#"></a>
</span>
</moz-message-bar>`;
}
return staticHtml`
${messageBar}
<${tag}
${spread(controlProps)}
${ref(this.controlRef)}
tabindex=${ifDefined(this.tabIndex)}
>${nestedSettings}</${tag}>`;
}
}
customElements.define("setting-control", SettingControl);