commit 6ae41c310c7dbd4887baea378f3e3511f749feee
parent 02833d069ed67760396a4b69c79c4b3e0cf6498c
Author: mark <mkennedy@mozilla.com>
Date: Wed, 5 Nov 2025 23:59:34 +0000
Bug 1994511 - Add support for nested control options on a config-based setting r=mstriemer
Differential Revision: https://phabricator.services.mozilla.com/D268894
Diffstat:
3 files changed, 226 insertions(+), 33 deletions(-)
diff --git a/browser/components/preferences/tests/chrome/test_setting_control_options.html b/browser/components/preferences/tests/chrome/test_setting_control_options.html
@@ -269,6 +269,145 @@
"Label was set from controlAttrs"
);
});
+
+ add_task(async function testNestedControlOptions() {
+ const PREF = "test.setting-control-custom-nested.one";
+ const SETTING = "setting-control-custom-nested-one";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF, 1]],
+ });
+ Preferences.addAll([{ id: PREF, type: "int" }]);
+ Preferences.addSetting({
+ id: SETTING,
+ pref: PREF,
+ });
+
+ let itemConfig = {
+ control: "moz-button-group",
+ id: SETTING,
+ options: [
+ {
+ control: "div",
+ options: [
+ {
+ control: "moz-checkbox",
+ },
+ ],
+ },
+ ],
+ };
+ let control = await renderTemplate(itemConfig);
+
+ is(
+ control.controlEl.localName,
+ "moz-button-group",
+ "The control rendered a moz-button-group as parent control"
+ );
+ const nestedControl = control.controlEl.children[0];
+
+ is(
+ nestedControl.localName,
+ "div",
+ "Rendered the nested div element control under the parent control"
+ );
+
+ const nestedControlOption = nestedControl.children[0];
+
+ is(
+ nestedControlOption.localName,
+ "moz-checkbox",
+ "Rendered the moz-checkbox option under the div element control"
+ );
+ });
+
+ add_task(async function testNestedControlOptionItems() {
+ const PREF = "test.setting-control-custom-nested-items.one";
+ const SETTING = "setting-control-custom-nested-items-one";
+
+ const FIRST_NESTED_ITEM_PREF = "test.settings-group.itemone";
+ const FIRST_NESTED_ITEM_SETTING = "setting-item-one";
+
+ const SECOND_NESTED_ITEM_PREF = "test.settings-group.itemtwo";
+ const SECOND_NESTED_ITEM_SETTING = "setting-item-two";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF, 1],
+ [FIRST_NESTED_ITEM_PREF, true],
+ [SECOND_NESTED_ITEM_PREF, false],
+ ],
+ });
+ Preferences.addAll([
+ { id: PREF, type: "int" },
+ { id: FIRST_NESTED_ITEM_PREF, type: "bool" },
+ { id: SECOND_NESTED_ITEM_PREF, type: "bool" },
+ ]);
+ Preferences.addSetting({
+ id: SETTING,
+ pref: PREF,
+ });
+
+ Preferences.addSetting({
+ id: FIRST_NESTED_ITEM_SETTING,
+ pref: FIRST_NESTED_ITEM_PREF,
+ });
+ Preferences.addSetting({
+ id: SECOND_NESTED_ITEM_SETTING,
+ pref: SECOND_NESTED_ITEM_PREF,
+ });
+
+ let itemConfig = {
+ control: "moz-radio-group",
+ l10nId: LABEL_L10N_ID,
+ id: SETTING,
+ options: [
+ {
+ control: "moz-radio",
+ l10nId: LABEL_L10N_ID,
+ items: [
+ {
+ l10nId: LABEL_L10N_ID,
+ id: FIRST_NESTED_ITEM_SETTING,
+ control: "moz-box-button",
+ },
+ ],
+ },
+ ],
+ };
+ let control = await renderTemplate(itemConfig);
+
+ is(
+ control.controlEl.localName,
+ "moz-radio-group",
+ "The control rendered a moz-radio-group as parent control"
+ );
+ const nestedOptionControl = control.controlEl.children[0];
+
+ is(
+ nestedOptionControl.localName,
+ "moz-radio",
+ "Rendered moz-radio as the nested control option"
+ );
+
+ const nestedControlItems = nestedOptionControl.children;
+ is(nestedControlItems.length, 1, "Rendered nested control item");
+ const [firstNestedControlItem] = nestedControlItems;
+ is(
+ firstNestedControlItem.localName,
+ "setting-control",
+ "Rendered the first nested setting-control item"
+ );
+ is(
+ firstNestedControlItem.controlEl.id,
+ FIRST_NESTED_ITEM_SETTING,
+ "Rendered the first nested setting ID"
+ );
+ is(
+ firstNestedControlItem.controlEl.localName,
+ "moz-box-button",
+ "Rendered moz-box-button as the first nested control item"
+ );
+ });
</script>
</head>
<body>
diff --git a/browser/components/preferences/widgets/setting-control/setting-control.mjs b/browser/components/preferences/widgets/setting-control/setting-control.mjs
@@ -7,7 +7,6 @@ import {
html,
ifDefined,
literal,
- nothing,
ref,
staticHtml,
unsafeStatic,
@@ -19,6 +18,15 @@ import {
/** @import MozCheckbox from "../../../../../toolkit/content/widgets/moz-checkbox/moz-checkbox.mjs"*/
/** @import { Setting } from "chrome://global/content/preferences/Setting.mjs"; */
+/** @import { PreferencesSettingConfigNestedControlOption } from "chrome://global/content/preferences/Preferences.mjs"; */
+
+/**
+ * Properties that represent a nested HTML element that will be a direct descendant of this setting control element
+ * @typedef {object} SettingNestedElementOption
+ * @property {Array<SettingNestedElementOption>} [options]
+ * @property {string} control - The {@link HTMLElement#localName} of any HTML element
+ * @property {Record<string, string>} [controlAttrs] - Attributes for the element
+ */
/**
* Mapping of parent control tag names to the literal tag name for their
@@ -43,6 +51,7 @@ const ITEM_SLOT_BY_PARENT = new Map([
["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"],
@@ -190,6 +199,7 @@ export class SettingControl extends SettingElement {
/**
* The default properties for an option.
+ * @param {PreferencesSettingConfigNestedControlOption | SettingNestedElementOption} config
*/
getOptionPropertyMapping(config) {
const props = this.getCommonPropertyMapping(config);
@@ -273,40 +283,62 @@ export class SettingControl extends SettingElement {
return this.setting.controllingExtensionInfo.l10nId;
}
+ /**
+ * Prepare nested item config and settings.
+ * @param {PreferencesSettingConfigNestedControlOption} config
+ * @returns {Array<string>}
+ */
+ 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 {PreferencesSettingConfigNestedControlOption | SettingNestedElementOption} config
+ * @returns {Array<string>}
+ */
+ 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);
+ return staticHtml`<${optionTag}
+ ${spread(this.getOptionPropertyMapping(opt))}
+ >${"items" in opt ? this.itemsTemplate(opt) : this.optionsTemplate(opt)}</${optionTag}>`;
+ });
+ }
+
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 getItemArgs = items =>
- items?.map(i => ({
- config: i,
- setting: this.getSetting(i.id),
- })) || [];
-
- // Prepare nested item config and settings.
- let itemArgs = getItemArgs(config.items);
- let itemTemplate = opts =>
- html`<setting-control
- .config=${opts.config}
- .setting=${opts.setting}
- .getSetting=${this.getSetting}
- slot=${ifDefined(ITEM_SLOT_BY_PARENT.get(control))}
- ></setting-control>`;
- let nestedSettings = itemArgs.map(itemTemplate);
-
- // Prepare any children that this element may need.
- let controlChildren = nothing;
- if (config.options) {
- controlChildren = config.options.map(opt => {
- let optionTag = opt.control
- ? unsafeStatic(opt.control)
- : KNOWN_OPTIONS.get(control);
- return staticHtml`<${optionTag}
- ${spread(this.getOptionPropertyMapping(opt))}
- >${opt.items ? getItemArgs(opt.items).map(itemTemplate) : ""}</${optionTag}>`;
- });
- }
+
+ 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.
@@ -351,7 +383,7 @@ export class SettingControl extends SettingElement {
${spread(controlProps)}
${ref(this.controlRef)}
tabindex=${ifDefined(this.tabIndex)}
- >${controlChildren}${nestedSettings}</${tag}>`;
+ >${nestedSettings}</${tag}>`;
}
}
customElements.define("setting-control", SettingControl);
diff --git a/toolkit/content/preferences/Preferences.mjs b/toolkit/content/preferences/Preferences.mjs
@@ -81,6 +81,27 @@ import { Setting } from "chrome://global/content/preferences/Setting.mjs";
*/
/**
+ * @typedef {Record<string, any>} PreferencesSettingConfigControlAttributes
+ */
+
+/**
+ * @typedef {Omit<PreferencesSettingConfigNestedControlOption, 'id | value'>} PreferencesSettingConfigNestedElementOption
+ */
+
+/**
+ * A set of properties that represent a nested control or element.
+ *
+ * @typedef {object} PreferencesSettingConfigNestedControlOption
+ * @property {string} [control] - The {@link HTMLElement#localName} of any HTML element that will be nested as a direct descendant of the control element. A moz-checkbox will be rendered by default.
+ * @property {PreferencesSettingConfigControlAttributes} [controlAttrs] - A map of any attributes to add to the control
+ * @property {string} [l10nId] - The fluent ID of the control
+ * @property {Array<PreferencesSettingConfigNestedElementOption>} [options] - Options for additional nested HTML elements. This will be overridden if items property is used.
+ * @property {string} [id]
+ * @property {string} [value] - An optional initial value used for the control element if it's an input element that supports a value property
+ * @property {Array<PreferencesSettingsConfig>} [items] - A list of setting control items that will get rendered as direct descendants of the setting control. This overrides the options property.
+ */
+
+/**
* @typedef {object} PreferencesSettingsConfig
* @property {string} id - The ID for the Setting, this should match the layout id
* @property {string} [l10nId] - The Fluent l10n ID for the setting
@@ -95,7 +116,7 @@ import { Setting } from "chrome://global/content/preferences/Setting.mjs";
* additional work that needs to happen, such as recording telemetry.
* If you want to set the value of the Setting then use the {@link PreferencesSettingsConfig.set} function.
* @property {Array<PreferencesSettingsConfig> | undefined} [items]
- * @property {string | undefined} [control]
+ * @property {PreferencesSettingConfigNestedControlOption['control']} [control] - The {@link HTMLElement#localName} of any HTML element that will be rendered as a control in the UI for the setting.
* @property {PreferencesSettingConfigSetupFunction} [setup] - A function to be called to register listeners for
* the setting. It should return a {@link PreferencesSettingConfigTeardownFunction} function to
* remove the listeners if necessary. This should emit change events when the setting has changed to
@@ -106,7 +127,8 @@ import { Setting } from "chrome://global/content/preferences/Setting.mjs";
* in {@link file://./../../browser/components/preferences/widgets/setting-group/setting-group.mjs}. This should be
* used for controls that aren’t regular form controls but instead perform an action when clicked, like a button or link.
* @property {Array<string> | void} [deps] - An array of setting IDs that this setting depends on, when these settings change this setting will emit a change event to update the UI
- * @property {Record<string, any>} [controlAttrs] - An object of additional attributes to be set on the control. These can be used to further customize the control for example a message bar of the warning type, or what dialog a button should open
+ * @property {PreferencesSettingConfigControlAttributes} [controlAttrs] - An object of additional attributes to be set on the control. These can be used to further customize the control for example a message bar of the warning type, or what dialog a button should open
+ * @property {Array<PreferencesSettingConfigNestedControlOption>} [options] - An optional list of nested controls for this setting (select options, radio group radios, etc)
* @property {string} [iconSrc] - A path to the icon for the control (if the control supports one)
* @property {string} [supportPage] - The SUMO support page slug for the setting
* @property {string} [subcategory] - The sub-category slug used for direct linking to a setting from SUMO