feat(core): css media query support (#10530)

This commit is contained in:
Dimitris-Rafail Katsampas
2024-07-01 19:28:59 +03:00
committed by GitHub
parent 6dd441d6ba
commit 9fd361c2e6
32 changed files with 1812 additions and 476 deletions

View File

@ -1,7 +1,7 @@
import * as TKUnit from '../../tk-unit';
import { StyleScope } from '@nativescript/core/ui/styling/style-scope';
import * as keyframeAnimation from '@nativescript/core/ui/animation/keyframe-animation';
import { CoreTypes } from '@nativescript/core';
import { Application, CoreTypes, Screen } from '@nativescript/core';
import * as helper from '../../ui-helper';
import * as stackModule from '@nativescript/core/ui/layouts/stack-layout';
import * as labelModule from '@nativescript/core/ui/label';
@ -18,7 +18,6 @@ function createAnimationFromCSS(css: string, name: string): keyframeAnimation.Ke
const selector = findSelectorInScope(scope, name);
if (selector) {
const animation = scope.getAnimations(selector.ruleset)[0];
return animation;
}
}
@ -40,7 +39,7 @@ export function test_ReadAnimationProperties() {
animation-direction: reverse;
animation-fill-mode: forwards;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.name, 'first');
TKUnit.assertEqual(animation.duration, 4000);
@ -56,7 +55,7 @@ export function test_ReadTheAnimationProperty() {
`.test {
animation: second 0.2s ease-out 1s 2;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.name, 'second');
TKUnit.assertEqual(animation.duration, 200);
@ -70,42 +69,42 @@ export function test_ReadAnimationCurve() {
`.test {
animation-timing-function: ease-in;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.curve, CoreTypes.AnimationCurve.easeIn);
animation = createAnimationFromCSS(
`.test {
animation-timing-function: ease-out;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.curve, CoreTypes.AnimationCurve.easeOut);
animation = createAnimationFromCSS(
`.test {
animation-timing-function: linear;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.curve, CoreTypes.AnimationCurve.linear);
animation = createAnimationFromCSS(
`.test {
animation-timing-function: ease-in-out;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.curve, CoreTypes.AnimationCurve.easeInOut);
animation = createAnimationFromCSS(
`.test {
animation-timing-function: spring;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.curve, CoreTypes.AnimationCurve.spring);
animation = createAnimationFromCSS(
`.test {
animation-timing-function: cubic-bezier(0.1, 1.0, 0.5, 0.5);
}`,
'test'
'test',
);
let curve = animation.curve;
TKUnit.assert(curve.x1 === 0.1 && curve.y1 === 1.0 && curve.x2 === 0.5 && curve.y2 === 0.5);
@ -116,14 +115,14 @@ export function test_ReadIterations() {
`.test {
animation-iteration-count: 5;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.iterations, 5);
animation = createAnimationFromCSS(
`.test {
animation-iteration-count: infinite;
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.iterations, Number.POSITIVE_INFINITY);
}
@ -133,21 +132,21 @@ export function test_ReadFillMode() {
`.test {
animation-iteration-count: 5;
}`,
'test'
'test',
);
TKUnit.assertFalse(animation.isForwards);
animation = createAnimationFromCSS(
`.test {
animation-fill-mode: forwards;
}`,
'test'
'test',
);
TKUnit.assertTrue(animation.isForwards);
animation = createAnimationFromCSS(
`.test {
animation-fill-mode: backwards;
}`,
'test'
'test',
);
TKUnit.assertFalse(animation.isForwards);
}
@ -157,21 +156,21 @@ export function test_ReadDirection() {
`.test {
animation-iteration-count: 5;
}`,
'test'
'test',
);
TKUnit.assertFalse(animation.isReverse);
animation = createAnimationFromCSS(
`.test {
animation-direction: reverse;
}`,
'test'
'test',
);
TKUnit.assertTrue(animation.isReverse);
animation = createAnimationFromCSS(
`.test {
animation-direction: normal;
}`,
'test'
'test',
);
TKUnit.assertFalse(animation.isReverse);
}
@ -183,7 +182,7 @@ export function test_ReadKeyframe() {
from { background-color: red; }
to { background-color: blue; }
}`,
'test'
'test',
);
TKUnit.assert(animation !== undefined, 'CSS selector was not created!');
TKUnit.assertEqual(animation.name, 'test', 'Wrong animation name!');
@ -200,7 +199,7 @@ export function test_ReadTransformAllSet() {
@keyframes test {
to { transform: rotate(10) scaleX(5) translate(100, 200); }
}`,
'test'
'test',
);
const { rotate, scale, translate } = getTransformsValues(animation.keyframes[0].declarations);
@ -219,7 +218,7 @@ export function test_ReadTransformNone() {
@keyframes test {
to { transform: none; }
}`,
'test'
'test',
);
const { rotate, scale, translate } = getTransformsValues(animation.keyframes[0].declarations);
@ -238,7 +237,7 @@ export function test_ReadScale() {
@keyframes test {
to { transform: scale(-5, 12.3pt); }
}`,
'test'
'test',
);
const { scale } = getTransforms(animation.keyframes[0].declarations);
@ -253,7 +252,7 @@ export function test_ReadScaleSingle() {
@keyframes test {
to { transform: scale(2); }
}`,
'test'
'test',
);
const { scale } = getTransforms(animation.keyframes[0].declarations);
@ -268,7 +267,7 @@ export function test_ReadScaleXY() {
@keyframes test {
to { transform: scaleX(5) scaleY(10); }
}`,
'test'
'test',
);
const { scale } = getTransforms(animation.keyframes[0].declarations);
@ -283,7 +282,7 @@ export function test_ReadScaleX() {
@keyframes test {
to { transform: scaleX(12.5); }
}`,
'test'
'test',
);
const { scale } = getTransforms(animation.keyframes[0].declarations);
@ -299,7 +298,7 @@ export function test_ReadScaleY() {
@keyframes test {
to { transform: scaleY(10); }
}`,
'test'
'test',
);
const { scale } = getTransforms(animation.keyframes[0].declarations);
@ -315,7 +314,7 @@ export function test_ReadScale3d() {
@keyframes test {
to { transform: scale3d(10, 20, 30); }
}`,
'test'
'test',
);
const { scale } = getTransforms(animation.keyframes[0].declarations);
@ -330,7 +329,7 @@ export function test_ReadTranslate() {
@keyframes test {
to { transform: translate(100, 20); }
}`,
'test'
'test',
);
const { translate } = getTransforms(animation.keyframes[0].declarations);
@ -345,7 +344,7 @@ export function test_ReadTranslateSingle() {
@keyframes test {
to { transform: translate(30); }
}`,
'test'
'test',
);
const { translate } = getTransforms(animation.keyframes[0].declarations);
@ -360,7 +359,7 @@ export function test_ReadTranslateXY() {
@keyframes test {
to { transform: translateX(5) translateY(10); }
}`,
'test'
'test',
);
const { translate } = getTransforms(animation.keyframes[0].declarations);
@ -375,7 +374,7 @@ export function test_ReadTranslateX() {
@keyframes test {
to { transform: translateX(12.5); }
}`,
'test'
'test',
);
const { translate } = getTransforms(animation.keyframes[0].declarations);
@ -391,7 +390,7 @@ export function test_ReadTranslateY() {
@keyframes test {
to { transform: translateY(10); }
}`,
'test'
'test',
);
const { translate } = getTransforms(animation.keyframes[0].declarations);
@ -407,7 +406,7 @@ export function test_ReadTranslate3d() {
@keyframes test {
to { transform: translate3d(10, 20, 30); }
}`,
'test'
'test',
);
const { translate } = getTransforms(animation.keyframes[0].declarations);
@ -422,7 +421,7 @@ export function test_ReadRotate() {
@keyframes test {
to { transform: rotate(5); }
}`,
'test'
'test',
);
const { rotate } = getTransforms(animation.keyframes[0].declarations);
@ -436,7 +435,7 @@ export function test_ReadRotateDeg() {
@keyframes test {
to { transform: rotate(45deg); }
}`,
'test'
'test',
);
const { rotate } = getTransforms(animation.keyframes[0].declarations);
@ -450,7 +449,7 @@ export function test_ReadRotateRad() {
@keyframes test {
to { transform: rotate(0.7853981634rad); }
}`,
'test'
'test',
);
const { rotate } = getTransforms(animation.keyframes[0].declarations);
@ -467,7 +466,7 @@ export function test_ReadAnimationWithUnsortedKeyframes() {
40%, 80% { opacity: 0.3; }
to { opacity: 1; }
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.keyframes.length, 6);
TKUnit.assertEqual(animation.keyframes[0].declarations[0].value, 0);
@ -502,7 +501,7 @@ export function test_LoadTwoAnimationsWithTheSameName() {
from { opacity: 0; }
to { opacity: 0.5; } /* this should override the previous one */
}`,
'a'
'a',
);
TKUnit.assertEqual(animation.keyframes.length, 2);
TKUnit.assertEqual(animation.keyframes[1].declarations[0].value, 0.5);
@ -520,7 +519,7 @@ export function test_LoadTwoAnimationsWithTheSameName() {
from { opacity: 0; }
to { opacity: 1; }
}`,
'a'
'a',
);
TKUnit.assertEqual(animation2.keyframes.length, 2);
@ -542,6 +541,68 @@ export function test_LoadAnimationProgrammatically() {
});
}
export function test_LoadMatchingMediaQueryKeyframeAnimation() {
const animation = createAnimationFromCSS(
`.a { animation-name: mq1; }
@media only screen and (max-width: ${Screen.mainScreen.widthDIPs}) {
@keyframes mq1 {
from { opacity: 0; }
to { opacity: 1; }
}
}`,
'a',
);
TKUnit.assertEqual(animation.keyframes.length, 2);
TKUnit.assertEqual(animation.keyframes[1].declarations[0].value, 1);
}
export function test_IgnoreNonMatchingMediaQueryKeyframe() {
const animation = createAnimationFromCSS(
`.a { animation-name: mq1; }
@media only screen and (max-width: ${Screen.mainScreen.widthDIPs - 1}) {
@keyframes mq1 {
from { opacity: 0; }
to { opacity: 1; }
}
}`,
'a',
);
TKUnit.assertEqual(animation.keyframes, null);
}
export function test_LoadMatchingNestedMediaQueryKeyframeAnimation() {
const animation = createAnimationFromCSS(
`.a { animation-name: mq1; }
@media only screen and (orientation: ${Application.orientation()}) {
@media only screen and (max-width: ${Screen.mainScreen.widthDIPs}) {
@keyframes mq1 {
from { opacity: 0; }
to { opacity: 1; }
}
}
}`,
'a',
);
TKUnit.assertEqual(animation.keyframes.length, 2);
TKUnit.assertEqual(animation.keyframes[1].declarations[0].value, 1);
}
export function test_IgnoreNonMatchingNestedMediaQueryKeyframe() {
const animation = createAnimationFromCSS(
`.a { animation-name: mq1; }
@media only screen and (orientation: ${Application.orientation()}) {
@media only screen and (max-width: ${Screen.mainScreen.widthDIPs - 1}) {
@keyframes mq1 {
from { opacity: 0; }
to { opacity: 1; }
}
}
}`,
'a',
);
TKUnit.assertEqual(animation.keyframes, null);
}
export function test_ExecuteCSSAnimation() {
let mainPage = helper.getCurrentPage();
mainPage.css = null;
@ -615,7 +676,7 @@ export function test_AnimationCurveInKeyframes() {
50% { background-color: green; }
to { background-color: black; }
}`,
'test'
'test',
);
TKUnit.assertEqual(animation.keyframes[0].curve, CoreTypes.AnimationCurve.linear);

View File

@ -1,5 +1,5 @@
import * as TKUnit from '../../tk-unit';
import { Application, Button, Label, Page, StackLayout, WrapLayout, TabView, TabViewItem, View, Utils, Color, resolveFileNameFromUrl, removeTaggedAdditionalCSS, addTaggedAdditionalCSS, unsetValue, knownFolders } from '@nativescript/core';
import { Application, Button, Label, Page, StackLayout, WrapLayout, TabView, TabViewItem, View, Utils, Color, resolveFileNameFromUrl, removeTaggedAdditionalCSS, addTaggedAdditionalCSS, unsetValue, knownFolders, Screen } from '@nativescript/core';
import * as helper from '../../ui-helper';
import { _evaluateCssCalcExpression } from '@nativescript/core/ui/core/properties';
@ -464,6 +464,132 @@ export function test_id_and_state_selector() {
testButtonPressedStateIsRed(btn);
}
export function test_matching_media_query_selector() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;
let btnWithId: Button;
let btnWithNoId: Button;
// >> article-using-matching-media-query-selector
page.css = `@media only screen and (max-width: ${Screen.mainScreen.widthDIPs}) {
Button#myButton {
color: red;
}
}`;
//// Will be styled
btnWithId = new Button();
btnWithId.id = 'myButton';
//// Won't be styled
btnWithNoId = new Button();
// << article-using-matching-media-query-selector
const stack = new StackLayout();
page.content = stack;
stack.addChild(btnWithId);
stack.addChild(btnWithNoId);
helper.assertViewColor(btnWithId, '#FF0000');
TKUnit.assert(btnWithNoId.style.color === undefined, 'Color should not have a value');
}
export function test_non_matching_media_query_selector() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;
let btnWithId: Button;
let btnWithNoId: Button;
// >> article-using-non-matching-media-query-selector
page.css = `@media only screen and (max-width: ${Screen.mainScreen.widthDIPs - 1}) {
Button#myButton {
color: red;
}
}`;
//// Will be styled
btnWithId = new Button();
btnWithId.id = 'myButton';
//// Won't be styled
btnWithNoId = new Button();
// << article-using-non-matching-media-query-selector
const stack = new StackLayout();
page.content = stack;
stack.addChild(btnWithId);
stack.addChild(btnWithNoId);
TKUnit.assert(btnWithId.style.color === undefined, 'Color should not have a value');
TKUnit.assert(btnWithNoId.style.color === undefined, 'Color should not have a value');
}
export function test_matching_nested_media_query_selector() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;
let btnWithId: Button;
let btnWithNoId: Button;
// >> article-using-matching-nested-media-query-selector
page.css = `
@media only screen and (orientation: ${Application.orientation()}) {
@media only screen and (max-width: ${Screen.mainScreen.widthDIPs}) {
Button#myButton {
color: red;
}
}
}`;
//// Will be styled
btnWithId = new Button();
btnWithId.id = 'myButton';
//// Won't be styled
btnWithNoId = new Button();
// << article-using-matching-nested-media-query-selector
const stack = new StackLayout();
page.content = stack;
stack.addChild(btnWithId);
stack.addChild(btnWithNoId);
helper.assertViewColor(btnWithId, '#FF0000');
TKUnit.assert(btnWithNoId.style.color === undefined, 'Color should not have a value');
}
export function test_non_matching_nested_media_query_selector() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;
let btnWithId: Button;
let btnWithNoId: Button;
// >> article-using-non-matching-nested-media-query-selector
page.css = `
@media only screen and (orientation: ${Application.orientation()}) {
@media only screen and (max-width: ${Screen.mainScreen.widthDIPs - 1}) {
Button#myButton {
color: red;
}
}
}`;
//// Will be styled
btnWithId = new Button();
btnWithId.id = 'myButton';
//// Won't be styled
btnWithNoId = new Button();
// << article-using-non-matching-nested-media-query-selector
const stack = new StackLayout();
page.content = stack;
stack.addChild(btnWithId);
stack.addChild(btnWithNoId);
TKUnit.assert(btnWithId.style.color === undefined, 'Color should not have a value');
TKUnit.assert(btnWithNoId.style.color === undefined, 'Color should not have a value');
}
export function test_restore_original_values_when_state_is_changed() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;

View File

@ -38,6 +38,7 @@ export function pageLoaded(args: EventData) {
examples.set('repeater', 'repeater/main-page');
examples.set('date-picker', 'date-picker/date-picker-page');
examples.set('nested-frames', 'nested-frames/main-page');
examples.set('media-queries', 'media-queries/main-page');
examples.set('screen-qualifiers', 'screen-qualifiers/main-page');
page.bindingContext = new MainPageViewModel(wrapLayout, examples);

View File

@ -0,0 +1,19 @@
.orientation-btn {
background-color: yellow;
}
@media (orientation: landscape) {
.orientation-btn {
background-color: pink;
}
}
.theme-mode-btn {
background-color: yellow;
}
@media (prefers-color-scheme: dark) {
.theme-mode-btn {
background-color: pink;
}
}

View File

@ -0,0 +1,34 @@
import { Button } from '@nativescript/core';
import { EventData } from '@nativescript/core/data/observable';
import { Observable } from '@nativescript/core/data/observable';
import { Page } from '@nativescript/core/ui/page';
export function onNavigatedTo(args: EventData) {
const page = <Page>args.object;
const orientationButton: Button = page.getViewById('orientation-match-media-btn');
if (orientationButton) {
const mq = matchMedia('(orientation: portrait)');
let mode = mq.matches ? 'portrait' : 'landscape';
orientationButton.text = `I' m in ${mode} mode!`;
mq.addEventListener('change', (event: MediaQueryListEvent) => {
mode = event.matches ? 'portrait' : 'landscape';
orientationButton.text = `I' m in ${mode} mode!`;
});
}
const themeModeButton: Button = page.getViewById('theme-match-media-btn');
if (themeModeButton) {
const mq = matchMedia('(prefers-color-scheme: light)');
let mode = mq.matches ? 'light' : 'dark';
themeModeButton.text = `I' m in ${mode} mode!`;
mq.addEventListener('change', (event: MediaQueryListEvent) => {
mode = event.matches ? 'light' : 'dark';
themeModeButton.text = `I' m in ${mode} mode!`;
});
}
}

View File

@ -0,0 +1,25 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatedTo="onNavigatedTo">
<ActionBar>
<Label text="Media Queries"></Label>
</ActionBar>
<ScrollView>
<StackLayout padding="20">
<Label text="Orientation change" fontSize="16" fontWeight="bold" marginBottom="12"/>
<StackLayout>
<Label text="CSS media query - color change" fontSize="14"/>
<Button class="orientation-btn" text="My color is different after orientation!"/>
<Label text="The matchMedia API - text change" fontSize="14"/>
<Button id="orientation-match-media-btn"/>
</StackLayout>
<Label text="Theme mode change" fontSize="16" fontWeight="bold" marginTop="12" marginBottom="12"/>
<StackLayout>
<Label text="CSS media query - color change" fontSize="14"/>
<Button class="theme-mode-btn" text="My color is different after theme change!"/>
<Label text="The matchMedia API - text change" fontSize="14"/>
<Button id="theme-match-media-btn"/>
</StackLayout>
</StackLayout>
</ScrollView>
</Page>

View File

@ -2,7 +2,7 @@ import { initAccessibilityCssHelper } from '../accessibility/accessibility-css-h
import { initAccessibilityFontScale } from '../accessibility/font-scale';
import { CoreTypes } from '../core-types';
import { CSSUtils } from '../css/system-classes';
import { Device } from '../platform';
import { Device, Screen } from '../platform';
import { profile } from '../profiling';
import { Trace } from '../trace';
import { Builder } from '../ui/builder';
@ -220,13 +220,17 @@ export class ApplicationCommon {
* @param rootView
* @param cssClasses
* @param newCssClass
* @param skipCssUpdate
*/
applyCssClass(rootView: View, cssClasses: string[], newCssClass: string): void {
applyCssClass(rootView: View, cssClasses: string[], newCssClass: string, skipCssUpdate: boolean = false): void {
if (!rootView.cssClasses.has(newCssClass)) {
cssClasses.forEach((cssClass) => this.removeCssClass(rootView, cssClass));
this.addCssClass(rootView, newCssClass);
this.increaseStyleScopeApplicationCssSelectorVersion(rootView);
if (!skipCssUpdate) {
rootView._onCssStateChange();
}
if (Trace.isEnabled()) {
const rootCssClasses = Array.from(rootView.cssClasses);
@ -457,7 +461,13 @@ export class ApplicationCommon {
if (this._orientation === value) {
return;
}
this._orientation = value;
// Update metrics early enough regardless of the existence of root view
// Also, CSS will use the correct size values during update trigger
Screen.mainScreen._updateMetrics();
this.orientationChanged(this.getRootView(), value);
this.notify(<OrientationChangedEventData>{
eventName: this.orientationChangedEvent,
@ -478,12 +488,18 @@ export class ApplicationCommon {
}
const newOrientationCssClass = `${CSSUtils.CLASS_PREFIX}${newOrientation}`;
this.applyCssClass(rootView, ORIENTATION_CSS_CLASSES, newOrientationCssClass);
this.applyCssClass(rootView, ORIENTATION_CSS_CLASSES, newOrientationCssClass, true);
const rootModalViews = <Array<View>>rootView._getRootModalViews();
rootModalViews.forEach((rootModalView) => {
this.applyCssClass(rootModalView, ORIENTATION_CSS_CLASSES, newOrientationCssClass);
this.applyCssClass(rootModalView, ORIENTATION_CSS_CLASSES, newOrientationCssClass, true);
// Trigger state change for root modal view classes and media queries
rootModalView._onCssStateChange();
});
// Trigger state change for root view classes and media queries
rootView._onCssStateChange();
}
getNativeApplication(): any {
@ -545,12 +561,18 @@ export class ApplicationCommon {
}
const newSystemAppearanceCssClass = `${CSSUtils.CLASS_PREFIX}${newSystemAppearance}`;
this.applyCssClass(rootView, SYSTEM_APPEARANCE_CSS_CLASSES, newSystemAppearanceCssClass);
this.applyCssClass(rootView, SYSTEM_APPEARANCE_CSS_CLASSES, newSystemAppearanceCssClass, true);
const rootModalViews = rootView._getRootModalViews();
rootModalViews.forEach((rootModalView) => {
this.applyCssClass(rootModalView as View, SYSTEM_APPEARANCE_CSS_CLASSES, newSystemAppearanceCssClass);
this.applyCssClass(rootModalView as View, SYSTEM_APPEARANCE_CSS_CLASSES, newSystemAppearanceCssClass, true);
// Trigger state change for root modal view classes and media queries
rootModalView._onCssStateChange();
});
// Trigger state change for root view classes and media queries
rootView._onCssStateChange();
}
private _inBackground: boolean = false;

View File

@ -231,7 +231,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication
}
get rootController() {
return this.window.rootViewController;
return this.window?.rootViewController;
}
get nativeApp() {

View File

@ -0,0 +1,27 @@
Copyright 2014 Yahoo! Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the Yahoo! Inc. nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL YAHOO! INC. BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -0,0 +1,118 @@
import { MediaQueryType, matchQuery, parseQuery } from '.';
describe('css-mediaquery', () => {
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);
});
});
});
});

View File

@ -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/style-properties';
// -----------------------------------------------------------------------------
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;
});
}

View File

@ -4,7 +4,7 @@ export interface Position {
}
export interface Node {
type: 'rule' | 'keyframes' | 'declaration' | 'import';
type: 'rule' | 'keyframes' | 'declaration' | 'import' | 'media';
position: Position;
}
@ -18,8 +18,22 @@ export interface Rule extends Node {
declarations: Declaration[];
}
export type AtRule = KeyFrames | Media;
export interface Keyframes extends Rule {
name: string;
vendor?: string;
keyframes?: Array<KeyFrame>;
}
export interface KeyFrame extends Node {
values: string[];
declarations: Array<Declaration>;
}
export interface Media extends Node {
media: string;
rules: Array<Rule | AtRule>;
}
export interface StyleSheet {

View File

@ -307,6 +307,9 @@ export function initGlobal() {
global.registerModule('animation', () => require('../animation-frame'));
installPolyfills('animation', ['requestAnimationFrame', 'cancelAnimationFrame']);
global.registerModule('media-query-list', () => require('../media-query-list'));
installPolyfills('media-query-list', ['matchMedia', 'MediaQueryList']);
global.registerModule('ui-dialogs', () => require('../ui/dialogs'));
installPolyfills('ui-dialogs', ['alert', 'confirm', 'prompt', 'login', 'action']);

View File

@ -48,9 +48,11 @@ global.interop = {
bool: {},
},
};
// global.UIApplication = {
// }
global.UIApplication = {
sharedApplication: {
statusBarOrientation: 1, // Portrait by default
},
};
global.UIDevice = {
currentDevice: {
systemVersion: '13.0',
@ -59,6 +61,16 @@ global.UIDevice = {
global.UIScreen = {
mainScreen: {
scale: 1,
bounds: {
origin: {
x: 0,
y: 0,
},
size: {
height: 1000,
width: 1000,
},
},
},
};
const cgColors = { CGColor: 1 };

View File

@ -0,0 +1,74 @@
import { matchMedia, checkIfMediaQueryMatches, 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})`);
expect(matchMediaWrapper).not.toThrow();
const mql = matchMediaWrapper();
expect(mql).toBeInstanceOf(MediaQueryList);
expect(() => mql.matches).not.toThrow();
expect(mql.matches).toBe(true);
});
it('should return a MediaQueryList that does not match the css media query', () => {
const matchMediaWrapper = () => matchMedia(`only screen and (max-width: ${widthDIPs - 1})`);
expect(matchMediaWrapper).not.toThrow();
const mql = matchMediaWrapper();
expect(mql).toBeInstanceOf(MediaQueryList);
expect(() => mql.matches).not.toThrow();
expect(mql.matches).toBe(false);
});
});
describe('MediaQueryList', () => {
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);
});
});
});

View File

@ -0,0 +1,214 @@
import { EventData, Observable } from '../data/observable';
import { Screen } from '../platform';
import { Application, ApplicationEventData } from '../application';
import { matchQuery, MediaQueryType } from '../css-mediaquery';
import { Trace } from '../trace';
const mediaQueryLists: MediaQueryListImpl[] = [];
const applicationEvents: string[] = [Application.orientationChangedEvent, Application.systemAppearanceChangedEvent];
// In browser, developers cannot create MediaQueryList instances without calling matchMedia
let isMediaInitializationEnabled: boolean = false;
function toggleApplicationEventListeners(toAdd: boolean) {
for (const eventName of applicationEvents) {
if (toAdd) {
Application.on(eventName, onDeviceChange);
} else {
Application.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,
object: mql,
matches: mql.matches,
media: mql.media,
});
}
}
}
function checkIfMediaQueryMatches(mediaQueryString: string): boolean {
const { widthPixels, heightPixels } = Screen.mainScreen;
let matches: boolean;
try {
matches = matchQuery(mediaQueryString, {
type: MediaQueryType.screen,
width: widthPixels,
height: heightPixels,
'device-width': widthPixels,
'device-height': heightPixels,
orientation: Application.orientation(),
'prefers-color-scheme': Application.systemAppearance(),
});
} catch (err) {
matches = false;
Trace.write(err, Trace.categories.MediaQuery, Trace.messageType.error);
}
return matches;
}
function matchMedia(mediaQueryString: string): MediaQueryListImpl {
isMediaInitializationEnabled = true;
const mediaQueryList = new MediaQueryListImpl();
isMediaInitializationEnabled = false;
mediaQueryList._media = mediaQueryString;
mediaQueryList._matches = checkIfMediaQueryMatches(mediaQueryString);
return mediaQueryList;
}
class MediaQueryListImpl extends Observable implements MediaQueryList {
public static readonly changeEvent = 'change';
public _media: string;
public _matches: boolean;
private _onChange: (this: MediaQueryList, ev: MediaQueryListEvent) => any;
private mediaQueryChangeListeners: Map<(this: MediaQueryList, ev: MediaQueryListEvent) => any, (data: EventData) => 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,
},
});
}
get media(): string {
this._throwInvocationError?.();
return this._media;
}
get matches(): boolean {
this._throwInvocationError?.();
return this._matches;
}
// @ts-ignore
public addEventListener(eventName: string, callback: (data: EventData) => void, thisArg?: any): void {
this._throwInvocationError?.();
const hasChangeListeners = this.hasListeners(MediaQueryListImpl.changeEvent);
// Call super method first since it throws in the case of bad parameters
super.addEventListener(eventName, callback, thisArg);
if (eventName === MediaQueryListImpl.changeEvent && !hasChangeListeners) {
mediaQueryLists.push(this);
if (mediaQueryLists.length === 1) {
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
super.removeEventListener(eventName, callback, thisArg);
if (eventName === MediaQueryListImpl.changeEvent) {
const hasChangeListeners = this.hasListeners(MediaQueryListImpl.changeEvent);
if (!hasChangeListeners) {
const index = mediaQueryLists.indexOf(this);
if (index >= 0) {
mediaQueryLists.splice(index, 1);
if (!mediaQueryLists.length) {
toggleApplicationEventListeners(false);
}
}
}
}
}
addListener(callback: (this: MediaQueryList, ev: MediaQueryListEvent) => any): void {
this._throwInvocationError?.();
// This kind of implementation helps maintain listener registration order
// regardless of using the deprecated methods or property onchange
const wrapperCallback = (data) => {
callback.call(this, <MediaQueryListEvent>{
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);
}
removeListener(callback: (this: MediaQueryList, ev: MediaQueryListEvent) => any): void {
this._throwInvocationError?.();
if (this.mediaQueryChangeListeners.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);
}
}
public get onchange(): (this: MediaQueryList, ev: MediaQueryListEvent) => any {
this._throwInvocationError?.();
return this._onChange;
}
public set onchange(callback: (this: MediaQueryList, ev: MediaQueryListEvent) => any) {
this._throwInvocationError?.();
// Remove old listener if any
if (this._onChange) {
this.removeListener(this._onChange);
}
if (callback) {
this.addListener(callback);
}
this._onChange = callback;
}
private _throwInvocationError() {
throw new TypeError('Illegal invocation');
}
}
export { matchMedia, MediaQueryListImpl as MediaQueryList, checkIfMediaQueryMatches };

View File

@ -1,64 +1,10 @@
/* tslint:disable:class-name */
import { Application } from '../application';
import { SDK_VERSION } from '../utils/constants';
import { platformNames } from './common';
import { Application } from '../../application';
import { SDK_VERSION } from '../../utils/constants';
import { platformNames } from '../common';
import { Screen } from '../screen';
const MIN_TABLET_PIXELS = 600;
export * from './common';
class MainScreen {
private _metrics: android.util.DisplayMetrics;
private reinitMetrics(): void {
if (!this._metrics) {
this._metrics = new android.util.DisplayMetrics();
}
this.initMetrics();
}
private initMetrics(): void {
const nativeApp = Application.android.getNativeApplication();
nativeApp.getSystemService(android.content.Context.WINDOW_SERVICE).getDefaultDisplay().getRealMetrics(this._metrics);
}
private get metrics(): android.util.DisplayMetrics {
if (!this._metrics) {
// NOTE: This will be memory leak but we MainScreen is singleton
Application.on('cssChanged', this.reinitMetrics, this);
Application.on(Application.orientationChangedEvent, this.reinitMetrics, this);
this._metrics = new android.util.DisplayMetrics();
this.initMetrics();
}
return this._metrics;
}
get widthPixels(): number {
return this.metrics.widthPixels;
}
get heightPixels(): number {
return this.metrics.heightPixels;
}
get scale(): number {
return this.metrics.density;
}
get widthDIPs(): number {
return this.metrics.widthPixels / this.metrics.density;
}
get heightDIPs(): number {
return this.metrics.heightPixels / this.metrics.density;
}
}
export class Screen {
static mainScreen = new MainScreen();
}
// This retains compatibility with NS6
export const screen = Screen;
class DeviceRef {
private _manufacturer: string;
private _model: string;

View File

@ -0,0 +1,70 @@
/*
* An object containing device specific information.
*/
export interface IDevice {
/**
* Gets the manufacturer of the device.
* For example: "Apple" or "HTC" or "Samsung".
*/
manufacturer: string;
/**
* Gets the model of the device.
* For example: "Nexus 5" or "iPhone".
*/
model: string;
/**
* Gets the OS of the device.
* For example: "Android" or "iOS".
*/
os: string;
/**
* Gets the OS version.
* For example: 4.4.4(android), 8.1(ios)
*/
osVersion: string;
/**
* Gets the SDK version.
* For example: 19(android), 8.1(ios).
*/
sdkVersion: string;
/**
* Gets the type of the current device.
* Available values: "Phone", "Tablet".
*/
deviceType: 'Phone' | 'Tablet';
/**
* Gets the uuid.
* On iOS this will return a new uuid if the application is re-installed on the device.
* If you need to receive the same uuid even after the application has been re-installed on the device,
* use this plugin: https://www.npmjs.com/package/nativescript-ios-uuid
*/
uuid: string;
/**
* Gets the preferred language. For example "en" or "en-US".
*/
language: string;
/**
* Gets the preferred region. For example "US".
*/
region: string;
}
/**
* Gets the current device information.
*/
export const Device: IDevice;
/**
* Gets the current device information.
*
* This retains compatibility with NS6
*/
export const device: IDevice;

View File

@ -1,8 +1,4 @@
/* tslint:disable:class-name */
import { platformNames } from './common';
import { ios } from '../utils';
export * from './common';
import { platformNames } from '../common';
type DeviceType = 'Phone' | 'Tablet' | 'Vision';
@ -87,44 +83,7 @@ class DeviceRef {
}
}
class MainScreen {
private _screen: UIScreen;
private get screen(): UIScreen {
if (!this._screen) {
// NOTE: may not want to cache this value with SwiftUI app lifecycle based apps (using NativeScriptViewFactory) given the potential of multiple scenes
const window = ios.getWindow();
this._screen = window ? window.screen : UIScreen.mainScreen;
}
return this._screen;
}
get widthPixels(): number {
return this.widthDIPs * this.scale;
}
get heightPixels(): number {
return this.heightDIPs * this.scale;
}
get scale(): number {
return this.screen.scale;
}
get widthDIPs(): number {
return this.screen.bounds.size.width;
}
get heightDIPs(): number {
return this.screen.bounds.size.height;
}
}
export const Device = new DeviceRef();
// This retains compatibility with NS6
export const device = Device;
export class Screen {
static mainScreen = new MainScreen();
}
// This retains compatibility with NS6
export const screen = Screen;

View File

@ -25,121 +25,5 @@ export const isApple: boolean;
export const isVisionOS: boolean;
export * from './common';
/*
* An object containing device specific information.
*/
export interface IDevice {
/**
* Gets the manufacturer of the device.
* For example: "Apple" or "HTC" or "Samsung".
*/
manufacturer: string;
/**
* Gets the model of the device.
* For example: "Nexus 5" or "iPhone".
*/
model: string;
/**
* Gets the OS of the device.
* For example: "Android" or "iOS".
*/
os: string;
/**
* Gets the OS version.
* For example: 4.4.4(android), 8.1(ios)
*/
osVersion: string;
/**
* Gets the SDK version.
* For example: 19(android), 8.1(ios).
*/
sdkVersion: string;
/**
* Gets the type of the current device.
* Available values: "Phone", "Tablet".
*/
deviceType: 'Phone' | 'Tablet';
/**
* Gets the uuid.
* On iOS this will return a new uuid if the application is re-installed on the device.
* If you need to receive the same uuid even after the application has been re-installed on the device,
* use this plugin: https://www.npmjs.com/package/nativescript-ios-uuid
*/
uuid: string;
/**
* Gets the preferred language. For example "en" or "en-US".
*/
language: string;
/**
* Gets the preferred region. For example "US".
*/
region: string;
}
/**
* An object containing screen information.
*/
export interface ScreenMetrics {
/**
* Gets the absolute width of the screen in pixels.
*/
widthPixels: number;
/**
* Gets the absolute height of the screen in pixels.
*/
heightPixels: number;
/**
* Gets the absolute width of the screen in density independent pixels.
*/
widthDIPs: number;
/**
* Gets the absolute height of the screen in density independent pixels.
*/
heightDIPs: number;
/**
* The logical density of the display. This is a scaling factor for the Density Independent Pixel unit.
*/
scale: number;
}
/**
* An object describing general information about a display.
*/
export class Screen {
/**
* Gets information about the main screen of the current device.
*/
static mainScreen: ScreenMetrics;
}
/**
* An object describing general information about a display.
*
* This retains compatibility with NS6
*/
export const screen: Screen;
/**
* Gets the current device information.
*/
export const Device: IDevice;
/**
* Gets the current device information.
*
* This retains compatibility with NS6
*/
export const device: IDevice;
export * from './device';
export * from './screen';

View File

@ -0,0 +1,3 @@
export * from './common';
export * from './device';
export * from './screen';

View File

@ -0,0 +1,49 @@
import { Application } from '../../application';
class MainScreen {
private _metrics: android.util.DisplayMetrics;
private initMetrics(): void {
const nativeApp = Application.android.getNativeApplication();
nativeApp.getSystemService(android.content.Context.WINDOW_SERVICE).getDefaultDisplay().getRealMetrics(this._metrics);
}
private get metrics(): android.util.DisplayMetrics {
if (!this._metrics) {
this._metrics = new android.util.DisplayMetrics();
this.initMetrics();
}
return this._metrics;
}
get widthPixels(): number {
return this.metrics.widthPixels;
}
get heightPixels(): number {
return this.metrics.heightPixels;
}
get scale(): number {
return this.metrics.density;
}
get widthDIPs(): number {
return this.metrics.widthPixels / this.metrics.density;
}
get heightDIPs(): number {
return this.metrics.heightPixels / this.metrics.density;
}
public _updateMetrics(): void {
if (!this._metrics) {
this._metrics = new android.util.DisplayMetrics();
}
this.initMetrics();
}
}
export class Screen {
static mainScreen = new MainScreen();
}
// This retains compatibility with NS6
export const screen = Screen;

View File

@ -0,0 +1,48 @@
/**
* An object containing screen information.
*/
export interface ScreenMetrics {
/**
* Gets the absolute width of the screen in pixels.
*/
widthPixels: number;
/**
* Gets the absolute height of the screen in pixels.
*/
heightPixels: number;
/**
* Gets the absolute width of the screen in density independent pixels.
*/
widthDIPs: number;
/**
* Gets the absolute height of the screen in density independent pixels.
*/
heightDIPs: number;
/**
* The logical density of the display. This is a scaling factor for the Density Independent Pixel unit.
*/
scale: number;
_updateMetrics(): void;
}
/**
* An object describing general information about a display.
*/
export class Screen {
/**
* Gets information about the main screen of the current device.
*/
static mainScreen: ScreenMetrics;
}
/**
* An object describing general information about a display.
*
* This retains compatibility with NS6
*/
export const screen: Screen;

View File

@ -0,0 +1,42 @@
import { ios } from '../../utils';
class MainScreen {
private _screen: UIScreen;
private get screen(): UIScreen {
if (!this._screen) {
// NOTE: may not want to cache this value with SwiftUI app lifecycle based apps (using NativeScriptViewFactory) given the potential of multiple scenes
const window = ios.getWindow();
this._screen = window ? window.screen : UIScreen.mainScreen;
}
return this._screen;
}
get widthPixels(): number {
return this.widthDIPs * this.scale;
}
get heightPixels(): number {
return this.heightDIPs * this.scale;
}
get scale(): number {
return this.screen.scale;
}
get widthDIPs(): number {
return this.screen.bounds.size.width;
}
get heightDIPs(): number {
return this.screen.bounds.size.height;
}
public _updateMetrics(): void {
// UIScreen handles the update on iOS
}
}
export class Screen {
static mainScreen = new MainScreen();
}
// This retains compatibility with NS6
export const screen = Screen;

View File

@ -119,6 +119,7 @@ export namespace Trace {
export const Transition = 'Transition';
export const Livesync = 'Livesync';
export const ModuleNameResolver = 'ModuleNameResolver';
export const MediaQuery = 'MediaQuery';
export const separator = ',';
export const All: string;

View File

@ -201,9 +201,10 @@ export namespace Trace {
export const Transition = 'Transition';
export const Livesync = 'Livesync';
export const ModuleNameResolver = 'ModuleNameResolver';
export const MediaQuery = 'MediaQuery';
export const separator = ',';
export const All: string = [VisualTreeEvents, Layout, Style, ViewHierarchy, NativeLifecycle, Debug, Navigation, Test, Binding, Error, Animation, Transition, Livesync, ModuleNameResolver].join(separator);
export const All: string = [VisualTreeEvents, Layout, Style, ViewHierarchy, NativeLifecycle, Debug, Navigation, Test, Binding, Error, Animation, Transition, Livesync, ModuleNameResolver, MediaQuery].join(separator);
export function concat(...args: any): string {
let result: string;

View File

@ -21,6 +21,9 @@ interface Keyframe {
export interface Keyframes {
name: string;
keyframes: Array<UnparsedKeyframe>;
tag?: string | number;
scopedTag?: string;
mediaQueryString?: string;
}
export interface UnparsedKeyframe {

View File

@ -10,9 +10,12 @@ import { unsetValue } from '../core/properties';
import { Animation } from '.';
import { backgroundColorProperty, scaleXProperty, scaleYProperty, translateXProperty, translateYProperty, rotateProperty, opacityProperty, rotateXProperty, rotateYProperty, widthProperty, heightProperty } from '../styling/style-properties';
export class Keyframes {
export interface Keyframes {
name: string;
keyframes: Array<UnparsedKeyframe>;
tag?: string | number;
scopedTag?: string;
mediaQueryString?: string;
}
export class UnparsedKeyframe {
@ -68,6 +71,11 @@ export class KeyframeAnimation {
private _target: View;
public static keyframeAnimationFromInfo(info: KeyframeAnimationInfo): KeyframeAnimation {
if (!info?.keyframes?.length) {
Trace.write(`No keyframes found for animation '${info.name}'.`, Trace.categories.Animation, Trace.messageType.warn);
return null;
}
const length = info.keyframes.length;
const animations = new Array<Keyframe>();
let startDuration = 0;
@ -244,7 +252,7 @@ export class KeyframeAnimation {
},
(error: any) => {
Trace.write(typeof error === 'string' ? error : error.message, Trace.categories.Animation, Trace.messageType.warn);
}
},
)
.catch((error: any) => {
Trace.write(typeof error === 'string' ? error : error.message, Trace.categories.Animation, Trace.messageType.warn);

View File

@ -16,9 +16,8 @@ import { CoreTypes } from '../../../core-types';
import { Background, BackgroundClearFlags, refreshBorderDrawable } from '../../styling/background';
import { profile } from '../../../profiling';
import { topmost } from '../../frame/frame-stack';
import { Screen } from '../../../platform';
import { Device, Screen } from '../../../platform';
import { AndroidActivityBackPressedEventData, Application } from '../../../application';
import { Device } from '../../../platform';
import { accessibilityEnabledProperty, accessibilityHiddenProperty, accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityLanguageProperty, accessibilityLiveRegionProperty, accessibilityMediaSessionProperty, accessibilityRoleProperty, accessibilityStateProperty, accessibilityValueProperty } from '../../../accessibility/accessibility-properties';
import { AccessibilityLiveRegion, AccessibilityRole, AndroidAccessibilityEvent, isAccessibilityServiceEnabled, sendAccessibilityEvent, updateAccessibilityProperties, updateContentDescription, AccessibilityState } from '../../../accessibility';
import * as Utils from '../../../utils';

View File

@ -1,5 +1,7 @@
import { parse } from '../../css/reworkcss';
import { createSelector, RuleSet, SelectorsMap, fromAstNodes, Node, Changes } from './css-selector';
import { Screen } from '../../platform';
import { createSelector, RuleSet, StyleSheetSelectorScope, fromAstNode, Node, Changes } from './css-selector';
import { _populateRules } from './style-scope';
describe('css-selector', () => {
it('button[attr]', () => {
@ -17,20 +19,23 @@ describe('css-selector', () => {
).toBeFalsy();
});
function create(css: string, source = 'css-selectors.ts@test'): { rules: RuleSet[]; map: SelectorsMap<any> } {
function create(css: string, source = 'css-selectors.ts@test'): { rulesets: RuleSet[]; selectorScope: StyleSheetSelectorScope<any> } {
const parsed = parse(css, { source });
const rulesAst = parsed.stylesheet.rules.filter((n) => n.type === 'rule');
const rules = fromAstNodes(rulesAst);
const map = new SelectorsMap(rules);
const rulesAst = parsed.stylesheet.rules;
const rulesets = [];
return { rules, map };
_populateRules(rulesAst, rulesets, []);
const selectorScope = new StyleSheetSelectorScope(rulesets);
return { rulesets, selectorScope };
}
function createOne(css: string, source = 'css-selectors.ts@test'): RuleSet {
const { rules } = create(css, source);
expect(rules.length).toBe(1);
const { rulesets } = create(css, source);
expect(rulesets.length).toBe(1);
return rules[0];
return rulesets[0];
}
it('single selector', () => {
@ -48,21 +53,21 @@ describe('css-selector', () => {
});
it('narrow selection', () => {
const { map } = create(`
const { selectorScope } = create(`
.login { color: blue; }
button { color: red; }
image { color: green; }
`);
const buttonQuerry = map.query({ cssType: 'button' }).selectors;
expect(buttonQuerry.length).toBe(1);
expect(buttonQuerry[0].ruleset.declarations).toEqual([{ property: 'color', value: 'red' }]);
const buttonQuery = selectorScope.query({ cssType: 'button' }).selectors;
expect(buttonQuery.length).toBe(1);
expect(buttonQuery[0].ruleset.declarations).toEqual([{ property: 'color', value: 'red' }]);
const imageQuerry = map.query({ cssType: 'image', cssClasses: new Set(['login']) }).selectors;
expect(imageQuerry.length).toBe(2);
const imageQuery = selectorScope.query({ cssType: 'image', cssClasses: new Set(['login']) }).selectors;
expect(imageQuery.length).toBe(2);
// Note class before type
expect(imageQuerry[0].ruleset.declarations).toEqual([{ property: 'color', value: 'blue' }]);
expect(imageQuerry[1].ruleset.declarations).toEqual([{ property: 'color', value: 'green' }]);
expect(imageQuery[0].ruleset.declarations).toEqual([{ property: 'color', value: 'blue' }]);
expect(imageQuery[1].ruleset.declarations).toEqual([{ property: 'color', value: 'green' }]);
});
const positiveMatches = {
@ -271,10 +276,93 @@ describe('css-selector', () => {
},
}),
).toBe(false);
// TODO: Re-add this when decorators actually work with ts-jest
// TODO: Re-add this when decorators actually work properly on ts-jest
//expect(rule.selectors[0].specificity).toEqual(0);
});
describe('media queries', () => {
const { widthDIPs } = Screen.mainScreen;
it('should apply css rules of matching media query', () => {
const { selectorScope } = create(`
@media only screen and (max-width: ${widthDIPs}) {
.login { color: blue; }
button { color: red; }
image { color: green; }
}
`);
const { selectors: buttonSelectors } = selectorScope.query({ cssType: 'button' });
expect(buttonSelectors.length).toBe(1);
expect(buttonSelectors[0].ruleset.declarations).toEqual([{ property: 'color', value: 'red' }]);
const { selectors: imageSelectors } = selectorScope.query({ cssType: 'image', cssClasses: new Set(['login']) });
expect(imageSelectors.length).toBe(2);
// Note class before type
expect(imageSelectors[0].ruleset.declarations).toEqual([{ property: 'color', value: 'blue' }]);
expect(imageSelectors[1].ruleset.declarations).toEqual([{ property: 'color', value: 'green' }]);
});
it('should not apply css rules of non-matching media query', () => {
const { selectorScope } = create(`
@media only screen and (max-width: ${widthDIPs - 1}) {
.login { color: blue; }
button { color: red; }
image { color: green; }
}
`);
const { selectors: buttonSelectors } = selectorScope.query({ cssType: 'button' });
expect(buttonSelectors.length).toBe(0);
const { selectors: imageSelectors } = selectorScope.query({ cssType: 'image', cssClasses: new Set(['login']) });
expect(imageSelectors.length).toBe(0);
});
it('should apply css rules of matching media and nested media queries', () => {
const { selectorScope } = create(`
@media only screen and (max-width: ${widthDIPs}) {
.login { color: blue; }
button { color: red; }
@media only screen and (orientation: portrait) {
image { color: green; }
}
}
`);
const { selectors: buttonSelectors } = selectorScope.query({ cssType: 'button' });
expect(buttonSelectors.length).toBe(1);
expect(buttonSelectors[0].ruleset.declarations).toEqual([{ property: 'color', value: 'red' }]);
const { selectors: imageSelectors } = selectorScope.query({ cssType: 'image', cssClasses: new Set(['login']) });
expect(imageSelectors.length).toBe(2);
// Note class before type
expect(imageSelectors[0].ruleset.declarations).toEqual([{ property: 'color', value: 'blue' }]);
expect(imageSelectors[1].ruleset.declarations).toEqual([{ property: 'color', value: 'green' }]);
});
it('should apply css rules of matching media queries but not non-matching nested media queries', () => {
const { selectorScope } = create(`
@media only screen and (max-width: ${widthDIPs}) {
.login { color: blue; }
@media only screen and (orientation: none) {
button { color: red; }
image { color: green; }
}
}
`);
const { selectors: buttonSelectors } = selectorScope.query({ cssType: 'button' });
expect(buttonSelectors.length).toBe(0);
const { selectors: imageSelectors } = selectorScope.query({ cssType: 'image', cssClasses: new Set(['login']) });
expect(imageSelectors.length).toBe(1);
expect(imageSelectors[0].ruleset.declarations).toEqual([{ property: 'color', value: 'blue' }]);
});
});
function toString() {
return this.cssType;
}

View File

@ -6,6 +6,9 @@ import { isNullOrUndefined } from '../../utils/types';
import * as ReworkCSS from '../../css';
import { CSSUtils } from '../../css/system-classes';
import { checkIfMediaQueryMatches } from '../../media-query-list';
export const MEDIA_QUERY_SEPARATOR = '&&';
/**
* An interface describing the shape of a type on which the selectors may apply.
@ -779,29 +782,34 @@ export namespace Selector {
}
export class RuleSet {
tag: string | number;
scopedTag: string;
constructor(
public selectors: SelectorCore[],
public declarations: Declaration[],
) {
public selectors: SelectorCore[];
public declarations: Declaration[];
public mediaQueryString: string;
public tag?: string | number;
public scopedTag?: string;
constructor(selectors: SelectorCore[], declarations: Declaration[]) {
this.selectors = selectors;
this.declarations = declarations;
this.selectors.forEach((sel) => (sel.ruleset = this));
}
public toString(): string {
return `${this.selectors.join(', ')} {${this.declarations.map((d, i) => `${i === 0 ? ' ' : ''}${d.property}: ${d.value}`).join('; ')} }`;
let desc = `${this.selectors.join(', ')} {${this.declarations.map((d, i) => `${i === 0 ? ' ' : ''}${d.property}: ${d.value}`).join('; ')} }`;
if (this.mediaQueryString) {
desc = `@media ${this.mediaQueryString} { ${desc} }`;
}
return desc;
}
public lookupSort(sorter: LookupSorter): void {
this.selectors.forEach((sel) => sel.lookupSort(sorter));
}
}
export function fromAstNodes(astRules: ReworkCSS.Node[]): RuleSet[] {
return (<ReworkCSS.Rule[]>astRules.filter(isRule)).map((rule) => {
const declarations = rule.declarations.filter(isDeclaration).map(createDeclaration);
const selectors = rule.selectors.map(createSelector);
export function fromAstNode(astRule: ReworkCSS.Rule): RuleSet {
const declarations = astRule.declarations.filter(isDeclaration).map(createDeclaration);
const selectors = astRule.selectors.map(createSelector);
return new RuleSet(selectors, declarations);
});
}
function createDeclaration(decl: ReworkCSS.Declaration): any {
@ -939,30 +947,43 @@ export function createSelector(sel: string): SimpleSelector | SimpleSelectorSequ
}
}
function isRule(node: ReworkCSS.Node): node is ReworkCSS.Rule {
return node.type === 'rule';
}
function isDeclaration(node: ReworkCSS.Node): node is ReworkCSS.Declaration {
return node.type === 'declaration';
}
export function matchMediaQueryString(mediaQueryString: string, cachedQueries: string[]): boolean {
// It can be a single or multiple queries in case of nested media queries
const mediaQueryStrings = mediaQueryString.split(MEDIA_QUERY_SEPARATOR);
return mediaQueryStrings.every((mq) => {
let isMatching: boolean;
// Query has already been validated
if (cachedQueries.includes(mq)) {
isMatching = true;
} else {
isMatching = checkIfMediaQueryMatches(mq);
if (isMatching) {
cachedQueries.push(mq);
}
}
return isMatching;
});
}
interface SelectorMap {
[key: string]: SelectorCore[];
}
export class SelectorsMap<T extends Node> implements LookupSorter {
export abstract class SelectorScope<T extends Node> implements LookupSorter {
private id: SelectorMap = {};
private class: SelectorMap = {};
private type: SelectorMap = {};
private universal: SelectorCore[] = [];
private position = 0;
public position: number = 0;
constructor(rulesets: RuleSet[]) {
rulesets.forEach((rule) => rule.lookupSort(this));
}
query(node: T): SelectorsMatch<T> {
const selectorsMatch = new SelectorsMatch<T>();
getSelectorCandidates(node: T) {
const { cssClasses, id, cssType } = node;
const selectorClasses = [this.universal, this.id[id], this.type[cssType]];
@ -970,11 +991,7 @@ export class SelectorsMap<T extends Node> implements LookupSorter {
cssClasses.forEach((c) => selectorClasses.push(this.class[c]));
}
const selectors = selectorClasses.reduce((cur, next) => cur.concat(next || []), []);
selectorsMatch.selectors = selectors.filter((sel) => sel.accumulateChanges(node, selectorsMatch)).sort((a, b) => a.specificity - b.specificity || a.pos - b.pos);
return selectorsMatch;
return selectorClasses.reduce((cur, next) => cur.concat(next || []), []);
}
sortById(id: string, sel: SelectorCore): void {
@ -1005,6 +1022,99 @@ export class SelectorsMap<T extends Node> implements LookupSorter {
}
}
export class MediaQuerySelectorScope<T extends Node> extends SelectorScope<T> {
private _mediaQueryString: string;
constructor(mediaQueryString: string) {
super();
this._mediaQueryString = mediaQueryString;
}
get mediaQueryString(): string {
return this._mediaQueryString;
}
}
export class StyleSheetSelectorScope<T extends Node> extends SelectorScope<T> {
private mediaQuerySelectorScopes: MediaQuerySelectorScope<T>[];
constructor(rulesets: RuleSet[]) {
super();
this.lookupRulesets(rulesets);
}
private createMediaQuerySelectorScope(mediaQueryString: string): MediaQuerySelectorScope<T> {
const selectorScope = new MediaQuerySelectorScope(mediaQueryString);
selectorScope.position = this.position;
if (this.mediaQuerySelectorScopes) {
this.mediaQuerySelectorScopes.push(selectorScope);
} else {
this.mediaQuerySelectorScopes = [selectorScope];
}
return selectorScope;
}
private lookupRulesets(rulesets: RuleSet[]) {
let lastMediaSelectorScope: MediaQuerySelectorScope<T>;
for (let i = 0, length = rulesets.length; i < length; i++) {
const ruleset = rulesets[i];
if (lastMediaSelectorScope && lastMediaSelectorScope.mediaQueryString !== ruleset.mediaQueryString) {
// Once done with current media query scope, update stylesheet scope position
this.position = lastMediaSelectorScope.position;
lastMediaSelectorScope = null;
}
if (ruleset.mediaQueryString) {
// Create media query selector scope and register selector lookups there
if (!lastMediaSelectorScope) {
lastMediaSelectorScope = this.createMediaQuerySelectorScope(ruleset.mediaQueryString);
}
ruleset.lookupSort(lastMediaSelectorScope);
} else {
ruleset.lookupSort(this);
}
}
// If reference of last media selector scope is still kept, update stylesheet scope position
if (lastMediaSelectorScope) {
this.position = lastMediaSelectorScope.position;
lastMediaSelectorScope = null;
}
}
query(node: T): SelectorsMatch<T> {
const selectorsMatch = new SelectorsMatch<T>();
const selectors = this.getSelectorCandidates(node);
// Validate media queries and include their selectors if needed
if (this.mediaQuerySelectorScopes) {
// Cache media query results to avoid validations of other identical queries
const validatedMediaQueries: string[] = [];
for (let i = 0, length = this.mediaQuerySelectorScopes.length; i < length; i++) {
const selectorScope = this.mediaQuerySelectorScopes[i];
const isMatchingAllQueries = matchMediaQueryString(selectorScope.mediaQueryString, validatedMediaQueries);
if (isMatchingAllQueries) {
const mediaQuerySelectors = selectorScope.getSelectorCandidates(node);
selectors.push(...mediaQuerySelectors);
}
}
}
selectorsMatch.selectors = selectors.filter((sel) => sel.accumulateChanges(node, selectorsMatch)).sort((a, b) => a.specificity - b.specificity || a.pos - b.pos);
return selectorsMatch;
}
}
interface ChangeAccumulator {
addAttribute(node: Node, attribute: string): void;
addPseudoClass(node: Node, pseudoClass: string): void;
@ -1056,7 +1166,9 @@ export const CSSHelper = {
SimpleSelectorSequence,
Selector,
RuleSet,
SelectorsMap,
fromAstNodes,
SelectorScope,
MediaQuerySelectorScope,
StyleSheetSelectorScope,
fromAstNode,
SelectorsMatch,
};

View File

@ -1,10 +1,9 @@
import { Keyframes } from '../animation/keyframe-animation';
import { ViewBase } from '../core/view-base';
import { View } from '../core/view';
import { unsetValue, _evaluateCssVariableExpression, _evaluateCssCalcExpression, isCssVariable, isCssVariableExpression, isCssCalcExpression } from '../core/properties';
import { SyntaxTree, Keyframes as KeyframesDefinition, Node as CssNode } from '../../css';
import * as ReworkCSS from '../../css';
import { RuleSet, SelectorsMap, SelectorCore, SelectorsMatch, ChangeMap, fromAstNodes, Node } from './css-selector';
import { RuleSet, StyleSheetSelectorScope, SelectorCore, SelectorsMatch, ChangeMap, fromAstNode, Node, MEDIA_QUERY_SEPARATOR, matchMediaQueryString } from './css-selector';
import { Trace } from '../../trace';
import { File, knownFolders, path } from '../../file-system';
import { Application, CssChangedEventData, LoadAppCSSEventData } from '../../application';
@ -45,6 +44,25 @@ try {
//
}
type KeyframesMap = Map<string, kam.Keyframes[]>;
let mergedApplicationCssSelectors: RuleSet[] = [];
let applicationCssSelectors: RuleSet[] = [];
const applicationAdditionalSelectors: RuleSet[] = [];
let mergedApplicationCssKeyframes: kam.Keyframes[] = [];
let applicationCssKeyframes: kam.Keyframes[] = [];
const applicationAdditionalKeyframes: kam.Keyframes[] = [];
let applicationCssSelectorVersion = 0;
const tagToScopeTag: Map<string | number, string> = new Map();
let currentScopeTag: string = null;
const animationsSymbol = Symbol('animations');
const kebabCasePattern = /-([a-z])/g;
const pattern = /('|")(.*?)\1/;
/**
* Evaluate css-variable and css-calc expressions
*/
@ -68,39 +86,32 @@ function evaluateCssExpressions(view: ViewBase, property: string, value: string)
}
export function mergeCssSelectors(): void {
applicationCssSelectors = applicationSelectors.slice();
applicationCssSelectors.push(...applicationAdditionalSelectors);
applicationCssSelectorVersion++;
mergedApplicationCssSelectors = applicationCssSelectors.slice();
mergedApplicationCssSelectors.push(...applicationAdditionalSelectors);
}
let applicationCssSelectors: RuleSet[] = [];
let applicationCssSelectorVersion = 0;
let applicationSelectors: RuleSet[] = [];
const tagToScopeTag: Map<string | number, string> = new Map();
let currentScopeTag: string = null;
const applicationAdditionalSelectors: RuleSet[] = [];
const applicationKeyframes: any = {};
const animationsSymbol = Symbol('animations');
const kebabCasePattern = /-([a-z])/g;
const pattern = /('|")(.*?)\1/;
export function mergeCssKeyframes(): void {
mergedApplicationCssKeyframes = applicationCssKeyframes.slice();
mergedApplicationCssKeyframes.push(...applicationAdditionalKeyframes);
}
class CSSSource {
private _selectors: RuleSet[] = [];
private _keyframes: kam.Keyframes[] = [];
private constructor(
private _ast: SyntaxTree,
private _ast: ReworkCSS.SyntaxTree,
private _url: string,
private _file: string,
private _keyframes: KeyframesMap,
private _source: string,
) {
this.parse();
}
public static fromDetect(cssOrAst: any, keyframes: KeyframesMap, fileName?: string): CSSSource {
public static fromDetect(cssOrAst: any, fileName?: string): CSSSource {
if (typeof cssOrAst === 'string') {
// raw-loader
return CSSSource.fromSource(cssOrAst, keyframes, fileName);
return CSSSource.fromSource(cssOrAst, fileName);
} else if (typeof cssOrAst === 'object') {
if (cssOrAst.default) {
cssOrAst = cssOrAst.default;
@ -108,15 +119,15 @@ class CSSSource {
if (cssOrAst.type === 'stylesheet' && cssOrAst.stylesheet && cssOrAst.stylesheet.rules) {
// css-loader
return CSSSource.fromAST(cssOrAst, keyframes, fileName);
return CSSSource.fromAST(cssOrAst, fileName);
}
}
// css2json-loader
return CSSSource.fromSource(cssOrAst.toString(), keyframes, fileName);
return CSSSource.fromSource(cssOrAst.toString(), fileName);
}
public static fromURI(uri: string, keyframes: KeyframesMap): CSSSource {
public static fromURI(uri: string): CSSSource {
// webpack modules require all file paths to be relative to /app folder
const appRelativeUri = CSSSource.pathRelativeToApp(uri);
const sanitizedModuleName = sanitizeModuleName(appRelativeUri);
@ -125,7 +136,7 @@ class CSSSource {
try {
const cssOrAst = global.loadModule(resolvedModuleName, true);
if (cssOrAst) {
return CSSSource.fromDetect(cssOrAst, keyframes, resolvedModuleName);
return CSSSource.fromDetect(cssOrAst, resolvedModuleName);
}
} catch (e) {
if (Trace.isEnabled()) {
@ -133,7 +144,7 @@ class CSSSource {
}
}
return CSSSource.fromFile(appRelativeUri, keyframes);
return CSSSource.fromFile(appRelativeUri);
}
private static pathRelativeToApp(uri: string): string {
@ -148,30 +159,30 @@ class CSSSource {
return uri;
}
const relativeUri = `.${uri.substr(appPath.length)}`;
const relativeUri = `.${uri.substring(appPath.length)}`;
return relativeUri;
}
public static fromFile(url: string, keyframes: KeyframesMap): CSSSource {
public static fromFile(url: string): CSSSource {
// .scss, .sass, etc. css files in vanilla app are usually compiled to .css so we will try to load a compiled file first.
const cssFileUrl = url.replace(/\..\w+$/, '.css');
if (cssFileUrl !== url) {
const cssFile = CSSSource.resolveCSSPathFromURL(cssFileUrl);
if (cssFile) {
return new CSSSource(undefined, url, cssFile, keyframes, undefined);
return new CSSSource(undefined, url, cssFile, undefined);
}
}
const file = CSSSource.resolveCSSPathFromURL(url);
return new CSSSource(undefined, url, file, keyframes, undefined);
return new CSSSource(undefined, url, file, undefined);
}
public static fromFileImport(url: string, keyframes: KeyframesMap, importSource: string): CSSSource {
public static fromFileImport(url: string, importSource: string): CSSSource {
const file = CSSSource.resolveCSSPathFromURL(url, importSource);
return new CSSSource(undefined, url, file, keyframes, undefined);
return new CSSSource(undefined, url, file, undefined);
}
@profile
@ -182,17 +193,22 @@ class CSSSource {
return file;
}
public static fromSource(source: string, keyframes: KeyframesMap, url?: string): CSSSource {
return new CSSSource(undefined, url, undefined, keyframes, source);
public static fromSource(source: string, url?: string): CSSSource {
return new CSSSource(undefined, url, undefined, source);
}
public static fromAST(ast: SyntaxTree, keyframes: KeyframesMap, url?: string): CSSSource {
return new CSSSource(ast, url, undefined, keyframes, undefined);
public static fromAST(ast: ReworkCSS.SyntaxTree, url?: string): CSSSource {
return new CSSSource(ast, url, undefined, undefined);
}
get selectors(): RuleSet[] {
return this._selectors;
}
get keyframes(): kam.Keyframes[] {
return this._keyframes;
}
get source(): string {
return this._source;
}
@ -216,7 +232,7 @@ class CSSSource {
}
}
if (this._ast) {
this.createSelectors();
this.createSelectorsAndKeyframes();
} else {
this._selectors = [];
}
@ -249,14 +265,40 @@ class CSSSource {
}
@profile
private createSelectors() {
private createSelectorsAndKeyframes() {
if (this._ast) {
this._selectors = [...this.createSelectorsFromImports(), ...this.createSelectorsFromSyntaxTree()];
const nodes = this._ast.stylesheet.rules;
const rulesets: RuleSet[] = [];
const keyframes: kam.Keyframes[] = [];
// When css2json-loader is enabled, imports are handled there and removed from AST rules
populateRulesFromImports(nodes, rulesets, keyframes);
_populateRules(nodes, rulesets, keyframes);
if (rulesets && rulesets.length) {
ensureCssAnimationParserModule();
rulesets.forEach((rule) => {
rule[animationsSymbol] = cssAnimationParserModule.CssAnimationParser.keyframeAnimationsFromCSSDeclarations(rule.declarations);
});
}
this._selectors = rulesets;
this._keyframes = keyframes;
}
}
private createSelectorsFromImports(): RuleSet[] {
const imports = this._ast['stylesheet']['rules'].filter((r) => r.type === 'import');
toString(): string {
return this._file || this._url || '(in-memory)';
}
}
function populateRulesFromImports(nodes: ReworkCSS.Node[], rulesets: RuleSet[], keyframes: kam.Keyframes[]): void {
const imports = nodes.filter((r) => r.type === 'import');
if (!imports.length) {
return;
}
const urlFromImportObject = (importObject) => {
const importItem = importObject['import'] as string;
@ -272,83 +314,151 @@ class CSSSource {
source: sourceFromImportObject(importObject),
});
const getCssFile = ({ url, source }) => (source ? CSSSource.fromFileImport(url, this._keyframes, source) : CSSSource.fromURI(url, this._keyframes));
const getCssFile = ({ url, source }) => (source ? CSSSource.fromFileImport(url, source) : CSSSource.fromURI(url));
const cssFiles = imports
.map(toUrlSourcePair)
.filter(({ url }) => !!url)
.map(getCssFile);
const selectors = cssFiles.map((file) => (file && file.selectors) || []);
return selectors.reduce((acc, val) => acc.concat(val), []);
for (const cssFile of cssFiles) {
if (cssFile) {
rulesets.push(...cssFile.selectors);
keyframes.push(...cssFile.keyframes);
}
private createSelectorsFromSyntaxTree(): RuleSet[] {
const nodes = this._ast.stylesheet.rules;
(<KeyframesDefinition[]>nodes.filter(isKeyframe)).forEach((node) => (this._keyframes[node.name] = node));
const rulesets = fromAstNodes(nodes);
if (rulesets && rulesets.length) {
ensureCssAnimationParserModule();
rulesets.forEach((rule) => {
rule[animationsSymbol] = cssAnimationParserModule.CssAnimationParser.keyframeAnimationsFromCSSDeclarations(rule.declarations);
});
}
}
return rulesets;
export function _populateRules(nodes: ReworkCSS.Node[], rulesets: RuleSet[], keyframes: kam.Keyframes[], mediaQueryString?: string): void {
for (const node of nodes) {
if (isKeyframe(node)) {
const keyframeRule: kam.Keyframes = {
name: node.name,
keyframes: node.keyframes,
mediaQueryString: mediaQueryString,
};
keyframes.push(keyframeRule);
} else if (isMedia(node)) {
// Media query is composite in the case of nested media queries
const compositeMediaQuery = mediaQueryString ? mediaQueryString + MEDIA_QUERY_SEPARATOR + node.media : node.media;
_populateRules(node.rules, rulesets, keyframes, compositeMediaQuery);
} else if (isRule(node)) {
const ruleset = fromAstNode(node);
ruleset.mediaQueryString = mediaQueryString;
rulesets.push(ruleset);
}
toString(): string {
return this._file || this._url || '(in-memory)';
}
}
export function removeTaggedAdditionalCSS(tag: string | number): boolean {
let changed = false;
let selectorsChanged = false;
let keyframesChanged = false;
let updated = false;
for (let i = 0; i < applicationAdditionalSelectors.length; i++) {
if (applicationAdditionalSelectors[i].tag === tag) {
applicationAdditionalSelectors.splice(i, 1);
i--;
changed = true;
selectorsChanged = true;
}
}
if (changed) {
mergeCssSelectors();
}
return changed;
for (let i = 0; i < applicationAdditionalKeyframes.length; i++) {
if (applicationAdditionalKeyframes[i].tag === tag) {
applicationAdditionalKeyframes.splice(i, 1);
i--;
keyframesChanged = true;
}
}
if (selectorsChanged) {
mergeCssSelectors();
updated = true;
}
if (keyframesChanged) {
mergeCssKeyframes();
updated = true;
}
if (updated) {
applicationCssSelectorVersion++;
}
return updated;
}
export function addTaggedAdditionalCSS(cssText: string, tag?: string | number): boolean {
const parsed: RuleSet[] = CSSSource.fromDetect(cssText, applicationKeyframes, undefined).selectors;
const { selectors, keyframes } = CSSSource.fromDetect(cssText, undefined);
const tagScope = currentScopeTag || (tag && tagToScopeTag.has(tag) && tagToScopeTag.get(tag)) || null;
if (tagScope && tag) {
tagToScopeTag.set(tag, tagScope);
}
let changed = false;
if (parsed && parsed.length) {
changed = true;
let selectorsChanged = false;
let keyframesChanged = false;
let updated = false;
if (selectors && selectors.length) {
selectorsChanged = true;
if (tag != null || tagScope != null) {
for (let i = 0; i < parsed.length; i++) {
parsed[i].tag = tag;
parsed[i].scopedTag = tagScope;
for (let i = 0, length = selectors.length; i < length; i++) {
selectors[i].tag = tag;
selectors[i].scopedTag = tagScope;
}
}
applicationAdditionalSelectors.push(...parsed);
mergeCssSelectors();
}
return changed;
applicationAdditionalSelectors.push(...selectors);
mergeCssSelectors();
updated = true;
}
if (keyframes && keyframes.length) {
keyframesChanged = true;
if (tag != null || tagScope != null) {
for (let i = 0, length = keyframes.length; i < length; i++) {
keyframes[i].tag = tag;
keyframes[i].scopedTag = tagScope;
}
}
applicationAdditionalKeyframes.push(...keyframes);
mergeCssKeyframes();
updated = true;
}
if (updated) {
applicationCssSelectorVersion++;
}
return updated;
}
const onCssChanged = profile('"style-scope".onCssChanged', (args: CssChangedEventData) => {
if (args.cssText) {
const parsed = CSSSource.fromSource(args.cssText, applicationKeyframes, args.cssFile).selectors;
if (parsed) {
applicationAdditionalSelectors.push(...parsed);
const { selectors, keyframes } = CSSSource.fromSource(args.cssText, args.cssFile);
let updated = false;
if (selectors) {
applicationAdditionalSelectors.push(...selectors);
mergeCssSelectors();
updated = true;
}
if (keyframes) {
applicationAdditionalKeyframes.push(...keyframes);
mergeCssKeyframes();
updated = true;
}
if (updated) {
applicationCssSelectorVersion++;
}
} else if (args.cssFile) {
loadCss(args.cssFile, null, null);
@ -359,20 +469,35 @@ function onLiveSync(args: CssChangedEventData): void {
loadCss(Application.getCssFileName(), null, null);
}
const loadCss = profile(`"style-scope".loadCss`, (cssModule: string) => {
const loadCss = profile(`"style-scope".loadCss`, (cssModule: string): void => {
if (!cssModule) {
return undefined;
return;
}
// safely remove "./" as global CSS should be resolved relative to app folder
if (cssModule.startsWith('./')) {
cssModule = cssModule.substr(2);
cssModule = cssModule.substring(2);
}
const result = CSSSource.fromURI(cssModule, applicationKeyframes).selectors;
if (result.length > 0) {
applicationSelectors = result;
const { selectors, keyframes } = CSSSource.fromURI(cssModule);
let updated = false;
// Check for existing application css selectors too in case the app is undergoing a live-sync
if (selectors.length > 0 || applicationCssSelectors.length > 0) {
applicationCssSelectors = selectors;
mergeCssSelectors();
updated = true;
}
// Check for existing application css keyframes too in case the app is undergoing a live-sync
if (keyframes.length > 0 || applicationCssKeyframes.length > 0) {
applicationCssKeyframes = keyframes;
mergeCssKeyframes();
updated = true;
}
if (updated) {
applicationCssSelectorVersion++;
}
});
@ -475,7 +600,7 @@ export class CssState {
private updateMatch() {
const view = this.viewRef.get();
if (view && view._styleScope) {
this._match = view._styleScope.matchSelectors(view);
this._match = view._styleScope.matchSelectors(view) ?? CssState.emptyMatch;
this._appliedSelectorsVersion = view._styleScope.getSelectorsVersion();
} else {
this._match = CssState.emptyMatch;
@ -494,12 +619,14 @@ export class CssState {
}
const matchingSelectors = this._match.selectors.filter((sel) => (sel.dynamic ? sel.match(view) : true));
if (!matchingSelectors || matchingSelectors.length === 0) {
// Ideally we should return here if there are no matching selectors, however
// if there are property removals, returning here would not remove them
// this is seen in STYLE test in automated.
// if (!matchingSelectors || matchingSelectors.length === 0) {
// return;
}
// }
view._batchUpdate(() => {
this.stopKeyframeAnimations();
this.setPropertyValues(matchingSelectors);
@ -511,7 +638,7 @@ export class CssState {
const animations: kam.KeyframeAnimation[] = [];
matchingSelectors.forEach((selector) => {
const ruleAnimations: kam.KeyframeAnimationInfo[] = selector.ruleset[animationsSymbol];
const ruleAnimations: kam.KeyframeAnimationInfo[] = selector.ruleset?.[animationsSymbol];
if (ruleAnimations) {
ensureKeyframeAnimationModule();
for (const animationInfo of ruleAnimations) {
@ -716,14 +843,18 @@ CssState.prototype._appliedAnimations = CssState.emptyAnimationArray;
CssState.prototype._matchInvalid = true;
export class StyleScope {
private _selectors: SelectorsMap<any>;
private _selectorScope: StyleSheetSelectorScope<any>;
private _css = '';
private _mergedCssSelectors: RuleSet[];
private _mergedCssKeyframes: kam.Keyframes[];
private _localCssSelectors: RuleSet[] = [];
private _localCssKeyframes: kam.Keyframes[] = [];
private _localCssSelectorVersion = 0;
private _localCssSelectorsAppliedVersion = 0;
private _applicationCssSelectorsAppliedVersion = 0;
private _keyframes = new Map<string, Keyframes>();
private _cssFiles: string[] = [];
get css(): string {
@ -749,10 +880,12 @@ export class StyleScope {
this._cssFiles.push(cssFileName);
currentScopeTag = cssFileName;
const cssSelectors = CSSSource.fromURI(cssFileName, this._keyframes);
const cssFile = CSSSource.fromURI(cssFileName);
currentScopeTag = null;
this._css = cssSelectors.source;
this._localCssSelectors = cssSelectors.selectors;
this._css = cssFile.source;
this._localCssSelectors = cssFile.selectors;
this._localCssKeyframes = cssFile.keyframes;
this._localCssSelectorVersion++;
this.ensureSelectors();
}
@ -761,8 +894,9 @@ export class StyleScope {
private setCss(cssString: string, cssFileName?): void {
this._css = cssString;
const cssFile = CSSSource.fromSource(cssString, this._keyframes, cssFileName);
const cssFile = CSSSource.fromSource(cssString, cssFileName);
this._localCssSelectors = cssFile.selectors;
this._localCssKeyframes = cssFile.keyframes;
this._localCssSelectorVersion++;
this.ensureSelectors();
}
@ -777,24 +911,27 @@ export class StyleScope {
currentScopeTag = cssFileName;
}
const parsedCssSelectors = cssString ? CSSSource.fromSource(cssString, this._keyframes, cssFileName) : CSSSource.fromURI(cssFileName, this._keyframes);
const cssFile = cssString ? CSSSource.fromSource(cssString, cssFileName) : CSSSource.fromURI(cssFileName);
currentScopeTag = null;
this._css = this._css + parsedCssSelectors.source;
this._localCssSelectors.push(...parsedCssSelectors.selectors);
this._css = this._css + cssFile.source;
this._localCssSelectors.push(...cssFile.selectors);
this._localCssKeyframes.push(...cssFile.keyframes);
this._localCssSelectorVersion++;
this.ensureSelectors();
}
public getKeyframeAnimationWithName(animationName: string): kam.KeyframeAnimationInfo {
const cssKeyframes = this._keyframes[animationName];
if (!cssKeyframes) {
return;
if (!this._mergedCssKeyframes) {
return null;
}
const keyframeRule = this.findKeyframeRule(animationName);
ensureKeyframeAnimationModule();
const animation = new keyframeAnimationModule.KeyframeAnimationInfo();
ensureCssAnimationParserModule();
animation.keyframes = cssAnimationParserModule.CssAnimationParser.keyframesArrayFromCSS(cssKeyframes.keyframes);
animation.keyframes = keyframeRule ? cssAnimationParserModule.CssAnimationParser.keyframesArrayFromCSS(keyframeRule.keyframes) : null;
return animation;
}
@ -821,36 +958,54 @@ export class StyleScope {
@profile
private _createSelectors() {
const toMerge: RuleSet[][] = [];
toMerge.push(applicationCssSelectors.filter((v) => !v.scopedTag || this._cssFiles.indexOf(v.scopedTag) >= 0));
const toMerge: RuleSet[] = [];
const toMergeKeyframes: kam.Keyframes[] = [];
toMerge.push(...mergedApplicationCssSelectors.filter((v) => !v.scopedTag || this._cssFiles.indexOf(v.scopedTag) >= 0));
toMergeKeyframes.push(...mergedApplicationCssKeyframes.filter((v) => !v.scopedTag || this._cssFiles.indexOf(v.scopedTag) >= 0));
this._applicationCssSelectorsAppliedVersion = applicationCssSelectorVersion;
toMerge.push(this._localCssSelectors);
toMerge.push(...this._localCssSelectors);
toMergeKeyframes.push(...this._localCssKeyframes);
this._localCssSelectorsAppliedVersion = this._localCssSelectorVersion;
for (const keyframe in applicationKeyframes) {
this._keyframes[keyframe] = applicationKeyframes[keyframe];
}
if (toMerge.length > 0) {
this._mergedCssSelectors = toMerge.reduce((merged, next) => merged.concat(next || []), []);
this._applyKeyframesOnSelectors();
this._selectors = new SelectorsMap(this._mergedCssSelectors);
this._mergedCssSelectors = toMerge;
this._selectorScope = new StyleSheetSelectorScope(this._mergedCssSelectors);
} else {
this._mergedCssSelectors = null;
this._selectorScope = null;
}
this._mergedCssKeyframes = toMergeKeyframes.length > 0 ? toMergeKeyframes : null;
}
// HACK: This @profile decorator creates a circular dependency
// HACK: because the function parameter type is evaluated with 'typeof'
@profile
public matchSelectors(view): SelectorsMatch<ViewBase> {
let match: SelectorsMatch<ViewBase>;
// should be (view: ViewBase): SelectorsMatch<ViewBase>
this.ensureSelectors();
return this._selectors.query(view);
if (this._selectorScope) {
match = this._selectorScope.query(view);
// Make sure to re-apply keyframes to matching selectors as a media query keyframe might be applicable at this point
this._applyKeyframesToSelectors(match.selectors);
} else {
match = null;
}
return match;
}
public query(node: Node): SelectorCore[] {
this.ensureSelectors();
return this._selectors.query(node).selectors;
const match = this.matchSelectors(node);
return match ? match.selectors : [];
}
getSelectorsVersion() {
@ -859,17 +1014,21 @@ export class StyleScope {
return 100000 * this._applicationCssSelectorsAppliedVersion + this._localCssSelectorsAppliedVersion;
}
private _applyKeyframesOnSelectors() {
for (let i = this._mergedCssSelectors.length - 1; i >= 0; i--) {
const ruleset = this._mergedCssSelectors[i];
const animations: kam.KeyframeAnimationInfo[] = ruleset[animationsSymbol];
if (animations !== undefined && animations.length) {
ensureCssAnimationParserModule();
for (const animation of animations) {
const cssKeyframe = this._keyframes[animation.name];
if (cssKeyframe !== undefined) {
animation.keyframes = cssAnimationParserModule.CssAnimationParser.keyframesArrayFromCSS(cssKeyframe.keyframes);
private _applyKeyframesToSelectors(selectors: SelectorCore[]) {
if (!selectors?.length) {
return;
}
for (let i = selectors.length - 1; i >= 0; i--) {
const ruleset = selectors[i].ruleset;
const animations: kam.KeyframeAnimationInfo[] = ruleset[animationsSymbol];
if (animations != null && animations.length) {
ensureCssAnimationParserModule();
for (const animation of animations) {
const keyframeRule = this.findKeyframeRule(animation.name);
animation.keyframes = keyframeRule ? cssAnimationParserModule.CssAnimationParser.keyframesArrayFromCSS(keyframeRule.keyframes) : null;
}
}
}
@ -878,9 +1037,39 @@ export class StyleScope {
public getAnimations(ruleset: RuleSet): kam.KeyframeAnimationInfo[] {
return ruleset[animationsSymbol];
}
}
type KeyframesMap = Map<string, Keyframes>;
private findKeyframeRule(animationName: string): kam.Keyframes {
if (!this._mergedCssKeyframes) {
return null;
}
// Cache media query results to avoid validations of other identical queries
let validatedMediaQueries: string[];
// Iterate in reverse order as the last usable keyframe rule matters the most
for (let i = this._mergedCssKeyframes.length - 1; i >= 0; i--) {
const rule = this._mergedCssKeyframes[i];
if (rule.name !== animationName) {
continue;
}
if (!rule.mediaQueryString) {
return rule;
}
if (!validatedMediaQueries) {
validatedMediaQueries = [];
}
const isMatchingAllQueries = matchMediaQueryString(rule.mediaQueryString, validatedMediaQueries);
if (isMatchingAllQueries) {
return rule;
}
}
return null;
}
}
export function resolveFileNameFromUrl(url: string, appDirectory: string, fileExists: (name: string) => boolean, importSource?: string): string {
let fileName: string = typeof url === 'string' ? url.trim() : '';
@ -896,7 +1085,7 @@ export function resolveFileNameFromUrl(url: string, appDirectory: string, fileEx
if (!isAbsolutePath) {
if (fileName[0] === '~' && fileName[1] !== '/' && fileName[1] !== '"') {
fileName = fileName.substr(1);
fileName = fileName.substring(1);
}
if (importSource) {
@ -932,7 +1121,7 @@ function resolveFilePathFromImport(importSource: string, fileName: string): stri
export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase, styleStr: string) {
const localStyle = `local { ${styleStr} }`;
const inlineRuleSet = CSSSource.fromSource(localStyle, new Map()).selectors;
const inlineRuleSet = CSSSource.fromSource(localStyle).selectors;
// Reset unscoped css-variables
view.style.resetUnscopedCssVariables();
@ -978,6 +1167,14 @@ function isParentDirectory(uriPart: string): boolean {
return uriPart === '..';
}
function isKeyframe(node: CssNode): node is KeyframesDefinition {
function isMedia(node: ReworkCSS.Node): node is ReworkCSS.Media {
return node.type === 'media';
}
function isKeyframe(node: ReworkCSS.Node): node is ReworkCSS.Keyframes {
return node.type === 'keyframes';
}
function isRule(node: ReworkCSS.Node): node is ReworkCSS.Rule {
return node.type === 'rule';
}