diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index 40acc43fc1a49..eab565fb6a875 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -7,7 +7,27 @@ */ /** - * This file is a port of shadowCSS from webcomponents.js to TypeScript. + * The following set contains all keywords that can be used in the animation css shorthand + * property and is used during the scoping of keyframes to make sure such keywords + * are not modified. + */ +const animationKeywords = new Set([ + // global values + 'inherit', 'initial', 'revert', 'unset', + // animation-direction + 'alternate', 'alternate-reverse', 'normal', 'reverse', + // animation-fill-mode + 'backwards', 'both', 'forwards', 'none', + // animation-play-state + 'paused', 'running', + // animation-timing-function + 'ease', 'ease-in', 'ease-in-out', 'ease-out', 'linear', 'step-start', 'step-end', + // `steps()` function + 'end', 'jump-both', 'jump-end', 'jump-none', 'jump-start', 'start' +]); + +/** + * The following class is a port of shadowCSS from webcomponents.js to TypeScript. * * Please make sure to keep to edits in sync with the source file. * @@ -131,7 +151,6 @@ declaration. This is a directive to the styling shim to use the selector in comments in lieu of the next selector when running under polyfill. */ - export class ShadowCss { strictStyling: boolean = true; @@ -157,6 +176,187 @@ export class ShadowCss { return this._insertPolyfillRulesInCssText(cssText); } + /** + * Process styles to add scope to keyframes. + * + * Modify both the names of the keyframes defined in the component styles and also the css + * animation rules using them. + * + * Animation rules using keyframes defined elsewhere are not modified to allow for globally + * defined keyframes. + * + * For example, we convert this css: + * + * ``` + * .box { + * animation: box-animation 1s forwards; + * } + * + * @keyframes box-animation { + * to { + * background-color: green; + * } + * } + * ``` + * + * to this: + * + * ``` + * .box { + * animation: scopeName_box-animation 1s forwards; + * } + * + * @keyframes scopeName_box-animation { + * to { + * background-color: green; + * } + * } + * ``` + * + * @param cssText the component's css text that needs to be scoped. + * @param scopeSelector the component's scope selector. + * + * @returns the scoped css text. + */ + private _scopeKeyframesRelatedCss(cssText: string, scopeSelector: string): string { + const unscopedKeyframesSet = new Set(); + const scopedKeyframesCssText = processRules( + cssText, + rule => this._scopeLocalKeyframeDeclarations(rule, scopeSelector, unscopedKeyframesSet)); + return processRules( + scopedKeyframesCssText, + rule => this._scopeAnimationRule(rule, scopeSelector, unscopedKeyframesSet)); + } + + /** + * Scopes local keyframes names, returning the updated css rule and it also + * adds the original keyframe name to a provided set to collect all keyframes names + * so that it can later be used to scope the animation rules. + * + * For example, it takes a rule such as: + * + * ``` + * @keyframes box-animation { + * to { + * background-color: green; + * } + * } + * ``` + * + * and returns: + * + * ``` + * @keyframes scopeName_box-animation { + * to { + * background-color: green; + * } + * } + * ``` + * and as a side effect it adds "box-animation" to the `unscopedKeyframesSet` set + * + * @param cssRule the css rule to process. + * @param scopeSelector the component's scope selector. + * @param unscopedKeyframesSet the set of unscoped keyframes names (which can be + * modified as a side effect) + * + * @returns the css rule modified with the scoped keyframes name. + */ + private _scopeLocalKeyframeDeclarations( + rule: CssRule, scopeSelector: string, unscopedKeyframesSet: Set): CssRule { + return { + ...rule, + selector: rule.selector.replace( + /(^@(?:-webkit-)?keyframes(?:\s+))(['"]?)(.+)\2(\s*)$/, + (_, start, quote, keyframeName, endSpaces) => { + unscopedKeyframesSet.add(unescapeQuotes(keyframeName, quote)); + return `${start}${quote}${scopeSelector}_${keyframeName}${quote}${endSpaces}`; + }), + }; + } + + /** + * Function used to scope a keyframes name (obtained from an animation declaration) + * using an existing set of unscopedKeyframes names to discern if the scoping needs to be + * performed (keyframes names of keyframes not defined in the component's css need not to be + * scoped). + * + * @param keyframe the keyframes name to check. + * @param scopeSelector the component's scope selector. + * @param unscopedKeyframesSet the set of unscoped keyframes names. + * + * @returns the scoped name of the keyframe, or the original name is the name need not to be + * scoped. + */ + private _scopeAnimationKeyframe( + keyframe: string, scopeSelector: string, unscopedKeyframesSet: ReadonlySet): string { + return keyframe.replace(/^(\s*)(['"]?)(.+?)\2(\s*)$/, (_, spaces1, quote, name, spaces2) => { + name = `${unscopedKeyframesSet.has(unescapeQuotes(name, quote)) ? scopeSelector + '_' : ''}${ + name}`; + return `${spaces1}${quote}${name}${quote}${spaces2}`; + }); + } + + /** + * Regular expression used to extrapolate the possible keyframes from an + * animation declaration (with possibly multiple animation definitions) + * + * The regular expression can be divided in three parts + * - (^|\s+) + * simply captures how many (if any) leading whitespaces are present + * - (?:(?:(['"])((?:\\\\|\\\2|(?!\2).)+)\2)|(-?[A-Za-z][\w\-]*)) + * captures two different possible keyframes, ones which are quoted or ones which are valid css + * idents (custom properties excluded) + * - (?=[,\s;]|$) + * simply matches the end of the possible keyframe, valid endings are: a comma, a space, a + * semicolon or the end of the string + */ + private _animationDeclarationKeyframesRe = + /(^|\s+)(?:(?:(['"])((?:\\\\|\\\2|(?!\2).)+)\2)|(-?[A-Za-z][\w\-]*))(?=[,\s]|$)/g; + + /** + * Scope an animation rule so that the keyframes mentioned in such rule + * are scoped if defined in the component's css and left untouched otherwise. + * + * It can scope values of both the 'animation' and 'animation-name' properties. + * + * @param rule css rule to scope. + * @param scopeSelector the component's scope selector. + * @param unscopedKeyframesSet the set of unscoped keyframes names. + * + * @returns the updated css rule. + **/ + private _scopeAnimationRule( + rule: CssRule, scopeSelector: string, unscopedKeyframesSet: ReadonlySet): CssRule { + let content = rule.content.replace( + /((?:^|\s+|;)(?:-webkit-)?animation(?:\s*):(?:\s*))([^;]+)/g, + (_, start, animationDeclarations) => start + + animationDeclarations.replace( + this._animationDeclarationKeyframesRe, + (original: string, leadingSpaces: string, quote = '', quotedName: string, + nonQuotedName: string) => { + if (quotedName) { + return `${leadingSpaces}${ + this._scopeAnimationKeyframe( + `${quote}${quotedName}${quote}`, scopeSelector, unscopedKeyframesSet)}`; + } else { + return animationKeywords.has(nonQuotedName) ? + original : + `${leadingSpaces}${ + this._scopeAnimationKeyframe( + nonQuotedName, scopeSelector, unscopedKeyframesSet)}`; + } + })); + content = content.replace( + /((?:^|\s+|;)(?:-webkit-)?animation-name(?:\s*):(?:\s*))([^;]+)/g, + (_match, start, commaSeparatedKeyframes) => `${start}${ + commaSeparatedKeyframes.split(',') + .map( + (keyframe: string) => + this._scopeAnimationKeyframe(keyframe, scopeSelector, unscopedKeyframesSet)) + .join(',')}`); + return {...rule, content}; + } + /* * Process styles to convert native ShadowDOM rules that will trip * up the css parser; we rely on decorating the stylesheet with inert rules. @@ -217,6 +417,7 @@ export class ShadowCss { cssText = this._convertColonHostContext(cssText); cssText = this._convertShadowDOMSelectors(cssText); if (scopeSelector) { + cssText = this._scopeKeyframesRelatedCss(cssText, scopeSelector); cssText = this._scopeSelectors(cssText, scopeSelector, hostSelector); } cssText = cssText + '\n' + unscopedRules; @@ -642,39 +843,39 @@ function extractCommentsWithHash(input: string): string[] { } const BLOCK_PLACEHOLDER = '%BLOCK%'; -const QUOTE_PLACEHOLDER = '%QUOTED%'; const _ruleRe = /(\s*)([^;\{\}]+?)(\s*)((?:{%BLOCK%}?\s*;?)|(?:\s*;))/g; -const _quotedRe = /%QUOTED%/g; const CONTENT_PAIRS = new Map([['{', '}']]); -const QUOTE_PAIRS = new Map([[`"`, `"`], [`'`, `'`]]); + +const COMMA_IN_PLACEHOLDER = '%COMMA_IN_PLACEHOLDER%'; +const SEMI_IN_PLACEHOLDER = '%SEMI_IN_PLACEHOLDER%'; +const COLON_IN_PLACEHOLDER = '%COLON_IN_PLACEHOLDER%'; + +const _cssCommaInPlaceholderReGlobal = new RegExp(COMMA_IN_PLACEHOLDER, 'g'); +const _cssSemiInPlaceholderReGlobal = new RegExp(SEMI_IN_PLACEHOLDER, 'g'); +const _cssColonInPlaceholderReGlobal = new RegExp(COLON_IN_PLACEHOLDER, 'g'); export class CssRule { constructor(public selector: string, public content: string) {} } export function processRules(input: string, ruleCallback: (rule: CssRule) => CssRule): string { - const inputWithEscapedQuotes = escapeBlocks(input, QUOTE_PAIRS, QUOTE_PLACEHOLDER); - const inputWithEscapedBlocks = - escapeBlocks(inputWithEscapedQuotes.escapedString, CONTENT_PAIRS, BLOCK_PLACEHOLDER); + const escaped = escapeInStrings(input); + const inputWithEscapedBlocks = escapeBlocks(escaped, CONTENT_PAIRS, BLOCK_PLACEHOLDER); let nextBlockIndex = 0; - let nextQuoteIndex = 0; - return inputWithEscapedBlocks.escapedString - .replace( - _ruleRe, - (...m: string[]) => { - const selector = m[2]; - let content = ''; - let suffix = m[4]; - let contentPrefix = ''; - if (suffix && suffix.startsWith('{' + BLOCK_PLACEHOLDER)) { - content = inputWithEscapedBlocks.blocks[nextBlockIndex++]; - suffix = suffix.substring(BLOCK_PLACEHOLDER.length + 1); - contentPrefix = '{'; - } - const rule = ruleCallback(new CssRule(selector, content)); - return `${m[1]}${rule.selector}${m[3]}${contentPrefix}${rule.content}${suffix}`; - }) - .replace(_quotedRe, () => inputWithEscapedQuotes.blocks[nextQuoteIndex++]); + const escapedResult = inputWithEscapedBlocks.escapedString.replace(_ruleRe, (...m: string[]) => { + const selector = m[2]; + let content = ''; + let suffix = m[4]; + let contentPrefix = ''; + if (suffix && suffix.startsWith('{' + BLOCK_PLACEHOLDER)) { + content = inputWithEscapedBlocks.blocks[nextBlockIndex++]; + suffix = suffix.substring(BLOCK_PLACEHOLDER.length + 1); + contentPrefix = '{'; + } + const rule = ruleCallback(new CssRule(selector, content)); + return `${m[1]}${rule.selector}${m[3]}${contentPrefix}${rule.content}${suffix}`; + }); + return unescapeInStrings(escapedResult); } class StringWithEscapedBlocks { @@ -722,6 +923,113 @@ function escapeBlocks( return new StringWithEscapedBlocks(resultParts.join(''), escapedBlocks); } +/** + * Object containing as keys characters that should be substituted by placeholders + * when found in strings during the css text parsing, and as values the respective + * placeholders + */ +const ESCAPE_IN_STRING_MAP: {[key: string]: string} = { + ';': SEMI_IN_PLACEHOLDER, + ',': COMMA_IN_PLACEHOLDER, + ':': COLON_IN_PLACEHOLDER +}; + +/** + * Parse the provided css text and inside strings (meaning, inside pairs of unescaped single or + * double quotes) replace specific characters with their respective placeholders as indicated + * by the `ESCAPE_IN_STRING_MAP` map. + * + * For example convert the text + * `animation: "my-anim:at\"ion" 1s;` + * to + * `animation: "my-anim%COLON_IN_PLACEHOLDER%at\"ion" 1s;` + * + * This is necessary in order to remove the meaning of some characters when found inside strings + * (for example `;` indicates the end of a css declaration, `,` the sequence of values and `:` the + * division between property and value during a declaration, none of these meanings apply when such + * characters are within strings and so in order to prevent parsing issues they need to be replaced + * with placeholder text for the duration of the css manipulation process). + * + * @param input the original css text. + * + * @returns the css text with specific characters in strings replaced by placeholders. + **/ +function escapeInStrings(input: string): string { + let result = input; + let currentQuoteChar: string|null = null; + for (let i = 0; i < result.length; i++) { + const char = result[i]; + if (char === '\\') { + i++; + } else { + if (currentQuoteChar !== null) { + // index i is inside a quoted sub-string + if (char === currentQuoteChar) { + currentQuoteChar = null; + } else { + const placeholder: string|undefined = ESCAPE_IN_STRING_MAP[char]; + if (placeholder) { + result = `${result.substr(0, i)}${placeholder}${result.substr(i + 1)}`; + i += placeholder.length - 1; + } + } + } else if (char === '\'' || char === '"') { + currentQuoteChar = char; + } + } + } + return result; +} + +/** + * Replace in a string all occurrences of keys in the `ESCAPE_IN_STRING_MAP` map with their + * original representation, this is simply used to revert the changes applied by the + * escapeInStrings function. + * + * For example it reverts the text: + * `animation: "my-anim%COLON_IN_PLACEHOLDER%at\"ion" 1s;` + * to it's original form of: + * `animation: "my-anim:at\"ion" 1s;` + * + * Note: For the sake of simplicity this function does not check that the placeholders are + * actually inside strings as it would anyway be extremely unlikely to find them outside of strings. + * + * @param input the css text containing the placeholders. + * + * @returns the css text without the placeholders. + */ +function unescapeInStrings(input: string): string { + let result = input.replace(_cssCommaInPlaceholderReGlobal, ','); + result = result.replace(_cssSemiInPlaceholderReGlobal, ';'); + result = result.replace(_cssColonInPlaceholderReGlobal, ':'); + return result; +} + +/** + * Unescape all quotes present in a string, but only if the string was actually already + * quoted. + * + * This generates a "canonical" representation of strings which can be used to match strings + * which would otherwise only differ because of differently escaped quotes. + * + * For example it converts the string (assumed to be quoted): + * `this \\"is\\" a \\'\\\\'test` + * to: + * `this "is" a '\\\\'test` + * (note that the latter backslashes are not removed as they are not actually escaping the single + * quote) + * + * + * @param input the string possibly containing escaped quotes. + * @param isQuoted boolean indicating whether the string was quoted inside a bigger string (if not + * then it means that it doesn't represent an inner string and thus no unescaping is required) + * + * @returns the string in the "canonical" representation without escaped quotes. + */ +function unescapeQuotes(str: string, isQuoted: boolean): string { + return !isQuoted ? str : str.replace(/((?:^|[^\\])(?:\\\\)*)\\(?=['"])/g, '$1'); +} + /** * Combine the `contextSelectors` with the `hostMarker` and the `otherSelectors` * to create a selector that matches the same as `:host-context()`. diff --git a/packages/compiler/test/shadow_css/keyframes_spec.ts b/packages/compiler/test/shadow_css/keyframes_spec.ts new file mode 100644 index 0000000000000..6c2cb898262ea --- /dev/null +++ b/packages/compiler/test/shadow_css/keyframes_spec.ts @@ -0,0 +1,534 @@ +/** + * @license + * Copyright Google LLC 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 {ShadowCss} from '@angular/compiler/src/shadow_css'; + +describe('ShadowCss, keyframes and animations', () => { + function s(css: string, contentAttr: string, hostAttr: string = '') { + const shadowCss = new ShadowCss(); + return shadowCss.shimCssText(css, contentAttr, hostAttr); + } + + it('should scope keyframes rules', () => { + const css = '@keyframes foo {0% {transform:translate(-50%) scaleX(0);}}'; + const expected = '@keyframes host-a_foo {0% {transform:translate(-50%) scaleX(0);}}'; + expect(s(css, 'host-a')).toEqual(expected); + }); + + it('should scope -webkit-keyframes rules', () => { + const css = '@-webkit-keyframes foo {0% {-webkit-transform:translate(-50%) scaleX(0);}} '; + const expected = + '@-webkit-keyframes host-a_foo {0% {-webkit-transform:translate(-50%) scaleX(0);}}'; + expect(s(css, 'host-a')).toEqual(expected); + }); + + it('should scope animations using local keyframes identifiers', () => { + const css = ` + button { + animation: foo 10s ease; + } + @keyframes foo { + 0% { + transform: translate(-50%) scaleX(0); + } + } + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation: host-a_foo 10s ease;'); + }); + + it('should not scope animations using non-local keyframes identifiers', () => { + const css = ` + button { + animation: foo 10s ease; + } + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation: foo 10s ease;'); + }); + + it('should scope animation-names using local keyframes identifiers', () => { + const css = ` + button { + animation-name: foo; + } + @keyframes foo { + 0% { + transform: translate(-50%) scaleX(0); + } + } + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation-name: host-a_foo;'); + }); + + it('should not scope animation-names using non-local keyframes identifiers', () => { + const css = ` + button { + animation-name: foo; + } + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation-name: foo;'); + }); + + it('should handle (scope or not) multiple animation-names', () => { + const css = ` + button { + animation-name: foo, bar,baz, qux , quux ,corge ,grault ,garply, waldo; + } + @keyframes foo {} + @keyframes baz {} + @keyframes quux {} + @keyframes grault {} + @keyframes waldo {}`; + const result = s(css, 'host-a'); + const animationNames = [ + 'host-a_foo', + ' bar', + 'host-a_baz', + ' qux ', + ' host-a_quux ', + 'corge ', + 'host-a_grault ', + 'garply', + ' host-a_waldo', + ]; + const expected = `animation-name: ${animationNames.join(',')};`; + expect(result).toContain(expected); + }); + + it('should handle (scope or not) multiple animation-names defined over multiple lines', () => { + const css = ` + button { + animation-name: foo, + bar,baz, + qux , + quux , + grault, + garply, waldo; + } + @keyframes foo {} + @keyframes baz {} + @keyframes quux {} + @keyframes grault {}`; + const result = s(css, 'host-a'); + ['foo', 'baz', 'quux', 'grault'].forEach( + scoped => expect(result).toContain(`host-a_${scoped}`)); + ['bar', 'qux', 'garply', 'waldo'].forEach(nonScoped => { + expect(result).toContain(nonScoped); + expect(result).not.toContain(`host-a_${nonScoped}`); + }); + }); + + it('should handle (scope or not) multiple animation definitions in a single declaration', () => { + const css = ` + div { + animation: 1s ease foo, 2s bar infinite, forwards baz 3s; + } + + p { + animation: 1s "foo", 2s "bar"; + } + + span { + animation: .5s ease 'quux', + 1s foo infinite, forwards "baz'" 1.5s, + 2s bar; + } + + button { + animation: .5s bar, + 1s foo 0.3s, 2s quux; + } + + @keyframes bar {} + @keyframes quux {} + @keyframes "baz'" {}`; + const result = s(css, 'host-a'); + expect(result).toContain('animation: 1s ease foo, 2s host-a_bar infinite, forwards baz 3s;'); + expect(result).toContain('animation: 1s "foo", 2s "host-a_bar";'); + expect(result).toContain(` + animation: .5s host-a_bar, + 1s foo 0.3s, 2s host-a_quux;`); + expect(result).toContain(` + animation: .5s ease 'host-a_quux', + 1s foo infinite, forwards "host-a_baz'" 1.5s, + 2s host-a_bar;`); + }); + + it(`should not modify css variables ending with 'animation' even if they reference a local keyframes identifier`, + () => { + const css = ` + button { + --variable-animation: foo; + } + @keyframes foo {}`; + const result = s(css, 'host-a'); + expect(result).toContain('--variable-animation: foo;'); + }); + + it(`should not modify css variables ending with 'animation-name' even if they reference a local keyframes identifier`, + () => { + const css = ` + button { + --variable-animation-name: foo; + } + @keyframes foo {}`; + const result = s(css, 'host-a'); + expect(result).toContain('--variable-animation-name: foo;'); + }); + + it('should maintain the spacing when handling (scoping or not) keyframes and animations', () => { + const css = ` + div { + animation-name : foo; + animation: 5s bar 1s backwards; + animation : 3s baz ; + animation-name:foobar ; + animation:1s "foo" , 2s "bar",3s "quux"; + } + + @-webkit-keyframes bar {} + @keyframes foobar {} + @keyframes quux {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation-name : foo;'); + expect(result).toContain('animation: 5s host-a_bar 1s backwards;'); + expect(result).toContain('animation : 3s baz ;'); + expect(result).toContain('animation-name:host-a_foobar ;'); + expect(result).toContain('@-webkit-keyframes host-a_bar {}'); + expect(result).toContain('@keyframes host-a_foobar {}'); + expect(result).toContain('animation:1s "foo" , 2s "host-a_bar",3s "host-a_quux"'); + }); + + it('should correctly process animations defined without any prefixed space', () => { + let css = '.test{display: flex;animation:foo 1s forwards;} @keyframes foo {}'; + let expected = + '.test[host-a]{display: flex;animation:host-a_foo 1s forwards;} @keyframes host-a_foo {}'; + expect(s(css, 'host-a')).toEqual(expected); + css = '.test{animation:foo 2s forwards;} @keyframes foo {}'; + expected = '.test[host-a]{animation:host-a_foo 2s forwards;} @keyframes host-a_foo {}'; + expect(s(css, 'host-a')).toEqual(expected); + css = 'button {display: block;animation-name: foobar;} @keyframes foobar {}'; + expected = + 'button[host-a] {display: block;animation-name: host-a_foobar;} @keyframes host-a_foobar {}'; + expect(s(css, 'host-a')).toEqual(expected); + }); + + it('should correctly process keyframes defined without any prefixed space', () => { + let css = '.test{display: flex;animation:bar 1s forwards;}@keyframes bar {}'; + let expected = + '.test[host-a]{display: flex;animation:host-a_bar 1s forwards;}@keyframes host-a_bar {}'; + expect(s(css, 'host-a')).toEqual(expected); + css = '.test{animation:bar 2s forwards;}@-webkit-keyframes bar {}'; + expected = '.test[host-a]{animation:host-a_bar 2s forwards;}@-webkit-keyframes host-a_bar {}'; + expect(s(css, 'host-a')).toEqual(expected); + }); + + it('should ignore keywords values when scoping local animations', () => { + const css = ` + div { + animation: inherit; + animation: unset; + animation: 3s ease reverse foo; + animation: 5s foo 1s backwards; + animation: none 1s foo; + animation: .5s foo paused; + animation: 1s running foo; + animation: 3s linear 1s infinite running foo; + animation: 5s foo ease; + animation: 3s .5s infinite steps(3,end) foo; + animation: 5s steps(9, jump-start) jump .5s; + animation: 1s step-end steps; + } + + @keyframes foo {} + @keyframes inherit {} + @keyframes unset {} + @keyframes ease {} + @keyframes reverse {} + @keyframes backwards {} + @keyframes none {} + @keyframes paused {} + @keyframes linear {} + @keyframes running {} + @keyframes end {} + @keyframes jump {} + @keyframes start {} + @keyframes steps {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation: inherit;'); + expect(result).toContain('animation: unset;'); + expect(result).toContain('animation: 3s ease reverse host-a_foo;'); + expect(result).toContain('animation: 5s host-a_foo 1s backwards;'); + expect(result).toContain('animation: none 1s host-a_foo;'); + expect(result).toContain('animation: .5s host-a_foo paused;'); + expect(result).toContain('animation: 1s running host-a_foo;'); + expect(result).toContain('animation: 3s linear 1s infinite running host-a_foo;'); + expect(result).toContain('animation: 5s host-a_foo ease;'); + expect(result).toContain('animation: 3s .5s infinite steps(3,end) host-a_foo;'); + expect(result).toContain('animation: 5s steps(9, jump-start) host-a_jump .5s;'); + expect(result).toContain('animation: 1s step-end host-a_steps;'); + }); + + it('should handle the usage of quotes', () => { + const css = ` + div { + animation: 1.5s foo; + } + + p { + animation: 1s 'foz bar'; + } + + @keyframes 'foo' {} + @keyframes "foz bar" {} + @keyframes bar {} + @keyframes baz {} + @keyframes qux {} + @keyframes quux {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes \'host-a_foo\' {}'); + expect(result).toContain('@keyframes "host-a_foz bar" {}'); + expect(result).toContain('animation: 1.5s host-a_foo;'); + expect(result).toContain('animation: 1s \'host-a_foz bar\';'); + }); + + it('should handle the usage of quotes containing escaped quotes', () => { + const css = ` + div { + animation: 1.5s "foo\\"bar"; + } + + p { + animation: 1s 'bar\\' \\'baz'; + } + + button { + animation-name: 'foz " baz'; + } + + @keyframes "foo\\"bar" {} + @keyframes "bar' 'baz" {} + @keyframes "foz \\" baz" {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes "host-a_foo\\"bar" {}'); + expect(result).toContain(`@keyframes "host-a_bar' 'baz" {}`); + expect(result).toContain('@keyframes "host-a_foz \\" baz" {}'); + expect(result).toContain('animation: 1.5s "host-a_foo\\"bar";'); + expect(result).toContain('animation: 1s \'host-a_bar\\\' \\\'baz\';'); + expect(result).toContain(`animation-name: 'host-a_foz " baz';`); + }); + + it('should handle the usage of commas in multiple animation definitions in a single declaration', + () => { + const css = ` + button { + animation: 1s "foo bar, baz", 2s 'qux quux'; + } + + div { + animation: 500ms foo, 1s 'bar, baz', 1500ms bar; + } + + p { + animation: 3s "bar, baz", 3s 'foo, bar' 1s, 3s "qux quux"; + } + + @keyframes "qux quux" {} + @keyframes "bar, baz" {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes "host-a_qux quux" {}'); + expect(result).toContain('@keyframes "host-a_bar, baz" {}'); + expect(result).toContain(`animation: 1s "foo bar, baz", 2s 'host-a_qux quux';`); + expect(result).toContain('animation: 500ms foo, 1s \'host-a_bar, baz\', 1500ms bar;'); + expect(result).toContain( + `animation: 3s "host-a_bar, baz", 3s 'foo, bar' 1s, 3s "host-a_qux quux";`); + }); + + it('should handle the usage of double quotes escaping in multiple animation definitions in a single declaration', + () => { + const css = ` + div { + animation: 1s "foo", 1.5s "bar"; + animation: 2s "fo\\"o", 2.5s "bar"; + animation: 3s "foo\\"", 3.5s "bar", 3.7s "ba\\"r"; + animation: 4s "foo\\\\", 4.5s "bar", 4.7s "baz\\""; + animation: 5s "fo\\\\\\"o", 5.5s "bar", 5.7s "baz\\""; + } + + @keyframes "foo" {} + @keyframes "fo\\"o" {} + @keyframes 'foo"' {} + @keyframes 'foo\\\\' {} + @keyframes bar {} + @keyframes "ba\\"r" {} + @keyframes "fo\\\\\\"o" {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes "host-a_foo" {}'); + expect(result).toContain('@keyframes "host-a_fo\\"o" {}'); + expect(result).toContain(`@keyframes 'host-a_foo"' {}`); + expect(result).toContain('@keyframes \'host-a_foo\\\\\' {}'); + expect(result).toContain('@keyframes host-a_bar {}'); + expect(result).toContain('@keyframes "host-a_ba\\"r" {}'); + expect(result).toContain('@keyframes "host-a_fo\\\\\\"o"'); + expect(result).toContain('animation: 1s "host-a_foo", 1.5s "host-a_bar";'); + expect(result).toContain('animation: 2s "host-a_fo\\"o", 2.5s "host-a_bar";'); + expect(result).toContain( + 'animation: 3s "host-a_foo\\"", 3.5s "host-a_bar", 3.7s "host-a_ba\\"r";'); + expect(result).toContain( + 'animation: 4s "host-a_foo\\\\", 4.5s "host-a_bar", 4.7s "baz\\"";'); + expect(result).toContain( + 'animation: 5s "host-a_fo\\\\\\"o", 5.5s "host-a_bar", 5.7s "baz\\"";'); + }); + + it('should handle the usage of single quotes escaping in multiple animation definitions in a single declaration', + () => { + const css = ` + div { + animation: 1s 'foo', 1.5s 'bar'; + animation: 2s 'fo\\'o', 2.5s 'bar'; + animation: 3s 'foo\\'', 3.5s 'bar', 3.7s 'ba\\'r'; + animation: 4s 'foo\\\\', 4.5s 'bar', 4.7s 'baz\\''; + animation: 5s 'fo\\\\\\'o', 5.5s 'bar', 5.7s 'baz\\''; + } + + @keyframes foo {} + @keyframes 'fo\\'o' {} + @keyframes 'foo'' {} + @keyframes 'foo\\\\' {} + @keyframes "bar" {} + @keyframes 'ba\\'r' {} + @keyframes "fo\\\\\\'o" {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes host-a_foo {}'); + expect(result).toContain('@keyframes \'host-a_fo\\\'o\' {}'); + expect(result).toContain('@keyframes \'host-a_foo\'\' {}'); + expect(result).toContain('@keyframes \'host-a_foo\\\\\' {}'); + expect(result).toContain('@keyframes "host-a_bar" {}'); + expect(result).toContain('@keyframes \'host-a_ba\\\'r\' {}'); + expect(result).toContain(`@keyframes "host-a_fo\\\\\\'o" {}`); + expect(result).toContain('animation: 1s \'host-a_foo\', 1.5s \'host-a_bar\';'); + expect(result).toContain('animation: 2s \'host-a_fo\\\'o\', 2.5s \'host-a_bar\';'); + expect(result).toContain( + 'animation: 3s \'host-a_foo\\\'\', 3.5s \'host-a_bar\', 3.7s \'host-a_ba\\\'r\';'); + expect(result).toContain( + 'animation: 4s \'host-a_foo\\\\\', 4.5s \'host-a_bar\', 4.7s \'baz\\\'\';'); + expect(result).toContain( + 'animation: 5s \'host-a_fo\\\\\\\'o\', 5.5s \'host-a_bar\', 5.7s \'baz\\\'\''); + }); + + it('should handle the usage of mixed single and double quotes escaping in multiple animation definitions in a single declaration', + () => { + const css = ` + div { + animation: 1s 'f\\"oo', 1.5s "ba\\'r"; + animation: 2s "fo\\"\\"o", 2.5s 'b\\\\"ar'; + animation: 3s 'foo\\\\', 3.5s "b\\\\\\"ar", 3.7s 'ba\\'\\"\\'r'; + animation: 4s 'fo\\'o', 4.5s 'b\\"ar\\"', 4.7s "baz\\'"; + } + + @keyframes 'f"oo' {} + @keyframes 'fo""o' {} + @keyframes 'foo\\\\' {} + @keyframes 'fo\\'o' {} + @keyframes 'ba\\'r' {} + @keyframes 'b\\\\"ar' {} + @keyframes 'b\\\\\\"ar' {} + @keyframes 'b"ar"' {} + @keyframes 'ba\\'\\"\\'r' {} + `; + const result = s(css, 'host-a'); + expect(result).toContain(`@keyframes 'host-a_f"oo' {}`); + expect(result).toContain(`@keyframes 'host-a_fo""o' {}`); + expect(result).toContain('@keyframes \'host-a_foo\\\\\' {}'); + expect(result).toContain('@keyframes \'host-a_fo\\\'o\' {}'); + expect(result).toContain('@keyframes \'host-a_ba\\\'r\' {}'); + expect(result).toContain(`@keyframes 'host-a_b\\\\"ar' {}`); + expect(result).toContain(`@keyframes 'host-a_b\\\\\\"ar' {}`); + expect(result).toContain(`@keyframes 'host-a_b"ar"' {}`); + expect(result).toContain(`@keyframes 'host-a_ba\\'\\"\\'r' {}`); + expect(result).toContain(`animation: 1s 'host-a_f\\"oo', 1.5s "host-a_ba\\'r";`); + expect(result).toContain(`animation: 2s "host-a_fo\\"\\"o", 2.5s 'host-a_b\\\\"ar';`); + expect(result).toContain( + `animation: 3s 'host-a_foo\\\\', 3.5s "host-a_b\\\\\\"ar", 3.7s 'host-a_ba\\'\\"\\'r';`); + expect(result).toContain( + `animation: 4s 'host-a_fo\\'o', 4.5s 'host-a_b\\"ar\\"', 4.7s "baz\\'";`); + }); + + it('should handle the usage of commas inside quotes', () => { + const css = ` + div { + animation: 3s 'bar,, baz'; + } + + p { + animation-name: "bar,, baz", foo,'ease, linear , inherit', bar; + } + + @keyframes 'foo' {} + @keyframes 'bar,, baz' {} + @keyframes 'ease, linear , inherit' {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes \'host-a_bar,, baz\' {}'); + expect(result).toContain('animation: 3s \'host-a_bar,, baz\';'); + expect(result).toContain( + `animation-name: "host-a_bar,, baz", host-a_foo,'host-a_ease, linear , inherit', bar;`); + }); + + it('should not ignore animation keywords when they are inside quotes', () => { + const css = ` + div { + animation: 3s 'unset'; + } + + button { + animation: 5s "forwards" 1s forwards; + } + + @keyframes unset {} + @keyframes forwards {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes host-a_unset {}'); + expect(result).toContain('@keyframes host-a_forwards {}'); + expect(result).toContain('animation: 3s \'host-a_unset\';'); + expect(result).toContain('animation: 5s "host-a_forwards" 1s forwards;'); + }); + + it('should handle css functions correctly', () => { + const css = ` + div { + animation: foo 0.5s alternate infinite cubic-bezier(.17, .67, .83, .67); + } + + button { + animation: calc(2s / 2) calc; + } + + @keyframes foo {} + @keyframes cubic-bezier {} + @keyframes calc {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes host-a_cubic-bezier {}'); + expect(result).toContain('@keyframes host-a_calc {}'); + expect(result).toContain( + 'animation: host-a_foo 0.5s alternate infinite cubic-bezier(.17, .67, .83, .67);'); + expect(result).toContain('animation: calc(2s / 2) host-a_calc;'); + }); +}); diff --git a/packages/compiler/test/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts similarity index 98% rename from packages/compiler/test/shadow_css_spec.ts rename to packages/compiler/test/shadow_css/shadow_css_spec.ts index 90f50d876e39b..edc14bcb42a6d 100644 --- a/packages/compiler/test/shadow_css_spec.ts +++ b/packages/compiler/test/shadow_css/shadow_css_spec.ts @@ -126,17 +126,6 @@ import {normalizeCSS} from '@angular/platform-browser/testing/src/browser_util'; expect(s(css, 'contenta')).toEqual(expected); }); - // Check that the browser supports unprefixed CSS animation - it('should handle keyframes rules', () => { - const css = '@keyframes foo {0% {transform:translate(-50%) scaleX(0);}}'; - expect(s(css, 'contenta')).toEqual(css); - }); - - it('should handle -webkit-keyframes rules', () => { - const css = '@-webkit-keyframes foo {0% {-webkit-transform:translate(-50%) scaleX(0);}}'; - expect(s(css, 'contenta')).toEqual(css); - }); - it('should handle complicated selectors', () => { expect(s('one::before {}', 'contenta')).toEqual('one[contenta]::before {}'); expect(s('one two {}', 'contenta')).toEqual('one[contenta] two[contenta] {}');