From bde1cff1324d1c8fe0455021a248425d0bdbdad2 Mon Sep 17 00:00:00 2001 From: Adam Plumer Date: Wed, 14 Nov 2018 01:22:50 -0600 Subject: [PATCH] feat(media-observer): migrate ObservableMedia BREAKING CHANGE: `ObservableMedia` is now deprecated in anticipation of RxJS v7. The new API is called **`MediaObserver`**, and provides the exact same functionality as ObservableMedia, except you cannot directly subscribe to it, You can subscribe to MediaObserver's `media$` property; in place of subscribing directly to ObservableMedia. Fixes #885. --- src/lib/core/README.md | 4 +- src/lib/core/base/base.ts | 23 +- src/lib/core/match-media/match-media.spec.ts | 23 +- src/lib/core/match-media/match-media.ts | 155 ++++++------- .../core/match-media/mock/mock-match-media.ts | 25 +- .../core/match-media/server-match-media.ts | 9 +- src/lib/core/media-monitor/media-monitor.ts | 34 +-- src/lib/core/media-observer/index.ts | 9 + .../media-observer/media-observer.spec.ts | 213 ++++++++++++++++++ src/lib/core/media-observer/media-observer.ts | 142 ++++++++++++ src/lib/core/module.ts | 3 +- .../core/observable-media/observable-media.ts | 34 +-- src/lib/core/public-api.ts | 1 + .../responsive-activation.ts | 63 ++---- src/lib/extended/show-hide/hide.spec.ts | 4 +- src/lib/extended/show-hide/show.spec.ts | 4 +- src/lib/flex/flex/flex.spec.ts | 3 +- 17 files changed, 520 insertions(+), 229 deletions(-) create mode 100644 src/lib/core/media-observer/index.ts create mode 100644 src/lib/core/media-observer/media-observer.spec.ts create mode 100644 src/lib/core/media-observer/media-observer.ts diff --git a/src/lib/core/README.md b/src/lib/core/README.md index 7e618b3f2..095782390 100644 --- a/src/lib/core/README.md +++ b/src/lib/core/README.md @@ -1,6 +1,6 @@ The `core` entrypoint contains all of the common utilities to build Layout components. Its primary exports are the `MediaQuery` utilities (`MatchMedia`, -`ObservableMedia`) and the module that encapsulates the imports of these +`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 from the root module to take advantage of tree shaking. @@ -22,4 +22,4 @@ export class AppModule {} import {BaseDirective} from '@angular/flex-layout/core'; export class NewLayoutDirective extends BaseDirective {} -``` \ No newline at end of file +``` diff --git a/src/lib/core/base/base.ts b/src/lib/core/base/base.ts index 83802d347..14249170b 100644 --- a/src/lib/core/base/base.ts +++ b/src/lib/core/base/base.ts @@ -25,9 +25,6 @@ import {StyleBuilder} from '../style-builder/style-builder'; /** Abstract base class for the Layout API styling directives. */ export abstract class BaseDirective implements OnDestroy, OnChanges { - get hasMediaQueryListener() { - return !!this._mqActivation; - } /** * Imperatively determine the current activated [input] value; @@ -52,7 +49,7 @@ export abstract class BaseDirective implements OnDestroy, OnChanges { previousVal = this._inputMap[key]; this._inputMap[key] = value; } - let change = new SimpleChange(previousVal, value, false); + const change = new SimpleChange(previousVal, value, false); this.ngOnChanges({[key]: change} as SimpleChanges); } @@ -137,8 +134,8 @@ export abstract class BaseDirective implements OnDestroy, OnChanges { * If not, use the fallback value! */ protected _getDefaultVal(key: string, fallbackVal: any): string | boolean { - let val = this._queryInput(key); - let hasDefaultVal = (val !== undefined && val !== null); + const val = this._queryInput(key); + const hasDefaultVal = (val !== undefined && val !== null); return (hasDefaultVal && val !== '') ? val : fallbackVal; } @@ -165,20 +162,19 @@ export abstract class BaseDirective implements OnDestroy, OnChanges { * And optionally add the flow value to element's inline style. */ protected _getFlexFlowDirection(target: HTMLElement, addIfMissing = false): string { - let value = 'row'; - let hasInlineValue = ''; - if (target) { - [value, hasInlineValue] = this._styler.getFlowDirection(target); + let [value, hasInlineValue] = this._styler.getFlowDirection(target); if (!hasInlineValue && addIfMissing) { const style = buildLayoutCSS(value); const elements = [target]; this._styler.applyStyleToElements(style, elements); } + + return value.trim(); } - return value.trim() || 'row'; + return 'row'; } /** Applies styles given via string pair or object map to the directive element */ @@ -237,11 +233,6 @@ export abstract class BaseDirective implements OnDestroy, OnChanges { return buffer; } - /** Fast validator for presence of attribute on the host element */ - protected hasKeyValue(key: string) { - return this._mqActivation!.hasKeyValue(key); - } - protected get hasInitialized() { return this._hasInitialized; } diff --git a/src/lib/core/match-media/match-media.spec.ts b/src/lib/core/match-media/match-media.spec.ts index 021ee4559..eb89eaafd 100644 --- a/src/lib/core/match-media/match-media.spec.ts +++ b/src/lib/core/match-media/match-media.spec.ts @@ -12,7 +12,7 @@ import {BreakPoint} from '../breakpoints/break-point'; import {MockMatchMedia, MockMatchMediaProvider} from './mock/mock-match-media'; import {BreakPointRegistry} from '../breakpoints/break-point-registry'; import {MatchMedia} from './match-media'; -import {ObservableMedia, ObservableMediaProvider} from '../observable-media/observable-media'; +import {MediaObserver} from '../media-observer/media-observer'; describe('match-media', () => { let matchMedia: MockMatchMedia; @@ -111,22 +111,23 @@ describe('match-media', () => { describe('match-media-observable', () => { let breakPoints: BreakPointRegistry; let matchMedia: MockMatchMedia; - let mediaQuery$: ObservableMedia; + let mediaObserver: MediaObserver; beforeEach(() => { // Configure testbed to prepare services TestBed.configureTestingModule({ - providers: [MockMatchMediaProvider, ObservableMediaProvider] + providers: [MockMatchMediaProvider] }); }); // Single async inject to save references; which are used in all tests below beforeEach(async(inject( - [ObservableMedia, MatchMedia, BreakPointRegistry], - (_media$: ObservableMedia, _matchMedia: MockMatchMedia, _breakPoints: BreakPointRegistry) => { + [MediaObserver, MatchMedia, BreakPointRegistry], + (_mediaObserver: MediaObserver, _matchMedia: MockMatchMedia, + _breakPoints: BreakPointRegistry) => { matchMedia = _matchMedia; // inject only to manually activate mediaQuery ranges breakPoints = _breakPoints; - mediaQuery$ = _media$; + mediaObserver = _mediaObserver; // Quick register all breakpoint mediaQueries breakPoints.items.forEach((bp: BreakPoint) => { @@ -141,7 +142,7 @@ describe('match-media-observable', () => { let current: MediaChange; let bp = breakPoints.findByAlias('md') !; matchMedia.activate(bp.mediaQuery); - let subscription = mediaQuery$.subscribe((change: MediaChange) => { + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { current = change; }); @@ -154,7 +155,7 @@ describe('match-media-observable', () => { it('can observe the initial, default activation for mediaQuery == "all". ', () => { let current: MediaChange; - let subscription = mediaQuery$.subscribe((change: MediaChange) => { + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { current = change; }); @@ -168,7 +169,7 @@ describe('match-media-observable', () => { it('can observe custom mediaQuery ranges', () => { let current: MediaChange; let customQuery = 'screen and (min-width: 610px) and (max-width: 620px'; - let subscription = mediaQuery$.subscribe((change: MediaChange) => { + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { current = change; }); @@ -184,7 +185,7 @@ describe('match-media-observable', () => { it('can observe registered breakpoint activations', () => { let current: MediaChange; let bp = breakPoints.findByAlias('md') !; - let subscription = mediaQuery$.subscribe((change: MediaChange) => { + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { current = change; }); @@ -205,7 +206,7 @@ describe('match-media-observable', () => { it('ignores mediaQuery de-activations', () => { let activationCount = 0; let deactivationCount = 0; - let subscription = mediaQuery$.subscribe((change: MediaChange) => { + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { if (change.matches) { ++activationCount; } else { diff --git a/src/lib/core/match-media/match-media.ts b/src/lib/core/match-media/match-media.ts index df16ac08c..dd396d7c9 100644 --- a/src/lib/core/match-media/match-media.ts +++ b/src/lib/core/match-media/match-media.ts @@ -21,23 +21,20 @@ import {MediaChange} from '../media-change'; */ @Injectable({providedIn: 'root'}) export class MatchMedia { - protected _registry: Map; - protected _source: BehaviorSubject; - protected _observable$: Observable; + protected _registry = new Map(); + protected _source = new BehaviorSubject(new MediaChange(true)); + protected _observable$ = this._source.asObservable(); constructor(protected _zone: NgZone, @Inject(PLATFORM_ID) protected _platformId: Object, @Inject(DOCUMENT) protected _document: any) { - this._registry = new Map(); - this._source = new BehaviorSubject(new MediaChange(true)); - this._observable$ = this._source.asObservable(); } /** * For the specified mediaQuery? */ isActive(mediaQuery: string): boolean { - let mql = this._registry.get(mediaQuery); + const mql = this._registry.get(mediaQuery); return !!mql ? mql.matches : false; } @@ -55,9 +52,7 @@ export class MatchMedia { } return this._observable$.pipe( - filter((change: MediaChange) => { - return mediaQuery ? (change.mediaQuery === mediaQuery) : true; - }) + filter(change => (mediaQuery ? (change.mediaQuery === mediaQuery) : true)) ); } @@ -66,31 +61,29 @@ export class MatchMedia { * mediaQuery. Each listener emits specific MediaChange data to observers */ registerQuery(mediaQuery: string | string[]) { - let list = normalizeQuery(mediaQuery); + const list = Array.isArray(mediaQuery) ? Array.from(new Set(mediaQuery)) : [mediaQuery]; if (list.length > 0) { - this._prepareQueryCSS(list, this._document); - - list.forEach(query => { - let mql = this._registry.get(query); - let onMQLEvent = (e: MediaQueryListEvent) => { - this._zone.run(() => { - let change = new MediaChange(e.matches, query); - this._source.next(change); - }); - }; - - if (!mql) { - mql = this._buildMQL(query); - mql.addListener(onMQLEvent); - this._registry.set(query, mql); - } - - if (mql.matches) { - onMQLEvent(mql as unknown as MediaQueryListEvent); - } - }); + buildQueryCss(list, this._document); } + + list.forEach(query => { + const onMQLEvent = (e: MediaQueryListEvent) => { + this._zone.run(() => this._source.next(new MediaChange(e.matches, query))); + }; + + let mql = this._registry.get(query); + + if (!mql) { + mql = this._buildMQL(query); + mql.addListener(onMQLEvent); + this._registry.set(query, mql); + } + + if (mql.matches) { + onMQLEvent(mql as unknown as MediaQueryListEvent); + } + }); } /** @@ -98,79 +91,63 @@ export class MatchMedia { * supports 0..n listeners for activation/deactivation */ protected _buildMQL(query: string): MediaQueryList { - let canListen = isPlatformBrowser(this._platformId) && - !!(window).matchMedia('all').addListener; - - return canListen ? (window).matchMedia(query) : { - matches: query === 'all' || query === '', - media: query, - addListener: () => { - }, - removeListener: () => { - } - } as unknown as MediaQueryList; + return constructMql(query, isPlatformBrowser(this._platformId)); } +} - /** - * For Webkit engines that only trigger the MediaQueryList Listener - * when there is at least one CSS selector for the respective media query. - * - * @param mediaQueries - * @param _document - */ - protected _prepareQueryCSS(mediaQueries: string[], _document: Document) { - const list: string[] = mediaQueries.filter(it => !ALL_STYLES[it]); - if (list.length > 0) { - const query = list.join(', '); +/** + * Private global registry for all dynamically-created, injected style tags + * @see prepare(query) + */ +const ALL_STYLES: {[key: string]: any} = {}; + +/** + * For Webkit engines that only trigger the MediaQueryList Listener + * when there is at least one CSS selector for the respective media query. + * + * @param mediaQueries + * @param _document + */ +function buildQueryCss(mediaQueries: string[], _document: Document) { + const list = mediaQueries.filter(it => !ALL_STYLES[it]); + if (list.length > 0) { + const query = list.join(', '); - try { - let styleEl = _document.createElement('style'); + try { + const styleEl = _document.createElement('style'); - styleEl.setAttribute('type', 'text/css'); - if (!(styleEl as any).styleSheet) { - let cssText = ` + styleEl.setAttribute('type', 'text/css'); + if (!(styleEl as any).styleSheet) { + const cssText = ` /* @angular/flex-layout - workaround for possible browser quirk with mediaQuery listeners see http://bit.ly/2sd4HMP */ @media ${query} {.fx-query-test{ }} ` ; - styleEl.appendChild(_document.createTextNode(cssText)); - } + styleEl.appendChild(_document.createTextNode(cssText)); + } - _document.head!.appendChild(styleEl); + _document.head!.appendChild(styleEl); - // Store in private global registry - list.forEach(mq => ALL_STYLES[mq] = styleEl); + // Store in private global registry + list.forEach(mq => ALL_STYLES[mq] = styleEl); - } catch (e) { - console.error(e); - } + } catch (e) { + console.error(e); } } } -/** - * Private global registry for all dynamically-created, injected style tags - * @see prepare(query) - */ -const ALL_STYLES: {[key: string]: any} = {}; - -/** - * 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[]); -} +function constructMql(query: string, isBrowser: boolean): MediaQueryList { + const canListen = isBrowser && !!(window).matchMedia('all').addListener; -/** - * Filter duplicate mediaQueries in the list - */ -function unique(list: string[]): string[] { - let seen: {[key: string]: boolean} = {}; - return list.filter(item => { - return seen.hasOwnProperty(item) ? false : (seen[item] = true); - }); + return canListen ? (window).matchMedia(query) : { + matches: query === 'all' || query === '', + media: query, + addListener: () => { + }, + removeListener: () => { + } + } as unknown as MediaQueryList; } - diff --git a/src/lib/core/match-media/mock/mock-match-media.ts b/src/lib/core/match-media/mock/mock-match-media.ts index 18c16e71e..838fc789e 100644 --- a/src/lib/core/match-media/mock/mock-match-media.ts +++ b/src/lib/core/match-media/mock/mock-match-media.ts @@ -35,7 +35,6 @@ export class MockMatchMedia extends MatchMedia { @Inject(DOCUMENT) _document: any, private _breakpoints: BreakPointRegistry) { super(_zone, _platformId, _document); - this._actives = []; } /** Easy method to clear all listeners for all mediaQueries */ @@ -64,11 +63,8 @@ export class MockMatchMedia extends MatchMedia { /** Converts an optional mediaQuery alias to a specific, valid mediaQuery */ _validateQuery(queryOrAlias: string) { - let bp = this._breakpoints.findByAlias(queryOrAlias); - if (bp) { - queryOrAlias = bp.mediaQuery; - } - return queryOrAlias; + const bp = this._breakpoints.findByAlias(queryOrAlias); + return (bp && bp.mediaQuery) || queryOrAlias; } /** @@ -77,8 +73,8 @@ export class MockMatchMedia extends MatchMedia { */ private _activateWithOverlaps(mediaQuery: string, useOverlaps: boolean): boolean { if (useOverlaps) { - let bp = this._breakpoints.findByQuery(mediaQuery); - let alias = bp ? bp.alias : 'unknown'; + const bp = this._breakpoints.findByQuery(mediaQuery); + const alias = bp ? bp.alias : 'unknown'; // Simulate activation of overlapping lt- ranges switch (alias) { @@ -120,8 +116,8 @@ export class MockMatchMedia extends MatchMedia { * */ private _activateByAlias(aliases: string) { - let activate = (alias: string) => { - let bp = this._breakpoints.findByAlias(alias); + const activate = (alias: string) => { + const bp = this._breakpoints.findByAlias(alias); this._activateByQuery(bp ? bp.mediaQuery : alias); }; aliases.split(',').forEach(alias => activate(alias.trim())); @@ -131,10 +127,9 @@ export class MockMatchMedia extends MatchMedia { * */ private _activateByQuery(mediaQuery: string) { - let mql = this._registry.get(mediaQuery); - let alreadyAdded = this._actives.reduce((found, it) => { - return found || (mql && (it.media === mql.media)); - }, false); + const mql = this._registry.get(mediaQuery)!; + const alreadyAdded = this._actives + .reduce((found, it) => (found || (mql && (it.media === mql.media))), false); if (mql && !alreadyAdded) { this._actives.push(mql.activate()); @@ -170,7 +165,7 @@ export class MockMatchMedia extends MatchMedia { } protected get hasActivated() { - return (this._actives.length > 0); + return this._actives.length > 0; } private _actives: MockMediaQueryList[] = []; diff --git a/src/lib/core/match-media/server-match-media.ts b/src/lib/core/match-media/server-match-media.ts index 441a8ead8..7137fe7b5 100644 --- a/src/lib/core/match-media/server-match-media.ts +++ b/src/lib/core/match-media/server-match-media.ts @@ -7,11 +7,9 @@ */ import {DOCUMENT} from '@angular/common'; import {Inject, Injectable, NgZone, PLATFORM_ID} from '@angular/core'; -import {BehaviorSubject, Observable} from 'rxjs'; import {BreakPoint} from '../breakpoints/break-point'; import {MatchMedia} from './match-media'; -import {MediaChange} from '../media-change'; /** * Special server-only class to simulate a MediaQueryList and @@ -115,17 +113,12 @@ export class ServerMediaQueryList implements MediaQueryList { */ @Injectable() export class ServerMatchMedia extends MatchMedia { - protected _registry: Map; - protected _source: BehaviorSubject; - protected _observable$: Observable; + protected _registry: Map = new Map(); constructor(protected _zone: NgZone, @Inject(PLATFORM_ID) protected _platformId: Object, @Inject(DOCUMENT) protected _document: any) { super(_zone, _platformId, _document); - this._registry = new Map(); - this._source = new BehaviorSubject(new MediaChange(true)); - this._observable$ = this._source.asObservable(); } /** Activate the specified breakpoint if we're on the server, no-op otherwise */ diff --git a/src/lib/core/media-monitor/media-monitor.ts b/src/lib/core/media-monitor/media-monitor.ts index f91cf2e0e..8454029cb 100644 --- a/src/lib/core/media-monitor/media-monitor.ts +++ b/src/lib/core/media-monitor/media-monitor.ts @@ -43,31 +43,22 @@ export class MediaMonitor { } get activeOverlaps(): BreakPoint[] { - let items: BreakPoint[] = this._breakpoints.overlappings.reverse(); - return items.filter((bp: BreakPoint) => { - return this._matchMedia.isActive(bp.mediaQuery); - }); + return this._breakpoints.overlappings + .reverse() + .filter(bp => this._matchMedia.isActive(bp.mediaQuery)); } get active(): BreakPoint | null { - let found: BreakPoint | null = null, items = this.breakpoints.reverse(); - items.forEach(bp => { - if (bp.alias !== '') { - if (!found && this._matchMedia.isActive(bp.mediaQuery)) { - found = bp; - } - } - }); - - let first = this.breakpoints[0]; - return found || (this._matchMedia.isActive(first.mediaQuery) ? first : null); + const items = this.breakpoints.reverse(); + const first = items.find(bp => bp.alias !== '' && this._matchMedia.isActive(bp.mediaQuery)); + return first || null; } /** * For the specified mediaQuery alias, is the mediaQuery range active? */ isActive(alias: string): boolean { - let bp = this._breakpoints.findByAlias(alias) || this._breakpoints.findByQuery(alias); + const bp = this._breakpoints.findByAlias(alias) || this._breakpoints.findByQuery(alias); return this._matchMedia.isActive(bp ? bp.mediaQuery : alias); } @@ -76,13 +67,12 @@ export class MediaMonitor { * If specific breakpoint is observed, only return *activated* events * otherwise return all events for BOTH activated + deactivated changes. */ - observe(alias?: string): Observable { - let bp = this._breakpoints.findByAlias(alias || '') || - this._breakpoints.findByQuery(alias || ''); - let hasAlias = (change: MediaChange) => (bp ? change.mqAlias !== '' : true); + observe(alias: string = ''): Observable { + const bp = this._breakpoints.findByAlias(alias) || this._breakpoints.findByQuery(alias); + const hasAlias = (change: MediaChange) => (bp ? change.mqAlias !== '' : true); // Note: the raw MediaChange events [from MatchMedia] do not contain important alias information - let media$ = this._matchMedia.observe(bp ? bp.mediaQuery : alias); + const media$ = this._matchMedia.observe(bp ? bp.mediaQuery : alias); return media$.pipe( map(change => mergeAlias(change, bp)), filter(hasAlias) @@ -94,7 +84,7 @@ export class MediaMonitor { * and prepare for immediate subscription notifications */ private _registerBreakpoints() { - let queries = this._breakpoints.sortedItems.map(bp => bp.mediaQuery); + const queries = this._breakpoints.sortedItems.map(bp => bp.mediaQuery); this._matchMedia.registerQuery(queries); } } diff --git a/src/lib/core/media-observer/index.ts b/src/lib/core/media-observer/index.ts new file mode 100644 index 000000000..459bf1401 --- /dev/null +++ b/src/lib/core/media-observer/index.ts @@ -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-observer'; diff --git a/src/lib/core/media-observer/media-observer.spec.ts b/src/lib/core/media-observer/media-observer.spec.ts new file mode 100644 index 000000000..35296ad7f --- /dev/null +++ b/src/lib/core/media-observer/media-observer.spec.ts @@ -0,0 +1,213 @@ +/** + * @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, async} from '@angular/core/testing'; +import {filter, map} from 'rxjs/operators'; + +import {BreakPoint} from '../breakpoints/break-point'; +import {BREAKPOINTS} from '../breakpoints/break-points-token'; +import {MatchMedia} from '../match-media/match-media'; +import {MediaChange} from '../media-change'; +import {MediaObserver} from './media-observer'; +import {MockMatchMedia, MockMatchMediaProvider} from '../match-media/mock/mock-match-media'; +import {BREAKPOINT} from '../tokens/breakpoint-token'; + +describe('observable-media', () => { + + describe('with default BreakPoints', () => { + let knownBreakPoints: BreakPoint[] = []; + let findMediaQuery: (alias: string) => string = (alias) => { + const NOT_FOUND = `${alias} not found`; + return knownBreakPoints.reduce((mediaQuery: string | null, bp) => { + return mediaQuery || ((bp.alias === alias) ? bp.mediaQuery : null); + }, null) as string || NOT_FOUND; + }; + beforeEach(() => { + // Configure testbed to prepare services + TestBed.configureTestingModule({ + providers: [MockMatchMediaProvider] + }); + }); + beforeEach(inject([BREAKPOINTS], (breakpoints: BreakPoint[]) => { + // Cache reference to list for easy testing... + knownBreakPoints = breakpoints; + })); + + it('can supports the `.isActive()` API', async(inject( + [MediaObserver, MatchMedia], + (media: MediaObserver, matchMedia: MockMatchMedia) => { + expect(media).toBeDefined(); + + // Activate mediaQuery associated with 'md' alias + matchMedia.activate('md'); + expect(media.isActive('md')).toBeTruthy(); + + matchMedia.activate('lg'); + expect(media.isActive('lg')).toBeTruthy(); + expect(media.isActive('md')).toBeFalsy(); + + }))); + + it('can supports RxJS operators', inject( + [MediaObserver, MatchMedia], + (mediaService: MediaObserver, matchMedia: MockMatchMedia) => { + let count = 0, + subscription = mediaService.media$.pipe( + filter((change: MediaChange) => change.mqAlias == 'md'), + map((change: MediaChange) => change.mqAlias) + ).subscribe(_ => { + count += 1; + }); + + + // Activate mediaQuery associated with 'md' alias + matchMedia.activate('sm'); + expect(count).toEqual(0); + + matchMedia.activate('md'); + expect(count).toEqual(1); + + matchMedia.activate('lg'); + expect(count).toEqual(1); + + matchMedia.activate('md'); + expect(count).toEqual(2); + + matchMedia.activate('gt-md'); + matchMedia.activate('gt-lg'); + matchMedia.activate('invalid'); + expect(count).toEqual(2); + + subscription.unsubscribe(); + })); + + it('can subscribe to built-in mediaQueries', inject( + [MediaObserver, MatchMedia], + (mediaObserver: MediaObserver, matchMedia: MockMatchMedia) => { + let current: MediaChange; + + expect(mediaObserver).toBeDefined(); + + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { + current = change; + }); + + async(() => { + // Confirm initial match is for 'all' + expect(current).toBeDefined(); + expect(current.matches).toBeTruthy(); + expect(current.mediaQuery).toEqual('all'); + + try { + matchMedia.autoRegisterQueries = false; + + // Activate mediaQuery associated with 'md' alias + matchMedia.activate('md'); + expect(current.mediaQuery).toEqual(findMediaQuery('md')); + + // Allow overlapping activations to be announced to observers + mediaObserver.filterOverlaps = false; + + matchMedia.activate('gt-lg'); + expect(current.mediaQuery).toEqual(findMediaQuery('gt-lg')); + + matchMedia.activate('unknown'); + expect(current.mediaQuery).toEqual(findMediaQuery('gt-lg')); + + } finally { + matchMedia.autoRegisterQueries = true; + subscription.unsubscribe(); + } + }); + })); + + it('can `.unsubscribe()` properly', inject( + [MediaObserver, MatchMedia], + (mediaObserver: MediaObserver, matchMedia: MockMatchMedia) => { + let current: MediaChange; + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { + current = change; + }); + + async(() => { + // Activate mediaQuery associated with 'md' alias + matchMedia.activate('md'); + expect(current.mediaQuery).toEqual(findMediaQuery('md')); + + // Un-subscribe + subscription.unsubscribe(); + + matchMedia.activate('lg'); + expect(current.mqAlias).toBe('md'); + + matchMedia.activate('xs'); + expect(current.mqAlias).toBe('md'); + }); + })); + + it('can observe a startup activation of XS', inject( + [MediaObserver, MatchMedia], + (mediaObserver: MediaObserver, matchMedia: MockMatchMedia) => { + let current: MediaChange; + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { + current = change; + }); + + async(() => { + // Activate mediaQuery associated with 'md' alias + matchMedia.activate('xs'); + expect(current.mediaQuery).toEqual(findMediaQuery('xs')); + + // Un-subscribe + subscription.unsubscribe(); + + matchMedia.activate('lg'); + expect(current.mqAlias).toBe('xs'); + }); + })); + }); + + describe('with custom BreakPoints', () => { + const gtXsMediaQuery = 'screen and (max-width:20px) and (orientations: landscape)'; + const mdMediaQuery = 'print and (min-width:10000px)'; + const CUSTOM_BREAKPOINTS = [ + {alias: 'print.md', mediaQuery: mdMediaQuery}, + {alias: 'tablet-gt-xs', mediaQuery: gtXsMediaQuery}, + ]; + + beforeEach(() => { + // Configure testbed to prepare services + TestBed.configureTestingModule({ + providers: [ + MockMatchMediaProvider, + {provide: BREAKPOINT, useValue: CUSTOM_BREAKPOINTS, multi: true}, + ] + }); + }); + + it('can activate custom alias with custom mediaQueries', inject( + [MediaObserver, MatchMedia], + (mediaObserver: MediaObserver, matchMedia: MockMatchMedia) => { + let current: MediaChange; + let subscription = mediaObserver.media$.subscribe((change: MediaChange) => { + current = change; + }); + + async(() => { + // Activate mediaQuery associated with 'md' alias + matchMedia.activate('print.md'); + expect(current.mediaQuery).toEqual(mdMediaQuery); + + matchMedia.activate('tablet-gt-xs'); + expect(current.mqAlias).toBe('tablet-gt-xs'); + expect(current.mediaQuery).toBe(gtXsMediaQuery); + + subscription.unsubscribe(); + }); + })); + }); +}); diff --git a/src/lib/core/media-observer/media-observer.ts b/src/lib/core/media-observer/media-observer.ts new file mode 100644 index 000000000..ebf65d7ff --- /dev/null +++ b/src/lib/core/media-observer/media-observer.ts @@ -0,0 +1,142 @@ +/** + * @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 {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {filter, map} from 'rxjs/operators'; + +import {BreakPointRegistry} from '../breakpoints/break-point-registry'; +import {MediaChange} from '../media-change'; +import {MatchMedia} from '../match-media/match-media'; +import {mergeAlias} from '../add-alias'; + +/** + * Class internalizes a MatchMedia service and exposes an Observable interface. + + * This exposes an Observable with a feature to subscribe to mediaQuery + * changes and a validator method (`isActive()`) to test if a mediaQuery (or alias) is + * currently active. + * + * !! Only mediaChange activations (not de-activations) are announced by the MediaObserver + * + * This class uses the BreakPoint Registry to inject alias information into the raw MediaChange + * notification. For custom mediaQuery notifications, alias information will not be injected and + * those fields will be ''. + * + * !! This is not an actual Observable. It is a wrapper of an Observable used to publish additional + * methods like `isActive(). To access the Observable and use RxJS operators, use + * `.media$` with syntax like mediaObserver.media$.map(....). + * + * @usage + * + * // RxJS + * import { filter } from 'rxjs/operators'; + * import { MediaObserver } from '@angular/flex-layout'; + * + * @Component({ ... }) + * export class AppComponent { + * status: string = ''; + * + * constructor(mediaObserver: MediaObserver) { + * const onChange = (change: MediaChange) => { + * this.status = change ? `'${change.mqAlias}' = (${change.mediaQuery})` : ''; + * }; + * + * // Subscribe directly or access observable to use filter/map operators + * // e.g. mediaObserver.media$.subscribe(onChange); + * + * mediaObserver.media$() + * .pipe( + * filter((change: MediaChange) => true) // silly noop filter + * ).subscribe(onChange); + * } + * } + */ +@Injectable({providedIn: 'root'}) +export class MediaObserver { + /** + * Whether to announce gt- breakpoint activations + */ + filterOverlaps = true; + readonly media$: Observable; + + constructor(private breakpoints: BreakPointRegistry, private mediaWatcher: MatchMedia) { + this._registerBreakPoints(); + this.media$ = this._buildObservable(); + } + + /** + * Test if specified query/alias is active. + */ + isActive(alias: string): boolean { + return this.mediaWatcher.isActive(this._toMediaQuery(alias)); + } + + // ************************************************ + // Internal Methods + // ************************************************ + + /** + * Register all the mediaQueries registered in the BreakPointRegistry + * This is needed so subscribers can be auto-notified of all standard, registered + * mediaQuery activations + */ + private _registerBreakPoints() { + const 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; as such this info + * must be injected into the MediaChange + */ + private _buildObservable() { + const excludeOverlaps = (change: MediaChange) => { + const bp = this.breakpoints.findByQuery(change.mediaQuery); + return !bp ? true : !(this.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() + .pipe( + filter(change => change.matches), + filter(excludeOverlaps), + map((change: MediaChange) => + mergeAlias(change, this._findByQuery(change.mediaQuery)) + ) + ); + } + + /** + * Breakpoint locator by alias + */ + private _findByAlias(alias: string) { + return this.breakpoints.findByAlias(alias); + } + + /** + * Breakpoint locator by mediaQuery + */ + private _findByQuery(query: string) { + return this.breakpoints.findByQuery(query); + } + + /** + * Find associated breakpoint (if any) + */ + private _toMediaQuery(query: string) { + const bp = this._findByAlias(query) || this._findByQuery(query); + return bp ? bp.mediaQuery : query; + } +} diff --git a/src/lib/core/module.ts b/src/lib/core/module.ts index f81baa619..5f89388b3 100644 --- a/src/lib/core/module.ts +++ b/src/lib/core/module.ts @@ -7,7 +7,6 @@ */ import {NgModule} from '@angular/core'; -import {ObservableMediaProvider} from './observable-media/observable-media'; import {BROWSER_PROVIDER} from './browser-provider'; /** @@ -17,7 +16,7 @@ import {BROWSER_PROVIDER} from './browser-provider'; */ @NgModule({ - providers: [ObservableMediaProvider, BROWSER_PROVIDER] + providers: [BROWSER_PROVIDER] }) export class CoreModule { } diff --git a/src/lib/core/observable-media/observable-media.ts b/src/lib/core/observable-media/observable-media.ts index ce9d42374..968af57df 100644 --- a/src/lib/core/observable-media/observable-media.ts +++ b/src/lib/core/observable-media/observable-media.ts @@ -17,6 +17,8 @@ import {BreakPoint} from '../breakpoints/break-point'; /** * Base class for MediaService and pseudo-token for + * @deprecated use MediaObserver instead + * @deletion-target v7.0.0-beta.21 */ export abstract class ObservableMedia implements Subscribable { abstract isActive(query: string): boolean; @@ -71,6 +73,8 @@ export abstract class ObservableMedia implements Subscribable { * ).subscribe(onChange); * } * } + * @deprecated use MediaObserver instead + * @deletion-target v7.0.0-beta.21 */ @Injectable({providedIn: 'root'}) export class MediaService implements ObservableMedia { @@ -89,8 +93,7 @@ export class MediaService implements ObservableMedia { * Test if specified query/alias is active. */ isActive(alias: string): boolean { - let query = this._toMediaQuery(alias); - return this.mediaWatcher.isActive(query); + return this.mediaWatcher.isActive(this._toMediaQuery(alias)); } /** @@ -127,7 +130,7 @@ export class MediaService implements ObservableMedia { * mediaQuery activations */ private _registerBreakPoints() { - let queries = this.breakpoints.sortedItems.map(bp => bp.mediaQuery); + const queries = this.breakpoints.sortedItems.map(bp => bp.mediaQuery); this.mediaWatcher.registerQuery(queries); } @@ -139,17 +142,9 @@ export class MediaService implements ObservableMedia { * must be injected into the MediaChange */ private _buildObservable() { - const self = this; - const media$ = this.mediaWatcher.observe(); - const activationsOnly = (change: MediaChange) => { - return change.matches === true; - }; - const addAliasInformation = (change: MediaChange) => { - return mergeAlias(change, this._findByQuery(change.mediaQuery)); - }; const excludeOverlaps = (change: MediaChange) => { - let bp = this.breakpoints.findByQuery(change.mediaQuery); - return !bp ? true : !(self.filterOverlaps && bp.overlapping); + const bp = this.breakpoints.findByQuery(change.mediaQuery); + return !bp ? true : !(this.filterOverlaps && bp.overlapping); }; /** @@ -157,10 +152,11 @@ export class MediaService implements ObservableMedia { * Inject associated (if any) alias information into the MediaChange event * Exclude mediaQuery activations for overlapping mQs. List bounded mQ ranges only */ - return media$.pipe( - filter(activationsOnly), + return this.mediaWatcher.observe().pipe( + filter(change => change.matches), filter(excludeOverlaps), - map(addAliasInformation) + map((change: MediaChange) => + mergeAlias(change, this._findByQuery(change.mediaQuery))) ); } @@ -182,13 +178,17 @@ export class MediaService implements ObservableMedia { * Find associated breakpoint (if any) */ private _toMediaQuery(query: string) { - let bp: BreakPoint | null = this._findByAlias(query) || this._findByQuery(query); + const bp: BreakPoint | null = this._findByAlias(query) || this._findByQuery(query); return bp ? bp.mediaQuery : query; } private readonly observable$: Observable; } +/** + * @deprecated + * @deletion-target v7.0.0-beta.21 + */ export const ObservableMediaProvider = { // tslint:disable-line:variable-name provide: ObservableMedia, useClass: MediaService diff --git a/src/lib/core/public-api.ts b/src/lib/core/public-api.ts index 6d95c817d..8c23ddfb0 100644 --- a/src/lib/core/public-api.ts +++ b/src/lib/core/public-api.ts @@ -17,6 +17,7 @@ export * from './breakpoints/index'; export * from './match-media/index'; export * from './media-monitor/index'; export * from './observable-media/index'; +export * from './media-observer/index'; export * from './responsive-activation/responsive-activation'; export * from './style-utils/style-utils'; diff --git a/src/lib/core/responsive-activation/responsive-activation.ts b/src/lib/core/responsive-activation/responsive-activation.ts index 4fc0678fb..f09c7c9f8 100644 --- a/src/lib/core/responsive-activation/responsive-activation.ts +++ b/src/lib/core/responsive-activation/responsive-activation.ts @@ -13,8 +13,6 @@ import {BreakPoint} from '../breakpoints/break-point'; import {MediaMonitor} from '../media-monitor/media-monitor'; import {extendObject} from '../../utils/object-extend'; -export declare type SubscriptionList = Subscription[]; - export interface BreakPointX extends BreakPoint { key: string; baseKey: string; @@ -40,9 +38,9 @@ export class KeyOptions { * NOTE: these interceptions enables the logic in the fx API directives to remain terse and clean. */ export class ResponsiveActivation { - private _subscribers: SubscriptionList = []; private _activatedInputKey: string = ''; - private _registryMap: BreakPointX[]; + private _registryMap: BreakPointX[] = this._buildRegistryMap(); + private _subscribers: Subscription[] = this._configureChangeObservers(); /** * Constructor @@ -50,8 +48,6 @@ export class ResponsiveActivation { constructor(private _options: KeyOptions, private _mediaMonitor: MediaMonitor, private _onMediaChanges: MediaQuerySubscriber) { - this._registryMap = this._buildRegistryMap(); - this._subscribers = this._configureChangeObservers(); } /** @@ -64,15 +60,6 @@ export class ResponsiveActivation { return [...this._registryMap].reverse(); } - /** - * Accessor to the DI'ed directive property - * Each directive instance has a reference to the MediaMonitor which is - * used HERE to subscribe to mediaQuery change notifications. - */ - get mediaMonitor(): MediaMonitor { - return this._mediaMonitor; - } - /** * Determine which directive @Input() property is currently active (for the viewport size): * The key must be defined (in use) or fallback to the 'closest' overlapping property key @@ -89,7 +76,7 @@ export class ResponsiveActivation { * Get the currently activated @Input value or the fallback default @Input value */ get activatedInput(): any { - let key = this.activatedInputKey; + const key = this.activatedInputKey; return this.hasKeyValue(key) ? this._lookupKeyValue(key) : this._options.defaultValue; } @@ -97,17 +84,14 @@ export class ResponsiveActivation { * Fast validator for presence of attribute on the host element */ hasKeyValue(key: string) { - let value = this._options.inputKeys[key]; - return typeof value !== 'undefined'; + return this._options.inputKeys[key] !== undefined; } /** * Remove interceptors, restore original functions, and forward the onDestroy() call */ destroy() { - this._subscribers.forEach((link: Subscription) => { - link.unsubscribe(); - }); + this._subscribers.forEach(link => link.unsubscribe()); this._subscribers = []; } @@ -115,21 +99,21 @@ export class ResponsiveActivation { * For each *defined* API property, register a callback to `_onMonitorEvents( )` * Cache 1..n subscriptions for internal auto-unsubscribes when the the directive destructs */ - private _configureChangeObservers(): SubscriptionList { - let subscriptions: Subscription[] = []; + private _configureChangeObservers(): Subscription[] { + const subscriptions: Subscription[] = []; - this._registryMap.forEach((bp: BreakPointX) => { + this._registryMap.forEach(bp => { if (this._keyInUse(bp.key)) { // Inject directive default property key name: to let onMediaChange() calls // know which property is being triggered... - let buildChanges = (change: MediaChange) => { + const buildChanges = (change: MediaChange) => { change = change.clone(); change.property = this._options.baseKey; return change; }; subscriptions.push( - this.mediaMonitor + this._mediaMonitor .observe(bp.alias) .pipe(map(buildChanges)) .subscribe(change => { @@ -147,14 +131,12 @@ export class ResponsiveActivation { * in the HTML markup */ private _buildRegistryMap() { - return this.mediaMonitor.breakpoints - .map(bp => { - return extendObject({}, bp, { - baseKey: this._options.baseKey, // e.g. layout, hide, self-align, flex-wrap - key: this._options.baseKey + bp.suffix // e.g. layoutGtSm, layoutMd, layoutGtLg - }); - }) - .filter(bp => this._keyInUse(bp.key)); + return this._mediaMonitor.breakpoints + .map(bp => extendObject({}, bp, { + baseKey: this._options.baseKey, // e.g. layout, hide, self-align, flex-wrap + key: this._options.baseKey + bp.suffix // e.g. layoutGtSm, layoutMd, layoutGtLg + })) + .filter(bp => this._keyInUse(bp.key)); } /** @@ -162,9 +144,8 @@ export class ResponsiveActivation { * mq-activated input value or the default value */ protected _onMonitorEvents(change: MediaChange) { - if (change.property == this._options.baseKey) { + if (change.property === this._options.baseKey) { change.value = this._calculateActivatedValue(change); - this._onMediaChanges(change); } } @@ -187,9 +168,9 @@ export class ResponsiveActivation { */ private _calculateActivatedValue(current: MediaChange): any { const currentKey = this._options.baseKey + current.suffix; // e.g. suffix == 'GtSm', - let newKey = this._activatedInputKey; // e.g. newKey == hideGtSm + let newKey = this._activatedInputKey; // e.g. newKey == hideGtSm - newKey = current.matches ? currentKey : ((newKey == currentKey) ? '' : newKey); + newKey = current.matches ? currentKey : ((newKey === currentKey) ? '' : newKey); this._activatedInputKey = this._validateInputKey(newKey); return this.activatedInput; @@ -202,11 +183,11 @@ export class ResponsiveActivation { * NOTE: scans in the order defined by activeOverLaps (largest viewport ranges -> smallest ranges) */ private _validateInputKey(inputKey: string) { - let isMissingKey = (key: string) => !this._keyInUse(key); + const isMissingKey = (key: string) => !this._keyInUse(key); if (isMissingKey(inputKey)) { - this.mediaMonitor.activeOverlaps.some(bp => { - let key = this._options.baseKey + bp.suffix; + this._mediaMonitor.activeOverlaps.some(bp => { + const key = this._options.baseKey + bp.suffix; if (!isMissingKey(key)) { inputKey = key; return true; // exit .some() diff --git a/src/lib/extended/show-hide/hide.spec.ts b/src/lib/extended/show-hide/hide.spec.ts index 52a88b344..f89b8f6ea 100644 --- a/src/lib/extended/show-hide/hide.spec.ts +++ b/src/lib/extended/show-hide/hide.spec.ts @@ -13,7 +13,7 @@ import { CoreModule, MockMatchMedia, MockMatchMediaProvider, - ObservableMedia, + MediaObserver, SERVER_TOKEN, StyleUtils, } from '@angular/flex-layout/core'; @@ -257,7 +257,7 @@ class TestHideComponent implements OnInit { isHidden = true; menuHidden = true; - constructor(public media: ObservableMedia) { + constructor(public media: MediaObserver) { } toggleMenu() { diff --git a/src/lib/extended/show-hide/show.spec.ts b/src/lib/extended/show-hide/show.spec.ts index 209080f5c..d529d2d27 100644 --- a/src/lib/extended/show-hide/show.spec.ts +++ b/src/lib/extended/show-hide/show.spec.ts @@ -25,7 +25,7 @@ import { MediaMonitor, MockMatchMedia, MockMatchMediaProvider, - ObservableMedia, + MediaObserver, SERVER_TOKEN, StyleUtils, } from '@angular/flex-layout/core'; @@ -384,7 +384,7 @@ class TestShowComponent implements OnInit { isHidden = false; menuOpen = true; - constructor(public media: ObservableMedia) { + constructor(public media: MediaObserver) { } toggleMenu() { diff --git a/src/lib/flex/flex/flex.spec.ts b/src/lib/flex/flex/flex.spec.ts index e10e1fae2..7d6bfc7e1 100644 --- a/src/lib/flex/flex/flex.spec.ts +++ b/src/lib/flex/flex/flex.spec.ts @@ -27,7 +27,6 @@ import { queryFor, expectEl, } from '../../utils/testing/helpers'; -import {FlexModule} from '../module'; describe('flex directive', () => { @@ -930,7 +929,7 @@ describe('flex directive', () => { }); -@Injectable({providedIn: FlexModule}) +@Injectable() export class MockFlexStyleBuilder extends StyleBuilder { buildStyles(_input: string) { return {'flex': '1 1 30%'};