setting-control.mjs (13858B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { 6 createRef, 7 html, 8 ifDefined, 9 literal, 10 ref, 11 staticHtml, 12 unsafeStatic, 13 } from "chrome://global/content/vendor/lit.all.mjs"; 14 import { 15 SettingElement, 16 spread, 17 } from "chrome://browser/content/preferences/widgets/setting-element.mjs"; 18 import MozInputFolder from "chrome://global/content/elements/moz-input-folder.mjs"; 19 20 /** @import { LitElement, Ref, TemplateResult } from "chrome://global/content/vendor/lit.all.mjs" */ 21 /** @import { SettingElementConfig } from "chrome://browser/content/preferences/widgets/setting-element.mjs" */ 22 /** @import { Setting } from "chrome://global/content/preferences/Setting.mjs" */ 23 24 /** 25 * @typedef {object} SettingNestedConfig 26 * @property {SettingControlConfig[]} [items] Additional nested SettingControls to render. 27 * @property {SettingOptionConfig[]} [options] 28 * Additional nested plain elements to render (may have SettingControls nested within them, though). 29 */ 30 31 /** 32 * @typedef {object} SettingOptionConfigExtensions 33 * @property {string} [control] 34 * The element tag to render, default assumed based on parent control. 35 * @property {any} [value] A value to set on the option. 36 * @property {boolean} [disabled] If the option should be disabled. 37 * @property {boolean} [hidden] If the option should be hidden. 38 */ 39 40 /** 41 * @typedef {object} SettingControlConfigExtensions 42 * @property {string} id 43 * The ID for the Setting, also set in the DOM unless overridden with controlAttrs.id 44 * @property {string} [control] The element to render, default to "moz-checkbox". 45 * @property {string} [controllingExtensionInfo] 46 * ExtensionSettingStore id for checking if a setting is controlled by an extension. 47 */ 48 49 /** 50 * @typedef {SettingOptionConfigExtensions & SettingElementConfig & SettingNestedConfig} SettingOptionConfig 51 * @typedef {SettingControlConfigExtensions & SettingElementConfig & SettingNestedConfig} SettingControlConfig 52 * @typedef {{ control: SettingControl } & HTMLElement} SettingControlChild 53 */ 54 55 /** 56 * @template T=Event 57 * @typedef {T & { target: SettingControlChild }} SettingControlEvent 58 * SettingControlEvent simplifies the types in this file, but causes issues when 59 * doing more involved work when used in Setting.mjs. When casting the 60 * `event.target` to a more specific type like MozButton (or even 61 * HTMLButtonElement) it gets flagged as being too different from SettingControlChild. 62 */ 63 64 /** 65 * Mapping of parent control tag names to the literal tag name for their 66 * expected children. eg. "moz-radio-group"->literal`moz-radio`. 67 */ 68 const KNOWN_OPTIONS = new Map([ 69 ["moz-radio-group", literal`moz-radio`], 70 ["moz-select", literal`moz-option`], 71 ["moz-visual-picker", literal`moz-visual-picker-item`], 72 ]); 73 74 /** 75 * Mapping of parent control tag names to the expected slot for their children. 76 * If there's no entry here for a control then it's expected that its children 77 * should go in the default slot. 78 * 79 * @type Map<string, string> 80 */ 81 const ITEM_SLOT_BY_PARENT = new Map([ 82 ["moz-checkbox", "nested"], 83 ["moz-input-email", "nested"], 84 ["moz-input-folder", "nested"], 85 ["moz-input-number", "nested"], 86 ["moz-input-password", "nested"], 87 ["moz-input-search", "nested"], 88 ["moz-input-tel", "nested"], 89 ["moz-input-text", "nested"], 90 ["moz-input-url", "nested"], 91 ["moz-radio", "nested"], 92 ["moz-radio-group", "nested"], 93 // NOTE: moz-select does not support the nested slot. 94 ["moz-toggle", "nested"], 95 ]); 96 97 export class SettingNotDefinedError extends Error { 98 /** @param {string} settingId */ 99 constructor(settingId) { 100 super( 101 `No Setting with id "${settingId}". Did you register it with Preferences.addSetting()?` 102 ); 103 this.name = "SettingNotDefinedError"; 104 this.settingId = settingId; 105 } 106 } 107 108 export class SettingControl extends SettingElement { 109 static SettingNotDefinedError = SettingNotDefinedError; 110 static properties = { 111 setting: { type: Object }, 112 config: { type: Object }, 113 value: {}, 114 parentDisabled: { type: Boolean }, 115 tabIndex: { type: Number, reflect: true }, 116 showEnableExtensionMessage: { type: Boolean, state: true }, 117 isDisablingExtension: { type: Boolean, state: true }, 118 }; 119 120 /** 121 * @type {Setting | undefined} 122 */ 123 #lastSetting; 124 125 constructor() { 126 super(); 127 /** @type {Ref<LitElement>} */ 128 this.controlRef = createRef(); 129 130 /** 131 * @type {Preferences['getSetting'] | undefined} 132 */ 133 this.getSetting = undefined; 134 135 /** 136 * @type {Setting | undefined} 137 */ 138 this.setting = undefined; 139 140 /** 141 * @type {SettingControlConfig | undefined} 142 */ 143 this.config = undefined; 144 145 /** 146 * @type {boolean | undefined} 147 */ 148 this.parentDisabled = undefined; 149 150 /** 151 * @type {boolean} 152 */ 153 this.showEnableExtensionMessage = false; 154 155 /** 156 * @type {boolean} 157 */ 158 this.isDisablingExtension = false; 159 } 160 161 createRenderRoot() { 162 return this; 163 } 164 165 focus() { 166 this.controlEl.focus(); 167 } 168 169 get controlEl() { 170 return this.controlRef.value; 171 } 172 173 async getUpdateComplete() { 174 let result = await super.getUpdateComplete(); 175 await this.controlEl?.updateComplete; 176 return result; 177 } 178 179 onSettingChange = () => { 180 this.setValue(); 181 this.requestUpdate(); 182 }; 183 184 /** 185 * @type {SettingElement['willUpdate']} 186 */ 187 willUpdate(changedProperties) { 188 if (changedProperties.has("setting")) { 189 if (this.#lastSetting) { 190 this.#lastSetting.off("change", this.onSettingChange); 191 } 192 this.#lastSetting = this.setting; 193 this.setValue(); 194 this.setting.on("change", this.onSettingChange); 195 } 196 if (!this.setting) { 197 throw new SettingNotDefinedError(this.config.id); 198 } 199 let prevHidden = this.hidden; 200 this.hidden = !this.setting.visible; 201 if (prevHidden != this.hidden) { 202 this.dispatchEvent(new Event("visibility-change", { bubbles: true })); 203 } 204 } 205 206 updated() { 207 const control = this.controlRef?.value; 208 if (!control) { 209 return; 210 } 211 212 // Set the value based on the control's API. 213 if ("checked" in control) { 214 control.checked = this.value; 215 } else if ("pressed" in control) { 216 control.pressed = this.value; 217 } else if ("value" in control) { 218 control.value = this.value; 219 } 220 221 control.requestUpdate?.(); 222 } 223 224 /** 225 * The default properties that controls and options accept. 226 * Note: for the disabled property, a setting can either be locked, 227 * or controlled by an extension but not both. 228 * 229 * @override 230 * @param {SettingElementConfig} config 231 */ 232 getCommonPropertyMapping(config) { 233 return { 234 ...super.getCommonPropertyMapping(config), 235 ".setting": this.setting, 236 ".control": this, 237 }; 238 } 239 240 /** 241 * The default properties for an option. 242 * 243 * @param {SettingOptionConfig} config 244 */ 245 getOptionPropertyMapping(config) { 246 return { 247 ...this.getCommonPropertyMapping(config), 248 ".value": config.value, 249 ".disabled": config.disabled, 250 ".hidden": config.hidden, 251 }; 252 } 253 254 /** 255 * The default properties for this control. 256 * 257 * @param {SettingControlConfig} config 258 */ 259 getControlPropertyMapping(config) { 260 return { 261 ...this.getCommonPropertyMapping(config), 262 ".parentDisabled": this.parentDisabled, 263 "?disabled": 264 this.setting.disabled || 265 this.setting.locked || 266 this.isControlledByExtension(), 267 // Hide moz-message-bar directly to maintain the role=alert functionality. 268 // This setting-control will be visually hidden in CSS. 269 ".hidden": config.control == "moz-message-bar" && this.hidden, 270 }; 271 } 272 273 getValue() { 274 return this.setting.value; 275 } 276 277 setValue = () => { 278 this.value = this.setting.value; 279 }; 280 281 /** 282 * @param {HTMLElement} el 283 * @returns {any} 284 */ 285 controlValue(el) { 286 let Cls = el.constructor; 287 if ( 288 "activatedProperty" in Cls && 289 Cls.activatedProperty && 290 el.localName != "moz-radio" 291 ) { 292 return el[/** @type {keyof typeof el} */ (Cls.activatedProperty)]; 293 } 294 if (el instanceof MozInputFolder) { 295 return el.folder; 296 } 297 return "value" in el ? el.value : null; 298 } 299 300 /** 301 * Called by our parent when our input changed. 302 * 303 * @param {SettingControlChild} el 304 */ 305 onChange(el) { 306 this.setting.userChange(this.controlValue(el)); 307 } 308 309 /** 310 * Called by our parent when our input is clicked. 311 * 312 * @param {MouseEvent} event 313 */ 314 onClick(event) { 315 this.setting.userClick(event); 316 } 317 318 async disableExtension() { 319 this.isDisablingExtension = true; 320 this.showEnableExtensionMessage = true; 321 await this.setting.disableControllingExtension(); 322 this.isDisablingExtension = false; 323 } 324 325 isControlledByExtension() { 326 return ( 327 this.setting.controllingExtensionInfo?.id && 328 this.setting.controllingExtensionInfo?.name 329 ); 330 } 331 332 handleEnableExtensionDismiss() { 333 this.showEnableExtensionMessage = false; 334 } 335 336 /** 337 * @param {MouseEvent} event 338 */ 339 navigateToAddons(event) { 340 let link = /** @type {HTMLAnchorElement} */ (event.target); 341 if (link.matches("a[data-l10n-name='addons-link']")) { 342 event.preventDefault(); 343 // @ts-ignore 344 let mainWindow = window.browsingContext.topChromeWindow; 345 mainWindow.BrowserAddonUI.openAddonsMgr("addons://list/extension"); 346 } 347 } 348 349 get extensionName() { 350 return this.setting.controllingExtensionInfo.name; 351 } 352 353 get extensionMessageId() { 354 return this.setting.controllingExtensionInfo.l10nId; 355 } 356 357 /** 358 * Prepare nested item config and settings. 359 * 360 * @param {SettingControlConfig | SettingOptionConfig} config 361 * @returns {TemplateResult[]} 362 */ 363 itemsTemplate(config) { 364 if (!config.items) { 365 return []; 366 } 367 368 const itemArgs = config.items.map(i => ({ 369 config: i, 370 setting: this.getSetting(i.id), 371 })); 372 let control = config.control || "moz-checkbox"; 373 return itemArgs.map( 374 item => 375 html`<setting-control 376 .config=${item.config} 377 .setting=${item.setting} 378 .getSetting=${this.getSetting} 379 slot=${ifDefined( 380 item.config.slot || ITEM_SLOT_BY_PARENT.get(control) 381 )} 382 ></setting-control>` 383 ); 384 } 385 386 /** 387 * Prepares any children (and any of its children's children) that this element may need. 388 * 389 * @param {SettingOptionConfig} config 390 * @returns {TemplateResult[]} 391 */ 392 optionsTemplate(config) { 393 if (!config.options) { 394 return []; 395 } 396 let control = config.control || "moz-checkbox"; 397 return config.options.map(opt => { 398 let optionTag = opt.control 399 ? unsafeStatic(opt.control) 400 : KNOWN_OPTIONS.get(control); 401 let spreadValues = spread(this.getOptionPropertyMapping(opt)); 402 let children = 403 "items" in opt ? this.itemsTemplate(opt) : this.optionsTemplate(opt); 404 if (opt.control == "a" && opt.controlAttrs?.is == "moz-support-link") { 405 // The `is` attribute must be set when the element is first added to the 406 // DOM. We need to mark that up manually, since `spread()` uses 407 // `el.setAttribute()` to set attributes it receives. 408 return html`<a is="moz-support-link" ${spreadValues}>${children}</a>`; 409 } 410 return staticHtml`<${optionTag} ${spreadValues}>${children}</${optionTag}>`; 411 }); 412 } 413 414 get extensionSupportPage() { 415 return this.setting.controllingExtensionInfo.supportPage; 416 } 417 418 render() { 419 // Allow the Setting to override the static config if necessary. 420 this.config = this.setting.getControlConfig(this.config); 421 let { config } = this; 422 let control = config.control || "moz-checkbox"; 423 424 let nestedSettings = 425 "items" in config 426 ? this.itemsTemplate(config) 427 : this.optionsTemplate(config); 428 429 // Get the properties for this element: id, fluent, disabled, etc. 430 // These will be applied to the control using the spread directive. 431 let controlProps = this.getControlPropertyMapping(config); 432 433 let tag = unsafeStatic(control); 434 let messageBar; 435 436 // NOTE: the showEnableMessage message bar should ONLY appear when 437 // there are no extensions controlling the setting. 438 if (this.isControlledByExtension()) { 439 let args = { name: this.extensionName }; 440 let supportPage = this.extensionSupportPage; 441 messageBar = html`<moz-message-bar 442 class="extension-controlled-message-bar" 443 .messageL10nId=${this.extensionMessageId} 444 .messageL10nArgs=${args} 445 > 446 ${supportPage 447 ? html`<a 448 is="moz-support-link" 449 slot="support-link" 450 support-page=${supportPage} 451 ></a>` 452 : ""} 453 <moz-button 454 slot="actions" 455 @click=${this.disableExtension} 456 ?disabled=${this.isDisablingExtension} 457 data-l10n-id="disable-extension" 458 ></moz-button> 459 </moz-message-bar>`; 460 } else if (this.showEnableExtensionMessage) { 461 messageBar = html`<moz-message-bar 462 class="reenable-extensions-message-bar" 463 dismissable="" 464 @message-bar:user-dismissed=${this.handleEnableExtensionDismiss} 465 > 466 <span 467 @click=${this.navigateToAddons} 468 slot="message" 469 data-l10n-id="extension-controlled-enable-2" 470 > 471 <a data-l10n-name="addons-link" href="#"></a> 472 </span> 473 </moz-message-bar>`; 474 } 475 return staticHtml` 476 ${messageBar} 477 <${tag} 478 ${spread(controlProps)} 479 ${ref(this.controlRef)} 480 tabindex=${ifDefined(this.tabIndex)} 481 >${nestedSettings}</${tag}>`; 482 } 483 } 484 customElements.define("setting-control", SettingControl);