Skip to content

feat(breakpoints): Create breakpoint management system #6772

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 29, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of these tests cover the case when mediaMatcher doesn't match; we could add a method to the fake like setNoMatch(...)

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.setPortraitMode();
}
}
```

Loading