diff --git a/packages/core/test/acceptance/host_binding_spec.ts b/packages/core/test/acceptance/host_binding_spec.ts new file mode 100644 index 0000000000..3d9137033d --- /dev/null +++ b/packages/core/test/acceptance/host_binding_spec.ts @@ -0,0 +1,299 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ComponentFactoryResolver, ComponentRef, Directive, HostBinding, Input, NgModule, ViewChild, ViewContainerRef} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {ivyEnabled, onlyInIvy} from '@angular/private/testing'; + +describe('host bindings', () => { + onlyInIvy('map-based [style] and [class] bindings are not supported in VE') + .it('should render host bindings on the root component', () => { + @Component({template: '...'}) + class MyApp { + @HostBinding('style') myStylesExp = {}; + @HostBinding('class') myClassesExp = {}; + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + const element = fixture.nativeElement; + fixture.detectChanges(); + + const component = fixture.componentInstance; + component.myStylesExp = {width: '100px'}; + component.myClassesExp = 'foo'; + fixture.detectChanges(); + + expect(element.style['width']).toEqual('100px'); + expect(element.classList.contains('foo')).toBeTruthy(); + + component.myStylesExp = {width: '200px'}; + component.myClassesExp = 'bar'; + fixture.detectChanges(); + + expect(element.style['width']).toEqual('200px'); + expect(element.classList.contains('foo')).toBeFalsy(); + expect(element.classList.contains('bar')).toBeTruthy(); + }); + + describe('defined in @Component', () => { + it('should combine the inherited static classes of a parent and child component', () => { + @Component({template: '...', host: {'class': 'foo bar'}}) + class ParentCmp { + } + + @Component({template: '...', host: {'class': 'foo baz'}}) + class ChildCmp extends ParentCmp { + } + + TestBed.configureTestingModule({declarations: [ChildCmp]}); + const fixture = TestBed.createComponent(ChildCmp); + fixture.detectChanges(); + + const element = fixture.nativeElement; + if (ivyEnabled) { + expect(element.classList.contains('bar')).toBeTruthy(); + } + expect(element.classList.contains('foo')).toBeTruthy(); + expect(element.classList.contains('baz')).toBeTruthy(); + }); + + it('should render host class and style on the root component', () => { + @Component({template: '...', host: {class: 'foo', style: 'color: red'}}) + class MyApp { + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + const element = fixture.nativeElement; + fixture.detectChanges(); + + expect(element.style['color']).toEqual('red'); + expect(element.classList.contains('foo')).toBeTruthy(); + }); + + + it('should not cause problems if detectChanges is called when a property updates', () => { + /** + * Angular Material CDK Tree contains a code path whereby: + * + * 1. During the execution of a template function in which **more than one** property is + * updated in a row. + * 2. A property that **is not the last property** is updated in the **original template**: + * - That sets up a new observable and subscribes to it + * - The new observable it sets up can emit synchronously. + * - When it emits, it calls `detectChanges` on a `ViewRef` that it has a handle to + * - That executes a **different template**, that has host bindings + * - this executes `setHostBindings` + * - Inside of `setHostBindings` we are currently updating the selected index **global + * state** via `setActiveHostElement`. + * 3. We attempt to update the next property in the **original template**. + * - But the selected index has been altered, and we get errors. + */ + + @Component({ + selector: 'child', + template: `...`, + }) + class ChildCmp { + } + + @Component({ + selector: 'parent', + template: ` +
+
+

{{prop}}

+

{{prop2}}

+
+ `, + host: { + '[style.color]': 'color', + }, + }) + class ParentCmp { + private _prop = ''; + + @ViewChild('template', {read: ViewContainerRef}) + vcr: ViewContainerRef = null !; + + private child: ComponentRef = null !; + + @Input() + set prop(value: string) { + // Material CdkTree has at least one scenario where setting a property causes a data + // source + // to update, which causes a synchronous call to detectChanges(). + this._prop = value; + if (this.child) { + this.child.changeDetectorRef.detectChanges(); + } + } + + get prop() { return this._prop; } + + @Input() + prop2 = 0; + + ngAfterViewInit() { + const factory = this.componentFactoryResolver.resolveComponentFactory(ChildCmp); + this.child = this.vcr.createComponent(factory); + } + + constructor(private componentFactoryResolver: ComponentFactoryResolver) {} + } + + @Component({ + template: ``, + }) + class App { + prop = 'a'; + prop2 = 1; + } + + @NgModule({ + entryComponents: [ChildCmp], + declarations: [ChildCmp], + }) + class ChildCmpModule { + } + + TestBed.configureTestingModule({declarations: [App, ParentCmp], imports: [ChildCmpModule]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + fixture.componentInstance.prop = 'b'; + fixture.componentInstance.prop2 = 2; + fixture.detectChanges(); + }); + }); + + describe('via @HostBinding', () => { + it('should render styling for parent and sub-classed components in order', () => { + @Component({ + template: ` + + ` + }) + class MyApp { + } + + @Component({template: '...'}) + class ParentCmp { + @HostBinding('style.width') width1 = '100px'; + @HostBinding('style.height') height1 = '100px'; + @HostBinding('style.opacity') opacity1 = '0.5'; + } + + @Component({selector: 'child-and-parent-cmp', template: '...'}) + class ChildCmp extends ParentCmp { + @HostBinding('style.width') width2 = '200px'; + @HostBinding('style.height') height2 = '200px'; + } + + TestBed.configureTestingModule({declarations: [MyApp, ParentCmp, ChildCmp]}); + const fixture = TestBed.createComponent(MyApp); + const element = fixture.nativeElement; + fixture.detectChanges(); + + const childElement = element.querySelector('child-and-parent-cmp'); + expect(childElement.style.width).toEqual('200px'); + expect(childElement.style.height).toEqual('200px'); + expect(childElement.style.opacity).toEqual('0.5'); + }); + + onlyInIvy('[style.prop] and [class.name] prioritization is a new feature') + .it('should prioritize styling present in the order of directive hostBinding evaluation, but consider sub-classed directive styling to be the most important', + () => { + + @Component({template: '
'}) + class MyApp { + } + + @Directive({selector: '[parent-dir]'}) + class ParentDir { + @HostBinding('style.width') + get width1() { return '100px'; } + + @HostBinding('style.height') + get height1() { return '100px'; } + + @HostBinding('style.color') + get color1() { return 'red'; } + } + + @Directive({selector: '[child-dir]'}) + class ChildDir extends ParentDir { + @HostBinding('style.width') + get width2() { return '200px'; } + + @HostBinding('style.height') + get height2() { return '200px'; } + } + + @Directive({selector: '[sibling-dir]'}) + class SiblingDir { + @HostBinding('style.width') + get width3() { return '300px'; } + + @HostBinding('style.height') + get height3() { return '300px'; } + + @HostBinding('style.opacity') + get opacity3() { return '0.5'; } + + @HostBinding('style.color') + get color1() { return 'blue'; } + } + + TestBed.configureTestingModule( + {declarations: [MyApp, ParentDir, ChildDir, SiblingDir]}); + const fixture = TestBed.createComponent(MyApp); + const element = fixture.nativeElement; + fixture.detectChanges(); + + const childElement = element.querySelector('div'); + + // width/height values were set in all directives, but the sub-class directive + // (ChildDir) + // had priority over the parent directive (ParentDir) which is why its value won. It + // also + // won over Dir because the SiblingDir directive was evaluated later on. + expect(childElement.style.width).toEqual('200px'); + expect(childElement.style.height).toEqual('200px'); + + // ParentDir styled the color first before Dir + expect(childElement.style.color).toEqual('red'); + + // Dir was the only directive to style opacity + expect(childElement.style.opacity).toEqual('0.5'); + }); + + it('should allow class-bindings to be placed on ng-container elements', () => { + @Component({ + template: ` + ... + ` + }) + class MyApp { + } + + @Directive({selector: '[dir-that-adds-other-classes]'}) + class DirThatAddsOtherClasses { + @HostBinding('class.other-class') bool = true; + } + + TestBed.configureTestingModule({declarations: [MyApp, DirThatAddsOtherClasses]}); + expect(() => { + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + }).not.toThrow(); + }); + + }); +}); diff --git a/packages/core/test/acceptance/styling_spec.ts b/packages/core/test/acceptance/styling_spec.ts index 613d9c4432..bfef7645b1 100644 --- a/packages/core/test/acceptance/styling_spec.ts +++ b/packages/core/test/acceptance/styling_spec.ts @@ -5,191 +5,11 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {Component, ComponentFactoryResolver, ComponentRef, Directive, ElementRef, HostBinding, Input, NgModule, ViewChild, ViewContainerRef} from '@angular/core'; +import {Component, Directive, ElementRef} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/src/matchers'; -import {ivyEnabled, onlyInIvy} from '@angular/private/testing'; - -describe('acceptance integration tests', () => { - onlyInIvy('map-based [style] and [class] bindings are not supported in VE') - .it('should render host bindings on the root component', () => { - @Component({template: '...'}) - class MyApp { - @HostBinding('style') myStylesExp = {}; - @HostBinding('class') myClassesExp = {}; - } - - TestBed.configureTestingModule({declarations: [MyApp]}); - const fixture = TestBed.createComponent(MyApp); - const element = fixture.nativeElement; - fixture.detectChanges(); - - const component = fixture.componentInstance; - component.myStylesExp = {width: '100px'}; - component.myClassesExp = 'foo'; - fixture.detectChanges(); - - expect(element.style['width']).toEqual('100px'); - expect(element.classList.contains('foo')).toBeTruthy(); - - component.myStylesExp = {width: '200px'}; - component.myClassesExp = 'bar'; - fixture.detectChanges(); - - expect(element.style['width']).toEqual('200px'); - expect(element.classList.contains('foo')).toBeFalsy(); - expect(element.classList.contains('bar')).toBeTruthy(); - }); - - it('should render host class and style on the root component', () => { - @Component({template: '...', host: {class: 'foo', style: 'color: red'}}) - class MyApp { - } - - TestBed.configureTestingModule({declarations: [MyApp]}); - const fixture = TestBed.createComponent(MyApp); - const element = fixture.nativeElement; - fixture.detectChanges(); - - expect(element.style['color']).toEqual('red'); - expect(element.classList.contains('foo')).toBeTruthy(); - }); - - it('should combine the inherited static styles of a parent and child component', () => { - @Component({template: '...', host: {'style': 'width:100px; height:100px;'}}) - class ParentCmp { - } - - @Component({template: '...', host: {'style': 'width:200px; color:red'}}) - class ChildCmp extends ParentCmp { - } - - TestBed.configureTestingModule({declarations: [ChildCmp]}); - const fixture = TestBed.createComponent(ChildCmp); - fixture.detectChanges(); - - const element = fixture.nativeElement; - if (ivyEnabled) { - expect(element.style['height']).toEqual('100px'); - } - expect(element.style['width']).toEqual('200px'); - expect(element.style['color']).toEqual('red'); - }); - - it('should combine the inherited static classes of a parent and child component', () => { - @Component({template: '...', host: {'class': 'foo bar'}}) - class ParentCmp { - } - - @Component({template: '...', host: {'class': 'foo baz'}}) - class ChildCmp extends ParentCmp { - } - - TestBed.configureTestingModule({declarations: [ChildCmp]}); - const fixture = TestBed.createComponent(ChildCmp); - fixture.detectChanges(); - - const element = fixture.nativeElement; - if (ivyEnabled) { - expect(element.classList.contains('bar')).toBeTruthy(); - } - expect(element.classList.contains('foo')).toBeTruthy(); - expect(element.classList.contains('baz')).toBeTruthy(); - }); - - it('should not cause problems if detectChanges is called when a property updates', () => { - /** - * Angular Material CDK Tree contains a code path whereby: - * - * 1. During the execution of a template function in which **more than one** property is - * updated in a row. - * 2. A property that **is not the last property** is updated in the **original template**: - * - That sets up a new observable and subscribes to it - * - The new observable it sets up can emit synchronously. - * - When it emits, it calls `detectChanges` on a `ViewRef` that it has a handle to - * - That executes a **different template**, that has host bindings - * - this executes `setHostBindings` - * - Inside of `setHostBindings` we are currently updating the selected index **global - * state** via `setActiveHostElement`. - * 3. We attempt to update the next property in the **original template**. - * - But the selected index has been altered, and we get errors. - */ - - @Component({ - selector: 'child', - template: `...`, - }) - class ChildCmp { - } - - @Component({ - selector: 'parent', - template: ` -
-
-

{{prop}}

-

{{prop2}}

-
- `, - host: { - '[style.color]': 'color', - }, - }) - class ParentCmp { - private _prop = ''; - - @ViewChild('template', {read: ViewContainerRef}) - vcr: ViewContainerRef = null !; - - private child: ComponentRef = null !; - - @Input() - set prop(value: string) { - // Material CdkTree has at least one scenario where setting a property causes a data source - // to update, which causes a synchronous call to detectChanges(). - this._prop = value; - if (this.child) { - this.child.changeDetectorRef.detectChanges(); - } - } - - get prop() { return this._prop; } - - @Input() - prop2 = 0; - - ngAfterViewInit() { - const factory = this.componentFactoryResolver.resolveComponentFactory(ChildCmp); - this.child = this.vcr.createComponent(factory); - } - - constructor(private componentFactoryResolver: ComponentFactoryResolver) {} - } - - @Component({ - template: ``, - }) - class App { - prop = 'a'; - prop2 = 1; - } - - @NgModule({ - entryComponents: [ChildCmp], - declarations: [ChildCmp], - }) - class ChildCmpModule { - } - - TestBed.configureTestingModule({declarations: [App, ParentCmp], imports: [ChildCmpModule]}); - const fixture = TestBed.createComponent(App); - fixture.detectChanges(); - - fixture.componentInstance.prop = 'b'; - fixture.componentInstance.prop2 = 2; - fixture.detectChanges(); - }); +describe('styling', () => { it('should render inline style and class attribute values on the element before a directive is instantiated', () => { @Component({ @@ -247,107 +67,6 @@ describe('acceptance integration tests', () => { expect(element.classList.contains('abc')).toBeFalsy(); }); - it('should render styling for parent and sub-classed components in order', () => { - @Component({ - template: ` - - ` - }) - class MyApp { - } - - @Component({template: '...'}) - class ParentCmp { - @HostBinding('style.width') width1 = '100px'; - @HostBinding('style.height') height1 = '100px'; - @HostBinding('style.opacity') opacity1 = '0.5'; - } - - @Component({selector: 'child-and-parent-cmp', template: '...'}) - class ChildCmp extends ParentCmp { - @HostBinding('style.width') width2 = '200px'; - @HostBinding('style.height') height2 = '200px'; - } - - TestBed.configureTestingModule({declarations: [MyApp, ParentCmp, ChildCmp]}); - const fixture = TestBed.createComponent(MyApp); - const element = fixture.nativeElement; - fixture.detectChanges(); - - const childElement = element.querySelector('child-and-parent-cmp'); - expect(childElement.style.width).toEqual('200px'); - expect(childElement.style.height).toEqual('200px'); - expect(childElement.style.opacity).toEqual('0.5'); - }); - - onlyInIvy('[style.prop] and [class.name] prioritization is a new feature') - .it('should prioritize styling present in the order of directive hostBinding evaluation, but consider sub-classed directive styling to be the most important', - () => { - const log: string[] = []; - - @Component({template: '
'}) - class MyApp { - } - - @Directive({selector: '[parent-dir]'}) - class ParentDir { - @HostBinding('style.width') - get width1() { return '100px'; } - - @HostBinding('style.height') - get height1() { return '100px'; } - - @HostBinding('style.color') - get color1() { return 'red'; } - } - - @Directive({selector: '[child-dir]'}) - class ChildDir extends ParentDir { - @HostBinding('style.width') - get width2() { return '200px'; } - - @HostBinding('style.height') - get height2() { return '200px'; } - } - - @Directive({selector: '[sibling-dir]'}) - class SiblingDir { - @HostBinding('style.width') - get width3() { return '300px'; } - - @HostBinding('style.height') - get height3() { return '300px'; } - - @HostBinding('style.opacity') - get opacity3() { return '0.5'; } - - @HostBinding('style.color') - get color1() { return 'blue'; } - } - - TestBed.configureTestingModule( - {declarations: [MyApp, ParentDir, ChildDir, SiblingDir]}); - const fixture = TestBed.createComponent(MyApp); - const element = fixture.nativeElement; - fixture.detectChanges(); - - const childElement = element.querySelector('div'); - - // width/height values were set in all directives, but the sub-class directive - // (ChildDir) - // had priority over the parent directive (ParentDir) which is why its value won. It - // also - // won over Dir because the SiblingDir directive was evaluated later on. - expect(childElement.style.width).toEqual('200px'); - expect(childElement.style.height).toEqual('200px'); - - // ParentDir styled the color first before Dir - expect(childElement.style.color).toEqual('red'); - - // Dir was the only directive to style opacity - expect(childElement.style.opacity).toEqual('0.5'); - }); - it('should ensure that static classes are assigned to ng-container elements and picked up for content projection', () => { @Component({ @@ -387,25 +106,4 @@ describe('acceptance integration tests', () => { const outer = element.querySelector('.outer-area'); expect(outer.textContent.trim()).toEqual('outer'); }); - - it('should allow class-bindings to be placed on ng-container elements', () => { - @Component({ - template: ` - ... - ` - }) - class MyApp { - } - - @Directive({selector: '[dir-that-adds-other-classes]'}) - class DirThatAddsOtherClasses { - @HostBinding('class.other-class') bool = true; - } - - TestBed.configureTestingModule({declarations: [MyApp, DirThatAddsOtherClasses]}); - expect(() => { - const fixture = TestBed.createComponent(MyApp); - fixture.detectChanges(); - }).not.toThrow(); - }); });