gecko-dev/browser/components/preferences/widgets/setting-control/setting-control.mjs
Mark Striemer eeff9ddade Bug 1965046 - Support arbitrary controls with config-based prefs r=settings-reviewers,reusable-components-reviewers,frontend-codestyle-reviewers,hjones,Gijs
When a control is provided in config-based prefs then an element of that
name will be created and passed a standard set of properties. If the
element supports the static activatedProperty getter then that property
will be set to the setting's value, otherwise the value property will be
set.

This additionally adds support for the getControlConfig() method when
registering a Setting with preferencesBindings.js. This method allows
for settings code to modify the static configuration to create more
dynamic controls.

Differential Revision: https://phabricator.services.mozilla.com/D253056
2025-07-03 16:15:22 +00:00

252 lines
7.2 KiB
JavaScript

/* 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 {
Directive,
createRef,
directive,
html,
ifDefined,
noChange,
nothing,
ref,
staticHtml,
unsafeStatic,
} from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
/**
* A Lit directive that applies all properties of an object to a DOM element.
*
* This directive interprets keys in the provided props object as follows:
* - Keys starting with `?` set or remove boolean attributes using `toggleAttribute`.
* - Keys starting with `.` set properties directly on the element.
* - Keys starting with `@` are currently not supported and will throw an error.
* - All other keys are applied as regular attributes using `setAttribute`.
*
* It avoids reapplying values that have not changed, but does not currently
* remove properties that were previously set and are no longer present in the new input.
*
* This directive is useful to "spread" an object of attributes/properties declaratively onto an
* element in a Lit template.
*/
class SpreadDirective extends Directive {
/**
* A record of previously applied properties to avoid redundant updates.
* @type {Record<string, unknown>}
*/
#prevProps = {};
/**
* Render nothing by default as all changes are made in update using DOM APIs
* on the element directly.
* @returns {typeof nothing}
*/
render() {
return nothing;
}
/**
* Apply props to the element using DOM APIs, updating only changed values.
* @param {AttributePart} part - The part of the template this directive is bound to.
* @param {[Record<string, unknown>]} propsArray - An array with a single object containing props to apply.
* @returns {typeof noChange} - Indicates to Lit that no re-render is needed.
*/
update(part, [props]) {
// TODO: This doesn't clear any values that were set in previous calls if
// they are no longer present.
// It isn't entirely clear to me (mstriemer) what we should do if a prop is
// removed, or if the prop has changed from say ?foo to foo. By not
// implementing the auto-clearing hopefully the consumer will do something
// that fits their use case.
/** @type {HTMLElement} */
let el = part.element;
for (let [key, value] of Object.entries(props)) {
// Skip if the value hasn't changed since the last update.
if (value === this.#prevProps[key]) {
continue;
}
// Update the element based on the property key matching Lit's templates:
// ?key -> el.toggleAttribute(key, value)
// .key -> el.key = value
// key -> el.setAttribute(key, value)
if (key.startsWith("?")) {
el.toggleAttribute(key.slice(1), Boolean(value));
} else if (key.startsWith(".")) {
el[key.slice(1)] = value;
} else if (key.startsWith("@")) {
throw new Error(
`Event listeners are not yet supported with spread (${key})`
);
} else {
el.setAttribute(key, String(value));
}
}
// Save current props for comparison in the next update.
this.#prevProps = props;
return noChange;
}
}
const spread = directive(SpreadDirective);
/**
* @type Map<string, HTMLElement>
*/
const controlInstances = new Map();
function getControlInstance(control = "moz-checkbox") {
if (!controlInstances.has(control)) {
controlInstances.set(control, document.createElement(control));
}
return controlInstances.get(control);
}
export class SettingControl extends MozLitElement {
#lastSetting;
static properties = {
setting: { type: Object },
config: { type: Object },
value: {},
parentDisabled: { type: Boolean },
};
constructor() {
super();
this.controlRef = createRef();
}
createRenderRoot() {
return this;
}
get controlEl() {
return this.controlRef.value;
}
async getUpdateComplete() {
let result = await super.getUpdateComplete();
await this.controlEl.updateComplete;
return result;
}
willUpdate(changedProperties) {
if (changedProperties.has("setting")) {
if (this.#lastSetting) {
this.#lastSetting.off("change", this.setValue);
}
this.#lastSetting = this.setting;
this.setValue();
this.setting.on("change", this.setValue);
}
}
/**
* The default properties that a control accepts.
*/
getPropertyMapping(config) {
const props = {
id: config.id,
"data-l10n-id": config.l10nId,
".iconSrc": config.iconSrc,
".supportPage": config.supportPage,
".parentDisabled": this.parentDisabled,
".control": this,
"data-subcategory": config.subcategory,
"?disabled": this.setting.locked,
};
if (config.l10nArgs) {
props["data-l10n-args"] = JSON.stringify(config.l10nArgs);
}
// Set the value based on the control's API.
let instance = getControlInstance(config.control);
if ("checked" in instance) {
props[".checked"] = this.value;
} else if ("pressed" in instance) {
props[".pressed"] = this.value;
} else if ("value" in instance) {
props[".value"] = this.value;
}
return props;
}
getValue() {
return this.setting.value;
}
setValue = () => {
this.value = this.setting.value;
};
controlValue(el) {
if (el.constructor.activatedProperty) {
return el[el.constructor.activatedProperty];
}
return el.value;
}
// Called by our parent when our input changed.
onChange(el) {
this.setting.userChange(this.controlValue(el));
this.setValue();
}
render() {
// Allow the Setting to override the static config if necessary.
this.config = this.setting.getControlConfig(this.config);
let { config } = this;
// Prepare nested item config and settings.
let itemArgs =
config.items
?.map(i => ({
config: i,
setting: this.getSetting(i.id),
}))
.filter(i => i.setting.visible) || [];
let nestedSettings = itemArgs.map(
opts =>
html`<setting-control
.config=${opts.config}
.setting=${opts.setting}
.getSetting=${this.getSetting}
slot="nested"
></setting-control>`
);
// Get the properties for this element: id, fluent, disabled, etc.
// These will be applied to the control using the spread directive.
let controlProps = this.getPropertyMapping(config);
// Prepare any children that this element may need.
let controlChildren = nothing;
if (config.control == "moz-select") {
controlChildren = config.options.map(
opt =>
html`<moz-option
.value=${opt.value}
data-l10n-id=${opt.l10nId}
data-l10n-args=${ifDefined(
opt.l10nArgs && JSON.stringify(opt.l10nArgs)
)}
></moz-option>`
);
}
let tag = unsafeStatic(config.control || "moz-checkbox");
return staticHtml`<${tag}
${spread(controlProps)}
${ref(this.controlRef)}
>${controlChildren}${nestedSettings}</${tag}>`;
}
}
customElements.define("setting-control", SettingControl);