-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
|
||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,3 +8,4 @@ | |
|
||
export * from './boolean-property'; | ||
export * from './number-property'; | ||
export * from './array'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
}); | ||
} | ||
} | ||
``` | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)', | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
``` | ||
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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(...)