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()); +}