From 673c8087e0f6d85feaca032a0f12b5650e2a124b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Mon, 19 Aug 2019 23:56:56 +0200 Subject: [PATCH] feat: implement css-variables and css-calc (#7553) * feat: implement basic support for css-variables * fix(test): test-watch-android and test-watch-ios was broken * fix: processing css-variables belong in CssProperty-classes Not in the StyleScope. * fix(css-variables): set style attribute override value from css-classes * feat: add css calc-support using 'reduce-css-calc' * fix(tslint): missing semicolon and incorrect quotemark * feat: move css-variable handling to Style-class * chor: add comments explaining css-variable implmentation * chor: set css-variables before other style properties * chor(css-variables): cleaning up * chor: code style fixes * test(CSS-CALC): Add tests for nested css-calc statements * fix(CSS-CALC): dip-unit not supported by reduce-css-calc * fix(tslint): use double quotemarks * test(css-calc): test _cssCalcConverter directly * chor(css-variables): rename and clean up _cssVariableConverter to _evaluateCssVariable * chor: rename varname to varName for consistency * chor: support css-calc and variables for normal properties * chor: use string.replace to evaluate css-variables * fix: Missing blank line before return * chor: rename css-calc functions * fix: undefined css-variables treated as 'unset' * fix(tslint): use double quotemarks * feat(css-variable): handle fallback values * chor(css-variables): handle unsetValue * chor: process css-calc and css-variables in style-scope * chore: clean-up css-calc/variable expressions * fix(css-calc): handle invalid expressions * chore(CSSState): update comments * chore(Style): rename css-variable functions * chore(css-variables): describe fallback logic * chore: move reset scoped css-variables to Style-class * chore(CssState): simplify check for css expressions * chore: add reduce-css-calc to /package.json --- package.json | 5 +- tests/app/ui/styling/style-tests.ts | 408 ++++++++++++++++++ tns-core-modules/package.json | 1 + .../ui/core/properties/properties.d.ts | 8 + .../ui/core/properties/properties.ts | 72 ++++ tns-core-modules/ui/styling/style-scope.ts | 105 ++++- tns-core-modules/ui/styling/style/style.d.ts | 26 ++ tns-core-modules/ui/styling/style/style.ts | 40 ++ 8 files changed, 651 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 036e40a42..905c56dca 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "nativescript-typedoc-theme": "git://github.com/NativeScript/nativescript-typedoc-theme.git#master", "parse-css": "git+https://github.com/tabatkins/parse-css.git", "parserlib": "^1.1.1", + "reduce-css-calc": "^2.1.6", "shady-css-parser": "^0.1.0", "shelljs": "^0.7.0", "source-map-support": "^0.4.17", @@ -71,8 +72,8 @@ "pretest": "npm run setup && npm run tsc", "test-android": "tns run android --path tests --justlaunch --no-watch", "test-ios": "tns run ios --path tests --justlaunch --no-watch", - "test-watch-android": "npm run pretest && concurrently --kill-others \"npm run tsc-tiw\" \"tns livesync android --path tests --watch\"", - "test-watch-ios": "npm run pretest && concurrently --kill-others \"npm run tsc-tiw\" \"tns livesync ios --path tests --watch\"", + "test-watch-android": "npm run pretest && concurrently --kill-others \"npm run tsc-w\" \"tns run android --path tests --watch\"", + "test-watch-ios": "npm run pretest && concurrently --kill-others \"npm run tsc-w\" \"tns run ios --path tests --watch\"", "typedoc": "typedoc --tsconfig tsconfig.typedoc.json --out bin/dist/apiref --includeDeclarations --name NativeScript --theme ./node_modules/nativescript-typedoc-theme --excludeExternals --externalPattern \"**/+(tns-core-modules|module).d.ts\"", "dev-typedoc": "npm run typedoc && cd bin/dist/apiref && npx http-server", "test-tsc-es2016": "npm run tsc -- -p tsconfig.public.es2016.json", diff --git a/tests/app/ui/styling/style-tests.ts b/tests/app/ui/styling/style-tests.ts index 6512d0f42..db4c180c4 100644 --- a/tests/app/ui/styling/style-tests.ts +++ b/tests/app/ui/styling/style-tests.ts @@ -13,6 +13,7 @@ import { resolveFileNameFromUrl, removeTaggedAdditionalCSS, addTaggedAdditionalC import { unsetValue } from "tns-core-modules/ui/core/view"; import * as color from "tns-core-modules/color"; import * as fs from "tns-core-modules/file-system"; +import { _evaluateCssCalcExpression } from "tns-core-modules/ui/core/properties/properties"; export function test_css_dataURI_is_applied_to_backgroundImageSource() { const stack = new stackModule.StackLayout(); @@ -1426,6 +1427,413 @@ export function test_CascadingClassNamesAppliesAfterPageLoad() { }); } +export function test_evaluateCssCalcExpression() { + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(1px + 1px)"), "2px", "Simple calc (1)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(50px - (20px - 30px))"), "60px", "Simple calc (2)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(100px - (100px - 100%))"), "100%", "Simple calc (3)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(100px + (100px - 100%))"), "calc(200px - 100%)", "Simple calc (4)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(100% - 10px + 20px)"), "calc(100% + 10px)", "Simple calc (5)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(100% + 10px - 20px)"), "calc(100% - 10px)", "Simple calc (6)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(10.px + .0px)"), "10px", "Simple calc (8)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("a calc(1px + 1px)"), "a 2px", "Ignore value surrounding calc function (1)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(1px + 1px) a"), "2px a", "Ignore value surrounding calc function (2)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("a calc(1px + 1px) b"), "a 2px b", "Ignore value surrounding calc function (3)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("a calc(1px + 1px) b calc(1em + 2em) c"), "a 2px b 3em c", "Ignore value surrounding calc function (4)"); + TKUnit.assertEqual(_evaluateCssCalcExpression(`calc(\n1px \n* 2 \n* 1.5)`), "3px", "Handle new lines"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(1/100)"), "0.01", "Handle precision correctly (1)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(5/1000000)"), "0.00001", "Handle precision correctly (2)"); + TKUnit.assertEqual(_evaluateCssCalcExpression("calc(5/100000)"), "0.00005", "Handle precision correctly (3)"); +} + +export function test_css_calc() { + const page = helper.getClearCurrentPage(); + + const stack = new stackModule.StackLayout(); + stack.css = ` + StackLayout.slim { + width: calc(100 * .1); + } + + StackLayout.wide { + width: calc(100 * 1.25); + } + + StackLayout.invalid-css-calc { + width: calc(asd3 * 1.25); + } + `; + + const label = new labelModule.Label(); + page.content = stack; + stack.addChild(label); + + stack.className = "slim"; + TKUnit.assertEqual(stack.width as any, 10, "Stack - width === 10"); + + stack.className = "wide"; + TKUnit.assertEqual(stack.width as any, 125, "Stack - width === 125"); + + (stack as any).style = `width: calc(100% / 2)`; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 0.5 }, "Stack - width === 50%"); + + // This should log an error for the invalid css-calc expression, but not cause a crash + stack.className = "invalid-css-calc"; +} + +export function test_css_calc_units() { + const page = helper.getClearCurrentPage(); + + const stack = new stackModule.StackLayout(); + stack.css = ` + StackLayout.no_unit { + width: calc(100 * .1); + } + + StackLayout.dip_unit { + width: calc(100dip * .1); + } + + StackLayout.pct_unit { + width: calc(100% * .1); + } + + StackLayout.px_unit { + width: calc(100px * .1); + } + `; + + const label = new labelModule.Label(); + page.content = stack; + stack.addChild(label); + + stack.className = "no_unit"; + TKUnit.assertEqual(stack.width as any, 10, "Stack - width === 10"); + + stack.className = "dip_unit"; + TKUnit.assertEqual(stack.width as any, 10, "Stack - width === 10dip"); + + stack.className = "pct_unit"; + TKUnit.assertDeepEqual(stack.width as any, { unit: "%", value: 0.1 }, "Stack - width === 10%"); + + stack.className = "px_unit"; + TKUnit.assertDeepEqual(stack.width as any, { unit: "px", value: 10 }, "Stack - width === 10px"); +} + +export function test_nested_css_calc() { + const page = helper.getClearCurrentPage(); + + const stack = new stackModule.StackLayout(); + stack.css = ` + StackLayout.slim { + width: calc(calc(10 * 10) * .1); + } + + StackLayout.wide { + width: calc(calc(10 * 10) * 1.25); + } + `; + + const label = new labelModule.Label(); + page.content = stack; + stack.addChild(label); + + stack.className = "slim"; + TKUnit.assertEqual(stack.width as any, 10, "Stack - width === 10"); + + stack.className = "wide"; + TKUnit.assertEqual(stack.width as any, 125, "Stack - width === 125"); + + (stack as any).style = `width: calc(100% * calc(1 / 2)`; + + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 0.5 }, "Stack - width === 50%"); +} + +export function test_css_variables() { + const blackColor = "#000000"; + const redColor = "#FF0000"; + const greenColor = "#00FF00"; + const blueColor = "#0000FF"; + + const page = helper.getClearCurrentPage(); + + const cssVarName = `--my-background-color-${Date.now()}`; + + const stack = new stackModule.StackLayout(); + stack.css = ` + StackLayout[use-css-vars] { + background-color: var(${cssVarName}); + } + + StackLayout.make-red { + ${cssVarName}: red; + } + + StackLayout.make-blue { + ${cssVarName}: blue; + } + + Label.lab1 { + background-color: var(${cssVarName}); + color: black; + }`; + + const label = new labelModule.Label(); + page.content = stack; + stack.addChild(label); + + // This should log an error about not finding the css-variable but not cause a crash + stack["use-css-vars"] = true; + label.className = "lab1"; + + stack.className = "make-red"; + TKUnit.assertEqual(label.color.hex, blackColor, "text color is black"); + TKUnit.assertEqual((stack.backgroundColor).hex, redColor, "Stack - background-color is red"); + TKUnit.assertEqual((label.backgroundColor).hex, redColor, "Label - background-color is red"); + + stack.className = "make-blue"; + TKUnit.assertEqual(label.color.hex, blackColor, "text color is black"); + TKUnit.assertEqual((stack.backgroundColor).hex, blueColor, "Stack - background-color is blue"); + TKUnit.assertEqual((label.backgroundColor).hex, blueColor, "Label - background-color is blue"); + + stack.className = "make-red"; + TKUnit.assertEqual(label.color.hex, blackColor, "text color is black"); + TKUnit.assertEqual((stack.backgroundColor).hex, redColor, "Stack - background-color is red"); + TKUnit.assertEqual((label.backgroundColor).hex, redColor, "Label - background-color is red"); + + // view.style takes priority over css-classes. + (stack as any).style = `${cssVarName}: ${greenColor}`; + stack.className = ""; + TKUnit.assertEqual(label.color.hex, blackColor, "text color is black"); + TKUnit.assertEqual((stack.backgroundColor).hex, greenColor, "Stack - background-color is green"); + TKUnit.assertEqual((label.backgroundColor).hex, greenColor, "Label - background-color is green"); + + stack.className = "make-red"; + TKUnit.assertEqual(label.color.hex, blackColor, "text color is black"); + TKUnit.assertEqual((stack.backgroundColor).hex, greenColor, "Stack - background-color is green"); + TKUnit.assertEqual((label.backgroundColor).hex, greenColor, "Label - background-color is green"); + + (stack as any).style = ""; + TKUnit.assertEqual(label.color.hex, blackColor, "text color is black"); + TKUnit.assertEqual((stack.backgroundColor).hex, redColor, "Stack - background-color is red"); + TKUnit.assertEqual((label.backgroundColor).hex, redColor, "Label - background-color is red"); +} + +export function test_css_calc_and_variables() { + const page = helper.getClearCurrentPage(); + + const cssVarName = `--my-width-factor-${Date.now()}`; + + const stack = new stackModule.StackLayout(); + stack.css = ` + StackLayout[use-css-vars] { + ${cssVarName}: 1; + width: calc(100% * var(${cssVarName})); + } + + StackLayout.slim { + ${cssVarName}: 0.1; + } + + StackLayout.wide { + ${cssVarName}: 1.25; + } + `; + + const label = new labelModule.Label(); + page.content = stack; + stack["use-css-vars"] = true; + stack.addChild(label); + + stack.className = ""; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 1 }, "Stack - width === 100%"); + + stack.className = "slim"; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 0.1 }, "Stack - width === 10%"); + + stack.className = "wide"; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 1.25 }, "Stack - width === 125%"); + + // Test setting the CSS variable via the style-attribute, this should override any value set via css-class + (stack as any).style = `${cssVarName}: 0.5`; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 0.5 }, "Stack - width === 50%"); +} + +export function test_css_variable_fallback() { + const redColor = "#FF0000"; + const blueColor = "#0000FF"; + const limeColor = new color.Color("lime").hex; + const yellowColor = new color.Color("yellow").hex; + + const classToValue = [ + { + className: "defined-css-variable", + expectedColor: blueColor, + }, { + className: "defined-css-variable-with-fallback", + expectedColor: blueColor, + }, { + className: "undefined-css-variable-without-fallback", + expectedColor: undefined, + }, { + className: "undefined-css-variable-with-fallback", + expectedColor: redColor, + }, { + className: "undefined-css-variable-with-defined-fallback", + expectedColor: limeColor, + }, { + className: "undefined-css-variable-with-multiple-fallbacks", + expectedColor: limeColor, + }, { + className: "undefined-css-variable-with-missing-fallback-value", + expectedColor: undefined, + }, { + className: "undefined-css-variable-with-nested-fallback", + expectedColor: yellowColor, + }, + ]; + + const page = helper.getClearCurrentPage(); + + const stack = new stackModule.StackLayout(); + stack.css = ` + .defined-css-variable { + --my-var: blue; + color: var(--my-var); /* resolved as color: blue; */ + } + + .defined-css-variable-with-fallback { + --my-var: blue; + color: var(--my-var, red); /* resolved as color: blue; */ + } + + .undefined-css-variable-without-fallback { + color: var(--undefined-var); /* resolved as color: unset; */ + } + + .undefined-css-variable-with-fallback { + color: var(--undefined-var, red); /* resolved as color: red; */ + } + + .undefined-css-variable-with-defined-fallback { + --my-fallback-var: lime; + color: var(--undefined-var, var(--my-fallback-var)); /* resolved as color: lime; */ + } + + .undefined-css-variable-with-multiple-fallbacks { + --my-fallback-var: lime; + color: var(--undefined-var, var(--my-fallback-var), yellow); /* resolved as color: lime; */ + } + + .undefined-css-variable-with-missing-fallback-value { + color: var(--undefined-var, var(--undefined-fallback-var)); /* resolved as color: unset; */ + } + + .undefined-css-variable-with-nested-fallback { + color: var(--undefined-var, var(--undefined-fallback-var, yellow)); /* resolved as color: yellow; */ + } + `; + + const label = new labelModule.Label(); + page.content = stack; + stack.addChild(label); + + for (const { className, expectedColor } of classToValue) { + label.className = className; + TKUnit.assertEqual(label.color && label.color.hex, expectedColor, className); + } +} + +export function test_nested_css_calc_and_variables() { + const page = helper.getClearCurrentPage(); + + const cssVarName = `--my-width-factor-base-${Date.now()}`; + const cssVarName2 = `--my-width-factor-${Date.now()}`; + + const stack = new stackModule.StackLayout(); + stack.css = ` + StackLayout[use-css-vars] { + ${cssVarName}: 0.5; + ${cssVarName2}: var(${cssVarName}); + width: calc(100% * calc(var(${cssVarName2}) * 2)); + } + + StackLayout.slim { + ${cssVarName}: 0.05; + } + + StackLayout.wide { + ${cssVarName}: 0.625 + } + + StackLayout.nested { + ${cssVarName2}: calc(var(${cssVarName}) * 2); + } + `; + + const label = new labelModule.Label(); + page.content = stack; + stack["use-css-vars"] = true; + stack.addChild(label); + + stack.className = ""; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 1 }, "Stack - width === 100%"); + + stack.className = "nested"; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 2 }, "Stack - width === 200%"); + + stack.className = "slim"; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 0.1 }, "Stack - width === 10%"); + + stack.className = "slim nested"; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 0.2 }, "Stack - width === 20%"); + + stack.className = "wide"; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 1.25 }, "Stack - width === 125%"); + + stack.className = "wide nested"; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 2.5 }, "Stack - width === 250%"); + + // Test setting the CSS variable via the style-attribute, this should override any value set via css-class + stack.className = "wide"; + (stack as any).style = `${cssVarName}: 0.25`; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 0.5 }, "Stack - width === 50%"); + + stack.className = "nested"; + TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 1 }, "Stack - width === 100%"); +} + +export function test_css_variable_is_applied_to_normal_properties() { + const stack = new stackModule.StackLayout(); + + const cssVarName = `--my-custom-variable-${Date.now()}`; + + helper.buildUIAndRunTest(stack, function (views: Array) { + const page = views[1]; + const expected = "horizontal"; + page.css = `StackLayout { + ${cssVarName}: ${expected}; + orientation: var(${cssVarName}); + }`; + TKUnit.assertEqual(stack.orientation, expected); + }); +} + +export function test_css_variable_is_applied_to_special_properties() { + const stack = new stackModule.StackLayout(); + + const cssVarName = `--my-custom-variable-${Date.now()}`; + + helper.buildUIAndRunTest(stack, function (views: Array) { + const page = views[1]; + const expected = "test"; + page.css = `StackLayout { + ${cssVarName}: ${expected}; + class: var(${cssVarName}); + }`; + TKUnit.assertEqual(stack.className, expected); + }); +} + export function test_resolveFileNameFromUrl_local_file_tilda() { const localFileExistsMock = (fileName: string) => true; const url = "~/theme/core.css"; diff --git a/tns-core-modules/package.json b/tns-core-modules/package.json index 91325f896..e65bcbf73 100644 --- a/tns-core-modules/package.json +++ b/tns-core-modules/package.json @@ -26,6 +26,7 @@ "license": "Apache-2.0", "typings": "tns-core-modules.d.ts", "dependencies": { + "reduce-css-calc": "^2.1.6", "tns-core-modules-widgets": "next", "tslib": "^1.9.3" }, diff --git a/tns-core-modules/ui/core/properties/properties.d.ts b/tns-core-modules/ui/core/properties/properties.d.ts index b30d81643..e3eb425b8 100644 --- a/tns-core-modules/ui/core/properties/properties.d.ts +++ b/tns-core-modules/ui/core/properties/properties.d.ts @@ -144,6 +144,10 @@ export function makeParser(isValid: (value: any) => boolean): (value: any) => export function getSetProperties(view: ViewBase): [string, any][]; export function getComputedCssValues(view: ViewBase): [string, any][]; +export function isCssVariable(property: string): boolean; +export function isCssCalcExpression(value: string): boolean; +export function isCssVariableExpression(value: string): boolean; + //@private /** * @private get all properties defined on ViewBase @@ -154,4 +158,8 @@ export function _getProperties(): Property[]; * @private get all properties defined on Style */ export function _getStyleProperties(): CssProperty[]; + +export function _evaluateCssVariableExpression(view: ViewBase, cssName: string, value: string): string; + +export function _evaluateCssCalcExpression(value: string): string; //@endprivate diff --git a/tns-core-modules/ui/core/properties/properties.ts b/tns-core-modules/ui/core/properties/properties.ts index b84fbe803..b79a611fb 100644 --- a/tns-core-modules/ui/core/properties/properties.ts +++ b/tns-core-modules/ui/core/properties/properties.ts @@ -1,3 +1,5 @@ +import reduceCSSCalc from "reduce-css-calc"; + // Definitions. import * as definitions from "../view-base"; import { ViewBase } from "../view-base"; @@ -56,6 +58,76 @@ export function _getStyleProperties(): CssProperty[] { return getPropertiesFromMap(cssSymbolPropertyMap) as CssProperty[]; } +const cssVariableExpressionRegexp = /\bvar\(\s*(--[^,\s]+?)(?:\s*,\s*(.+))?\s*\)/; +const cssVariableAllExpressionsRegexp = /\bvar\(\s*(--[^,\s]+?)(?:\s*,\s*(.+))?\s*\)/g; + +export function isCssVariable(property: string) { + return /^--[^,\s]+?$/.test(property); +} + +export function isCssCalcExpression(value: string) { + return /\bcalc\(/.test(value); +} + +export function isCssVariableExpression(value: string) { + return cssVariableExpressionRegexp.test(value); +} + +export function _evaluateCssVariableExpression(view: ViewBase, cssName: string, value: string): string { + if (typeof value !== "string") { + return value; + } + + if (!isCssVariableExpression(value)) { + // Value is not using css-variable(s) + return value; + } + + let output = value.trim(); + + // Evaluate every (and nested) css-variables in the value. + let lastValue: string; + while (lastValue !== output) { + lastValue = output; + + output = output.replace(cssVariableAllExpressionsRegexp, (matchStr, cssVariableName: string, fallbackStr: string) => { + const cssVariableValue = view.style.getCssVariable(cssVariableName); + if (cssVariableValue !== null) { + return cssVariableValue; + } + + if (fallbackStr) { + // css-variable not found, using fallback-string. + const fallbackOutput = _evaluateCssVariableExpression(view, cssName, fallbackStr); + if (fallbackOutput) { + // If the fallback have multiple values, return the first of them. + return fallbackOutput.split(",")[0]; + } + } + + // Couldn't find a value for the css-variable or the fallback, return "unset" + traceWrite(`Failed to get value for css-variable "${cssVariableName}" used in "${cssName}"=[${value}] to ${view}`, traceCategories.Style, traceMessageType.error); + + return "unset"; + }); + } + + return output; +} + +export function _evaluateCssCalcExpression(value: string) { + if (typeof value !== "string") { + return value; + } + + if (isCssCalcExpression(value)) { + // WORKAROUND: reduce-css-calc can't handle the dip-unit. + return reduceCSSCalc(value.replace(/([0-9]+(\.[0-9]+)?)dip\b/g, "$1")); + } else { + return value; + } +} + function getPropertiesFromMap(map): Property[] | CssProperty[] { const props = []; Object.getOwnPropertySymbols(map).forEach(symbol => props.push(map[symbol])); diff --git a/tns-core-modules/ui/styling/style-scope.ts b/tns-core-modules/ui/styling/style-scope.ts index 334c53e8d..084eedfa6 100644 --- a/tns-core-modules/ui/styling/style-scope.ts +++ b/tns-core-modules/ui/styling/style-scope.ts @@ -1,7 +1,7 @@ import { Keyframes } from "../animation/keyframe-animation"; import { ViewBase } from "../core/view-base"; import { View } from "../core/view"; -import { unsetValue } from "../core/properties"; +import { unsetValue, _evaluateCssVariableExpression, _evaluateCssCalcExpression, isCssVariable, isCssVariableExpression, isCssCalcExpression } from "../core/properties"; import { SyntaxTree, Keyframes as KeyframesDefinition, @@ -42,6 +42,7 @@ function ensureKeyframeAnimationModule() { import * as capm from "./css-animation-parser"; import { sanitizeModuleName } from "../builder/module-name-sanitizer"; import { resolveModuleName } from "../../module-name-resolver"; + let cssAnimationParserModule: typeof capm; function ensureCssAnimationParserModule() { if (!cssAnimationParserModule) { @@ -59,6 +60,28 @@ try { // } +/** + * Evaluate css-variable and css-calc expressions + */ +function evaluateCssExpressions(view: ViewBase, property: string, value: string) { + const newValue = _evaluateCssVariableExpression(view, property, value); + if (newValue === "unset") { + return unsetValue; + } + + value = newValue; + + try { + value = _evaluateCssCalcExpression(value); + } catch (e) { + traceWrite(`Failed to evaluate css-calc for property [${property}] for expression [${value}] to ${view}. ${e.stack}`, traceCategories.Error, traceMessageType.error); + + return unsetValue; + } + + return value; +} + export function mergeCssSelectors(): void { applicationCssSelectors = applicationSelectors.slice(); applicationCssSelectors.push.apply(applicationCssSelectors, applicationAdditionalSelectors); @@ -522,22 +545,60 @@ export class CssState { matchingSelectors.forEach(selector => selector.ruleset.declarations.forEach(declaration => newPropertyValues[declaration.property] = declaration.value)); - Object.freeze(newPropertyValues); const oldProperties = this._appliedPropertyValues; - for (const key in oldProperties) { - if (!(key in newPropertyValues)) { - if (key in view.style) { - view.style[`css:${key}`] = unsetValue; + + let isCssExpressionInUse = false; + + // Update values for the scope's css-variables + view.style.resetScopedCssVariables(); + + for (const property in newPropertyValues) { + const value = newPropertyValues[property]; + if (isCssVariable(property)) { + view.style.setScopedCssVariable(property, value); + + delete newPropertyValues[property]; + continue; + } + + isCssExpressionInUse = isCssExpressionInUse || isCssVariableExpression(value) || isCssCalcExpression(value); + } + + if (isCssExpressionInUse) { + // Evalute css-expressions to get the latest values. + for (const property in newPropertyValues) { + const value = evaluateCssExpressions(view, property, newPropertyValues[property]); + if (value === unsetValue) { + delete newPropertyValues[property]; + continue; + } + + newPropertyValues[property] = value; + } + } + + // Property values are fully updated, freeze the object to be used for next update. + Object.freeze(newPropertyValues); + + // Unset removed values + for (const property in oldProperties) { + if (!(property in newPropertyValues)) { + if (property in view.style) { + view.style[`css:${property}`] = unsetValue; } else { // TRICKY: How do we unset local value? } } } + + // Set new values to the style for (const property in newPropertyValues) { if (oldProperties && property in oldProperties && oldProperties[property] === newPropertyValues[property]) { + // Skip unchanged values continue; } + const value = newPropertyValues[property]; try { if (property in view.style) { @@ -547,7 +608,7 @@ export class CssState { view[camelCasedProperty] = value; } } catch (e) { - traceWrite(`Failed to apply property [${property}] with value [${value}] to ${view}. ${e}`, traceCategories.Error, traceMessageType.error); + traceWrite(`Failed to apply property [${property}] with value [${value}] to ${view}. ${e.stack}`, traceCategories.Error, traceMessageType.error); } } @@ -819,21 +880,41 @@ function resolveFilePathFromImport(importSource: string, fileName: string): stri export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase, styleStr: string) { let localStyle = `local { ${styleStr} }`; let inlineRuleSet = CSSSource.fromSource(localStyle, new Map()).selectors; - const style = view.style; + + // Reset unscoped css-variables + view.style.resetUnscopedCssVariables(); + + // Set all the css-variables first, so we can be sure they are up-to-date + inlineRuleSet[0].declarations.forEach(d => { + // Use the actual property name so that a local value is set. + let property = d.property; + if (isCssVariable(property)) { + view.style.setUnscopedCssVariable(property, d.value); + } + }); inlineRuleSet[0].declarations.forEach(d => { // Use the actual property name so that a local value is set. - let name = d.property; + let property = d.property; try { - if (name in style) { - style[name] = d.value; + if (isCssVariable(property)) { + // Skip css-variables, they have been handled + return; + } + + const value = evaluateCssExpressions(view, property, d.value); + if (property in view.style) { + view.style[property] = value; } else { - view[name] = d.value; + view[property] = value; } } catch (e) { traceWrite(`Failed to apply property [${d.property}] with value [${d.value}] to ${view}. ${e}`, traceCategories.Error, traceMessageType.error); } }); + + // This is needed in case of changes to css-variable or css-calc expressions. + view._onCssStateChange(); }); function isCurrentDirectory(uriPart: string): boolean { diff --git a/tns-core-modules/ui/styling/style/style.d.ts b/tns-core-modules/ui/styling/style/style.d.ts index 03e002bef..b8b204d4a 100644 --- a/tns-core-modules/ui/styling/style/style.d.ts +++ b/tns-core-modules/ui/styling/style/style.d.ts @@ -168,6 +168,32 @@ export class Style extends Observable { * The shorthand properties are defined as non-enumerable so it should be safe to for-in the keys that are set in the bag. */ public readonly PropertyBag: PropertyBagClass; + + /** + * Set a scoped css-value. These are css-variables set from CssState + */ + public setScopedCssVariable(varName: string, value: string): void; + + /** + * Set a unscoped css-value. These are css-variables set on view.style + */ + public setUnscopedCssVariable(varName: string, value: string): void; + + /** + * Get value of the css-variable. + * If the value is not set on this style-object, try the parent view. + */ + public getCssVariable(varName: string): string | null; + + /** + * Remove all scoped css-variables + */ + public resetScopedCssVariables(): void; + + /** + * Remove all unscoped css-variables + */ + public resetUnscopedCssVariables(): void; } interface PropertyBagClass { diff --git a/tns-core-modules/ui/styling/style/style.ts b/tns-core-modules/ui/styling/style/style.ts index 4842e4f7a..6801b3db1 100644 --- a/tns-core-modules/ui/styling/style/style.ts +++ b/tns-core-modules/ui/styling/style/style.ts @@ -18,6 +18,9 @@ import { import { TextAlignment, TextDecoration, TextTransform, WhiteSpace } from "../../text-base"; export class Style extends Observable implements StyleDefinition { + private unscopedCssVariables = new Map(); + private scopedCssVariables = new Map(); + constructor(ownerView: ViewBase | WeakRef) { super(); @@ -29,6 +32,43 @@ export class Style extends Observable implements StyleDefinition { } } + public setScopedCssVariable(varName: string, value: string): void { + this.scopedCssVariables.set(varName, value); + } + + public setUnscopedCssVariable(varName: string, value: string): void { + this.unscopedCssVariables.set(varName, value); + } + + public getCssVariable(varName: string): string | null { + const view = this.view; + if (!view) { + return null; + } + + if (this.unscopedCssVariables.has(varName)) { + return this.unscopedCssVariables.get(varName); + } + + if (this.scopedCssVariables.has(varName)) { + return this.scopedCssVariables.get(varName); + } + + if (!view.parent || !view.parent.style) { + return null; + } + + return view.parent.style.getCssVariable(varName); + } + + public resetScopedCssVariables() { + this.scopedCssVariables.clear(); + } + + public resetUnscopedCssVariables() { + this.unscopedCssVariables.clear(); + } + toString() { const view = this.viewRef.get(); if (!view) {