Skip to content

Commit 9029b8f

Browse files
committed
Create screen type service to determine screen type: Web, Tablet, Handset
1 parent 1b6b270 commit 9029b8f

23 files changed

+600
-7
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+
fdescribe('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 `BreakpointsModule`, components can inject `BreakpointsManager` 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: BreakpointManager) {
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: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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(inject([MediaMatcher], (_mediaMatcher: FakeMediaMatcher) => {
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.clear();
83+
expect(queryMatchState).toBeFalsy();
84+
});
85+
});
86+
});
87+
88+
export class FakeMediaQueryList implements MediaQueryList {
89+
/** The callback for change events. */
90+
addListenerCallback?: (mql: MediaQueryList) => void;
91+
92+
constructor(public matches, public media) {}
93+
94+
/** Toggles the matches state and "emits" a change event. */
95+
toggle() {
96+
this.matches = !this.matches;
97+
this.addListenerCallback!(this);
98+
}
99+
100+
/** Registers the callback method for change events. */
101+
addListener(callback: (mql: MediaQueryList) => void) {
102+
this.addListenerCallback = callback;
103+
}
104+
105+
/** Noop, but required for implementing MediaQueryList. */
106+
removeListener() {}
107+
}
108+
109+
@Injectable()
110+
export class FakeMediaMatcher {
111+
/** A map of match media queries. */
112+
private queries: Map<string, FakeMediaQueryList> = new Map();
113+
114+
/** The number of distinct queries created in the media matcher during a test. */
115+
get queryCount(): number {
116+
return this.queries.size;
117+
}
118+
119+
/** Fakes the match media response to be controlled in tests. */
120+
matchMedia(query: string): FakeMediaQueryList {
121+
let mql = new FakeMediaQueryList(true, query);
122+
this.queries.set(query, mql);
123+
return mql;
124+
}
125+
126+
/** Clears all queries from the map of queries. */
127+
clear() {
128+
this.queries.clear();
129+
}
130+
131+
/** Toggles the matching state of the provided query. */
132+
toggleQuery(query: string) {
133+
if (this.queries.has(query)) {
134+
this.queries.get(query)!.toggle();
135+
}
136+
}
137+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
export type BreakpointQuery = string | string[];
18+
19+
export interface BreakpointState {
20+
query: string;
21+
matches: boolean;
22+
}
23+
24+
interface Query {
25+
observable: Observable<BreakpointState>;
26+
mql: MediaQueryList;
27+
}
28+
29+
@Injectable()
30+
export class BreakpointObserver implements OnDestroy {
31+
// A map of all media queries currently being listened for.
32+
private _queries: Map<string, Query> = new Map();
33+
// A subject for all other observables to takeUntil based on.
34+
private _activeSubject: Subject<{}> = new Subject();
35+
36+
constructor(private mediaMatcher: MediaMatcher, private zone: NgZone) {}
37+
38+
/** Completes the active subject, signalling to all other observables to complete. */
39+
ngOnDestroy() {
40+
this._activeSubject.next();
41+
this._activeSubject.complete();
42+
}
43+
44+
/**
45+
* Whether the query currently is matched.
46+
*/
47+
isMatched(value: BreakpointQuery): boolean {
48+
let queries = coerceArray(value);
49+
return queries.some((mediaQuery: string) => {
50+
return this._registerQuery(mediaQuery).mql.matches;
51+
});
52+
}
53+
54+
/**
55+
* Retrieves an observable for the BreakpointQuery, broadcasting changes to the matching state.
56+
*/
57+
observe(value: BreakpointQuery): Observable<BreakpointState> {
58+
let queries = coerceArray(value);
59+
let observables = queries.map(query => this._registerQuery(query).observable);
60+
61+
return combineLatest(observables, (a: BreakpointState, b: BreakpointState) => {
62+
return <BreakpointState>{
63+
matches: (a && a.matches) || (b && b.matches) || false,
64+
query: queries.join(', ')
65+
};
66+
});
67+
}
68+
69+
/**
70+
* Registers a specific query to be listened for.
71+
*/
72+
private _registerQuery(query: string): Query {
73+
// Only set up a new MediaQueryList if it is not already being listened for.
74+
if (this._queries.has(query)) {
75+
return this._queries.get(query)!;
76+
}
77+
// Create new MediaQueryList.
78+
let mql: MediaQueryList = this.mediaMatcher.matchMedia(query);
79+
// Create callback for match changes and add it is as a listener.
80+
let queryObservable = RxChain.from(fromEventPattern(
81+
// Listener callback methods are wrapped to be placed back in ngZone.
82+
(listener: MediaQueryListListener) => {
83+
mql.addListener((e: MediaQueryList) => this.zone.run(() => listener(e)));
84+
},
85+
(listener: MediaQueryListListener) => {
86+
mql.removeListener((e: MediaQueryList) => this.zone.run(() => listener(e)));
87+
}))
88+
.call(takeUntil, this._activeSubject)
89+
.call(startWith, mql)
90+
.call(map, (nextMql: MediaQueryList) => {
91+
return <BreakpointState>{query: query, matches: nextMql.matches};
92+
})
93+
.result();
94+
95+
// Add the MediaQueryList to the set of queries.
96+
let output = <Query>{observable: queryObservable, mql: mql};
97+
this._queries.set(query, output);
98+
return output;
99+
}
100+
}

src/cdk/layout/breakpoints.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
// tslint:disable-next-line:variable-name
9+
export const Breakpoints = {
10+
Handset: '(max-width: 599px) and (orientation: portrait), ' +
11+
'(max-width: 959px) and (orientation: landscape)',
12+
Tablet: '(min-width: 600px) and (max-width: 839px) and (orientation: portrait), ' +
13+
'(min-width: 960px) and (max-width: 1279px) and (orientation: landscape)',
14+
Web: '(min-width: 840px) and (orientation: portrait), ' +
15+
'(min-width: 1280px) and (orientation: landscape)',
16+
17+
HandsetPortrait: '(max-width: 599px) and (orientation: portrait)',
18+
TabletPortrait: '(min-width: 600px) and (max-width: 839px) and (orientation: portrait)',
19+
WebPortrait: '(min-width: 840px) and (orientation: portrait)',
20+
21+
HandsetLandscape: '(max-width: 959px) and (orientation: landscape)',
22+
TabletLandscape: '(min-width: 960px) and (max-width: 1279px) and (orientation: landscape)',
23+
WebLandscape: '(min-width: 1280px) and (orientation: landscape)',
24+
};

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 `BreakpointsModule`, components can inject `MediaMatcher` to make matchMedia
4+
directly 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)