Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 84 additions & 3 deletions apps/automated/src/ui/styling/style-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,87 @@ export function test_css_variables() {
TKUnit.assertEqual((<Color>label.backgroundColor).hex, redColor, 'Label - background-color is red');
}

export function test_undefined_css_variable_invalidates_entire_expression() {
const page = helper.getClearCurrentPage();

const cssVarName = `--my-background-color-${Date.now()}`;

const stack = new StackLayout();
stack.css = `
Label.lab1 {
box-shadow: 10 10 5 12 var(${cssVarName});
color: black;
}`;

const label = new Label();
page.content = stack;
stack.addChild(label);

label.className = 'lab1';

TKUnit.assertEqual(label.style.boxShadow, undefined, 'the css variable is undefined');
}

export function test_css_variable_with_another_css_variable_as_value() {
const page = helper.getClearCurrentPage();
const redColor = '#FF0000';
const cssVarName = `--my-background-color-${Date.now()}`;
const cssShadowVarName = `--my-shadow-color-${Date.now()}`;

const stack = new StackLayout();
stack.css = `
StackLayout {
${cssVarName}: ${redColor};
}

Label {
${cssShadowVarName}: var(${cssVarName});
}

Label.lab1 {
box-shadow: 10 10 5 12 var(${cssShadowVarName});
color: black;
}`;

const label = new Label();
page.content = stack;
stack.addChild(label);

label.className = 'lab1';

TKUnit.assertEqual(label.style.boxShadow?.color?.hex, redColor, 'Failed to resolve css expression variable');
}

export function test_css_variable_that_resolves_to_another_css_variable_order_desc() {
const page = helper.getClearCurrentPage();

const cssVarName = `--my-var1-${Date.now()}`;
const cssVarName2 = `--my-var2-${Date.now()}`;
const cssVarName3 = `--my-var3-${Date.now()}`;
const greenColor = '#008000';

const stack = new StackLayout();
stack.css = `
StackLayout.var {
background-color: var(${cssVarName3});
}
StackLayout.var {
${cssVarName3}: var(${cssVarName2});
}
StackLayout.var {
${cssVarName2}: var(${cssVarName});
}
StackLayout.var {
${cssVarName}: ${greenColor};
}
`;

stack.className = 'var';
page.content = stack;

TKUnit.assertEqual(stack.style.backgroundColor?.hex, greenColor, 'Failed to resolve css variable of css variable');
}

export function test_css_calc_and_variables() {
const page = helper.getClearCurrentPage();

Expand Down Expand Up @@ -1969,6 +2050,7 @@ export function test_css_calc_and_variables() {

export function test_css_variable_fallback() {
const redColor = '#FF0000';
const greenColor = '#008000';
const blueColor = '#0000FF';
const limeColor = new Color('lime').hex;
const yellowColor = new Color('yellow').hex;
Expand Down Expand Up @@ -1996,7 +2078,7 @@ export function test_css_variable_fallback() {
},
{
className: 'undefined-css-variable-with-multiple-fallbacks',
expectedColor: limeColor,
expectedColor: greenColor,
},
{
className: 'undefined-css-variable-with-missing-fallback-value',
Expand Down Expand Up @@ -2036,8 +2118,7 @@ export function test_css_variable_fallback() {
}

.undefined-css-variable-with-multiple-fallbacks {
--my-fallback-var: lime;
color: var(--undefined-var, var(--my-fallback-var), yellow); /* resolved as color: lime; */
color: var(--undefined-var, var(--my-fallback-var), green); /* resolved as color: green; */
}

.undefined-css-variable-with-missing-fallback-value {
Expand Down
100 changes: 100 additions & 0 deletions packages/core/css/css-var-func-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { parseCssVariableExpression } from './css-var-func-parser';

describe('css-var-func-parser', () => {
it('basic css variable resolution', () => {
const cssVal = 'red';
const value = parseCssVariableExpression('var(--bg-color)', (cssVarName) => cssVal);

expect(value).toBe('red');
});

it('css expression with list of values', () => {
const cssVal = 'red';
const value = parseCssVariableExpression('25 solid var(--bg-color)', (cssVarName) => cssVal);

expect(value).toBe('25 solid red');
});

it('basic css variable resolution failure', () => {
const value = parseCssVariableExpression('var(--bg-color)', null);

expect(value).toBe('unset');
});

it('css expression with list of values failure', () => {
expect(() => parseCssVariableExpression('25 solid var(--bg-color)', null)).toThrowError('var(--bg-color)');
});

it('css variable with basic fallback', () => {
const value = parseCssVariableExpression('var(--bg-color, yellow)', null);

expect(value).toBe('yellow');
});

it('css variable with css variable fallback', () => {
const fallbackVal = 'blue';
const value = parseCssVariableExpression('var(--bg-color, var(--test))', (cssVarName) => {
if (cssVarName === '--test') {
return fallbackVal;
}
});

expect(value).toBe(fallbackVal);
});

it('css variable with multiple fallbacks', () => {
const value = parseCssVariableExpression('var(--bg-color, var(--test), var(--test2), purple)', null);

expect(value).toBe('purple');
});

it('css variable with deeply nested css variable fallback', () => {
const fallbackVal = 'black';
const value = parseCssVariableExpression('var(--bg-color, var(--test, var(--test2, var(--test3, red))))', (cssVarName) => {
if (cssVarName === '--test3') {
return fallbackVal;
}
});

expect(value).toBe(fallbackVal);
});

it('css expression with list of comma-separated values', () => {
const expression = 'var(--shadow-color) var(--shadow-offset-x) var(--shadow-offset-y), var(--shadow-inset-color) var(--shadow-inset-x) var(--shadow-inset-y)';
const value = parseCssVariableExpression(expression, (cssVarName) => {
switch (cssVarName) {
case '--shadow-color':
return 'green';
case '--shadow-offset-x':
return '5';
case '--shadow-offset-y':
return '5';
case '--shadow-inset-color':
return 'yellow';
case '--shadow-inset-x':
return '15';
case '--shadow-inset-y':
return '15';
}
});

expect(value).toBe('green 5 5, yellow 15 15');
});

it('css expression with list of comma-separated values failure', () => {
const expression = 'var(--shadow-color) var(--shadow-offset-x) var(--shadow-offset-y), var(--shadow-inset-color) var(--shadow-inset-x) var(--shadow-inset-y)';

expect(() =>
parseCssVariableExpression(expression, (cssVarName) => {
switch (cssVarName) {
case '--shadow-color':
return 'green';
case '--shadow-offset-x':
return '5';
case '--shadow-offset-y':
return '5';
}
}),
).toThrowError('var(--shadow-inset-color)');
});
});
142 changes: 142 additions & 0 deletions packages/core/css/css-var-func-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { tokenize, TokenType } from '@csstools/css-tokenizer';
import { ComponentValue, FunctionNode, isCommentNode, isFunctionNode, isTokenNode, parseCommaSeparatedListOfComponentValues, parseListOfComponentValues, stringify, walk } from '@csstools/css-parser-algorithms';

const functionName: string = 'var';

export function parseCssVariableExpression(value: string, replaceWithCallback: (cssVarName: string) => string): string {
const componentValueSet = parseCommaSeparatedListOfComponentValues(
tokenize({
css: value,
}),
);

for (const componentValues of componentValueSet) {
let unresolvedNode: ComponentValue = null;

walk(componentValues, (entry, index) => {
const componentValue = entry.node;

if (isFunctionNode(componentValue) && componentValue.getName() === functionName) {
const newNode = _parseCssVarFunctionArguments(componentValue, replaceWithCallback);
const parentNodes = entry.parent.value;

if (!newNode || !_replaceNode(index, newNode, parentNodes)) {
unresolvedNode = componentValue;
return false;
}
}
});

if (unresolvedNode) {
const isSingleNode = componentValueSet.length === 1 && componentValueSet[0].length === 1 && componentValueSet[0][0] === unresolvedNode;
if (!isSingleNode) {
throw new Error(`Failed to resolve CSS variable '${unresolvedNode}' for expression: '${value}'`);
}

return 'unset';
}
}

return stringify(componentValueSet).trim();
}

/**
* Parses css variable functions and their fallback values.
*
* @param node
* @param replaceWithCallback
* @returns
*/
function _parseCssVarFunctionArguments(node: FunctionNode, replaceWithCallback: (cssVarName: string) => string): ComponentValue | ComponentValue[] {
let cssVarName: string = null;
let finalValue: ComponentValue | ComponentValue[] = null;
let currentFallbackValues: ComponentValue[];

const fallbackValueSet: ComponentValue[][] = [];

node.forEach((entry) => {
const childNode = entry.node;

if (isCommentNode(childNode)) {
return;
}

if (isTokenNode(childNode)) {
const tokens = childNode.value;

if (tokens[0] === TokenType.Ident) {
if (tokens[1].startsWith('--')) {
if (fallbackValueSet.length) {
throw new Error('Invalid css variable function fallback value: ' + childNode);
} else {
// This is the first parsable parameter
cssVarName = tokens[1];
}
return;
}
}

// Track the fallback comma-separated values
if (tokens[0] === TokenType.Comma) {
currentFallbackValues = [];
fallbackValueSet.push(currentFallbackValues);
return;
}
}

if (currentFallbackValues) {
currentFallbackValues.push(childNode);
}
});

// Resolve the css variable here and use a fallback value if it fails
const cssVarValue = replaceWithCallback?.(cssVarName);

if (cssVarValue) {
finalValue = parseListOfComponentValues(
tokenize({
css: cssVarValue,
}),
);
} else {
// Switch to the first available fallback value if any
for (const componentValues of fallbackValueSet) {
let isValidFallback: boolean = true;

walk(componentValues, (entry, index) => {
const componentValue = entry.node;

if (isFunctionNode(componentValue) && componentValue.getName() === functionName) {
const nodeResult = _parseCssVarFunctionArguments(componentValue, replaceWithCallback);
const parentNodes = entry.parent.value;

if (!nodeResult || !_replaceNode(index, nodeResult, parentNodes)) {
isValidFallback = false;
return false; // Invalid fallback
}
}
});

if (isValidFallback) {
finalValue = componentValues;
break;
}
}
}

return finalValue;
}

function _replaceNode(index: string | number, newNode: ComponentValue | ComponentValue[], nodes: ComponentValue[]): boolean {
if (typeof index !== 'number') {
return false;
}

if (Array.isArray(newNode)) {
nodes.splice(index, 1, ...newNode);
} else {
nodes.splice(index, 1, newNode);
}

return true;
}
Loading