Blog

Create a custom component with Lit

By  
Tatu Lund
Tatu Lund
·
On Jun 28, 2022 6:06:01 PM
·

Learn how to make your custom component a first-class citizen in the Lumo design system.

In this tutorial, I will share some learnings I have gathered with my team when we have implemented custom components for Vaadin. When you start creating your custom components, I would assume you like them to have a similar look and feel to our stock components, so that they fit in with the rest. There are a couple of things to help you achieve this. I am using the TabSheet component as a case example. The code examples and information in this tutorial are applicable to both Vaadin 14 and Vaadin 23.

The basics

The concept of a web component is actually easy to understand for Java developers. Nowadays, HTML is an extensible markup language; you can define your own tags. There is native support for this in modern browsers. Lit is a library that reduces some of the need to write boilerplate code when doing this. It is especially useful when using TypeScript instead of JavaScript as your implementation language. Being typed, it is more approachable for a Java-bred full stack developer.

Most importantly, a web component adds the concept of protecting the internal implementation of the component. We call this Shadow DOM. Global CSS is not applied in Shadow DOM, elements in Shadow DOM are not visible outside of the component, and components’ internal CSS is not applied elsewhere. So this works pretty much like the private class members in Java. So components become more manageable.

The outline of the web component implementation with Lit looks like this:

import { css, html, LitElement, TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators';

@customElement('tab-sheet')
export class TabSheet extends LitElement {

  @property()
  selected = 0;

  static get styles() {
    return css`

      … the internal CSS styles
    `;
  }
  render() {
    return html`
      … the internal HTML content
    `;
  }
}

The basic structure is simple. You need to define your tag with an @customElement decorator. Here, it is tab-sheet. The rule is that a name needs to have at least one hyphen. Single-word names are reserved for built-in HTML tags. 

See more about naming custom web components: https://www.webcomponents.org/community/articles/how-should-i-name-my-element

In addition to this, we need to have the function render, which returns the HTML content of the component. There may be a styles function to return the CSS used inside the component. The properties are tagged with an @property decorator.

Use existing Vaadin components as building blocks

The first step in achieving good look and feel compatibility with the Lumo design system is to use existing Vaadin components as building blocks. In my component, I have decided to use vaadin-tabs and vaadin-tab as building blocks. I am also using vaadin-icon to wrap icons. In addition, I can naturally use any native HTML markup I need.

import '@vaadin/vaadin-tabs';
import '@vaadin/vaadin-icon';
…
@customElement('tab-sheet')
export class TabSheet extends LitElement {
…
  render() {
    return html`
      <div part="container" class="container">
        <vaadin-tabs part="tabs" orientation="${this.orientation}"
theme="${this.theme}" .selected=${this.selected}
@selected-changed="${this.selectedChanged}">
          ${this._getTabs().map(
        (template) => template
      )}
        </vaadin-tabs>
        ${this._getSlots().map((tab) => html`
          <div class="sheet" part="sheet" id=${tab} style="display: none">
            <slot name=${tab}>
            </slot>
          </div>`
        )}
      </div>
    `;
  }
}

The ${...} syntax in the markup is Lit’s data binding definition. These can also contain TypeScript function calls and code. This allows us to dynamically build HTML, as in our example, where we generate a div holding a slot and a tab for each child, which have been appended to our component. @selected-changed="${this.selectedChanged} binds a function that is called when a tab is selected by the user.

 selectedChanged(e: CustomEvent) {
    const page = e.detail.value;
    const tab = this.getTab(page);
    this._doSelectTab(page);
    const details : JSON = <JSON><unknown>{
      "index": page,
      "caption": this.getTabCaption(tab),
      "tab": tab
    }
    const event = new CustomEvent('tab-changed', {
      detail: details,
      composed: true,
      cancelable: true,
      bubbles: true
    });
    this.dispatchEvent(event);
  }

In the selectedChanged function, I am composing and firing a new CustomEvent that can be observed by the user of the TabSheet component and also in our Java implementation.

Tip 1: If your component is supposed to have user-defined content or semantic content, it is better to have them in the light DOM, i.e. “public”. One way to achieve this is to have slots for such content. In the TabSheet component, the content of the tabs is a natural example of this:

          <div class="sheet" part="sheet" id=${tab} style="display: none">
            <slot name=${tab}>
            </slot>
          </div>`

Tip 2: Propagate theme attribute to your Shadow DOM parts.

      <vaadin-tabs part="tabs" orientation="${this.orientation}" theme="${this.theme}" .selected=${this.selected} @selected-changed="${this.selectedChanged}">
          ${this._getTabs().map(
        (template) => template
      )}
        </vaadin-tabs>

This will allow users of the component to utilize the theme variants of the vaadin-tabs and vaadin-tab components.

Use Lumo custom properties in your internal styles

When writing CSS for the component, instead of using fixed values for colors, roundness, etc., it is good practice to parameterize them. And, as I want to make this component to fit the Lumo design system, instead of using my own custom properties, I use custom properties of Lumo. This way, the component works seamlessly with the global theming of the application. For example, I use a couple of contrast colors to make the component fit nicely with other Vaadin components. If you change the values of these properties in your global styles, TabSheet, TextArea, TextField, etc. use the same accents, and go hand in hand.

@customElement('tab-sheet')
export class TabSheet extends LitElement {
…
  static get styles() {
    return css`
        …
        :host([orientation="vertical"][theme~="bordered"]) [part="container"] {
          box-shadow: 0 0 0 1px var(--lumo-contrast-30pct);
          border-radius: var(--lumo-border-radius-m);
        }
        :host([theme~="bordered"]) [part="sheet"] {
          box-shadow: 0 0 0 1px var(--lumo-contrast-30pct);
          border-radius: var(--lumo-border-radius-m);
        }
        :host([orientation="vertical"][theme~="bordered"]) [part="sheet"] {
         border-top-left-radius: unset;
        }
        [part="tab"][theme~="bordered"] {
          background: var(--lumo-contrast-10pct);
        }
        [part="tab"][theme~="bordered"][orientation="horizontal"] {
          border: 1px solid var(--lumo-contrast-30pct); 
          border-bottom: none; 
          border-top-left-radius: var(--lumo-border-radius-m);
          border-top-right-radius: var(--lumo-border-radius-m);
        }
        [part="tab"][theme~="bordered"][selected] {
          background: var(--lumo-base-color);
          border-left: 1px solid var(--lumo-contrast-30pct);
    }
  `;
  }
  …
}

Tip 3: Create variants of the styling using theme attribute values. Here, I have defined a “bordered” variant, which looks like this. 

Implementing ThemableMixin

You are probably familiar with the fact that some parts of the Vaadin components are allowed to be styled using CSS injected into web components. It is possible to style the custom component the same way by implementing ThemableMixin. This is quite straightforward. We just need to include it as an extended class by the component. The ThemableMixin calls the is function to obtain the tag name. So it needs to be implemented, which is not normally required in Lit-based components. 

import { ThemableMixin } from
'@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
…

@customElement('tab-sheet')
export class TabSheet extends ThemableMixin(LitElement) {
  static get is() {
    return 'tab-sheet';
  }
}

Now when the TabSheet is used in an application, and it is desired to have a “blue” variant of the component, it is possible to create: 

frontend/themes/mytheme/components/tab-sheet.css 

:host([theme~="blue"]) [part="tab"] {
background: aliceblue;
}
:host([theme~="blue"]) [part="sheet"] { 
background: aliceblue;
}

The propagation of the theme attribute and careful usage of the part attribute names makes this feature useful.

Creating the Java API and integration with Flow

In my case I have the tab-sheet.ts in the src/main/resources/META-INF/resources/frontend folder of the JAR module of the component. 

There are a couple of mandatory things I need to add into my Java class that uses this web component. The first thing is an @JsModule annotation, which defines which file contains the implementation, and an @Tag annotation, which specifies what the tag name is. As I am using some built-in components in my markup, I add @Uses annotations for these. This will ensure that the right content is included in the frontend bundle in production builds.

@JsModule("./tab-sheet.ts")
@Tag("tab-sheet")
@Uses(Icon.class)
@Uses(Tabs.class)
@Uses(Tab.class)
public class TabSheet extends Component implements HasSize, HasTheme {
   …
}

The Java class needs to be a Vaadin component, so I need to extend either the LitTemplate or the Component classes, or some other elementary Vaadin class that itself extends Component. As I do not need an @Id binding of the internal elements in my component, I have used Component, as it has a lighter footprint.

Tip 4: Implement the needed Vaadin mixin interfaces to add a standard API. In my case, I have added HasSize and HasTheme.

Building the API is relatively easy. For example, I have selected tab index as a property in my web component, so I just use the Element API to set its value.


/**
     * Set selected tab using index. This will fire TabChangeEvent. Sheet
     * attached to the tab will be shown.
     * 
     * @param index
     *            Index of the tab, base 0.
     */
    public void setSelected(int index) {
        getElement().setProperty("selected", index);
    }

Lit has an internal mechanism to observe property changes. Thus, when the value of the bound property is changed, the render function of the web component is automatically called, and the component is redrawn to show the right tab.

The @DomEvent annotation can be used to listen to the custom event I fired when the tab was changed by the user.

    /**
     * TabChangeEvent is fired when user changes the
     *
     * @param <R>
     *            Parameter is here, so that TabSheet can be extended.
     */
    @DomEvent("tab-changed")
    public static class TabChangedEvent<R extends TabSheet>
            extends ComponentEvent<TabSheet> {
        private int index;
        private TabSheet source;
        private String caption;
        private String tab;

        public TabChangedEvent(TabSheet source, boolean fromClient,
                @EventData("event.detail") JsonObject details) {
            super(source, fromClient);
            this.index = (int) details.getNumber("index");
            this.tab = details.getString("tab");
            this.caption = details.getString("caption");
            this.source = source;
        }
       …
    }

Furthermore, I can add an API for the user of the component to listen to this event.

   /**
     * Add listener for Tab change events.
     * 
     * @param listener
     *            Functional interface, lambda expression of the listener
     *            callback function.
     * @return Listener registration. Use {@link Registration#remove()} to
     *         remove the listener.
     */
    public Registration addTabChangedListener(
            ComponentEventListener<TabChangedEvent<TabSheet>> listener) {
        return addListener(TabChangedEvent.class,
                (ComponentEventListener) listener);
    }

New Components can be added to TabSheet by simply appending their Elements as children to the TabSheet’s root element. Again, Lit will take care of calling the render function if needed.


     /**
     * /** Add a new component to the TabSheet as a new sheet.
     * 
     * @param caption
     *            Caption string used in corresponding Tab
     * @param content
     *            The content Component
     * @param icon
     *            Icon to be used on tab, can be null
     */
    public void addTab(String caption, Component content, VaadinIcon icon) {
        Objects.requireNonNull(caption, "caption must be defined");
        Objects.requireNonNull(content, "content must be defined");
        content.getElement().setAttribute("tabcaption", caption);
        if (icon != null) {
            content.getElement().setAttribute("tabicon", getIcon(icon));
        }
        getElement().appendChild(content.getElement());
    }

Finally, I enable the Java user to apply the bordered and other theme variants of the component, by defining the TabSheetVariant enum.

   public enum TabSheetVariant 
        LUMO_ICON_ON_TOP("icon-on-top"), LUMO_CENTERED("centered"), LUMO_SMALL(
                "small"), LUMO_MINIMAL("minimal"), LUMO_HIDE_SCROLL_BUTTONS(
                        "hide-scroll-buttons"), LUMO_EQUAL_WIDTH_TABS(
                                "equal-width-tabs"), BORDERED(
                                        "bordered"), MATERIAL_FIXED("fixed");
        ...
    }

An API to add these to used theme names:

   /**
     * Adds theme variants to the component.
     *
     * @param variants
     *            theme variants to add
     */
    public void addThemeVariants(TabSheetVariant... variants) {
        getThemeNames()
                .addAll(Stream.of(variants).map(TabSheetVariant::getVariantName)
                        .collect(Collectors.toList()));
    }

Summa summarum, I now have a TabSheet component that fully fits the family of Vaadin components, following the conventions in both look and feel, as well as the APIs in the HTML/TypeScript and Java. 

Additional resources

Want to know more? The learning center has three in-depth training sessions on how to create custom components using Lit with Vaadin. They are labeled Vaadin 14, but there is no difference between the Vaadin 14 and 23 series.

https://vaadin.com/learn/training/v14-custom-component-lit-basics

https://vaadin.com/learn/training/v14-custom-component-lit-html

https://vaadin.com/learn/training/v14-custom-component-lit-data-binding

The full code of the component can be found on GitHub. There are a number of small details that I have skipped in this tutorial. The project also has a full set of integration tests implemented using TestBench.

https://github.com/tatulund/tabsheet

Tatu Lund
Tatu Lund
Tatu Lund has a long experience as product manager in different industries. Now he is head of the team delivering Vaadin support and training services. You can follow him on Twitter - @ TatuLund
Other posts by Tatu Lund