diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index 50b27b6f8d..225133bccb 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -12,6 +12,7 @@ import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import type { StyleScope } from '../ui/styling/style-scope'; import type { AndroidApplication as AndroidApplicationType, iOSApplication as iOSApplicationType } from '.'; import type { ApplicationEventData, CssChangedEventData, DiscardedErrorEventData, FontScaleChangedEventData, InitRootViewEventData, LaunchEventData, LoadAppCSSEventData, NativeScriptError, OrientationChangedEventData, SystemAppearanceChangedEventData, LayoutDirectionChangedEventData, UnhandledErrorEventData } from './application-interfaces'; +import { ApplicationEventNames, ApplicationEventNameType } from './application-event-names'; import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; import { getAppMainEntry, isAppInBackground, setAppInBackground, setAppMainEntry } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; @@ -179,26 +180,7 @@ interface ApplicationEvents { on(event: 'fontScaleChanged', callback: (args: FontScaleChangedEventData) => void, thisArg?: any): void; } -export class ApplicationCommon { - readonly launchEvent = 'launch'; - readonly suspendEvent = 'suspend'; - readonly displayedEvent = 'displayed'; - readonly backgroundEvent = 'background'; - readonly foregroundEvent = 'foreground'; - readonly resumeEvent = 'resume'; - readonly exitEvent = 'exit'; - readonly lowMemoryEvent = 'lowMemory'; - readonly uncaughtErrorEvent = 'uncaughtError'; - readonly discardedErrorEvent = 'discardedError'; - readonly orientationChangedEvent = 'orientationChanged'; - readonly systemAppearanceChangedEvent = 'systemAppearanceChanged'; - readonly layoutDirectionChangedEvent = 'layoutDirectionChanged'; - readonly fontScaleChangedEvent = 'fontScaleChanged'; - readonly livesyncEvent = 'livesync'; - readonly loadAppCssEvent = 'loadAppCss'; - readonly cssChangedEvent = 'cssChanged'; - readonly initRootViewEvent = 'initRootView'; - +export class ApplicationCommon implements ApplicationEventNameType { // Expose statically for backwards compat on AndroidApplication.on etc. /** * @deprecated Use `Application.android.on()` instead. @@ -277,6 +259,78 @@ export class ApplicationCommon { }; } + get launchEvent() { + return ApplicationEventNames.launchEvent; + } + + get suspendEvent() { + return ApplicationEventNames.suspendEvent; + } + + get displayedEvent() { + return ApplicationEventNames.displayedEvent; + } + + get backgroundEvent() { + return ApplicationEventNames.backgroundEvent; + } + + get foregroundEvent() { + return ApplicationEventNames.foregroundEvent; + } + + get resumeEvent() { + return ApplicationEventNames.resumeEvent; + } + + get exitEvent() { + return ApplicationEventNames.exitEvent; + } + + get lowMemoryEvent() { + return ApplicationEventNames.lowMemoryEvent; + } + + get uncaughtErrorEvent() { + return ApplicationEventNames.uncaughtErrorEvent; + } + + get discardedErrorEvent() { + return ApplicationEventNames.discardedErrorEvent; + } + + get orientationChangedEvent() { + return ApplicationEventNames.orientationChangedEvent; + } + + get systemAppearanceChangedEvent() { + return ApplicationEventNames.systemAppearanceChangedEvent; + } + + get layoutDirectionChangedEvent() { + return ApplicationEventNames.layoutDirectionChangedEvent; + } + + get fontScaleChangedEvent() { + return ApplicationEventNames.fontScaleChangedEvent; + } + + get livesyncEvent() { + return ApplicationEventNames.livesyncEvent; + } + + get loadAppCssEvent() { + return ApplicationEventNames.loadAppCssEvent; + } + + get cssChangedEvent() { + return ApplicationEventNames.cssChangedEvent; + } + + get initRootViewEvent() { + return ApplicationEventNames.initRootViewEvent; + } + /** * @internal */ diff --git a/packages/core/application/application-event-names.ts b/packages/core/application/application-event-names.ts new file mode 100644 index 0000000000..2eeeeb32f8 --- /dev/null +++ b/packages/core/application/application-event-names.ts @@ -0,0 +1,23 @@ +export const ApplicationEventNames = Object.freeze({ + launchEvent: 'launch', + suspendEvent: 'suspend', + displayedEvent: 'displayed', + backgroundEvent: 'background', + foregroundEvent: 'foreground', + resumeEvent: 'resume', + exitEvent: 'exit', + lowMemoryEvent: 'lowMemory', + uncaughtErrorEvent: 'uncaughtError', + discardedErrorEvent: 'discardedError', + orientationChangedEvent: 'orientationChanged', + systemAppearanceChangedEvent: 'systemAppearanceChanged', + layoutDirectionChangedEvent: 'layoutDirectionChanged', + fontScaleChangedEvent: 'fontScaleChanged', + livesyncEvent: 'livesync', + loadAppCssEvent: 'loadAppCss', + cssChangedEvent: 'cssChanged', + initRootViewEvent: 'initRootView', +}); + +export type ApplicationEventNameType = typeof ApplicationEventNames; +export type ApplicationEventName = ApplicationEventNameType[keyof ApplicationEventNameType]; diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index b3f6edc7cf..24b3c8112a 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -42,7 +42,7 @@ import { setA11yEnabled, } from '../accessibility/accessibility-common'; import { androidGetForegroundActivity, androidGetStartActivity, androidSetForegroundActivity, androidSetStartActivity, applyContentDescription } from './helpers'; -import { getImageFetcher, getNativeApp, getRootView, initImageCache, setA11yUpdatePropertiesCallback, setApplicationPropertiesCallback, setAppMainEntry, setNativeApp, setRootView, setToggleApplicationEventListenersCallback } from './helpers-common'; +import { getImageFetcher, getNativeApp, getRootView, initImageCache, setA11yUpdatePropertiesCallback, setApplicationPropertiesCallback, setAppMainEntry, setNativeApp, setRootView } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; import type { AndroidApplication as IAndroidApplication } from './application'; import lazy from '../utils/lazy'; @@ -1447,18 +1447,6 @@ function setAccessibilityDelegate(view: View): void { androidView.setAccessibilityDelegate(TNSAccessibilityDelegate); } -const applicationEvents: string[] = [Application.orientationChangedEvent, Application.systemAppearanceChangedEvent]; -function toggleApplicationEventListeners(toAdd: boolean, callback: (args: ApplicationEventData) => void) { - for (const eventName of applicationEvents) { - if (toAdd) { - Application.on(eventName, callback); - } else { - Application.off(eventName, callback); - } - } -} -setToggleApplicationEventListenersCallback(toggleApplicationEventListeners); - setApplicationPropertiesCallback(() => { return { orientation: Application.orientation(), diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index fca05bf8bf..4f59c8a659 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -47,7 +47,7 @@ import { enforceArray, } from '../accessibility/accessibility-common'; import { CoreTypes } from '../core-types'; -import { getiOSWindow, setA11yUpdatePropertiesCallback, setApplicationPropertiesCallback, setAppMainEntry, setiOSWindow, setRootView, setToggleApplicationEventListenersCallback } from './helpers-common'; +import { getiOSWindow, setA11yUpdatePropertiesCallback, setApplicationPropertiesCallback, setAppMainEntry, setiOSWindow, setRootView } from './helpers-common'; @NativeClass class NotificationObserver extends NSObject { @@ -1754,18 +1754,6 @@ export function initAccessibilityCssHelper(): void { } setInitAccessibilityCssHelper(initAccessibilityCssHelper); -const applicationEvents: string[] = [Application.orientationChangedEvent, Application.systemAppearanceChangedEvent]; -function toggleApplicationEventListeners(toAdd: boolean, callback: (args: ApplicationEventData) => void) { - for (const eventName of applicationEvents) { - if (toAdd) { - Application.on(eventName, callback); - } else { - Application.off(eventName, callback); - } - } -} -setToggleApplicationEventListenersCallback(toggleApplicationEventListeners); - setApplicationPropertiesCallback(() => { return { orientation: Application.orientation(), diff --git a/packages/core/application/helpers-common.ts b/packages/core/application/helpers-common.ts index eb6209e1bd..54d4a432b5 100644 --- a/packages/core/application/helpers-common.ts +++ b/packages/core/application/helpers-common.ts @@ -116,17 +116,6 @@ export function setAppMainEntry(entry: any /* NavigationEntry */) { _appMainEntry = entry; } -// Aids avoiding circular dependencies by allowing the application event listeners to be toggled -let _toggleApplicationEventListenersHandler: (toAdd: boolean, callback: (args: any) => void) => void; -export function toggleApplicationEventListeners(toAdd: boolean, callback: (args: any) => void) { - if (_toggleApplicationEventListenersHandler) { - _toggleApplicationEventListenersHandler(toAdd, callback); - } -} -export function setToggleApplicationEventListenersCallback(callback: (toAdd: boolean, callback: (args: any) => void) => void) { - _toggleApplicationEventListenersHandler = callback; -} - // Aids avoiding circular dependencies by allowing the application properties to be retrieved type ApplicationPropertyValues = { orientation: 'portrait' | 'landscape' | 'unknown'; systemAppearance: 'dark' | 'light' | null }; let _applicationPropertiesCallback: () => ApplicationPropertyValues; diff --git a/packages/core/application/helpers.android.ts b/packages/core/application/helpers.android.ts index 04292707a2..47f2142854 100644 --- a/packages/core/application/helpers.android.ts +++ b/packages/core/application/helpers.android.ts @@ -1,4 +1,3 @@ -import { SDK_VERSION } from '../utils/constants'; import { getNativeApp, updateA11yPropertiesCallback } from './helpers-common'; import { AccessibilityRole, AccessibilityState } from '../accessibility/accessibility-common'; import { Trace } from '../trace'; diff --git a/packages/core/css-mediaquery/index.spec.ts b/packages/core/css-mediaquery/index.spec.ts index 3627587dfb..da84dfb74f 100644 --- a/packages/core/css-mediaquery/index.spec.ts +++ b/packages/core/css-mediaquery/index.spec.ts @@ -1,118 +1,16 @@ -import { MediaQueryType, matchQuery, parseQuery } from '.'; +import { checkIfMediaQueryMatches } from '.'; +import { Screen } from '../platform'; describe('css-mediaquery', () => { - describe('parseQuery', () => { - it('should parse media queries without expressions', () => { - expect(parseQuery('screen')).toEqual([ - { - inverse: false, - type: MediaQueryType.screen, - features: [], - }, - ]); + const { widthDIPs } = Screen.mainScreen; - expect(parseQuery('not screen')).toEqual([ - { - inverse: true, - type: MediaQueryType.screen, - features: [], - }, - ]); + describe('checkIfMediaQueryMatches', () => { + it('should return true for a correct match', () => { + expect(checkIfMediaQueryMatches(`only screen and (max-width: ${widthDIPs})`)).toBe(true); }); - it('should throw a SyntaxError when a media query is invalid', () => { - expect(() => parseQuery('some crap')).toThrow(SyntaxError); - expect(() => parseQuery('48em')).toThrow(SyntaxError); - expect(() => parseQuery('screen and crap')).toThrow(SyntaxError); - expect(() => parseQuery('screen and (48em)')).toThrow(SyntaxError); - expect(() => parseQuery('screen and (foo:)')).toThrow(SyntaxError); - expect(() => parseQuery('()')).toThrow(SyntaxError); - expect(() => parseQuery('(foo) (bar)')).toThrow(SyntaxError); - expect(() => parseQuery('(foo:) and (bar)')).toThrow(SyntaxError); - }); - }); - - describe('matchQuery', () => { - describe('Equality check', () => { - it('orientation: should return true for a correct match (===)', () => { - expect(matchQuery('(orientation: portrait)', { orientation: 'portrait' })).toBe(true); - }); - - it('orientation: should return false for an incorrect match (===)', () => { - expect(matchQuery('(orientation: landscape)', { orientation: 'portrait' })).toBe(false); - }); - - it('prefers-color-scheme: should return true for a correct match (===)', () => { - expect(matchQuery('(prefers-color-scheme: dark)', { 'prefers-color-scheme': 'dark' })).toBe(true); - }); - - it('prefers-color-scheme: should return false for an incorrect match (===)', () => { - expect(matchQuery('(prefers-color-scheme: light)', { 'prefers-color-scheme': 'dark' })).toBe(false); - }); - - it('width: should return true for a correct match', () => { - expect(matchQuery('(width: 800px)', { width: 800 })).toBe(true); - }); - - it('width: should return false for an incorrect match', () => { - expect(matchQuery('(width: 800px)', { width: 900 })).toBe(false); - }); - }); - - describe('Type', () => { - it('should return true for a correct match', () => { - expect(matchQuery('screen', { type: MediaQueryType.screen })).toBe(true); - }); - - it('should return false for an incorrect match', () => { - expect( - matchQuery('screen and (orientation: portrait)', { - type: MediaQueryType.print, - orientation: 'portrait', - }), - ).toBe(false); - }); - - it('should return false for a media query without a type when type is specified in the value object', () => { - expect(matchQuery('(min-width: 500px)', { type: MediaQueryType.screen })).toBe(false); - }); - - it('should return true for a media query without a type when type is not specified in the value object', () => { - expect(matchQuery('(min-width: 500px)', { width: 700 })).toBe(true); - }); - }); - - describe('Not', () => { - it('should return false when theres a match on a `not` query', () => { - expect( - matchQuery('not screen and (orientation: portrait)', { - type: MediaQueryType.screen, - orientation: 'landscape', - }), - ).toBe(false); - }); - - it('should not disrupt an OR query', () => { - expect( - matchQuery('not screen and (color), screen and (min-height: 48em)', { - type: MediaQueryType.screen, - height: 1000, - }), - ).toBe(true); - }); - - it('should return false for when type === all', () => { - expect( - matchQuery('not all and (min-width: 48em)', { - type: MediaQueryType.all, - width: 1000, - }), - ).toBe(false); - }); - - it('should return true for inverted value', () => { - expect(matchQuery('not screen and (min-width: 48px)', { width: 24 })).toBe(true); - }); + it('should return false for an incorrect match', () => { + expect(checkIfMediaQueryMatches(`only screen and (max-width: ${widthDIPs - 1})`)).toBe(false); }); }); }); diff --git a/packages/core/css-mediaquery/index.ts b/packages/core/css-mediaquery/index.ts index c55ddb65a2..1656b5ba0c 100644 --- a/packages/core/css-mediaquery/index.ts +++ b/packages/core/css-mediaquery/index.ts @@ -1,176 +1,28 @@ -/* -Copyright (c) 2014, Yahoo! Inc. All rights reserved. -Copyrights licensed under the New BSD License. -See the accompanying LICENSE file for terms. -*/ - -// https://github.com/ericf/css-mediaquery - +import { getApplicationProperties } from '../application/helpers-common'; +import { Screen } from '../platform'; import { Trace } from '../trace'; -import { Length } from '../ui/styling/length-shared'; - -// ----------------------------------------------------------------------------- - -const RE_MEDIA_QUERY = /^(?:(only|not)?\s*([_a-z][_a-z0-9-]*)|(\([^\)]+\)))(?:\s*and\s*(.*))?$/i, - RE_MQ_EXPRESSION = /^\(\s*([_a-z-][_a-z0-9-]*)\s*(?:\:\s*([^\)]+))?\s*\)$/, - RE_MQ_FEATURE = /^(?:(min|max)-)?(.+)/, - RE_LENGTH_UNIT = /(em|rem|px|cm|mm|in|pt|pc)?\s*$/, - RE_RESOLUTION_UNIT = /(dpi|dpcm|dppx)?\s*$/; - -export enum MediaQueryType { - all = 'all', - print = 'print', - screen = 'screen', -} - -export type MediaQueryProperties = 'width' | 'height' | 'device-width' | 'device-height' | 'orientation' | 'prefers-color-scheme'; - -export interface MediaQueryEnvironmentParams { - type?: MediaQueryType; - width?: number; - height?: number; - 'device-width'?: number; - 'device-height'?: number; - orientation?: string; - 'prefers-color-scheme'?: string; -} - -export interface MediaQueryExpression { - inverse: boolean; - type: MediaQueryType; - features: MediaQueryFeature[]; -} - -export interface MediaQueryFeature { - modifier: string; - property: MediaQueryProperties | string; - value: string; -} - -export function matchQuery(mediaQuery: string, values: MediaQueryEnvironmentParams): boolean { - const expressions = parseQuery(mediaQuery); - - return expressions.some((query) => { - const { type, inverse, features } = query; - - // Either the parsed or specified `type` is "all", or the types must be - // equal for a match. - const typeMatch = query.type === 'all' || values.type === query.type; - - // Quit early when `type` doesn't match, but take "not" into account - if ((typeMatch && inverse) || !(typeMatch || inverse)) { - return false; - } - - const expressionsMatch = features.every((feature) => { - const value: any = values[feature.property]; - - // Missing or falsy values don't match - if (!value && value !== 0) { - return false; - } - - switch (feature.property) { - case 'orientation': - case 'prefers-color-scheme': - if (typeof value !== 'string') { - return false; - } - - return value.toLowerCase() === feature.value.toLowerCase(); - default: { - // Numeric properties - let numVal: number; - - if (typeof value !== 'number') { - Trace.write(`Unknown CSS media query feature property: '${feature.property}' on '${query}'`, Trace.categories.MediaQuery, Trace.messageType.warn); - return false; - } - - switch (feature.property) { - case 'width': - case 'height': - case 'device-width': - case 'device-height': { - numVal = Length.toDevicePixels(Length.parse(feature.value), 0); - break; - } - default: - Trace.write(`Unknown CSS media query feature property: '${feature.property}' on '${query}'`, Trace.categories.MediaQuery, Trace.messageType.warn); - break; - } - - switch (feature.modifier) { - case 'min': - return value >= numVal; - case 'max': - return value <= numVal; - default: - return value === numVal; - } - - break; - } - } +import { matchQuery, MediaQueryType } from './parser'; + +export function checkIfMediaQueryMatches(mediaQueryString: string): boolean { + const { widthPixels, heightPixels } = Screen.mainScreen; + + let matches: boolean; + + try { + const appProperties = getApplicationProperties(); + matches = matchQuery(mediaQueryString, { + type: MediaQueryType.screen, + width: widthPixels, + height: heightPixels, + 'device-width': widthPixels, + 'device-height': heightPixels, + orientation: appProperties.orientation, + 'prefers-color-scheme': appProperties.systemAppearance, }); + } catch (err) { + matches = false; + Trace.write(err, Trace.categories.MediaQuery, Trace.messageType.error); + } - return (expressionsMatch && !inverse) || (!expressionsMatch && inverse); - }); -} - -export function parseQuery(mediaQuery: string): MediaQueryExpression[] { - const mediaQueryStrings = mediaQuery.split(','); - - return mediaQueryStrings.map((query) => { - query = query.trim(); - - const captures = query.match(RE_MEDIA_QUERY); - - // Media query must be valid - if (!captures) { - throw new SyntaxError(`Invalid CSS media query: '${query}'`); - } - - const modifier = captures[1]; - const type = captures[2]; - const featureString = ((captures[3] || '') + (captures[4] || '')).trim(); - - const expression: MediaQueryExpression = { - inverse: !!modifier && modifier.toLowerCase() === 'not', - type: MediaQueryType[type ? type.toLowerCase() : 'all'] ?? 'all', - features: [], - }; - - // Check for media query features - if (!featureString) { - return expression; - } - - // Split features string into a list - const features = featureString.match(/\([^\)]+\)/g); - - // Media query must be valid - if (!features) { - throw new SyntaxError(`Invalid CSS media query features: '${featureString}' on '${query}'`); - } - - for (const feature of features) { - const captures = feature.match(RE_MQ_EXPRESSION); - - // Media query must be valid - if (!captures) { - throw new SyntaxError(`Invalid CSS media query feature: '${feature}' on '${query}'`); - } - - const featureData = captures[1].toLowerCase().match(RE_MQ_FEATURE); - - expression.features.push({ - modifier: featureData[1], - property: featureData[2], - value: captures[2], - }); - } - - return expression; - }); + return matches; } diff --git a/packages/core/css-mediaquery/LICENSE b/packages/core/css-mediaquery/parser/LICENSE similarity index 100% rename from packages/core/css-mediaquery/LICENSE rename to packages/core/css-mediaquery/parser/LICENSE diff --git a/packages/core/css-mediaquery/parser/index.spec.ts b/packages/core/css-mediaquery/parser/index.spec.ts new file mode 100644 index 0000000000..c4c3e084d1 --- /dev/null +++ b/packages/core/css-mediaquery/parser/index.spec.ts @@ -0,0 +1,118 @@ +import { MediaQueryType, matchQuery, parseQuery } from '.'; + +describe('css-mediaquery parser', () => { + describe('parseQuery', () => { + it('should parse media queries without expressions', () => { + expect(parseQuery('screen')).toEqual([ + { + inverse: false, + type: MediaQueryType.screen, + features: [], + }, + ]); + + expect(parseQuery('not screen')).toEqual([ + { + inverse: true, + type: MediaQueryType.screen, + features: [], + }, + ]); + }); + + it('should throw a SyntaxError when a media query is invalid', () => { + expect(() => parseQuery('some crap')).toThrow(SyntaxError); + expect(() => parseQuery('48em')).toThrow(SyntaxError); + expect(() => parseQuery('screen and crap')).toThrow(SyntaxError); + expect(() => parseQuery('screen and (48em)')).toThrow(SyntaxError); + expect(() => parseQuery('screen and (foo:)')).toThrow(SyntaxError); + expect(() => parseQuery('()')).toThrow(SyntaxError); + expect(() => parseQuery('(foo) (bar)')).toThrow(SyntaxError); + expect(() => parseQuery('(foo:) and (bar)')).toThrow(SyntaxError); + }); + }); + + describe('matchQuery', () => { + describe('Equality check', () => { + it('orientation: should return true for a correct match (===)', () => { + expect(matchQuery('(orientation: portrait)', { orientation: 'portrait' })).toBe(true); + }); + + it('orientation: should return false for an incorrect match (===)', () => { + expect(matchQuery('(orientation: landscape)', { orientation: 'portrait' })).toBe(false); + }); + + it('prefers-color-scheme: should return true for a correct match (===)', () => { + expect(matchQuery('(prefers-color-scheme: dark)', { 'prefers-color-scheme': 'dark' })).toBe(true); + }); + + it('prefers-color-scheme: should return false for an incorrect match (===)', () => { + expect(matchQuery('(prefers-color-scheme: light)', { 'prefers-color-scheme': 'dark' })).toBe(false); + }); + + it('width: should return true for a correct match', () => { + expect(matchQuery('(width: 800px)', { width: 800 })).toBe(true); + }); + + it('width: should return false for an incorrect match', () => { + expect(matchQuery('(width: 800px)', { width: 900 })).toBe(false); + }); + }); + + describe('Type', () => { + it('should return true for a correct match', () => { + expect(matchQuery('screen', { type: MediaQueryType.screen })).toBe(true); + }); + + it('should return false for an incorrect match', () => { + expect( + matchQuery('screen and (orientation: portrait)', { + type: MediaQueryType.print, + orientation: 'portrait', + }), + ).toBe(false); + }); + + it('should return false for a media query without a type when type is specified in the value object', () => { + expect(matchQuery('(min-width: 500px)', { type: MediaQueryType.screen })).toBe(false); + }); + + it('should return true for a media query without a type when type is not specified in the value object', () => { + expect(matchQuery('(min-width: 500px)', { width: 700 })).toBe(true); + }); + }); + + describe('Not', () => { + it('should return false when theres a match on a `not` query', () => { + expect( + matchQuery('not screen and (orientation: portrait)', { + type: MediaQueryType.screen, + orientation: 'landscape', + }), + ).toBe(false); + }); + + it('should not disrupt an OR query', () => { + expect( + matchQuery('not screen and (color), screen and (min-height: 48em)', { + type: MediaQueryType.screen, + height: 1000, + }), + ).toBe(true); + }); + + it('should return false for when type === all', () => { + expect( + matchQuery('not all and (min-width: 48em)', { + type: MediaQueryType.all, + width: 1000, + }), + ).toBe(false); + }); + + it('should return true for inverted value', () => { + expect(matchQuery('not screen and (min-width: 48px)', { width: 24 })).toBe(true); + }); + }); + }); +}); diff --git a/packages/core/css-mediaquery/parser/index.ts b/packages/core/css-mediaquery/parser/index.ts new file mode 100644 index 0000000000..78bbf35445 --- /dev/null +++ b/packages/core/css-mediaquery/parser/index.ts @@ -0,0 +1,176 @@ +/* +Copyright (c) 2014, Yahoo! Inc. All rights reserved. +Copyrights licensed under the New BSD License. +See the accompanying LICENSE file for terms. +*/ + +// https://github.com/ericf/css-mediaquery + +import { Trace } from '../../trace'; +import { Length } from '../../ui/styling/length-shared'; + +// ----------------------------------------------------------------------------- + +const RE_MEDIA_QUERY = /^(?:(only|not)?\s*([_a-z][_a-z0-9-]*)|(\([^\)]+\)))(?:\s*and\s*(.*))?$/i, + RE_MQ_EXPRESSION = /^\(\s*([_a-z-][_a-z0-9-]*)\s*(?:\:\s*([^\)]+))?\s*\)$/, + RE_MQ_FEATURE = /^(?:(min|max)-)?(.+)/, + RE_LENGTH_UNIT = /(em|rem|px|cm|mm|in|pt|pc)?\s*$/, + RE_RESOLUTION_UNIT = /(dpi|dpcm|dppx)?\s*$/; + +export enum MediaQueryType { + all = 'all', + print = 'print', + screen = 'screen', +} + +export type MediaQueryProperties = 'width' | 'height' | 'device-width' | 'device-height' | 'orientation' | 'prefers-color-scheme'; + +export interface MediaQueryEnvironmentParams { + type?: MediaQueryType; + width?: number; + height?: number; + 'device-width'?: number; + 'device-height'?: number; + orientation?: string; + 'prefers-color-scheme'?: string; +} + +export interface MediaQueryExpression { + inverse: boolean; + type: MediaQueryType; + features: MediaQueryFeature[]; +} + +export interface MediaQueryFeature { + modifier: string; + property: MediaQueryProperties | string; + value: string; +} + +export function matchQuery(mediaQuery: string, values: MediaQueryEnvironmentParams): boolean { + const expressions = parseQuery(mediaQuery); + + return expressions.some((query) => { + const { type, inverse, features } = query; + + // Either the parsed or specified `type` is "all", or the types must be + // equal for a match. + const typeMatch = query.type === 'all' || values.type === query.type; + + // Quit early when `type` doesn't match, but take "not" into account + if ((typeMatch && inverse) || !(typeMatch || inverse)) { + return false; + } + + const expressionsMatch = features.every((feature) => { + const value: any = values[feature.property]; + + // Missing or falsy values don't match + if (!value && value !== 0) { + return false; + } + + switch (feature.property) { + case 'orientation': + case 'prefers-color-scheme': + if (typeof value !== 'string') { + return false; + } + + return value.toLowerCase() === feature.value.toLowerCase(); + default: { + // Numeric properties + let numVal: number; + + if (typeof value !== 'number') { + Trace.write(`Unknown CSS media query feature property: '${feature.property}' on '${query}'`, Trace.categories.MediaQuery, Trace.messageType.warn); + return false; + } + + switch (feature.property) { + case 'width': + case 'height': + case 'device-width': + case 'device-height': { + numVal = Length.toDevicePixels(Length.parse(feature.value), 0); + break; + } + default: + Trace.write(`Unknown CSS media query feature property: '${feature.property}' on '${query}'`, Trace.categories.MediaQuery, Trace.messageType.warn); + break; + } + + switch (feature.modifier) { + case 'min': + return value >= numVal; + case 'max': + return value <= numVal; + default: + return value === numVal; + } + + break; + } + } + }); + + return (expressionsMatch && !inverse) || (!expressionsMatch && inverse); + }); +} + +export function parseQuery(mediaQuery: string): MediaQueryExpression[] { + const mediaQueryStrings = mediaQuery.split(','); + + return mediaQueryStrings.map((query) => { + query = query.trim(); + + const captures = query.match(RE_MEDIA_QUERY); + + // Media query must be valid + if (!captures) { + throw new SyntaxError(`Invalid CSS media query: '${query}'`); + } + + const modifier = captures[1]; + const type = captures[2]; + const featureString = ((captures[3] || '') + (captures[4] || '')).trim(); + + const expression: MediaQueryExpression = { + inverse: !!modifier && modifier.toLowerCase() === 'not', + type: MediaQueryType[type ? type.toLowerCase() : 'all'] ?? 'all', + features: [], + }; + + // Check for media query features + if (!featureString) { + return expression; + } + + // Split features string into a list + const features = featureString.match(/\([^\)]+\)/g); + + // Media query must be valid + if (!features) { + throw new SyntaxError(`Invalid CSS media query features: '${featureString}' on '${query}'`); + } + + for (const feature of features) { + const captures = feature.match(RE_MQ_EXPRESSION); + + // Media query must be valid + if (!captures) { + throw new SyntaxError(`Invalid CSS media query feature: '${feature}' on '${query}'`); + } + + const featureData = captures[1].toLowerCase().match(RE_MQ_FEATURE); + + expression.features.push({ + modifier: featureData[1], + property: featureData[2], + value: captures[2], + }); + } + + return expression; + }); +} diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index adc95121cb..387c3f6ed1 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -8,7 +8,7 @@ export type { NativeScriptConfig } from './config'; export * from './application'; -export { androidRegisterBroadcastReceiver, androidUnregisterBroadcastReceiver, androidRegisteredReceivers, iosAddNotificationObserver, iosRemoveNotificationObserver, iosNotificationObservers } from './application/helpers'; +export * from './application/application-event-names'; export { getNativeApp, setNativeApp } from './application/helpers-common'; export * as ApplicationSettings from './application-settings'; export namespace AccessibilityEvents { @@ -46,6 +46,7 @@ export type { iosSymbolScaleType } from './image-source'; export { ModuleNameResolver } from './module-name-resolver'; export { _setResolver } from './module-name-resolver/helpers'; export type { PlatformContext } from './module-name-resolver'; +export * from './media-query-list'; export type { ModuleListProvider } from './module-name-resolver/helpers'; export { isAndroid, isIOS, isVisionOS, isApple, Screen, Device, platformNames } from './platform'; export type { IDevice } from './platform'; diff --git a/packages/core/index.ts b/packages/core/index.ts index 4e12c9fe93..9afc1c091a 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -3,6 +3,7 @@ // Init globals first (use import to ensure it's always at the top) import './globals'; export * from './application'; +export * from './application/application-event-names'; export { getNativeApp, setNativeApp } from './application/helpers-common'; export * as ApplicationSettings from './application-settings'; import * as Accessibility from './accessibility'; @@ -32,6 +33,7 @@ export { ImageAsset } from './image-asset'; export type { ImageAssetOptions } from './image-asset'; export { ImageSource } from './image-source'; export type { iosSymbolScaleType } from './image-source'; +export * from './media-query-list'; export { ModuleNameResolver } from './module-name-resolver'; export { _setResolver } from './module-name-resolver/helpers'; export type { PlatformContext } from './module-name-resolver'; diff --git a/packages/core/media-query-list/index.spec.ts b/packages/core/media-query-list/index.spec.ts index 5950a40907..762923d186 100644 --- a/packages/core/media-query-list/index.spec.ts +++ b/packages/core/media-query-list/index.spec.ts @@ -1,19 +1,9 @@ -import { matchMedia, checkIfMediaQueryMatches, MediaQueryList } from '.'; +import { matchMedia, MediaQueryList } from '.'; import { Screen } from '../platform'; describe('media-query-list', () => { const { widthDIPs } = Screen.mainScreen; - describe('checkIfMediaQueryMatches', () => { - it('should return true for a correct match', () => { - expect(checkIfMediaQueryMatches(`only screen and (max-width: ${widthDIPs})`)).toBe(true); - }); - - it('should return false for an incorrect match', () => { - expect(checkIfMediaQueryMatches(`only screen and (max-width: ${widthDIPs - 1})`)).toBe(false); - }); - }); - describe('matchMedia', () => { it('should return a MediaQueryList that matches the css media query', () => { const matchMediaWrapper = () => matchMedia(`only screen and (max-width: ${widthDIPs})`); @@ -44,31 +34,5 @@ describe('media-query-list', () => { it('should throw when calling constructor', () => { expect(() => new MediaQueryList()).toThrow(new TypeError('Illegal constructor')); }); - - it('should throw when accessing matches and media getters', () => { - const error = new TypeError('Illegal invocation'); - - expect(() => MediaQueryList.prototype.matches).toThrow(error); - expect(() => MediaQueryList.prototype.media).toThrow(error); - }); - - it('should throw when accessing or modifying onchange event', () => { - const error = new TypeError('Illegal invocation'); - - expect(() => MediaQueryList.prototype.onchange).toThrow(error); - expect(() => { - MediaQueryList.prototype.onchange = null; - }).toThrow(error); - }); - - it('should throw when adding or removing event listeners', () => { - const eventCallback = (data) => {}; - const error = new TypeError('Illegal invocation'); - - expect(() => MediaQueryList.prototype.addEventListener('change', eventCallback)).toThrow(error); - expect(() => MediaQueryList.prototype.removeEventListener('change', eventCallback)).toThrow(error); - expect(() => MediaQueryList.prototype.addListener(eventCallback)).toThrow(error); - expect(() => MediaQueryList.prototype.removeListener(eventCallback)).toThrow(error); - }); }); }); diff --git a/packages/core/media-query-list/index.ts b/packages/core/media-query-list/index.ts index 31f540a9ca..b3ca296cdb 100644 --- a/packages/core/media-query-list/index.ts +++ b/packages/core/media-query-list/index.ts @@ -1,140 +1,113 @@ -import { EventData, Observable } from '../data/observable'; -import { Screen } from '../platform/screen'; -import { getApplicationProperties, toggleApplicationEventListeners } from '../application/helpers-common'; +import { ApplicationEventName, ApplicationEventNames } from '../application/application-event-names'; import type { ApplicationEventData } from '../application/application-interfaces'; -import { matchQuery, MediaQueryType } from '../css-mediaquery'; -import { Trace } from '../trace'; +import { checkIfMediaQueryMatches } from '../css-mediaquery'; +import { EventData, Observable } from '../data/observable'; +import { getNativeScriptGlobals } from '../globals/global-utils'; +import { Optional } from '../utils/typescript-utils'; + +export type MediaQueryEventCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => any; + +export interface MediaQueryListEvent { + readonly matches: boolean; + readonly media: string; +} -const mediaQueryLists: MediaQueryListImpl[] = []; +export interface MediaQueryListEventData extends EventData, MediaQueryListEvent {} -// In browser, developers cannot create MediaQueryList instances without calling matchMedia -let isMediaInitializationEnabled: boolean = false; +const globalEvents = getNativeScriptGlobals().events; +const applicationEvents: ApplicationEventName[] = [ApplicationEventNames.orientationChangedEvent, ApplicationEventNames.systemAppearanceChangedEvent]; +const mediaQueryLists: MediaQueryList[] = []; + +function toggleApplicationEventListeners(toAdd: boolean) { + for (const eventName of applicationEvents) { + if (toAdd) { + globalEvents.on(eventName, onDeviceChange); + } else { + globalEvents.off(eventName, onDeviceChange); + } + } +} function onDeviceChange(args: ApplicationEventData) { for (const mql of mediaQueryLists) { const matches = checkIfMediaQueryMatches(mql.media); if (mql.matches !== matches) { - mql._matches = matches; - mql.notify({ - eventName: MediaQueryListImpl.changeEvent, + eventName: MediaQueryList.changeEvent, object: mql, - matches: mql.matches, + matches: matches, media: mql.media, }); } } } -function checkIfMediaQueryMatches(mediaQueryString: string): boolean { - const { widthPixels, heightPixels } = Screen.mainScreen; - - let matches: boolean; - - try { - const appProperties = getApplicationProperties(); - matches = matchQuery(mediaQueryString, { - type: MediaQueryType.screen, - width: widthPixels, - height: heightPixels, - 'device-width': widthPixels, - 'device-height': heightPixels, - orientation: appProperties.orientation, - 'prefers-color-scheme': appProperties.systemAppearance, - }); - } catch (err) { - matches = false; - Trace.write(err, Trace.categories.MediaQuery, Trace.messageType.error); - } - - return matches; +export function matchMedia(mediaQueryString: string): MediaQueryList { + return Reflect.construct(MediaQueryListInternal, [mediaQueryString], MediaQueryList) as any; } -function matchMedia(mediaQueryString: string): MediaQueryListImpl { - isMediaInitializationEnabled = true; - const mediaQueryList = new MediaQueryListImpl(); - isMediaInitializationEnabled = false; +// For internal usage +class MediaQueryListInternal extends Observable { + declare readonly mMedia: string; + declare mMatches: boolean; - mediaQueryList._media = mediaQueryString; - mediaQueryList._matches = checkIfMediaQueryMatches(mediaQueryString); - return mediaQueryList; + constructor(media: string) { + super(); + + this.mMedia = media; + this.mMatches = checkIfMediaQueryMatches(media); + } } -class MediaQueryListImpl extends Observable implements MediaQueryList { +export class MediaQueryList extends Observable { public static readonly changeEvent = 'change'; - public _media: string; - public _matches: boolean; + private readonly mMedia: string; + private mMatches: boolean; - private _onChange: (this: MediaQueryList, ev: MediaQueryListEvent) => any; - private mediaQueryChangeListeners: Map<(this: MediaQueryList, ev: MediaQueryListEvent) => any, (data: EventData) => void>; + private mOnchange: MediaQueryEventCallback; + private mMediaQueryChangeListeners: Map void>; constructor() { super(); - - if (!isMediaInitializationEnabled) { - throw new TypeError('Illegal constructor'); - } - - Object.defineProperties(this, { - _media: { - writable: true, - }, - _matches: { - writable: true, - }, - _onChange: { - writable: true, - value: null, - }, - mediaQueryChangeListeners: { - value: new Map<(this: MediaQueryList, ev: MediaQueryListEvent) => any, (data: EventData) => void>(), - }, - _throwInvocationError: { - value: null, - }, - }); + throw new TypeError('Illegal constructor'); } get media(): string { - this._throwInvocationError?.(); - - return this._media; + return this.mMedia; } get matches(): boolean { - this._throwInvocationError?.(); - - return this._matches; + return this.mMatches; } - // @ts-ignore - public addEventListener(eventName: string, callback: (data: EventData) => void, thisArg?: any, once?: boolean): void { - this._throwInvocationError?.(); + public override notify>(data: T): void { + // Update match state + if (data.eventName === MediaQueryList.changeEvent) { + this.mMatches = data.matches; + } + super.notify(data); + } - const hasChangeListeners = this.hasListeners(MediaQueryListImpl.changeEvent); + public override addEventListener(eventName: 'change', callback: (data: MediaQueryListEventData) => void, thisArg?: any, once?: boolean): void { + const hasChangeListeners = this.hasListeners(MediaQueryList.changeEvent); - // Call super method first since it throws in the case of bad parameters super.addEventListener(eventName, callback, thisArg, once); - if (eventName === MediaQueryListImpl.changeEvent && !hasChangeListeners) { + if (eventName === MediaQueryList.changeEvent && !hasChangeListeners) { mediaQueryLists.push(this); if (mediaQueryLists.length === 1) { - toggleApplicationEventListeners(true, onDeviceChange); + toggleApplicationEventListeners(true); } } } - // @ts-ignore - public removeEventListener(eventName: string, callback?: (data: EventData) => void, thisArg?: any): void { - this._throwInvocationError?.(); - - // Call super method first since it throws in the case of bad parameters + public override removeEventListener(eventName: 'change', callback?: (data: MediaQueryListEventData) => void, thisArg?: any): void { super.removeEventListener(eventName, callback, thisArg); - if (eventName === MediaQueryListImpl.changeEvent) { - const hasChangeListeners = this.hasListeners(MediaQueryListImpl.changeEvent); + if (eventName === MediaQueryList.changeEvent) { + const hasChangeListeners = this.hasListeners(MediaQueryList.changeEvent); if (!hasChangeListeners) { const index = mediaQueryLists.indexOf(this); @@ -142,64 +115,64 @@ class MediaQueryListImpl extends Observable implements MediaQueryList { mediaQueryLists.splice(index, 1); if (!mediaQueryLists.length) { - toggleApplicationEventListeners(false, onDeviceChange); + toggleApplicationEventListeners(false); } } } } } - addListener(callback: (this: MediaQueryList, ev: MediaQueryListEvent) => any): void { - this._throwInvocationError?.(); - + addListener(callback: (this: MediaQueryList, ev: MediaQueryList) => any): void { // This kind of implementation helps maintain listener registration order // regardless of using the deprecated methods or property onchange - const wrapperCallback = (data) => { - callback.call(this, { + const wrapperCallback = (_data) => { + callback.call(this, { matches: this.matches, media: this.media, }); }; // Call this method first since it throws in the case of bad parameters - this.addEventListener(MediaQueryListImpl.changeEvent, wrapperCallback); - this.mediaQueryChangeListeners.set(callback, wrapperCallback); + this.addEventListener(MediaQueryList.changeEvent, wrapperCallback); + + if (!this.mMediaQueryChangeListeners) { + this.mMediaQueryChangeListeners = new Map(); + } + this.mMediaQueryChangeListeners.set(callback, wrapperCallback); } - removeListener(callback: (this: MediaQueryList, ev: MediaQueryListEvent) => any): void { - this._throwInvocationError?.(); + removeListener(callback: MediaQueryEventCallback): void { + const mediaChangeListeners = this.mMediaQueryChangeListeners; - if (this.mediaQueryChangeListeners.has(callback)) { + if (mediaChangeListeners && mediaChangeListeners.has(callback)) { // Call this method first since it throws in the case of bad parameters - this.removeEventListener(MediaQueryListImpl.changeEvent, this.mediaQueryChangeListeners.get(callback)); - this.mediaQueryChangeListeners.delete(callback); + this.removeEventListener(MediaQueryList.changeEvent, mediaChangeListeners.get(callback)); + mediaChangeListeners.delete(callback); } } - public get onchange(): (this: MediaQueryList, ev: MediaQueryListEvent) => any { - this._throwInvocationError?.(); - - return this._onChange; + public get onchange(): MediaQueryEventCallback { + return this.mOnchange; } - public set onchange(callback: (this: MediaQueryList, ev: MediaQueryListEvent) => any) { - this._throwInvocationError?.(); - + public set onchange(callback: MediaQueryEventCallback) { // Remove old listener if any - if (this._onChange) { - this.removeListener(this._onChange); + if (this.mOnchange) { + this.removeListener(this.mOnchange); } if (callback) { this.addListener(callback); } - this._onChange = callback; + this.mOnchange = callback; } - private _throwInvocationError() { - throw new TypeError('Illegal invocation'); + toJSON() { + return { + media: this.media, + matches: this.matches, + onchange: this.onchange, + } as this; } } - -export { matchMedia, MediaQueryListImpl as MediaQueryList, checkIfMediaQueryMatches }; diff --git a/packages/core/ui/core/view/view-helper/index.android.ts b/packages/core/ui/core/view/view-helper/index.android.ts index efe07ecc24..f8f2c71031 100644 --- a/packages/core/ui/core/view/view-helper/index.android.ts +++ b/packages/core/ui/core/view/view-helper/index.android.ts @@ -1,5 +1,4 @@ import { Color } from '../../../../color'; -import { Trace } from '../../../../trace'; import { SDK_VERSION } from '../../../../utils/constants'; export * from './view-helper-common'; diff --git a/packages/core/ui/styling/css-selector.ts b/packages/core/ui/styling/css-selector.ts index 3cc4bf640b..206625b040 100644 --- a/packages/core/ui/styling/css-selector.ts +++ b/packages/core/ui/styling/css-selector.ts @@ -1,11 +1,10 @@ import { parse as convertToCSSWhatSelector, Selector as CSSWhatSelector, DataType as CSSWhatDataType } from 'css-what'; import '../../globals'; import { isCssVariable } from '../core/properties'; -import { Trace, CoreTypes } from './styling-shared'; import { isNullOrUndefined } from '../../utils/types'; import * as ReworkCSS from '../../css'; -import { checkIfMediaQueryMatches } from '../../media-query-list'; +import { checkIfMediaQueryMatches } from '../../css-mediaquery'; export const MEDIA_QUERY_SEPARATOR = '&&'; @@ -675,7 +674,9 @@ export namespace Selector { public trackChanges(node: Node, map: ChangeAccumulator): void { this.selectors.forEach((sel, i) => { if (i === 0) { - node && sel.trackChanges(node, map); + if (node) { + sel.trackChanges(node, map); + } } else { node = node.parent;