Selector API

Tabris.js offers APIs to find and manipulate widgets anywhere in the UI tree using selectors. A selectors is a string, a widget constructor, or a filter function that can be used by the framework to filter a given set of widgets and select only those with a specific type, ID, or class attribute or parent-child relationship.

Selector Syntax

Type Selectors

The simplest method to select widgets is to refer to their type. For example, the following statement would select all instances of CheckBox.

page.find('CheckBox')

You may also give the type via the constructor itself:

page.find(CheckBox)

This is preferable in TypeScript (as it provides better autocompletion), or to also select all widgets that extend a given widget class:

page.find(Composite)

This would find all instances of Composite or one of it’s subclasses, like TabFolder or Canvas.

Finally, a functional component can also work:

page.find(StyledText)

However, this does not always work out-of-the-box. Read more about this here.

ID Selectors

The id of a widget is a property like any other. It is initially undefined, so you have to assign an ID yourself to refer to the widget. Usually you would do this when you create the widget:

<Button id='submit'/>

… or …

new Button({id: 'submit'});

To select a widget by its ID, you can use the selector expression '#id' where id is the ID of the widget:

page.find('#submit')

By convention IDs should be unique within the given subtree, although this is not enforced by the framework. See “Encapsulation”.

Class Selectors

The class property is a string containing a whitespace separated list of “classes”. A class is an arbitrary name for a state or category the widget should be identifiable by. It may only contain alphanumeric characters, '_' and '-'.

:point_right: The class attribute is comparable to the concept of a CSS class, and not related to JavaScript/TypeScript classes in any way.

Examples:

<TextView class='label important'/>

or

new TextView({class: 'label important'});

Classes may be mixed, re-used and changed on any widget at any time. Using the classList property is a handy way to do so:

textView.classList.push('important');

To select a widget by a class attribute, use the selector expression '.class' where class is the class name:

page.find('.important')

Relationship Selectors

A widget may also selected by its parent. This is done by giving first the selector of the parent, followed by ' > ', and then the selector of the child.

Example: Let’s say you have a page with two buttons:

<Page>
  <Button/>
  <Composite>
    <Button/>
  </Composite>
</Page>

In this case you can either select both buttons…

page.find('Button')

…or only the button within the composite…

page.find('Composite > Button')

…or only the button directly attached to the page:

page.find('Page > Button')

In this case the 'Page' we select here is supposed to only refer to the page object itself, not any (potential) children that are also of the type Page. To avoid this ambiguity we can use the :host pseudo class:

page.find(':host > Button')

The :host selector refers specifically to the widget that applies the selector, in this case the page.

Star selector

The character '*' matches all widgets. Sometimes useful in conjunction with the apply method or Relationship Selectors:

page.find(':host > * > Button')

This selects all Buttons attached to any child of page, regardless of the type of the child in between.

Selector Functions

Filter functions are also valid selectors. These type of functions are called for each widget candidate and need to return either true to include it or false to exclude it. For example, the following snippet would select all visible widgets on a page:

page.children(widget => widget.visible)

When the given function is a constructor it will not be called, but treated as a type selector.

WidgetCollection

Instances of WidgetCollection can be both the basis and the result of a selection. Therefore a quick overview is useful here:

Widget collections are array-like objects that represent a set of widgets in Tabris.js. They are immutable and every entry is unique, i.e. they never contain any duplicates. They may also be empty, representing zero widgets.

Notably, a widget collection features a subset of the widget API that allows modifying all contained widgets at once. This includes set, trigger, on, off, once, append, appendTo, dispose and animate. Example:

collection
  .set({background: 'blue'}) // make all widgets blue..
  .animate({opacity: 0}, {duration: 400}); // fade out

The same could be done to only a specific entry:

collection[3]
  .set({background: 'blue'})
  .animate({opacity: 0}, {duration: 400});

The first example will never fail, even if the collection is empty, while the second may cause an exception if the collection has less than four widgets. Like arrays, widget collections are zero indexed.

While WidgetCollection has some array-like API (length, forEach, indexOf), you can also get an actual array, which is mutable safe copy:

const arr = collection.toArray();
arr.splice(2, 3);
const collection2 = new WidgetCollection(arr);

JSX can be used to create a widget collection, usually to create and append multiple widgets to the same parent:

contentView.append(
  <WidgetCollection>
    <TextView/>
    <TextView/>
    <TextView/>
  </WidgetCollection>
);

In TypeScript WidgetCollection is a generic type (WidgetCollection<T extends Widget = Widget>>)that “knows” what type of widgets are contained - if they are all of the same type:

const collection: WidgetCollection<TextView> = new WidgetCollection([new TextView()]);
collection[0].text = 'foo'; // would not compile on WidgetCollection<Widget>

Such a collection is created implicitly whenever a constructor is used as a type selector.

APIs that accept Selectors

composite.children()

The method composite.children(selector) method returns a new widget collection containing the composite’s current children that match the given selector. This includes only first generation descendants, so children of children are not part of the result. If the composite is a custom component (user defined subclass) that encapsulates its children **the method will always return an empty WidgetCollection.

The selector parameter defaults to *, so children() is the same as children('*').

(
  <Composite>
    <TextView/>
    <TextView/>
    <Composite>
      <TextView/>
    </Composite>
  </Composite>
).children(TextView).set({left: 23});

This will modify the first two children of the given composite since these are TextView instances.

composite.find()

The method composite.find(selector) returns a new widget collection containing all descendants that match the given selector. This excludes the widget the method was called on, and any descendants of encapsulated components.

The selector parameter defaults to *, so find() is the same as find('*').

(
  <Composite>
    <TextView/>
    <TextView/>
    <Composite>
      <TextView/>
    </Composite>
  </Composite>
).find(TextView).set({left: 23});

This will modify all TextView elements in the tree.

composite.apply()

Note: Within encapsulated components, use _apply() instead.

A shortcut for setting different sets of attributes for different selections in one method call. The method takes a plain object with selectors as keys and attributes objects as values. This object is called a “ruleset”:

page.apply({
  '#okbutton': {text: 'OK!', background: 'yellow'},
  '#cancelbutton': {text: 'Cancel!', textColor: 'red'}
});

The scope includes the widget it is called on:

page.apply({':host': {background: 'green'}}); // same as "page.background = green";

When using declarative UI syntax "apply" is a special attribute that calls this function.

Composite({
  apply: {'#foo': {background: 'blue'}},
  children: [
    TextView({id: 'foo', text: 'Has blue background'})
  ]
})

The order in which the attributes objects are applied depends on the type of selectors being used. The order is:

  • '*' > 'Type' > '.class' > '#id'

For example, the following call would make all widgets within the page blue, except for the buttons, which would be green, except for '#mybutton', which would be red:

page.apply({
  '#mybutton': {background: 'red'},
  'Button': {background: 'green'},
  '*': {background: 'blue'}
});

When using child selectors, the more specific selector wins. In this example, all buttons are green except for those directly attached to page, which are red.

page.apply({
  ':host > Button': {background: 'red'},
  'Button': {background: 'green'}
});

:point_right: The order of the attributes objects in the object literal is meaningless. According to the EcmaScript standard the members of a JavaScript object do not have a defined order. The priority of two selectors with the same specificity is undefined.

Listeners can also be registered via apply:

page.apply({
  '#foo': {onTap: listener}
});

:point_right: Unlike listener registration via methods (e.g. button.onSelect(listener)), apply replaces any listener previously registered via apply for the same event type. These “attached” listeners work like properties. In the above example, if apply previously registered another listener for onSelect on the ‘foo’ button, that listener will be de-registered before the new one is registered. It will also de-register any listener for that event type that was registered via declarative UI.

Strict mode

To ensure apply addresses the right widgets with the right attributes, it can be executed in ‘strict’ mode and the Setter helper function can be used to create the attributes object. The kind of the selector then determines how many widgets must match (exactly one for id, at least one for any other), and Setter determines what type the widget must have. If these conditions are not met, an error will be thrown.

page.apply('strict', {
  '#foo': Setter(Button, {textColor: 'red'}), // must match exactly one Button
  '.bar': Setter(TextView, {background: 'green'}) // must match one or more
  'Composite': Setter(Composite, {background: 'blue'}) // must match one or more
});

:point_right: The apply attribute always uses strict mode. The apply() function does not use it by default for historic reasons.

Alternative syntax for using Setter:

page.apply('strict', [
  Setter(Button, '#foo', {textColor: 'red'}),
  Setter(TextView, '.bar', {background: 'green'})
  Setter(Composite, {background: 'green'}) // now also matches Composite subclasses
]);

Reactive apply()

The, apply method/attribute can also take a callback instead of a ruleset object. That callback is given the host widget and must return a ruleset that may be derived from the widget state. The callback will be invoked when a property of the widget changes.

Updates via apply callbacks are scheduled by specific triggers. When setting the callback via apply attribute (e.g. <Composite apply={...}> or Composite({apply: ...}))) or element (<Apply>...</Apply>), this is any change event on the widget or its data property. If called programmatically, the trigger event has to be given explicitly. It can either be an event name (including ‘on’, e.g. ‘onTap’), the string '*' to react to any property change (as described above), or 'update' for manual updates only.

Using an specific event is the only way to make apply react to the few change event types that are not included by '*', specifically 'onBoundsChanged' or any scrolling related property:

contentView.apply(
  {mode: 'strict', trigger: 'onBoundsChanged'},
  ({bounds}) => (bounds.height > bounds.width) ? {
    '#foo': {layoutData: fooVertical},
    '#bar': {layoutData: barVertical}
  } : {
    '#foo': {layoutData: fooHorizontal},
    '#bar': {layoutData: barHorizontal}
  }
);

$()

This function is a global alias for tabris.contentView.find(), and it therefore accepts the same selector parameters.

$('.foo > .bar').set({background: 'blue'});
// same thing:
tabris.contentView.find('.foo > .bar').set({background: 'blue'});

Note that $() will not search through all widgets in the UI tree. It’s scope does not include any widgets in the drawer, a popover, or an encapsulated custom component. A component is encapsulated if it overrides the children() method or uses the @component decorator.

Due to it’s scope it is mainly intended to be used in snippets, for debugging and when bootstrapping your application.

widgetCollection.filter()

The method widgetCollection.filter(selector) returns a new widget collection containing all entries of the original collection that match the given selector. This is useful to narrow down an initial selection:

(
  <Composite>
    <TextView class='foo'/>
    <TextView class='bar'/>
    <Button class='foo'/>
  </Composite>
).children(TextView).filter('.foo').set({left: 23});

This will modify the first TextView instance, but neither the Button nor the second TextView.

widgetCollection.first() and widgetCollection.last()

These return the first/last entry in the collection that match the given selector. If no element matches they return undefined. The selector parameter defaults to *, so first() is the same as first('*'). It is also effectively the same as accessing the element via index:

page.find('#submit').first() === page.find('#submit')[0];

In TypeScript first(Type) and last(Type) perform an implicit cast:

page.find('#submit').first().text = 'Hello'; // does not compile
page.find('#submit').first(Button).text = 'Hello'; // OK

widgetCollection.only()

Very similar to first(), except that it requires the collection to have exactly one match for the given selector. If there is more or less than one match the method throws en Error. This is preferable to first() if there is only one match expected, as it greatly reduces the risk of accidentally selecting the wrong one or encountering hard-to-debug exceptions if no match exists.

Without a selector the widget collection needs to have exactly one entry.

// throws if more than one child with the id "submit" exists:
page.find('#submit').only() === page.find('#submit').first();

widgetCollection.children()

The method collection.children(selector) will apply the given selector to all children of it’s own entries. This allows selecting by parent-child relationships, similar to relationship selectors:

widget.find(':host > .foo > .bar');
widget.children('.foo').children('.bar'); // same result

While this method is longer, it allows using non-string selector, i.e. functions/constructors.

When subclassing a Composite (including Page, Tab and Canvas), it is recommended to overwrite the children method to encapsulate the component. The method will then always return an empty WidgetCollection, even when the composite/component contains children. The _children() method will still work the same way.

Encapsulation

All custom components should override their children method to protect them from outside manipulation:

class MyCustomComponent extends Composite {

  // ...

  children() {
    return new WidgetCollection();
  }

}

Alternatively, when using the (TypeScript-only) @component decorator will do this automatically:

@component
class MyCustomComponent extends Composite {

  // ... no override needed

}

Either approach will prevent find() and apply() from including any children of MyCustomComponent. It will always appear as though it has no children. For MyCustomComponent itself to still be able to select its own children it needs to use the non-public version of the selector API:

class MyCustomComponent extends Composite {

  // ...

  doSomething()
    this._children().set({background: 'red'});
    this._find('#foo').set({background: 'green'});
    this._apply({'.bar': {background: 'blue'}});
  }

}

Why encapsulation?

By default the scope of find and apply include all descendants of their hosts, including all children of a custom component. This may not be desireable:

widget.append(
  <Composite>
    <Button id='primary'/>
    <MyCustomComponent/>
  </Composite>
);

In this scenario we may want to select all '#primary' elements:

widget.find('#primary').set({text: 'blue'});

But MyCustomComponent may itself also contain a match for '#primary':

class MyCustomComponent extends Composite {

  constructor(properties) {
    super(properties);
    this.append(<TextView id='primary'/>);
  }

}

This would be an unexpected collision, assuming MyCustomComponent considers its own children to be internals that should not be accessed by outside code. This is what encapsulation prevents.