Skip to content
This repository was archived by the owner on Jan 6, 2025. It is now read-only.

Commit 66e7463

Browse files
feat(core): implement MediaTrigger to allow manual breakpoint activations (#997)
The Windows MatchMedia API announces breakpoint activations during viewport resizing. For some scenarios, developers need support for manual activations (without resizing). Such features are useful for: * for SSR * for designer environments > for immediate rendering of layouts for specific (1..n) breakpoints
1 parent 53a6ebb commit 66e7463

File tree

9 files changed

+329
-47
lines changed

9 files changed

+329
-47
lines changed

src/lib/core/README.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
The `core` entrypoint contains all of the common utilities to build Layout
2-
components. Its primary exports are the `MediaQuery` utilities (`MatchMedia`,
3-
`MediaObserver`) and the module that encapsulates the imports of these
2+
components. Its primary exports are the `MediaQuery` utility
3+
`MediaObserver` and the module that encapsulates the imports of these
44
providers, the `CoreModule`, and the base directive for layout
5-
components, `BaseDirective`. These utilies can be imported separately
5+
components, `BaseDirective2`. These utilities can be imported separately
66
from the root module to take advantage of tree shaking.
77

88
```typescript
@@ -19,7 +19,7 @@ export class AppModule {}
1919
```
2020

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

24-
export class NewLayoutDirective extends BaseDirective {}
24+
export class NewLayoutDirective extends BaseDirective2 {}
2525
```

src/lib/core/match-media/match-media.ts

+11-11
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@ import {MediaChange} from '../media-change';
2121
*/
2222
@Injectable({providedIn: 'root'})
2323
export class MatchMedia {
24-
/** Initialize with 'all' so all non-responsive APIs trigger style updates */
25-
protected _source = new BehaviorSubject<MediaChange>(new MediaChange(true));
26-
27-
protected _registry = new Map<string, MediaQueryList>();
28-
protected _observable$ = this._source.asObservable();
24+
/** Initialize source with 'all' so all non-responsive APIs trigger style updates */
25+
readonly source = new BehaviorSubject<MediaChange>(new MediaChange(true));
26+
registry = new Map<string, MediaQueryList>();
2927

3028
constructor(protected _zone: NgZone,
3129
@Inject(PLATFORM_ID) protected _platformId: Object,
@@ -37,7 +35,7 @@ export class MatchMedia {
3735
*/
3836
get activations(): string[] {
3937
const results: string[] = [];
40-
this._registry.forEach((mql: MediaQueryList, key: string) => {
38+
this.registry.forEach((mql: MediaQueryList, key: string) => {
4139
if (mql.matches) {
4240
results.push(key);
4341
}
@@ -49,7 +47,7 @@ export class MatchMedia {
4947
* For the specified mediaQuery?
5048
*/
5149
isActive(mediaQuery: string): boolean {
52-
const mql = this._registry.get(mediaQuery);
50+
const mql = this.registry.get(mediaQuery);
5351
return !!mql ? mql.matches : false;
5452
}
5553

@@ -86,7 +84,7 @@ export class MatchMedia {
8684
matches.forEach((e: MediaChange) => {
8785
observer.next(e);
8886
});
89-
this._source.next(lastChange); // last match is cached
87+
this.source.next(lastChange); // last match is cached
9088
}
9189
observer.complete();
9290
});
@@ -108,14 +106,14 @@ export class MatchMedia {
108106

109107
list.forEach((query: string) => {
110108
const onMQLEvent = (e: MediaQueryListEvent) => {
111-
this._zone.run(() => this._source.next(new MediaChange(e.matches, query)));
109+
this._zone.run(() => this.source.next(new MediaChange(e.matches, query)));
112110
};
113111

114-
let mql = this._registry.get(query);
112+
let mql = this.registry.get(query);
115113
if (!mql) {
116114
mql = this.buildMQL(query);
117115
mql.addListener(onMQLEvent);
118-
this._registry.set(query, mql);
116+
this.registry.set(query, mql);
119117
}
120118

121119
if (mql.matches) {
@@ -133,6 +131,8 @@ export class MatchMedia {
133131
protected buildMQL(query: string): MediaQueryList {
134132
return constructMql(query, isPlatformBrowser(this._platformId));
135133
}
134+
135+
protected _observable$ = this.source.asObservable();
136136
}
137137

138138
/**

src/lib/core/match-media/mock/mock-match-media.ts

+13-22
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,9 @@ import {BreakPointRegistry} from '../../breakpoints/break-point-registry';
1919
@Injectable()
2020
export class MockMatchMedia extends MatchMedia {
2121

22-
/** Special flag used to test BreakPoint registrations with MatchMedia */
23-
autoRegisterQueries = true;
2422

25-
/**
26-
* Allow fallback to overlapping mediaQueries to determine
27-
* activatedInput(s).
28-
*/
29-
useOverlaps = false;
30-
31-
protected _registry: Map<string, MockMediaQueryList> = new Map();
23+
autoRegisterQueries = true; // Used for testing BreakPoint registrations
24+
useOverlaps = false; // Allow fallback to overlapping mediaQueries
3225

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

4033
/** Easy method to clear all listeners for all mediaQueries */
4134
clearAll() {
42-
this._registry.forEach((mql: MockMediaQueryList) => {
43-
mql.destroy();
35+
this.registry.forEach((mql: MediaQueryList) => {
36+
(mql as MockMediaQueryList).destroy();
4437
});
45-
this._registry.clear();
38+
this.registry.clear();
4639
this.useOverlaps = false;
4740
}
4841

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

134-
if (mql && !alreadyAdded) {
135-
this._actives.push(mql.activate());
125+
if (mql && !this.isActive(mediaQuery)) {
126+
this.registry.set(mediaQuery, mql.activate());
136127
}
137128
return this.hasActivated;
138129
}
139130

140131
/** Deactivate all current MQLs and reset the buffer */
141132
private _deactivateAll() {
142-
this._actives.forEach(it => it.deactivate());
143-
this._actives = [];
133+
this.registry.forEach((it: MediaQueryList) => {
134+
(it as MockMediaQueryList).deactivate();
135+
});
144136
return this;
145137
}
146138

147139
/** Insure the mediaQuery is registered with MatchMedia */
148140
private _registerMediaQuery(mediaQuery: string) {
149-
if (!this._registry.has(mediaQuery) && this.autoRegisterQueries) {
141+
if (!this.registry.has(mediaQuery) && this.autoRegisterQueries) {
150142
this.registerQuery(mediaQuery);
151143
}
152144
}
@@ -160,10 +152,9 @@ export class MockMatchMedia extends MatchMedia {
160152
}
161153

162154
protected get hasActivated() {
163-
return this._actives.length > 0;
155+
return this.activations.length > 0;
164156
}
165157

166-
private _actives: MockMediaQueryList[] = [];
167158
}
168159

169160
/**

src/lib/core/media-trigger/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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+
export * from './media-trigger';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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 {TestBed, inject, fakeAsync, tick} from '@angular/core/testing';
9+
10+
import {MediaTrigger} from './media-trigger';
11+
import {MediaChange} from '../media-change';
12+
import {MatchMedia} from '../match-media/match-media';
13+
import {MockMatchMedia, MockMatchMediaProvider} from '../match-media/mock/mock-match-media';
14+
import {MediaObserver} from '../media-observer/media-observer';
15+
16+
describe('media-trigger', () => {
17+
let mediaObserver: MediaObserver;
18+
let mediaTrigger: MediaTrigger;
19+
let matchMedia: MockMatchMedia;
20+
21+
const activateQuery = (aliases: string[]) => {
22+
mediaTrigger.activate(aliases);
23+
tick(100); // Since MediaObserver has 50ms debounceTime
24+
};
25+
26+
describe('', () => {
27+
beforeEach(() => {
28+
// Configure testbed to prepare services
29+
TestBed.configureTestingModule({
30+
providers: [
31+
MockMatchMediaProvider,
32+
MediaTrigger
33+
]
34+
});
35+
});
36+
37+
beforeEach(inject([MediaObserver, MediaTrigger, MatchMedia],
38+
(_mediaObserver: MediaObserver, _mediaTrigger: MediaTrigger, _matchMedia: MockMatchMedia) => { // tslint:disable-line:max-line-length
39+
mediaObserver = _mediaObserver;
40+
mediaTrigger = _mediaTrigger;
41+
matchMedia = _matchMedia;
42+
43+
_matchMedia.useOverlaps = true;
44+
}));
45+
46+
it('can trigger activations with list of breakpoint aliases', fakeAsync(() => {
47+
let activations: MediaChange[] = [];
48+
let subscription = mediaObserver.asObservable().subscribe(
49+
(changes: MediaChange[]) => {
50+
activations = [...changes];
51+
});
52+
53+
// assign default activation(s) with overlaps allowed
54+
matchMedia.activate('xl');
55+
const originalActivations = matchMedia.activations.length;
56+
57+
// Activate mediaQuery associated with 'md' alias
58+
activateQuery(['sm']);
59+
expect(activations.length).toEqual(1);
60+
expect(activations[0].mqAlias).toEqual('sm');
61+
62+
// Activations are sorted by descending priority
63+
activateQuery(['lt-lg', 'md']);
64+
expect(activations.length).toEqual(2);
65+
expect(activations[0].mqAlias).toEqual('md');
66+
expect(activations[1].mqAlias).toEqual('lt-lg');
67+
68+
// Clean manual activation overrides
69+
mediaTrigger.restore();
70+
tick(100);
71+
expect(activations.length).toEqual(originalActivations);
72+
73+
subscription.unsubscribe();
74+
}));
75+
});
76+
});

0 commit comments

Comments
 (0)