| # Using Lit in Chromium WebUI Development |
| |
| [TOC] |
| |
| ## Background |
| |
| This documentation focuses on using Lit in the context of Chromium WebUI, |
| and on compatibility between Lit and Polymer elements, since much of Chromium |
| WebUI is currently written in Polymer. It assumes familiarity with the |
| following: |
| |
| * Web Components: The basic foundation on which Lit is built. See |
| [Introduction to Web Components from MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) and the related |
| [MDN guide for custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements). |
| * Lit framework, see [official docs](https://lit.dev/docs/), and especially |
| the [Reactive properties, Lifecycle](https://lit.dev/docs/components/lifecycle/) |
| and [Expressions](https://lit.dev/docs/templates/expressions/) sections. |
| * Lit vs Polymer differences. See |
| [Lit for Polymer users](https://lit.dev/articles/lit-for-polymer-users/), |
| covering some general Lit/Polymer compatibility and migration issues |
| |
| ***promo |
| For developers unfamiliar with these, the external sources linked above are |
| recommended before continuing with this documentation. |
| *** |
| |
| ## Chromium WebUI Infrastructure: CrLitElement base class |
| <code>[CrLitElement](https://source.chromium.org/chromium/chromium/src/+/main:third_party/lit/v3_0/cr_lit_element.ts;l=1?q=cr_lit_element&sq=&ss=chromium%2Fchromium%2Fsrc)</code> is provided as a base class for Chromium WebUI development. It contains code to |
| |
| 1. Reduce the amount of boilerplate code necessary for individual elements. |
| 2. Improve compatibility with elements using Polymer (necessary in a codebase |
| that is using both Polymer and Lit). |
| 3. Make Polymer -> Lit migrations easier |
| |
| ***promo |
| Lit custom elements in Chromium should inherit from the `CrLitElement` class. |
| *** |
| |
| Specific features of `CrLitElement` include: |
| |
| 1. Forces initial rendering to be synchronous when |
| 1. The element’s connectedCallback runs |
| 2. The element is focused before the connectedCallback has run |
| 3. Child elements are accessed with `this.$` before the connectedCallback |
| has run |
| |
| This means that there is no need to call `await element.updateComplete` |
| before accessing the element’s DOM in certain cases: |
| 1. Immediately after attaching it |
| 2. When doing something like focusing the element or accessing its children |
| with `element.$` in a Polymer parent’s `connectedCallback()`, which may |
| run before the Lit child’s `connectedCallback()`. |
| |
| For more detail on this, see the inline documentation in `CrLitElement`. |
| 2. “$” proxy to allow accessing child elements by ID with `this.$.id`, to match |
| the behavior of Polymer, which does the same thing. |
| 3. Implementation of `notify: true` for properties. Setting this will cause |
| `CrLitElement` to fire `foo-changed` events in the `updated()` lifecycle |
| callback, whenever some property `foo` with `notify: true` set is changed. |
| This allows compatibility with Polymer parent element two-way bindings, but |
| is not exactly the same; see more details below. |
| 4. Changes Lit’s default property/attribute mapping to match the mapping used |
| in Polymer - i.e. property `fooBar` will be mapped to attribute `foo-bar`, |
| not the Lit default of `foobar` |
| |
| ## Lit Data Bindings and handling `-changed` events |
| As noted above, `CrLitElement` forces an initial synchronous render in |
| `connectedCallback()`. This means child elements may initialize properties |
| with `notify: true` and then fire `-changed` events for these properties in |
| `updated()` as soon as they are connected, which may occur before the parent |
| element has finished its first update. |
| |
| One consequence of this is that `-changed` event handlers cannot assume that |
| the element has completed its first update when the `-changed` event is |
| received, and should not make any changes to the element's DOM until after |
| waiting for the element's `updateComplete` promise. This means such handlers |
| must either (1) be async and `await this.updateComplete;` before running any |
| code that updates the element's DOM, or (2) only update properties on the |
| parent in response to the child's property change, and perform resulting UI |
| updates in the `updated()` lifecycle method instead. |
| |
| Note that if the parent property being updated is protected or private, a cast |
| will be necessary to check for changes to the property in `changedProperties`. |
| This is also demonstrated in the example below. |
| |
| Suppose the Lit child has a property with `notify: true` as follows: |
| ``` |
| static override get properties() { |
| return { |
| foo: { |
| type: Boolean, |
| notify: true, |
| }, |
| }; |
| } |
| ``` |
| |
| This property is also bound to a parent element that listens for the |
| `-changed` event as follows: |
| ``` |
| <foo-child ?foo="${this.foo_}" on-foo-changed="${this.onFooChanged_}"> |
| </foo-child> |
| <demo-child id="demo"></demo-child> |
| ``` |
| |
| The parent TypeScript code could look like this: |
| ``` |
| static override get properties() { |
| return { |
| foo_: {type: Boolean}, |
| }; |
| } |
| |
| protected foo_: boolean = true; |
| |
| onFooChanged_(e: CustomEvent<{value: boolean}>) { |
| // Updates the parent's property that is bound to the child. |
| this.foo_ = e.detail.value; |
| } |
| |
| override updated(changedProperties: PropertyValues<this>) { |
| super.updated(changedProperties); |
| |
| // Cast necessary to check for changes to protected/private properties. |
| const changedPrivateProperties = |
| changedProperties as Map<PropertyKey, unknown>; |
| |
| // Updates the DOM when |foo_| changes. |
| if (changedPrivateProperties.has('foo_')) { |
| if (this.foo_) { |
| this.$.demo.show(); |
| } else { |
| this.$.demo.hide(); |
| } |
| } |
| } |
| ``` |
| |
| ## Lit and Polymer Data Bindings Compatibility |
| Two-way bindings are not natively supported in Lit. As mentioned above, |
| basic compatibility is provided by the `CrLitElement` base class’s |
| implementation of `notify: true`. However, these events differ from the Polymer |
| two-way binding behavior. |
| * In Polymer two-way bindings, the child element only fires a `-changed` |
| event if the property is modified from the child. The child element does not |
| fire the event if the property value is set from the parent. |
| * The equivalent code in `CrLitElement` can’t differentiate whether the |
| property was set from the parent or the child itself and always fires the |
| `-changed` event when the value changes. |
| |
| *** promo |
| When migrating parent or child elements to Lit, any code directly handling |
| `-changed` events in the parent should either be agnostic to whether the |
| corresponding property was changed from the parent or the child, or should check |
| that the new value in the event differs from the current parent value. |
| *** |
| |
| Example: a `-changed` event handler that logs a metric indicating something was |
| changed from the child (e.g. due to user input) should add a check before |
| logging that the new value is in fact new, and was not set by the parent via the |
| data binding. |
| |
| The behavior difference for two-way bindings can be seen by playing with this [Lit playground example](https://lit.dev/playground/#project=W3sibmFtZSI6InRvcC1lbGVtZW50LnRzIiwiY29udGVudCI6ImltcG9ydCB7UG9seW1lckVsZW1lbnQsIGh0bWx9IGZyb20gJ0Bwb2x5bWVyL3BvbHltZXInO1xuaW1wb3J0IHtjdXN0b21FbGVtZW50LCBwcm9wZXJ0eX0gZnJvbSAnQHBvbHltZXIvZGVjb3JhdG9ycyc7XG5pbXBvcnQge0NyRHVtbXlQb2x5bWVyRWxlbWVudH0gZnJvbSAnLi9jcl9kdW1teV9wb2x5bWVyLmpzJztcbmltcG9ydCB7Q3JEdW1teUxpdEVsZW1lbnR9IGZyb20gJy4vY3JfZHVtbXlfbGl0LmpzJztcbmltcG9ydCAnLi9jcl9kdW1teV9saXQuanMnO1xuaW1wb3J0ICcuL2NyX2R1bW15X3BvbHltZXIuanMnO1xuXG5AY3VzdG9tRWxlbWVudCgndG9wLWVsZW1lbnQnKVxuY2xhc3MgVG9wRWxlbWVudCBleHRlbmRzIFBvbHltZXJFbGVtZW50IHtcbiAgc3RhdGljIGdldCB0ZW1wbGF0ZSgpIHtcbiAgICByZXR1cm4gaHRtbGBcbiAgICAgIDxzdHlsZT5cbiAgICAgICAgOmhvc3Qge1xuICAgICAgICAgIGNvbG9yOiBibHVlO1xuICAgICAgICAgIGRpc3BsYXk6YmxvY2s7XG4gICAgICAgICAgYm9yZGVyOiAycHggc29saWQgYmx1ZTtcbiAgICAgICAgfVxuICAgICAgICBcbiAgICAgICAgI2NoaWxkcmVuIHtcbiAgICAgICAgICBkaXNwbGF5OiBmbGV4O1xuICAgICAgICAgIGdhcDogMTBweDtcbiAgICAgICAgfVxuICAgICAgPC9zdHlsZT5cbiAgICAgIDxkaXY-UE9MWU1FUiBQQVJFTlQ8L2Rpdj5cbiAgICAgIDxsYWJlbD5TZXQgUG9seW1lciBwYXJlbnQgdmFsdWU6IDxpbnB1dCB0eXBlPVwibnVtYmVyXCIgb24taW5wdXQ9XCJvbklucHV0X1wiPjwvaW5wdXQ-PC9sYWJlbD5cbiAgICAgIDxkaXYgaWQ9XCJjaGlsZHJlblwiPlxuICAgICAgPGNyLWR1bW15LXBvbHltZXIgdmFsdWU9XCJ7e3ZhbHVlUG9seW1lcn19XCIgb24tdmFsdWUtY2hhbmdlZD1cIm9uUG9seW1lclZhbHVlQ2hhbmdlZF9cIj48L2NyLWR1bW15LXBvbHltZXI-XG4gICAgICA8Y3ItZHVtbXktbGl0IHZhbHVlPVwie3t2YWx1ZUxpdH19XCIgb24tdmFsdWUtY2hhbmdlZD1cIm9uTGl0VmFsdWVDaGFuZ2VkX1wiPjwvY3ItZHVtbXktbGl0PlxuICAgICAgPC9kaXY-XG4gICAgICA8ZGl2IGlkPVwibG9nXCI-PC9kaXY-XG4gICAgYDtcbiAgfVxuICBcbiAgc3RhdGljIGdldCBwcm9wZXJ0aWVzKCkge1xuICAgIHJldHVybiB7XG4gICAgICB2YWx1ZVBvbHltZXI6IHt0eXBlOiBOdW1iZXJ9LFxuICAgICAgdmFsdWVMaXQ6IHt0eXBlOiBOdW1iZXJ9LFxuICAgIH07XG4gIH1cbiAgXG4gIHZhbHVlTGl0OiBudW1iZXI7XG4gIHZhbHVlUG9seW1lcjogbnVtYmVyO1xuICBcbiAgb25JbnB1dF8oKSB7ICAgIFxuICAgIHRoaXMudmFsdWVQb2x5bWVyID0gTnVtYmVyKHRoaXMuc2hhZG93Um9vdC5xdWVyeVNlbGVjdG9yKCdpbnB1dCcpLnZhbHVlKTtcbiAgICB0aGlzLnZhbHVlTGl0ID0gdGhpcy52YWx1ZVBvbHltZXI7XG4gIH1cbiAgXG4gIG9uUG9seW1lclZhbHVlQ2hhbmdlZF8oZSkge1xuICAgIGNvbnN0IGR1bW15ID0gdGhpcy5zaGFkb3dSb290IS5xdWVyeVNlbGVjdG9yKCdjci1kdW1teS1wb2x5bWVyJykgYXMgQ3JEdW1teVBvbHltZXJFbGVtZW50O1xuICAgIHRoaXMuJC5sb2cudGV4dENvbnRlbnQgKz0gJ3BvbHltZXItdmFsdWUtY2hhbmdlZCB0byAnICsgZS5kZXRhaWwudmFsdWUgKyAnLi4uICAnO1xuICB9XG4gICBvbkxpdFZhbHVlQ2hhbmdlZF8oZSkge1xuICAgIGNvbnN0IGR1bW15ID0gdGhpcy5zaGFkb3dSb290IS5xdWVyeVNlbGVjdG9yKCdjci1kdW1teS1saXQnKSBhcyBDckR1bW15TGl0RWxlbWVudDtcbiAgICB0aGlzLiQubG9nLnRleHRDb250ZW50ICs9ICdsaXQtdmFsdWUtY2hhbmdlZCB0byAnICsgZS5kZXRhaWwudmFsdWUgKyAnLi4uICAnO1xuICB9XG59O1xuXG4ifSx7Im5hbWUiOiJwYWNrYWdlLmpzb24iLCJjb250ZW50Ijoie1xuICBcImRlcGVuZGVuY2llc1wiOiB7XG4gICAgXCJsaXRcIjogXCJeMy4wLjBcIixcbiAgICBcIkBsaXQvcmVhY3RpdmUtZWxlbWVudFwiOiBcIl4yLjAuMFwiLFxuICAgIFwibGl0LWVsZW1lbnRcIjogXCJeNC4wLjBcIixcbiAgICBcImxpdC1odG1sXCI6IFwiXjMuMC4wXCJcbiAgfVxufSIsImhpZGRlbiI6dHJ1ZX0seyJuYW1lIjoiaW5kZXguaHRtbCIsImNvbnRlbnQiOiI8IURPQ1RZUEUgaHRtbD5cbjxoZWFkPlxuICA8c2NyaXB0IHR5cGU9XCJtb2R1bGVcIiBzcmM9XCIuL3RvcC1lbGVtZW50LmpzXCI-PC9zY3JpcHQ-XG4gIDxzY3JpcHQgdHlwZT1cIm1vZHVsZVwiIHNyYz1cIi4vdG9wLWVsZW1lbnQtbGl0LmpzXCI-PC9zY3JpcHQ-XG48L2hlYWQ-XG48Ym9keT5cbiAgPHRvcC1lbGVtZW50PjwvdG9wLWVsZW1lbnQ-XG4gIDx0b3AtZWxlbWVudC1saXQ-PC90b3AtZWxlbWVudC1saXQ-XG48L2JvZHk-XG4ifSx7Im5hbWUiOiJjcl9kdW1teV9wb2x5bWVyLnRzIiwiY29udGVudCI6ImltcG9ydCB7UG9seW1lckVsZW1lbnQsIGh0bWx9IGZyb20gJ0Bwb2x5bWVyL3BvbHltZXInO1xuaW1wb3J0IHtjdXN0b21FbGVtZW50LCBwcm9wZXJ0eX0gZnJvbSAnQHBvbHltZXIvZGVjb3JhdG9ycyc7XG5cbkBjdXN0b21FbGVtZW50KCdjci1kdW1teS1wb2x5bWVyJylcbmV4cG9ydCBjbGFzcyBDckR1bW15UG9seW1lckVsZW1lbnQgZXh0ZW5kcyBQb2x5bWVyRWxlbWVudCB7XG4gIHN0YXRpYyBnZXQgdGVtcGxhdGUoKSB7XG4gICAgcmV0dXJuIGh0bWxgXG4gICAgICAgPHN0eWxlPlxuICAgICAgICA6aG9zdCB7XG4gICAgICAgICAgYm9yZGVyOiAycHggc29saWQgYmx1ZTtcbiAgICAgICAgICBjb2xvcjogYmx1ZTtcbiAgICAgICAgICBkaXNwbGF5OiBibG9jaztcbiAgICAgICAgfVxuICAgICAgIDwvc3R5bGU-XG4gICAgICAgPGRpdj5Qb2x5bWVyIGNoaWxkIHZhbHVlIGlzOiBbW3ZhbHVlXV08L2Rpdj5cbiAgICBgO1xuICB9XG4gIFxuICBzdGF0aWMgZ2V0IHByb3BlcnRpZXMoKSB7XG4gICAgcmV0dXJuIHtcbiAgICAgIHZhbHVlOiB7dHlwZTogTnVtYmVyLCBub3RpZnk6IHRydWV9LFxuICAgIH07XG4gIH1cbiAgXG4gIHZhbHVlOiBudW1iZXIgPSAxO1xufSJ9LHsibmFtZSI6InRvcC1lbGVtZW50LWxpdC50cyIsImNvbnRlbnQiOiJpbXBvcnQge0xpdEVsZW1lbnQsIGh0bWwsIG5vdGhpbmd9IGZyb20gJ2xpdCc7XG5pbXBvcnQge2N1c3RvbUVsZW1lbnR9IGZyb20gJ2xpdC9kZWNvcmF0b3JzLmpzJztcblxuaW1wb3J0IHtDckR1bW15UG9seW1lckVsZW1lbnR9IGZyb20gJy4vY3JfZHVtbXlfcG9seW1lci5qcyc7XG5pbXBvcnQgJy4vY3JfZHVtbXlfcG9seW1lci5qcyc7XG5pbXBvcnQge0NyRHVtbXlMaXRFbGVtZW50fSBmcm9tICcuL2NyX2R1bW15X2xpdC5qcyc7XG5pbXBvcnQgJy4vY3JfZHVtbXlfbGl0LmpzJztcblxuQGN1c3RvbUVsZW1lbnQoJ3RvcC1lbGVtZW50LWxpdCcpXG5jbGFzcyBUb3BFbGVtZW50TGl0IGV4dGVuZHMgTGl0RWxlbWVudCB7XG4gIG92ZXJyaWRlIHJlbmRlcigpIHtcbiAgICByZXR1cm4gaHRtbGBcbiAgICAgIDxzdHlsZT5cbiAgICAgICAgOmhvc3Qge1xuICAgICAgICAgIGJvcmRlcjogMnB4IHNvbGlkIGdyZWVuO1xuICAgICAgICAgIGNvbG9yOiBncmVlbjtcbiAgICAgICAgICBkaXNwbGF5OiBibG9jaztcbiAgICAgICAgfVxuICAgICAgICBcbiAgICAgICAgI2NoaWxkcmVuIHtcbiAgICAgICAgICBkaXNwbGF5OiBmbGV4O1xuICAgICAgICAgIGdhcDogMTBweDtcbiAgICAgICAgfVxuICAgICAgPC9zdHlsZT5cbiAgICAgIDxkaXY-TElUIFBBUkVOVDwvZGl2PlxuICAgICAgPGxhYmVsPlNldCBMaXQgcGFyZW50IHZhbHVlOiA8aW5wdXQgdHlwZT1cIm51bWJlclwiIEBpbnB1dD1cIiR7dGhpcy5vbklucHV0X31cIj48L2lucHV0PjwvbGFiZWw-XG4gICAgICA8ZGl2IGlkPVwiY2hpbGRyZW5cIj5cbiAgICAgIDxjci1kdW1teS1wb2x5bWVyIHZhbHVlPVwiJHt0aGlzLnZhbHVlUG9seW1lciB8fCBub3RoaW5nfVwiIEB2YWx1ZS1jaGFuZ2VkPVwiJHt0aGlzLm9uUG9seW1lclZhbHVlQ2hhbmdlZF99XCI-PC9jci1kdW1teS1wb2x5bWVyPlxuICAgICAgPGNyLWR1bW15LWxpdCB2YWx1ZT1cIiR7dGhpcy52YWx1ZUxpdCB8fCBub3RoaW5nfVwiIEB2YWx1ZS1jaGFuZ2VkPVwiJHt0aGlzLm9uTGl0VmFsdWVDaGFuZ2VkX31cIj48L2NyLWR1bW15LWxpdD5cbiAgICAgIDwvZGl2PlxuICAgICAgPGRpdiBpZD1cImxvZ1wiPjwvZGl2PlxuICAgIGA7XG4gIH1cblxuICBzdGF0aWMgb3ZlcnJpZGUgZ2V0IHByb3BlcnRpZXMoKSB7XG4gICAgcmV0dXJuIHtcbiAgICAgIHZhbHVlUG9seW1lcjoge3R5cGU6IE51bWJlcn0sXG4gICAgICB2YWx1ZUxpdDoge3R5cGU6IE51bWJlcn0sXG4gICAgfTtcbiAgfVxuICBcbiAgdmFsdWVQb2x5bWVyOiBudW1iZXI7XG4gIHZhbHVlTGl0OiBudW1iZXI7XG4gIFxuICBvbklucHV0XygpIHtcbiAgICB0aGlzLnZhbHVlUG9seW1lciA9IE51bWJlcih0aGlzLnNoYWRvd1Jvb3QucXVlcnlTZWxlY3RvcignaW5wdXQnKS52YWx1ZSk7XG4gICAgdGhpcy52YWx1ZUxpdCA9IHRoaXMudmFsdWVQb2x5bWVyO1xuICB9XG4gIFxuICBvblBvbHltZXJWYWx1ZUNoYW5nZWRfKGUpIHtcbiAgICBjb25zdCBkdW1teSA9IHRoaXMuc2hhZG93Um9vdCEucXVlcnlTZWxlY3RvcignY3ItZHVtbXktcG9seW1lcicpIGFzIENyRHVtbXlQb2x5bWVyRWxlbWVudDtcbiAgICB0aGlzLnNoYWRvd1Jvb3QhLnF1ZXJ5U2VsZWN0b3IoJyNsb2cnKS50ZXh0Q29udGVudCArPSAncG9seW1lci12YWx1ZS1jaGFuZ2VkIHRvICcgKyBlLmRldGFpbC52YWx1ZSArICcuLi4gICc7XG4gIH1cbiAgXG4gIG9uTGl0VmFsdWVDaGFuZ2VkXyhlKSB7XG4gICAgY29uc3QgZHVtbXkgPSB0aGlzLnNoYWRvd1Jvb3QhLnF1ZXJ5U2VsZWN0b3IoJ2NyLWR1bW15LWxpdCcpIGFzIENyRHVtbXlMaXRFbGVtZW50O1xuICAgIHRoaXMuc2hhZG93Um9vdCEucXVlcnlTZWxlY3RvcignI2xvZycpLnRleHRDb250ZW50ICs9ICdsaXQtdmFsdWUtY2hhbmdlZCB0byAnICsgZS5kZXRhaWwudmFsdWUgKyAnLi4uICAnO1xuICB9XG59XG4gIn0seyJuYW1lIjoiY3JfZHVtbXlfbGl0LnRzIiwiY29udGVudCI6ImltcG9ydCB7TGl0RWxlbWVudCwgaHRtbCwgUHJvcGVydHlWYWx1ZXN9IGZyb20gJ2xpdCc7XG5pbXBvcnQge2N1c3RvbUVsZW1lbnR9IGZyb20gJ2xpdC9kZWNvcmF0b3JzLmpzJztcblxuQGN1c3RvbUVsZW1lbnQoJ2NyLWR1bW15LWxpdCcpXG5leHBvcnQgY2xhc3MgQ3JEdW1teUxpdEVsZW1lbnQgZXh0ZW5kcyBMaXRFbGVtZW50IHtcbiAgb3ZlcnJpZGUgcmVuZGVyKCkge1xuICAgIHJldHVybiBodG1sYFxuICAgICAgPHN0eWxlPlxuICAgICAgICA6aG9zdCB7XG4gICAgICAgICAgYm9yZGVyOiAycHggc29saWQgZ3JlZW47XG4gICAgICAgICAgY29sb3I6IGdyZWVuO1xuICAgICAgICAgIGRpc3BsYXk6IGJsb2NrO1xuICAgICAgICB9XG4gICAgICA8L3N0eWxlPlxuICAgICAgPGRpdj5MaXQgY2hpbGQgdmFsdWUgaXM6ICR7dGhpcy52YWx1ZX08L2Rpdj5cbiAgICBgO1xuICB9XG5cbiAgc3RhdGljIG92ZXJyaWRlIGdldCBwcm9wZXJ0aWVzKCkge1xuICAgIHJldHVybiB7XG4gICAgICB2YWx1ZToge3R5cGU6IE51bWJlcn0sXG4gICAgfTtcbiAgfVxuICBcbiAgb3ZlcnJpZGUgY29ubmVjdGVkQ2FsbGJhY2soKSB7XG4gICAgc3VwZXIuY29ubmVjdGVkQ2FsbGJhY2soKTtcbiAgICBcbiAgICB0aGlzLnBlcmZvcm1VcGRhdGUoKTtcbiAgfVxuIFxuICB2YWx1ZTogbnVtYmVyID0gMTtcbiAgXG4gIC8vIEltcGxlbWVudGF0aW9uIG9mIG5vdGlmeTogdHJ1ZSBmcm9tIENyTGl0RWxlbWVudFxuICBvdmVycmlkZSB1cGRhdGVkKGNoYW5nZWRQcm9wZXJ0aWVzOiBQcm9wZXJ0eVZhbHVlczx0aGlzPikge1xuICAgIGlmIChjaGFuZ2VkUHJvcGVydGllcy5oYXMoJ3ZhbHVlJykpIHtcbiAgICAgIHRoaXMuZGlzcGF0Y2hFdmVudChuZXcgQ3VzdG9tRXZlbnQoJ3ZhbHVlLWNoYW5nZWQnLCB7IGJ1YmJsZXM6IHRydWUsIGNvbXBvc2VkOiB0cnVlLCBkZXRhaWw6IHsgdmFsdWU6IHRoaXMudmFsdWUgfX0pKTtcbiAgICB9XG4gIH1cbn0ifV0). |
| Modifying initialization of the properties in the different parent and child |
| elements in this playground example reveals that there are also differences |
| in behavior on initialization. These differences are also documented in the |
| following table: |
| |
| | |Property initialized in both parent and child|Property initialized in parent only|Property initialized in child only|Property uninitialized in both parent adnd child| |
| |---|---|---|---|---| |
| |**Polymer parent hosting Polymer child**|Parent value propagates to child. No `-changed` event fired.|Parent value propagates to child. No `-changed` event fired.|Child value propagates to parent. `-changed` event fired.|Property is left undefined. No events fired.| |
| |**Polymer parent hosting Lit child**|Parent value propagates to child. `-changed` event fired.|Parent value propagates to child. `-changed` event fired.|Child value propagates to parent. `-changed` event fired.|Property is left undefined. No events fired.| |
| |**Lit parent hosting Polymer child**|Parent value propagates to child. `-changed` event fired.|Parent value propagates to child. `-changed` event fired.|Child value propagates to parent if binding is using attribute syntax\*. If using property syntax\*\*, parent `undefined` value takes precedence and `-changed` event fires with `undefined`.|Property is left undefined. No events fired.| |
| |**Lit parent hosting Lit child**|Parent value propagates to child. `-changed` event fired.|Parent value propagates to child. `-changed` event fired.| Child value propagates to parent if binding is using attribute syntax\*. If using property syntax\*\*, parent `undefined` value takes precedence and `-changed` event fires with `undefined`.|Property is left undefined. No events fired.| |
| |
| ***aside |
| \* attribute syntax: `value="${this.childValue || nothing}"` |
| |
| \*\* property syntax: `.value="${this.value}" `Note that this syntax must be used for anything that is not a boolean, string, or number. |
| *** |
| |
| ## Polymer iron/paper elements alternatives |
| Previously, code in Chromium WebUI relied heavily on the Polymer library of |
| elements in addition to the Polymer framework itself. The following table |
| captures the list of elements that were still being used in Desktop WebUI code |
| when the WebUI team started exploring Lit as an alternative to Polymer, and |
| discusses the recommended future approach for each. Some of these |
| elements have subsequently been removed from Desktop (non-CrOS) builds, and more |
| will be removed over time. |
| |
| *** note |
| Note: `iron-` and `paper-` elements are |
| [no longer recommended even for Polymer UIs.](https://chromium.googlesource.com/chromium/src/+/HEAD/styleguide/web/web.md#Polymer) |
| *** |
| |
| |POLYMER LIBRARY ELEMENT|RECOMMENDED APPROACH| |
| |-----------------------|--------------------| |
| |`iron-list`|Consider using a simple `items.map(...)` pattern. If you have a very large number of elements and need `iron-list` for its re-use of nodes, currently you will need to use `iron-list` itself (and consequently pull in Polymer, ~90kb). See also later section on migrating `iron-list` clients.| |
| |`iron-icon`|Use `cr-icon`.| |
| |`iron-collapse`|Use `cr-collapse`.| |
| |`paper-spinner`|Style `throbber.svg` with CSS as needed.| |
| |`paper-styles`|Do not use, these styles are pre-2023 refresh and have been removed on non-CrOS builds.| |
| |`iron-flex-layout`|Do not use, use standard CSS to style elements.| |
| |`iron-a11y-announcer`|Use `cr-a11y-announcer`| |
| |`iron-location`/`iron-query-params`|Use `CrRouter` or custom code.| |
| |`iron-scroll-target-behavior`|Do not use.| |
| |`paper-progress`|Use either `cr-progress` or the native `<progress>` element with CSS styling.| |
| |`iron-iconset-svg`/`iron-meta`|Use `cr-iconset` and `IconsetMap`.| |
| |`iron-media-query`|Do not use, use `window.matchMedia()`.| |
| |`iron-pages`|Do not use, replace with equivalent conditional rendering or use `cr-page-selector`.| |
| |`iron-scroll-threshold`|Do not use.| |
| |`iron-resizable-behavior`|Do not use. `ResizeObserver`s can be used to trigger changes in items that need to be modified when something is resized.| |
| |`paper-tooltip`|Use `cr-tooltip`.| |
| |`iron-a11y-keys`|Do not use.| |
| |`iron-selector`/`iron-selectable-behavior`|Use `CrSelectableMixin`.| |
| |
| ## Anatomy of a Lit-based element |
| In Chromium WebUI, Lit based custom elements are defined using 3 files: |
| 1. A \*`.ts` file defining the element, which imports the template from the |
| \*`.html.js` file and the style from the \*`.css.js` file. |
| 2. A \*`.css` file containing the element’s styling. This file is run through |
| `css_to_wrapper` at build time to create a \*`.css.js` file that the |
| element’s \*`.ts` file can import the styles from. |
| 3. A \*`.html.ts` file containing the element’s HTML template. This file can be |
| either |
| * auto-generated from a checked-in `*.html` file via `html_to_wrapper` |
| (preferred approach for Polymer->Lit migrations), OR |
| * directly be checked in to the repository (preferred approach for new Lit |
| code, or post migration cleanups) |
| |
| ***note |
| Note: This differs from Polymer based custom elements in Chromium, which |
| typically use only 2 files: a `.html` file containing both the element’s |
| template and its styling, and a `.ts` file containing the element definition. |
| *** |
| |
| Example `.ts` file: |
| ``` |
| // Copyright 2024 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| import '//resources/cr_elements/cr_input/cr_input.js'; |
| |
| import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js'; |
| import {getCss} from './my_example.css.js'; |
| import {getHtml} from './my_example.html.js'; |
| |
| export interface MyExampleElement { |
| $: { |
| input: HTMLElement, |
| }; |
| } |
| |
| export class MyExampleElement extends CrLitElement { |
| static get is() { |
| return 'my-example'; |
| } |
| |
| static override get styles() { |
| return getCss(); |
| } |
| |
| override render() { |
| return getHtml.bind(this)(); |
| } |
| |
| static override get properties() { |
| return { |
| disabled: { |
| type: Boolean, |
| reflect: true, |
| }, |
| myValue: {type: String}, |
| }; |
| } |
| |
| disabled: boolean = false; |
| myValue: string = 'hello world'; |
| |
| // Referenced from the template, so must be protected (not private). |
| protected onInputValueChanged_(e: CustomEvent<string>) { |
| this.myValue = e.detail.value; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'my-example': MyExampleElement; |
| } |
| } |
| |
| customElements.define(MyExampleElement.is, MyExampleElement); |
| ``` |
| |
| Example CSS file: |
| ``` |
| /* Copyright 2024 The Chromium Authors |
| * Use of this source code is governed by a BSD-style license that can be |
| * found in the LICENSE file. */ |
| |
| /* #css_wrapper_metadata_start |
| * #type=style-lit |
| * #import=//resources/cr_elements/cr_shared_vars.css.js |
| * #scheme=relative |
| * #css_wrapper_metadata_end */ |
| |
| #input { |
| background-color: blue; |
| --cr-input-error-display: none; |
| } |
| |
| :host([disabled]) #input { |
| background-color: gray; |
| } |
| ``` |
| |
| ***note |
| CSS files holding Lit element styles should begin with metadata comments, |
| between 2 lines marked with `#css_wrapper_metadata_start` and |
| `#css_wrapper_metadata_end`. These comments tell `css_to_wrapper()` how to |
| generate the wrapper `.css.ts` file. |
| * `style-lit` indicates this is a Lit style file |
| * imports are specified with `#import=//import/path/for/file` |
| * includes (not used in this particular example) are specified with |
| `#include="style-name-1 style-name-2"` |
| * `scheme=relative` indicates imports should be scheme-relative |
| (i.e. use “`//resources`”) |
| *** |
| |
| Example `.html.ts `file: |
| ``` |
| // Copyright 2024 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {html} from '//resources/lit/v3_0/lit.rollup.js'; |
| import type {MyExampleElement} from './my_example.js'; |
| |
| export function getHtml(this: MyExampleElement) { |
| return html` |
| <div>Input something</div> |
| <cr-input id="input" .value="${this.myValue}" |
| ?disabled="${this.disabled}" |
| @value-changed="${this.onInputValueChanged_}"> |
| </cr-input>`; |
| } |
| ``` |
| |
| `BUILD.gn` file configuration: |
| ``` |
| build_webui("build") { |
| … |
| # Use non_web_component_files since the .html.ts file is checked in. |
| non_web_component_files = [ |
| "my_example.html.ts", |
| "my_example.ts", |
| ] |
| # Unlike Polymer, when using Lit non-shared CSS code resides in dedicated |
| # CSS files passed to css_to_wrapper. |
| css_files = [ |
| "my_example.css", |
| ] |
| # Other TS Compiler related arguments… |
| ts_deps = [ |
| "//ui/webui/resources/cr_elements:build_ts", |
| "//third_party/lit/v3_0:build_ts", |
| ] |
| } |
| ``` |
| ***note |
| Note that unlike for Polymer custom elements, |
| both `.ts` and `.html.ts` files are passed as `non_web_component_files`. This |
| indicates to `build_webui()` that they do not have a corresponding `.html` file |
| that needs to be passed to `html_to_wrapper()` (since in the case of Lit |
| elements, `.html.ts` files are checked in directly). |
| *** |
| |
| ## Polymer to Lit migrations |
| |
| Many of the boilerplate steps for migrating from Polymer to Lit are documented |
| in the [readme](https://chromium.googlesource.com/chromium/src/+/main/ui/webui/resources/tools/codemods/lit_migration.md) |
| in the Lit migration [script](https://source.chromium.org/chromium/chromium/src/+/main:ui/webui/resources/tools/codemods/lit_migration.py) |
| folder. |
| |
| ***promo |
| Most of the basic boilerplate migration steps can be automated using the |
| migration script. |
| *** |
| |
| Prior to running the script, [jscodeshift](https://github.com/facebook/jscodeshift#readme) |
| needs to be downloaded and installed as follows: |
| |
| ``` |
| npm install --prefix ui/webui/resources/tools/codemods jscodeshift |
| ``` |
| |
| The script can be invoked to begin migrating an element from Polymer to Lit as |
| follows (replace the `most_visited.ts` file path with the file being migrated): |
| |
| ``` |
| python3 ui/webui/resources/tools/codemods/lit_migration.py \ |
| --file ui/webui/resources/cr_components/most_visited/most_visited.ts |
| ``` |
| |
| The rest of this section describes migration steps that cannot be automated |
| using the script. |
| |
| ### Computed properties |
| Computed properties in Polymer may not be needed after migrating Lit, if the |
| properties are simply used to populate some part of the element’s template and |
| are not reflected as attributes or double-bound to a Polymer parent. Lit |
| automatically re-renders any changed parts of the template without needing to |
| have the individual properties listed as parameters in the HTML template, so |
| in these cases the computation method can be used directly in the template |
| without specifying parameters. An example of this follows. |
| |
| Polymer HTML template snippet: |
| ``` |
| <cr-button hidden="[[hideButton_]]">Click Me</cr-button> |
| ``` |
| |
| In the Polymer element definition: |
| ``` |
| static get properties() { |
| return { |
| loading: Boolean, |
| showingDialog: Boolean, |
| hideButton_: { |
| type: Boolean, |
| computed: 'computeHideButton_(loading, showingDialog)', |
| }, |
| }; |
| } |
| // Other code goes here |
| |
| private computeHideButton_(): boolean { |
| return !this.loading && !this.showingDialog; |
| } |
| ``` |
| |
| This could be rewritten in Lit, omitting the `hideButton_` property entirely. |
| |
| Equivalent Lit HTML template snippet: |
| ``` |
| <cr-button ?hidden="${this.computeHideButton_()}">Click Me</cr-button> |
| ``` |
| |
| Equivalent Lit element definition: |
| ``` |
| static get properties() { |
| return { |
| loading: {type: Boolean}, |
| showingDialog: {type: Boolean}, |
| }; |
| } |
| // Other code goes here |
| // Anything referenced in the HTML template needs to be protected, not |
| // private. |
| protected computeHideButton_(): boolean { |
| return !this.loading && !this.showingDialog; |
| } |
| ``` |
| |
| In other cases, where computed properties are bound to other elements, used as |
| attributes, or are needed for other internal logic, they can be computed in the |
| `willUpdate()` lifecycle callback when the properties that they depend on change |
| as in the following example: |
| ``` |
| override willUpdate(changedProperties: PropertyValues<this>) { |
| super.willUpdate(changedProperties); |
| |
| if (changedProperties.has('value')) { |
| const values = (this.value || '').split(','); |
| this.multipleValues_ = values.length > 1; |
| } |
| } |
| ``` |
| |
| ### Observers |
| Observer code should be triggered in either the `willUpdate()` lifecycle |
| callback or the `updated()` lifecycle callback, depending on whether it is |
| internal logic or requires accessing the element’s DOM: |
| * Any code that measures or queries the element’s DOM belongs in `updated()` |
| to avoid measuring or querying before rendering has actually completed for |
| the current cycle. |
| * Otherwise, updates to other reactive properties should generally be put in |
| `willUpdate() `so that any resulting template changes from these property |
| updates can be batched with the other changes in a single update, rather |
| than triggering a second round of updates. |
| |
| Consider the following Polymer code, with a complex observer: |
| ``` |
| static get properties() { |
| return { |
| max: Number, |
| min: Number, |
| value: Number, |
| }; |
| } |
| |
| static get observers() { |
| return [ 'onValueSet_(min, max, value)' ]; |
| } |
| |
| private onValueSet_() { |
| this.value = Math.min(Math.max(this.value, this.min), this.max); |
| const demo = this.shadowRoot!.querySelector('#demo'); |
| if (demo) { |
| demo.style.height = `${this.value}px`; |
| } |
| } |
| ``` |
| |
| The Lit migrated code would look as follows, with the observer code split |
| into `willUpdate()` and `updated()` based on whether it accesses the DOM: |
| ``` |
| static override get properties() { |
| return { |
| max: {type: Number}, |
| min: {type: Number}, |
| value: {type: Number}, |
| }; |
| } |
| |
| override willUpdate(changedProperties: PropertyValues<this>) { |
| super.willUpdate(changedProperties); |
| // Clamp value in willUpdate() so we don't trigger a second update |
| // cycle for the same changes. |
| if (changedProperties.has('min') || changedProperties.has('max') || |
| changedProperties.has('value')) { |
| this.value = Math.min(Math.max(this.value, this.min), this.max); |
| } |
| } |
| |
| override updated(changedProperties: PropertyValues<this>) { |
| super.updated(changedProperties); |
| |
| // Querying and modifying the DOM should happen in updated(). |
| if (changedProperties.has('value')) { |
| const demo = this.shadowRoot!.querySelector('#demo'); |
| if (demo) { |
| demo.style.height = `${this.value}px`; |
| } |
| } |
| } |
| ``` |
| |
| ### dom-if |
| Polymer `<template is="dom-if">` should generally be replaced by ternary |
| statements in the `.html.ts` file of the form |
| `${condition ? html`<some-html>` : ''}`. Example simplified from |
| `cr-toolbar`: |
| |
| Polymer `cr_toolbar.html`: |
| ``` |
| <div id="content"> |
| <template is="dom-if" if="[[showMenu]]" restamp> |
| <cr-icon-button id="menuButton" class="no-overlap" |
| iron-icon="cr20:menu" on-click="onMenuClick_"> |
| </cr-icon-button> |
| </template> |
| <h1>[[pageName]]</h1> |
| </div> |
| ``` |
| |
| Lit `cr_toolbar.html.ts`: |
| ``` |
| <div id="content"> |
| ${this.showMenu ? html` |
| <cr-icon-button id="menuButton" class="no-overlap" |
| iron-icon="cr20:menu" @click="${this.onMenuClick_}"> |
| </cr-icon-button>` : ''} |
| <h1>${this.pageName}</h1> |
| </div> |
| ``` |
| |
| ***note |
| Lit conditional rendering is specifically similar to a dom-if template |
| that uses `restamp` (like the example above). This represents the vast majority |
| of cases in Chromium WebUI code. |
| *** |
| |
| ### dom-repeat |
| Polymer `<template is="dom-repeat">` should generally be replaced by a `map()` |
| call, of the form |
| `${this.myItems.map((item, index) => html`<div>item.name</div>`)}`. |
| |
| Unlike in Polymer where events triggered from elements in the template are |
| augmented with data about the item and index they are associated with (i.e. the |
| `DomRepeatEvent` data), event handlers connected to elements in a repeated |
| subtree in Lit receive the original event without any additional data. |
| |
| *** promo |
| When migrating to Lit, event handlers that use the `DomRepeatEvent'`s `item` |
| and/or `index` need to use a different method to get this information. |
| *** |
| |
| One possibility is to set the index or item as data attributes on elements that |
| fire events, as seen in the example that follows. |
| |
| From the Polymer element template: |
| ``` |
| <template is="dom-repeat" items="[[listItems]]"> |
| <div class="item-container [[getSelectedClass_(item, selectedItem)]]"> |
| <cr-button id="[[getItemId_(index)]]" on-click="onItemClick_"> |
| [[item.name]] |
| </cr-button> |
| </div> |
| </template> |
| ``` |
| |
| From the Polymer element definition: |
| ``` |
| private getItemId_(index: number): string { |
| return 'listItemId' + index; |
| } |
| |
| private getSelectedClass_(item: ListItemType): string { |
| return (item === this.selectedItem) ? 'selected' : ''; |
| } |
| |
| private onItemClick_(e: DomRepeatEvent<ListItemType>) { |
| this.selectedItem = e.model.item; |
| // Autoscroll to selected item if it is not completely visible. |
| const list = |
| this.shadowRoot!.querySelectorAll<HTMLElement>('.item-container'); |
| const selectedElement = list[e.model.index]; |
| assert(selectedElement!.classList.contains('selected')); |
| selectedElement!.scrollIntoViewIfNeeded(); |
| } |
| ``` |
| |
| Lit template: |
| ``` |
| ${this.listItems.map((item, index) => html` |
| <div class="item-container ${this.getSelectedClass_(item)}"> |
| <cr-button id="${this.getItemId_(index)}" |
| data-index="${index}" @click="${this.onItemClick_}"> |
| ${item.name} |
| </cr-button> |
| </div> |
| `)} |
| ``` |
| ***note |
| Note the `data-index` setting the `data` attribute on the |
| `cr-button` that triggers the click handler. |
| *** |
| |
| From the Lit element definition file: |
| ``` |
| protected getItemId_(index: number): string { |
| return 'listItemId' + index; |
| } |
| |
| protected getSelectedClass_(item: ListItemType): string { |
| return item === this.selectedItem ? 'selected' : ''; |
| } |
| |
| protected onItemClick_(e: Event) { |
| const currentTarget = e.currentTarget as HTMLElement; |
| |
| // Use dataset to get the index set in the .html.ts template. |
| const index = Number(currentTarget.dataset['index']); |
| this.selectedItem = this.listItems[index]; |
| |
| // Autoscroll to selected item if it is not completely visible. |
| const list = |
| this.shadowRoot!.querySelectorAll<HTMLElement>('.item-container'); |
| const selectedElement = list[index]; |
| selectedElement!.scrollIntoViewIfNeeded(); |
| } |
| ``` |
| |
| ### Using composition for more complex dom-if/dom-repeat cases |
| In more complex cases, composition in the Lit `.html.ts` file may be more |
| readable and easier to maintain than directly replacing dom-ifs and dom-repeats |
| as described above. Composition involves the use of helper functions called from |
| the main `getHtml()` function to define portions of the element’s HTML template. |
| |
| Cases where this has proven useful include: |
| 1. Nested `<template is="dom-if">` and/or `<template is="dom-repeat">` |
| 2. dom-repeats using the `filter` option |
| |
| An example based on a simplified form of `cr-url-list-item`, which uses |
| composition, follows. |
| |
| From the Polymer `.html` template: |
| ``` |
| <div class="folder-and-count"> |
| <template is="dom-if" if="[[shouldShowFolderImages_(size)]]" restamp> |
| <template is="dom-repeat" items="[[imageUrls]]" |
| filter="shouldShowImageUrl_"> |
| <div class="image-container" hidden$="[[!firstImageLoaded_]]"> |
| <img is="cr-auto-img" auto-src="[[item]]" draggable="false"> |
| </div> |
| </template> |
| </template> |
| <div class="count">[[getDisplayedCount_(count)]]</div> |
| </div> |
| ``` |
| |
| From the Polymer element definition: |
| ``` |
| private shouldShowImageUrl_(_url: string, index: number) { |
| return index <= 1; |
| } |
| |
| private shouldShowFolderImages_(): boolean { |
| return this.size !== CrUrlListItemSize.COMPACT; |
| } |
| |
| private getDisplayedCount_() { |
| if (this.count && this.count > 999) { |
| // The square to display the count only fits 3 characters. |
| return '99+'; |
| } |
| |
| return this.count; |
| } |
| ``` |
| |
| From the Lit `.html.ts` template file: |
| ``` |
| import {html} from '//resources/lit/v3_0/lit.rollup.js'; |
| |
| import type {CrUrlListItemElement} from './cr_url_list_item.js'; |
| |
| function getImageHtml(this: CrUrlListItemElement, |
| item: string, index: number) { |
| // Replaces dom-repeat's |filter| property by returning empty if the |
| // filter function returns false for this item and index. |
| if (!this.shouldShowImageUrl_(item, index)) { |
| return ''; |
| } |
| |
| return html` |
| <div class="image-container" ?hidden="${!this.firstImageLoaded_}"> |
| <img is="cr-auto-img" auto-src="${item}" draggable="false"> |
| </div>`; |
| } |
| |
| function getFolderImagesHtml(this: CrUrlListItemElement) { |
| // Replaces dom-if by returning empty string if condition is false. |
| if (!this.shouldShowFolderImages_()) { |
| return ''; |
| } |
| |
| // Replaces dom-repeat with map() |
| return html`${ |
| this.imageUrls.map( |
| (item, index) => getImageHtml.bind(this)(item, index))}`; |
| } |
| |
| export function getHtml(this: CrUrlListItemElement) { |
| return html` |
| /* other content here */ |
| <div class="folder-and-count"> |
| ${getFolderImagesHtml.bind(this)()} |
| <div class="count">${this.getDisplayedCount_()}</div> |
| </div> |
| /* other content */ |
| `; |
| } |
| ``` |
| |
| From the Lit element definition: |
| ``` |
| protected getDisplayedCount_(): string { |
| if (this.count && this.count > 999) { |
| // The square to display the count only fits 3 characters. |
| return '99+'; |
| } |
| |
| return this.count === undefined ? '' : this.count.toString(); |
| } |
| |
| protected shouldShowImageUrl_(_url: string, index: number): boolean { |
| return index <= 1; |
| } |
| |
| protected shouldShowFolderImages_(): boolean { |
| return this.size !== CrUrlListItemSize.COMPACT; |
| } |
| ``` |
| |
| ### Migrating iron-list clients |
| Currently, there is no Lit alternative for the `iron-list` element. For cases |
| where `iron-list` is used that do not require a virtual list (i.e., number of |
| list items is not very large), `iron-list` may be replaced with a `map()` |
| pattern, as described above for `dom-repeat`. |
| |
| For cases that need a virtual list, note the following: |
| |
| 1. `iron-list`'s parent element *must* be a Polymer element, because |
| `iron-list` accepts a `<template>`. Lit data bindings do not work inside |
| `<template>`s. |
| 2. Elements that are used as children in an `iron-list` may be migrated to Lit, |
| but due to differences in rendering timing between Polymer and Lit, it is |
| important to manually fire an `iron-resize` event from the child's |
| `updated()` lifecycle callback whenever any property that impacts the height |
| of the child element has changed. See example below: |
| |
| From the `list_parent.html` template (must be Polymer) |
| ``` |
| <iron-list id="list" items="[[listItems_]]" as="item"> |
| <template> |
| <custom-item description="[[item.description]]" name="[[item.name]]" |
| on-click="onListItemClick_"> |
| </custom-item> |
| </template> |
| </iron-list> |
| ``` |
| |
| From the child `custom_item.html.ts` template: |
| ``` |
| <div class="name">${this.name}</div> |
| <div class="description" ?hidden="${!this.description}"> |
| ${this.description} |
| </div> |
| ``` |
| |
| In this case, the value of `description` impacts the height of the child |
| item. If `iron-list` is not notified of when the child is done with rendering |
| a change to this property, it may compute the child's height incorrectly, and |
| display gaps or overlap in the list. To prevent this, the child item should |
| fire `iron-resize` in `updated()` if its `description` property changes. |
| |
| From `custom_item.ts`: |
| ``` |
| override updated(changedProperties: PropertyValues<this>) { |
| super.updated(changedProperties); |
| if (changedProperties.has('description')) { |
| this.fire('iron-resize'); |
| } |
| } |
| ``` |
| |
| ## Additional Lit and Polymer differences |
| ### Testing |
| |
| A large number of unit tests do something like the following: |
| ``` |
| // Validate that the input is disabled when invalid is set. |
| myTestElement.invalid = true; |
| assertTrue(myTestElement.$.input.disabled); |
| ``` |
| |
| This assumes that setting `invalid` synchronously updates the DOM of the test |
| element. If the test element is a Lit-based element, this is no longer the case, |
| and we need to wait for a render cycle to complete. There are a couple of ways |
| to do this: |
| |
| 1. Preferred: Use `await microtasksFinished()` test helper method from |
| chrome://webui-test/test\_util.js. This method awaits a setTimeout of 0 which |
| allows any render cycles to complete (useful if there may be multiple Lit |
| elements that need to finish updating before assertions). |
| 2. Directly `await myTestElement.updateComplete` (waits for the test element’s |
| render cycle). |
| |
| Updated example: |
| ``` |
| // Validate that the input is disabled when invalid is set. |
| myTestElement.invalid = true; |
| await microtasksFinished(); |
| assertTrue(myTestElement.$.input.disabled); |
| ``` |
| |
| *** note |
| Note: for many test cases, it is less fragile to directly wait on an event |
| or BrowserProxy call that should be triggered by an action in a test, instead |
| of either assuming everything is synchronous or waiting on framework-dependent |
| test helpers like `microtasksFinished()` or the Polymer |
| `waitAfterNextRender()/flushTasks()` that do not actually guarantee that |
| anything specific has happened. |
| *** |
| |
| ### Use of the `hidden` attribute |
| As documented in the [styleguide](https://chromium.googlesource.com/chromium/src/+/HEAD/styleguide/web/web.md#Polymer), |
| in Polymer the `hidden` attribute was recommended over `<template is="dom-if">` |
| for cases of showing and hiding small amounts of HTML or a single element. In |
| Lit, since conditional rendering does not rely on adding a custom element like |
| dom-if, there is not the same potential performance downside to using |
| conditional rendering instead of the `hidden` attribute. |
| |
| ***promo |
| In Lit, conditional rendering can be used instead of the |
| `hidden` attribute in most cases, and should always be used anywhere |
| `<template is="dom-if">` would previously have been used in Polymer code. |
| *** |