Deep dive and best practices
This tutorial will cover some finer details and best practices when designing and developing UI5 Web Components.
Metadata deep dive ​
The metadata defines the public API of your component. Among other things, here you define:
- the tag name
- what properties/attributes (and of what type) your component supports
- what slots your component supports
- what events your component fires
Tag ​
The tag name must include a -
as required for any custom element. The tag is declared using @customElement
decorator:
@custom("my-component")
//or
@custom({
tag: "my-component"
})
and then the usage is:
<my-component></my-component>
The tag
, as defined is referred to as the "pure tag", meaning it is not suffixed (scoping is not used).
Important: the pure tag name of every UI5 Web Component is always set as an attribute to the component too.
For example, when you create a ui5-button
:
<ui5-button id="b1" class="button1" design="Emphasized"></ui5-button>
the framework will create an empty attribute with the name ui5-button
too, so the actual DOM would look like this:
<ui5-button id="b1" class="button1" design="Emphasized" ui5-button></ui5-button>
Even if a suffix for tag names is configured (when scoping is enabled), the attribute with the pure tag name will be the same.
For example, if the configured suffix is -demo
and all components are used with this suffix:
<ui5-button-demo id="b1" class="button1" design="Emphasized" ui5-button></ui5-button-demo>
the attribute will still be the same (ui5-button
as opposed to the tag name of ui5-button-demo
).
Therefore, the best practice when developing UI5 Web Components is to write CSS selectors for the shadow roots using attribute selectors, instead of tag selectors.
For example, if the Demo.hbs
file looks like this:
<div class="my-component">
<ui5-button id="openBtn">Open</ui5-button>
<div>
<slot></slot>
</div>
<ui5-list></ui5-list>
</div>
you should not write selectors by tag name for other components in the Demo.css
file:
ui5-button {
width: 50px;
}
because, as stated above, the tag name could be suffixed and is not guaranteed to always be the same as the pure tag name.
Instead, use the attribute selector:
[ui5-button] {
width: 50px;
}
or another type of selector (for example by ID):
#openBtn {
width: 50px;
}
Properties ​
Properties are managed state​
The framework will create a getter/setter pair on your component's prototype for each property, defined with @property
decorator.
For example, defining text property:
@property
text = ""
you can use the text
getter/setter on this component's instances:
let t = myComponent.text;
myComponent.text = "New text";
Whenever text
is read or set, the framework-defined getter/setter will be called and thus the framework will be in control of the property.
Properties vs attributes​
The properties
section defines both properties and attributes for your component. By default, for each property (camelCase
name) an attribute with the
same name but in kebab-case
is supported. Properties of type Object
have no attribute counterparts. If you wish to not have an attribute for a given property regardless of type, you can configure it with noAttribute: true
setting.
Public vs private properties​
The framework does not distinguish between public and private properties. You can treat some properties as private in a sense that you can document them as such and not advertise them to users.
The usual convention is that private properties start with an _
, but this is not mandatory. In the end, all properties defined in the metadata, public or private,
are component state, therefore cause the component to be invalidated and subsequently re-rendered, when changed.
Property types and default values​
The most common types of properties are String
, Boolean
, Object
and Number
.
Most property types can have a default but Boolean
should always false
by default.
Examples​
Example of defining properties:
class MyComponent extends UI5Element {
@property()
text = "Hello";
@property({ type: Number, noAttribute: true })
width = 1024;
@property({ type: Number })
scale = 0.5;
@property({ type: Object })
data = {};
/**
* @private
*/
@property({ type: Boolean })
_isPhone = {};
}
Here text
, width
, scale
and data
are public properties, and _isPhone
private, but only by convention. If the user (or the component internally) changes any of these properties, the component will be invalidated.
Best practices for using properties​
The best practice is to never change public properties from within the component (they are owned by the application) unless the property changes due to user interaction (f.e. the user typed in an input - so you change the value
property; or the user clicked a checkbox - and you flip the checked
property). It is also
a best practice to always fire an event if you change a public property due to user interaction, to let the application know and synchronize its own state.
As for private properties, the best practice is to only change them internally and never let the application know about their existence.
Both public and private properties are great ways to create CSS selectors for your component with the :host()
selector. The :host()
selector targets the custom element itself, and can be combined with other selectors:
:host {
height: 5rem;
width: 5rem;
}
:host([size="XS"]) {
height: 2rem;
width: 2rem;
}
<my-comopnent size="XS"></my-comopnent> <!-- :host() targets my-component -->
Here for example, if the size
property (respectively the attribute with the same name) is set to XS
, the component's dimensions will be changed from 5rem
to 2rem
.
Using attribute selectors is the best practice as you don't have to set CSS classes on your component - you can write CSS selectors with :host()
by attribute.
Metadata properties vs standard JS properties​
It is important not to confuse properties defined with @property
decorator with regular Javascript properties.
You can create any number of properties on your component's instance, f.e.:
constructor() {
super();
this._isMobile = false;
}
However, only metadata-defined properties are managed by the framework: cause invalidation and are converted to/from attributes. Feel free to create as many regular JS properties for the purpose of your component's functionality as you need, but bear in mind that they will not be managed by the framework.
Understanding rendering​
What is rendering? ​
In the context of UI5 Web Components the notion of rendering means creating the content of a shadow root (building the shadow DOM).
Physical and logical components ​
Each component that has a template
described in @customElement
decorator will be rendered (will have its shadow DOM built) initially and every time it gets invalidated.
Example:
import MyComponentTemplate from "./generated/templates/MyComponentTemplate.lit.js";
@customElement({
template: MyComponentTemplate
})
Components that do not have template
defined in @customElement
decorator are considered logical or marker elements only. These components are never rendered (do not have a shadow root at all)
and their only purpose is to serve as items for higher-order components. The classical example of a logical component is a select option.
Example:
<ui5-calendar>
<ui5-date></ui5-date>
</ui5-calendar>
The ui5-date
component does not have template, and is therefore never rendered. However, the ui5-calendar
component, which is a physical component that has a template,
renders HTML corresponding to each of its children (ui5-date
instances) as part of its own shadow DOM.
What is invalidation? ​
Invalidation means scheduling an already rendered component for asynchronous re-rendering (in the next animation frame). If an already invalidated component gets changed again, before having been re-rendered, this will have no downside - it's in the queue of components to be re-rendered anyway.
Important: when a component is re-rendered, only the parts of its shadow DOM, dependent on the changed properties/slots are changed, which makes most updates very fast.
A component becomes invalidated whenever:
- a metadata-defined property changes (not regular properties that f.e. you define in the constructor)
- children are added/removed/rearranged in any slot declared with
@slot
decorator. - a slotted child in a slot configured with
invalidateOnChildChange: true
is invalidated.
Changes to properties always cause an invalidation. No specific metadata configuration is needed.
@property()
text?: string;
Whenever text
changes, the component will be invalidated.
As we defined earlier there two kind of slots - unnamed and named. Unnamed slots do not cause an invalidation. Most components do not need to render differently based on whether they have any slotted children or not. This component will not invalidate when children are added/removed from any of its unnamed slots.
However, some components render differently based on whether they have children or not (e.g. show counters/other UX elements for the number of children, f.e. carousel; or have special styles when empty or have a child in a specific slot, f.e. button with an icon).
If that is the case for the component you're building, you need to define slot using slot
decorator. Thus, your component will become invalidated whenever children are added, removed or swap places in any of its slots.
@slot({ type: HTMLElement, "default": true })
content!: Array<HTMLElement>;
@slot()
header!: Array<HTMLElement>;
@slot()
footer!: Array<HTMLElement>;
Now that this component has slots defined with @slot
decorator, changes to each of these slots will trigger an invalidation.
And finally, there are components that not only need to render differently based on the number/type of children they have, but they must also get invalidated whenever their children change. This holds true for all components that work with abstract items (such as select with options, combo box with combo box items) because these abstract items do not have a template (do not render themselves) and therefore rely on their parent to render some DOM for them in its own shadow root. So, when they get invalidated, they must also invalidate their parent.
@slot({ type: HTMLElement, "default": true, invalidateOnChildChange: true })
content!: Array<HTMLElement>;
@slot()
header!: Array<HTMLElement>;
@slot()
footer!: Array<HTMLElement>;
Only changes to children in the "content" slot will trigger invalidation for this component. Note that invalidateOnChildChange
is defined per slot.
Finally, invalidateOnChildChange
allows for more fine-granular rules when exactly children can invalidate their parents.
Lifecycle hooks​
Using the right lifecycle hook for the task is crucial to a well-designed and performant component.
constructor
​
Use the constructor for one-time initialization tasks.
What to do:
- bind functions to
this
(very common when using theResizeHandler
helper class) - do one-time work when the first instance of a given component is created (f.e. instantiate a helper class or attach a special event listener to the
window
object)
What not to do:
- anything rendering-related (use
onBeforeRendering
/onAfterRendering
) - anything related to the state (use
onBeforeRendering
) - anything requiring DOM manipulation (the component isn't attached to the DOM yet - use
onAfterRendering
oronEnterDOM
/onExitDOM
)
Example:
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js";
@customElement({
tag: "my-component",
})
class MyComponent extends UI5Element {
_itemNavigation: ItemNavigation;
_handleResizeBound: ResizeObserverCallback;
constructor() {
super();
// bind a method once so that you can pass the same function to register/deregister-based helpers
this._handleResizeBound = this._handleResize.bind(this);
// do one-time work when the first instance of a component is created
if (!isGlobalHandlerAttached) {
document.addEventListener("mouseup", this._deactivate);
isGlobalHandlerAttached = true;
}
// initialize a helper class for the instance
this._itemNavigation = new ItemNavigation(this, {
navigationMode: NavigationMode.Horizontal,
getItemsCallback: () => this._getFocusableItems(),
});
}
}
onBeforeRendering
​
Use onBeforeRendering
to prepare variables to be used in the .hbs
template.
What to do:
- prepare calculated (derived) state for use in the renderer
What not to do:
- do not try to access the DOM (use
onAfterRendering
instead)
Let's take for example a component with the following metadata:
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
@customElement({
tag: "my-component",
})
class MyComponent extends UI5Element {
@property()
filter = "";
@slot({ type: HTMLElement, individualSlots: true, "default": true })
items!: Array<HTMLElement>
}
This component has a filter
property and a default
slot that we want to call items
(thus accessible with this.items
).
Let's imagine we want to only show the items whose name
property matches the value of our filter
property - so we filter the items by name.
class MyComponent extends UI5Element {
@property()
filter = "";
@slot({ type: HTMLElement, individualSlots: true, "default": true })
items!: Array<HTMLElement>
this._filteredItems = [];
onBeforeRendering() {
this._filteredItems = this.items.filter(item => item.name.includes(this.filter));
}
}
In onBeforeRendering
we prepare a _filteredItems
array with some of the component's children (only the ones that have the this.filter
text as part of their name
property)
And finally, in the .hbs
template we have for example:
We loop over the _fiteredItems
array that we prepared in onBeforeRendering
and for each child we render a slot
based on the child's _individualSlot
property,
created automatically by the framework due to the default slot's metadata configuration (individualSlots: true
).
The usage of this component would be for example:
<my-filter-component filter="John">
<my-filter-item name="John Smith"></my-filter-item>
<my-filter-item name="Jane Doe"></my-filter-item>
<my-filter-item name="Jack Johnson"></my-filter-item>
</my-filter-component>
The user would only see the first and third items as these are the only ones we rendered an individual slot for (the ones matching the filter
value of "John").
In summary: onBeforeRendering
is the best place to prepare all the variables you are going to need in the .hbs
template.
onAfterRendering
​
The onAfterRendering
lifecycle hook allows you to access the DOM every time the component is rendered.
You should avoid using this method whenever possible. It's best to delegate all HTML manipulation to the framework: change the state of the component, the component will be invalidated, the template will be executed with the latest state, and DOM will be updated accordingly. It is an anti-pattern to manually change the DOM.
In some cases, however, you must directly access the DOM since certain operations can only be performed imperatively (and not via the template):
- setting the focus;
- manually scrolling an element to a certain position;
- calling a public method on a DOM Element (for example, to close a popup);
- reading the sizes of DOM Elements;
Example:
<div class="my-component">
<input id="first">
<input id="second">
</div>
onAfterRendering() {
this.shadowRoot.querySelector("#second").focus();
this._totalWidth = this.shadowRoot.querySelector("div.my-component").offsetWidth;
}
onEnterDOM
and onExitDOM
​
Unlike onBeforeRendering
and onAfterRendering
, which sound like parts of the same flow (but are not, and are actually used for completely independent tasks),
onEnterDOM
and onExitDOM
should almost always be used together, therefore they are presented as a whole in this article.
onEnterDOM
is executed during the web component's standardconnectedCallback
method's executiononExitDOM
is executed during the web component's standarddisconnectedCallback
method's execution
If you have prior experience with web component development, you could think of onEnterDOM
as connectedCallback
and of onExitDOM
as disconnectedCallback
.
Note that these hooks are completely independent of the component's rendering lifecycle, and are solely related to its insertion and removal from DOM.
Normally, when a web component is created, for example:
const b = document.createElement("my-component");
it is already fully operational, although it isn't in DOM yet. Therefore, you should use onEnterDOM
and onExitDOM
only for functionality, related to
the component being in the DOM tree at all (and not to rendering, stying or anything related to the shadow root).
Common use cases are:
- registering/de-registering a ResizeHandler
- working with Intersection observer
- any work you want to carry out only if the component is in the DOM;
Probably the best example of these hooks is the usage of the ResizeHandler
helper class.
The component has a private _width
property, defined and the following code in its class:
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
@customElement({
tag: "my-component",
})
class MyComponent extends UI5Element {
@property({ type: Number })
_width = 0;
constructor() {
super();
this._fnOnResize = this._onResize.bind(this);
}
onEnterDOM() {
ResizeHandler.register(this, this._fnOnResize);
}
onExitDOM() {
ResizeHandler.deregister(this, this._fnOnResize);
}
_onResize() {
this._width = this.offsetWidth;
}
get styles() {
return {
valueStateMsgPopover: {
"max-width": `${this._width}px`,
},
};
}
}
In the constructor
we bind the _onResize
method to the component's instance to get a function with the correct context,
and then in onEnterDOM
and onExitDOM
we register/deregister this function with the ResizeHandler
helper class.
Then, whenever the component resizes, the ResizeHandler
will trigger the callback, the metadata _width
property will be updated to a new value in _onResize
,
the component will be invalidated, and the template will be executed with the new value of _width
, respectively styles
.