diff --git a/src/cdk/coercion/array.spec.ts b/src/cdk/coercion/array.spec.ts new file mode 100644 index 00000000000..acc24cdd7b9 --- /dev/null +++ b/src/cdk/coercion/array.spec.ts @@ -0,0 +1,35 @@ +import {coerceArray} from './array'; + +describe('coerceArray', () => { + + it('should wrap a string in an array', () => { + let stringVal = 'just a string'; + expect(coerceArray(stringVal)).toEqual([stringVal]); + }); + + it('should wrap a number in an array', () => { + let numberVal = 42; + expect(coerceArray(numberVal)).toEqual([numberVal]); + }); + + it('should wrap an object in an array', () => { + let objectVal = { something: 'clever' }; + expect(coerceArray(objectVal)).toEqual([objectVal]); + }); + + it('should wrap a null vall in an array', () => { + let nullVal = null; + expect(coerceArray(nullVal)).toEqual([nullVal]); + }); + + it('should wrap an undefined value in an array', () => { + let undefinedVal = undefined; + expect(coerceArray(undefinedVal)).toEqual([undefinedVal]); + }); + + it('should not wrap an array in an array', () => { + let arrayVal = [1, 2, 3]; + expect(coerceArray(arrayVal)).toBe(arrayVal); + }); + +}); diff --git a/src/cdk/coercion/array.ts b/src/cdk/coercion/array.ts new file mode 100644 index 00000000000..c8a6766390b --- /dev/null +++ b/src/cdk/coercion/array.ts @@ -0,0 +1,12 @@ +/** + * @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 + */ + +/** Wraps the provided value in an array, unless the provided value is an array. */ +export function coerceArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; +} diff --git a/src/cdk/coercion/public_api.ts b/src/cdk/coercion/public_api.ts index 53767a7ac27..4aa4d40d308 100644 --- a/src/cdk/coercion/public_api.ts +++ b/src/cdk/coercion/public_api.ts @@ -8,3 +8,4 @@ export * from './boolean-property'; export * from './number-property'; +export * from './array'; diff --git a/src/cdk/layout/breakpoints-observer.md b/src/cdk/layout/breakpoints-observer.md new file mode 100644 index 00000000000..c5dfc710b58 --- /dev/null +++ b/src/cdk/layout/breakpoints-observer.md @@ -0,0 +1,26 @@ +### BreakpointsModule + +When including the CDK's `LayoutModule`, components can inject `BreakpointsObserver` to request +the matching state of a CSS Media Query. + +A set of breakpoints is provided based on the Material Design +[breakpoint system](https://material.io/guidelines/layout/responsive-ui.html#responsive-ui-breakpoints). + +#### Example +```ts +@Component({ ... }) +export class MyWidget { + isHandset: Observable; + + constructor(bm: BreakpointObserver) { + bm.observe(Handset).subscribe((state: BreakpointState) => { + if (state.matches) { + this.makeEverythingFitOnSmallScreen(); + } else { + this.expandEverythingToFillTheScreen(); + } + }); + } +} +``` + diff --git a/src/cdk/layout/breakpoints-observer.spec.ts b/src/cdk/layout/breakpoints-observer.spec.ts new file mode 100644 index 00000000000..6272b9d00d7 --- /dev/null +++ b/src/cdk/layout/breakpoints-observer.spec.ts @@ -0,0 +1,149 @@ +/** + * @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 {LayoutModule, BreakpointObserver, BreakpointState} from './index'; +import {MediaMatcher} from './media-matcher'; +import {async, TestBed, inject} from '@angular/core/testing'; +import {Injectable} from '@angular/core'; + +describe('BreakpointObserver', () => { + let breakpointManager: BreakpointObserver; + let mediaMatcher: FakeMediaMatcher; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [LayoutModule], + providers: [{provide: MediaMatcher, useClass: FakeMediaMatcher}] + }); + })); + + beforeEach(inject( + [BreakpointObserver, MediaMatcher], + (bm: BreakpointObserver, mm: FakeMediaMatcher) => { + breakpointManager = bm; + mediaMatcher = mm; + })); + + afterEach(() => { + mediaMatcher.clear(); + }); + + it('retrieves the whether a query is currently matched', () => { + let query = 'everything starts as true in the FakeMediaMatcher'; + expect(breakpointManager.isMatched(query)).toBeTruthy(); + }); + + it('reuses the same MediaQueryList for matching queries', () => { + expect(mediaMatcher.queryCount).toBe(0); + breakpointManager.observe('query1'); + expect(mediaMatcher.queryCount).toBe(1); + breakpointManager.observe('query1'); + expect(mediaMatcher.queryCount).toBe(1); + breakpointManager.observe('query2'); + expect(mediaMatcher.queryCount).toBe(2); + breakpointManager.observe('query1'); + expect(mediaMatcher.queryCount).toBe(2); + }); + + it('accepts an array of queries', () => { + let queries = ['1 query', '2 query', 'red query', 'blue query']; + breakpointManager.observe(queries); + expect(mediaMatcher.queryCount).toBe(queries.length); + }); + + it('completes all events when the breakpoint manager is destroyed', () => { + let firstTest = jasmine.createSpy('test1'); + breakpointManager.observe('test1').subscribe(undefined, undefined, firstTest); + let secondTest = jasmine.createSpy('test2'); + breakpointManager.observe('test2').subscribe(undefined, undefined, secondTest); + + expect(firstTest).not.toHaveBeenCalled(); + expect(secondTest).not.toHaveBeenCalled(); + + breakpointManager.ngOnDestroy(); + + expect(firstTest).toHaveBeenCalled(); + expect(secondTest).toHaveBeenCalled(); + }); + + it('emits an event on the observable when values change', () => { + let query = '(width: 999px)'; + let queryMatchState: boolean = false; + breakpointManager.observe(query).subscribe((state: BreakpointState) => { + queryMatchState = state.matches; + }); + + async(() => { + expect(queryMatchState).toBeTruthy(); + mediaMatcher.setMatchesQuery(query, false); + expect(queryMatchState).toBeFalsy(); + }); + }); + + it('emits a true matches state when the query is matched', () => { + let query = '(width: 999px)'; + mediaMatcher.setMatchesQuery(query, true); + expect(breakpointManager.isMatched(query)).toBeTruthy(); + }); + + it('emits a false matches state when the query is not matched', () => { + let query = '(width: 999px)'; + mediaMatcher.setMatchesQuery(query, false); + expect(breakpointManager.isMatched(query)).toBeTruthy(); + }); +}); + +export class FakeMediaQueryList implements MediaQueryList { + /** The callback for change events. */ + addListenerCallback?: (mql: MediaQueryList) => void; + + constructor(public matches, public media) {} + + /** Toggles the matches state and "emits" a change event. */ + setMatches(matches: boolean) { + this.matches = matches; + this.addListenerCallback!(this); + } + + /** Registers the callback method for change events. */ + addListener(callback: (mql: MediaQueryList) => void) { + this.addListenerCallback = callback; + } + + /** Noop, but required for implementing MediaQueryList. */ + removeListener() {} +} + +@Injectable() +export class FakeMediaMatcher { + /** A map of match media queries. */ + private queries: Map = new Map(); + + /** The number of distinct queries created in the media matcher during a test. */ + get queryCount(): number { + return this.queries.size; + } + + /** Fakes the match media response to be controlled in tests. */ + matchMedia(query: string): FakeMediaQueryList { + let mql = new FakeMediaQueryList(true, query); + this.queries.set(query, mql); + return mql; + } + + /** Clears all queries from the map of queries. */ + clear() { + this.queries.clear(); + } + + /** Toggles the matching state of the provided query. */ + setMatchesQuery(query: string, matches: boolean) { + if (this.queries.has(query)) { + this.queries.get(query)!.setMatches(matches); + } + } +} diff --git a/src/cdk/layout/breakpoints-observer.ts b/src/cdk/layout/breakpoints-observer.ts new file mode 100644 index 00000000000..3a7c481c4f6 --- /dev/null +++ b/src/cdk/layout/breakpoints-observer.ts @@ -0,0 +1,97 @@ +/** + * @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 {Injectable, NgZone, OnDestroy} from '@angular/core'; +import {MediaMatcher} from './media-matcher'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; +import {RxChain, map, startWith, takeUntil} from '@angular/cdk/rxjs'; +import {coerceArray} from '@angular/cdk/coercion'; +import {combineLatest} from 'rxjs/observable/combineLatest'; +import {fromEventPattern} from 'rxjs/observable/fromEventPattern'; + +/** The current state of a layout breakpoint. */ +export interface BreakpointState { + matches: boolean; +} + +interface Query { + observable: Observable; + mql: MediaQueryList; +} + +/** + * Utility for checking the matching state of @media queries. + */ +@Injectable() +export class BreakpointObserver implements OnDestroy { + /** A map of all media queries currently being listened for. */ + private _queries: Map = new Map(); + /** A subject for all other observables to takeUntil based on. */ + private _destroySubject: Subject<{}> = new Subject(); + + constructor(private mediaMatcher: MediaMatcher, private zone: NgZone) {} + + /** Completes the active subject, signalling to all other observables to complete. */ + ngOnDestroy() { + this._destroySubject.next(); + this._destroySubject.complete(); + } + + /** Whether the query currently is matched. */ + isMatched(value: string | string[]): boolean { + let queries = coerceArray(value); + return queries.some(mediaQuery => this._registerQuery(mediaQuery).mql.matches); + } + + /** + * Gets an observable of results for the given queries that will emit new results for any changes + * in matching of the given queries. + */ + observe(value: string | string[]): Observable { + let queries = coerceArray(value); + let observables = queries.map(query => this._registerQuery(query).observable); + + return combineLatest(observables, (a: BreakpointState, b: BreakpointState) => { + return { + matches: !!((a && a.matches) || (b && b.matches)), + }; + }); + } + + /** Registers a specific query to be listened for. */ + private _registerQuery(query: string): Query { + // Only set up a new MediaQueryList if it is not already being listened for. + if (this._queries.has(query)) { + return this._queries.get(query)!; + } + + let mql: MediaQueryList = this.mediaMatcher.matchMedia(query); + // Create callback for match changes and add it is as a listener. + let queryObservable = RxChain.from(fromEventPattern( + // Listener callback methods are wrapped to be placed back in ngZone. Callbacks must be placed + // back into the zone because matchMedia is only included in Zone.js by loading the + // webapis-media-query.js file alongside the zone.js file. Additionally, some browsers do not + // have MediaQueryList inherit from EventTarget, which causes inconsistencies in how Zone.js + // patches it. + (listener: MediaQueryListListener) => { + mql.addListener((e: MediaQueryList) => this.zone.run(() => listener(e))); + }, + (listener: MediaQueryListListener) => { + mql.removeListener((e: MediaQueryList) => this.zone.run(() => listener(e))); + })) + .call(takeUntil, this._destroySubject) + .call(startWith, mql) + .call(map, (nextMql: MediaQueryList) => ({matches: nextMql.matches})) + .result(); + + // Add the MediaQueryList to the set of queries. + let output = {observable: queryObservable, mql: mql}; + this._queries.set(query, output); + return output; + } +} diff --git a/src/cdk/layout/breakpoints.ts b/src/cdk/layout/breakpoints.ts new file mode 100644 index 00000000000..278ab780ec6 --- /dev/null +++ b/src/cdk/layout/breakpoints.ts @@ -0,0 +1,25 @@ +/** + * @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 + */ +// PascalCase is being used as Breakpoints is used like an enum. +// tslint:disable-next-line:variable-name +export const Breakpoints = { + Handset: '(max-width: 599px) and (orientation: portrait), ' + + '(max-width: 959px) and (orientation: landscape)', + Tablet: '(min-width: 600px) and (max-width: 839px) and (orientation: portrait), ' + + '(min-width: 960px) and (max-width: 1279px) and (orientation: landscape)', + Web: '(min-width: 840px) and (orientation: portrait), ' + + '(min-width: 1280px) and (orientation: landscape)', + + HandsetPortrait: '(max-width: 599px) and (orientation: portrait)', + TabletPortrait: '(min-width: 600px) and (max-width: 839px) and (orientation: portrait)', + WebPortrait: '(min-width: 840px) and (orientation: portrait)', + + HandsetLandscape: '(max-width: 959px) and (orientation: landscape)', + TabletLandscape: '(min-width: 960px) and (max-width: 1279px) and (orientation: landscape)', + WebLandscape: '(min-width: 1280px) and (orientation: landscape)', +}; diff --git a/src/cdk/layout/index.ts b/src/cdk/layout/index.ts new file mode 100644 index 00000000000..5a6b2bc7c6d --- /dev/null +++ b/src/cdk/layout/index.ts @@ -0,0 +1,8 @@ +/** + * @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 * from './public_api'; diff --git a/src/cdk/layout/media-matcher.md b/src/cdk/layout/media-matcher.md new file mode 100644 index 00000000000..259ad241a7c --- /dev/null +++ b/src/cdk/layout/media-matcher.md @@ -0,0 +1,17 @@ +### MediaMatcher + +When including the CDK's `LayoutModule`, components can inject `MediaMatcher` to access the +matchMedia method, if available on the platform. + +#### Example +```ts +@Component({ ... }) +export class MyWidget { + constructor(mm: MediaMatcher) { + mm.matchMedia('(orientation: landscape)').matches ? + this.setPortraitMode() : + this.setPortraitMode(); + } +} +``` + diff --git a/src/cdk/layout/media-matcher.spec.ts b/src/cdk/layout/media-matcher.spec.ts new file mode 100644 index 00000000000..10f0bf5c45e --- /dev/null +++ b/src/cdk/layout/media-matcher.spec.ts @@ -0,0 +1,47 @@ +/** + * @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 {LayoutModule, BreakpointObserver} from './index'; +import {MediaMatcher} from './media-matcher'; +import {async, TestBed, inject} from '@angular/core/testing'; +import {Platform} from '@angular/cdk/platform'; + +describe('MediaMatcher', () => { + let breakpointManager: BreakpointObserver; + let mediaMatcher: MediaMatcher; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [LayoutModule] + }); + })); + + beforeEach(inject( + [BreakpointObserver, MediaMatcher], + (bm: BreakpointObserver, mm: MediaMatcher) => { + breakpointManager = bm; + mediaMatcher = mm; + })); + + it('correctly returns a MediaQueryList to check for matches', () => { + expect(mediaMatcher.matchMedia('(min-width: 1px)').matches).toBeTruthy(); + expect(mediaMatcher.matchMedia('(max-width: 1px)').matches).toBeFalsy(); + }); + + it('adds css rules for provided queries when the platform is webkit, otherwise adds nothing.', + inject([Platform], (platform: Platform) => { + let randomWidth = Math.random(); + expect(document.head.textContent).not.toContain(randomWidth); + mediaMatcher.matchMedia(`(width: ${randomWidth})`); + + if (platform.WEBKIT) { + expect(document.head.textContent).toContain(randomWidth); + } else { + expect(document.head.textContent).not.toContain(randomWidth); + } + })); +}); diff --git a/src/cdk/layout/media-matcher.ts b/src/cdk/layout/media-matcher.ts new file mode 100644 index 00000000000..b5a2db900d1 --- /dev/null +++ b/src/cdk/layout/media-matcher.ts @@ -0,0 +1,77 @@ +/** + * @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 {Injectable} from '@angular/core'; +import {Platform} from '@angular/cdk/platform'; + +/** + * Global registry for all dynamically-created, injected style tags. + */ +const styleElementForWebkitCompatibility: Map = new Map(); + +/** + * A utility for calling matchMedia queries. + */ +@Injectable() +export class MediaMatcher { + /** The internal matchMedia method to return back a MediaQueryList like object. */ + private _matchMedia: (query: string) => MediaQueryList; + + constructor(private platform: Platform) { + this._matchMedia = this.platform.isBrowser ? + // matchMedia is bound to the window scope intentionally as it is an illegal invocation to + // call it from a different scope. + window.matchMedia.bind(window) : + noopMatchMedia; + } + + /** + * Confirms the layout engine will trigger for the selector query provided and returns the + * MediaQueryList for the query provided. + */ + matchMedia(query: string): MediaQueryList { + if (this.platform.WEBKIT) { + createEmptyStyleRule(query); + } + return this._matchMedia(query); + } +} + +/** + * For Webkit engines that only trigger the MediaQueryListListener when there is at least one CSS + * selector for the respective media query. + */ +function createEmptyStyleRule(query: string) { + if (!styleElementForWebkitCompatibility.has(query)) { + try { + const style = document.createElement('style'); + + style.setAttribute('type', 'text/css'); + if (!style.sheet) { + const cssText = `@media ${query} {.fx-query-test{ }}`; + style.appendChild(document.createTextNode(cssText)); + } + + document.getElementsByTagName('head')[0].appendChild(style); + + // Store in private global registry + styleElementForWebkitCompatibility.set(query, style); + } catch (e) { + console.error(e); + } + } +} + +/** No-op matchMedia replacement for non-browser platforms. */ +function noopMatchMedia(query: string): MediaQueryList { + return { + matches: query === 'all' || query === '', + media: query, + addListener: () => {}, + removeListener: () => {} + }; +} diff --git a/src/cdk/layout/public_api.ts b/src/cdk/layout/public_api.ts new file mode 100644 index 00000000000..43468c08fb3 --- /dev/null +++ b/src/cdk/layout/public_api.ts @@ -0,0 +1,21 @@ +/** + * @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 {NgModule} from '@angular/core'; +import {PlatformModule} from '@angular/cdk/platform'; +import {BreakpointObserver} from './breakpoints-observer'; +import {MediaMatcher} from './media-matcher'; + +@NgModule({ + providers: [BreakpointObserver, MediaMatcher], + imports: [PlatformModule], +}) +export class LayoutModule {} + +export {BreakpointObserver, BreakpointState} from './breakpoints-observer'; +export {Breakpoints} from './breakpoints'; +export {MediaMatcher} from './media-matcher'; diff --git a/src/cdk/layout/tsconfig-build.json b/src/cdk/layout/tsconfig-build.json new file mode 100644 index 00000000000..56654d9971f --- /dev/null +++ b/src/cdk/layout/tsconfig-build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public_api.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/cdk/layout", + "skipTemplateCodegen": true + } +} diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index b0d3e33cf61..185fc2cad87 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -32,4 +32,3 @@ export class DemoAppModule { this._appRef.bootstrap(EntryApp); } } - diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 7409cbccef3..f95c0a7a731 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -67,6 +67,7 @@ export class DemoApp { {name: 'Progress Spinner', route: '/progress-spinner'}, {name: 'Radio', route: '/radio'}, {name: 'Ripple', route: '/ripple'}, + {name: 'Screen Type', route: '/screen-type'}, {name: 'Select', route: '/select'}, {name: 'Sidenav', route: '/sidenav'}, {name: 'Slide Toggle', route: '/slide-toggle'}, diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index b73d8c9dd38..ed284116831 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -38,6 +38,8 @@ import {SnackBarDemo} from '../snack-bar/snack-bar-demo'; import {StepperDemo} from '../stepper/stepper-demo'; import {PeopleDatabase} from '../table/people-database'; import {TableDemo} from '../table/table-demo'; +import {ScreenTypeDemo} from '../screen-type/screen-type-demo'; +import {LayoutModule} from '@angular/cdk/layout'; import {TableHeaderDemo} from '../table/table-header-demo'; import {FoggyTabContent, RainyTabContent, SunnyTabContent, TabsDemo} from '../tabs/tabs-demo'; import {ToolbarDemo} from '../toolbar/toolbar-demo'; @@ -53,6 +55,7 @@ import {DEMO_APP_ROUTES} from './routes'; ReactiveFormsModule, RouterModule.forChild(DEMO_APP_ROUTES), DemoMaterialModule, + LayoutModule, ], declarations: [ AutocompleteDemo, @@ -91,6 +94,7 @@ import {DEMO_APP_ROUTES} from './routes'; RippleDemo, RotiniPanel, ScienceJoke, + ScreenTypeDemo, SelectDemo, SidenavDemo, SliderDemo, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index cebe53bdb29..db33beea133 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -27,6 +27,7 @@ import {ProgressBarDemo} from '../progress-bar/progress-bar-demo'; import {ProgressSpinnerDemo} from '../progress-spinner/progress-spinner-demo'; import {RadioDemo} from '../radio/radio-demo'; import {RippleDemo} from '../ripple/ripple-demo'; +import {ScreenTypeDemo} from '../screen-type/screen-type-demo'; import {SelectDemo} from '../select/select-demo'; import {SidenavDemo} from '../sidenav/sidenav-demo'; import {SlideToggleDemo} from '../slide-toggle/slide-toggle-demo'; @@ -81,6 +82,9 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'toolbar', component: ToolbarDemo}, {path: 'tooltip', component: TooltipDemo}, {path: 'typography', component: TypographyDemo}, + {path: 'expansion', component: ExpansionDemo}, + {path: 'stepper', component: StepperDemo}, + {path: 'screen-type', component: ScreenTypeDemo}, ]} ]; diff --git a/src/demo-app/index.html b/src/demo-app/index.html index b8669924fdb..de018522783 100644 --- a/src/demo-app/index.html +++ b/src/demo-app/index.html @@ -22,7 +22,7 @@ - +