Skip to content

Commit

Permalink
feat(core): implement MediaTrigger to allow manual breakpoint activat…
Browse files Browse the repository at this point in the history
…ions

Windows MatchMedia API announces breakpoint activations during viewport resizing.
For ome usage scenarios, support for manual activations is needed (without resizing).

* immediate rendering of layouts for specific (1..n) breakpoints
* for SSR
  • Loading branch information
ThomasBurleson committed Jan 15, 2019
1 parent 53a6ebb commit 3636b6c
Show file tree
Hide file tree
Showing 8 changed files with 300 additions and 42 deletions.
10 changes: 5 additions & 5 deletions src/lib/core/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
The `core` entrypoint contains all of the common utilities to build Layout
components. Its primary exports are the `MediaQuery` utilities (`MatchMedia`,
`MediaObserver`) and the module that encapsulates the imports of these
components. Its primary exports are the `MediaQuery` utility
`MediaObserver` and the module that encapsulates the imports of these
providers, the `CoreModule`, and the base directive for layout
components, `BaseDirective`. These utilies can be imported separately
components, `BaseDirective2`. These utilities can be imported separately
from the root module to take advantage of tree shaking.

```typescript
Expand All @@ -19,7 +19,7 @@ export class AppModule {}
```

```typescript
import {BaseDirective} from '@angular/flex-layout/core';
import {BaseDirective2} from '@angular/flex-layout/core';

export class NewLayoutDirective extends BaseDirective {}
export class NewLayoutDirective extends BaseDirective2 {}
```
22 changes: 11 additions & 11 deletions src/lib/core/match-media/match-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ import {MediaChange} from '../media-change';
*/
@Injectable({providedIn: 'root'})
export class MatchMedia {
/** Initialize with 'all' so all non-responsive APIs trigger style updates */
protected _source = new BehaviorSubject<MediaChange>(new MediaChange(true));

protected _registry = new Map<string, MediaQueryList>();
protected _observable$ = this._source.asObservable();
/** Initialize source with 'all' so all non-responsive APIs trigger style updates */
readonly source = new BehaviorSubject<MediaChange>(new MediaChange(true));
registry = new Map<string, MediaQueryList>();

constructor(protected _zone: NgZone,
@Inject(PLATFORM_ID) protected _platformId: Object,
Expand All @@ -37,7 +35,7 @@ export class MatchMedia {
*/
get activations(): string[] {
const results: string[] = [];
this._registry.forEach((mql: MediaQueryList, key: string) => {
this.registry.forEach((mql: MediaQueryList, key: string) => {
if (mql.matches) {
results.push(key);
}
Expand All @@ -49,7 +47,7 @@ export class MatchMedia {
* For the specified mediaQuery?
*/
isActive(mediaQuery: string): boolean {
const mql = this._registry.get(mediaQuery);
const mql = this.registry.get(mediaQuery);
return !!mql ? mql.matches : false;
}

Expand Down Expand Up @@ -86,7 +84,7 @@ export class MatchMedia {
matches.forEach((e: MediaChange) => {
observer.next(e);
});
this._source.next(lastChange); // last match is cached
this.source.next(lastChange); // last match is cached
}
observer.complete();
});
Expand All @@ -108,14 +106,14 @@ export class MatchMedia {

list.forEach((query: string) => {
const onMQLEvent = (e: MediaQueryListEvent) => {
this._zone.run(() => this._source.next(new MediaChange(e.matches, query)));
this._zone.run(() => this.source.next(new MediaChange(e.matches, query)));
};

let mql = this._registry.get(query);
let mql = this.registry.get(query);
if (!mql) {
mql = this.buildMQL(query);
mql.addListener(onMQLEvent);
this._registry.set(query, mql);
this.registry.set(query, mql);
}

if (mql.matches) {
Expand All @@ -133,6 +131,8 @@ export class MatchMedia {
protected buildMQL(query: string): MediaQueryList {
return constructMql(query, isPlatformBrowser(this._platformId));
}

protected _observable$ = this.source.asObservable();
}

/**
Expand Down
35 changes: 13 additions & 22 deletions src/lib/core/match-media/mock/mock-match-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,9 @@ import {BreakPointRegistry} from '../../breakpoints/break-point-registry';
@Injectable()
export class MockMatchMedia extends MatchMedia {

/** Special flag used to test BreakPoint registrations with MatchMedia */
autoRegisterQueries = true;

/**
* Allow fallback to overlapping mediaQueries to determine
* activatedInput(s).
*/
useOverlaps = false;

protected _registry: Map<string, MockMediaQueryList> = new Map();
autoRegisterQueries = true; // Used for testing BreakPoint registrations
useOverlaps = false; // Allow fallback to overlapping mediaQueries

constructor(_zone: NgZone,
@Inject(PLATFORM_ID) _platformId: Object,
Expand All @@ -39,10 +32,10 @@ export class MockMatchMedia extends MatchMedia {

/** Easy method to clear all listeners for all mediaQueries */
clearAll() {
this._registry.forEach((mql: MockMediaQueryList) => {
mql.destroy();
this.registry.forEach((mql: MediaQueryList) => {
(mql as MockMediaQueryList).destroy();
});
this._registry.clear();
this.registry.clear();
this.useOverlaps = false;
}

Expand Down Expand Up @@ -127,26 +120,25 @@ export class MockMatchMedia extends MatchMedia {
*
*/
private _activateByQuery(mediaQuery: string) {
const mql = this._registry.get(mediaQuery);
const alreadyAdded = this._actives
.reduce((found, it) => (found || (mql ? (it.media === mql.media) : false)), false);
const mql: MockMediaQueryList = this.registry.get(mediaQuery) as MockMediaQueryList;

if (mql && !alreadyAdded) {
this._actives.push(mql.activate());
if (mql && !this.isActive(mediaQuery)) {
this.registry.set(mediaQuery, mql.activate());
}
return this.hasActivated;
}

/** Deactivate all current MQLs and reset the buffer */
private _deactivateAll() {
this._actives.forEach(it => it.deactivate());
this._actives = [];
this.registry.forEach((it: MediaQueryList) => {
(it as MockMediaQueryList).deactivate();
});
return this;
}

/** Insure the mediaQuery is registered with MatchMedia */
private _registerMediaQuery(mediaQuery: string) {
if (!this._registry.has(mediaQuery) && this.autoRegisterQueries) {
if (!this.registry.has(mediaQuery) && this.autoRegisterQueries) {
this.registerQuery(mediaQuery);
}
}
Expand All @@ -160,10 +152,9 @@ export class MockMatchMedia extends MatchMedia {
}

protected get hasActivated() {
return this._actives.length > 0;
return this.activations.length > 0;
}

private _actives: MockMediaQueryList[] = [];
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/lib/core/media-trigger/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright Google LLC 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 './media-trigger';
76 changes: 76 additions & 0 deletions src/lib/core/media-trigger/media-trigger.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @license
* Copyright Google LLC 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 {TestBed, inject, fakeAsync, tick} from '@angular/core/testing';

import {MediaTrigger} from './media-trigger';
import {MediaChange} from '../media-change';
import {MatchMedia} from '../match-media/match-media';
import {MockMatchMedia, MockMatchMediaProvider} from '../match-media/mock/mock-match-media';
import {MediaObserver} from '../media-observer/media-observer';

describe('media-trigger', () => {
let mediaObserver: MediaObserver;
let mediaTrigger: MediaTrigger;
let matchMedia: MockMatchMedia;

const activateQuery = (aliases: string[]) => {
mediaTrigger.activate(aliases);
tick(100); // Since MediaObserver has 50ms debounceTime
};

describe('', () => {
beforeEach(() => {
// Configure testbed to prepare services
TestBed.configureTestingModule({
providers: [
MockMatchMediaProvider,
MediaTrigger
]
});
});

beforeEach(inject([MediaObserver, MediaTrigger, MatchMedia],
(_mediaObserver: MediaObserver, _mediaTrigger: MediaTrigger, _matchMedia: MockMatchMedia) => { // tslint:disable-line:max-line-length
mediaObserver = _mediaObserver;
mediaTrigger = _mediaTrigger;
matchMedia = _matchMedia;

_matchMedia.useOverlaps = true;
}));

it('can trigger activations with list of breakpoint aliases', fakeAsync(() => {
let activations: MediaChange[] = [];
let subscription = mediaObserver.asObservable().subscribe(
(changes: MediaChange[]) => {
activations = [...changes];
});

// assign default activation(s) with overlaps allowed
matchMedia.activate('xl');
const originalActivations = matchMedia.activations.length;

// Activate mediaQuery associated with 'md' alias
activateQuery(['sm']);
expect(activations.length).toEqual(1);
expect(activations[0].mqAlias).toEqual('sm');

// Activations are sorted by descending priority
activateQuery(['lt-lg', 'md']);
expect(activations.length).toEqual(2);
expect(activations[0].mqAlias).toEqual('md');
expect(activations[1].mqAlias).toEqual('lt-lg');

// Clean manual activation overrides
mediaTrigger.restore();
tick(100);
expect(activations.length).toEqual(originalActivations);

subscription.unsubscribe();
}));
});
});
Loading

0 comments on commit 3636b6c

Please sign in to comment.