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.