Create screen type service to determine screen type: Web, Tablet, Handset (#6772)

This commit is contained in:
Joey Perrott
2017-09-29 13:24:14 -07:00
committed by Andrew Seguin
parent f43bf23804
commit 811e50451b
23 changed files with 622 additions and 2 deletions
+35
View File
@@ -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
View File
@@ -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
View File
@@ -8,3 +8,4 @@
export * from './boolean-property';
export * from './number-property';
export * from './array';
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
```
+47
View File
@@ -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);
}
}));
});
+77
View File
@@ -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<string, HTMLStyleElement> = 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: () => {}
};
}
+21
View File
@@ -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';
+13
View File
@@ -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
}
}
-1
View File
@@ -32,4 +32,3 @@ export class DemoAppModule {
this._appRef.bootstrap(EntryApp);
}
}
+1
View File
@@ -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'},
+4
View File
@@ -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,
+4
View File
@@ -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},
]}
];
+1 -1
View File
@@ -22,7 +22,7 @@
<script src="node_modules/systemjs/dist/system.src.js"></script>
<script src="node_modules/zone.js/dist/zone.js"></script>
<script src="node_modules/hammerjs/hammer.min.js"></script>
<script>
System.import('system-config.js').then(function () {
System.import('main');
@@ -0,0 +1,29 @@
<h2>Screen Type</h2>
<md-grid-list cols="6" rowHeight="1:2">
<md-grid-tile [class.active]="(isHandset | async)?.matches"
colspan="2">
<md-icon>smartphone</md-icon>
<p>Handset</p>
</md-grid-tile>
<md-grid-tile [class.active]="(isTablet | async)?.matches"
colspan="2">
<md-icon>tablet_android</md-icon>
<p>Tablet</p>
</md-grid-tile>
<md-grid-tile [class.active]="(isWeb | async)?.matches"
colspan="2">
<md-icon>laptop</md-icon>
<p>Web</p>
</md-grid-tile>
<md-grid-tile [class.active]="(isPortrait | async)?.matches"
colspan="3">
<md-icon>stay_current_portrait</md-icon>
<p>Portrait</p>
</md-grid-tile>
<md-grid-tile [class.active]="(isLandscape | async)?.matches"
colspan="3">
<md-icon>stay_current_landscape</md-icon>
<p>Landscape</p>
</md-grid-tile>
</md-grid-list>
@@ -0,0 +1,24 @@
.mat-grid-list {
max-width: 700px;
margin: 0 auto;
min-width: 400px;
}
.mat-grid-tile {
outline: 1px solid lightgray;
.mat-figure {
flex-direction: column;
}
&.active {
background: rgba(0, 0, 0, 0.12);
}
}
h2 {
max-width: 700px;
min-width: 400px;
text-align: center;
margin: 15px auto;
}
@@ -0,0 +1,27 @@
import {Component, ViewEncapsulation} from '@angular/core';
import {BreakpointObserver, BreakpointState, Breakpoints} from '@angular/cdk/layout';
import {Observable} from 'rxjs/Observable';
@Component({
moduleId: module.id,
selector: 'screen-type',
templateUrl: 'screen-type-demo.html',
styleUrls: ['screen-type-demo.css'],
encapsulation: ViewEncapsulation.None,
})
export class ScreenTypeDemo {
isHandset: Observable<BreakpointState>;
isTablet: Observable<BreakpointState>;
isWeb: Observable<BreakpointState>;
isPortrait: Observable<BreakpointState>;
isLandscape: Observable<BreakpointState>;
constructor(private mqm: BreakpointObserver) {
this.isHandset = this.mqm.observe([Breakpoints.HandsetLandscape,
Breakpoints.HandsetPortrait]);
this.isTablet = this.mqm.observe(Breakpoints.Tablet);
this.isWeb = this.mqm.observe([Breakpoints.WebLandscape, Breakpoints.WebPortrait]);
this.isPortrait = this.mqm.observe('(orientation: portrait)');
this.isLandscape = this.mqm.observe('(orientation: landscape)');
}
}
+1
View File
@@ -35,6 +35,7 @@ System.config({
'@angular/cdk/coercion': 'dist/packages/cdk/coercion/index.js',
'@angular/cdk/collections': 'dist/packages/cdk/collections/index.js',
'@angular/cdk/keycodes': 'dist/packages/cdk/keycodes/index.js',
'@angular/cdk/layout': 'dist/bundles/cdk-layout.umd.js',
'@angular/cdk/observers': 'dist/packages/cdk/observers/index.js',
'@angular/cdk/overlay': 'dist/packages/cdk/overlay/index.js',
'@angular/cdk/platform': 'dist/packages/cdk/platform/index.js',
+3
View File
@@ -56,10 +56,12 @@ export const rollupGlobals = {
'rxjs/Subject': 'Rx',
'rxjs/Subscription': 'Rx',
'rxjs/Observer': 'Rx',
'rxjs/Subscriber': 'Rx',
'rxjs/Scheduler': 'Rx',
'rxjs/observable/combineLatest': 'Rx.Observable',
'rxjs/observable/forkJoin': 'Rx.Observable',
'rxjs/observable/fromEvent': 'Rx.Observable',
'rxjs/observable/fromEventPattern': 'Rx.Observable',
'rxjs/observable/merge': 'Rx.Observable',
'rxjs/observable/of': 'Rx.Observable',
'rxjs/observable/throw': 'Rx.Observable',
@@ -83,6 +85,7 @@ export const rollupGlobals = {
'rxjs/add/observable/fromEvent': 'Rx.Observable',
'rxjs/add/observable/of': 'Rx.Observable',
'rxjs/add/observable/interval': 'Rx.Observable',
'rxjs/add/observable/combineLatest': 'Rx.Observable',
'rxjs/add/operator/startWith': 'Rx.Observable.prototype',
'rxjs/add/operator/map': 'Rx.Observable.prototype',
'rxjs/add/operator/debounceTime': 'Rx.Observable.prototype',