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
42 changes: 42 additions & 0 deletions src/__tests__/compiler/selectors.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { compile } from "react-native-css/compiler";

import { getClassNameSelectors } from "../../compiler/selector-builder";

test("empty", () => {
Expand Down Expand Up @@ -150,3 +152,43 @@ test(".group-[.test]:text-white { &:is(:where(.group):is(.test) *) {}", () => {
},
]);
});

// HTML compares a dir value ASCII case-insensitively unless the selector carries s
test.each([
['[dir="RTL"]', "rtl"],
['[dir="Rtl"]', "rtl"],
['[dir="rtl"]', "rtl"],
['[dir="RTL" i]', "rtl"],
['[dir="RTL" s]', "RTL"],
])(".t%s compiles to the direction %p", (attribute, direction) => {
const rule = compile(`.t${attribute} { color: red }`).stylesheet()
.s?.[0]?.[1][0];

expect(rule?.m).toStrictEqual([["=", "dir", direction]]);
});

test.each([
['[class="a b"]', ["a", "className", "=", "a b"]],
['[class~="b"]', ["a", "className", "~=", "b"]],
['[class*="b"]', ["a", "className", "*=", "b"]],
["[class]", ["a", "className"]],
['[data-state="open"]', ["d", "state", "=", "open"]],
["[disabled]", ["a", "disabled"]],
])(".t%s reads the prop it arrives on", (attribute, query) => {
const rule = compile(`.t${attribute} { color: red }`).stylesheet()
.s?.[0]?.[1][0];

expect(rule?.aq).toStrictEqual([query]);
});

test("an ancestor [class~=…] reads className through the :is() site", () => {
const rule = compile(
`.child:is(:where(.group)[class~="active"] *) { color: red }`,
)
.stylesheet()
.s?.find(([name]) => name === "child")?.[1][0];

expect(rule?.cq).toStrictEqual([
{ n: "g:group", a: [["a", "className", "~=", "active"]] },
]);
});
79 changes: 79 additions & 0 deletions src/__tests__/native/attributes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,82 @@ test.each(["=", "|=", "~=", "^=", "$=", "*="] as const)(
expect(screen.getByTestId(testID).props.style.width).toBe(40);
},
);

// react-native-web writes a dataSet value as a data-* attribute unless it is null or undefined
describe("a dataSet value projects as react-native-web writes it", () => {
test.each([
[false, true],
[0, true],
["", true],
[null, false],
[undefined, false],
])("[data-test] over %p is present: %s", (value, present) => {
registerCSS(`.test { width: 40px; } .test[data-test] { width: 80px; }`);
render(
<View
testID={testID}
className="test"
{...{ dataSet: { test: value } }}
/>,
);
expect(screen.getByTestId(testID).props.style.width).toBe(
present ? 80 : 40,
);
});

test("a false plain prop is an absent boolean attribute", () => {
registerCSS(`.test { width: 40px; } .test[disabled] { width: 80px; }`);
render(<View testID={testID} className="test" {...{ disabled: false }} />);
expect(screen.getByTestId(testID).props.style.width).toBe(40);
});

test.each([
['[data-test="a,b"]', true],
['[data-test~="a"]', false],
])("%s over the array [a, b] matches: %s", (attribute, matches) => {
registerCSS(`.test { width: 40px; } .test${attribute} { width: 80px; }`);
render(
<View
testID={testID}
className="test"
{...{ dataSet: { test: ["a", "b"] } }}
/>,
);
expect(screen.getByTestId(testID).props.style.width).toBe(
matches ? 80 : 40,
);
});
});

test.each([
["[class='test example']", "example", true],
["[class='example']", "example", false],
["[class~='example']", "example", true],
["[class~='example']", "other", false],
["[class*='xamp']", "example", true],
["[class]", "example", true],
])(
".test%s over the class list test %s matches: %s",
(selector, name, matches) => {
registerCSS(`.test { width: 40px; } .test${selector} { width: 80px; }`);
render(<View testID={testID} className={`test ${name}`} />);
expect(screen.getByTestId(testID).props.style.width).toBe(
matches ? 80 : 40,
);
},
);

test.each([
["group active", true],
["group", false],
])("an ancestor class list %p answers [class~=…]: %s", (name, matches) => {
registerCSS(
`.child { width: 40px; } .child:is(:where(.group)[class~="active"] *) { width: 80px; }`,
);
render(
<View className={name}>
<View testID={testID} className="child" />
</View>,
);
expect(screen.getByTestId(testID).props.style.width).toBe(matches ? 80 : 40);
});
14 changes: 14 additions & 0 deletions src/__tests__/native/direction-media.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,17 @@ test.each([false, true])(
expect(screen.getByTestId("ltr")).toHaveStyle({ width: isRTL ? 10 : 37 });
},
);

test.each([
['[dir="RTL"]', true],
['[dir="RTL" i]', true],
['[dir="RTL" s]', false],
])(".t%s matches a right-to-left layout: %s", (attribute, matches) => {
Object.defineProperty(I18nManager, "isRTL", {
configurable: true,
value: true,
});
registerCSS(`.t { width: 10px; } .t${attribute} { width: 37px; }`);
render(<View testID="t" className="t" />);
expect(screen.getByTestId("t")).toHaveStyle({ width: matches ? 37 : 10 });
});
114 changes: 39 additions & 75 deletions src/compiler/selector-builder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AttrOperation, Selector, SelectorList } from "lightningcss";

import { Specificity } from "../utilities";
import { asciiLowerCase } from "../utilities/ascii-case";
import type {
AttributeQuery,
AttrSelectorOperator,
Expand Down Expand Up @@ -249,63 +250,17 @@ function parseComponents(
return [];
}

getMediaQuery(ref).push([operator, "dir", component.operation.value]);
// HTML compares a dir value ASCII case-insensitively unless the selector carries s
const direction =
component.operation.caseSensitivity === "explicit-case-sensitive"
? component.operation.value
: asciiLowerCase(component.operation.value);
getMediaQuery(ref).push([operator, "dir", direction]);
return parseComponents(rest, options, root, ref, specificity);
} else {
// specificity[Specificity.ClassName] =
// (specificity[Specificity.ClassName] ?? 0) + 1;
const attributeQuery: AttributeQuery = component.name.startsWith(
"data-",
)
? // [data-*] are turned into `dataSet` queries
["d", toRNProperty(component.name.replace("data-", ""))]
: // Everything else is turned into `attribute` queries
[
"a",
component.name.startsWith("aria-")
? component.name
: toRNProperty(component.name),
];
if (component.operation) {
let operator: AttrSelectorOperator | undefined;
switch (component.operation.operator) {
case "equal":
operator = "=";
break;
case "includes":
operator = "~=";
break;
case "dash-match":
operator = "|=";
break;
case "prefix":
operator = "^=";
break;
case "substring":
operator = "*=";
break;
case "suffix":
operator = "$=";
break;
default:
component.operation.operator satisfies never;
break;
}
if (operator) {
// Append the operator onto the attribute query
attributeQuery.push(operator, component.operation.value);
if (
component.operation.caseSensitivity === "ascii-case-insensitive"
) {
attributeQuery.push("i");
} else if (
component.operation.caseSensitivity === "explicit-case-sensitive"
) {
attributeQuery.push("s");
}
}
}
getAttributeQuery(ref).push(attributeQuery);
getAttributeQuery(ref).push(attributeQueryFor(component));
specificity[Specificity.ClassName] =
(specificity[Specificity.ClassName] ?? 0) + 1;
return parseComponents(rest, options, root, ref, specificity);
Expand Down Expand Up @@ -474,28 +429,7 @@ function parseIsWhereComponents(
// specificity[Specificity.ClassName] =
// (specificity[Specificity.ClassName] ?? 0) + 1;
}
const attributeQuery: AttributeQuery = component.name.startsWith("data-")
? // [data-*] are turned into `dataSet` queries
["d", toRNProperty(component.name.replace("data-", ""))]
: // Everything else is turned into `attribute` queries
[
"a",
component.name.startsWith("aria-")
? component.name
: toRNProperty(component.name),
];
if (component.operation) {
const operator = operatorMap[component.operation.operator];
// Append the operator onto the attribute query
attributeQuery.push(operator, component.operation.value);
if (component.operation.caseSensitivity === "ascii-case-insensitive") {
attributeQuery.push("i");
} else if (
component.operation.caseSensitivity === "explicit-case-sensitive"
) {
attributeQuery.push("s");
}
}
const attributeQuery = attributeQueryFor(component);
queries ??= [{ specificity: [] }];
for (const query of queries) {
if (type === "is") {
Expand Down Expand Up @@ -608,6 +542,36 @@ type CamelCase<S extends string> =
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
: Lowercase<S>;

// React Native names the class list className, and keeps an aria-* prop as written
function attributePropName(name: string): string {
if (name === "class") {
return "className";
}

return name.startsWith("aria-") ? name : toRNProperty(name);
}

function attributeQueryFor(
component: Extract<Selector[number], { type: "attribute" }>,
): AttributeQuery {
const query: AttributeQuery = component.name.startsWith("data-")
? ["d", toRNProperty(component.name.replace("data-", ""))]
: ["a", attributePropName(component.name)];

const operation = component.operation;
if (!operation) {
return query;
}

query.push(operatorMap[operation.operator], operation.value);
if (operation.caseSensitivity === "ascii-case-insensitive") {
query.push("i");
} else if (operation.caseSensitivity === "explicit-case-sensitive") {
query.push("s");
}
return query;
}

const operatorMap: Record<AttrOperation["operator"], AttrSelectorOperator> = {
"equal": "=",
"includes": "~=",
Expand Down
61 changes: 38 additions & 23 deletions src/native/conditions/attributes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { AttributeQuery } from "react-native-css/compiler";

import { asciiLowerCase } from "../../utilities/ascii-case";
import type { RenderGuard } from "./guards";

type AttributeQueryType = AttributeQuery[0];

export function testAttributes(
queries: AttributeQuery[],
props: Record<string, unknown> | undefined | null,
Expand Down Expand Up @@ -29,43 +32,55 @@ function testAttribute(
guards.push([type, prop, value]);

if (!operator) {
return value !== undefined && value !== null && value !== false;
return isPresent(type, value);
}

if (operator === "!") return !value;
if (value === undefined || value === null || testValue === undefined)
return false;

if (
typeof value !== "string" &&
typeof value !== "number" &&
typeof value !== "boolean"
)
return false;
let actual = String(value);
if (caseSensitivity === "i") {
// CSS attribute flags fold ASCII letters only, not Unicode characters.
actual = actual.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
testValue = testValue.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
}
const attribute = attributeText(type, value);
if (attribute === undefined || testValue === undefined) return false;

const insensitive = caseSensitivity === "i";
const actual = insensitive ? asciiLowerCase(attribute) : attribute;
const expected = insensitive ? asciiLowerCase(testValue) : testValue;

switch (operator) {
case "=":
return actual === testValue;
return actual === expected;
case "~=":
return (
testValue !== "" && actual.split(/[\t\n\f\r ]+/).includes(testValue)
);
return expected !== "" && actual.split(/[\t\n\f\r ]+/).includes(expected);
case "|=":
return actual === testValue || actual.startsWith(testValue + "-");
return actual === expected || actual.startsWith(expected + "-");
case "^=":
return testValue !== "" && actual.startsWith(testValue);
return expected !== "" && actual.startsWith(expected);
case "$=":
return testValue !== "" && actual.endsWith(testValue);
return expected !== "" && actual.endsWith(expected);
case "*=":
return testValue !== "" && actual.includes(testValue);
return expected !== "" && actual.includes(expected);
default:
operator satisfies never;
return false;
}
}

// react-native-web writes a dataSet entry as a data-* attribute for every value but null and undefined, while a false prop is an absent boolean attribute
function isPresent(type: AttributeQueryType, value: unknown): boolean {
return (
value !== undefined && value !== null && (type === "d" || value !== false)
);
}

function attributeText(
type: AttributeQueryType,
value: unknown,
): string | undefined {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return String(value);
}

return type === "d" && Array.isArray(value) ? value.join(",") : undefined;
}
4 changes: 4 additions & 0 deletions src/utilities/ascii-case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Selectors §6.3 and HTML fold ASCII letters only: toLowerCase would also fold U+212A KELVIN SIGN onto k
export function asciiLowerCase(text: string): string {
return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
}