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
9 changes: 9 additions & 0 deletions packages/core/ui/styling/css-shadow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ describe('css-shadow', () => {
expect(shadow).toBeNull();
});

it('0 0 10px color-mix(in srgb, blue 35%, transparent)', () => {
const shadow = parseCSSShadow('0 0 10px color-mix(in srgb, blue 35%, transparent)');
expect(shadow.inset).toBe(false);
expect(shadow.offsetX).toBe(0);
expect(shadow.offsetY).toBe(0);
expect(shadow.blurRadius).toEqual(Length.parse('10px'));
expect(shadow.color).toEqual(new Color('color-mix(in srgb, blue 35%, transparent)'));
});

it('5em 1em gold unset', () => {
// partially invalid shorthand should result in standard default fallback
const shadow = parseCSSShadow('5em 1em gold unset');
Expand Down
88 changes: 88 additions & 0 deletions packages/core/ui/styling/css-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { parseCSSCommaSeparatedListOfValues, parseCSSShorthand, splitOnTopLevelComma, splitOnTopLevelSpacesAndCommas, splitOnTopLevelWhiteSpace } from './css-utils';
import { Length } from './length-shared';

describe('css-utils', () => {
describe('splitOnTopLevelWhiteSpace', () => {
it('splits plain values', () => {
expect(splitOnTopLevelWhiteSpace('1px 1px 2px black')).toEqual(['1px', '1px', '2px', 'black']);
});

it('keeps single-level function arguments together', () => {
expect(splitOnTopLevelWhiteSpace('0 0 10px rgba(0, 0, 0, 0.5)')).toEqual(['0', '0', '10px', 'rgba(0, 0, 0, 0.5)']);
});

it('keeps nested function arguments together', () => {
expect(splitOnTopLevelWhiteSpace('0 0 10px color-mix(in srgb, var(--x) 35%, transparent)')).toEqual(['0', '0', '10px', 'color-mix(in srgb, var(--x) 35%, transparent)']);
});

it('collapses consecutive whitespace', () => {
expect(splitOnTopLevelWhiteSpace('1px\t 2px\n3px')).toEqual(['1px', '2px', '3px']);
});

it('recovers after an unbalanced closing parenthesis', () => {
expect(splitOnTopLevelWhiteSpace('a) b c')).toEqual(['a)', 'b', 'c']);
});

it('returns no parts for an empty string', () => {
expect(splitOnTopLevelWhiteSpace('')).toEqual([]);
});
});

describe('splitOnTopLevelComma', () => {
it('splits a list of functions but not their arguments', () => {
expect(splitOnTopLevelComma('rgb(1, 2, 3), rgb(4, 5, 6)')).toEqual(['rgb(1, 2, 3)', ' rgb(4, 5, 6)']);
});

it('handles nested functions', () => {
expect(splitOnTopLevelComma('0 0 10px color-mix(in srgb, var(--x) 35%, transparent), 0 0 5px red')).toEqual(['0 0 10px color-mix(in srgb, var(--x) 35%, transparent)', ' 0 0 5px red']);
});

it('keeps empty parts like String.split', () => {
expect(splitOnTopLevelComma('a,,b,')).toEqual(['a', '', 'b', '']);
});
});

describe('splitOnTopLevelSpacesAndCommas', () => {
it('separates on commas as well as whitespace', () => {
expect(splitOnTopLevelSpacesAndCommas('red,blue')).toEqual(['red', 'blue']);
expect(splitOnTopLevelSpacesAndCommas('red, blue green')).toEqual(['red', 'blue', 'green']);
});

it('keeps function arguments together', () => {
expect(splitOnTopLevelSpacesAndCommas('red rgb(1, 2, 3) color-mix(in srgb, red 35%, blue)')).toEqual(['red', 'rgb(1, 2, 3)', 'color-mix(in srgb, red 35%, blue)']);
});
});

describe('parseCSSCommaSeparatedListOfValues', () => {
it('returns an empty list for an empty value', () => {
expect(parseCSSCommaSeparatedListOfValues('')).toEqual([]);
});

it('splits multiple shadows with nested function colors', () => {
const values = parseCSSCommaSeparatedListOfValues('0 0 10px color-mix(in srgb, blue 35%, transparent), 1px 1px 2px black');
expect(values).toHaveLength(2);
expect(values[0]).toBe('0 0 10px color-mix(in srgb, blue 35%, transparent)');
});
});

describe('parseCSSShorthand', () => {
it('parses a shadow-like shorthand with a nested function color', () => {
const data = parseCSSShorthand('0 0 10px color-mix(in srgb, var(--x) 35%, transparent)');
expect(data.color).toBe('color-mix(in srgb, var(--x) 35%, transparent)');
expect(data.inset).toBe(false);
expect(data.values).toEqual([0, 0, Length.parse('10px')]);
});

it('parses a leading function color', () => {
const data = parseCSSShorthand('rgba(0, 0, 0, 0.5) 1px 2px');
expect(data.color).toBe('rgba(0, 0, 0, 0.5)');
expect(data.values).toEqual([Length.parse('1px'), Length.parse('2px')]);
});

it('returns null for empty and keyword values', () => {
expect(parseCSSShorthand('')).toBeNull();
expect(parseCSSShorthand('none')).toBeNull();
expect(parseCSSShorthand('unset')).toBeNull();
});
});
});
145 changes: 138 additions & 7 deletions packages/core/ui/styling/css-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,146 @@ export function cleanupImportantFlags(value: unknown, propertyName: string) {
return value;
}

function isWhiteSpaceCode(code: number): boolean {
return code === 32 || (code >= 9 && code <= 13) || code === 160;
}

const ANY_WHITE_SPACE_RE = /\s+/;
const SPACES_AND_COMMAS_RE = /[\s,]+/;

/**
* Matches whitespace except if the whitespace is contained in parenthesis - e.g. rgb(a), hsl color.
* Matches whitespace outside of parenthesis, but only reliably for a single
* nesting level - fine for `rgb(a, b, c)`, wrong for `color-mix(...)` holding
* nested functions.
*/
const WHITE_SPACE_RE = /\s(?![^(]*\))/;
const FLAT_WHITE_SPACE_RE = /\s(?![^(]*\))/;

/**
* The comma form of {@link FLAT_WHITE_SPACE_RE}, with the same one-level limit.
*/
const FLAT_COMMA_RE = /,(?![^(]*\))/;

function isNotEmpty(part: string): boolean {
return part !== '';
}

function hasNestedParens(value: string, open: number): boolean {
while (open !== -1) {
const nextOpen = value.indexOf('(', open + 1);
if (nextOpen === -1) {
return false;
}

const close = value.indexOf(')', open + 1);
if (close === -1 || nextOpen < close) {
return true;
}

open = nextOpen;
}

return false;
}

// The character scan below is what handles arbitrary nesting, but without a JIT
// it is several times slower than the native regex engine - so values a regex
// splits correctly (none or a single level of parenthesis) keep taking one.
function splitTopLevel(value: string, commaIsSeparator: boolean): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;

for (let i = 0, length = value.length; i < length; i++) {
const code = value.charCodeAt(i);
if (code === 40 /* ( */) {
depth++;
} else if (code === 41 /* ) */) {
if (depth > 0) {
depth--;
}
} else if (depth === 0 && (isWhiteSpaceCode(code) || (commaIsSeparator && code === 44))) {
if (i > start) {
parts.push(value.slice(start, i));
}
start = i + 1;
}
}

if (start < value.length) {
parts.push(value.slice(start));
}

return parts;
}

/**
* Matches comma except if the comma is contained in parenthesis - e.g. rgb(a, b, c).
* Split on whitespace outside of any parenthesis, so function values keep their
* arguments together whatever their nesting - e.g. `color-mix(in srgb, var(--x) 35%, transparent)`.
* Consecutive whitespace produces no empty parts.
*/
const COMMA_RE = /,(?![^(]*\))/;
export function splitOnTopLevelWhiteSpace(value: string): string[] {
const open = value.indexOf('(');
if (open === -1) {
return value.split(ANY_WHITE_SPACE_RE).filter(isNotEmpty);
}
if (!hasNestedParens(value, open)) {
return value.split(FLAT_WHITE_SPACE_RE).filter(isNotEmpty);
}

return splitTopLevel(value, false);
}

/**
* Like {@link splitOnTopLevelWhiteSpace}, but commas separate too - the tolerant
* form positioning shorthands like `border-color` accept.
*/
export function splitOnTopLevelSpacesAndCommas(value: string): string[] {
if (!value.includes('(')) {
return value.split(SPACES_AND_COMMAS_RE).filter(isNotEmpty);
}

return splitTopLevel(value, true);
}

/**
* Split on commas outside of any parenthesis - e.g. `rgb(a, b, c), rgb(d, e, f)`
* splits only between the two functions. Empty parts are kept, like `String.split`.
*/
export function splitOnTopLevelComma(value: string): string[] {
const open = value.indexOf('(');
if (open === -1) {
return value.split(',');
}
if (!hasNestedParens(value, open)) {
return value.split(FLAT_COMMA_RE);
}

return splitTopLevelComma(value);
}

function splitTopLevelComma(value: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;

for (let i = 0, length = value.length; i < length; i++) {
const code = value.charCodeAt(i);
if (code === 40 /* ( */) {
depth++;
} else if (code === 41 /* ) */) {
if (depth > 0) {
depth--;
}
} else if (depth === 0 && code === 44 /* , */) {
parts.push(value.slice(start, i));
start = i + 1;
}
}

parts.push(value.slice(start));

return parts;
}

/**
* Matches a Length value with or without a unit
Expand All @@ -44,18 +175,18 @@ export function parseCSSCommaSeparatedListOfValues(value: string): string[] {
return [];
}

return value.split(COMMA_RE);
return splitOnTopLevelComma(value);
}

export function parseCSSShorthand(value: string): {
values: Array<CoreTypes.LengthType>;
color: string;
inset: boolean;
} {
const parts = value.trim().split(WHITE_SPACE_RE);
const parts = splitOnTopLevelWhiteSpace(value.trim());
const first = parts[0];

if (['', 'none', 'unset'].includes(first)) {
if (!first || ['none', 'unset'].includes(first)) {
return null;
}

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

import { Label } from '../label';
import { Color } from '../../color';

describe('border-color shorthand', () => {
it('applies a function color with nested arguments to all sides', () => {
const label = new Label();
label.style.borderColor = 'color-mix(in srgb, red 50%, blue)' as any;

const expected = new Color('color-mix(in srgb, red 50%, blue)');
expect(label.style.borderTopColor).toEqual(expected);
expect(label.style.borderRightColor).toEqual(expected);
expect(label.style.borderBottomColor).toEqual(expected);
expect(label.style.borderLeftColor).toEqual(expected);
});

it('splits per-side colors around function values', () => {
const label = new Label();
label.style.borderColor = 'red rgb(1, 2, 3) blue color-mix(in srgb, red 35%, blue)' as any;

expect(label.style.borderTopColor).toEqual(new Color('red'));
expect(label.style.borderRightColor).toEqual(new Color('rgb(1, 2, 3)'));
expect(label.style.borderBottomColor).toEqual(new Color('blue'));
expect(label.style.borderLeftColor).toEqual(new Color('color-mix(in srgb, red 35%, blue)'));
});

it('still accepts comma-separated colors', () => {
const label = new Label();
label.style.borderColor = 'red,blue' as any;

expect(label.style.borderTopColor).toEqual(new Color('red'));
expect(label.style.borderBottomColor).toEqual(new Color('red'));
expect(label.style.borderRightColor).toEqual(new Color('blue'));
expect(label.style.borderLeftColor).toEqual(new Color('blue'));
});
});
20 changes: 8 additions & 12 deletions packages/core/ui/styling/style-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { LinearGradient } from './linear-gradient';
import { parseCSSShadow, ShadowCSSValues } from './css-shadow';
import { transformConverter } from './css-transform';
import { ClipPathFunction } from './clip-path-function';
import { parseCSSCommaSeparatedListOfValues } from './css-utils';
import { parseCSSCommaSeparatedListOfValues, splitOnTopLevelSpacesAndCommas } from './css-utils';

interface ShorthandPositioning {
top: string;
Expand Down Expand Up @@ -62,8 +62,10 @@ function parseClipPath(value: string): string | ClipPathFunction {
}

function parseShorthandPositioning(value: string): ShorthandPositioning {
const arr = value.split(/[ ,]+/);
return positioningFromParts(value.split(/[ ,]+/), value);
}

function positioningFromParts(arr: string[], value: string): ShorthandPositioning {
let top: string;
let right: string;
let bottom: string;
Expand Down Expand Up @@ -124,16 +126,10 @@ function parseShorthandGap(value: string): ShorthandGap {
}

function parseBorderColorPositioning(value: string): ShorthandPositioning {
if (value.indexOf('rgb') === 0 || value.indexOf('hsl') === 0) {
return {
top: value,
right: value,
bottom: value,
left: value,
};
}

return parseShorthandPositioning(value);
// Colors can be functions with spaces and commas in their arguments
// (`rgb(0, 0, 0)`, `color-mix(in srgb, red 35%, blue)`), so only
// top-level separators delimit the sides.
return positioningFromParts(splitOnTopLevelSpacesAndCommas(value.trim()), value);
}

function convertToBackgrounds(value: string): [CssProperty<any, any> | CssAnimationProperty<any, any>, any][] {
Expand Down
Loading