diff --git a/browser/components/storybook/docs/README.lit-guide.stories.md b/browser/components/storybook/docs/README.lit-guide.stories.md index 25358e49b8b8..1c6060aef442 100644 --- a/browser/components/storybook/docs/README.lit-guide.stories.md +++ b/browser/components/storybook/docs/README.lit-guide.stories.md @@ -84,6 +84,26 @@ class MyCustomElement extends MozLitElement { } ``` +#### It provides the mapped attribute helpers for standard web attributes + +When you want to accept a standard attribute such as accesskey, title or +aria-label at the component level but it should really be set on a child +element then you can set the `mapped: true` option in your property +definition and the attribute will be removed from the host when it is set. +Note that the attribute can not be unset once it is set. + +```js +class MyElement extends MozLitElement { + static properties = { + accessKey: { type: String, mapped: true }, + }; + + render() { + return html``; + } +} +``` + #### It implements support for Lit's `@query` and `@queryAll` decorators The Lit library includes `@query` and `@queryAll` [decorators](https://lit.dev/docs/components/shadow-dom/#@query-@queryall-and-@queryasync-decorators) that provide an easy way of finding elements within the internal component DOM. These do not work in `mozilla-central` as we do not have support for JavaScript decorators. Instead, `MozLitElement` provides equivalent [DOM querying functionality](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/lit-utils.mjs#87-99) via defining a static `queries` property on the subclass. For example the following Lit code that queries the component's DOM for certain selectors and assigns the results to different class properties: diff --git a/toolkit/content/tests/widgets/test_moz_lit_element.html b/toolkit/content/tests/widgets/test_moz_lit_element.html index 55b080bda223..d2d38b383945 100644 --- a/toolkit/content/tests/widgets/test_moz_lit_element.html +++ b/toolkit/content/tests/widgets/test_moz_lit_element.html @@ -52,6 +52,9 @@ example-label = message: { type: String, fluent: true }, tooltipText: { type: String, fluent: true }, renamedAttribute: { type: String, fluent: true, attribute: "rename" }, + mappedAttribute: { type: String, mapped: true, attribute: "mapped" }, + accessKey: { type: String, mapped: true }, + ariaLabel: { type: String, mapped: true }, }; render() { @@ -80,6 +83,20 @@ example-label = "Text rendered automatically on upgrade" ); }); + + add_task(async function testMappedAttributes() { + let el = await renderTemplate( + html`` + ); + is(el.accessKey, "f", "accessKey property is correct"); + ok(!el.hasAttribute("accesskey"), "accesskey attribute was removed"); + + is(el.mappedAttribute, "mapped-val", "mappedAttribute is mapped with custom attribute"); + ok(!el.hasAttribute("mapped"), "mapped attribute was removed"); + + is(el.ariaLabel, "Label!", "ariaLabel property is set"); + ok(!el.hasAttribute("aria-label"), "aria-label was removed"); + }); diff --git a/toolkit/content/widgets/lit-utils.mjs b/toolkit/content/widgets/lit-utils.mjs index 841bbda360b1..21859d25aae2 100644 --- a/toolkit/content/widgets/lit-utils.mjs +++ b/toolkit/content/widgets/lit-utils.mjs @@ -63,6 +63,16 @@ function queryAll(el, selector) { * ******* * + * Mapped properties support (moving a standard attribute to rendered content) + * + * When you want to accept a standard attribute such as accesskey, title or + * aria-label at the component level but it should really be set on a child + * element then you can set the `mapped: true` option in your property + * definition and the attribute will be removed from the host when it is set. + * Note that the attribute can not be unset once it is set. + * + ******* + * * Test helper for sending events after a change: `dispatchOnUpdateComplete` * * When some async stuff is going on and you want to wait for it in a test, you @@ -106,6 +116,25 @@ export class MozLitElement extends LitElement { } } + static createProperty(attrName, options) { + if (options.mapped) { + let domAttrPropertyName = `${attrName}Attribute`; + let domAttrName = options.attribute ?? attrName.toLowerCase(); + if (attrName.startsWith("aria")) { + domAttrName = domAttrName.replace("aria", "aria-"); + } + this.mappedAttributes ??= []; + this.mappedAttributes.push([attrName, domAttrPropertyName]); + options.state = true; + super.createProperty(domAttrPropertyName, { + type: String, + attribute: domAttrName, + reflect: true, + }); + } + return super.createProperty(attrName, options); + } + constructor() { super(); let { queries } = this.constructor; @@ -156,6 +185,22 @@ export class MozLitElement extends LitElement { } } + willUpdate(changes) { + this.#handleMappedAttributeChange(changes); + } + + #handleMappedAttributeChange(changes) { + if (!this.constructor.mappedAttributes) { + return; + } + for (let [attrName, domAttrName] of this.constructor.mappedAttributes) { + if (changes.has(domAttrName)) { + this[attrName] = this[domAttrName]; + this[domAttrName] = null; + } + } + } + get #l10n() { if (!this.#l10nObj) { this.#l10nObj = diff --git a/toolkit/content/widgets/moz-button/moz-button.mjs b/toolkit/content/widgets/moz-button/moz-button.mjs index a47f422a4828..cc8799d291eb 100644 --- a/toolkit/content/widgets/moz-button/moz-button.mjs +++ b/toolkit/content/widgets/moz-button/moz-button.mjs @@ -47,19 +47,12 @@ export default class MozButton extends MozLitElement { type: { type: String, reflect: true }, size: { type: String, reflect: true }, disabled: { type: Boolean, reflect: true }, - title: { type: String, state: true }, - titleAttribute: { type: String, attribute: "title", reflect: true }, + title: { type: String, mapped: true }, tooltipText: { type: String, fluent: true }, - ariaLabelAttribute: { - type: String, - attribute: "aria-label", - reflect: true, - }, - ariaLabel: { type: String, state: true }, + ariaLabel: { type: String, mapped: true }, iconSrc: { type: String }, hasVisibleLabel: { type: Boolean, state: true }, - accessKeyAttribute: { type: String, attribute: "accesskey", reflect: true }, - accessKey: { type: String, state: true }, + accessKey: { type: String, mapped: true }, }; static queries = { @@ -76,21 +69,6 @@ export default class MozButton extends MozLitElement { this.hasVisibleLabel = !!this.label; } - willUpdate(changes) { - if (changes.has("titleAttribute")) { - this.title = this.titleAttribute; - this.titleAttribute = null; - } - if (changes.has("ariaLabelAttribute")) { - this.ariaLabel = this.ariaLabelAttribute; - this.ariaLabelAttribute = null; - } - if (changes.has("accessKeyAttribute")) { - this.accessKey = this.accessKeyAttribute; - this.accessKeyAttribute = null; - } - } - // Delegate clicks on host to the button element. click() { this.buttonEl.click(); diff --git a/toolkit/content/widgets/moz-checkbox/moz-checkbox.mjs b/toolkit/content/widgets/moz-checkbox/moz-checkbox.mjs index 7fa6beda1e1d..5caa4d215dc2 100644 --- a/toolkit/content/widgets/moz-checkbox/moz-checkbox.mjs +++ b/toolkit/content/widgets/moz-checkbox/moz-checkbox.mjs @@ -34,12 +34,7 @@ export default class MozCheckbox extends MozLitElement { checked: { type: Boolean, reflect: true }, disabled: { type: Boolean, reflect: true }, description: { type: String, fluent: true }, - accessKeyAttribute: { - type: String, - attribute: "accesskey", - reflect: true, - }, - accessKey: { type: String, state: true }, + accessKey: { type: String, mapped: true }, supportPage: { type: String, attribute: "support-page" }, }; @@ -88,13 +83,6 @@ export default class MozCheckbox extends MozLitElement { this.dispatchEvent(newEvent); } - willUpdate(changes) { - if (changes.has("accessKeyAttribute")) { - this.accessKey = this.accessKeyAttribute; - this.accessKeyAttribute = null; - } - } - iconTemplate() { if (this.iconSrc) { return html``; diff --git a/toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs b/toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs index d021fac30631..5f91ad2c37fa 100644 --- a/toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs +++ b/toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs @@ -235,12 +235,7 @@ export class MozRadio extends MozLitElement { #controller; static properties = { - accessKey: { type: String, state: true }, - accessKeyAttribute: { - type: String, - attribute: "accesskey", - reflect: true, - }, + accessKey: { type: String, mapped: true }, checked: { type: Boolean, reflect: true }, description: { type: String, fluent: true }, disabled: { type: Boolean, reflect: true }, @@ -277,6 +272,8 @@ export class MozRadio extends MozLitElement { } willUpdate(changedProperties) { + super.willUpdate(changedProperties); + // Handle setting checked directly via JS. if ( changedProperties.has("checked") && @@ -306,11 +303,6 @@ export class MozRadio extends MozLitElement { this.#controller.syncFocusState(); } } - - if (changedProperties.has("accessKeyAttribute")) { - this.accessKey = this.accessKeyAttribute; - this.accessKeyAttribute = null; - } } handleClick() { diff --git a/toolkit/content/widgets/moz-toggle/moz-toggle.mjs b/toolkit/content/widgets/moz-toggle/moz-toggle.mjs index 1c6fb5a5fc3a..fbb2f18f8303 100644 --- a/toolkit/content/widgets/moz-toggle/moz-toggle.mjs +++ b/toolkit/content/widgets/moz-toggle/moz-toggle.mjs @@ -28,8 +28,7 @@ export default class MozToggle extends MozLitElement { label: { type: String }, description: { type: String }, ariaLabel: { type: String, attribute: "aria-label" }, - accessKeyAttribute: { type: String, attribute: "accesskey", reflect: true }, - accessKey: { type: String, state: true }, + accessKey: { type: String, mapped: true }, }; static get queries() { @@ -66,13 +65,6 @@ export default class MozToggle extends MozLitElement { this.buttonEl.focus(); } - willUpdate(changes) { - if (changes.has("accessKeyAttribute")) { - this.accessKey = this.accessKeyAttribute; - this.accessKeyAttribute = null; - } - } - descriptionTemplate() { if (this.description) { return html`