Skip to content
This repository was archived by the owner on Dec 4, 2017. It is now read-only.

Commit e92651f

Browse files
bennadelFoxandxss
authored andcommitted
Add cookbook for custom form controls with ngModel.
This cookbook demonstrates how to use value accessors to connect custom components to the robust Form functionality that Angular provides with its `ngModel`, `ngControl`, and `ngFormControl` directives.
1 parent 5fd6ae3 commit e92651f

File tree

13 files changed

+559
-0
lines changed

13 files changed

+559
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// gulp run-e2e-tests --filter=cb-custom-form-controls
2+
describe('Building Custom Form Controls With ngModel', function () {
3+
4+
beforeAll(function () {
5+
browser.get('');
6+
});
7+
8+
it('should all be off', function () {
9+
10+
var toggles = element.all( by.css( 'cb-toggle' ) );
11+
12+
expect( toggles.getAttribute( 'class' ) ).toContain( "for-off" );
13+
expect( toggles.getAttribute( 'class' ) ).not.toContain( "for-on" );
14+
15+
});
16+
17+
it('should all toggle on', function () {
18+
19+
var toggle = element.all( by.css( 'cb-toggle' ) ).first();
20+
21+
toggle.click();
22+
23+
var toggles = element.all( by.css( 'cb-toggle' ) );
24+
25+
expect( toggles.getAttribute( 'class' ) ).toContain( "for-on" );
26+
expect( toggles.getAttribute( 'class' ) ).not.toContain( "for-off" );
27+
28+
});
29+
30+
it('should use ng-form classes that reflect interactions', function () {
31+
32+
var toggle = element.all( by.css( 'cb-toggle' ) ).last();
33+
34+
expect( toggle.getAttribute( 'class' ) ).toContain( 'ng-pristine' );
35+
expect( toggle.getAttribute( 'class' ) ).not.toContain( 'ng-dirty' );
36+
37+
toggle.click();
38+
39+
expect( toggle.getAttribute( 'class' ) ).toContain( 'ng-dirty' );
40+
expect( toggle.getAttribute( 'class' ) ).not.toContain( 'ng-pristine' );
41+
42+
var toggles = element.all( by.css( 'cb-toggle' ) );
43+
44+
expect( toggles.getAttribute( 'class' ) ).toContain( "for-off" );
45+
expect( toggles.getAttribute( 'class' ) ).not.toContain( "for-on" );
46+
47+
});
48+
49+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
npm-debug.log
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// #docregion
2+
// Import the native Angular services.
3+
import { Component } from '@angular/core';
4+
5+
// Import our custom Angular classes.
6+
// #docregion providers
7+
import { ToggleComponent } from './toggle.component';
8+
import { ToggleNgModelDirective } from './toggle-ng-model.directive';
9+
10+
@Component({
11+
selector: 'cb-app',
12+
directives: [ ToggleComponent, ToggleNgModelDirective ],
13+
template:
14+
`
15+
<cb-toggle [value]='isOn' (valueChange)='handleChange( $event )'></cb-toggle>
16+
<cb-toggle [(value)]='isOn'></cb-toggle>
17+
<cb-toggle [(ngModel)]='isOn'></cb-toggle>
18+
19+
<form #toggleForm='ngForm'>
20+
<cb-toggle #toggle='ngForm' ngControl='toggle' [(ngModel)]='isOn'></cb-toggle>
21+
<p>
22+
<strong>Form is Dirty:</strong> {{ toggleForm.dirty }}<br />
23+
<strong>Toggle is Dirty:</strong> {{ toggle.dirty }}
24+
</p>
25+
</form>
26+
`
27+
})
28+
// #enddocregion providers
29+
export class AppComponent {
30+
31+
// Public properties.
32+
public isOn: boolean;
33+
34+
public constructor() {
35+
this.isOn = false;
36+
}
37+
38+
public handleChange( newValue: boolean ) : void {
39+
this.isOn = newValue;
40+
}
41+
42+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// #docregion
2+
import { bootstrap } from '@angular/platform-browser-dynamic';
3+
import { AppComponent } from './app.component';
4+
5+
bootstrap(AppComponent).then(
6+
function handleResolve() {
7+
window.console.info( 'Angular finished bootstrapping your application!' )
8+
},
9+
function handleReject( error ) {
10+
console.warn( 'Angular was not able to bootstrap your application.' );
11+
console.error( error );
12+
}
13+
);
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// #docregion
2+
// Import the native Angular services.
3+
import { ChangeDetectorRef } from '@angular/core';
4+
import { ControlValueAccessor } from '@angular/common';
5+
import { Directive } from '@angular/core';
6+
import { forwardRef } from '@angular/core';
7+
import { HostListener } from '@angular/core';
8+
import { NG_VALUE_ACCESSOR } from '@angular/common';
9+
import { provide } from '@angular/core';
10+
import { SimpleChange } from '@angular/core';
11+
12+
// #docregion valueaccesor
13+
// Import our custom Angular classes.
14+
import { ToggleComponent } from './toggle.component';
15+
16+
// #docregion provider
17+
@Directive({
18+
selector: 'cb-toggle[ngControl],cb-toggle[ngFormControl],cb-toggle[ngModel]',
19+
providers: [
20+
provide(
21+
NG_VALUE_ACCESSOR,
22+
{
23+
useExisting: forwardRef( () => ToggleNgModelDirective ),
24+
multi: true
25+
}
26+
)
27+
],
28+
host: {
29+
'(valueChange)': 'handleValueChange( $event )'
30+
}
31+
})
32+
export class ToggleNgModelDirective implements ControlValueAccessor {
33+
// #enddocregion provider
34+
35+
// Private properties.
36+
private changeCount: number;
37+
private changeDetectorRef: ChangeDetectorRef;
38+
private onChange: any;
39+
private onTouched: any;
40+
private toggle: ToggleComponent;
41+
42+
public constructor( toggle: ToggleComponent, changeDetectorRef: ChangeDetectorRef ) {
43+
this.changeCount = 0;
44+
this.changeDetectorRef = changeDetectorRef;
45+
this.onChange = noop;
46+
this.onTouched = noop;
47+
this.toggle = toggle;
48+
}
49+
50+
public handleValueChange( newValue: boolean ) : void {
51+
// When implementing ngModel, we are purposefully circumventing one-way data flow.
52+
// As such, when the target component emits a change event, we want to turn around
53+
// and pipe that change right back into the target component.
54+
this.applyChangeToTarget( this.toggle.value, newValue );
55+
this.onChange( newValue );
56+
}
57+
58+
public registerOnChange( newOnChange: any ) : void {
59+
this.onChange = newOnChange;
60+
}
61+
62+
public registerOnTouched( newOnTouched: any ) : void {
63+
// CAUTION: For this demo, we are not worrying about "touch" events.
64+
this.onTouched = newOnTouched;
65+
}
66+
67+
public writeValue( newValue: any ) : void {
68+
this.applyChangeToTarget( this.toggle.value, !! newValue );
69+
}
70+
71+
// Private methods.
72+
73+
// #docregion applychanges
74+
private applyChangeToTarget( previousValue: boolean, currentValue: boolean ) : void {
75+
// Pipe the value into the target and alert the change detector that inputs
76+
// have been changed PROGRAMMATICALLY.
77+
this.toggle.value = currentValue;
78+
this.changeDetectorRef.markForCheck();
79+
80+
// Unfortunately, when we change the target component's inputs programmatically,
81+
// Angular doesn't help us with the life-cycle methods. As such, we have to fill in
82+
// the ngOnChanges() gap as the target component may be depending on it internally.
83+
if ( this.toggle.ngOnChanges ) {
84+
var change = new SimpleChange( previousValue, currentValue );
85+
86+
// Unfortunately, Angular uses a private token internally to determine if the
87+
// given change is the "first" change (for the given value) in the component's
88+
// life-cycle. Since the target component may be relying on the isFirstChange()
89+
// method to work as expected, we have to patch it manually.
90+
if ( ! this.changeCount++ ) {
91+
change.isFirstChange = ( () => true );
92+
}
93+
94+
this.toggle.ngOnChanges({ value: change });
95+
}
96+
}
97+
// #enddocregion applychanges
98+
99+
}
100+
// #enddocregion valueaccesor
101+
102+
function noop() {
103+
// No-operation function.
104+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// #docregion
2+
// Import the native Angular services.
3+
import { ChangeDetectionStrategy } from '@angular/core';
4+
import { Component } from '@angular/core';
5+
import { EventEmitter } from '@angular/core';
6+
import { HostBinding } from '@angular/core';
7+
import { HostListener } from '@angular/core';
8+
import { Input } from '@angular/core';
9+
import { OnChanges } from '@angular/core';
10+
import { Output } from '@angular/core';
11+
import { SimpleChange } from '@angular/core';
12+
13+
// #docregion component
14+
@Component({
15+
selector: 'cb-toggle',
16+
host: {
17+
'[class.for-on]': 'value',
18+
'[class.for-off]': '! value',
19+
'(click)': 'handleClick( $event )'
20+
},
21+
template:
22+
`
23+
<div class='switch'>
24+
<span class='thumb'></span>
25+
</div>
26+
<span class='label'>{{ ( value ? 'On' : 'Off' ) }}</span>
27+
`
28+
})
29+
export class ToggleComponent implements OnChanges {
30+
31+
// Public properties.
32+
@Input() public value: boolean;
33+
@Output() public valueChange: EventEmitter<boolean>;
34+
35+
public constructor() {
36+
this.value = false;
37+
this.valueChange = new EventEmitter();
38+
}
39+
40+
public handleClick( event: any ) : void {
41+
this.valueChange.next( ! this.value );
42+
}
43+
44+
public ngOnChanges( changes: any ) {
45+
console.log(
46+
'Toggle changed from %s to %s during %s change.',
47+
changes.value.previousValue,
48+
changes.value.currentValue,
49+
( changes.value.isFirstChange() ? 'first' : 'subsequent' )
50+
);
51+
}
52+
53+
}
54+
// #enddocregion component

public/docs/_examples/cb-custom-form-controls/ts/example-config.json

Whitespace-only changes.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!-- #docregion -->
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1">
7+
<base href="/">
8+
9+
<title>
10+
Creating Custom Form Controls Using ngModel
11+
</title>
12+
13+
<!-- #docregion style -->
14+
<link rel="stylesheet" type="text/css" href="styles.css">
15+
<link rel="stylesheet" type="text/css" href="sample.css">
16+
<!-- #enddocregion style -->
17+
18+
<!-- Polyfill(s) for older browsers -->
19+
<script src="node_modules/es6-shim/es6-shim.min.js"></script>
20+
21+
<script src="node_modules/zone.js/dist/zone.js"></script>
22+
<script src="node_modules/reflect-metadata/Reflect.js"></script>
23+
<script src="node_modules/systemjs/dist/system.src.js"></script>
24+
25+
<script src="systemjs.config.js"></script>
26+
<script>
27+
System.import('app')
28+
.then(function() { console.info( "System.js loaded your application module." )})
29+
.catch(function(err){ console.error(err); });
30+
</script>
31+
</head>
32+
<body>
33+
34+
<h1>
35+
Creating Custom Form Controls Using ngModel
36+
</h1>
37+
38+
<cb-app>Loading app...</cb-app>
39+
40+
</body>
41+
</html>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"description": "Creating Custom Form Controls Using ngModel",
3+
"files": [
4+
"!**/*.d.ts",
5+
"!**/*.js",
6+
"!**/*.[1].*"
7+
],
8+
"tags": [ "cookbook" ]
9+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/* #docregion */
2+
cb-toggle {
3+
cursor: pointer ;
4+
display: table ;
5+
height: 26px ;
6+
line-height: 27px ;
7+
margin-bottom: 16px ;
8+
user-select: none ;
9+
-moz-user-select: none ;
10+
-webkit-user-select: none ;
11+
}
12+
13+
cb-toggle div.switch {
14+
background-color: #333333 ;
15+
border-radius: 26px 26px 26px 26px ;
16+
display: inline-block ;
17+
height: 26px ;
18+
overflow: hidden ;
19+
position: relative ;
20+
width: 55px ;
21+
}
22+
23+
cb-toggle span.thumb {
24+
background-color: #F0F0F0 ;
25+
border-radius: 100px ;
26+
height: 20px ;
27+
left: 0px ;
28+
margin-left: 4px ;
29+
position: absolute ;
30+
transition: all 200ms ease ;
31+
-moz-transition: all 200ms ease ;
32+
-webkit-transition: all 200ms ease ;
33+
top: 3px ;
34+
width: 20px ;
35+
}
36+
37+
cb-toggle span.label {
38+
display: inline-block ;
39+
margin-left: 7px ;
40+
text-transform: uppercase ;
41+
vertical-align: top ;
42+
}
43+
44+
cb-toggle.for-on div.switch {
45+
background-color: #30659B ;
46+
}
47+
48+
cb-toggle.for-on span.thumb {
49+
left: 100% ;
50+
margin-left: -24px ;
51+
}
52+
53+
form {
54+
border: 1px dashed #CCCCCC ;
55+
border-radius: 4px 4px 4px 4px ;
56+
padding: 20px 20px 20px 20px ;
57+
}
58+
59+
form p {
60+
margin: 0px 0px 0px 0px ;
61+
}

public/docs/ts/latest/cookbook/_data.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
"intro": "Use relative URLs for component templates and styles."
2727
},
2828

29+
"custom-form-controls": {
30+
"title": "Custom Form Controls",
31+
"intro": "Creating custom form controls using ngModel."
32+
},
33+
2934
"dependency-injection": {
3035
"title": "Dependency Injection",
3136
"intro": "Techniques for Dependency Injection"

0 commit comments

Comments
 (0)