Skip to content

Commit 811e504

Browse files
josephperrottandrewseguin
authored andcommitted
Create screen type service to determine screen type: Web, Tablet, Handset (#6772)
1 parent f43bf23 commit 811e504

23 files changed

+622
-2
lines changed

src/cdk/coercion/array.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {coerceArray} from './array';
2+
3+
describe('coerceArray', () => {
4+
5+
it('should wrap a string in an array', () => {
6+
let stringVal = 'just a string';
7+
expect(coerceArray(stringVal)).toEqual([stringVal]);
8+
});
9+
10+
it('should wrap a number in an array', () => {
11+
let numberVal = 42;
12+
expect(coerceArray(numberVal)).toEqual([numberVal]);
13+
});
14+
15+
it('should wrap an object in an array', () => {
16+
let objectVal = { something: 'clever' };
17+
expect(coerceArray(objectVal)).toEqual([objectVal]);
18+
});
19+
20+
it('should wrap a null vall in an array', () => {
21+
let nullVal = null;
22+
expect(coerceArray(nullVal)).toEqual([nullVal]);
23+
});
24+
25+
it('should wrap an undefined value in an array', () => {
26+
let undefinedVal = undefined;
27+
expect(coerceArray(undefinedVal)).toEqual([undefinedVal]);
28+
});
29+
30+
it('should not wrap an array in an array', () => {
31+
let arrayVal = [1, 2, 3];
32+
expect(coerceArray(arrayVal)).toBe(arrayVal);
33+
});
34+
35+
});

src/cdk/coercion/array.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/** Wraps the provided value in an array, unless the provided value is an array. */
10+
export function coerceArray<T>(value: T | T[]): T[] {
11+
return Array.isArray(value) ? value : [value];
12+
}

src/cdk/coercion/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88

99
export * from './boolean-property';
1010
export * from './number-property';
11+
export * from './array';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
### BreakpointsModule
2+
3+
When including the CDK's `LayoutModule`, components can inject `BreakpointsObserver` to request
4+
the matching state of a CSS Media Query.
5+
6+
A set of breakpoints is provided based on the Material Design
7+
[breakpoint system](https://material.io/guidelines/layout/responsive-ui.html#responsive-ui-breakpoints).
8+
9+
#### Example
10+
```ts
11+
@Component({ ... })
12+
export class MyWidget {
13+
isHandset: Observable<BreakpointState>;
14+
15+
constructor(bm: BreakpointObserver) {
16+
bm.observe(Handset).subscribe((state: BreakpointState) => {
17+
if (state.matches) {
18+
this.makeEverythingFitOnSmallScreen();
19+
} else {
20+
this.expandEverythingToFillTheScreen();
21+
}
22+
});
23+
}
24+
}
25+
```
26+
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {LayoutModule, BreakpointObserver, BreakpointState} from './index';
9+
import {MediaMatcher} from './media-matcher';
10+
import {async, TestBed, inject} from '@angular/core/testing';
11+
import {Injectable} from '@angular/core';
12+
13+
describe('BreakpointObserver', () => {
14+
let breakpointManager: BreakpointObserver;
15+
let mediaMatcher: FakeMediaMatcher;
16+
17+
beforeEach(async(() => {
18+
TestBed.configureTestingModule({
19+
imports: [LayoutModule],
20+
providers: [{provide: MediaMatcher, useClass: FakeMediaMatcher}]
21+
});
22+
}));
23+
24+
beforeEach(inject(
25+
[BreakpointObserver, MediaMatcher],
26+
(bm: BreakpointObserver, mm: FakeMediaMatcher) => {
27+
breakpointManager = bm;
28+
mediaMatcher = mm;
29+
}));
30+
31+
afterEach(() => {
32+
mediaMatcher.clear();
33+
});
34+
35+
it('retrieves the whether a query is currently matched', () => {
36+
let query = 'everything starts as true in the FakeMediaMatcher';
37+
expect(breakpointManager.isMatched(query)).toBeTruthy();
38+
});
39+
40+
it('reuses the same MediaQueryList for matching queries', () => {
41+
expect(mediaMatcher.queryCount).toBe(0);
42+
breakpointManager.observe('query1');
43+
expect(mediaMatcher.queryCount).toBe(1);
44+
breakpointManager.observe('query1');
45+
expect(mediaMatcher.queryCount).toBe(1);
46+
breakpointManager.observe('query2');
47+
expect(mediaMatcher.queryCount).toBe(2);
48+
breakpointManager.observe('query1');
49+
expect(mediaMatcher.queryCount).toBe(2);
50+
});
51+
52+
it('accepts an array of queries', () => {
53+
let queries = ['1 query', '2 query', 'red query', 'blue query'];
54+
breakpointManager.observe(queries);
55+
expect(mediaMatcher.queryCount).toBe(queries.length);
56+
});
57+
58+
it('completes all events when the breakpoint manager is destroyed', () => {
59+
let firstTest = jasmine.createSpy('test1');
60+
breakpointManager.observe('test1').subscribe(undefined, undefined, firstTest);
61+
let secondTest = jasmine.createSpy('test2');
62+
breakpointManager.observe('test2').subscribe(undefined, undefined, secondTest);
63+
64+
expect(firstTest).not.toHaveBeenCalled();
65+
expect(secondTest).not.toHaveBeenCalled();
66+
67+
breakpointManager.ngOnDestroy();
68+
69+
expect(firstTest).toHaveBeenCalled();
70+
expect(secondTest).toHaveBeenCalled();
71+
});
72+
73+
it('emits an event on the observable when values change', () => {
74+
let query = '(width: 999px)';
75+
let queryMatchState: boolean = false;
76+
breakpointManager.observe(query).subscribe((state: BreakpointState) => {
77+
queryMatchState = state.matches;
78+
});
79+
80+
async(() => {
81+
expect(queryMatchState).toBeTruthy();
82+
mediaMatcher.setMatchesQuery(query, false);
83+
expect(queryMatchState).toBeFalsy();
84+
});
85+
});
86+
87+
it('emits a true matches state when the query is matched', () => {
88+
let query = '(width: 999px)';
89+
mediaMatcher.setMatchesQuery(query, true);
90+
expect(breakpointManager.isMatched(query)).toBeTruthy();
91+
});
92+
93+
it('emits a false matches state when the query is not matched', () => {
94+
let query = '(width: 999px)';
95+
mediaMatcher.setMatchesQuery(query, false);
96+
expect(breakpointManager.isMatched(query)).toBeTruthy();
97+
});
98+
});
99+
100+
export class FakeMediaQueryList implements MediaQueryList {
101+
/** The callback for change events. */
102+
addListenerCallback?: (mql: MediaQueryList) => void;
103+
104+
constructor(public matches, public media) {}
105+
106+
/** Toggles the matches state and "emits" a change event. */
107+
setMatches(matches: boolean) {
108+
this.matches = matches;
109+
this.addListenerCallback!(this);
110+
}
111+
112+
/** Registers the callback method for change events. */
113+
addListener(callback: (mql: MediaQueryList) => void) {
114+
this.addListenerCallback = callback;
115+
}
116+
117+
/** Noop, but required for implementing MediaQueryList. */
118+
removeListener() {}
119+
}
120+
121+
@Injectable()
122+
export class FakeMediaMatcher {
123+
/** A map of match media queries. */
124+
private queries: Map<string, FakeMediaQueryList> = new Map();
125+
126+
/** The number of distinct queries created in the media matcher during a test. */
127+
get queryCount(): number {
128+
return this.queries.size;
129+
}
130+
131+
/** Fakes the match media response to be controlled in tests. */
132+
matchMedia(query: string): FakeMediaQueryList {
133+
let mql = new FakeMediaQueryList(true, query);
134+
this.queries.set(query, mql);
135+
return mql;
136+
}
137+
138+
/** Clears all queries from the map of queries. */
139+
clear() {
140+
this.queries.clear();
141+
}
142+
143+
/** Toggles the matching state of the provided query. */
144+
setMatchesQuery(query: string, matches: boolean) {
145+
if (this.queries.has(query)) {
146+
this.queries.get(query)!.setMatches(matches);
147+
}
148+
}
149+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {Injectable, NgZone, OnDestroy} from '@angular/core';
9+
import {MediaMatcher} from './media-matcher';
10+
import {Observable} from 'rxjs/Observable';
11+
import {Subject} from 'rxjs/Subject';
12+
import {RxChain, map, startWith, takeUntil} from '@angular/cdk/rxjs';
13+
import {coerceArray} from '@angular/cdk/coercion';
14+
import {combineLatest} from 'rxjs/observable/combineLatest';
15+
import {fromEventPattern} from 'rxjs/observable/fromEventPattern';
16+
17+
/** The current state of a layout breakpoint. */
18+
export interface BreakpointState {
19+
matches: boolean;
20+
}
21+
22+
interface Query {
23+
observable: Observable<BreakpointState>;
24+
mql: MediaQueryList;
25+
}
26+
27+
/**
28+
* Utility for checking the matching state of @media queries.
29+
*/
30+
@Injectable()
31+
export class BreakpointObserver implements OnDestroy {
32+
/** A map of all media queries currently being listened for. */
33+
private _queries: Map<string, Query> = new Map();
34+
/** A subject for all other observables to takeUntil based on. */
35+
private _destroySubject: Subject<{}> = new Subject();
36+
37+
constructor(private mediaMatcher: MediaMatcher, private zone: NgZone) {}
38+
39+
/** Completes the active subject, signalling to all other observables to complete. */
40+
ngOnDestroy() {
41+
this._destroySubject.next();
42+
this._destroySubject.complete();
43+
}
44+
45+
/** Whether the query currently is matched. */
46+
isMatched(value: string | string[]): boolean {
47+
let queries = coerceArray(value);
48+
return queries.some(mediaQuery => this._registerQuery(mediaQuery).mql.matches);
49+
}
50+
51+
/**
52+
* Gets an observable of results for the given queries that will emit new results for any changes
53+
* in matching of the given queries.
54+
*/
55+
observe(value: string | string[]): Observable<BreakpointState> {
56+
let queries = coerceArray(value);
57+
let observables = queries.map(query => this._registerQuery(query).observable);
58+
59+
return combineLatest(observables, (a: BreakpointState, b: BreakpointState) => {
60+
return {
61+
matches: !!((a && a.matches) || (b && b.matches)),
62+
};
63+
});
64+
}
65+
66+
/** Registers a specific query to be listened for. */
67+
private _registerQuery(query: string): Query {
68+
// Only set up a new MediaQueryList if it is not already being listened for.
69+
if (this._queries.has(query)) {
70+
return this._queries.get(query)!;
71+
}
72+
73+
let mql: MediaQueryList = this.mediaMatcher.matchMedia(query);
74+
// Create callback for match changes and add it is as a listener.
75+
let queryObservable = RxChain.from(fromEventPattern(
76+
// Listener callback methods are wrapped to be placed back in ngZone. Callbacks must be placed
77+
// back into the zone because matchMedia is only included in Zone.js by loading the
78+
// webapis-media-query.js file alongside the zone.js file. Additionally, some browsers do not
79+
// have MediaQueryList inherit from EventTarget, which causes inconsistencies in how Zone.js
80+
// patches it.
81+
(listener: MediaQueryListListener) => {
82+
mql.addListener((e: MediaQueryList) => this.zone.run(() => listener(e)));
83+
},
84+
(listener: MediaQueryListListener) => {
85+
mql.removeListener((e: MediaQueryList) => this.zone.run(() => listener(e)));
86+
}))
87+
.call(takeUntil, this._destroySubject)
88+
.call(startWith, mql)
89+
.call(map, (nextMql: MediaQueryList) => ({matches: nextMql.matches}))
90+
.result();
91+
92+
// Add the MediaQueryList to the set of queries.
93+
let output = {observable: queryObservable, mql: mql};
94+
this._queries.set(query, output);
95+
return output;
96+
}
97+
}

src/cdk/layout/breakpoints.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
// PascalCase is being used as Breakpoints is used like an enum.
9+
// tslint:disable-next-line:variable-name
10+
export const Breakpoints = {
11+
Handset: '(max-width: 599px) and (orientation: portrait), ' +
12+
'(max-width: 959px) and (orientation: landscape)',
13+
Tablet: '(min-width: 600px) and (max-width: 839px) and (orientation: portrait), ' +
14+
'(min-width: 960px) and (max-width: 1279px) and (orientation: landscape)',
15+
Web: '(min-width: 840px) and (orientation: portrait), ' +
16+
'(min-width: 1280px) and (orientation: landscape)',
17+
18+
HandsetPortrait: '(max-width: 599px) and (orientation: portrait)',
19+
TabletPortrait: '(min-width: 600px) and (max-width: 839px) and (orientation: portrait)',
20+
WebPortrait: '(min-width: 840px) and (orientation: portrait)',
21+
22+
HandsetLandscape: '(max-width: 959px) and (orientation: landscape)',
23+
TabletLandscape: '(min-width: 960px) and (max-width: 1279px) and (orientation: landscape)',
24+
WebLandscape: '(min-width: 1280px) and (orientation: landscape)',
25+
};

src/cdk/layout/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
export * from './public_api';

src/cdk/layout/media-matcher.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
### MediaMatcher
2+
3+
When including the CDK's `LayoutModule`, components can inject `MediaMatcher` to access the
4+
matchMedia method, if available on the platform.
5+
6+
#### Example
7+
```ts
8+
@Component({ ... })
9+
export class MyWidget {
10+
constructor(mm: MediaMatcher) {
11+
mm.matchMedia('(orientation: landscape)').matches ?
12+
this.setPortraitMode() :
13+
this.setPortraitMode();
14+
}
15+
}
16+
```
17+

0 commit comments

Comments
 (0)