Note: the ElementVComponent API is currently in Experimental status.
The APIs discussed in this documentation are subject to change. More specifically,
ElementVComponent authors may be required to make changes to their component implementations
when upgrading to future versions of JET.
The ElementVComponent base class provides a mechanism for defining JET
Custom Components.
Like the JET Core Components
and composite components, ElementVComponent-based components
are exposed as custom elements. From the application developer’s perspective, these
custom elements are (essentially) indistinguishable from JET’s other component types.
Where ElementVComponents differ is in the component implementation strategy: ElementVComponents produce
content via virtual DOM rendering.
To create a new ElementVComponent-based custom component, the component author typically does the following:
- Implements a class that extends ElementVComponent. This class must be authored in TypeScript.
- Overrides the render() method to return a virtual DOM representation
of the component’s content.
- Sets the @customElement() decorator with the custom element tag name passed in as a parameter.
- Defines the public contract of the custom element.
- Properties: defined as members of the Props class.
- Methods: defined as methods of the ElementVComponent class and marked for exposure on the custom element using the @method decorator.
- Events: defined as members of the Props class using the naming convention on[EventName] and having type
Action or CancelableAction.
- Slots: defined as members of the Props class having type Slot.
Given the above, JET generates an HTMLElement subclass and registers this as a custom
element with the browser. These ElementVComponent-based custom elements can then be used anywhere
that other JET components are used, and application developers can leverage typical JET
functionality such as data binding, slotting, etc.
A minimal ElementVComponent subclass is shown below:
import { h, ElementVComponent, customElement } from "ojs/ojvcomponent";
import "ojs/ojavatar";
class Props {
initials?: string = '';
fullName?: string = '';
department?: 'Billing' | 'Sales' | 'Engineering';
rank?: Rank = { level: 1, title: 'entry level' };
}
type Rank = {
level: number,
title: string
};
@customElement('oj-sample-employee')
export class SampleEmployee extends ElementVComponent<Props> {
protected render(): ElementVComponent.VNode {
return (
<div>
<oj-avatar initials={this.props.initials} />
<span>{this.props.fullName}</span>
</div>
);
}
}
Rendering
Every ElementVComponent class must provide an implementation of the render() method.
This method returns a tree of virtual DOM nodes that represents the component's content.
The return value can take one of two forms:
- The render function can return a single virtual DOM node representing the root
custom element and any child content specified as virtual DOM children. If the
component needs to modify root attributes, needs to set a ref callback on the root
custom element, or contains multiple virtual DOM children, this return value form
must be used.
- If the component's content consists of a single virtual DOM child, a single virtual
DOM node representing the child node may be returned, omitting a node representing
the root custom element.
While it is always acceptable to include a virtual DOM node representing the root
custom element as in #1, the return form in #2 is supported as a convenience. In many cases, the root
virutal DOM node can be omitted.
Virtual DOM nodes are plain old JavaScript objects that specify the node type
(typically the element’s tag name), properties and children. This information is
used by the underlying virtual DOM engine to produce live DOM (i.e. by calling
document.createElement()).
Virtual DOM nodes can be created in one of two ways:
- By calling the virtual DOM node factory function, which is exported
from the ojs/ojvcomponent module under the name "h". The
h
factory function takes the type, properties and children and returns a
virtual DOM node.
- Declaratively via TSX (a TypeScript flavor of JSX).
The latter approach is strongly preferred as it results in more readable code. A build-time transformation step
will ultimately convert the TSX markup into calls to h()
that will be executed at run-time.
Note that in either case, the virtual DOM factory function must be imported as
an import named "h".
The render() method will be called whenever component state or properties change to return the new VDOM. The virtual
component will then diff the VDOM and patch the live DOM with updates. As custom elements,
these virtual components are used in the same way as other JET components, supporting data binding
and slotting.
JSX Syntax
Virtual component render functions support the use of JSX which is an XML
syntax that looks similar to HTML, but supports a different attribute syntax.
JSX Attributes
Component properties, global HTMLElement properties, event listeners, ref, and key attributes
can all be specified using the virtual component JSX attribute syntax. JSX expects the
HTMLElement
property names for all JSX attributes except for class and for. In cases where an attribute does not have an
equivalent property on the HTMLElement (data-, aria-, role, etc), the attribute name
should be used. The style attribute is special cased and only supports object values
e.g. style={ {color: 'blue', fontSize: '12px'} }. Primitive JSX attribute values can
be set directly using the equal operator, or within {...} brackets for JavaScript values
e.g. the style example above.
The JET
data binding syntax
using double curly or square brackets is not supported when using JSX. Additionally, subproperty
syntax (e.g. complexProperty.subProperty={...}) is not supported; when dealing with complex-typed properties,
the full value must be specified (i.e. complexProperty={ {subProperty: ...} }).
class
The class JSX attribute supports space delimited class names in addition to an
Object whose keys are individual style classes and whose values are booleans to determine
whether those style classes should be present in the DOM.
(e.g. class={ {'oj-hover': isHovered} }).
Event Listeners
Event listeners follow a 'on'[EventName] naming syntax e.g.
onClick={clickListener} and unlike data bound on-click listeners set on the root
custom element, JSX event listeners will only receive a single event parameter.
Use the @listener decorator to bind an event listener to the component instance - 'this'.
The @listener decorator accepts an options object that would be passed to the
DOM addEventListener() method for specifying capture or passive listeners.
import { h, ElementVComponent, customElement, listener } from "ojs/ojvcomponent";
@customElement('oj-sample-component')
export class SampleComponent extends ElementVComponent {
@listener({ passive: true })
private _touchStartHandler(event) {
// handler code
}
protected render(): ElementVComponent.VNode {
return (
<div onTouchstart={this._touchStartHandler}>
…
</div>
);
}
}
Refs
While we recommend that rendering is done declaratively, for use cases where
a reference to a DOM node is necessary, a ref attribute
along with a callback can be set on the virtual node within the render function.
The callback function will be called with either a DOM node when using the element syntax
or a ElementVComponent instance when using the class syntax after the node has been inserted
into the DOM. The ref callback will be called again with null when the node has been
unmounted. See the lifecycle doc for ref callback
ordering in relation to other lifecycle methods.
import { h, ElementVComponent, customElement } from "ojs/ojvcomponent";
@customElement('oj-sample-component')
export class SampleComponent extends ElementVComponent {
private _scrollingDiv: HTMLDivElement;
protected render(): ElementVComponent.VNode {
return (
<div ref={this._setScrollingDiv}>
…
</div>
);
}
protected mounted(): void {
this._adjustScrollingDiv();
}
protected updated(oldProps: Readonly<Props>, oldState: Readonly<State>): void
this._adjustScrollingDiv();
}
private _setScrollingDiv = (elem) => {
this._scrollingDiv = elem as HTMLDivElement;
}
private _adjustScrollingDiv(): void {
// Perform some calculations
…
this._scrollingDiv.style.height = calculatedValue;
}
}
Keys
When rendering lists of virtual nodes, it may be beneficial to set key attributes
in JSX to help distinguish between insertions, deletions, and updates. Without keys,
the ElementVComponent diffing logic will compare the old and new virtual node lists in order,
so an insertion before the first virtual node will result in a diff for all subsequent
virtual nodes without the key attribute. The key can be of type string or number.
Root Attributes
In general, we do not recommend modifying core HTML properties on the custom element to
avoid overriding application set values. However in cases where this is necessary
(e.g. moving or copying attributes for accessibility), authors should register properties
they plan to update or listen to changes from as members of their Props class, marked with the @rootProperty decorator.
These root properties will then be populated in the component's this.props
object as
long as they are present in the live DOM; unlike component properties, no default values will be made available
in this.props
for root properties. When rendering, only core HTML properties that are specifically marked with the @rootProperty decorator will be
reflected in the live DOM on the root custom element; any other core HTML properties will be ignored.
Components will be notified of changes to root properties similar to component properties and trigger a rerender.
Style and class properties can be set on the root custom element and are applied additively to the application-provided
style and class. Event listeners can be added using the on[PropertyName] syntax in the root element within the component's
render() method and will be added or removed using the DOM's addEventListener and removeEventListener methods.
Style, class, and event listeners can always be specified on the root custom element and do not need to be declared
as members of the Props class unlike other root properties.
protected render(): ElementVComponent.VNode {
return (
<oj-sample-component onClick={this._clickListener} style={ {color: red} } class='my-class-name'>
<div>…</div>
</oj-sample-component>
);
}
Components often need to generate unique IDs for internal DOM. The uniqueId() method can
be called to retrieve an id that is unique to the component instance (matching the live DOM if it has been specified)
that can be used e.g. as a prefix for IDs on internal DOM.
State Updates
Components may track internal state that is not reflected through their
properties. There is a state mechanism for supporting this. Components
should initialize their state objects in their constructors. After that,
components should treat their this.state
objects as immutable
and call updateState to request a
state update.
The updateState() method does not immediately update the state of the component,
it just puts the update in a queue to be processed later. The framework will batch
multiple updates together to make rendering more efficient. It will schedule
the change and rerender the component. Consider using function callback instead
of an object when updating the state in order to avoid stale data for the state.
Calls to updateState() will only cause the component to rerender if the end result
differs from the original state.
import { h, ElementVComponent, customElement } from "ojs/ojvcomponent";
class Props { … }
type State = {
foo: boolean,
bar: boolean
}
@customElement('oj-sample-component')
export class SampleComponent extends ElementVComponent<Props, State> {
constructor(props: Readonly<Props>) {
// State should be instantiated in the constructor
this.state = {
foo: true,
bar: false
}
}
@listener()
private _handleClick() {
// Update state in response to user interaction, triggering a
// re-render.
this.updateState({ foo: false });
}
}
Default Values
Static default values for components can be provided using direct value assignments
on the corresponding members in the Props class. In addition, dynamic default values
can be specified using the @dynamicDefault() decorator with a parameter representing
a method that should be called to retrieve the default value at runtime.
Object- or Array-typed default values will recursively frozen before being returned
as property values to prevent subsequent modification. Any Objects that are not POJOs
will not be frozen (including references to them inside other Objects or Arrays) and
it is the component's responsibility to ensure that default values of this type (e.g. class instances)
are immutable.
import { h, ElementVComponent, customElement, dynamicDefault } from "ojs/ojvcomponent";
function computeDynamicDefault(): string { … }
class Props {
primitiveProperty?: number = 0;
complexProperty?: {index: number} = {index: 0};
classProperty?: MyType = new ImmutableMyTypeImpl();
@dynamicDefault(computeDynamicDefault) dynamicProperty?: string;
}
@customElement('oj-sample-component')
export class SampleComponent extends ElementVComponent<Props> {
…
}
Lifecycle Methods
In addition to the required render method, virtual components have several optional lifecycle
methods that give the component hooks to setup/cleanup global listeners, do geometry management,
and update state. See the API doc for each lifecycle method for details.
Mount
Update
Unmount
Slotting
Component authors declare their expected slots as members of their Props class. The various slot types are exposed as follows:
- Default slot - exposed through the
children
member with type Children
.
- Ordinary slot - exposed through a member with type
Slot
whose name corresponds to the slot name.
- Template slots - exposed through a member with type
TemplateSlot<SlotContextType>
whose name corresponds to the slot name. SlotContextType
represents the data type for this template slot (corresponding to the type of the $current object).
- Dynamic slots - any slots that are not explicitly declared as one of the three previous types
will be exposed in a Map of type
DynamicSlots
.
This may be useful in cases where the set of expected slots cannot be statically defined, but is determined
by the component through other means at runtime. The dynamic slot map can only contain ordinary slots.
- Dynamic template slots - similar to
DynamicSlots
except slot content should
only be template nodes which will be exposed in a Map of type DynamicTemplateSlots
.
Note that a component may only support one type of dynamic slot. So if a property of type DynamicSlots exists,
there cannot be another property of type DynamicTemplateSlots.
Note that in all of the cases above, the component author must declare the corresponding properties in their Props class in order to receive access to slot content.
During component rendering, default slot content can simply be inlined as with any other virtual DOM content. Components can test for the existence of the children
property to decide whether to render default content.
class Props {
children?: ElementVComponent.Children;
}
@customElement('oj-sample-component')
export class SampleComponent extends ElementVComponent<Props> {
protected render(): ElementVComponent.VNode {
return (
<div style="border-style: solid; width:200px;">
{ this.props.children || <span>Default Content</span> }
</div>
);
}
}
Ordinary slots are exposed as render functions at runtime and can simply be called to retrieved the corresponding content.
class Props {
header?: Slot;
}
@customElement('oj-sample-component')
export class SampleComponent extends ElementVComponent<Props> {
protected render(): ElementVComponent.VNode {
return (
<div style="border-style: solid; width:200px;">
{ this.props.header?.() || <span>Default Header Content</span> }
</div>
);
}
}
Template slots are also exposed as render functions at runtime, but additional take an argument representing the template data.
type Item {
index: number;
text: string;
}
class Props {
itemTemplate?: Slot<Item>;
items?: string[];
}
@customElement('oj-sample-component')
export class SampleComponent extends ElementVComponent<Props> {
protected render(): ElementVComponent.VNode {
return (
const templateFunction = this.itemTemplate || this._defaultTemplate;
<ul>
items.map( (item, index) =>
<li>
{ templateFunction({index: index, text: item}) }
</li>);
</ul>
);
}
}
Dynamic slots are rendered exactly like ordinary and template slots once the component determines what slot to render.
class Props {
cards?: DynamicSlots;
currentCard: string;
}
@customElement('oj-sample-component')
export class SampleComponent extends ElementVComponent<Props> {
protected render(): ElementVComponent.VNode {
return (
<div style="border-style: solid; width:200px;">
{ this.props.cards?.[this.props.currentCard] }
</div>
);
}
}
Performance Considerations
Every time a component's render function is called, everything contained is created anew.
As a result, complex properties (e.g. non-primitive values like Object types, event listeners),
should be created outside of the render function's scope. Otherwise, e.g. the component would
be specifying a different instance of an event listener each time the component is rendered
which would result in unnecessary DOM changes. Event listeners should be declared as instance functions
marked with the @listener decorator which will ensure that they are property bound.
Non-primitive values should be saved in variables
outside of the render function.
import { h, ElementVComponent, customElement, listener } from "ojs/ojvcomponent";
class Props {…}
@customElement('oj-sample-collection')
export class SampleCollection extends ElementVComponent<Props> {
constructor(props: Readonly<Props>) {
super(props);
}
@listener()
private _handleClick(event) { … }
protected render(): ElementVComponent.VNode {
return (
<div onClick={this._handleClick}/>
);
}
}