feat(ivy): properly apply style="", [style], [style.foo] and [attr.style] bindings (#24602)

PR Close #24602
This commit is contained in:
Matias Niemelä
2018-06-19 12:45:00 -07:00
committed by Miško Hevery
parent 52d43a99ef
commit 3980640d53
22 changed files with 1904 additions and 143 deletions

View File

@ -379,3 +379,9 @@ export const enum RenderFlags {
/* Whether to run the update block (e.g. refresh bindings) */
Update = 0b10
}
// Note this will expand once `class` is introduced to styling
export const enum InitialStylingFlags {
/** Mode for matching initial style values */
INITIAL_STYLES = 0b00,
}

View File

@ -37,9 +37,13 @@ export class Identifiers {
static elementClassNamed: o.ExternalReference = {name: 'ɵkn', moduleName: CORE};
static elementStyle: o.ExternalReference = {name: 'ɵs', moduleName: CORE};
static elementStyling: o.ExternalReference = {name: 'ɵs', moduleName: CORE};
static elementStyleNamed: o.ExternalReference = {name: 'ɵsn', moduleName: CORE};
static elementStyle: o.ExternalReference = {name: 'ɵsm', moduleName: CORE};
static elementStyleProp: o.ExternalReference = {name: 'ɵsp', moduleName: CORE};
static elementStylingApply: o.ExternalReference = {name: 'ɵsa', moduleName: CORE};
static containerCreate: o.ExternalReference = {name: 'ɵC', moduleName: CORE};

View File

@ -0,0 +1,111 @@
/**
* @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
*/
const enum Char {
OpenParen = 40,
CloseParen = 41,
Colon = 58,
Semicolon = 59,
BackSlash = 92,
QuoteNone = 0, // indicating we are not inside a quote
QuoteDouble = 34,
QuoteSingle = 39,
}
/**
* Parses string representation of a style and converts it into object literal.
*
* @param value string representation of style as used in the `style` attribute in HTML.
* Example: `color: red; height: auto`.
* @returns an object literal. `{ color: 'red', height: 'auto'}`.
*/
export function parseStyle(value: string): {[key: string]: any} {
const styles: {[key: string]: any} = {};
let i = 0;
let parenDepth = 0;
let quote: Char = Char.QuoteNone;
let valueStart = 0;
let propStart = 0;
let currentProp: string|null = null;
let valueHasQuotes = false;
while (i < value.length) {
const token = value.charCodeAt(i++) as Char;
switch (token) {
case Char.OpenParen:
parenDepth++;
break;
case Char.CloseParen:
parenDepth--;
break;
case Char.QuoteSingle:
// valueStart needs to be there since prop values don't
// have quotes in CSS
valueHasQuotes = valueHasQuotes || valueStart > 0;
if (quote === Char.QuoteNone) {
quote = Char.QuoteSingle;
} else if (quote === Char.QuoteSingle && value.charCodeAt(i - 1) !== Char.BackSlash) {
quote = Char.QuoteNone;
}
break;
case Char.QuoteDouble:
// same logic as above
valueHasQuotes = valueHasQuotes || valueStart > 0;
if (quote === Char.QuoteNone) {
quote = Char.QuoteDouble;
} else if (quote === Char.QuoteDouble && value.charCodeAt(i - 1) !== Char.BackSlash) {
quote = Char.QuoteNone;
}
break;
case Char.Colon:
if (!currentProp && parenDepth === 0 && quote === Char.QuoteNone) {
currentProp = hyphenate(value.substring(propStart, i - 1).trim());
valueStart = i;
}
break;
case Char.Semicolon:
if (currentProp && valueStart > 0 && parenDepth === 0 && quote === Char.QuoteNone) {
const styleVal = value.substring(valueStart, i - 1).trim();
styles[currentProp] = valueHasQuotes ? stripUnnecessaryQuotes(styleVal) : styleVal;
propStart = i;
valueStart = 0;
currentProp = null;
valueHasQuotes = false;
}
break;
}
}
if (currentProp && valueStart) {
const styleVal = value.substr(valueStart).trim();
styles[currentProp] = valueHasQuotes ? stripUnnecessaryQuotes(styleVal) : styleVal;
}
return styles;
}
export function stripUnnecessaryQuotes(value: string): string {
const qS = value.charCodeAt(0);
const qE = value.charCodeAt(value.length - 1);
if (qS == qE && (qS == Char.QuoteSingle || qS == Char.QuoteDouble)) {
const tempValue = value.substring(1, value.length - 1);
// special case to avoid using a multi-quoted string that was just chomped
// (e.g. `font-family: "Verdana", "sans-serif"`)
if (tempValue.indexOf('\'') == -1 && tempValue.indexOf('"') == -1) {
value = tempValue;
}
}
return value;
}
export function hyphenate(value: string): string {
return value.replace(/[a-z][A-Z]/g, v => {
return v.charAt(0) + '-' + v.charAt(1);
}).toLowerCase();
}

View File

@ -30,6 +30,7 @@ import {Identifiers as R3} from '../r3_identifiers';
import {htmlAstToRender3Ast} from '../r3_template_transform';
import {R3QueryMetadata} from './api';
import {parseStyle} from './styling';
import {CONTEXT_NAME, I18N_ATTR, I18N_ATTR_PREFIX, ID_SEPARATOR, IMPLICIT_REFERENCE, MEANING_SEPARATOR, REFERENCE_PREFIX, RENDER_FLAGS, TEMPORARY_NAME, asLiteral, getQueryPredicate, invalid, mapToExpression, noop, temporaryAllocator, trimTrailingNulls, unsupported} from './util';
function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefined {
@ -40,8 +41,6 @@ function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefin
return R3.elementAttribute;
case BindingType.Class:
return R3.elementClassNamed;
case BindingType.Style:
return R3.elementStyleNamed;
default:
return undefined;
}
@ -51,8 +50,7 @@ function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefin
// code (where this map is used) deals with DOM element property values
// (like elm.propName) and not component bindining properties (like [propName]).
const SPECIAL_CASED_PROPERTIES_INSTRUCTION_MAP: {[index: string]: o.ExternalReference} = {
'className': R3.elementClass,
'style': R3.elementStyle
'className': R3.elementClass
};
export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver {
@ -316,19 +314,70 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
// Add the attributes
const i18nMessages: o.Statement[] = [];
const attributes: o.Expression[] = [];
const initialStyleDeclarations: o.Expression[] = [];
Object.getOwnPropertyNames(outputAttrs).forEach(name => {
const value = outputAttrs[name];
attributes.push(o.literal(name));
if (attrI18nMetas.hasOwnProperty(name)) {
const meta = parseI18nMeta(attrI18nMetas[name]);
const variable = this.constantPool.getTranslation(value, meta);
attributes.push(variable);
const styleInputs: t.BoundAttribute[] = [];
const allOtherInputs: t.BoundAttribute[] = [];
element.inputs.forEach((input: t.BoundAttribute) => {
// [attr.style] should not be treated as a styling-based
// binding since it is intended to write directly to the attr
// and therefore will skip all style resolution that is present
// with style="", [style]="" and [style.prop]="" assignments
if (input.name == 'style' && input.type == BindingType.Property) {
// this should always go first in the compilation (for [style])
styleInputs.splice(0, 0, input);
} else if (input.type == BindingType.Style) {
styleInputs.push(input);
} else {
attributes.push(o.literal(value));
allOtherInputs.push(input);
}
});
let currStyleIndex = 0;
let staticStylesMap: {[key: string]: any}|null = null;
const stylesIndexMap: {[key: string]: number} = {};
Object.getOwnPropertyNames(outputAttrs).forEach(name => {
const value = outputAttrs[name];
if (name == 'style') {
staticStylesMap = parseStyle(value);
Object.keys(staticStylesMap).forEach(prop => { stylesIndexMap[prop] = currStyleIndex++; });
} else {
attributes.push(o.literal(name));
if (attrI18nMetas.hasOwnProperty(name)) {
const meta = parseI18nMeta(attrI18nMetas[name]);
const variable = this.constantPool.getTranslation(value, meta);
attributes.push(variable);
} else {
attributes.push(o.literal(value));
}
}
});
for (let i = 0; i < styleInputs.length; i++) {
const input = styleInputs[i];
const isMapBasedStyleBinding = i === 0 && input.name === 'style';
if (!isMapBasedStyleBinding && !stylesIndexMap.hasOwnProperty(input.name)) {
stylesIndexMap[input.name] = currStyleIndex++;
}
}
// this will build the instructions so that they fall into the following syntax
// => [prop1, prop2, prop3, 0, prop1, value1, prop2, value2]
Object.keys(stylesIndexMap).forEach(prop => {
initialStyleDeclarations.push(o.literal(prop));
});
if (staticStylesMap) {
initialStyleDeclarations.push(o.literal(core.InitialStylingFlags.INITIAL_STYLES));
Object.keys(staticStylesMap).forEach(prop => {
initialStyleDeclarations.push(o.literal(prop));
const value = staticStylesMap ![prop];
initialStyleDeclarations.push(o.literal(value));
});
}
const attrArg: o.Expression = attributes.length > 0 ?
this.constantPool.getConstLiteral(o.literalArr(attributes), true) :
o.TYPED_NULL_EXPR;
@ -365,11 +414,14 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this.addNamespaceInstruction(currentNamespace, element);
}
const isEmptyElement = element.children.length === 0 && element.outputs.length === 0;
const implicit = o.variable(CONTEXT_NAME);
if (isEmptyElement) {
const elementStyleIndex =
(initialStyleDeclarations.length || styleInputs.length) ? this.allocateDataSlot() : 0;
const createSelfClosingInstruction =
elementStyleIndex === 0 && element.children.length === 0 && element.outputs.length === 0;
if (createSelfClosingInstruction) {
this.instruction(
this._creationCode, element.sourceSpan, R3.element, ...trimTrailingNulls(parameters));
} else {
@ -381,6 +433,20 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this._creationCode, element.sourceSpan, R3.elementStart,
...trimTrailingNulls(parameters));
// initial styling for static style="..." attributes
if (elementStyleIndex > 0) {
let paramsList: (o.Expression)[] = [o.literal(elementStyleIndex)];
if (initialStyleDeclarations.length) {
// the template compiler handles initial styling (e.g. style="foo") values
// in a special command called `elementStyle` so that the initial styles
// can be processed during runtime. These initial styles values are bound to
// a constant because the inital style values do not change (since they're static).
paramsList.push(
this.constantPool.getConstLiteral(o.literalArr(initialStyleDeclarations), true));
}
this._creationCode.push(o.importExpr(R3.elementStyling).callFn(paramsList).toStmt());
}
// Generate Listeners (outputs)
element.outputs.forEach((outputAst: t.BoundEvent) => {
const elName = sanitizeIdentifier(element.name);
@ -404,11 +470,33 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
});
}
if (styleInputs.length && elementStyleIndex > 0) {
const indexLiteral = o.literal(elementStyleIndex);
styleInputs.forEach((input, i) => {
const isMapBasedStyleBinding = i == 0 && input.name == 'style';
const convertedBinding = this.convertPropertyBinding(implicit, input.value, true);
if (isMapBasedStyleBinding) {
this.instruction(
this._bindingCode, input.sourceSpan, R3.elementStyle, indexLiteral, convertedBinding);
} else {
const key = input.name;
let styleIndex: number = stylesIndexMap[key] !;
this.instruction(
this._bindingCode, input.sourceSpan, R3.elementStyleProp, indexLiteral,
o.literal(styleIndex), convertedBinding);
}
});
const spanEnd = styleInputs[styleInputs.length - 1].sourceSpan;
this.instruction(this._bindingCode, spanEnd, R3.elementStylingApply, indexLiteral);
}
// Generate element input bindings
element.inputs.forEach((input: t.BoundAttribute) => {
allOtherInputs.forEach((input: t.BoundAttribute) => {
if (input.type === BindingType.Animation) {
this._unsupported('animations');
}
const convertedBinding = this.convertPropertyBinding(implicit, input.value);
const specialInstruction = SPECIAL_CASED_PROPERTIES_INSTRUCTION_MAP[input.name];
if (specialInstruction) {
@ -442,7 +530,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
t.visitAll(this, element.children);
}
if (!isEmptyElement) {
if (!createSelfClosingInstruction) {
// Finish element construction mode.
this.instruction(
this._creationCode, element.endSourceSpan || element.sourceSpan, R3.elementEnd);
@ -568,7 +656,8 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
statements.push(o.importExpr(reference, null, span).callFn(params, span).toStmt());
}
private convertPropertyBinding(implicit: o.Expression, value: AST): o.Expression {
private convertPropertyBinding(implicit: o.Expression, value: AST, skipBindFn?: boolean):
o.Expression {
const pipesConvertedValue = value.visit(this._valueConverter);
if (pipesConvertedValue instanceof Interpolation) {
const convertedPropertyBinding = convertPropertyBinding(
@ -581,7 +670,8 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this, implicit, pipesConvertedValue, this.bindingContext(), BindingForm.TrySimple,
() => error('Unexpected interpolation'));
this._bindingCode.push(...convertedPropertyBinding.stmts);
return o.importExpr(R3.bind).callFn([convertedPropertyBinding.currValExpr]);
const valExpr = convertedPropertyBinding.currValExpr;
return skipBindFn ? valExpr : o.importExpr(R3.bind).callFn([valExpr]);
}
}
}

View File

@ -321,15 +321,23 @@ describe('compiler compliance', () => {
const factory = 'factory: function MyComponent_Factory() { return new MyComponent(); }';
const template = `
template: function MyComponent_Template(rf: IDENT, ctx: IDENT) {
if (rf & 1) {
$r3$.ɵEe(0, 'div');
}
if (rf & 2) {
$r3$.ɵkn(0, 'error', $r3$.ɵb(ctx.error));
$r3$.ɵsn(0, 'background-color', $r3$.ɵb(ctx.color));
}
}
const _c0 = ['background-color'];
class MyComponent {
static ngComponentDef = i0.ɵdefineComponent({type:MyComponent,selectors:[['my-component']],
factory:function MyComponent_Factory(){
return new MyComponent();
},template:function MyComponent_Template(rf:number,ctx:any){
if (rf & 1) {
$r3$.ɵE(0, 'div');
$r3$.ɵs(1, _c0);
$r3$.ɵe();
}
if (rf & 2) {
$r3$.ɵsp(1, 0, ctx.color);
$r3$.ɵsa(1);
$r3$.ɵkn(0, 'error', $r3$.ɵb(ctx.error));
}
}
`;

View File

@ -6,7 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/
import {InitialStylingFlags} from '../../src/core';
import {MockDirectory, setup} from '../aot/test_util';
import {compile, expectEmit} from './mock_compile';
describe('compiler compliance: styling', () => {
@ -16,7 +18,7 @@ describe('compiler compliance: styling', () => {
compileCommon: true,
});
describe('[style]', () => {
describe('[style] and [style.prop]', () => {
it('should create style instructions on the element', () => {
const files = {
app: {
@ -40,10 +42,13 @@ describe('compiler compliance: styling', () => {
const template = `
template: function MyComponent_Template(rf: $RenderFlags$, $ctx$: $MyComponent$) {
if (rf & 1) {
$r3$.ɵEe(0, 'div');
$r3$.ɵE(0, 'div');
$r3$.ɵs(1);
$r3$.ɵe();
}
if (rf & 2) {
$r3$.ɵs(0,$r3$.ɵb($ctx$.myStyleExp));
$r3$.ɵsm(1, $ctx$.myStyleExp);
$r3$.ɵsa(1);
}
}
`;
@ -51,6 +56,63 @@ describe('compiler compliance: styling', () => {
const result = compile(files, angularFiles);
expectEmit(result.source, template, 'Incorrect template');
});
it('should place initial, multi, singular and application followed by attribute styling instructions in the template code in that order',
() => {
const files = {
app: {
'spec.ts': `
import {Component, NgModule} from '@angular/core';
@Component({
selector: 'my-component',
template: \`<div style="opacity:1"
[attr.style]="'border-width: 10px'"
[style.width]="myWidth"
[style]="myStyleExp"
[style.height]="myHeight"></div>\`
})
export class MyComponent {
myStyleExp = [{color:'red'}, {color:'blue', duration:1000}]
myWidth = '100px';
myHeight = '100px';
}
@NgModule({declarations: [MyComponent]})
export class MyModule {}
`
}
};
const template = `
const _c0 = ['opacity','width','height',${InitialStylingFlags.INITIAL_STYLES},'opacity','1'];
class MyComponent {
static ngComponentDef = i0.ɵdefineComponent({
type: MyComponent,
selectors:[['my-component']],
factory:function MyComponent_Factory(){
return new MyComponent();
},
template: function MyComponent_Template(rf: $RenderFlags$, $ctx$: $MyComponent$) {
if (rf & 1) {
$r3$.ɵE(0, 'div');
$r3$.ɵs(1, _c0);
$r3$.ɵe();
}
if (rf & 2) {
$r3$.ɵsm(1, $ctx$.myStyleExp);
$r3$.ɵsp(1, 1, $ctx$.myWidth);
$r3$.ɵsp(1, 2, $ctx$.myHeight);
$r3$.ɵsa(1);
$r3$.ɵa(0, 'style', $r3$.ɵb('border-width: 10px'));
}
}
});
`;
const result = compile(files, angularFiles);
expectEmit(result.source, template, 'Incorrect template');
});
});
describe('[class]', () => {

View File

@ -0,0 +1,93 @@
/**
* @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 {hyphenate, parseStyle, stripUnnecessaryQuotes} from '../../src/render3/view/styling';
describe('inline css style parsing', () => {
it('should parse empty or blank strings', () => {
const result1 = parseStyle('');
expect(result1).toEqual({});
const result2 = parseStyle(' ');
expect(result2).toEqual({});
});
it('should parse a string into a key/value map', () => {
const result = parseStyle('width:100px;height:200px;opacity:0');
expect(result).toEqual({width: '100px', height: '200px', opacity: '0'});
});
it('should trim values and properties', () => {
const result = parseStyle('width :333px ; height:666px ; opacity: 0.5;');
expect(result).toEqual({width: '333px', height: '666px', opacity: '0.5'});
});
it('should chomp out start/end quotes', () => {
const result = parseStyle(
'content: "foo"; opacity: \'0.5\'; font-family: "Verdana", Helvetica, "sans-serif"');
expect(result).toEqual(
{content: 'foo', opacity: '0.5', 'font-family': '"Verdana", Helvetica, "sans-serif"'});
});
it('should not mess up with quoted strings that contain [:;] values', () => {
const result = parseStyle('content: "foo; man: guy"; width: 100px');
expect(result).toEqual({content: 'foo; man: guy', width: '100px'});
});
it('should not mess up with quoted strings that contain inner quote values', () => {
const quoteStr = '"one \'two\' three \"four\" five"';
const result = parseStyle(`content: ${quoteStr}; width: 123px`);
expect(result).toEqual({content: quoteStr, width: '123px'});
});
it('should respect parenthesis that are placed within a style', () => {
const result = parseStyle('background-image: url("foo.jpg")');
expect(result).toEqual({'background-image': 'url("foo.jpg")'});
});
it('should respect multi-level parenthesis that contain special [:;] characters', () => {
const result = parseStyle('color: rgba(calc(50 * 4), var(--cool), :5;); height: 100px;');
expect(result).toEqual({color: 'rgba(calc(50 * 4), var(--cool), :5;)', height: '100px'});
});
it('should hyphenate style properties from camel case', () => {
const result = parseStyle('borderWidth: 200px');
expect(result).toEqual({
'border-width': '200px',
});
});
describe('quote chomping', () => {
it('should remove the start and end quotes', () => {
expect(stripUnnecessaryQuotes('\'foo bar\'')).toEqual('foo bar');
expect(stripUnnecessaryQuotes('"foo bar"')).toEqual('foo bar');
});
it('should not remove quotes if the quotes are not at the start and end', () => {
expect(stripUnnecessaryQuotes('foo bar')).toEqual('foo bar');
expect(stripUnnecessaryQuotes(' foo bar ')).toEqual(' foo bar ');
expect(stripUnnecessaryQuotes('\'foo\' bar')).toEqual('\'foo\' bar');
expect(stripUnnecessaryQuotes('foo "bar"')).toEqual('foo "bar"');
});
it('should not remove quotes if there are inner quotes', () => {
const str = '"Verdana", "Helvetica"';
expect(stripUnnecessaryQuotes(str)).toEqual(str);
});
});
describe('camelCasing => hyphenation', () => {
it('should convert a camel-cased value to a hyphenated value', () => {
expect(hyphenate('fooBar')).toEqual('foo-bar');
expect(hyphenate('fooBarMan')).toEqual('foo-bar-man');
expect(hyphenate('-fooBar-man')).toEqual('-foo-bar-man');
});
it('should make everything lowercase',
() => { expect(hyphenate('-WebkitAnimation')).toEqual('-webkit-animation'); });
});
});