diff --git a/public/docs/_examples/cb-custom-form-controls/e2e-spec.ts b/public/docs/_examples/cb-custom-form-controls/e2e-spec.ts new file mode 100644 index 0000000000..4121b7805b --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/e2e-spec.ts @@ -0,0 +1,44 @@ +/// +'use strict'; +describe('Building Custom Form Controls With ngModel', function () { + + beforeAll(function () { + browser.get(''); + }); + + it('should all be off', function () { + const toggles = element.all(by.css('cb-toggle')); + + expect(toggles.getAttribute('class')).toContain('for-off'); + expect(toggles.getAttribute('class')).not.toContain('for-on'); + }); + + it('should all toggle on', function () { + const toggle = element.all(by.css('cb-toggle')).first(); + + toggle.click(); + + const toggles = element.all(by.css('cb-toggle')); + + expect(toggles.getAttribute('class')).toContain('for-on'); + expect(toggles.getAttribute('class')).not.toContain('for-off'); + }); + + it('should use ng-form classes that reflect interactions', function () { + const toggle = element.all(by.css('cb-toggle')).last(); + + expect(toggle.getAttribute('class')).toContain('ng-pristine'); + expect(toggle.getAttribute('class')).not.toContain('ng-dirty'); + + toggle.click(); + + expect(toggle.getAttribute('class')).toContain('ng-dirty'); + expect(toggle.getAttribute('class')).not.toContain('ng-pristine'); + + const toggles = element.all(by.css('cb-toggle')); + + expect(toggles.getAttribute('class')).toContain('for-off'); + expect(toggles.getAttribute('class')).not.toContain('for-on'); + }); + +}); diff --git a/public/docs/_examples/cb-custom-form-controls/ts/.gitignore b/public/docs/_examples/cb-custom-form-controls/ts/.gitignore new file mode 100644 index 0000000000..5c96d0d93b --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/ts/.gitignore @@ -0,0 +1 @@ +npm-debug.log \ No newline at end of file diff --git a/public/docs/_examples/cb-custom-form-controls/ts/app/app.component.ts b/public/docs/_examples/cb-custom-form-controls/ts/app/app.component.ts new file mode 100644 index 0000000000..ffc5ade9dc --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/ts/app/app.component.ts @@ -0,0 +1,31 @@ +// #docregion +import { Component } from '@angular/core'; +// #docregion providers +import { ToggleComponent } from './toggle.component'; +import { ToggleNgModelDirective } from './toggle-ng-model.directive'; + +@Component({ + selector: 'cb-app', + directives: [ ToggleComponent, ToggleNgModelDirective ], + template: ` + + + + +
+ +

+ Form is Dirty: {{ toggleForm.dirty }}
+ Toggle is Dirty: {{ toggle.dirty }} +

+
+ ` +}) +// #enddocregion providers +export class AppComponent { + public isOn = false; + + public handleChange(newValue: boolean): void { + this.isOn = newValue; + } +} diff --git a/public/docs/_examples/cb-custom-form-controls/ts/app/main.ts b/public/docs/_examples/cb-custom-form-controls/ts/app/main.ts new file mode 100644 index 0000000000..cf659fb889 --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/ts/app/main.ts @@ -0,0 +1,13 @@ +// #docregion +import { bootstrap } from '@angular/platform-browser-dynamic'; +import { AppComponent } from './app.component'; + +bootstrap(AppComponent).then( + function handleResolve() { + window.console.info( 'Angular finished bootstrapping your application!' ) + }, + function handleReject( error ) { + console.warn( 'Angular was not able to bootstrap your application.' ); + console.error( error ); + } +); diff --git a/public/docs/_examples/cb-custom-form-controls/ts/app/toggle-ng-model.directive.ts b/public/docs/_examples/cb-custom-form-controls/ts/app/toggle-ng-model.directive.ts new file mode 100644 index 0000000000..7b6676c7d6 --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/ts/app/toggle-ng-model.directive.ts @@ -0,0 +1,85 @@ +// #docregion +import { ChangeDetectorRef, Directive, forwardRef, provide, SimpleChange } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/common'; + +// #docregion valueaccesor +import { ToggleComponent } from './toggle.component'; + +// #docregion provider +@Directive({ + selector: 'cb-toggle[ngControl],cb-toggle[ngFormControl],cb-toggle[ngModel]', + providers: [ + provide( + NG_VALUE_ACCESSOR, + { + useExisting: forwardRef(() => ToggleNgModelDirective), + multi: true + } + ) + ], + host: { + '(valueChange)': 'handleValueChange($event)' + } +}) +export class ToggleNgModelDirective implements ControlValueAccessor { +// #enddocregion provider + + private changeCount = 0; + private onChange = noop; + private onTouched = noop; + + constructor(private toggle: ToggleComponent, private changeDetectorRef: ChangeDetectorRef) {} + + handleValueChange(newValue: boolean): void { + // When implementing ngModel, we are purposefully circumventing one-way data flow. + // As such, when the target component emits a change event, we want to turn around + // and pipe that change right back into the target component. + this.applyChangeToTarget(this.toggle.value, newValue); + this.onChange(newValue); + } + + registerOnChange(newOnChange: any): void { + this.onChange = newOnChange; + } + + registerOnTouched(newOnTouched: any): void { + // CAUTION: For this demo, we are not worrying about "touch" events. + this.onTouched = newOnTouched; + } + + writeValue(newValue: any): void { + this.applyChangeToTarget(this.toggle.value, !! newValue ); + } + + // #docregion applychanges + private applyChangeToTarget(previousValue: boolean, currentValue: boolean): void { + // Pipe the value into the target and alert the change detector that inputs + // have been changed PROGRAMMATICALLY. + this.toggle.value = currentValue; + this.changeDetectorRef.markForCheck(); + + // Unfortunately, when we change the target component's inputs programmatically, + // Angular doesn't help us with the life-cycle methods. As such, we have to fill in + // the ngOnChanges() gap as the target component may be depending on it internally. + if (this.toggle.ngOnChanges) { + let change = new SimpleChange( previousValue, currentValue ); + + // Unfortunately, Angular uses a private token internally to determine if the + // given change is the "first" change (for the given value) in the component's + // life-cycle. Since the target component may be relying on the isFirstChange() + // method to work as expected, we have to patch it manually. + if (!this.changeCount++) { + change.isFirstChange = (() => true); + } + + this.toggle.ngOnChanges({ value: change }); + } + } + // #enddocregion applychanges + +} +// #enddocregion valueaccesor + +function noop(): void { + // No-operation function. +} diff --git a/public/docs/_examples/cb-custom-form-controls/ts/app/toggle.component.ts b/public/docs/_examples/cb-custom-form-controls/ts/app/toggle.component.ts new file mode 100644 index 0000000000..ef478a3b56 --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/ts/app/toggle.component.ts @@ -0,0 +1,37 @@ +// #docregion +import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; + +// #docregion component +@Component({ + selector: 'cb-toggle', + host: { + '[class.for-on]': 'value', + '[class.for-off]': '! value', + '(click)': 'handleClick($event)' + }, + template: ` +
+ +
+ {{ ( value ? 'On' : 'Off' ) }} + ` +}) +export class ToggleComponent implements OnChanges { + @Input() public value = false; + @Output() public valueChange = new EventEmitter; + + handleClick(event: any): void { + this.valueChange.next( ! this.value ); + } + + ngOnChanges(changes: any): void { + console.log( + 'Toggle changed from %s to %s during %s change.', + changes.value.previousValue, + changes.value.currentValue, + (changes.value.isFirstChange() ? 'first' : 'subsequent') + ); + } + +} +// #enddocregion component diff --git a/public/docs/_examples/cb-custom-form-controls/ts/example-config.json b/public/docs/_examples/cb-custom-form-controls/ts/example-config.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/public/docs/_examples/cb-custom-form-controls/ts/index.html b/public/docs/_examples/cb-custom-form-controls/ts/index.html new file mode 100644 index 0000000000..29890cece7 --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/ts/index.html @@ -0,0 +1,41 @@ + + + + + + + + + + Creating Custom Form Controls Using ngModel + + + + + + + + + + + + + + + + + + + +

+ Creating Custom Form Controls Using ngModel +

+ + Loading app... + + + diff --git a/public/docs/_examples/cb-custom-form-controls/ts/plnkr.json b/public/docs/_examples/cb-custom-form-controls/ts/plnkr.json new file mode 100644 index 0000000000..5b7ad480a0 --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/ts/plnkr.json @@ -0,0 +1,9 @@ +{ + "description": "Creating Custom Form Controls Using ngModel", + "files": [ + "!**/*.d.ts", + "!**/*.js", + "!**/*.[1].*" + ], + "tags": [ "cookbook" ] +} diff --git a/public/docs/_examples/cb-custom-form-controls/ts/sample.css b/public/docs/_examples/cb-custom-form-controls/ts/sample.css new file mode 100644 index 0000000000..cb52df07d1 --- /dev/null +++ b/public/docs/_examples/cb-custom-form-controls/ts/sample.css @@ -0,0 +1,61 @@ +/* #docregion */ +cb-toggle { + cursor: pointer ; + display: table ; + height: 26px ; + line-height: 27px ; + margin-bottom: 16px ; + user-select: none ; + -moz-user-select: none ; + -webkit-user-select: none ; +} + +cb-toggle div.switch { + background-color: #333333 ; + border-radius: 26px 26px 26px 26px ; + display: inline-block ; + height: 26px ; + overflow: hidden ; + position: relative ; + width: 55px ; +} + +cb-toggle span.thumb { + background-color: #F0F0F0 ; + border-radius: 100px ; + height: 20px ; + left: 0px ; + margin-left: 4px ; + position: absolute ; + transition: all 200ms ease ; + -moz-transition: all 200ms ease ; + -webkit-transition: all 200ms ease ; + top: 3px ; + width: 20px ; +} + +cb-toggle span.label { + display: inline-block ; + margin-left: 7px ; + text-transform: uppercase ; + vertical-align: top ; +} + +cb-toggle.for-on div.switch { + background-color: #30659B ; +} + +cb-toggle.for-on span.thumb { + left: 100% ; + margin-left: -24px ; +} + +form { + border: 1px dashed #CCCCCC ; + border-radius: 4px 4px 4px 4px ; + padding: 20px 20px 20px 20px ; +} + +form p { + margin: 0px 0px 0px 0px ; +} diff --git a/public/docs/ts/latest/cookbook/_data.json b/public/docs/ts/latest/cookbook/_data.json index f82d816cef..df29db6bef 100644 --- a/public/docs/ts/latest/cookbook/_data.json +++ b/public/docs/ts/latest/cookbook/_data.json @@ -26,6 +26,11 @@ "intro": "Use relative URLs for component templates and styles." }, + "custom-form-controls": { + "title": "Custom Form Controls", + "intro": "Creating custom form controls using ngModel." + }, + "dependency-injection": { "title": "Dependency Injection", "intro": "Techniques for Dependency Injection" diff --git a/public/docs/ts/latest/cookbook/custom-form-controls.jade b/public/docs/ts/latest/cookbook/custom-form-controls.jade new file mode 100644 index 0000000000..18d7391f00 --- /dev/null +++ b/public/docs/ts/latest/cookbook/custom-form-controls.jade @@ -0,0 +1,180 @@ +include ../_util-fns + +a(id='top') + +:marked + When we want to make a custom form control, the first thing that we want to do is **not + make a custom form control**. By that, I mean that we want to build our components as if + they know nothing about forms. In the same way that the native Input, Select, and + Textarea elements know nothing about `ngModel`, neither should our custom components. + Binding a component to a form-based interaction is the responsibility of an external + "glue and translation layer" know as a "value accessor". + +:marked + ## Building A Basic Component + +:marked + To explore the relationship between components and form controls, we'll start by + building a Toggle component that displays an On/Off switch. Our component will accept + a `[value]` input binding and expose a `(valueChange)` output event: + +:marked + **See the [live example](/resources/live-examples/cb-custom-form-controls/ts/plnkr.html)**. + ++makeExample( 'cb-custom-form-controls/ts/app/toggle.component.ts', 'component', 'app/toggle.component.ts - Our Custom Widget' )(format='.') + +:marked + At this point, we might actually be done. + +:marked + If all we need to do is implement a proper one-way data flow, this Toggle component is + ready for consumption; we can use the `[value]` input to assign a value and the + `(valueChange)` output to react to user interactions. + +code-example(format='') + <cb-toggle [value]='isOn' (valueChange)='handleChange($event)'></cb-toggle> + +:marked + In fact, even if we want to implement a two-way data flow, this Toggle component is + equally viable thanks to the `[x]` and `(xChange)` naming convention that we followed: + +code-example(format='') + <cb-toggle [(value)]='isOn'></cb-toggle> + +:marked + ## Connecting Components To The Form + +:marked + For most situations, the preceding code is likely to be sufficient. However, if we + need to take advantage of the robust form management features that Angular provides, + we need to create a "value accessor" that facilitates bi-directional communication + between the parent form and our component. The value accessor has several + responsibilities: + +:marked + * Push external data into the component. + * Announce data-change events emitted by the component. + * Loop data-change events back into the component (*implementing two-way data flow*). + * Format data on the way into the component (*implementing optional formatters*). + * Parse data on the way out of the component (*implementing optional parsers*). + * Coordinate with change-detection strategies. + * Coordinate with the component life-cycle event handlers. + +:marked + The value accessor must implement the `ControlValueAccessor` interface: + +:marked + * `writeValue(obj: any) : void` + * `registerOnChange(fn: any) : void` + * `registerOnTouched(fn: any) : void` + +:marked + In order for `ngModel` to be able to consume the value accessor, the value accessor + needs to be made available within the dependency injector using the `NG_VALUE_ACCESSOR` + token. Typically, this is done using an attribute directive that selects on the target + element's node-name but limits the match based on the existence of an attribute that + implies `ngModel` usage. For example, the default value accessor for `Textarea` uses + the following selector: + +:marked + `textarea[ngControl],textarea[ngFormControl],textarea[ngModel]` + +:marked + In this scenario, the attribute directive is typically playing two roles. On the one + hand, it is a directive that is providing the value accessor service to the injector + associated with the target component. But, on the other hand, it is also implementing + the value accessor interface. In other words, it is both a directive and a value + accessor. In order to accomplish this, we have to define a provider that points back + to itself: + ++makeExample( 'cb-custom-form-controls/ts/app/toggle-ng-model.directive.ts', 'provider', 'app/toggle-ng-model.directive.ts - Provider Meta-Data' )(format='.') + +:marked + Notice that our `ToggleNgModelDirective` directive meta-data defines a `useExisting` + provider that is nothing more than a `forwardRef()` back to itself. And, that our + directive implements the `ControlValueAccessor` interface. At this point, when the + `ngModel` directive requests the `NG_VALUE_ACCESSOR` injectable, Angular will provide + it with the `ToggleNgModelDirective` class instance. + +:marked + Now, implementing the actual `ControlValueAccessor` interface is complicated. But, to + some degree, it can be boiled down to a single method that applies the value change + to the target component (which, in our case, is the `ToggleComponent` component): + ++makeExample( 'cb-custom-form-controls/ts/app/toggle-ng-model.directive.ts', 'applychanges', 'app/toggle-ng-model.directive.ts - Applying Value Changes' )(format='.') + +:marked + At this point, the rest of the value accessor is little more than a set of methods that + marshal external requests (from either the `ToggleComponent` component or the `ngModel` + directive) and invoke the above method with the appropriate arguments. Bringing it all + together, our Directive / Value Accessor implementation looks like this: + ++makeExample( 'cb-custom-form-controls/ts/app/toggle-ng-model.directive.ts', 'valueaccesor', 'app/toggle-ng-model.directive.ts - Bringing It All Together' )(format='.') + +:marked + In this case, we didn't implement any optional parsers or formatters. But, if we wanted + to, we could parse the data in our `handleValueChange()` method; and, we could format + the data in our `writeValue()` method. + +:marked + Now that we have our value accessor defined, we can use our our `ToggleComponent` + component with or without `ngModel`. Of course, if we want to use `ngModel`, we have to + provide the calling context with the both the `ToggleComponent` and the + `ToggleNgModelDirective` directives; otherwise, `ngModel` won't know how to access the + underlying data. In the following view, notice that we are using four separate + instances of the `ToggleComponent` component, each with a slightly different syntax and + access pattern. + ++makeExample( 'cb-custom-form-controls/ts/app/app.component.ts', 'providers', 'app/app.component.ts - Consuming ToggleComponent With ngModel' )(format='.') + +:marked + The last Toggle in the example is wrapped in a `form` element that exposes view-local + references for `#toggleForm` and `#toggle`. With these references we can confirm that + the appropriate CSS classes - `ng-pristine` and `ng-dirty` - are present on the last + toggle when we interact with it. + +figure.image-display + img( src='/resources/images/cookbooks/custom-form-controls/custom-form-controls.gif' alt='Custom form controls with ngModel' ) + +:marked + Here's the complete solution: + ++makeTabs( + ` + cb-custom-form-controls/ts/app/main.ts, + cb-custom-form-controls/ts/app/app.component.ts, + cb-custom-form-controls/ts/app/toggle.component.ts, + cb-custom-form-controls/ts/app/toggle-ng-model.directive.ts, + cb-custom-form-controls/ts/sample.css + `, + '', + ` + main.ts, + app.component.ts, + toggle.component.ts, + toggle-ng-model.directive.ts, + sample.css + ` +) + +.l-sub-section + :marked + ### Future Improvements + + :marked + If we study the above code, we can see that the value accessor functionality depends + on a few references: + + :marked + * The target component. + * The name of the property being mutated. + * The `changeDetectorRef`. + + :marked + Since the bulk of the logic is already encapsulated within the + `applyChangeToTarget()` method, it would be rather easy to move all of this logic + into an abstract base class. Then, our concrete class would do little more than + invoke the super class constructor with the appropriate references. + +:marked + [Back to top](#top) diff --git a/public/resources/images/cookbooks/custom-form-controls/custom-form-controls.gif b/public/resources/images/cookbooks/custom-form-controls/custom-form-controls.gif new file mode 100644 index 0000000000..e6d7ea427e Binary files /dev/null and b/public/resources/images/cookbooks/custom-form-controls/custom-form-controls.gif differ