From 9fd361c2e6a14d79e61c9565f7c1b532d5ba499b Mon Sep 17 00:00:00 2001 From: Dimitris-Rafail Katsampas Date: Mon, 1 Jul 2024 19:28:59 +0300 Subject: [PATCH] feat(core): css media query support (#10530) --- .../src/ui/animation/css-animation-tests.ts | 141 +++-- apps/automated/src/ui/styling/style-tests.ts | 128 ++++- apps/ui/src/main-page.ts | 1 + apps/ui/src/media-queries/main-page.css | 19 + apps/ui/src/media-queries/main-page.ts | 34 ++ apps/ui/src/media-queries/main-page.xml | 25 + .../core/application/application-common.ts | 36 +- packages/core/application/application.ios.ts | 2 +- packages/core/css-mediaquery/LICENSE | 27 + packages/core/css-mediaquery/index.spec.ts | 118 +++++ packages/core/css-mediaquery/index.ts | 176 ++++++ packages/core/css/reworkcss.d.ts | 16 +- packages/core/globals/index.ts | 3 + packages/core/jest.setup.ts | 18 +- packages/core/media-query-list/index.spec.ts | 74 +++ packages/core/media-query-list/index.ts | 214 ++++++++ .../platform/{ => device}/index.android.ts | 62 +-- packages/core/platform/device/index.d.ts | 70 +++ .../core/platform/{ => device}/index.ios.ts | 43 +- packages/core/platform/index.d.ts | 120 +---- packages/core/platform/index.ts | 3 + .../core/platform/screen/index.android.ts | 49 ++ packages/core/platform/screen/index.d.ts | 48 ++ packages/core/platform/screen/index.ios.ts | 42 ++ packages/core/trace/index.d.ts | 1 + packages/core/trace/index.ts | 3 +- .../core/ui/animation/keyframe-animation.d.ts | 3 + .../core/ui/animation/keyframe-animation.ts | 12 +- packages/core/ui/core/view/index.android.ts | 3 +- packages/core/ui/styling/css-selector.spec.ts | 124 ++++- packages/core/ui/styling/css-selector.ts | 174 ++++-- packages/core/ui/styling/style-scope.ts | 499 ++++++++++++------ 32 files changed, 1812 insertions(+), 476 deletions(-) create mode 100644 apps/ui/src/media-queries/main-page.css create mode 100644 apps/ui/src/media-queries/main-page.ts create mode 100644 apps/ui/src/media-queries/main-page.xml create mode 100644 packages/core/css-mediaquery/LICENSE create mode 100644 packages/core/css-mediaquery/index.spec.ts create mode 100644 packages/core/css-mediaquery/index.ts create mode 100644 packages/core/media-query-list/index.spec.ts create mode 100644 packages/core/media-query-list/index.ts rename packages/core/platform/{ => device}/index.android.ts (60%) create mode 100644 packages/core/platform/device/index.d.ts rename packages/core/platform/{ => device}/index.ios.ts (65%) create mode 100644 packages/core/platform/index.ts create mode 100644 packages/core/platform/screen/index.android.ts create mode 100644 packages/core/platform/screen/index.d.ts create mode 100644 packages/core/platform/screen/index.ios.ts diff --git a/apps/automated/src/ui/animation/css-animation-tests.ts b/apps/automated/src/ui/animation/css-animation-tests.ts index edb41b114..4c96efde6 100644 --- a/apps/automated/src/ui/animation/css-animation-tests.ts +++ b/apps/automated/src/ui/animation/css-animation-tests.ts @@ -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); diff --git a/apps/automated/src/ui/styling/style-tests.ts b/apps/automated/src/ui/styling/style-tests.ts index 2cca56556..681c2a12c 100644 --- a/apps/automated/src/ui/styling/style-tests.ts +++ b/apps/automated/src/ui/styling/style-tests.ts @@ -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; diff --git a/apps/ui/src/main-page.ts b/apps/ui/src/main-page.ts index cc025764d..a75fdf3d7 100644 --- a/apps/ui/src/main-page.ts +++ b/apps/ui/src/main-page.ts @@ -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); diff --git a/apps/ui/src/media-queries/main-page.css b/apps/ui/src/media-queries/main-page.css new file mode 100644 index 000000000..4230edd8a --- /dev/null +++ b/apps/ui/src/media-queries/main-page.css @@ -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; + } +} \ No newline at end of file diff --git a/apps/ui/src/media-queries/main-page.ts b/apps/ui/src/media-queries/main-page.ts new file mode 100644 index 000000000..09f29776f --- /dev/null +++ b/apps/ui/src/media-queries/main-page.ts @@ -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 = 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!`; + }); + } +} diff --git a/apps/ui/src/media-queries/main-page.xml b/apps/ui/src/media-queries/main-page.xml new file mode 100644 index 000000000..014e953f0 --- /dev/null +++ b/apps/ui/src/media-queries/main-page.xml @@ -0,0 +1,25 @@ + + + + + + + +