Skip to content

Commit

Permalink
Create screen type service to determine screen type: Web, Tablet, Han…
Browse files Browse the repository at this point in the history
…dset (#6772)
  • Loading branch information
josephperrott authored and andrewseguin committed Sep 29, 2017
1 parent f43bf23 commit 811e504
Show file tree
Hide file tree
Showing 23 changed files with 622 additions and 2 deletions.
35 changes: 35 additions & 0 deletions src/cdk/coercion/array.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});

});
12 changes: 12 additions & 0 deletions src/cdk/coercion/array.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value];
}
1 change: 1 addition & 0 deletions src/cdk/coercion/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@

export * from './boolean-property';
export * from './number-property';
export * from './array';
26 changes: 26 additions & 0 deletions src/cdk/layout/breakpoints-observer.md
Original file line number Diff line number Diff line change
@@ -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<BreakpointState>;

constructor(bm: BreakpointObserver) {
bm.observe(Handset).subscribe((state: BreakpointState) => {
if (state.matches) {
this.makeEverythingFitOnSmallScreen();
} else {
this.expandEverythingToFillTheScreen();
}
});
}
}
```

149 changes: 149 additions & 0 deletions src/cdk/layout/breakpoints-observer.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, FakeMediaQueryList> = 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);
}
}
}
97 changes: 97 additions & 0 deletions src/cdk/layout/breakpoints-observer.ts
Original file line number Diff line number Diff line change
@@ -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<BreakpointState>;
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<string, Query> = 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<BreakpointState> {
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;
}
}
25 changes: 25 additions & 0 deletions src/cdk/layout/breakpoints.ts
Original file line number Diff line number Diff line change
@@ -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)',
};
8 changes: 8 additions & 0 deletions src/cdk/layout/index.ts
Original file line number Diff line number Diff line change
@@ -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';
17 changes: 17 additions & 0 deletions src/cdk/layout/media-matcher.md
Original file line number Diff line number Diff line change
@@ -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 comment has been minimized.

Copy link
@axtho

axtho Sep 30, 2017

Should this not be setLandscapeMode()?

this.setPortraitMode();
}
}
```

Loading

0 comments on commit 811e504

Please sign in to comment.