Skip to content

Commit

Permalink
fix(ObservableMedia): startup should propagate lastReplay value properly
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ThomasBurleson committed Jun 12, 2017
1 parent aae1deb commit d9177cb
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 53 deletions.
4 changes: 3 additions & 1 deletion src/demo-app/app/shared/media-query-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export class MediaQueryStatus implements OnDestroy {
private _watcher : Subscription;
activeMediaQuery : string;

constructor(media$ : ObservableMedia) { this.watchMediaQueries(media$); }
constructor(media$ : ObservableMedia) {
this.watchMediaQueries(media$);
}

ngOnDestroy() {
this._watcher.unsubscribe();
Expand Down
15 changes: 15 additions & 0 deletions src/lib/media-query/breakpoints/break-point-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ export class BreakPointRegistry {
return [...this._registry];
}

/**
* Accessor to sorted list used for
* registration with matchMedia API
*
* NOTE: During breakpoint registration,
* we want to register the overlaps FIRST so the non-overlaps
* will trigger the MatchMedia:BehaviorSubject last!
* And the largest, non-overlap should be the lastReplay value
*/
get sortedItems(): BreakPoint[ ] {
let overlaps = this._registry.filter(it=>it.overlapping === true);
let nonOverlaps = this._registry.filter(it=>it.overlapping !== true);

return [...overlaps, ...nonOverlaps];
}
/**
* Search breakpoints by alias (e.g. gt-xs)
*/
Expand Down
96 changes: 60 additions & 36 deletions src/lib/media-query/match-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,56 +76,59 @@ export class MatchMedia {
observe(mediaQuery?: string): Observable<MediaChange> {
this.registerQuery(mediaQuery);

return this._observable$.filter((change: MediaChange) => {
return mediaQuery ? (change.mediaQuery === mediaQuery) : true;
});
return this._observable$
.filter((change: MediaChange) => {
return mediaQuery ? (change.mediaQuery === mediaQuery) : true;
});
}

/**
* Based on the BreakPointRegistry provider, register internal listeners for each unique
* mediaQuery. Each listener emits specific MediaChange data to observers
*/
registerQuery(mediaQuery: string) {
if (mediaQuery) {
let mql = this._registry.get(mediaQuery);
let onMQLEvent = (e: MediaQueryList) => {
this._zone.run(() => {
let change = new MediaChange(e.matches, mediaQuery);
this._source.next(change);
});
};
registerQuery(mediaQuery: string | Array<string>) {
let list = normalizeQuery(mediaQuery);

if (list.length > 0) {
prepareQueryCSS(list);

list.forEach(query => {
let mql = this._registry.get(query);
let onMQLEvent = (e: MediaQueryList) => {
this._zone.run(() => {
let change = new MediaChange(e.matches, query);
this._source.next(change);
});
};

if (!mql) {
mql = this._buildMQL(mediaQuery);
mql.addListener(onMQLEvent);
this._registry.set(mediaQuery, mql);
}
if (!mql) {
mql = this._buildMQL(query);
mql.addListener(onMQLEvent);
this._registry.set(query, mql);
}

if (mql.matches) {
onMQLEvent(mql); // Announce activate range for initial subscribers
}
if (mql.matches) {
onMQLEvent(mql); // Announce activate range for initial subscribers
}
});
}

}

/**
* Call window.matchMedia() to build a MediaQueryList; which
* supports 0..n listeners for activation/deactivation
*/
protected _buildMQL(query: string): MediaQueryList {
prepareQueryCSS(query);

let canListen = !!(<any>window).matchMedia('all').addListener;
return canListen ? (<any>window).matchMedia(query) : <MediaQueryList>{
matches: query === 'all' || query === '',
media: query,
addListener: () => {
},
removeListener: () => {
}
};
matches: query === 'all' || query === '',
media: query,
addListener: () => {
},
removeListener: () => {
}
};
}

}

/**
Expand All @@ -135,31 +138,52 @@ export class MatchMedia {
const ALL_STYLES = {};

/**
* For Webkit engines that only trigger the MediaQueryListListener
* For Webkit engines that only trigger the MediaQueryList Listener
* when there is at least one CSS selector for the respective media query.
*
* @param query string The mediaQuery used to create a faux CSS selector
*
*/
function prepareQueryCSS(query) {
if (!ALL_STYLES[query]) {
function prepareQueryCSS(mediaQueries: Array<string>) {
let list = mediaQueries.filter(it => !ALL_STYLES[it]);
if (list.length > 0) {
let query = list.join(", ");
try {
let style = document.createElement('style');

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

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

// Store in private global registry
ALL_STYLES[query] = style;
list.forEach(mq => ALL_STYLES[mq] = style);

} catch (e) {
console.error(e);
}
}
}

function normalizeQuery(mediaQuery: string | Array<string>): Array<string> {
return (typeof mediaQuery === 'undefined') ? [] :
(typeof mediaQuery === 'string') ? [mediaQuery] : unique(mediaQuery as Array<string>);
}

/**
* Filter duplicate mediaQueries in the list
*/
function unique(list: Array<string>): Array<string> {
var seen = {};
return list.filter(item => {
return seen.hasOwnProperty(item) ? false : (seen[item] = true);
});
}

5 changes: 2 additions & 3 deletions src/lib/media-query/media-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,7 @@ export class MediaMonitor {
* and prepare for immediate subscription notifications
*/
private _registerBreakpoints() {
this._breakpoints.items.forEach(bp => {
this._matchMedia.registerQuery(bp.mediaQuery);
});
let queries = this._breakpoints.sortedItems.map(bp => bp.mediaQuery);
this._matchMedia.registerQuery(queries);
}
}
2 changes: 1 addition & 1 deletion src/lib/media-query/observable-media-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {ObservableMedia, MediaService} from './observable-media';
export function OBSERVABLE_MEDIA_PROVIDER_FACTORY(parentService: ObservableMedia,
matchMedia: MatchMedia,
breakpoints: BreakPointRegistry) {
return parentService || new MediaService(matchMedia, breakpoints);
return parentService || new MediaService(breakpoints, matchMedia);
}
/**
* Provider to return global service for observable service for all MediaQuery activations
Expand Down
26 changes: 14 additions & 12 deletions src/lib/media-query/observable-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ export class MediaService implements ObservableMedia {
*/
public filterOverlaps = true;

constructor(private mediaWatcher: MatchMedia,
private breakpoints: BreakPointRegistry) {
this.observable$ = this._buildObservable();
constructor(private breakpoints: BreakPointRegistry,
private mediaWatcher: MatchMedia) {
this._registerBreakPoints();
this.observable$ = this._buildObservable();
}

/**
Expand Down Expand Up @@ -122,22 +122,19 @@ export class MediaService implements ObservableMedia {
* mediaQuery activations
*/
private _registerBreakPoints() {
this.breakpoints.items.forEach((bp: BreakPoint) => {
this.mediaWatcher.registerQuery(bp.mediaQuery);
return bp;
});
let queries = this.breakpoints.sortedItems.map(bp => bp.mediaQuery);
this.mediaWatcher.registerQuery(queries);
}

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

/**
* Only pass/announce activations (not de-activations)
* Inject associated (if any) alias information into the MediaChange event
* Exclude mediaQuery activations for overlapping mQs. List bounded mQ ranges only
*/
return this.mediaWatcher.observe()
.filter(activationsOnly)
.map(addAliasInformation)
Expand Down

0 comments on commit d9177cb

Please sign in to comment.