diff --git a/src/__tests__/compiler/selectors.test.tsx b/src/__tests__/compiler/selectors.test.tsx
index 1df75cf0..8088dac4 100644
--- a/src/__tests__/compiler/selectors.test.tsx
+++ b/src/__tests__/compiler/selectors.test.tsx
@@ -1,3 +1,5 @@
+import { compile } from "react-native-css/compiler";
+
import { getClassNameSelectors } from "../../compiler/selector-builder";
test("empty", () => {
@@ -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"]] },
+ ]);
+});
diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx
index 900dde70..2c4288d9 100644
--- a/src/__tests__/native/attributes.test.tsx
+++ b/src/__tests__/native/attributes.test.tsx
@@ -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(
+ ,
+ );
+ 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();
+ 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(
+ ,
+ );
+ 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();
+ 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(
+
+
+ ,
+ );
+ expect(screen.getByTestId(testID).props.style.width).toBe(matches ? 80 : 40);
+});
diff --git a/src/__tests__/native/direction-media.test.tsx b/src/__tests__/native/direction-media.test.tsx
index 8ed38ee4..014c087a 100644
--- a/src/__tests__/native/direction-media.test.tsx
+++ b/src/__tests__/native/direction-media.test.tsx
@@ -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();
+ expect(screen.getByTestId("t")).toHaveStyle({ width: matches ? 37 : 10 });
+});
diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts
index 6787c0ca..cc8b8684 100644
--- a/src/compiler/selector-builder.ts
+++ b/src/compiler/selector-builder.ts
@@ -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,
@@ -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);
@@ -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") {
@@ -608,6 +542,36 @@ type CamelCase =
? `${Lowercase}${Uppercase}${CamelCase}`
: Lowercase;
+// 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,
+): 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 = {
"equal": "=",
"includes": "~=",
diff --git a/src/native/conditions/attributes.ts b/src/native/conditions/attributes.ts
index e856e79e..f6b53ec5 100644
--- a/src/native/conditions/attributes.ts
+++ b/src/native/conditions/attributes.ts
@@ -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 | undefined | null,
@@ -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;
+}
diff --git a/src/utilities/ascii-case.ts b/src/utilities/ascii-case.ts
new file mode 100644
index 00000000..748b92c5
--- /dev/null
+++ b/src/utilities/ascii-case.ts
@@ -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());
+}