docs/webui/webui_lit_style_guide.md
[TOC]
This style guide outlines coding standards and best practices for Chromium WebUI development with Lit. These guidelines are intended to ensure code quality, consistency, and performance across the codebase. They supplement the Chromium Web Development Style Guide.
A Lit element class definition should have the following structure.
// my_button.ts example contents
import {getCss} from './my_button.css.js';
import {getHtml} from './my_button.html.js';
class MyButtonElement extends CrLitElement {
static get is() {
return 'my-button';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
...
};
}
}
declare global {
interface HTMLElementTagNameMap {
'cr-button': MyButtonElement;
}
}
customElements.define(MyButtonElement.is, MyButtonElement);
Specifically the following pieces are required:
Element suffix, which matches the
naming of native HTML elements (for example HTMLButtonElement), and
clearly conveys that a class is a UI component.static get is() {...} holds the DOM name of the custom element.interface HTMLElementTagNameMap {...} informs the TypeScript compiler
about the association between the DOM name and the class name, allowing it
to infer the type in document.createElement or querySelector,
querySelectorAll calls. Must be placed after the class definition in the
same file.customElements.define(...) registers the custom element at runtime, so
that the browser knows which class to instantiate when encountering the
corresponding DOM name. Must be placed after the class definition in the
same file..html.ts template
file using getHtml(), and is not defined inline. Exception: dummy test
elements and low level cr_elements implementing customized rendering
behavior may use html and render directly.Methods (if applicable) must be defined in the following order for consistency:
is, styles, render, properties, constructor, connectedCallback,
disconnectedCallback, willUpdate, firstUpdated, updated.
Class fields should be defined after the properties getter and before the
constructor or, if no constructor exists, before any subsequent methods in the
method definition order.
Moreover, separate class fields that have a corresponding Lit reactive property from class fields that don't correspond to any property with a blank line. For example:
static override get properties() {
return {
isActive: {type: Boolean},
isDefault: {type: Boolean},
};
}
accessor isActive: boolean = false;
accessor isDefault: boolean = false;
private browserProxy: BrowserProxy = BrowserProxyImpl.getInstance();
this.fire(...) for firing events where possible.Do not:
this.dispatchEvent(new CustomEvent(
'event-one', {bubbles: true, composed: true}));
this.dispatchEvent(new CustomEvent(
'event-two', {bubbles: true, composed: true, detail: someValue}));
Do:
this.fire('event-one');
this.fire('event-two', someValue);
// OK to use dispatchEvent() when firing non-bubbling or non-composed events.
this.dispatchEvent(new CustomEvent('event-three', {detail: someValue}));
Note: When a non-bubbling or non-composed event should be fired use
dispatchEvent() directly, as fire() creates bubbling and composed events.
Templates should remain declarative and logic-free. Avoid all of the following
in the .html.ts file:
let or const.getHtml() function.Instead:
.ts).The overall goal is to separate the HTML template from the element's business
logic as much as possible, and draw a clear boundary between the
responsibilities of each file. .html.ts files should mostly look like regular
HTML code with a bit of Lit extras (Lit expressions), and any non-trivial TS
logic should be delegated to helper methods in the .ts file. Similarly, the
.ts file should not be responsible for defining any portion of the template
directly, as this should be done in the .html.ts file.
Do not:
// my_element.html.ts
export function getHtml(this: MyElement) {
const isVisible = this.someCondition && this.otherCondition;
return html`
<div ?hidden="${isVisible}">...</div>
`;
}
Do:
// my_element.ts
protected accessor isVisible = false;
// my_element.html.ts
export function getHtml(this: MyElement) {
return html`
<div ?hidden="${!this.isVisible}">...</div>
`;
}
In most cases, a chunk of HTML that is needed across multiple locations in the DOM should be refactored into a custom element that can be used as needed. Custom elements are the canonical way of creating reusable chunks of template, just as shared styles are the way to share CSS between elements and mixins or helper methods/classes can be used to share TypeScript logic/behavior.
However, there are some cases where a different solution may be preferred.
Example:
// my_element.html.ts
export function getHtml(this: MyElement) {
// Small amount of repeated HTML for the cr-button is okay.
return html`
${this.submitButtonFirst ? html`
<cr-button id="submit" @click="${this.onSubmitClick}">
$i18n{submit}
</cr-button>
` : ''}
<cr-button id="cancel" @click="${this.onCancelClick}">
$i18n{cancel}
</cr-button>
${!this.submitButtonFirst ? html`
<cr-button id="submit" @click="${this.onSubmitClick}">
$i18n{submit}
</cr-button>
` : ''}
`;
}
Do not:
/* my_element.css */
.fancy-css {
border: 4px solid blue;
}
// my_element.html.ts
export function getHtml(this: MyElement) {
const button = html`
<cr-button id="submit" @click="${this.onSubmitClick}">
$i18n{submit}
</cr-button>`;
return html`
${this.fancyStyleEnabled ? html`
<div class="fancy-css">${button}</div>
` : button}
`;
}
Do:
/* my_element.css */
:host([fancy-style-enabled]) .wrapper {
border: 4px solid blue;
}
// my_element.html.ts
export function getHtml(this: MyElement) {
return html`
<div class="wrapper">
<cr-button id="submit" @click="${this.onSubmitClick}">
$i18n{submit}
</cr-button>
</div>
`;
}
Example:
// my_element_fancy_button.html.ts
export function getHtml(this: MyElement) {
return html`
<fancy-button .prop1="${this.prop1}"
.prop2="${this.prop2}"
.prop3="${this.prop3}"
.prop4="${this.prop4}"
.prop5="${this.prop5}"
.prop6="${this.prop6}"
.prop7="${this.prop7}"
.prop8="${this.prop8}"
.prop9="${this.prop9}"
@one="${this.onButtonOne}"
@two="${this.onButtonTwo}"
@three="${this.onButtonThree}"
@four="${this.onButtonFour}">
</fancy-button>
`;
}
// my_element.html.ts
import {getHtml as getFancyButtonHtml} from './my_element_fancy_button.html.js';
export function getHtml(this: MyElement) {
return html`
${this.compact ? html`
<div class="compact">
${getFancyButtonHtml.bind(this)()}
<cr-input id="input></cr-input>
</div>
` : html`
<div class="tall">
<cr-textarea id="input></cr-textarea>
<fancy-menu>
${getFancyButtonHtml.bind(this)()}
</fancy-menu>
</div>
`}
Do not use inline arrow functions (lambdas) in templates to pass data to event handlers. Creating new functions on every render hurts performance and readability.
A common reason to reach for lambdas is to bind additional data to an event
handler. Instead, bind a unique identifier, commonly an index, to a data-*
attribute on the element and retrieve it in the handler.
Do not:
${this.items.map(item => html`
<button @click="${() => this.onItemClick(item)}">${item}</button>
`)}
Do:
${this.items.map((item, index) => html`
<button data-index="${index}" @click="${this.onItemClick}">${item}</button>
`)}
protected onItemClick(e: Event) {
const currentTarget = e.currentTarget as HTMLElement;
const item = items[Number(currentTarget.dataset['index'])];
}
When using map to render lists, prefer using "item" as the loop variable
name for consistency. Nested loops may use specific names to avoid confusion.
${this.items.map(item => html`...`)}
Wrap the return statement of your getHtml function or render method in // clang-format off and // clang-format on comments. This prevents
clang-format from mangling the HTML template string, ensuring it remains
readable.
export function getHtml(this: MyCrLitElement) {
// clang-format off
return html`
<!--_html_template_start_-->
<div>...</div>
<!--_html_template_end_-->`;
// clang-format on
}
-element suffix._element suffix.Element suffix.Where the pattern to derive the class and file names from the DOM name is
foo-bar-baz<OptionalPrefix>FooBarBazElement, or BarBazElement,
or BazElementfoo_bar_baz.{css,html,ts}, bar_baz.{css,html,ts} or
baz.{css,html,ts},Event names should be in lower kebab case.
Event names should state what happened, not what will happen by whoever handles the event.
close-click, selection-change-clickclose-dialog, update-selectionEvent handlers should be named using the pattern on<OptionalContext>[Event].
onRetryClick, onSelectionChanged, onPointerdownonRetryClicked, handleRetry, selectionHandlerIf the DOM node that the event handler is bound to has an ID, the
<OptionalContext> portion of the handler name should be that ID.
Do not:
<button id="foo" @click="${this.onBarClick}"></button>
Do:
<button id="foo" @click="${this.onClick}"></button>
<button id="foo" @click="${this.onFooClick}"></button>
For elements that always exist in the Shadow DOM (not part of any conditional
rendering), prefer this.$.<id> to access them. This provides a strictly typed
and consistent way to access elements compared to
this.shadowRoot.querySelector.
Do not:
const button = this.shadowRoot.querySelector('#submitButton');
Do:
// In your class definition
export interface MyElement {
$: {
submitButton: CrButtonElement,
}
}
// In your code
const button = this.$.submitButton;