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

Commit 314bc9b

Browse files
fix(ObservableMedia): startup should propagate lastReplay value properly
ObservableMedia only dispatches notifications for activated, non-overlapping breakpoints. If the MatchMedia lastReplay value is an *overlapping* breakpoint (e.g. `lt-md`, `gt-lg`) then that value will be filtered by ObservableMedia and not be emitted to subscribers. * MatchMedia breakpoints registration was not correct * overlapping breakpoints were registered in the wrong order * non-overlapping breakpoints should be registered last; so the BehaviorSubject's last replay value should be an non-overlapping breakpoint range. * Optimize stylesheet injection to group `n` mediaQuerys in a single stylesheet > See working plunker: https://plnkr.co/edit/yylQr2IdbGy2Yr00srrN?p=preview Fixes #245, #275, #303
1 parent aae1deb commit 314bc9b

File tree

7 files changed

+99
-56
lines changed

7 files changed

+99
-56
lines changed

src/demo-app/app/shared/media-query-status.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ export class MediaQueryStatus implements OnDestroy {
3131
private _watcher : Subscription;
3232
activeMediaQuery : string;
3333

34-
constructor(media$ : ObservableMedia) { this.watchMediaQueries(media$); }
34+
constructor(media$ : ObservableMedia) {
35+
this.watchMediaQueries(media$);
36+
}
3537

3638
ngOnDestroy() {
3739
this._watcher.unsubscribe();

src/lib/media-query/breakpoints/break-point-registry.ts

+14
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ export class BreakPointRegistry {
2929
return [...this._registry];
3030
}
3131

32+
/**
33+
* Accessor to sorted list used for
34+
* registration with matchMedia API
35+
*
36+
* NOTE: During breakpoint registration, we want to register the overlaps FIRST
37+
* so the non-overlaps will trigger the MatchMedia:BehaviorSubject last!
38+
* And the largest, non-overlap, matching breakpoint should be the lastReplay value
39+
*/
40+
get sortedItems(): BreakPoint[ ] {
41+
let overlaps = this._registry.filter(it=>it.overlapping === true);
42+
let nonOverlaps = this._registry.filter(it=>it.overlapping !== true);
43+
44+
return [...overlaps, ...nonOverlaps];
45+
}
3246
/**
3347
* Search breakpoints by alias (e.g. gt-xs)
3448
*/

src/lib/media-query/match-media.spec.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,12 @@ describe('match-media', () => {
8686
});
8787

8888

89-
it('can observe only a specific custom mediaQuery ranges', () => {
89+
it('can observe an array of custom mediaQuery ranges', () => {
9090
let current: MediaChange, activated;
9191
let query1 = "screen and (min-width: 610px) and (max-width: 620px)";
9292
let query2 = "(min-width: 730px) and (max-width: 950px)";
9393

94-
matchMedia.registerQuery(query1);
95-
matchMedia.registerQuery(query2);
94+
matchMedia.registerQuery([query1,query2]);
9695

9796
let subscription = matchMedia.observe(query1).subscribe((change: MediaChange) => {
9897
current = change;

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

+63-36
Original file line numberDiff line numberDiff line change
@@ -76,56 +76,59 @@ export class MatchMedia {
7676
observe(mediaQuery?: string): Observable<MediaChange> {
7777
this.registerQuery(mediaQuery);
7878

79-
return this._observable$.filter((change: MediaChange) => {
80-
return mediaQuery ? (change.mediaQuery === mediaQuery) : true;
81-
});
79+
return this._observable$
80+
.filter((change: MediaChange) => {
81+
return mediaQuery ? (change.mediaQuery === mediaQuery) : true;
82+
});
8283
}
8384

8485
/**
8586
* Based on the BreakPointRegistry provider, register internal listeners for each unique
8687
* mediaQuery. Each listener emits specific MediaChange data to observers
8788
*/
88-
registerQuery(mediaQuery: string) {
89-
if (mediaQuery) {
90-
let mql = this._registry.get(mediaQuery);
91-
let onMQLEvent = (e: MediaQueryList) => {
92-
this._zone.run(() => {
93-
let change = new MediaChange(e.matches, mediaQuery);
94-
this._source.next(change);
95-
});
96-
};
89+
registerQuery(mediaQuery: string | Array<string>) {
90+
let list = normalizeQuery(mediaQuery);
91+
92+
if (list.length > 0) {
93+
prepareQueryCSS(list);
94+
95+
list.forEach(query => {
96+
let mql = this._registry.get(query);
97+
let onMQLEvent = (e: MediaQueryList) => {
98+
this._zone.run(() => {
99+
let change = new MediaChange(e.matches, query);
100+
this._source.next(change);
101+
});
102+
};
97103

98-
if (!mql) {
99-
mql = this._buildMQL(mediaQuery);
100-
mql.addListener(onMQLEvent);
101-
this._registry.set(mediaQuery, mql);
102-
}
104+
if (!mql) {
105+
mql = this._buildMQL(query);
106+
mql.addListener(onMQLEvent);
107+
this._registry.set(query, mql);
108+
}
103109

104-
if (mql.matches) {
105-
onMQLEvent(mql); // Announce activate range for initial subscribers
106-
}
110+
if (mql.matches) {
111+
onMQLEvent(mql); // Announce activate range for initial subscribers
112+
}
113+
});
107114
}
108-
109115
}
110116

111117
/**
112118
* Call window.matchMedia() to build a MediaQueryList; which
113119
* supports 0..n listeners for activation/deactivation
114120
*/
115121
protected _buildMQL(query: string): MediaQueryList {
116-
prepareQueryCSS(query);
117-
118122
let canListen = !!(<any>window).matchMedia('all').addListener;
119123
return canListen ? (<any>window).matchMedia(query) : <MediaQueryList>{
120-
matches: query === 'all' || query === '',
121-
media: query,
122-
addListener: () => {
123-
},
124-
removeListener: () => {
125-
}
126-
};
124+
matches: query === 'all' || query === '',
125+
media: query,
126+
addListener: () => {
127+
},
128+
removeListener: () => {
129+
}
130+
};
127131
}
128-
129132
}
130133

131134
/**
@@ -135,31 +138,55 @@ export class MatchMedia {
135138
const ALL_STYLES = {};
136139

137140
/**
138-
* For Webkit engines that only trigger the MediaQueryListListener
141+
* For Webkit engines that only trigger the MediaQueryList Listener
139142
* when there is at least one CSS selector for the respective media query.
140143
*
141144
* @param query string The mediaQuery used to create a faux CSS selector
142145
*
143146
*/
144-
function prepareQueryCSS(query) {
145-
if (!ALL_STYLES[query]) {
147+
function prepareQueryCSS(mediaQueries: Array<string>) {
148+
let list = mediaQueries.filter(it => !ALL_STYLES[it]);
149+
if (list.length > 0) {
150+
let query = list.join(", ");
146151
try {
147152
let style = document.createElement('style');
148153

149154
style.setAttribute('type', 'text/css');
150155
if (!style['styleSheet']) {
151-
let cssText = `@media ${query} {.fx-query-test{ }}`;
156+
let cssText = `/*
157+
@angular/flex-layout - workaround for possible browser quirk with mediaQuery listeners
158+
see http://bit.ly/2sd4HMP
159+
*/
160+
@media ${query} {.fx-query-test{ }}`;
152161
style.appendChild(document.createTextNode(cssText));
153162
}
154163

155164
document.getElementsByTagName('head')[0].appendChild(style);
156165

157166
// Store in private global registry
158-
ALL_STYLES[query] = style;
167+
list.forEach(mq => ALL_STYLES[mq] = style);
159168

160169
} catch (e) {
161170
console.error(e);
162171
}
163172
}
164173
}
165174

175+
/**
176+
* Always convert to unique list of queries; for iteration in ::registerQuery()
177+
*/
178+
function normalizeQuery(mediaQuery: string | Array<string>): Array<string> {
179+
return (typeof mediaQuery === 'undefined') ? [] :
180+
(typeof mediaQuery === 'string') ? [mediaQuery] : unique(mediaQuery as Array<string>);
181+
}
182+
183+
/**
184+
* Filter duplicate mediaQueries in the list
185+
*/
186+
function unique(list: Array<string>): Array<string> {
187+
let seen = {};
188+
return list.filter(item => {
189+
return seen.hasOwnProperty(item) ? false : (seen[item] = true);
190+
});
191+
}
192+

src/lib/media-query/media-monitor.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,7 @@ export class MediaMonitor {
9292
* and prepare for immediate subscription notifications
9393
*/
9494
private _registerBreakpoints() {
95-
this._breakpoints.items.forEach(bp => {
96-
this._matchMedia.registerQuery(bp.mediaQuery);
97-
});
95+
let queries = this._breakpoints.sortedItems.map(bp => bp.mediaQuery);
96+
this._matchMedia.registerQuery(queries);
9897
}
9998
}

src/lib/media-query/observable-media-provider.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {ObservableMedia, MediaService} from './observable-media';
2424
export function OBSERVABLE_MEDIA_PROVIDER_FACTORY(parentService: ObservableMedia,
2525
matchMedia: MatchMedia,
2626
breakpoints: BreakPointRegistry) {
27-
return parentService || new MediaService(matchMedia, breakpoints);
27+
return parentService || new MediaService(breakpoints, matchMedia);
2828
}
2929
/**
3030
* Provider to return global service for observable service for all MediaQuery activations

src/lib/media-query/observable-media.ts

+14-12
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ export class MediaService implements ObservableMedia {
8181
*/
8282
public filterOverlaps = true;
8383

84-
constructor(private mediaWatcher: MatchMedia,
85-
private breakpoints: BreakPointRegistry) {
86-
this.observable$ = this._buildObservable();
84+
constructor(private breakpoints: BreakPointRegistry,
85+
private mediaWatcher: MatchMedia) {
8786
this._registerBreakPoints();
87+
this.observable$ = this._buildObservable();
8888
}
8989

9090
/**
@@ -122,22 +122,19 @@ export class MediaService implements ObservableMedia {
122122
* mediaQuery activations
123123
*/
124124
private _registerBreakPoints() {
125-
this.breakpoints.items.forEach((bp: BreakPoint) => {
126-
this.mediaWatcher.registerQuery(bp.mediaQuery);
127-
return bp;
128-
});
125+
let queries = this.breakpoints.sortedItems.map(bp => bp.mediaQuery);
126+
this.mediaWatcher.registerQuery(queries);
129127
}
130128

131129
/**
132130
* Prepare internal observable
133-
* NOTE: the raw MediaChange events [from MatchMedia] do not contain important alias information
134-
* these must be injected into the MediaChange
131+
*
132+
* NOTE: the raw MediaChange events [from MatchMedia] do not
133+
* contain important alias information; as such this info
134+
* must be injected into the MediaChange
135135
*/
136136
private _buildObservable() {
137137
const self = this;
138-
// Only pass/announce activations (not de-activations)
139-
// Inject associated (if any) alias information into the MediaChange event
140-
// Exclude mediaQuery activations for overlapping mQs. List bounded mQ ranges only
141138
const activationsOnly = (change: MediaChange) => {
142139
return change.matches === true;
143140
};
@@ -149,6 +146,11 @@ export class MediaService implements ObservableMedia {
149146
return !bp ? true : !(self.filterOverlaps && bp.overlapping);
150147
};
151148

149+
/**
150+
* Only pass/announce activations (not de-activations)
151+
* Inject associated (if any) alias information into the MediaChange event
152+
* Exclude mediaQuery activations for overlapping mQs. List bounded mQ ranges only
153+
*/
152154
return this.mediaWatcher.observe()
153155
.filter(activationsOnly)
154156
.map(addAliasInformation)

0 commit comments

Comments
 (0)