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
9 changes: 7 additions & 2 deletions packages/core/css/reworkcss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface Position {
}

export interface Node {
type: 'rule' | 'keyframes' | 'declaration' | 'import' | 'media';
type: 'rule' | 'keyframes' | 'declaration' | 'import' | 'media' | 'supports';
position: Position;
}

Expand All @@ -23,7 +23,7 @@ export interface Rule extends Node {
}

// @ts-ignore
export type AtRule = KeyFrames | Media;
export type AtRule = KeyFrames | Media | Supports;

export interface Keyframes extends Rule {
name: string;
Expand All @@ -41,6 +41,11 @@ export interface Media extends Node {
rules: Array<Rule | AtRule>;
}

export interface Supports extends Node {
supports: string;
rules: Array<Rule | AtRule>;
}

export interface StyleSheet {
rules: Rule[];
}
Expand Down
49 changes: 49 additions & 0 deletions packages/core/ui/core/properties/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export { unsetValue } from './property-shared';
const cssPropertyNames: string[] = [];
const cssShorthandConverters = new Map<string, (value: string) => [any, any][]>();
const cssShorthandLonghands = new Map<string, string[]>();
/** Value converter of every `CssProperty` by css name; `undefined` when the value is taken as-is. */
const cssValueConverters = new Map<string, ((value: string) => any) | undefined>();
const HAS_OWN = Object.prototype.hasOwnProperty;
const symbolPropertyMap = {};
const cssSymbolPropertyMap = {};
Expand Down Expand Up @@ -195,6 +197,44 @@ export function _expandCssShorthand(cssName: string, value: string): [string, an
}
}

/**
* Whether a `@supports` declaration test passes: core knows the css property and
* its converter accepts the value. `undefined` when the property is unknown.
*/
export function _isCssDeclarationSupported(cssName: string, value: string): boolean | undefined {
if (isCssVariable(cssName)) {
return true;
}

let converter: ((value: string) => any) | undefined;
if (cssValueConverters.has(cssName)) {
converter = cssValueConverters.get(cssName);
} else if (cssShorthandConverters.has(cssName)) {
converter = cssShorthandConverters.get(cssName);
} else {
const animationProperty = CssAnimationProperty._getByCssName(cssName);
if (!animationProperty) {
return undefined;
}

converter = animationProperty._valueConverter;
}

// var() and calc() only resolve per view, so they cannot be rejected up front.
if (!converter || isCssWideKeyword(value) || isCssVariableExpression(value) || isCssCalcExpression(value)) {
return true;
}

try {
const converted = converter(value);

// Some numeric converters are a bare parseFloat.
return !(typeof converted === 'number' && isNaN(converted));
} catch {
return false;
}
}

export function isCssVariable(property: string) {
return CSS_VARIABLE_NAME_RE.test(property);
}
Expand Down Expand Up @@ -761,6 +801,9 @@ export class CssProperty<T extends Style, U> {
let valueChanged = options.valueChanged;
let valueConverter = options.valueConverter;
let overrideConverter = false;
if (options.cssName) {
cssValueConverters.set(options.cssName, valueConverter);
}

this.overrideHandlers = function (options: CssPropertyOptions<T, U>) {
if (typeof options.equalityComparer !== 'undefined') {
Expand All @@ -775,6 +818,9 @@ export class CssProperty<T extends Style, U> {
if (typeof options.valueConverter !== 'undefined') {
valueConverter = options.valueConverter;
overrideConverter = true;
if (property.cssLocalName) {
cssValueConverters.set(property.cssLocalName, valueConverter);
}
}
};

Expand Down Expand Up @@ -1255,6 +1301,9 @@ export class InheritedCssProperty<T extends Style, U> extends CssProperty<T, U>
if (typeof options.valueConverter !== 'undefined') {
valueConverter = options.valueConverter;
overrideConverter = true;
if (property.cssLocalName) {
cssValueConverters.set(property.cssLocalName, valueConverter);
}
}
};

Expand Down
82 changes: 82 additions & 0 deletions packages/core/ui/styling/css-supports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect, beforeAll } from 'vitest';

import { evaluateSupportsCondition } from './css-supports';

describe('evaluateSupportsCondition', () => {
beforeAll(async () => {
// Registers the style properties the declaration tests look up.
await import('../label');
});

it('passes a declaration core parses', () => {
expect(evaluateSupportsCondition('(color: red)')).toBe(true);
expect(evaluateSupportsCondition('(color: color-mix(in srgb, red 50%, blue))')).toBe(true);
expect(evaluateSupportsCondition('(text-transform: uppercase)')).toBe(true);
expect(evaluateSupportsCondition('(margin: 10 20)')).toBe(true);
});

it('fails a declaration whose value core does not parse', () => {
expect(evaluateSupportsCondition('(color: not-a-color)')).toBe(false);
expect(evaluateSupportsCondition('(text-transform: full-width)')).toBe(false);
});

it('fails a declaration of a property core does not know', () => {
expect(evaluateSupportsCondition('(display: grid)')).toBe(false);
});

it('passes values that only resolve per view', () => {
expect(evaluateSupportsCondition('(color: var(--accent))')).toBe(true);
expect(evaluateSupportsCondition('(width: calc(100% - 10))')).toBe(true);
expect(evaluateSupportsCondition('(--accent: red)')).toBe(true);
});

it('matches property names case-insensitively', () => {
expect(evaluateSupportsCondition('(COLOR: red)')).toBe(true);
});

it('ignores !important in a declaration test', () => {
expect(evaluateSupportsCondition('(color: red !important)')).toBe(true);
});

it('evaluates not, and, or', () => {
expect(evaluateSupportsCondition('not (display: grid)')).toBe(true);
expect(evaluateSupportsCondition('not (color: red)')).toBe(false);
expect(evaluateSupportsCondition('(color: red) and (display: grid)')).toBe(false);
expect(evaluateSupportsCondition('(color: red) and (text-transform: uppercase) and (margin: 4)')).toBe(true);
expect(evaluateSupportsCondition('(display: grid) or (color: red)')).toBe(true);
expect(evaluateSupportsCondition('(display: grid) or (display: flex)')).toBe(false);
expect(evaluateSupportsCondition('(color: red) AND (margin: 4)')).toBe(true);
});

it('evaluates nested conditions', () => {
expect(evaluateSupportsCondition('((display: grid) or (color: red)) and (not (display: grid))')).toBe(true);
expect(evaluateSupportsCondition('(not ((color: red) and (display: grid)))')).toBe(true);
});

it('fails mixed and/or without parentheses as a whole', () => {
expect(evaluateSupportsCondition('(color: red) and (margin: 4) or (display: grid)')).toBe(false);
});

it('fails malformed conditions as a whole', () => {
expect(evaluateSupportsCondition('')).toBe(false);
expect(evaluateSupportsCondition('color: red')).toBe(false);
expect(evaluateSupportsCondition('(color: red')).toBe(false);
expect(evaluateSupportsCondition('not (color: red) and (margin: 4)')).toBe(false);
expect(evaluateSupportsCondition('(color: red) (margin: 4)')).toBe(false);
});

it('evaluates unknown tests to false, so they can still be negated', () => {
expect(evaluateSupportsCondition('font-tech(color-COLRv1)')).toBe(false);
expect(evaluateSupportsCondition('not font-tech(color-COLRv1)')).toBe(true);
expect(evaluateSupportsCondition('(foo bar)')).toBe(false);
});

it('evaluates selector()', () => {
expect(evaluateSupportsCondition('selector(.a > .b)')).toBe(true);
expect(evaluateSupportsCondition('selector(.a >)')).toBe(false);
});

it('does not split on parentheses or colons inside strings', () => {
expect(evaluateSupportsCondition('(font-family: "a:b)")')).toBe(true);
});
});
Loading
Loading