From eb22fe53316ae2cb84408ee88b82a23320f16dc5 Mon Sep 17 00:00:00 2001 From: Thomas Burleson Date: Mon, 27 Feb 2017 17:19:46 -0600 Subject: [PATCH] fix(ngStyle, ngClass): StyleDirective security fixes & merge activated styles (#198) BREAKING CHANGE: * `[style.]` selectors are deprecated in favor of `[ngStyle.]` selectors * `[class.]` selectors are deprecated in favor of `[ngClass.]` selectors * default styles are merged with activated styles ```html
``` ```html
``` Fixes #197. --- package.json | 2 +- .../app/github-issues/DemosGithubIssues.ts | 9 +- .../app/github-issues/issue.197.demo.ts | 58 ++++++ src/lib/flexbox/api/class.spec.ts | 41 ++-- src/lib/flexbox/api/class.ts | 97 ++++------ src/lib/flexbox/api/style.spec.ts | 151 +++++++++------ src/lib/flexbox/api/style.ts | 183 +++++++++++------- src/lib/utils/index.ts | 1 + src/lib/utils/style-transforms.spec.ts | 71 +++++++ src/lib/utils/style-transforms.ts | 112 +++++++++++ src/lib/utils/testing/custom-matchers.ts | 27 +++ 11 files changed, 542 insertions(+), 210 deletions(-) create mode 100644 src/demo-app/app/github-issues/issue.197.demo.ts create mode 100644 src/lib/utils/style-transforms.spec.ts create mode 100644 src/lib/utils/style-transforms.ts diff --git a/package.json b/package.json index 76e57dc71..da0c7099a 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "ts-node": "^0.7.3", "tslint": "^4.2.0", "tslint-loader": "^3.3.0", - "typescript": "2.0.10", + "typescript": "^2.0.10", "url-loader": "^0.5.7", "webpack": "2.2.0-rc.3", "webpack-bundle-analyzer": "^2.2.0", diff --git a/src/demo-app/app/github-issues/DemosGithubIssues.ts b/src/demo-app/app/github-issues/DemosGithubIssues.ts index 5db11f220..1f1d7363b 100644 --- a/src/demo-app/app/github-issues/DemosGithubIssues.ts +++ b/src/demo-app/app/github-issues/DemosGithubIssues.ts @@ -5,8 +5,9 @@ import {Component} from '@angular/core'; template: ` - + + ` }) export class DemosGithubIssues { @@ -19,16 +20,18 @@ import {FlexLayoutModule} from "../../../lib"; // `gulp build:components` to import {DemoIssue5345} from "./issue.5345.demo"; import {DemoIssue9897} from "./issue.9897.demo"; -import {DemoIssue181} from './issue.181.demo'; import {DemoIssue135} from "./issue.135.demo"; +import {DemoIssue181} from './issue.181.demo'; +import {DemoIssue197} from './issue.197.demo'; @NgModule({ declarations: [ DemosGithubIssues, // used by the Router with the root app component DemoIssue5345, DemoIssue9897, + DemoIssue135, DemoIssue181, - DemoIssue135 + DemoIssue197 ], imports: [ CommonModule, diff --git a/src/demo-app/app/github-issues/issue.197.demo.ts b/src/demo-app/app/github-issues/issue.197.demo.ts new file mode 100644 index 000000000..107d1bf47 --- /dev/null +++ b/src/demo-app/app/github-issues/issue.197.demo.ts @@ -0,0 +1,58 @@ +import {Component, OnDestroy} from '@angular/core'; +import {Subscription} from "rxjs/Subscription"; +import 'rxjs/add/operator/filter'; + +import {MediaChange} from "../../../lib/media-query/media-change"; +import {ObservableMedia} from "../../../lib/media-query/observable-media-service"; + +// [ngStyle="{'font-size.px': 10, color: 'rgb(0,0,0)', 'text-align':'left'}" +// style="font-size:10px; color:black; text-align:left;" +@Component({ + selector: 'demo-issue-197', + styleUrls: [ + '../demo-app/material2.css' + ], + template: ` + + + Issue #197 + Responsive Style directive should merge with default inline style: + +
+
+
+ <div fxFlexFill
+     style="font-size:10px; color:black; text-align:'left';"
+     [style.sm]="{'font-size':'16px', color:#a63db8, text-align:'center' }"
+     ngStyle.md="font-size:24px; color:#00f;" text-align:'right'>
+ </div> +
+
+
+
+ +
Active mediaQuery: {{ activeMediaQuery }}
+
+
+ ` +}) +export class DemoIssue197 implements OnDestroy { + public activeMediaQuery = ""; + + constructor(media$: ObservableMedia) { + this._watcher = media$.subscribe((change: MediaChange) => { + let value = change ? `'${change.mqAlias}' = (${change.mediaQuery})` : ""; + this.activeMediaQuery = value; + }); + } + + ngOnDestroy() { + this._watcher.unsubscribe(); + } + + private _watcher: Subscription; +} diff --git a/src/lib/flexbox/api/class.spec.ts b/src/lib/flexbox/api/class.spec.ts index 39ee519ae..3f7277e2a 100644 --- a/src/lib/flexbox/api/class.spec.ts +++ b/src/lib/flexbox/api/class.spec.ts @@ -57,44 +57,49 @@ describe('class directive', () => { const selector = `class-${mq}`; it(`should apply '${selector}' with '${mq}' media query`, () => { fixture = createTestComponent(` -
+
`); - activateMediaQuery(mq, true); + activateMediaQuery(mq); expectNativeEl(fixture).toHaveCssClass(selector); }); }); it('should keep existing class selector', () => { fixture = createTestComponent(` -
-
- `); +
+
+ `); + expectNativeEl(fixture).toHaveCssClass('existing-class'); - activateMediaQuery('xs', true); + activateMediaQuery('xs'); + expectNativeEl(fixture).toHaveCssClass('existing-class'); + + activateMediaQuery('lg'); expectNativeEl(fixture).toHaveCssClass('existing-class'); + expectNativeEl(fixture).not.toHaveCssClass('xs-class'); }); it('should allow more than one responsive breakpoint on one element', () => { fixture = createTestComponent(` -
+
`); - activateMediaQuery('xs', true); + activateMediaQuery('xs'); expectNativeEl(fixture).toHaveCssClass('xs-class'); expectNativeEl(fixture).not.toHaveCssClass('md-class'); - activateMediaQuery('md', true); + activateMediaQuery('md'); expectNativeEl(fixture).not.toHaveCssClass('xs-class'); expectNativeEl(fixture).toHaveCssClass('md-class'); }); it('should work with ngClass object notation', () => { fixture = createTestComponent(` -
-
- `); - activateMediaQuery('xs', true); +
+
+ `); + activateMediaQuery('xs'); expectNativeEl(fixture, {hasXs1: true, hasXs2: false}).toHaveCssClass('xs-1'); expectNativeEl(fixture, {hasXs1: true, hasXs2: false}).not.toHaveCssClass('xs-2'); @@ -104,10 +109,10 @@ describe('class directive', () => { it('should work with ngClass array notation', () => { fixture = createTestComponent(` -
-
- `); - activateMediaQuery('xs', true); +
+
+ `); + activateMediaQuery('xs'); expectNativeEl(fixture).toHaveCssClass('xs-1'); expectNativeEl(fixture).toHaveCssClass('xs-2'); }); diff --git a/src/lib/flexbox/api/class.ts b/src/lib/flexbox/api/class.ts index fcc5d69fc..e8d16f846 100644 --- a/src/lib/flexbox/api/class.ts +++ b/src/lib/flexbox/api/class.ts @@ -32,66 +32,43 @@ export type NgClassType = string | string[] | Set | {[klass: string]: an */ @Directive({ selector: ` - [class.xs], - [class.gt-xs], - [class.sm], - [class.gt-sm], - [class.md], - [class.gt-md], - [class.lg], - [class.gt-lg], - [class.xl] + [ngClass.xs], [class.xs], + [ngClass.gt-xs], [class.gt-xs], + [ngClass.sm], [class.sm], + [ngClass.gt-sm], [class.gt-sm], + [ngClass.md], [class.md], + [ngClass.gt-md], [class.gt-md], + [ngClass.lg], [class.lg], + [ngClass.gt-lg], [class.gt-lg] ` }) export class ClassDirective extends NgClass implements OnInit, OnChanges, OnDestroy { - @Input('class.xs') - set classXs(val: NgClassType) { - this._base.cacheInput('classXs', val); - } - - @Input('class.gt-xs') - set classGtXs(val: NgClassType) { - this._base.cacheInput('classGtXs', val); - }; - - @Input('class.sm') - set classSm(val: NgClassType) { - this._base.cacheInput('classSm', val); - }; - - @Input('class.gt-sm') - set classGtSm(val: NgClassType) { - this._base.cacheInput('classGtSm', val); - }; - - @Input('class.md') - set classMd(val: NgClassType) { - this._base.cacheInput('classMd', val); - }; - - @Input('class.gt-md') - set classGtMd(val: NgClassType) { - this._base.cacheInput('classGtMd', val); - }; - - @Input('class.lg') - set classLg(val: NgClassType) { - this._base.cacheInput('classLg', val); - }; - - @Input('class.gt-lg') - set classGtLg(val: NgClassType) { - this._base.cacheInput('classGtLg', val); - }; - - @Input('class.xl') - set classXl(val: NgClassType) { - this._base.cacheInput('classXl', val); - }; - - constructor(private monitor: MediaMonitor, - private _bpRegistry: BreakPointRegistry, + /* tslint:disable */ + @Input('ngClass.xs') set ngClassXs(val: NgClassType) { this._base.cacheInput('classXs', val, true); } + @Input('ngClass.gt-xs') set ngClassGtXs(val: NgClassType) { this._base.cacheInput('classGtXs', val, true); }; + @Input('ngClass.sm') set ngClassSm(val: NgClassType) { this._base.cacheInput('classSm', val, true); }; + @Input('ngClass.gt-sm') set ngClassGtSm(val: NgClassType) { this._base.cacheInput('classGtSm', val, true);} ; + @Input('ngClass.md') set ngClassMd(val: NgClassType) { this._base.cacheInput('classMd', val, true); }; + @Input('ngClass.gt-md') set ngClassGtMd(val: NgClassType) { this._base.cacheInput('classGtMd', val, true);}; + @Input('ngClass.lg') set ngClassLg(val: NgClassType) { this._base.cacheInput('classLg', val, true);}; + @Input('ngClass.gt-lg') set ngClassGtLg(val: NgClassType) { this._base.cacheInput('classGtLg', val, true); }; + @Input('ngClass.xl') set ngClassXl(val: NgClassType) { this._base.cacheInput('classXl', val, true); }; + + /** Deprecated selectors */ + @Input('class.xs') set classXs(val: NgClassType) { this._base.cacheInput('classXs', val, true); } + @Input('class.gt-xs') set classGtXs(val: NgClassType) { this._base.cacheInput('classGtXs', val, true); }; + @Input('class.sm') set classSm(val: NgClassType) { this._base.cacheInput('classSm', val, true); }; + @Input('class.gt-sm') set classGtSm(val: NgClassType) { this._base.cacheInput('classGtSm', val, true); }; + @Input('class.md') set classMd(val: NgClassType) { this._base.cacheInput('classMd', val, true);}; + @Input('class.gt-md') set classGtMd(val: NgClassType) { this._base.cacheInput('classGtMd', val, true);}; + @Input('class.lg') set classLg(val: NgClassType) { this._base.cacheInput('classLg', val, true); }; + @Input('class.gt-lg') set classGtLg(val: NgClassType) { this._base.cacheInput('classGtLg', val, true); }; + @Input('class.xl') set classXl(val: NgClassType) { this._base.cacheInput('classXl', val, true); }; + + /* tslint:enable */ + constructor(protected monitor: MediaMonitor, + protected _bpRegistry: BreakPointRegistry, _iterableDiffers: IterableDiffers, _keyValueDiffers: KeyValueDiffers, _ngEl: ElementRef, _renderer: Renderer) { super(_iterableDiffers, _keyValueDiffers, _ngEl, _renderer); @@ -102,7 +79,9 @@ export class ClassDirective extends NgClass implements OnInit, OnChanges, OnDest * For @Input changes on the current mq activation property, see onMediaQueryChanges() */ ngOnChanges(changes: SimpleChanges) { - const changed = this._bpRegistry.items.some(it => `class${it.suffix}` in changes); + const changed = this._bpRegistry.items.some(it => { + return (`ngClass${it.suffix}` in changes) || (`class${it.suffix}` in changes); + }); if (changed || this._base.mqActivation) { this._updateStyle(); } @@ -123,7 +102,7 @@ export class ClassDirective extends NgClass implements OnInit, OnChanges, OnDest this._base.ngOnDestroy(); } - private _updateStyle(value?: NgClassType) { + protected _updateStyle(value?: NgClassType) { let clazz = value || this._base.queryInput("class") || ''; if (this._base.mqActivation) { clazz = this._base.mqActivation.activatedInput; @@ -136,6 +115,6 @@ export class ClassDirective extends NgClass implements OnInit, OnChanges, OnDest * Special adapter to cross-cut responsive behaviors * into the ClassDirective */ - private _base: BaseFxDirectiveAdapter; + protected _base: BaseFxDirectiveAdapter; } diff --git a/src/lib/flexbox/api/style.spec.ts b/src/lib/flexbox/api/style.spec.ts index ea0818bb9..0297db92d 100644 --- a/src/lib/flexbox/api/style.spec.ts +++ b/src/lib/flexbox/api/style.spec.ts @@ -5,24 +5,23 @@ * 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, OnInit, Inject -} from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MockMatchMedia } from '../../media-query/mock/mock-match-media'; -import { MatchMedia } from '../../media-query/match-media'; -import { ObservableMedia } from '../../media-query/observable-media-service'; -import { BreakPointsProvider } from '../../media-query/breakpoints/break-points'; -import { BreakPointRegistry } from '../../media-query/breakpoints/break-point-registry'; - -import { customMatchers } from '../../utils/testing/custom-matchers'; +import {Component} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {MockMatchMedia} from '../../media-query/mock/mock-match-media'; +import {MatchMedia} from '../../media-query/match-media'; +import {BreakPointsProvider} from '../../media-query/breakpoints/break-points'; +import {BreakPointRegistry} from '../../media-query/breakpoints/break-point-registry'; + +import {LayoutDirective} from './layout'; +import {StyleDirective} from './style'; +import {MediaQueriesModule} from '../../media-query/_module'; + +import {customMatchers} from '../../utils/testing/custom-matchers'; import { makeCreateTestComponent, expectNativeEl } from '../../utils/testing/helpers'; -import { StyleDirective } from './style'; -import { MediaQueriesModule } from '../../media-query/_module'; describe('style directive', () => { let fixture: ComponentFixture; @@ -38,10 +37,10 @@ describe('style directive', () => { // Configure testbed to prepare services TestBed.configureTestingModule({ imports: [CommonModule, MediaQueriesModule], - declarations: [TestStyleComponent, StyleDirective], + declarations: [TestStyleComponent, LayoutDirective, StyleDirective], providers: [ BreakPointRegistry, BreakPointsProvider, - { provide: MatchMedia, useClass: MockMatchMedia } + {provide: MatchMedia, useClass: MockMatchMedia} ] }); }); @@ -53,62 +52,92 @@ describe('style directive', () => { }); [ - { mq: 'xs', styleStr: "{'font-size': '15px'}", styleObj: { 'font-size': '15px' } }, - { mq: 'sm', styleStr: "{'font-size': '16px'}", styleObj: { 'font-size': '16px' } }, - { mq: 'md', styleStr: "{'font-size': '17px'}", styleObj: { 'font-size': '17px' } }, - { mq: 'lg', styleStr: "{'font-size': '18px'}", styleObj: { 'font-size': '18px' } } + {mq: 'xs', styleStr: "{'font-size': '15px'}", styleObj: {'font-size': '15px'}}, + {mq: 'sm', styleStr: "{'font-size': '16px'}", styleObj: {'font-size': '16px'}}, + {mq: 'md', styleStr: "{'font-size': '17px'}", styleObj: {'font-size': '17px'}}, + {mq: 'lg', styleStr: "{'font-size': '18px'}", styleObj: {'font-size': '18px'}} ] - .forEach(testData => { - it(`should apply '${testData.styleStr}' with '${testData.mq}' media query`, () => { - fixture = createTestComponent(` -
-
- `); - activateMediaQuery(testData.mq, true); - expectNativeEl(fixture).toHaveCssStyle(testData.styleObj); - }); + .forEach(testData => { + it(`should apply '${testData.styleStr}' with '${testData.mq}' media query`, () => { + fixture = createTestComponent(` +
+
+ `); + activateMediaQuery(testData.mq); + expectNativeEl(fixture).toHaveCssStyle(testData.styleObj); }); + }); + + it('should merge with default inline styles', () => { + fixture = createTestComponent(` +
+
+ `); + expectNativeEl(fixture).toHaveCssStyle({color: 'blue'}); + activateMediaQuery('xs'); + expectNativeEl(fixture).toHaveCssStyle({color: 'blue', 'font-size': '15px'}); + }); - it('should override existing styles', () => { + it('should support raw-string notations', () => { fixture = createTestComponent(` -
-
- `); - expectNativeEl(fixture).toHaveCssStyle({ color: 'blue' }); - activateMediaQuery('xs', true); - expectNativeEl(fixture).toHaveCssStyle({ 'font-size': '15px' }); +
+
+ `); + expectNativeEl(fixture).toHaveCssStyle({color: 'blue'}); + activateMediaQuery('xs'); + expectNativeEl(fixture).toHaveCssStyle({ + 'color': 'blue', + 'font-size': '15px', + 'background-color': 'rgb(252, 41, 41)' + }); }); it('should allow more than one responsive breakpoint on one element', () => { fixture = createTestComponent(` -
-
- `); - activateMediaQuery('xs', true); - expectNativeEl(fixture).toHaveCssStyle({ 'font-size': '16px' }); - expectNativeEl(fixture).not.toHaveCssStyle({ 'font-size': '12px' }); - activateMediaQuery('md', true); - expectNativeEl(fixture).not.toHaveCssStyle({ 'font-size': '16px' }); - expectNativeEl(fixture).toHaveCssStyle({ 'font-size': '12px' }); +
+
+ `); + + fixture.detectChanges(); + + activateMediaQuery('xs'); + expectNativeEl(fixture).toHaveCssStyle({'display': 'flex'}); + expectNativeEl(fixture).toHaveCssStyle({'font-size': '16px'}); + expectNativeEl(fixture).not.toHaveCssStyle({'font-size': '12px'}); + + activateMediaQuery('md'); + expectNativeEl(fixture).not.toHaveCssStyle({'font-size': '16px'}); + expectNativeEl(fixture).toHaveCssStyle({'font-size': '12px'}); + + activateMediaQuery('lg'); + expectNativeEl(fixture).not.toHaveCssStyle({'font-size': '12px'}); + expectNativeEl(fixture).not.toHaveCssStyle({'font-size': '16px'}); + expectNativeEl(fixture).toHaveCssStyle({'font-size': '10px'}); // original is gone + expectNativeEl(fixture).toHaveCssStyle({'margin-left': '13px'}); // portion remains + }); it('should work with special ngStyle px notation', () => { fixture = createTestComponent(` -
-
- `); - activateMediaQuery('xs', true); - expectNativeEl(fixture).toHaveCssStyle({ 'font-size': '15px' }); +
+
+ `); + activateMediaQuery('xs'); + expectNativeEl(fixture).toHaveCssStyle({'font-size': '15px'}); }); it('should work with bound values', () => { fixture = createTestComponent(` -
-
- `); - activateMediaQuery('xs', true); - expectNativeEl(fixture, { fontSize: 19 }).toHaveCssStyle({ 'font-size': '19px' }); +
+
+ `); + activateMediaQuery('xs'); + expectNativeEl(fixture, {fontSize: 19}).toHaveCssStyle({'font-size': '19px'}); }); }); @@ -120,14 +149,8 @@ describe('style directive', () => { selector: 'test-style-api', template: `PlaceHolder Template HTML` }) -export class TestStyleComponent implements OnInit { +export class TestStyleComponent { fontSize: number; - - constructor( @Inject(ObservableMedia) private media) { - } - - ngOnInit() { - } } diff --git a/src/lib/flexbox/api/style.ts b/src/lib/flexbox/api/style.ts index b63d23dcf..486553569 100644 --- a/src/lib/flexbox/api/style.ts +++ b/src/lib/flexbox/api/style.ts @@ -11,10 +11,10 @@ import { Input, OnDestroy, OnInit, - Renderer, OnChanges, - SimpleChanges, - KeyValueDiffers + Renderer, + KeyValueDiffers, + SimpleChanges, SecurityContext } from '@angular/core'; import {NgStyle} from '@angular/common'; @@ -22,9 +22,15 @@ import {BaseFxDirectiveAdapter} from './base-adapter'; import {BreakPointRegistry} from './../../media-query/breakpoints/break-point-registry'; import {MediaChange} from '../../media-query/media-change'; import {MediaMonitor} from '../../media-query/media-monitor'; +import {extendObject} from '../../utils/object-extend'; +import {DomSanitizer} from '@angular/platform-browser'; -/** NgStyle allowed inputs **/ -export type NgStyleType = string | string[] | Set | {[klass: string]: any}; +import { + NgStyleRawList, + NgStyleType, + NgStyleSanitizer, + ngStyleUtils as _ +} from '../../utils/style-transforms'; /** * Directive to add responsive support for ngStyle. @@ -32,79 +38,76 @@ export type NgStyleType = string | string[] | Set | {[klass: string]: an */ @Directive({ selector: ` - [style.xs], - [style.gt-xs], - [style.sm], - [style.gt-sm], - [style.md], - [style.gt-md], - [style.lg], - [style.gt-lg], - [style.xl] + [ngStyle], + [ngStyle.xs], [style.xs], + [ngStyle.gt-xs], [style.gt-xs], + [ngStyle.sm], [style.sm], + [ngStyle.gt-sm], [style.gt-sm], + [ngStyle.md], [style.md], + [ngStyle.gt-md], [style.gt-md], + [ngStyle.lg], [style.lg], + [ngStyle.gt-lg], [style.gt-lg], + [ngStyle.xl], [style.xl] ` }) export class StyleDirective extends NgStyle implements OnInit, OnChanges, OnDestroy { - @Input('style.xs') - set styleXs(val: NgStyleType) { - this._base.cacheInput('styleXs', val, true); + /** + * Intercept ngStyle assignments so we cache the default styles + * which are merged with activated styles or used as fallbacks. + */ + @Input('ngStyle') + set styleBase(val: NgStyleType) { + this._base.cacheInput('style', val, true); + this.ngStyle = this._base.inputMap['style']; } - @Input('style.gt-xs') - set styleGtXs(val: NgStyleType) { - this._base.cacheInput('styleGtXs', val, true); - }; - - @Input('style.sm') - set styleSm(val: NgStyleType) { - this._base.cacheInput('styleSm', val, true); - }; - - @Input('style.gt-sm') - set styleGtSm(val: NgStyleType) { - this._base.cacheInput('styleGtSm', val, true); - }; - - @Input('style.md') - set styleMd(val: NgStyleType) { - this._base.cacheInput('styleMd', val, true); - }; - - @Input('style.gt-md') - set styleGtMd(val: NgStyleType) { - this._base.cacheInput('styleGtMd', val, true); - }; - - @Input('style.lg') - set styleLg(val: NgStyleType) { - this._base.cacheInput('styleLg', val, true); - }; - - @Input('style.gt-lg') - set styleGtLg(val: NgStyleType) { - this._base.cacheInput('styleGtLg', val, true); - }; - - @Input('style.xl') - set styleXl(val: NgStyleType) { - this._base.cacheInput('styleXl', val, true); - }; - + /* tslint:disable */ + @Input('ngStyle.xs') set ngStyleXs(val: NgStyleType) { this._base.cacheInput('styleXs', val, true); } + @Input('ngStyle.gt-xs') set ngStyleGtXs(val: NgStyleType) { this._base.cacheInput('styleGtXs', val, true); }; + @Input('ngStyle.sm') set ngStyleSm(val: NgStyleType) { this._base.cacheInput('styleSm', val, true); }; + @Input('ngStyle.gt-sm') set ngStyleGtSm(val: NgStyleType) { this._base.cacheInput('styleGtSm', val, true);} ; + @Input('ngStyle.md') set ngStyleMd(val: NgStyleType) { this._base.cacheInput('styleMd', val, true); }; + @Input('ngStyle.gt-md') set ngStyleGtMd(val: NgStyleType) { this._base.cacheInput('styleGtMd', val, true);}; + @Input('ngStyle.lg') set ngStyleLg(val: NgStyleType) { this._base.cacheInput('styleLg', val, true);}; + @Input('ngStyle.gt-lg') set ngStyleGtLg(val: NgStyleType) { this._base.cacheInput('styleGtLg', val, true); }; + @Input('ngStyle.xl') set ngStyleXl(val: NgStyleType) { this._base.cacheInput('styleXl', val, true); }; + + /** Deprecated selectors */ + @Input('style.xs') set styleXs(val: NgStyleType) { this._base.cacheInput('styleXs', val, true); } + @Input('style.gt-xs') set styleGtXs(val: NgStyleType) { this._base.cacheInput('styleGtXs', val, true); }; + @Input('style.sm') set styleSm(val: NgStyleType) { this._base.cacheInput('styleSm', val, true); }; + @Input('style.gt-sm') set styleGtSm(val: NgStyleType) { this._base.cacheInput('styleGtSm', val, true); }; + @Input('style.md') set styleMd(val: NgStyleType) { this._base.cacheInput('styleMd', val, true);}; + @Input('style.gt-md') set styleGtMd(val: NgStyleType) { this._base.cacheInput('styleGtMd', val, true);}; + @Input('style.lg') set styleLg(val: NgStyleType) { this._base.cacheInput('styleLg', val, true); }; + @Input('style.gt-lg') set styleGtLg(val: NgStyleType) { this._base.cacheInput('styleGtLg', val, true); }; + @Input('style.xl') set styleXl(val: NgStyleType) { this._base.cacheInput('styleXl', val, true); }; + + /* tslint:enable */ /** - * + * Constructor for the ngStyle subclass; which adds selectors and + * a MediaQuery Activation Adapter */ constructor(private monitor: MediaMonitor, - private _bpRegistry: BreakPointRegistry, - _differs: KeyValueDiffers, _ngEl: ElementRef, _renderer: Renderer) { + protected _bpRegistry: BreakPointRegistry, + protected _sanitizer: DomSanitizer, + _differs: KeyValueDiffers, + _ngEl: ElementRef, _renderer: Renderer) { super(_differs, _ngEl, _renderer); - this._base = new BaseFxDirectiveAdapter(monitor, _ngEl, _renderer); + + // Build adapter, `cacheInput()` interceptor, and get current inline style if any + this._buildAdapter(monitor, _ngEl, _renderer); + this._base.cacheInput('style', _ngEl.nativeElement.getAttribute("style"), true); } /** * For @Input changes on the current mq activation property, see onMediaQueryChanges() */ ngOnChanges(changes: SimpleChanges) { - const changed = this._bpRegistry.items.some(it => `style${it.suffix}` in changes); + const changed = this._bpRegistry.items.some(it => { + return (`ngStyle${it.suffix}` in changes) || (`style${it.suffix}` in changes); + }); if (changed || this._base.mqActivation) { this._updateStyle(); } @@ -118,26 +121,76 @@ export class StyleDirective extends NgStyle implements OnInit, OnChanges, OnDest this._base.listenForMediaQueryChanges('style', '', (changes: MediaChange) => { this._updateStyle(changes.value); }); - this._updateStyle(); } ngOnDestroy() { this._base.ngOnDestroy(); } - private _updateStyle(value?: NgStyleType) { + // ************************************************************************ + // Private Internal Methods + // ************************************************************************ + + /** + * Use the currently activated input property and assign to + * `ngStyle` which does the style injections... + */ + protected _updateStyle(value?: NgStyleType) { let style = value || this._base.queryInput("style") || ''; if (this._base.mqActivation) { style = this._base.mqActivation.activatedInput; } + // Delegate subsequent activity to the NgStyle logic this.ngStyle = style; } + + /** + * Build MediaQuery Activation Adapter + * This adapter manages listening to mediaQuery change events and identifying + * which property value should be used for the style update + */ + protected _buildAdapter(monitor: MediaMonitor, _ngEl: ElementRef, _renderer: Renderer) { + this._base = new BaseFxDirectiveAdapter(monitor, _ngEl, _renderer); + + // Build intercept to convert raw strings to ngStyleMap + let cacheInput = this._base.cacheInput.bind(this._base); + this._base.cacheInput = (key?: string, source?: any, cacheRaw = false, merge = true) => { + let styles = this._buildStyleMap(source); + if (merge) { + styles = extendObject({}, this._base.inputMap['style'], styles); + } + cacheInput(key, styles, cacheRaw); + }; + } + + /** + * Convert raw strings to ngStyleMap; which is required by ngStyle + * NOTE: Raw string key-value pairs MUST be delimited by `;` + * Comma-delimiters are not supported due to complexities of + * possible style values such as `rgba(x,x,x,x)` and others + */ + protected _buildStyleMap(styles: NgStyleType) { + let sanitizer: NgStyleSanitizer = (val: any) => { + // Always safe-guard (aka sanitize) style property values + return this._sanitizer.sanitize(SecurityContext.STYLE, val); + }; + if (styles) { + switch ( _.getType(styles) ) { + case 'string': return _.buildMapFromList(_.buildRawList(styles), sanitizer); + case 'array' : return _.buildMapFromList(styles as NgStyleRawList, sanitizer); + case 'set' : return _.buildMapFromSet(styles, sanitizer); + default : return _.buildMapFromSet(styles, sanitizer); + } + } + return styles; + } + /** * Special adapter to cross-cut responsive behaviors * into the StyleDirective */ - private _base: BaseFxDirectiveAdapter; + protected _base: BaseFxDirectiveAdapter; } diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 8166444ad..aa4a91c04 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -5,6 +5,7 @@ * 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 */ +export * from './style-transforms'; export * from './auto-prefixer'; export * from './object-extend'; export * from './add-alias'; diff --git a/src/lib/utils/style-transforms.spec.ts b/src/lib/utils/style-transforms.spec.ts new file mode 100644 index 000000000..74acb9524 --- /dev/null +++ b/src/lib/utils/style-transforms.spec.ts @@ -0,0 +1,71 @@ +/** + * @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 {customMatchers, expect} from './testing/custom-matchers'; +import {NgStyleRawList, NgStyleMap, ngStyleUtils as _} from './style-transforms'; + +describe('ngStyleUtils', () => { + beforeEach(() => { + jasmine.addMatchers(customMatchers); + }); + + it('should parse a raw string of key:value pairs', () => { + let list: NgStyleRawList = _.buildRawList(` + color:'red'; + font-size :16px; + background-color:rgba(116, 37, 49, 0.72); + `); + + expect(list[0]).toEqual("color:'red'"); + expect(list[1]).toEqual("font-size :16px"); + expect(list[2]).toEqual("background-color:rgba(116, 37, 49, 0.72)"); + }); + + it('should build an iterable map from a raw string of key:value pairs', () => { + let map: NgStyleMap = _.buildMapFromList(_.buildRawList(` + color:'red'; + font-size :16px; + background-color:rgba(116, 37, 49, 0.72); + `)); + + expect(map).toHaveMap({ + 'color': 'red', + 'font-size': '16px', + 'background-color': 'rgba(116, 37, 49, 0.72)' + }); + }); + + it('should build an iterable map from an Array of key:value strings', () => { + let map: NgStyleMap = _.buildMapFromList(_.buildRawList(` + color:'red'; + font-size :16px; + background-color:rgba(116, 37, 49, 0.72); + `)); + + expect(map).toHaveMap({ + 'color': 'red', + 'font-size': '16px', + 'background-color': 'rgba(116, 37, 49, 0.72)' + }); + }); + + it('should build an iterable map from an Set of key:value pairs', () => { + let customSet = new Set(); + customSet.add("color:'red'"); + customSet.add("font-size :16px;"); + customSet.add("background-color:rgba(116, 37, 49, 0.72)"); + + let map: NgStyleMap = _.buildMapFromSet(customSet); + + expect(map).toHaveMap({ + 'color': 'red', + 'font-size': '16px', + 'background-color': 'rgba(116, 37, 49, 0.72)' + }); + }); + +}); diff --git a/src/lib/utils/style-transforms.ts b/src/lib/utils/style-transforms.ts new file mode 100644 index 000000000..67cdc273a --- /dev/null +++ b/src/lib/utils/style-transforms.ts @@ -0,0 +1,112 @@ +/** + * @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 + */ + +export type NgStyleRawList = string[]; +export type NgStyleMap = {[klass: string]: string}; +// NgStyle selectors accept NgStyleType values +export type NgStyleType = string | Set | NgStyleRawList | NgStyleMap; + +/** + * Callback function for SecurityContext.STYLE sanitization + */ +export type NgStyleSanitizer = (val: any) => string; + +/** + * NgStyle allowed inputs + */ +export class NgStyleKeyValue { + constructor(public key: string, public value: string, noQuotes = true) { + this.key = noQuotes ? key.replace(/['"]/g, "").trim() : key.trim(); + + this.value = noQuotes ? value.replace(/['"]/g, "").trim() : value.trim(); + this.value = this.value.replace(/;/, ""); + } +} + +/** + * Transform Operators for @angular/flex-layout NgStyle Directive + */ +export const ngStyleUtils = { + getType, + buildRawList, + buildMapFromList, + buildMapFromSet +}; + +function getType(target: any): string { + let what = typeof target; + if (what === 'object') { + return (target.constructor === Array) ? 'array' : + (target.constructor === Set ) ? 'set' : 'object'; + } + return what; +} + +/** + * Split string of key:value pairs into Array of k-v pairs + * e.g. 'key:value; key:value; key:value;' -> ['key:value',...] + */ +function buildRawList(source: any, delimiter = ";"): NgStyleRawList { + return String(source) + .trim() + .split(delimiter) + .map((val: string) => val.trim()) + .filter(val => val !== ""); +} + +/** + * Convert array of key:value strings to a iterable map object + */ +function buildMapFromList(styles: NgStyleRawList, sanitize?: NgStyleSanitizer): NgStyleMap { + let sanitizeValue = (it: NgStyleKeyValue) => { + if (sanitize) { + it.value = sanitize(it.value); + } + return it; + }; + + return styles + .map(stringToKeyValue) + .filter(entry => !!entry) + .map(sanitizeValue) + .reduce(keyValuesToMap, {}); +}; + +/** + * Convert Set or raw Object to an iterable NgStyleMap + */ +function buildMapFromSet(source: any, sanitize?: NgStyleSanitizer): NgStyleMap { + let list = new Array(); + if (getType(source) == 'set') { + source.forEach(entry => list.push(entry)); + } else { // simple hashmap + Object.keys(source).forEach(key => { + list.push(`${key}:${source[key]}`); + }); + } + return buildMapFromList(list, sanitize); +} + + +/** + * Convert "key:value" -> [key, value] + */ +function stringToKeyValue(it: string): NgStyleKeyValue { + let [key, val] = it.split(":"); + return val ? new NgStyleKeyValue(key, val) : null; +}; + +/** + * Convert [ [key,value] ] -> { key : value } + */ +function keyValuesToMap(map: NgStyleMap, entry: NgStyleKeyValue): NgStyleMap { + if (!!entry.key) { + map[entry.key] = entry.value; + } + return map; +} diff --git a/src/lib/utils/testing/custom-matchers.ts b/src/lib/utils/testing/custom-matchers.ts index 4100d9b3f..d077729f2 100644 --- a/src/lib/utils/testing/custom-matchers.ts +++ b/src/lib/utils/testing/custom-matchers.ts @@ -27,6 +27,11 @@ export interface NgMatchers extends jasmine.Matchers { */ toHaveText(expected: string): boolean; + /** + * Compare key:value pairs as matching EXACTLY + */ + toHaveMap(expected: {[k: string]: string}): boolean; + /** * Expect the element to have the given CSS class. * @@ -111,6 +116,28 @@ export const customMatchers: jasmine.CustomMatcherFactories = { } }, + toHaveMap : function() { + return { + compare: function (actual: {[k: string]: string}, map: {[k: string]: string}) { + let allPassed: boolean; + allPassed = Object.keys(map).length !== 0; + Object.keys(map).forEach(key => { + allPassed = allPassed && (actual[key] === map[key]); + }); + + return { + pass: allPassed, + get message() { + return ` + Expected ${JSON.stringify(actual)} ${!allPassed ? ' ' : 'not '} to contain the + "${JSON.stringify(map)}" + `; + } + }; + } + }; + }, + toHaveCssStyle: function () { return { compare: function (actual: any, styles: {[k: string]: string}|string) {