Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/__tests__/compiler/compiler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,26 @@ test("preserves excluded CSS variables using the public compiler option", () =>
expect(rule?.v?.some(([name]) => name === "red")).toBe(false);
});

test.each([
[
"an hsl() alpha",
`:root { --alpha: 0.5; } .test { color: hsl(0 100% 50% / var(--alpha)); }`,
`.test { color: hsl(0 100% 50% / 0.5); }`,
],
[
"an rgb() alpha",
`:root { --alpha: 0.5; } .test { color: rgb(255 0 0 / var(--alpha)); }`,
`.test { color: rgb(255 0 0 / 0.5); }`,
],
[
"a light-dark() arm",
`:root { --dark: #654321; } .test { color: light-dark(#123456, var(--dark)); }`,
`.test { color: light-dark(#123456, #654321); }`,
],
])("folds a variable read inside %s", (_name, css, folded) => {
expect(compile(css).stylesheet()).toStrictEqual(compile(folded).stylesheet());
});

test("multiple rules with same selector", () => {
const compiled = compile(`
.redOrGreen:hover {
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/compiler/declarations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ const tests = [
["rotate: x 3deg;", [{ d: [[[{}, "rotateX", "3deg"], "rotateX"]], s: [1, 1] }]],
["stroke-width: 1px;", [{ d: [[1, ["strokeWidth"]]], s: [1, 1] }]],
["stroke: black;", [{ d: [["#000", ["stroke"]]], s: [1, 1] }]],
["background-color: rgb(255 0 0 / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: rgb(100% 0% 0% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: rgb(50% 25% 10% / var(--a));", [{ d: [[[{}, "rgba", [128, 64, 26, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: hsl(0 84.2% 60.2% / var(--a));", [{ d: [[[{}, "rgba", [239, 68, 68, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: hsl(120 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [0, 255, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: hsl(calc(NaN) 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: hsl(calc(infinity) 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: hsl(4294967296 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: hsl(1e20 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: hsl(-600 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [0, 255, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
["background-color: hsl(1e7 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [170, 0, 255, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
] as const;

test.each(tests)("declarations for %s", (declarations, expected) => {
Expand Down
207 changes: 206 additions & 1 deletion src/__tests__/native/colors.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { render, screen } from "@testing-library/react-native";
import { processColor } from "react-native";

import { act, render, screen } from "@testing-library/react-native";
import { View } from "react-native-css/components/View";
import { registerCSS, testID } from "react-native-css/jest";
import { colorScheme } from "react-native-css/runtime";

describe("hsl", () => {
test("inline", () => {
Expand Down Expand Up @@ -136,6 +139,208 @@ describe("hsla", () => {
});
});

// Chromium 153, Firefox 155 and WebKit 26.6 compute each colour below with an alpha of 0.5
const BROWSER_COLORS = [
["rgb(255 0 0)", "rgba(255, 0, 0, 0.5)"],
["rgb(100% 0% 0%)", "rgba(255, 0, 0, 0.5)"],
["rgb(51 102 153)", "rgba(51, 102, 153, 0.5)"],
["rgb(127.5 0 0)", "rgba(128, 0, 0, 0.5)"],
["rgb(50% 25% 10%)", "rgba(128, 64, 26, 0.5)"],
["rgb(50% 25% 75%)", "rgba(128, 64, 191, 0.5)"],
["rgb(300 -20 0)", "rgba(255, 0, 0, 0.5)"],
["hsl(120 50% 50%)", "rgba(64, 191, 64, 0.5)"],
["hsl(120deg 100% 25%)", "rgba(0, 128, 0, 0.5)"],
["hsl(0.5turn 50% 50%)", "rgba(64, 191, 191, 0.5)"],
["hsl(3.14159rad 50% 50%)", "rgba(64, 191, 191, 0.5)"],
["hsl(200grad 50% 50%)", "rgba(64, 191, 191, 0.5)"],
["hsl(480 50% 50%)", "rgba(64, 191, 64, 0.5)"],
["hsl(-120 50% 50%)", "rgba(64, 64, 191, 0.5)"],
["hsl(-600 100% 50%)", "rgba(0, 255, 0, 0.5)"],
["hsl(1e7 100% 50%)", "rgba(170, 0, 255, 0.5)"],
["hsl(120 150% 50%)", "rgba(0, 255, 0, 0.5)"],
["hsl(120 50% 120%)", "rgba(255, 255, 255, 0.5)"],
["hsl(120 -10% 50%)", "rgba(128, 128, 128, 0.5)"],
["hsl(0 84.2% 60.2%)", "rgba(239, 68, 68, 0.5)"],
["hsl(33.3 47.4% 11.2%)", "rgba(42, 30, 15, 0.5)"],
] as const;

// The same browsers render an infinite or NaN hue as hue 0
const NON_FINITE_HUE_COLORS = [
["hsl(calc(infinity) 50% 50%)", "rgba(191, 64, 64, 0.5)"],
["hsl(calc(-infinity) 50% 50%)", "rgba(191, 64, 64, 0.5)"],
["hsl(calc(NaN) 50% 50%)", "rgba(191, 64, 64, 0.5)"],
] as const;

function withAlpha(color: string, alpha: string) {
return `${color.slice(0, -1)} / ${alpha})`;
}

function renderedBackground(id = testID) {
const style: unknown = screen.getByTestId(id).props.style;

return typeof style === "object" &&
style !== null &&
"backgroundColor" in style &&
typeof style.backgroundColor === "string"
? processColor(style.backgroundColor)
: undefined;
}

function renderInParent() {
render(
<View className="parent">
<View testID={testID} className="my-class" />
</View>,
);
}

describe("a colour with an unresolved alpha renders what browsers compute", () => {
test.each([...BROWSER_COLORS, ...NON_FINITE_HUE_COLORS])(
"%s, alpha read at runtime",
(color, expected) => {
registerCSS(
`.parent { --a: 0.5; } .my-class { background-color: ${withAlpha(color, "var(--a)")}; }`,
{ inlineVariables: false },
);

renderInParent();

expect(renderedBackground()).toBe(processColor(expected));
},
);

test.each([...BROWSER_COLORS, ...NON_FINITE_HUE_COLORS])(
"%s, alpha read from a fallback",
(color, expected) => {
registerCSS(
`.my-class { background-color: ${withAlpha(color, "var(--a, 0.5)")}; }`,
);

renderInParent();

expect(renderedBackground()).toBe(processColor(expected));
},
);

test.each(BROWSER_COLORS)(
"%s, alpha folded at compile time",
(color, expected) => {
registerCSS(
`.parent { --a: 0.5; } .my-class { background-color: ${withAlpha(color, "var(--a)")}; }`,
);

renderInParent();

expect(renderedBackground()).toBe(processColor(expected));
},
);

test.each(BROWSER_COLORS)(
"%s, resolved at compile time",
(color, expected) => {
registerCSS(
`.my-class { background-color: ${withAlpha(color, "0.5")}; }`,
);

renderInParent();

expect(renderedBackground()).toBe(processColor(expected));
},
);

test("the alpha is the variable's value on the element", () => {
registerCSS(
`.parent { --a: 0.5; } .my-class { --a: 0.25; background-color: ${withAlpha("rgb(255 0 0)", "var(--a)")}; }`,
{ inlineVariables: false },
);

renderInParent();

expect(renderedBackground()).toBe(processColor("rgba(255, 0, 0, 0.25)"));
});
});

describe("a hue the second lightningcss pass reads as a float32", () => {
// Chromium and WebKit reduce 3e9 to 120deg; Firefox renders it as hue 0
test("below 2 ** 32 the hue reduces", () => {
registerCSS(
`.my-class { background-color: ${withAlpha("hsl(3e9 100% 50%)", "var(--a, 0.5)")}; }`,
);

renderInParent();

expect(renderedBackground()).toBe(processColor("rgba(0, 255, 0, 0.5)"));
});

test.each(["4294967296", "1e20", "1e38"])(
"%s names no angle and renders as hue 0",
(hue) => {
registerCSS(`
.my-class { background-color: ${withAlpha(`hsl(${hue} 100% 50%)`, "var(--a, 0.5)")}; }
.resolved { background-color: hsl(${hue} 100% 50% / 0.5); }
`);

render(
<>
<View testID={testID} className="my-class" />
<View testID="resolved" className="resolved" />
</>,
);

expect(renderedBackground()).toBe(processColor("rgba(255, 0, 0, 0.5)"));
expect(renderedBackground("resolved")).toBe(renderedBackground());
},
);

// lightningcss's first pass resolves an infinite hue to an achromatic colour; browsers render hue 0
test("lightningcss resolves calc(infinity) to black where the unresolved spelling renders hue 0", () => {
registerCSS(`
.resolved { background-color: hsl(calc(infinity) 100% 50%); }
.my-class { background-color: ${withAlpha("hsl(calc(infinity) 100% 50%)", "var(--a, 1)")}; }
`);

render(
<>
<View testID="resolved" className="resolved" />
<View testID={testID} className="my-class" />
</>,
);

expect(renderedBackground("resolved")).toBe(processColor("#000"));
expect(renderedBackground()).toBe(processColor("rgb(255, 0, 0)"));
});
});

describe("light-dark()", () => {
afterEach(() => {
act(() => {
colorScheme.set("light");
});
});

test("carries an unresolved alpha into both schemes", () => {
registerCSS(`
:root { color-scheme: light dark; }
.my-class {
background-color: light-dark(
rgb(50% 25% 10% / var(--a, 0.5)),
hsl(120 100% 50% / var(--a, 0.5))
);
}
`);

render(<View testID={testID} className="my-class" />);

expect(renderedBackground()).toBe(processColor("rgba(128, 64, 26, 0.5)"));

act(() => {
colorScheme.set("dark");
});

expect(renderedBackground()).toBe(processColor("rgba(0, 255, 0, 0.5)"));
});
});

describe("currentcolor", () => {
test("currentcolor and global variables", () => {
registerCSS(`
Expand Down
37 changes: 29 additions & 8 deletions src/compiler/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2924,6 +2924,19 @@ export function parseTranslateProp(
return parseLength(value[prop], builder);
}

// colorjs.io holds sRGB in 0-1 and rgba() takes 0-255; CSS Color 4 renders a missing component as 0
function toRgbChannel(coordinate: number | null): number {
return Math.round((coordinate ?? 0) * 255);
}

// The second lightningcss pass reparses a hue as a float32, whose step from 2 ** 32 spans more than a turn
const FLOAT32_HUE_LIMIT = 2 ** 32;

// Browsers render an infinite or NaN hue as 0, and past the float32 limit a hue names no angle either
function toHueDegrees(hue: number): number {
return Math.abs(hue) < FLOAT32_HUE_LIMIT ? hue : 0;
}

export function parseUnresolvedColor(
color: UnresolvedColor,
builder: StylesheetBuilder,
Expand All @@ -2932,27 +2945,35 @@ export function parseUnresolvedColor(
): StyleDescriptor {
switch (color.type) {
case "rgb":
// lightningcss hands over rgb channels as 0-255 integers, the scale rgba() takes
return [
{},
"rgba",
[
round(color.r * 255),
round(color.g * 255),
round(color.b * 255),
color.r,
color.g,
color.b,
parseUnparsed(color.alpha, builder, property),
],
];
case "hsl":
case "hsl": {
// The channels are resolved here, so they convert to the sRGB parseColor writes for the same colour
const { coords } = new Color({
space: "hsl",
coords: [toHueDegrees(color.h), color.s, color.l],
}).to("srgb");

return [
{},
color.type,
"rgba",
[
color.h,
color.s,
color.l,
toRgbChannel(coords[0]),
toRgbChannel(coords[1]),
toRgbChannel(coords[2]),
parseUnparsed(color.alpha, builder, property),
],
];
}
case "light-dark": {
const extraRule = builder.extendRule({
m: [["=", "prefers-color-scheme", "dark"]],
Expand Down
15 changes: 15 additions & 0 deletions src/compiler/inline-variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@ function flattenPart(
part.value.arguments = part.value.arguments.flatMap((arg) => {
return flattenPart(arg, vars);
});
} else if (part.type === "unresolved-color") {
const color = part.value;

switch (color.type) {
case "rgb":
case "hsl":
color.alpha = color.alpha.flatMap((arg) => flattenPart(arg, vars));
break;
case "light-dark":
color.light = color.light.flatMap((arg) => flattenPart(arg, vars));
color.dark = color.dark.flatMap((arg) => flattenPart(arg, vars));
break;
default:
color satisfies never;
}
}

return part;
Expand Down