Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/automated/src/ui/styling/style-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1996,7 +1996,7 @@ export function test_css_variable_fallback() {
},
{
className: 'undefined-css-variable-with-multiple-fallbacks',
expectedColor: limeColor,
expectedColor: undefined,
},
{
className: 'undefined-css-variable-with-missing-fallback-value',
Expand Down Expand Up @@ -2037,7 +2037,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), yellow); /* resolved as color: unset; */
}

.undefined-css-variable-with-missing-fallback-value {
Expand Down
55 changes: 55 additions & 0 deletions packages/core/ui/core/properties/css-variable-expression.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';

import { _evaluateCssVariableExpression } from '.';

function evaluate(value: string, variables: Record<string, string> = {}) {
const view = { style: { getCssVariable: (name: string) => (name in variables ? variables[name] : null) } };
return _evaluateCssVariableExpression(view as any, 'color', value);
}

describe('_evaluateCssVariableExpression', () => {
it('substitutes defined variables and falls back for undefined ones', () => {
expect(evaluate('var(--a)', { '--a': 'blue' })).toBe('blue');
expect(evaluate('var(--a, red)', { '--a': 'blue' })).toBe('blue');
expect(evaluate('var(--missing, red)')).toBe('red');
expect(evaluate('var(--missing)')).toBe('unset');
});

it('resolves nested fallbacks', () => {
expect(evaluate('var(--missing, var(--b))', { '--b': 'lime' })).toBe('lime');
expect(evaluate('var(--missing, var(--missing-too, yellow))')).toBe('yellow');
expect(evaluate('var(--missing, var(--missing-too))')).toBe('unset');
});

it('keeps parentheses inside the fallback', () => {
expect(evaluate('var(--missing, rgb(0, 0, 255))')).toBe('rgb(0, 0, 255)');
expect(evaluate('linear-gradient(var(--missing, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%))')).toBe('linear-gradient(rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)');
});

it('keeps every comma-separated part of the fallback', () => {
expect(evaluate('var(--missing, red 0%, blue 100%)')).toBe('red 0%, blue 100%');
});

it('uses the fallback for a variable set to initial', () => {
expect(evaluate('var(--a, red)', { '--a': 'initial' })).toBe('red');
expect(evaluate('var(--a)', { '--a': 'initial' })).toBe('unset');
});

it('resolves a gradient built from variables with fallbacks', () => {
const variables = {
'--via-stops': 'initial',
'--position': 'to right in oklab',
'--from': 'rgb(98, 96, 255)',
'--from-position': '0%',
'--to': 'rgb(246, 51, 154)',
'--to-position': '100%',
};
expect(evaluate('linear-gradient(var(--via-stops, var(--position), var(--from) var(--from-position), var(--to) var(--to-position)))', variables)).toBe(
'linear-gradient(to right in oklab, rgb(98, 96, 255) 0%, rgb(246, 51, 154) 100%)',
);
});

it('leaves values without variables alone', () => {
expect(evaluate('rgb(0, 0, 0)')).toBe('rgb(0, 0, 0)');
});
});
33 changes: 24 additions & 9 deletions packages/core/ui/core/properties/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,20 +219,21 @@ export function _evaluateCssVariableExpression(view: ViewBase, cssName: string,
continue;
}

const endIdx = output.indexOf(')', idx);
const endIdx = findClosingParenthesis(output, idx + 3);
if (endIdx === -1) {
continue;
}

const matched = output
.substring(idx + 4, endIdx)
.split(',')
.map((v) => v.trim())
.filter((v) => !!v);
const cssVariableName = matched.shift();
const body = output.substring(idx + 4, endIdx);
const commaIdx = body.indexOf(',');
const cssVariableName = (commaIdx === -1 ? body : body.substring(0, commaIdx)).trim();
const fallback = commaIdx === -1 ? '' : body.substring(commaIdx + 1).trim();
let cssVariableValue = view.style.getCssVariable(cssVariableName);
if (cssVariableValue === null && matched.length) {
cssVariableValue = _evaluateCssVariableExpression(view, cssName, matched.join(', ')).split(',')[0];
if (typeof cssVariableValue === 'string' && cssVariableValue.trim() === 'initial') {
cssVariableValue = null;
}
if (cssVariableValue === null && fallback) {
cssVariableValue = _evaluateCssVariableExpression(view, cssName, fallback);
}

if (!cssVariableValue) {
Expand All @@ -245,6 +246,20 @@ export function _evaluateCssVariableExpression(view: ViewBase, cssName: string,
return output;
}

function findClosingParenthesis(value: string, openIdx: number): number {
let depth = 0;
for (let i = openIdx; i < value.length; i++) {
const ch = value[i];
if (ch === '(') {
depth++;
} else if (ch === ')' && --depth === 0) {
return i;
}
}

return -1;
}

export function _evaluateCssCalcExpression(value: string) {
if (typeof value !== 'string') {
return value;
Expand Down
73 changes: 73 additions & 0 deletions packages/core/ui/styling/style-scope.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ function styled(css: string, className?: string): { view: any; scope: StyleScope
return { view, scope };
}

function styledWithParent(css: string, parentClassName: string): { view: any; parent: any; scope: StyleScope } {
const { view, scope } = styled(css);
const parent = view.parent;
parent.className = parentClassName;

return { view, parent, scope };
}

describe('CssState.setPropertyValues', () => {
it('applies matched declarations', () => {
const { view } = styled('label { color: red; }');
Expand Down Expand Up @@ -112,6 +120,71 @@ describe('CssState.setPropertyValues', () => {
expect(view.style.color.toString()).toBe('#FF0000');
});

it('resolves custom properties that refer to ones declared after them', () => {
const { view } = styled('label { --a: var(--b, blue); --b: var(--c); --c: red; color: var(--a); }');
view._cssState.onLoaded();

expect(view.style.color.toString()).toBe('#FF0000');
});

it('resolves a chain of custom properties that each refer to a later one', () => {
const { view } = styled('label { --a: var(--b); --b: var(--c); --c: var(--d); --d: red; color: var(--a); }');
view._cssState.onLoaded();

expect(view.style.color.toString()).toBe('#FF0000');
expect(view.style.getCssVariable('--a')).toBe('red');
});

it('treats custom properties in a cycle as undefined, so var() falls back', () => {
const { view } = styled('label { --a: var(--b); --b: var(--a); color: var(--a, blue); }');
view._cssState.onLoaded();

expect(view.style.color.toString()).toBe('#0000FF');
expect(view.style.getCssVariable('--a')).toBeNull();
});

it('does not inherit a custom property this view declared with an unresolvable value', () => {
const { view, parent } = styledWithParent('.p { --a: green; } label { --a: var(--missing); color: var(--a, blue); }', 'p');
parent._cssState.onLoaded();
view._cssState.onLoaded();

expect(view.style.color.toString()).toBe('#0000FF');
});

it('evaluates properties after the custom properties they use, whatever the order', () => {
const { view } = styled('.a { color: var(--brand); } .b { --brand: var(--base); --base: red; }', 'a b');
view._cssState.onLoaded();

expect(view.style.color.toString()).toBe('#FF0000');
});

it('prefers a later-defined variable over the fallback that referenced it', () => {
const css = `
.l { --via-stops: initial; }
.from { --from: red; --stops: var(--via-stops, var(--from)); }
.via { --via: lime; --via-stops: var(--from), var(--via); --stops: var(--via-stops); }
.to { --stops: var(--via-stops, var(--from)); color: var(--stops); }
`;
const { view } = styled(css, 'l from via to');
view._cssState.onLoaded();

expect(view.style.getCssVariable('--stops')).toBe('red, lime');
});

it('unsets a property whose new value is invalid instead of keeping the previous one', () => {
const css = `
.valid { --fallback: lime; color: var(--missing, var(--fallback)); }
.invalid { --fallback: lime; color: var(--missing, var(--fallback), yellow); }
`;
const { view } = styled(css, 'valid');
view._cssState.onLoaded();
expect(view.style.color.toString()).toBe('#00FF00');

view.className = 'invalid';
view._cssState.onLoaded();
expect(view.style.color).toBeUndefined();
});

it('does not re-apply an unchanged css expression', () => {
const { view } = styled('label { --brand: red; color: var(--brand); }');
view._cssState.onLoaded();
Expand Down
78 changes: 73 additions & 5 deletions packages/core/ui/styling/style-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,61 @@ function evaluateCssExpressions(view: ViewBase, property: string, value: string)
return value;
}

function evaluateCssVariable(view: ViewBase, name: string, value: string): string | null {
const evaluated = evaluateCssExpressions(view, name, value);

return evaluated === unsetValue ? null : evaluated;
}

function settleCssVariables(view: ViewBase, cssExpsProperties: Record<string, string>) {
let names: string[];
for (const property in cssExpsProperties) {
if (isCssVariable(property)) {
if (!names) {
names = [];
}
names.push(property);
view.style.setScopedCssVariable(property, evaluateCssVariable(view, property, cssExpsProperties[property]));
}
}
if (!names || names.length < 2 || !hasForwardCssVariableReference(names, cssExpsProperties)) {
return;
}

for (let pass = 1; pass < names.length; pass++) {
let changed = false;
for (const name of names) {
const value = evaluateCssVariable(view, name, cssExpsProperties[name]);
if (value !== view.style.getCssVariable(name)) {
view.style.setScopedCssVariable(name, value);
changed = true;
}
}
if (!changed) {
return;
}
}
}

function hasForwardCssVariableReference(names: string[], values: Record<string, string>): boolean {
for (let i = 0; i < names.length; i++) {
const value = values[names[i]];
for (let j = i + 1; j < names.length; j++) {
const ref = `var(${names[j]}`;
let at = value.indexOf(ref);
while (at !== -1) {
const next = value[at + ref.length];
if (next === ')' || next === ',' || next === ' ' || next === undefined) {
return true;
}
at = value.indexOf(ref, at + 1);
}
}
}

return false;
}

/**
* Only marks the merged list dirty - it is rebuilt on next read, since frameworks
* register stylesheets one call at a time.
Expand Down Expand Up @@ -926,21 +981,23 @@ export class CssState {
valuesToApply[property] = value;
}
//we need to parse CSS vars first before evaluating css expressions
if (cssExpsProperties) {
settleCssVariables(view, cssExpsProperties);
}
for (const property in cssExpsProperties) {
const hadOldValue = property in oldProperties;
const oldValue = hadOldValue ? oldProperties[property] : undefined;
if (hadOldValue) {
delete oldProperties[property];
}

const value = evaluateCssExpressions(view, property, cssExpsProperties[property]);

if (isCssVariable(property)) {
view.style.setScopedCssVariable(property, value);
delete newPropertyValues[property];
continue;
}

const value = evaluateCssExpressions(view, property, cssExpsProperties[property]);

if (value === unsetValue) {
delete newPropertyValues[property];
} else {
Expand Down Expand Up @@ -1009,15 +1066,26 @@ export class CssState {
// Set new values to the style
for (const property in valuesToApply) {
const value = valuesToApply[property];
const inStyle = property in view.style;
const camelCasedProperty = inStyle ? undefined : property.replace(kebabCasePattern, kebabCaseReplacementFunc);
try {
if (property in view.style) {
if (inStyle) {
view.style[`css:${property}`] = value;
} else {
const camelCasedProperty = property.replace(kebabCasePattern, kebabCaseReplacementFunc);
view[camelCasedProperty] = value;
}
} catch (e) {
Trace.write(`Failed to apply property [${property}] with value [${value}] to ${view}. ${e.stack}`, Trace.categories.Error, Trace.messageType.error);
delete newPropertyValues[property];
try {
if (inStyle) {
view.style[`css:${property}`] = unsetValue;
} else {
view[camelCasedProperty] = unsetValue;
}
} catch (e) {
Trace.write(`Failed to unset property [${property}] on ${view}. ${e.stack}`, Trace.categories.Error, Trace.messageType.error);
}
}
}

Expand Down
Loading