From e3633888edabedc1303f96effeb92efb3e6f5129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Thu, 6 Sep 2018 18:50:57 -0700 Subject: [PATCH] feat(ivy): support animation @triggers in templates (#25849) PR Close #25849 --- .../compliance/r3_compiler_compliance_spec.ts | 2 +- .../r3_view_compiler_styling_spec.ts | 46 +++++++ .../compiler/src/render3/view/template.ts | 58 ++++++-- .../core/test/render3/integration_spec.ts | 128 +++++++++++++++++- 4 files changed, 212 insertions(+), 22 deletions(-) diff --git a/packages/compiler-cli/test/compliance/r3_compiler_compliance_spec.ts b/packages/compiler-cli/test/compliance/r3_compiler_compliance_spec.ts index ba02f37428..fdb81fc978 100644 --- a/packages/compiler-cli/test/compliance/r3_compiler_compliance_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_compiler_compliance_spec.ts @@ -836,7 +836,7 @@ describe('compiler compliance', () => { }; const MyAppDefinition = ` - const $e0_attr$ = [${AttributeMarker.SelectOnly}, "names"]; + const $e0_attr$ = [${AttributeMarker.SelectOnly}, "names"]; const $e0_ff$ = function ($v0$, $v1$, $v2$, $v3$, $v4$, $v5$, $v6$, $v7$, $v8$) { return ["start-", $v0$, $v1$, $v2$, $v3$, $v4$, "-middle-", $v5$, $v6$, $v7$, $v8$, "-end"]; } diff --git a/packages/compiler-cli/test/compliance/r3_view_compiler_styling_spec.ts b/packages/compiler-cli/test/compliance/r3_view_compiler_styling_spec.ts index 3a5aeb201f..7b52c47e9b 100644 --- a/packages/compiler-cli/test/compliance/r3_view_compiler_styling_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_view_compiler_styling_spec.ts @@ -180,6 +180,52 @@ describe('compiler compliance: styling', () => { const result = compile(files, angularFiles); expectEmit(result.source, template, 'Incorrect template'); }); + + it('should generate any animation triggers into the component template', () => { + const files = { + app: { + 'spec.ts': ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: "my-component", + template: \` +
+
+
\`, + }) + export class MyComponent { + } + + @NgModule({declarations: [MyComponent]}) + export class MyModule {} + ` + } + }; + + const template = ` + const $e0_attrs$ = ["@foo", ""]; + const $e1_attrs$ = ["@bar", ""]; + const $e2_attrs$ = ["@baz", ""]; + … + MyComponent.ngComponentDef = $r3$.ɵdefineComponent({ + … + template: function MyComponent_Template(rf, $ctx$) { + if (rf & 1) { + $r3$.ɵelement(0, "div", $e0_attrs$); + $r3$.ɵelement(1, "div", $e1_attrs$); + $r3$.ɵelement(2, "div", $e2_attrs$); + } + if (rf & 2) { + $r3$.ɵelementAttribute(0, "@foo", $r3$.ɵbind(ctx.exp)); + } + } + }); + `; + + const result = compile(files, angularFiles); + expectEmit(result.source, template, 'Incorrect template'); + }); }); describe('[style] and [style.prop]', () => { diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 1ba335163e..c3c84fe164 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -36,10 +36,11 @@ function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefin switch (type) { case BindingType.Property: return R3.elementProperty; - case BindingType.Attribute: - return R3.elementAttribute; case BindingType.Class: return R3.elementClassProp; + case BindingType.Attribute: + case BindingType.Animation: + return R3.elementAttribute; default: return undefined; } @@ -459,7 +460,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver initialClassDeclarations.length || classInputs.length; // add attributes for directive matching purposes - attributes.push(...this.prepareSelectOnlyAttrs(allOtherInputs, element.outputs)); + attributes.push(...this.prepareSyntheticAndSelectOnlyAttrs(allOtherInputs, element.outputs)); parameters.push(this.toAttrsParam(attributes)); // local refs (ex.:
) @@ -608,20 +609,27 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // Generate element input bindings allOtherInputs.forEach((input: t.BoundAttribute) => { - if (input.type === BindingType.Animation) { - console.error('warning: animation bindings not yet supported'); - return; - } - const instruction = mapBindingToInstruction(input.type); - if (instruction) { + if (input.type === BindingType.Animation) { + const value = input.value.visit(this._valueConverter); + // setAttribute without a value doesn't make any sense + if (value.name || value.value) { + const name = prepareSyntheticAttributeName(input.name); + this.updateInstruction(input.sourceSpan, R3.elementAttribute, () => { + return [ + o.literal(elementIndex), o.literal(name), this.convertPropertyBinding(implicit, value) + ]; + }); + } + } else if (instruction) { const params: any[] = []; const sanitizationRef = resolveSanitizationFn(input, input.securityContext); if (sanitizationRef) params.push(sanitizationRef); - // TODO(chuckj): runtime: security context? + // TODO(chuckj): runtime: security context const value = input.value.visit(this._valueConverter); this.allocateBindingSlots(value); + this.updateInstruction(input.sourceSpan, instruction, () => { return [ o.literal(elementIndex), o.literal(input.name), @@ -680,7 +688,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const attrsExprs: o.Expression[] = []; template.attributes.forEach( (a: t.TextAttribute) => { attrsExprs.push(asLiteral(a.name), asLiteral(a.value)); }); - attrsExprs.push(...this.prepareSelectOnlyAttrs(template.inputs, template.outputs)); + attrsExprs.push(...this.prepareSyntheticAndSelectOnlyAttrs(template.inputs, template.outputs)); parameters.push(this.toAttrsParam(attrsExprs)); // local refs (ex.: ) @@ -856,14 +864,30 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver return attributesMap; } - private prepareSelectOnlyAttrs(inputs: t.BoundAttribute[], outputs: t.BoundEvent[]): + private prepareSyntheticAndSelectOnlyAttrs(inputs: t.BoundAttribute[], outputs: t.BoundEvent[]): o.Expression[] { const attrExprs: o.Expression[] = []; + const nonSyntheticInputs: t.BoundAttribute[] = []; - if (inputs.length || outputs.length) { + if (inputs.length) { + const EMPTY_STRING_EXPR = asLiteral(''); + inputs.forEach(input => { + if (input.type === BindingType.Animation) { + // @attributes are for Renderer2 animation @triggers, but this feature + // may be supported differently in future versions of angular. However, + // @triggers should always just be treated as regular attributes (it's up + // to the renderer to detect and use them in a special way). + attrExprs.push(asLiteral(prepareSyntheticAttributeName(input.name)), EMPTY_STRING_EXPR); + } else { + nonSyntheticInputs.push(input); + } + }); + } + + if (nonSyntheticInputs.length || outputs.length) { attrExprs.push(o.literal(core.AttributeMarker.SelectOnly)); - inputs.forEach((i: t.BoundAttribute) => { attrExprs.push(asLiteral(i.name)); }); - outputs.forEach((o: t.BoundEvent) => { attrExprs.push(asLiteral(o.name)); }); + nonSyntheticInputs.forEach((i: t.BoundAttribute) => attrExprs.push(asLiteral(i.name))); + outputs.forEach((o: t.BoundEvent) => attrExprs.push(asLiteral(o.name))); } return attrExprs; @@ -1429,3 +1453,7 @@ function isStyleSanitizable(prop: string): boolean { } return false; } + +function prepareSyntheticAttributeName(name: string) { + return '@' + name; +} diff --git a/packages/core/test/render3/integration_spec.ts b/packages/core/test/render3/integration_spec.ts index 9fa3c376ad..b08a163fb2 100644 --- a/packages/core/test/render3/integration_spec.ts +++ b/packages/core/test/render3/integration_spec.ts @@ -9,12 +9,13 @@ import {ElementRef, TemplateRef, ViewContainerRef} from '@angular/core'; import {RenderFlags} from '@angular/core/src/render3'; -import {RendererType2} from '../../src/render/api'; +import {RendererStyleFlags2, RendererType2} from '../../src/render/api'; import {getOrCreateNodeInjectorForNode, getOrCreateTemplateRef} from '../../src/render3/di'; import {AttributeMarker, defineComponent, defineDirective, injectElementRef, injectTemplateRef, injectViewContainerRef} from '../../src/render3/index'; + import {NO_CHANGE, bind, container, containerRefreshEnd, containerRefreshStart, element, elementAttribute, elementClassProp, elementContainerEnd, elementContainerStart, elementEnd, elementProperty, elementStart, elementStyleProp, elementStyling, elementStylingApply, embeddedViewEnd, embeddedViewStart, interpolation1, interpolation2, interpolation3, interpolation4, interpolation5, interpolation6, interpolation7, interpolation8, interpolationV, listener, load, loadDirective, projection, projectionDef, text, textBinding, template} from '../../src/render3/instructions'; import {InitialStylingFlags} from '../../src/render3/interfaces/definition'; -import {RElement, Renderer3, RendererFactory3, domRendererFactory3} from '../../src/render3/interfaces/renderer'; +import {RElement, Renderer3, RendererFactory3, domRendererFactory3, RText, RComment, RNode, RendererStyleFlags3, ProceduralRenderer3} from '../../src/render3/interfaces/renderer'; import {HEADER_OFFSET, CONTEXT, DIRECTIVES} from '../../src/render3/interfaces/view'; import {sanitizeUrl} from '../../src/sanitization/sanitization'; import {Sanitizer, SecurityContext} from '../../src/sanitization/security'; @@ -1387,7 +1388,7 @@ describe('render3 integration test', () => { } }); } - const rendererFactory = new MockRendererFactory(); + const rendererFactory = new ProxyRenderer3Factory(); new ComponentFixture(StyledComp, {rendererFactory}); expect(rendererFactory.lastCapturedType !.styles).toEqual(['div { color: red; }']); expect(rendererFactory.lastCapturedType !.encapsulation).toEqual(100); @@ -1413,7 +1414,7 @@ describe('render3 integration test', () => { template: (rf: RenderFlags, ctx: AnimComp) => {} }); } - const rendererFactory = new MockRendererFactory(); + const rendererFactory = new ProxyRenderer3Factory(); new ComponentFixture(AnimComp, {rendererFactory}); const capturedAnimations = rendererFactory.lastCapturedType !.data !['animations']; @@ -1435,11 +1436,74 @@ describe('render3 integration test', () => { template: (rf: RenderFlags, ctx: AnimComp) => {} }); } - const rendererFactory = new MockRendererFactory(); + const rendererFactory = new ProxyRenderer3Factory(); new ComponentFixture(AnimComp, {rendererFactory}); const data = rendererFactory.lastCapturedType !.data; expect(data.animations).toEqual([]); }); + + it('should allow [@trigger] bindings to be picked up by the underlying renderer', () => { + class AnimComp { + static ngComponentDef = defineComponent({ + type: AnimComp, + consts: 1, + vars: 1, + selectors: [['foo']], + factory: () => new AnimComp(), + template: (rf: RenderFlags, ctx: AnimComp) => { + if (rf & RenderFlags.Create) { + element(0, 'div', [AttributeMarker.SelectOnly, '@fooAnimation']); + } + if (rf & RenderFlags.Update) { + elementAttribute(0, '@fooAnimation', bind(ctx.animationValue)); + } + } + }); + + animationValue = '123'; + } + + const rendererFactory = new MockRendererFactory(['setAttribute']); + const fixture = new ComponentFixture(AnimComp, {rendererFactory}); + + const renderer = rendererFactory.lastRenderer !; + fixture.component.animationValue = '456'; + fixture.update(); + + const spy = renderer.spies['setAttribute']; + const [elm, attr, value] = spy.calls.mostRecent().args; + + expect(attr).toEqual('@fooAnimation'); + expect(value).toEqual('456'); + }); + + it('should allow creation-level [@trigger] properties to be picked up by the underlying renderer', + () => { + class AnimComp { + static ngComponentDef = defineComponent({ + type: AnimComp, + consts: 1, + vars: 1, + selectors: [['foo']], + factory: () => new AnimComp(), + template: (rf: RenderFlags, ctx: AnimComp) => { + if (rf & RenderFlags.Create) { + element(0, 'div', ['@fooAnimation', '']); + } + } + }); + } + + const rendererFactory = new MockRendererFactory(['setAttribute']); + const fixture = new ComponentFixture(AnimComp, {rendererFactory}); + + const renderer = rendererFactory.lastRenderer !; + fixture.update(); + + const spy = renderer.spies['setAttribute']; + const [elm, attr, value] = spy.calls.mostRecent().args; + expect(attr).toEqual('@fooAnimation'); + }); }); describe('element discovery', () => { @@ -2201,7 +2265,7 @@ class LocalSanitizer implements Sanitizer { bypassSecurityTrustUrl(value: string) { return new LocalSanitizedValue(value); } } -class MockRendererFactory implements RendererFactory3 { +class ProxyRenderer3Factory implements RendererFactory3 { lastCapturedType: RendererType2|null = null; createRenderer(hostElement: RElement|null, rendererType: RendererType2|null): Renderer3 { @@ -2209,3 +2273,55 @@ class MockRendererFactory implements RendererFactory3 { return domRendererFactory3.createRenderer(hostElement, rendererType); } } + +class MockRendererFactory implements RendererFactory3 { + lastRenderer: any; + private _spyOnMethods: string[]; + + constructor(spyOnMethods?: string[]) { this._spyOnMethods = spyOnMethods || []; } + + createRenderer(hostElement: RElement|null, rendererType: RendererType2|null): Renderer3 { + const renderer = this.lastRenderer = new MockRenderer(this._spyOnMethods); + return renderer; + } +} + +class MockRenderer implements ProceduralRenderer3 { + public spies: {[methodName: string]: any} = {}; + + constructor(spyOnMethods: string[]) { + spyOnMethods.forEach(methodName => { + this.spies[methodName] = spyOn(this as any, methodName).and.callThrough(); + }); + } + + destroy(): void {} + createComment(value: string): RComment { return document.createComment(value); } + createElement(name: string, namespace?: string|null): RElement { + return document.createElement(name); + } + createText(value: string): RText { return document.createTextNode(value); } + appendChild(parent: RElement, newChild: RNode): void { parent.appendChild(newChild); } + insertBefore(parent: RNode, newChild: RNode, refChild: RNode|null): void { + parent.insertBefore(newChild, refChild, false); + } + removeChild(parent: RElement, oldChild: RNode): void { parent.removeChild(oldChild); } + selectRootElement(selectorOrNode: string|any): RElement { + return ({} as any); + } + setAttribute(el: RElement, name: string, value: string, namespace?: string|null): void {} + removeAttribute(el: RElement, name: string, namespace?: string|null): void {} + addClass(el: RElement, name: string): void {} + removeClass(el: RElement, name: string): void {} + setStyle( + el: RElement, style: string, value: any, + flags?: RendererStyleFlags2|RendererStyleFlags3): void {} + removeStyle(el: RElement, style: string, flags?: RendererStyleFlags2|RendererStyleFlags3): void {} + setProperty(el: RElement, name: string, value: any): void {} + setValue(node: RText, value: string): void {} + + // TODO(misko): Deprecate in favor of addEventListener/removeEventListener + listen(target: RNode, eventName: string, callback: (event: any) => boolean | void): () => void { + return () => {}; + } +}