From 9cd1514e06e8e7ac00898b5c96244e9b1faa75c5 Mon Sep 17 00:00:00 2001 From: Thomas Burleson Date: Sat, 10 Jun 2017 12:32:59 +1200 Subject: [PATCH] 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 --- src/demo-app/app/shared/media-query-status.ts | 4 +- .../breakpoints/break-point-registry.ts | 14 +++ src/lib/media-query/match-media.spec.ts | 5 +- src/lib/media-query/match-media.ts | 99 ++++++++++++------- src/lib/media-query/media-monitor.ts | 5 +- .../media-query/observable-media-provider.ts | 2 +- src/lib/media-query/observable-media.ts | 26 ++--- 7 files changed, 99 insertions(+), 56 deletions(-) diff --git a/src/demo-app/app/shared/media-query-status.ts b/src/demo-app/app/shared/media-query-status.ts index 194a30064..682799712 100644 --- a/src/demo-app/app/shared/media-query-status.ts +++ b/src/demo-app/app/shared/media-query-status.ts @@ -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(); diff --git a/src/lib/media-query/breakpoints/break-point-registry.ts b/src/lib/media-query/breakpoints/break-point-registry.ts index e3a7be4e4..d76770dc5 100644 --- a/src/lib/media-query/breakpoints/break-point-registry.ts +++ b/src/lib/media-query/breakpoints/break-point-registry.ts @@ -29,6 +29,20 @@ 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, matching breakpoint 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) */ diff --git a/src/lib/media-query/match-media.spec.ts b/src/lib/media-query/match-media.spec.ts index 0a0ce1a68..4d32ecec2 100644 --- a/src/lib/media-query/match-media.spec.ts +++ b/src/lib/media-query/match-media.spec.ts @@ -86,13 +86,12 @@ describe('match-media', () => { }); - it('can observe only a specific custom mediaQuery ranges', () => { + it('can observe an array of custom mediaQuery ranges', () => { let current: MediaChange, activated; let query1 = "screen and (min-width: 610px) and (max-width: 620px)"; let query2 = "(min-width: 730px) and (max-width: 950px)"; - matchMedia.registerQuery(query1); - matchMedia.registerQuery(query2); + matchMedia.registerQuery([query1, query2]); let subscription = matchMedia.observe(query1).subscribe((change: MediaChange) => { current = change; diff --git a/src/lib/media-query/match-media.ts b/src/lib/media-query/match-media.ts index 5e07f84cc..a26d07e49 100644 --- a/src/lib/media-query/match-media.ts +++ b/src/lib/media-query/match-media.ts @@ -76,36 +76,42 @@ export class MatchMedia { observe(mediaQuery?: string): Observable { 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 | 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 + } + }); } - } /** @@ -113,19 +119,16 @@ export class MatchMedia { * supports 0..n listeners for activation/deactivation */ protected _buildMQL(query: string): MediaQueryList { - prepareQueryCSS(query); - let canListen = !!(window).matchMedia('all').addListener; return canListen ? (window).matchMedia(query) : { - matches: query === 'all' || query === '', - media: query, - addListener: () => { - }, - removeListener: () => { - } - }; + matches: query === 'all' || query === '', + media: query, + addListener: () => { + }, + removeListener: () => { + } + }; } - } /** @@ -135,27 +138,33 @@ 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: 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); @@ -163,3 +172,21 @@ function prepareQueryCSS(query) { } } +/** + * Always convert to unique list of queries; for iteration in ::registerQuery() + */ +function normalizeQuery(mediaQuery: string | string[]): string[] { + return (typeof mediaQuery === 'undefined') ? [] : + (typeof mediaQuery === 'string') ? [mediaQuery] : unique(mediaQuery as string[]); +} + +/** + * Filter duplicate mediaQueries in the list + */ +function unique(list: string[]): string[] { + let seen = {}; + return list.filter(item => { + return seen.hasOwnProperty(item) ? false : (seen[item] = true); + }); +} + diff --git a/src/lib/media-query/media-monitor.ts b/src/lib/media-query/media-monitor.ts index 2f52f4ab9..97bcad9bb 100644 --- a/src/lib/media-query/media-monitor.ts +++ b/src/lib/media-query/media-monitor.ts @@ -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); } } diff --git a/src/lib/media-query/observable-media-provider.ts b/src/lib/media-query/observable-media-provider.ts index 466b0ea27..e5e42a5eb 100644 --- a/src/lib/media-query/observable-media-provider.ts +++ b/src/lib/media-query/observable-media-provider.ts @@ -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 diff --git a/src/lib/media-query/observable-media.ts b/src/lib/media-query/observable-media.ts index 308393df4..6ebe91703 100644 --- a/src/lib/media-query/observable-media.ts +++ b/src/lib/media-query/observable-media.ts @@ -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(); } /** @@ -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; }; @@ -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)