Skip to content

Commit 09d8868

Browse files
committed
Support two-way binding on non-widget objects
This allows @Bind({all: bindings]) to establish multiple two-way bindings between the object assigned to the decorated property and the component children. @Bindall is a shorthand for this syntax since overloading @Bind to take the bindings directly would be ambiguous. The implementation of this feature is (and has to be) fundamentally different from the way @Bind(path) works, has the latter is really just a re-direct. However, the perceived behavior is the same. Add one example "bind-two-way-model" and extend the example "bind-two-way-change-events", both linked in the documentation. The "utils" module was getting too lengthy according to ts-lint rules and was split in to "utils" and "utils-databinding". Module "bind-one-way" was split in to "processOneWayBindings" and "applyJsxBindings" to make it more similar to the modules implementing the two-way binding.
1 parent 2d4304a commit 09d8868

29 files changed

+1129
-264
lines changed

doc/databinding/@bind.md

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,117 @@
44

55
> :point_right: Make sure to first read the [introduction to data binding](./index.md).
66
7-
This decorator creates two-way bindings within a custom component. Changes to the decorated *component property* are reflected on the *target property* of a *target element* (child) and the other way around.
7+
This decorator creates two-way bindings within a custom component. Changes to the decorated *component property* value are reflected on the *target property* of a *target element* (child) and the other way around.
88

99
`@bind` can by applied to any property of a class decorated with [`@component`](./@component.md). It also implies [`@property`](./@property.md) and includes its [typeGuard](./@property.md) feature. Only one of the two can be applied to the same property.
1010

11-
`@bind` requires exactly one parameter:
11+
`@bind` has several signatures:
1212

13-
## @bind(options)
13+
## @bind({path: string, typeGuard?: Function})
14+
15+
Where `path` has the format `'#<targetElementId>.<targetProperty>'`.
16+
17+
Binds the decorated *component property* to the property `<targetProperty>` of the *target element* (descendant widget) with an `id` of `<targetElementId>`. Example:
1418

15-
Where `options` is of the type
1619
```ts
17-
{
18-
path: "#<targetElementId>.<targetProperty>",
19-
typeGuard?: Function
20-
}
20+
@bind({path: '#source.selection'})
21+
public myNumber: number = 50;
2122
```
2223

23-
> See example app ["bind-two-way"](../../examples/bind-two-way).
24+
This establishes a two-way binding from the `myNumber` property to the property `selection` of the child with the id `'source'`. The binding is established after `append` is called the first time on the component, there needs to be exactly one descendant widget with the given id, and it has to have a property of the same type.
2425

25-
Binds the decorated *component property* to the property `<targetProperty>` of the *target element* (descendant widget) with an `id` of `<targetElementId>`. The binding is established after `append` is called the first time on the component, there needs to be exactly one descendant widget with the given id, and it has to have a property of the same type.
26+
> See example app ["bind-two-way"](../../examples/bind-two-way).
2627
27-
Change events are fired for the decorated *component property* if (and only if) the *target element* fires change events.
28+
Change events are fired for the decorated *component property* when the *target element* fires change events.
2829

2930
> See example app ["bind-two-way-change-events"](../../examples/bind-two-way-change-events).
3031
31-
A [`typeGuard`](./@property.md#propertytypeguard). may be given to perform value checks.
32+
A [`typeGuard`](./@property.md#propertytypeguard) may be given to perform value checks.
3233

33-
As with one-way bindings, setting the *component property* to `undefined` resets the *target property* to its initial value.
34+
As with one-way bindings, setting the *component property* to `undefined` resets the *target property* to its initial value for when the binding was first established.
3435

3536
## @bind(path)
3637

37-
Shorthand for `@bind({path: path})`
38+
Shorthand for `@bind({path: path})`.
39+
40+
Example:
41+
42+
```ts
43+
@bind('#source.selection')
44+
public myNumber: number = 50;
45+
```
46+
47+
## @bind({all: Bindings, typeGuard?: Function})
48+
49+
Where `Bindings` is in the format of:
50+
```
51+
{
52+
<sourceProperty>: '#<targetElementId>.<targetProperty>'
53+
}
54+
```
55+
Establish a two-way binding between the property `<sourceProperty>` of the *object assigned to the decorated property* and the property `<targetProperty>` of the *target element* (descendant widget) with an `id` of `<targetElementId>`. Multiple bindings may be established this way. Example:
56+
57+
```ts
58+
@bind({all:{
59+
myText: '#input1.text',
60+
myNumber: '#input2.selection'
61+
}})
62+
public model: Model;
63+
```
64+
65+
This establishes 2 two-way bindings:
66+
* One between the `myText` property of the assigned `Model` object and the property `text` of the child with the id `input1`.
67+
* And one between the `myNumber` property of the assigned `Model` object and the property `selection` of the child with the id `input2`.
68+
69+
> See example app ["bind-two-way-model"](../../examples/bind-two-way-model).
70+
71+
The bindings are first established when `append` is called the first time on the component. Again, the bindings are established after `append` is called the first time on the component, there needs to be exactly one descendant widget with the given id for each binding, and they have to have a property of the same type as the source property.
72+
73+
The `model` property can be set at any time and the bindings will update accordingly. However, the target elements will always stay the same.
74+
75+
See also [`@bindAll`](./@bindAll.md).
76+
77+
### Properties eligible for bindings
78+
79+
Both source and target property need to generate change events for the two-way binding to work. The quickest way to implement this is using [`@property`](./@property.md), which can be used on non-widget classes as well:
80+
81+
```ts
82+
class Model {
83+
@property public myText: string;
84+
@property public myNumber: number;
85+
}
86+
```
87+
88+
Note that there is no need to explicitly create an event API, `@bind` can 'talk' directly to `@property`. However, an explicit implementation is also possible:
89+
90+
```ts
91+
class Model {
92+
93+
@event public onMyTextChanged: ChangeListeners<Model, 'myText'>;
94+
private _myText: string;
95+
96+
public set myText(value: string) {
97+
if (this._myText !== value) {
98+
this._myText = value;
99+
this.onMyTextChanged.trigger({value});
100+
}
101+
}
102+
103+
public get myText() {
104+
return this._myText;
105+
}
106+
107+
}
108+
```
109+
110+
### Edge Cases
111+
112+
The component property (`model` in the above example) may also be set to `null` (or `undefined`) at any time. In that case all the target properties will be set back to their initial values. The initial value in this case refers to the value a target property had the moment the target element was attached to the component.
113+
114+
When a new two-way binding is established all the target properties will be set to the current value of the their respective source property. There is one exception to this behavior: If the source property is set to `undefined` (but not `null`) at that moment it will be assigned the current value of the target property. If a source property is set to `undefined` later both properties are set to the initial value of the target property.
115+
116+
If a source property converts or ignores the incoming value of the target property, the target property will follow and change again to contain the new source property value.
117+
118+
If a target property converts or ignores the incoming value of a source property, the source property will ignore that and keep its own value. The two properties are out-of-sync in this case.
119+
120+
If either property throws, the error will be propagated to the caller that originally caused the value change. In this case the two properties *may* end up out-of-sync.

doc/databinding/@bindAll.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
---
3+
# @bindAll
4+
5+
> :point_right: Make sure to first read the [introduction to data binding](./index.md).
6+
7+
## @bindAll(bindings)
8+
9+
This decorators is simply a shorthand for [`@bind({all: bindings})`](./@bind.md). It can be used for object-to-widget two-way bindings if no `typeGuard` parameter is needed.

doc/di/@shared.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
---
3-
## @shared
3+
# @shared
44

55
> :point_right: Make sure to first read the [introduction to dependency injection](./index.md) and the [`@inject`](./@inject.md) documentation.
66

examples/bind-two-way-change-events/src/ExampleComponent.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { ChangeListeners, Composite, Properties, Slider, Stack, TextInput, TextView } from 'tabris';
2-
import { bind, component, event } from 'tabris-decorators';
2+
import { bind, bindAll, component, event, property } from 'tabris-decorators';
3+
4+
export class Model {
5+
@event public onMyTextChanged: ChangeListeners<Model, 'myText'>;
6+
@property public myText: string;
7+
}
38

49
@component
510
export class ExampleComponent extends Composite {
611

7-
@bind('#source1.selection') public myNumber: number = 50;
12+
@bind('#source1.selection') public myNumber: number;
813
@event public onMyNumberChanged: ChangeListeners<ExampleComponent, 'myNumber'>;
914

10-
@bind('#source2.text') public myText: string = 'Hello World!';
11-
@event public onMyTextChanged: ChangeListeners<ExampleComponent, 'myText'>;
15+
@bindAll({myText: '#source2.text'})
16+
public model: Model;
1217

1318
constructor(properties: Properties<ExampleComponent>) {
1419
super();
@@ -19,7 +24,7 @@ export class ExampleComponent extends Composite {
1924
<TextView>Source of "myNumber":</TextView>
2025
<Slider id='source1' width={200}/>
2126

22-
<TextView>Source of "myText":</TextView>
27+
<TextView>Source of "model.myText":</TextView>
2328
<TextInput id='source2' width={200}/>
2429

2530
</Stack>
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
import { contentView, ProgressBar, PropertyChangedEvent, Stack, TextView } from 'tabris';
2-
import { ExampleComponent } from './ExampleComponent';
2+
import { ExampleComponent, Model } from './ExampleComponent';
3+
4+
const model = new Model();
35

46
contentView.append(
57
<Stack layoutData='stretch' alignment='stretchX' padding={12} spacing={12}>
6-
<ExampleComponent background='silver'
7-
onMyNumberChanged={updateProgressBar}
8-
onMyTextChanged={updateTextView}/>
8+
<ExampleComponent background='silver' model={model}
9+
onMyNumberChanged={updateProgressBar}/>
910
<TextView font='18px'>Current values:</TextView>
1011
<ProgressBar width={200}/>
1112
<TextView font='18px'/>
1213
</Stack>
1314
);
1415

16+
model.onMyTextChanged(updateTextView);
17+
1518
function updateProgressBar(ev: PropertyChangedEvent<ExampleComponent, number>) {
1619
$(ProgressBar).only().selection = ev.value;
1720
}
1821

19-
function updateTextView(ev: PropertyChangedEvent<ExampleComponent, string>) {
22+
function updateTextView(ev: PropertyChangedEvent<Model, string>) {
2023
$(TextView).last().text = ev.value;
2124
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Example "bind-one-way"
2+
3+
Demonstrates the use of the `bind` JSX attribute prefix to create one-way bindings from a custom component instance to its children. This app creates an instance of the included `ExampleComponent` class and a checkbox that allows to change the property values of that instance.
4+
5+
The `ExampleComponent` property `myText` is bound to the `text` properties of multiple `TextView` children. The first two variants have the same effect, just using different syntax. The third variant demonstrates how to define a fallback value. It will be displayed when the property `myText` is set to `undefined`, which is the case when the checkbox is not checked.
6+
7+
The other property `myObject` is of the type `Model`, which is defined in the same file as `ExampleComponent`. In the component the `Model` field `someString` is bound to the `TextView` property `text` and `someNumber` to the `ProgressBar` property `selection` properties, once with and without a fallback value.
8+
9+
One-way bindings require the `@component` decorator on the custom component class and the `@property` decorator on the component properties.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "bind-one-way",
3+
"version": "3.1.0",
4+
"dependencies": {
5+
"reflect-metadata": "^0.1.13",
6+
"tabris": "^3.0.0",
7+
"tabris-decorators": "^3.0.0",
8+
"typescript": "3.3.x"
9+
},
10+
"main": "dist/app.js",
11+
"scripts": {
12+
"start": "tabris serve -w -a",
13+
"build": "tsc -p .",
14+
"watch": "tsc -p . -w --preserveWatchOutput --inlineSourceMap"
15+
}
16+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Composite, Properties, Slider, Stack, TextInput, TextView } from 'tabris';
2+
import { bindAll, component, property } from 'tabris-decorators';
3+
4+
export class Model {
5+
@property public myText: string;
6+
@property public myNumber: number;
7+
}
8+
9+
@component
10+
export class ExampleComponent extends Composite {
11+
12+
@bindAll({
13+
myText: '#input1.text',
14+
myNumber: '#input2.selection'
15+
})
16+
public model: Model;
17+
18+
constructor(properties: Properties<ExampleComponent>) {
19+
super();
20+
this.set(properties);
21+
this.append(
22+
<Stack spacing={12} padding={12} >
23+
24+
<TextView>Bound to "myText"</TextView>
25+
<TextInput id='input1' width={200} text='Fallback Text'/>
26+
27+
<TextView>Bound to "myNumber"</TextView>
28+
<Slider id='input2' width={200}/>
29+
30+
</Stack>
31+
);
32+
this._find(TextView).set({font: {size: 18}});
33+
}
34+
35+
}

examples/bind-two-way-model/src/Model.ts

Whitespace-only changes.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { CheckBox, CheckBoxSelectEvent, Color, contentView, Stack, Button, TextView } from 'tabris';
2+
import { ExampleComponent, Model } from './ExampleComponent';
3+
4+
const model = new Model();
5+
resetValues();
6+
7+
contentView.append(
8+
<Stack stretch alignment='stretchX' padding={12} spacing={12}>
9+
<CheckBox font={{size: 24}} onSelect={toggleModelAttached}>
10+
Attach Model
11+
</CheckBox>
12+
<Button onSelect={resetValues}>
13+
Reset Model Values
14+
</Button>
15+
<Button onSelect={printValues}>
16+
Print Model Values
17+
</Button>
18+
<ExampleComponent background={Color.silver}/>
19+
<TextView/>
20+
</Stack>
21+
);
22+
23+
function toggleModelAttached({checked}: CheckBoxSelectEvent) {
24+
$(ExampleComponent).only().model = checked ? model : null;
25+
}
26+
27+
function resetValues() {
28+
model.myText = 'Initial Model Text';
29+
model.myNumber = 50;
30+
}
31+
32+
function printValues() {
33+
$(TextView).only().text = model.myText + '/' + model.myNumber;
34+
}

0 commit comments

Comments
 (0)