Declarative UI
In the context of Tabris.js, “Declarative UI” refers to a set of APIs and syntaxes that define application UI in a more compact and expressive way than using exclusively imperative code. Specifically, it allows creating UI elements, setting their properties, attaching listeners and adding children. It does not necessarily imply UIs declared in separate files, proprietary file formats, reactive patterns or data binding.
See also: Functional Components.
Flavors
Tabris.js offers two options when it comes to declarative UI:
JSX:
JSX is a proprietary extension to the JavaScript/TypeScript syntax that allows mixing code with XML-like declarations. Tabris.js 3 supports JSX syntax out of the box in .jsx
and .tsx
files with any TypeScript compiler based projects. The only requirement is that in tsconfig.json
the jsx
property is set to "react"
and jsxFactory
to "JSX.createElement"
.
JSX is the more powerful and flexible option, especially combined with decorators.
Widget Factories:
Introduced in Tabris.js 3.6, widget factories are mainly intended to be a pure JavaScript alternative to the JSX setup that does not require a proprietary compiler. However, it can be used in TypeScript in case it’s the preferred syntax, and it is also slightly more type-safe than JSX since the type of the elements do not resolve to any
.
A widget factory is technically any function that returns a widget (which would include functional components), but in the context of this article it specifically refers to a widget constructor that can be called without new
. When used in that manner the function will allow addition creation arguments (“attributes”), just like JSX elements.
Unlike JSX this option does currently not feature a databinding extension, and it is not applicable to dialogs.
Comparison
Consult the following table to get an overview of how imperative UI, JSX and widget factories differ in syntax:
Action | Example |
---|---|
Create an instance | |
Imperative | new TextView() |
Factory | TextView() |
JSX | <TextView /> |
Set a string property | |
Imperative | new TextView({text: 'foo'}) |
Factory | TextView({text: 'foo'}) |
JSX | <TextView text='foo' /> |
Set non-string property | |
Imperative | new TextView({elevation: 3}) |
Factory | TextView({elevation: 3}) |
JSX | <TextView elevation={3} /> |
Set property to true | |
Imperative | new TextView({markupEnabled: true}) |
Factory | TextView({markupEnabled: true}) |
JSX | <TextView markupEnabled /> |
Register listener | |
Imperative | new TextView().onResize(cb) |
Factory | TextView({onResize: cb}) |
JSX | <TextView onResize={cb} /> |
Set properties object | |
Imperative | new TextView(props) |
Factory | TextView(props) |
JSX | <TextView {...props} /> |
Mix in properties object | |
Imperative | new TextView({text: 'foo', ...props}) |
Factory | new TextView({text: 'foo', ...props}) |
JSX | <TextView text='foo' {...props} /> |
Add new children | |
Imperative | new Composite().append(new TextView()) |
Factory | Composite({children: [TextView()]}) |
JSX | <Composite><TextView /></Composite> |
Add existing children | |
Imperative | new Composite().append(children) |
Factory | Composite({children}) |
JSX | <Composite>{children}</Composite> |
Apply ruleset | |
Imperative | new Composite().apply(rules) |
Factory | Composite({apply: rules}) |
JSX | <Composite><Apply>{rules}</Composite></Apply> |
Listeners registered via declarative UI become “attached” listeners. There can only ever be one attached listener per type on each widget. This is rarely a relevant difference, with one exception: The
apply
method also registers “attached” listeners, meaning it can de-register any listener that was registered with JSX or factory API to register a new one.
JSX specifics
In JSX, some properties can also be set by putting the value as the content of the element, like children. The value must be put in curly braces, except strings and markup, which can be inlined directly.
Most commonly this sets the text
property. Examples:
JavaScript/TypeScript | JSX |
---|---|
new TextView({text: fooVar}) |
<TextView>{fooVar}</TextView> |
new TextView({text: 'foo'}) |
<TextView>{'foo'}</TextView> |
new TextView({text: 'foo'}) |
<TextView>foo</TextView> |
new TextView({text: '<b>bar/b>'}) |
<TextView><b>bar</b></TextView> |
new TextView({text: 'bar ' + foo}) |
<TextView>bar {foo}</TextView> |
Properties that support this are always marked as such in the API reference.
In TypeScript JSX expressions themselves are type-safe, but their return type is
any
! So be extra careful when you assign them to a variable to give it the proper type.
By using Setter
any attribute of any element can be set via a separate child element:
<Button text='Simple dialog'>
<Setter target={Button} attribute='onSelect'>
{() => {
doSomething();
doSomething();
}}
</Setter>
</Button>
To add multiple children to an existing parent you group them using WidgetCollection
or $
:
import {contentView, Button, TextView, WidgetCollection, $} from 'tabris';
// JavaScript/TypeScript:
contentView.append(
new Button(),
new TextView()
);
// JSX:
contentView.append(
<WidgetCollection>
<Button />
<TextView />
</WidgetCollection>
);
// <$> can be used as a shortcut of <WidgetCollection>
contentView.append(
<$>
<Button />
<TextView />
</$>
);
This is not necessary inside JSX elements:
<Composite>
<Button />
<TextView />
</Composite>
The <$>
element can also create multiline strings:
/** @type {string} **/
const str =
<$>
This is some very long text
across multiple lines
</$>
);
contentView.append(<TextView>{str}</TextView>);
The line breaks and indentions will not become part of the final string.
Custom Components
A custom component is any user-defined class extending a built-in widget. They can be used in a declarative UI expression as well as long as the constructor takes a properties
object and passes it on to the base class in a this.set(properties)
call. The creation attributes that are available on the element are derived from the properties and events of the component:
- All public properties except functions (methods) are valid attributes.
- All events defined via
Listeners
properties are also valid listener attributes. - All child types accepted by the super type are still accepted.
Any such component can be used as a JSX element right away.
asFactory
To be used as a factory it needs to passed through the asFactory
method first:
Plain JavaScript:
exports.ExampleComponent = asFactory(ExampleComponent);
In the ES6 module system the class needs to be renamed on export:
export const ExampleComponent = asFactory(ExampleComponentBase);
In TypeScript the type needs to be exported separately. By using a namespace a rename can be avoided:
namespace internal {
@component
export class ExampleComponent extends Composite {
// ...
}
}
export const ExampleComponent = asFactory(internal.ExampleComponent);
export type ExampleComponent = internal.ExampleComponent;
Data Binding
Declarative data binding via JSX is provided by the tabris-decorators
extension. Once installed in your project, you can do one-way bindings between a property of your custom component and the property of a child like this:
@component
class CustomComponent extends Composite {
@property public myText: string = 'foo';
constructor(properties: CompositeProperties) {
super(properties);
this.append(
<TextView bind-text='myText'/>
);
}
}
The tabris-decorators
module also provides one-way and two-way via decorators. These do not require JSX, but still TypeScript.
Dialogs
This is a JSX-only feature.
Dialogs can be created via JSX-syntax as well. This should be combined with the dialogs dedicated, static “open” method:
AlertDialog.open(
<AlertDialog title='Warning' buttons={ {ok: 'OK'} }>
You have been logged out
</AlertDialog>
);
Adding Special Attributes
In the rare case that the element API needs to be modified, you can do so by declaring a special (TypeScript-only) property called jsxAttributes
. The type of this property defines what attributes are accepted. The property may NOT be assigned a value.
Despite the name
jsxAttributes
is also affecting widget factories.
The following example defines a JSX component that takes a “foo” attribute even though there is no matching property:
class CustomComponent extends tabris.Composite {
public jsxAttributes: tabris.JSXAttributes<this> & {foo: number};
constructor({foo, ...properties}: tabris.Properties<CustomComponent> & {foo: number}) {
super(properties);
console.log(foo);
}
}
The type JSXAttributes<this>
provides the default behavior for creation attributes as described above. The second part {foo: number}
is the additional attribute. In this case it would be a required attribute, but it can be made optional like this: {foo?: number}
. The constructor properties parameter type should be extended in exactly the same way.
Which elements are accepted as children is determined by a children
entry of jsxAttributes
. If your custom component does not accept children you could disallow them like this:
class CustomComponent extends tabris.Composite {
public jsxAttributes: tabris.JSXAttributes<this> & {children?: never};
}