Skip to content

Controller

Azarattum edited this page Nov 24, 2020 · 17 revisions

Controller components determine the behavior of your application.

Controllers are stored in:

src
└───components
    └───app
        └───controllers

Usually controllers will be the most frequently used building block in your application. You use them to define logic for user interactions and update UI accordingly. There are two types of controllers: non-relational and relational.

Example

Exampler controller included in the source code:

import Controller, { Relation } from "../../common/controller.abstract";

/**
 * Example of a controller.
 * It has `Relation.Default` which means association with DOM, but
 * no data binding. And the controller cannot emit any events, because
 * type template says `never`, it can be changed to any union string type
 * like `"event1" | "event2" | "event3"`.
 *
 * Best practice to name controllers as `something(er/or)`
 */
export default class Exampler extends Controller<never>(Relation.Default) {
	/**
	 * Initialization of Exampler controller
	 */
	public async initialize(): Promise<void> {
		///Controller initialization logic goes here
	}
}

This is a simple relational controller without data-binding and any events to emit.

Non-Relational

Controllers of this type can have only a single instance at a time. Usually they are created at the first page load and persist till the end. They have a relation of null. This is the only and default type for node apps. In order to make a browser controller non-relational you have to explicitly specify Relation.None in class definition:

import Controller, { Relation } from "../../common/controller.abstract";

export default class Exampler extends Controller<never>(Relation.None) {
    ...
}

Non-relational controllers have the same features as services. Which are events and exposing.

This is the type of controllers you want to use for something similar to services but with some DOM interactions or window access. Checkout some prebuild non-relational components for browser: Hasher, Offliner.

Relational

This is the type of controllers you usually create in browser. They are related to all the elements which have a controller attribute:

<div controller="exampler">
    <p controller="exampler texter"></p>
</div>

As you see in the example above, exampler and texter are some controllers. You can define multiple elements with the same controller. Each of them will have an independent state (even if the controller is inside itself). Besides, you may add multiple controllers to an element as shown with the paragraph element.

Features

Events

Usage is same as with service's events. Can be used in both relational and non-relational controllers.

Exposing

Usage is same as with service's exposing. Can be used in both relational and non-relational controllers.

For relational controllers a global shortcut is available: ctrl. It lets you call an exposed function on an a particular controller (the one that has the calling element in the scope) from an element's event handler:

div(controller="exampler") //- ← `.say()` will be called on this controller
    button(onclick="ctrl.say('hello')")

div(controller="exampler") //- ← `.say()` will not be called on this controller

ctrl tries to find the nearest parent controller which has the requested function exposed.

Container

This feature is only for relational controllers.

this.container can be used to refer to the related HTML element from a controller. For example:

div(controller="exampler" data-something="42")
import Controller from "../../common/controller.abstract";

export default class Exampler extends Controller<never>() {
	public initialize(): void {
		//this.container - referes to the `div` element
		this.container //<div controller="exampler" data-something="42"></div>
		this.container.dataset.something //42
	}
}

Element(s) Decorator

This feature is only for relational controllers.

Lets you write @element or @elements property decorators. Which use query selector within the controller's container to find one or all the matching elements respectively.

The default behavior is to use class selector with the property name, but you can specify a custom selector with @element(<selector>). If no matching elements were found, @elements will return an empty array while @element will throw an exception.

Example:

div(controller="exampler")
    span(class="text")
    div(class="epic")
    div
import Controller, {
	element,
	elements
} from "../../common/controller.abstract";

export default class Exampler extends Controller<never>() {
	@element("span")
	private span!: HTMLSpanElement;
	@element
	private text!: HTMLSpanElement;
	@element
	private nothing!: HTMLSpanElement;
	@elements("div")
	private divs!: HTMLDivElement[];

	public initialize(): void {
		this.span //<span class="text"></span>
		this.text; //<span class="text"></span>
		this.divs; //[div.epic, div]
		this.nothing; //Error: Element ".nothing" does not exist!
	}
}

The exclamation marks tell TypeScript that the values will be defined later than the construction. Alternatively, you might set them to null if you wish.

Data-Binding

This feature is only for relational controllers.

Allows you to bind some data to the UI. To enable data-bidning on a controller use Relation.Binding:

export default class Exampler extends Controller<never>(Relation.Binding) { ... }

Data-binded variable are case insensitive!

Data from the controller bounds to pug's template variables compiled by the view-loader. You can access this data from this.data proxy:

div(controller="exampler")
    div=value
...
public initialize(): void {
	this.data.value = 42;
}
...

This will render:

<div controller="exampler">
    <div>
        <data-text bind-value="42" bind="value">42</data-text>
    </div>
</div>

You can pass arrays or objects:

div(controller="exampler")
    - obj = {}
    - arr = []

    div=arr[0]
    div=obj.a
...
public initialize(): void {
	this.data.arr = [1];
	this.data.obj.a = 42;
}
...

Keep in mind that the pug compiler does not know if the value is an array or an object. So, it will throw an error when we try to access some properties of undefined. That is why we need to supply default render-time values for our data: obj = {} and arr = []. When the controller will have been initialized obj will become {a: "42"} and arr - ["1"]. Note that all the data is being converted to strings before being passed to DOM.

There is an important difference between writing - arr = [] and - let arr = []. In the first case we set a default value for the arr datapoint, whereas in the second we make arr a constant (it means compile-time only variable), it will render as an empty array and will be unable to be change by data-binding.

Apart from binding content, you are able to bind attributes and even use expressions!

div(controller="exampler" class=good ? "good" : "bad")
this.data.good = true; //<div class="good" controller="exampler"></div>
this.data.good = false; //<div class="bad" controller="exampler"></div>

Loops

Any object-like data can be iterated with pug each loop (nested loops are also supported).

- array = []
each item, key in array
    p=`${key}: ${item}`
this.data.array = [1,2,3] //<p>0: 1</p> <p>1: 2</p> <p>2: 3</p>
this.data.array[0] = 42; //<p>0: 42</p> <p>1: 2</p> <p>2: 3</p>

If we want the data to be rendered without a controller. We should define array as a compile-time variable instead: - const array = [1,2,3]. Then:

- const array = [1,2,3]
each item, key in array
    p=`${key}: ${item}`
//↓ Does nothing
this.data.array[0] = 42; //<p>0: 1</p> <p>1: 2</p> <p>2: 3</p>

Inputs

Since we have a 2-way data-binding implemented in TheFramework. You can simply change the data by passing it as the value attribute to any input element:

p=text
input(value=text)

Now we write something in the input element, for example "hello". The text in the paragraph instantly updates with the input. Also we can access text from a controller:

this.data.text; //"hello"

Bind Decorator

Bind decorators provide you a simpler and more elegant approach to data-binding. When you decorate your controller's properties with @bind they are automatically bound to this.data.<property> value. Or you could specify a custom path with @bind(<path>).

div(controller="exampler")
    p=string
    p=number
import Controller, { bind, Relation } from "../../common/controller.abstract";

export default class Exampler extends Controller<never>(Relation.Binding) {
	@bind
	private number: number = 0;
	@bind("string")
	private text: string = "hello";

	public initialize(): void {
		setTimeout(() => {
			this.number = 42;
			this.text = "bye";
		}, 1000);
	}
}

In this example we set initial values of number and string to 0 and "hello". After a second we change them to 42 and "bye".

The most significant difference from this.data is that binded attributes retain their type. This is achieved with convertTo function from Utils. Moreover, the prototype is also preserved, so you can bind really complex objects. Check out our language model example to see this in action.