diff --git a/src/__tests__/compiler/condition-semantics.test.ts b/src/__tests__/compiler/condition-semantics.test.ts new file mode 100644 index 00000000..7423996e --- /dev/null +++ b/src/__tests__/compiler/condition-semantics.test.ts @@ -0,0 +1,136 @@ +import type { + ContainerCondition as CSSContainerCondition, + MediaCondition as CSSMediaCondition, +} from "lightningcss"; +import { compile, type StyleRule } from "react-native-css/compiler"; + +import { parseContainerCondition } from "../../compiler/container-query"; +import { parseMediaQueryCondition } from "../../compiler/media-query"; +import { StylesheetBuilder } from "../../compiler/stylesheet"; + +function compileChildConditions(css: string): Pick[] { + return (compile(css).stylesheet().s ?? []).flatMap(([className, rules]) => + className === "child" ? rules.map(({ m, cq }) => ({ m, cq })) : [], + ); +} + +// CSS Conditional 5 §5.4: an unknown size feature or an unresolvable value selects no container +test.each([ + "@container (fictional-feature > 10px)", + "@container (fictional-feature > 10px) or (width > 400px)", + "@container not (fictional-feature > 10px)", + "@container (width > env(safe-area-inset-top))", + "@container (width > env(safe-area-inset-top)) or (width > 400px)", +])("%s emits no block", (prelude) => { + expect( + compileChildConditions(`${prelude} { .child { color: red } }`), + ).toStrictEqual([]); +}); + +// An unknown keeps an `and` or a `not` unknown (MQ5 §3.1), so a condition no device can make true compiles to no rule +test.each([ + "@container style(--foo: bar)", + "@container not style(--foo: bar)", + "@container style(--foo: bar) and (width > 400px)", + "@media (width > env(safe-area-inset-top))", + "@media not (width: env(safe-area-inset-top))", + "@media (orientation: bogus)", + "@media not (orientation: bogus)", + "@media not ((width > 100px) or (orientation: bogus))", + "@media not (env(safe-area-inset-top) < width < env(safe-area-inset-bottom))", + "@media not (env(safe-area-inset-top) < width < 800px)", + "@media not (400px < width < env(safe-area-inset-bottom))", + "@media not ((env(safe-area-inset-top) < width < 800px) or (width < 0px))", +])("%s, which no device can make true, emits no block", (prelude) => { + expect( + compileChildConditions(`${prelude} { .child { color: red } }`), + ).toStrictEqual([]); +}); + +test.each<[string, Pick]>([ + [ + "@container style(--foo: bar) or (width > 400px)", + { m: undefined, cq: [{ m: ["|", [["?"], [">", "width", 400]]] }] }, + ], + [ + "@media (width > env(safe-area-inset-top)) or (width > 400px)", + { m: [["|", [["?"], [">", "width", 400]]]], cq: undefined }, + ], + [ + "@container (width > 10ch) or (width > 100px)", + { + m: undefined, + cq: [ + { + m: [ + "|", + [ + [">", "width", null], + [">", "width", 100], + ], + ], + }, + ], + }, + ], + [ + "@media not (400px < width < 800px)", + { m: [["!", ["[]", "width", 400, "<", 800, "<"]]], cq: undefined }, + ], + [ + "@container (width > 10em)", + { m: undefined, cq: [{ m: [">", "width", [{}, "em", 10, 1]] }] }, + ], + [ + "@media (constructor: x)", + { m: [["=", "constructor", "x"]], cq: undefined }, + ], + ["@media (grid: 0)", { m: [["=", "grid", 0]], cq: undefined }], + ["@media (grid: 1)", { m: [["=", "grid", 1]], cq: undefined }], +])("%s compiles to %j", (prelude, conditions) => { + expect( + compileChildConditions(`${prelude} { .child { color: red } }`), + ).toStrictEqual([conditions]); +}); + +test("a media query list keeps the branch a device can match beside one none can", () => { + expect( + compileChildConditions( + "@media (width > env(safe-area-inset-top)), (width > 400px) { .child { color: red } }", + ), + ).toStrictEqual([{ m: [[">", "width", 400]], cq: undefined }]); +}); + +// lightningcss 1.33's error recovery emits as a condition the 1.30 types do not list, which MQ5 §3.1 leaves unknown +const UNLISTED_CONDITIONS = [ + ["condition", { type: "unknown", value: [] }], + ["operator", { type: "operation", operator: "xor", conditions: [] }], +] as const; + +test.each(UNLISTED_CONDITIONS)( + "a media %s lightningcss's types do not list compiles to an unknown term", + (_, unlisted) => { + const condition: unknown = unlisted; + + expect( + parseMediaQueryCondition( + condition as CSSMediaCondition, + new StylesheetBuilder({}), + ), + ).toStrictEqual(["?"]); + }, +); + +test.each(UNLISTED_CONDITIONS)( + "a container %s lightningcss's types do not list selects no container", + (_, unlisted) => { + const condition: unknown = unlisted; + + expect( + parseContainerCondition( + condition as CSSContainerCondition, + new StylesheetBuilder({}), + ), + ).toBeUndefined(); + }, +); diff --git a/src/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts new file mode 100644 index 00000000..4963b410 --- /dev/null +++ b/src/__tests__/native/compare.test.ts @@ -0,0 +1,141 @@ +import type { + MediaFeatureComparison, + MediaFeatureOperand, +} from "react-native-css/compiler"; + +import { + compareMediaFeature, + evaluateCondition, + isTruthyFeatureValue, + testMediaFeatureInterval, + type FeatureReader, + type MeasuredFeatureValue, + type MediaFeatureName, + type MediaInterval, +} from "../../native/conditions/compare"; +import { UNKNOWN, type Truth } from "../../native/conditions/kleene"; + +test.each([ + [">", [false, false, true]], + [">=", [false, true, true]], + ["<", [true, false, false]], + ["<=", [true, true, false]], + ["=", [false, true, false]], +] as const)("%s compares 39, 40 and 41 against 40", (operator, expected) => { + expect( + [39, 40, 41].map((value) => compareMediaFeature(operator, value, 40)), + ).toStrictEqual(expected); +}); + +const operands: [ + label: string, + operator: MediaFeatureComparison, + left: MediaFeatureOperand | undefined, + right: MediaFeatureOperand, + truth: Truth, +][] = [ + ["an unmeasured feature", ">", undefined, 400, UNKNOWN], + ["an unresolved operand", ">", 400, null, UNKNOWN], + ["an operand only an element resolves", ">", 400, [{}, "em", 10, 1], UNKNOWN], + ["a keyword ordered against a number", ">", "landscape", 400, UNKNOWN], + ["an equal keyword", "=", "landscape", "landscape", true], + ["a different keyword", "=", "portrait", "landscape", false], +]; + +test.each(operands)("%s", (_label, operator, left, right, truth) => { + expect(compareMediaFeature(operator, left, right)).toBe(truth); +}); + +const intervals: [ + label: string, + condition: MediaInterval, + value: MeasuredFeatureValue, + truth: Truth, +][] = [ + ["inside", ["[]", "width", 400, "<", 800, "<"], 600, true], + ["below", ["[]", "width", 400, "<", 800, "<"], 300, false], + ["above", ["[]", "width", 400, "<", 800, "<"], 900, false], + ["on an open bound", ["[]", "width", 400, "<", 800, "<"], 400, false], + ["on a closed bound", ["[]", "width", 400, "<=", 800, "<"], 400, true], + ["written descending", ["[]", "width", 800, ">", 400, ">"], 600, true], + ["unmeasured", ["[]", "width", 400, "<", 800, "<"], undefined, UNKNOWN], + [ + "with an unresolved bound", + ["[]", "width", null, "<", 800, "<"], + 600, + UNKNOWN, + ], + ["false on one bound", ["[]", "width", 700, "<", null, "<"], 600, false], +]; + +test.each(intervals)("an interval %s", (_label, condition, value, truth) => { + expect(testMediaFeatureInterval(condition, value)).toBe(truth); +}); + +test.each([ + [400, true], + [0, false], + [Number.NaN, false], + ["hover", true], + ["none", false], + ["no-preference", false], + [undefined, UNKNOWN], +] as const)("%p in a boolean context is %p", (value, truth) => { + expect(isTruthyFeatureValue(value)).toBe(truth); +}); + +const recordingReader = ( + values: Readonly>>, + read: MediaFeatureName[], +): FeatureReader => ({ + read: (name) => { + read.push(name); + return values[name]; + }, + compare: ([operator, name, value]) => { + read.push(name); + return compareMediaFeature(operator, values[name], value); + }, +}); + +test("an empty conjunction is true and an empty disjunction false", () => { + const reader = recordingReader({}, []); + + expect(evaluateCondition(["&", []], reader, undefined)).toBe(true); + expect(evaluateCondition(["|", []], reader, undefined)).toBe(false); +}); + +test("a decided term stops the evaluation, and an unknown one does not", () => { + const read: MediaFeatureName[] = []; + const reader = recordingReader({ width: 400, height: 300 }, read); + + expect( + evaluateCondition( + [ + "&", + [ + ["=", "scan", "progressive"], + [">", "width", 500], + [">", "height", 100], + ], + ], + reader, + undefined, + ), + ).toBe(false); + expect( + evaluateCondition( + [ + "|", + [ + ["=", "scan", "progressive"], + [">", "width", 300], + [">", "height", 100], + ], + ], + reader, + undefined, + ), + ).toBe(true); + expect(read).toStrictEqual(["scan", "width", "scan", "width"]); +}); diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx new file mode 100644 index 00000000..9d6c00a9 --- /dev/null +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -0,0 +1,445 @@ +import { Platform } from "react-native"; + +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +import { + DEFAULT_CONTAINER_NAME, + testContainerQuery, +} from "../../native/conditions/container-query"; +import { testMediaQuery } from "../../native/conditions/media-query"; +import { dimensions, vw, type Getter } from "../../native/reactivity"; + +function mediaMatches( + condition: string, + size = { width: 600, height: 400 }, +): boolean { + registerCSS(` + .child { width: 10px; } + @media ${condition} { .child { width: 37px; } } + `); + act(() => { + dimensions.set({ ...dimensions.get(), ...size }); + }); + const { unmount } = render(); + const { width } = screen.getByTestId("child").props.style; + unmount(); + return width === 37; +} + +function containerMatches( + condition: string, + size = { width: 400, height: 200 }, +): boolean { + registerCSS(` + .parent { container-type: size; } + .child { width: 10px; } + @container ${condition} { .child { width: 37px; } } + `); + const { unmount } = render( + + + , + ); + fireEvent(screen.getByTestId("parent"), "layout", { + nativeEvent: { layout: { x: 0, y: 0, ...size } }, + }); + const { width } = screen.getByTestId("child").props.style; + unmount(); + return width === 37; +} + +describe("@media on a 600x400 screen", () => { + test.each([ + ["(400px < width < 800px)", true], + ["(400px < width < 500px)", false], + ["(600px < width < 800px)", false], + ["(600px <= width < 800px)", true], + ["(800px > width > 400px)", true], + ["(300px < height < 500px)", true], + ])("a range pair %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + + // MQ5 §3.1: a term the runtime cannot decide is unknown, and a rule applies only when its query is true + test.each([ + ["(width > env(safe-area-inset-top))", false], + ["not (width > env(safe-area-inset-top))", false], + ["(width: env(safe-area-inset-top)) or (width > 400px)", true], + ["not ((width: env(safe-area-inset-top)) and (width > 9999px))", true], + ["not all and (width > 10vw)", false], + ["not (fictional-feature: 3)", false], + ["(fictional-thing) or (width > 400px)", true], + ["(width > 400px) and (fictional-thing)", false], + ["(orientation: bogus)", false], + ["not (orientation: bogus)", false], + ["not (hover: bogus)", false], + ["not (prefers-color-scheme: bogus)", false], + ["not (scripting: bogus)", false], + ["not (update: bogus)", false], + ])("an undecidable term in %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + + // MQ5 §2.4.2: a feature in a boolean context is false only for zero, none and no-preference + test.each([ + ["(width)", true], + ["(orientation)", true], + ["(resolution)", true], + ["(hover)", false], + ["(color)", true], + ["(scripting)", true], + ["(monochrome)", false], + ["(grid)", false], + ["not (monochrome)", true], + ["(color-index)", false], + ["(display-mode)", true], + ["(dir)", false], + ])("the boolean context %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + + // MQ4 §3: a media type, such as this library's platform types, matches in any case + test.each([ + ["IOS", true], + ["not IOS", false], + ])("the media type in %s matches on iOS: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + + // css-values-4 §4.1: a keyword matches in any case + test.each([ + ["(orientation: LANDSCAPE)", true], + ["not (orientation: LANDSCAPE)", false], + ["(orientation: PORTRAIT)", false], + ["not (orientation: PORTRAIT)", true], + ])("the keyword in %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + + test.each([ + ["(min-color: 8)", true], + ["(min-color: 9)", false], + ["(monochrome: 0)", true], + ["(grid: 1)", false], + ["(scripting: enabled)", true], + ["(update: fast)", true], + ])( + "a feature every screen answers alike %s matches: %s", + (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }, + ); + + // The Compatibility Standard's -webkit-device-pixel-ratio is resolution in dppx, and PixelRatio.get() is 2 under Jest + test.each([ + ["(min-resolution: 2dppx)", true], + ["(-webkit-min-device-pixel-ratio: 2)", true], + ["(-webkit-device-pixel-ratio: 2)", true], + ["(-webkit-min-device-pixel-ratio: 3)", false], + ["not (-webkit-min-device-pixel-ratio: 3)", true], + ])("at a pixel ratio of 2, %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + + test.each([ + ["dark", "(prefers-color-scheme)", true], + ["dark", "(prefers-color-scheme: dark)", true], + ["dark", "(prefers-color-scheme: light)", false], + ["dark", "(prefers-color-scheme: DARK)", true], + ["dark", "(prefers-color-scheme: Light)", false], + ] as const)( + "with a %p preference, %s matches: %s", + (scheme, condition, expected) => { + act(() => { + colorScheme.set(scheme); + }); + expect(mediaMatches(condition)).toBe(expected); + }, + ); +}); + +test.each([ + ["not (width: 10em)", false], + ["not (width: 10vw)", false], + ["(width: 10vw) or (width > 0px)", true], + ["not (min-width: calc(10em + 1vw))", false], + ["(min-width: calc(10em + 1vw)) or (width > 0px)", true], +])( + "@media %s, whose length the runtime does not resolve, matches: %s", + (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }, +); + +// No browser takes env() as a feature's value, so a range pair with an env() bound is unknown whatever its other bound answers (MQ5 §3.1) +test.each([ + ["not (env(safe-area-inset-top) < width < 800px)", 900], + ["not (400px < width < env(safe-area-inset-bottom))", 300], + ["not ((env(safe-area-inset-top) < width < 800px) or (width < 0px))", 900], +])("@media %s on a %ipx-wide screen matches: false", (condition, width) => { + expect(mediaMatches(condition, { width, height: 400 })).toBe(false); +}); + +// MQ5 §3.1: a bound the compiler cannot resolve leaves its comparison unknown, so the other bound alone can make a range pair false +test.each([ + [900, true], + [600, false], +])( + "@media not (1in < width < 800px) on a %ipx-wide screen matches: %s", + (width, expected) => { + expect( + mediaMatches("not (1in < width < 800px)", { width, height: 400 }), + ).toBe(expected); + }, +); + +// MQ5 §7.1, §7.2: pointer and hover are the primary input's, and a touchscreen cannot hover and points coarsely +describe.each([ + ["ios", "none", "coarse"], + ["android", "none", "coarse"], + ["macos", "hover", "fine"], + ["windows", "hover", "fine"], +] as const)( + "on %s the primary input answers hover as %s and pointer as %s", + (os, hover, pointer) => { + let platform: jest.ReplaceProperty; + + beforeEach(() => { + platform = jest.replaceProperty(Platform, "OS", os); + }); + + afterEach(() => { + platform.restore(); + }); + + test.each([ + ["(hover: hover)", hover === "hover"], + ["(hover: none)", hover === "none"], + ["(hover)", hover === "hover"], + ["(any-hover: hover)", hover === "hover"], + ["(any-hover: none)", hover === "none"], + ["(pointer: fine)", pointer === "fine"], + ["(pointer: coarse)", pointer === "coarse"], + ["not (pointer: fine)", pointer === "coarse"], + ["(pointer)", true], + ["(any-pointer: fine)", pointer === "fine"], + ["(any-pointer: coarse)", pointer === "coarse"], + ["(any-pointer: none)", false], + ["(any-pointer)", true], + ["(hover: none) and (pointer: coarse)", hover === "none"], + ])("%s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + }, +); + +// MQ5 §7.1: a TV's remote is no pointing device, and Mac Catalyst points with a mouse +describe.each([ + ["a TV", "none", "none", { isTV: true, isMacCatalyst: false }], + ["Mac Catalyst", "hover", "fine", { isTV: false, isMacCatalyst: true }], +] as const)( + "on %s the primary input answers hover as %s and pointer as %s", + (_, hover, pointer, flags) => { + beforeEach(() => { + jest.spyOn(Platform, "isTV", "get").mockReturnValue(flags.isTV); + if (Platform.OS !== "ios") { + throw new Error("the suite runs on the iOS preset"); + } + jest + .spyOn(Platform, "isMacCatalyst", "get") + .mockReturnValue(flags.isMacCatalyst); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each([ + ["(hover: hover)", hover === "hover"], + ["(hover: none)", hover === "none"], + ["(any-hover: none)", hover === "none"], + ["(pointer: fine)", pointer === "fine"], + ["(pointer: coarse)", false], + ["(pointer: none)", pointer === "none"], + ["(pointer)", pointer !== "none"], + ["(any-pointer: none)", pointer === "none"], + ["(any-pointer: fine)", pointer === "fine"], + ["(any-pointer)", pointer !== "none"], + ])("%s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + }, +); + +// React Native's hover flag sends a hover for a finger too, and a hover does not change the primary input +describe("on iOS, a hover event", () => { + test.each([ + ["(hover: hover)", false, false], + ["(hover: none)", true, true], + ["(any-hover: hover)", false, false], + ["(pointer: fine)", false, false], + ["(pointer: coarse)", true, true], + ["(any-pointer: fine)", false, false], + ])( + "leaves %s matching %s before and %s after", + (condition, before, after) => { + registerCSS(` + .target:hover { opacity: 0.5; } + .child { width: 10px; } + @media ${condition} { .child { width: 37px; } } + `); + render( + <> + + + , + ); + const widthBefore = screen.getByTestId("child").props.style.width; + + fireEvent(screen.getByTestId("target"), "hoverIn"); + + expect({ + before: widthBefore === 37, + after: screen.getByTestId("child").props.style.width === 37, + }).toStrictEqual({ before, after }); + }, + ); +}); + +describe("@media on a 400x600 screen", () => { + const portrait = { width: 400, height: 600 }; + + // css-values-4 §4.1: a keyword matches in any case + test.each([ + ["(orientation: portrait)", true], + ["(orientation: PORTRAIT)", true], + ["not (orientation: PORTRAIT)", false], + ["(orientation: Landscape)", false], + ])("the keyword in %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition, portrait)).toBe(expected); + }); +}); + +describe("@container on a 400x200 container", () => { + test.each([ + ["(300px < width < 500px)", true], + ["(400px < width < 500px)", false], + ["(500px > width > 300px)", true], + ["(100px < height < 300px)", true], + ])("a range pair %s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); + + // CSS Conditional 5 §5.4: an unknown size feature or an unresolvable value selects no container + test.each([ + "(fictional-feature > 10px)", + "not (fictional-feature > 10px)", + "(fictional-feature > 10px) or (width > 0px)", + "(width > env(safe-area-inset-top))", + "not (width > env(safe-area-inset-top))", + "(env(safe-area-inset-top) < width < 500px)", + "not (env(safe-area-inset-top) < width < 500px)", + "(orientation: bogus)", + "not (orientation: bogus)", + ])("%s selects no container", (condition) => { + expect(containerMatches(condition)).toBe(false); + }); + + // em is relative to the container's font size, which the runtime does not read + test.each([ + ["not (width > 10em)", false], + ["(width > 10em) or (width > 0px)", true], + ])("%s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); + + test.each([ + ["(width)", true], + ["(orientation)", true], + ["(aspect-ratio)", true], + ["not (width)", false], + ])("the boolean context %s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); + + // css-values-4 §4.1: a keyword matches in any case + test.each([ + ["(orientation: LANDSCAPE)", true], + ["not (orientation: LANDSCAPE)", false], + ["(orientation: Portrait)", false], + ])("the keyword in %s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); + + test("(width) does not match a container of zero width", () => { + expect(containerMatches("(width)", { width: 0, height: 200 })).toBe(false); + }); + + // A length the runtime cannot resolve, such as one that needs a font's metrics, leaves its term unknown, and an `or` keeps its other terms + test.each([ + ["(width > 10ch) or (width > 100px)", true], + ["not (width > 10ch)", false], + ])("%s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); +}); + +test("a decided conjunction reads only the term that decided it", () => { + const read: unknown[] = []; + const get: Getter = (observable) => { + read.push(observable); + return observable.get(); + }; + act(() => { + dimensions.set({ ...dimensions.get(), width: 600, height: 400 }); + }); + + expect( + testMediaQuery( + [ + [ + "&", + [ + [">=", "width", 9999], + ["=", "prefers-color-scheme", "dark"], + ], + ], + ], + get, + ), + ).toBe(false); + expect(read).toStrictEqual([vw]); +}); + +test("a container feature the runtime does not measure applies nowhere, negated or not", () => { + const get: Getter = (observable) => observable.get(); + const containers = { [DEFAULT_CONTAINER_NAME]: { key: {}, props: {} } }; + + expect( + testContainerQuery( + { m: [">", "fictional-feature", 10] }, + containers, + [], + get, + ), + ).toBe(false); + expect( + testContainerQuery( + { m: ["!", [">", "fictional-feature", 10]] }, + containers, + [], + get, + ), + ).toBe(false); +}); + +test("an unknown term applies nowhere, negated or not", () => { + const get: Getter = (observable) => observable.get(); + + expect(testMediaQuery([["?"]], get)).toBe(false); + expect(testMediaQuery([["!", ["?"]]], get)).toBe(false); + expect(testMediaQuery([["|", [["?"], [">=", "width", 0]]]], get)).toBe(true); +}); diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index 3ea60394..23c09ff5 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -29,7 +29,7 @@ test("Unnamed containers", () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(child).toHaveStyle(undefined); + expect(child).not.toHaveStyle({ color: "#fff" }); // Jest does not fire layout events, so we need to manually fireEvent(parent, "layout", { diff --git a/src/__tests__/native/container-rejection.test.tsx b/src/__tests__/native/container-rejection.test.tsx index 6308af19..2da5b872 100644 --- a/src/__tests__/native/container-rejection.test.tsx +++ b/src/__tests__/native/container-rejection.test.tsx @@ -10,7 +10,6 @@ test.each([ "(width > 100px) and style(--theme: dark)", "style(--theme: dark) and (width > 100px)", "style(--theme: dark) or style(--theme: light)", - "not ((width > 100px) and style(--theme: dark))", ])("unsupported container predicate does not apply: %s", (condition) => { registerCSS(` .container { container-type: inline-size; } @@ -30,6 +29,32 @@ test.each([ } }); +test("negated conjunction applies only where its size condition decides it", () => { + registerCSS(` + .container { container-type: inline-size; } + .child { width: 10px; } + @container not ((width > 100px) and style(--theme: dark)) { + .child { width: 37px; } + } + `); + render( + + + , + ); + // Below 100px the conjunction is false whatever style() answers, so its negation is true + for (const [width, expected] of [ + [200, 10], + [50, 37], + [200, 10], + ]) { + fireEvent(screen.getByTestId("parent"), "layout", { + nativeEvent: { layout: { width, height: 200 } }, + }); + expect(screen.getByTestId("child")).toHaveStyle({ width: expected }); + } +}); + test("supported disjunction retains its size condition and responds to resizing", () => { registerCSS(` .container { container-type: inline-size; } diff --git a/src/__tests__/native/kleene.test.ts b/src/__tests__/native/kleene.test.ts new file mode 100644 index 00000000..1f1f4ff9 --- /dev/null +++ b/src/__tests__/native/kleene.test.ts @@ -0,0 +1,40 @@ +import { + and, + matches, + negate, + or, + UNKNOWN, +} from "../../native/conditions/kleene"; + +test.each([ + [true, false], + [false, true], + [UNKNOWN, UNKNOWN], +] as const)("not %s is %s", (value, expected) => { + expect(negate(value)).toBe(expected); +}); + +test.each([ + [true, true], + [false, false], + [UNKNOWN, false], +] as const)("a rule applies on %s: %s", (value, expected) => { + expect(matches(value)).toBe(expected); +}); + +test.each([ + [true, true, true, true], + [true, false, false, true], + [true, UNKNOWN, UNKNOWN, true], + [false, false, false, false], + [false, UNKNOWN, false, UNKNOWN], + [UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN], +] as const)( + "%s with %s: and is %s, or is %s", + (left, right, conjunction, disjunction) => { + expect(and(left, right)).toBe(conjunction); + expect(and(right, left)).toBe(conjunction); + expect(or(left, right)).toBe(disjunction); + expect(or(right, left)).toBe(disjunction); + }, +); diff --git a/src/__tests__/vendor/tailwind/container-queries.test.tsx b/src/__tests__/vendor/tailwind/container-queries.test.tsx index d67b9b0c..2be56a4f 100644 --- a/src/__tests__/vendor/tailwind/container-queries.test.tsx +++ b/src/__tests__/vendor/tailwind/container-queries.test.tsx @@ -16,7 +16,7 @@ test("Unnamed containers", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(child).toHaveStyle(undefined); + expect(child).not.toHaveStyle({ color: "#fff" }); // Jest does not fire layout events, so we need to manually fireEvent(parent, "layout", { @@ -41,7 +41,7 @@ test("Named containers", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(child).toHaveStyle(undefined); + expect(child).not.toHaveStyle({ color: "#fff" }); fireEvent(parent, "layout", { nativeEvent: { diff --git a/src/__tests__/vendor/tailwind/groups.test.tsx b/src/__tests__/vendor/tailwind/groups.test.tsx index f3e2e84e..777c8236 100644 --- a/src/__tests__/vendor/tailwind/groups.test.tsx +++ b/src/__tests__/vendor/tailwind/groups.test.tsx @@ -1,3 +1,5 @@ +import { Platform } from "react-native"; + import { fireEvent, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components"; @@ -7,49 +9,75 @@ const grandparentID = "grandparentID"; const parentID = "parent"; const childID = "child"; -test("Styling based on parent state (group-{modifier})", async () => { - await render( - - - , - ); +describe("on a primary input that hovers", () => { + beforeEach(() => { + if (Platform.OS !== "ios") { + throw new Error("the suite runs on the iOS preset"); + } + jest.spyOn(Platform, "isMacCatalyst", "get").mockReturnValue(true); + }); - const parent = screen.getByTestId(parentID); - const child = screen.getByTestId(childID); + afterEach(() => { + jest.restoreAllMocks(); + }); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + test("Styling based on parent state (group-{modifier})", async () => { + await render( + + + , + ); - fireEvent(parent, "hoverIn"); + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); - expect(child).toHaveStyle({ color: "#fff" }); -}); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); -test("Differentiating nested groups", async () => { - await render( - - - - - - , - ); + fireEvent(parent, "hoverIn"); - const grandparent = screen.getByTestId(grandparentID); - const parent = screen.getByTestId(parentID); - const child = screen.getByTestId(childID); + expect(child).toHaveStyle({ color: "#fff" }); + }); - expect(grandparent).toHaveStyle(undefined); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + test("Differentiating nested groups", async () => { + await render( + + + + + + , + ); - fireEvent(grandparent, "hoverIn"); + const grandparent = screen.getByTestId(grandparentID); + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); - expect(child).toHaveStyle(undefined); + expect(grandparent).not.toHaveStyle({ color: "#fff" }); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); - fireEvent(parent, "hoverIn"); + fireEvent(grandparent, "hoverIn"); - expect(child).toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); + + fireEvent(parent, "hoverIn"); + + expect(child).toHaveStyle({ color: "#fff" }); + }); +}); + +// Tailwind's group-hover: holds only where the primary input can hover, which a touchscreen cannot +test("group-hover does not apply on a touchscreen", async () => { + await render( + + + , + ); + + fireEvent(screen.getByTestId(parentID), "hoverIn"); + + expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#fff" }); }); test("arbitrary groups - single className", async () => { @@ -62,8 +90,8 @@ test("arbitrary groups - single className", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); rerender( @@ -84,8 +112,8 @@ test("arbitrary groups - multiple className", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); rerender( @@ -93,8 +121,8 @@ test("arbitrary groups - multiple className", async () => { , ); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); rerender( @@ -118,8 +146,8 @@ test("arbitrary groups - props", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); rerender( diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index c4abd1e0..e941a557 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -1,3 +1,5 @@ +import { Platform } from "react-native"; + import { fireEvent, screen } from "@testing-library/react-native"; import { Switch, TextInput, View } from "react-native-css/components"; @@ -5,18 +7,59 @@ import { render } from "./_tailwind"; const testID = "component"; -test("hover", async () => { - await render(); +describe("on a primary input that hovers", () => { + beforeEach(() => { + if (Platform.OS !== "ios") { + throw new Error("the suite runs on the iOS preset"); + } + jest.spyOn(Platform, "isMacCatalyst", "get").mockReturnValue(true); + }); - const component = screen.getByTestId(testID); + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("hover", async () => { + await render(); + + const component = screen.getByTestId(testID); + + expect(component).not.toHaveStyle({ color: "#fff" }); + + fireEvent(component, "hoverIn"); + expect(component).toHaveStyle({ color: "#fff" }); + + fireEvent(component, "hoverOut"); + expect(component).not.toHaveStyle({ color: "#fff" }); + }); + + test("mixed", async () => { + await render( + , + ); + + const component = screen.getByTestId(testID); + expect(component).not.toHaveStyle({ color: "#fff" }); + + fireEvent(component, "pressIn"); + expect(component).not.toHaveStyle({ color: "#fff" }); + + fireEvent(component, "hoverIn"); + expect(component).not.toHaveStyle({ color: "#fff" }); + + fireEvent(component, "focus"); + expect(component).toHaveStyle({ color: "#fff" }); + }); +}); - expect(component).toHaveStyle(undefined); +// Tailwind's hover: holds only where the primary input can hover, which a touchscreen cannot +test("hover does not apply on a touchscreen", async () => { + await render(); + const component = screen.getByTestId(testID); fireEvent(component, "hoverIn"); - expect(component).toHaveStyle({ color: "#fff" }); - fireEvent(component, "hoverOut"); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); }); test("focus", async () => { @@ -24,13 +67,13 @@ test("focus", async () => { const component = screen.getByTestId(testID); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); fireEvent(component, "focus"); expect(component).toHaveStyle({ color: "#fff" }); fireEvent(component, "blur"); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); }); test("active", async () => { @@ -38,31 +81,13 @@ test("active", async () => { const component = screen.getByTestId(testID); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); fireEvent(component, "pressIn"); expect(component).toHaveStyle({ color: "#fff" }); fireEvent(component, "pressOut"); - expect(component).toHaveStyle(undefined); -}); - -test("mixed", async () => { - await render( - , - ); - - const component = screen.getByTestId(testID); - expect(component).toHaveStyle(undefined); - - fireEvent(component, "pressIn"); - expect(component).toHaveStyle(undefined); - - fireEvent(component, "hoverIn"); - expect(component).toHaveStyle(undefined); - - fireEvent(component, "focus"); - expect(component).toHaveStyle({ color: "#fff" }); + expect(component).not.toHaveStyle({ color: "#fff" }); }); test("selection", async () => { diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..2fb9986d 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -36,7 +36,7 @@ import { import { inlineVariables } from "./inline-variables"; import { extractKeyFrames } from "./keyframes"; import { lightningcssLoader } from "./lightningcss-loader"; -import { parseMediaQuery } from "./media-query"; +import { canMatch, parseMediaQuery } from "./media-query"; import { StylesheetBuilder } from "./stylesheet"; import { supportsConditionValid } from "./supports"; @@ -404,7 +404,7 @@ function extractContainer( // Iterate over all rules inside the containerRule and extract their styles using the updated CompilerCollection const condition = parseContainerCondition(containerRule.condition, builder); - if (!condition) { + if (!condition || !canMatch(condition)) { // An unsupported condition must not turn into an unconditional container. return; } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..00de1161 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -174,30 +174,35 @@ export type AnimationKeyframes = [string | number, StyleDeclaration[]]; /****************************** Conditions ******************************/ export type MediaCondition = + // Unknown, as a container style() query is to the runtime + | ["?"] // Boolean | ["!!", MediaFeatureNameFor_MediaFeatureId] // Not | ["!", MediaCondition] // And - | ["&", MediaCondition[]] + | ["&", readonly MediaCondition[]] // Or - | ["|", MediaCondition[]] + | ["|", readonly MediaCondition[]] // Comparison | [ MediaFeatureComparison, MediaFeatureNameFor_MediaFeatureId | "dir", - StyleDescriptor, + MediaFeatureOperand, ] // [Start, End] | [ "[]", MediaFeatureNameFor_MediaFeatureId, - StyleDescriptor, // Start + MediaFeatureOperand, // Start MediaFeatureComparison, // Start comparison - StyleDescriptor, // End + MediaFeatureOperand, // End MediaFeatureComparison, // End comparison ]; +// JSON writes undefined in an array as null, so null is what a device reads for a value the compiler could not resolve +export type MediaFeatureOperand = Exclude | null; + export type MediaFeatureComparison = "=" | ">" | ">=" | "<" | "<="; export interface PseudoClassesQuery { diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index 4d525493..3a71dae8 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -1,52 +1,46 @@ -/* eslint-disable */ import type { + ContainerSizeFeatureId, ContainerCondition as CSSContainerCondition, - QueryFeatureFor_ContainerSizeFeatureId, } from "lightningcss"; import type { MediaCondition } from "./compiler.types"; -import { - parseMediaFeatureOperator, - parseMediaFeatureValue, -} from "./media-query"; +import { parseQueryFeature } from "./media-query"; import type { StylesheetBuilder } from "./stylesheet"; -export function parseContainerCondition( - condition: CSSContainerCondition, - builder: StylesheetBuilder, -) { - let containerQuery = parseContainerQueryCondition(condition, builder); - - // If any of these are undefined, the media query is invalid - if (!containerQuery || containerQuery.some((v) => v === undefined)) { - return; - } +const SIZE_FEATURES: ReadonlySet = new Set([ + "width", + "height", + "inline-size", + "block-size", + "aspect-ratio", + "orientation", +]); - return containerQuery; -} - -function parseContainerQueryCondition( +// Chromium and WebKit drop a container query holding an unknown size feature, a keyword its feature does not take or env(), so it compiles to nothing; any other value the runtime cannot resolve leaves its feature unknown (CSS Conditional 5 §5.4) +export function parseContainerCondition( condition: CSSContainerCondition, builder: StylesheetBuilder, ): MediaCondition | undefined { switch (condition.type) { case "feature": - return parseFeature(condition.value, builder); - case "not": + return SIZE_FEATURES.has(condition.value.name) + ? parseQueryFeature(condition.value, builder) + : undefined; + case "not": { const query = parseContainerCondition(condition.value, builder); return query ? ["!", query] : undefined; - case "operation": - const parsed = condition.conditions.map((c) => - parseContainerCondition(c, builder), - ); - if (condition.operator === "and" && parsed.some((c) => !c)) { - // Dropping a conjunct would broaden the query to unrelated containers. - return; - } - const conditions = parsed.filter((c): c is MediaCondition => !!c); + } + case "operation": { + const conditions: MediaCondition[] = []; + + for (const child of condition.conditions) { + const query = parseContainerCondition(child, builder); - if (conditions.length === 0) { - return; + if (!query) { + return; + } + + conditions.push(query); } switch (condition.operator) { @@ -58,45 +52,11 @@ function parseContainerQueryCondition( condition.operator satisfies never; return; } + } case "style": - // We don't support these yet - return; + return ["?"]; default: condition satisfies never; return; } } - -function parseFeature( - feature: QueryFeatureFor_ContainerSizeFeatureId, - builder: StylesheetBuilder, -): MediaCondition | undefined { - switch (feature.type) { - case "boolean": - return ["!!", feature.name]; - case "plain": - return [ - "=", - feature.name, - parseMediaFeatureValue(feature.value, builder), - ]; - case "range": - return [ - parseMediaFeatureOperator(feature.operator), - feature.name, - parseMediaFeatureValue(feature.value, builder), - ]; - case "interval": - return [ - "[]", - feature.name, - parseMediaFeatureValue(feature.start, builder), - parseMediaFeatureOperator(feature.startOperator), - parseMediaFeatureValue(feature.end, builder), - parseMediaFeatureOperator(feature.endOperator), - ]; - default: - feature satisfies never; - return; - } -} diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 1408adc0..11bd1cfa 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -1,16 +1,18 @@ -/* eslint-disable */ import type { CalcFor_Length, MediaCondition as CSSMediaCondition, MediaFeatureComparison as CSSMediaFeatureComparison, MediaFeatureValue as CSSMediaFeatureValue, MediaQuery as CSSMediaQuery, + QueryFeatureFor_ContainerSizeFeatureId, QueryFeatureFor_MediaFeatureId, } from "lightningcss"; +import { asciiLowerCase } from "../utilities/ascii-case"; import type { MediaCondition, MediaFeatureComparison, + MediaFeatureOperand, StyleDescriptor, } from "./compiler.types"; import { parseLength } from "./declarations"; @@ -32,23 +34,19 @@ export function parseMediaQuery( // These all/screen are not conditions, they always apply if (query.mediaType !== "all" && query.mediaType !== "screen") { - platformCondition = ["=", "platform", query.mediaType]; + // Media types are ASCII case-insensitive (MQ4 §3), and lightningcss lowercases only all, print and screen + platformCondition = ["=", "platform", asciiLowerCase(query.mediaType)]; } } if (query.condition) { condition = parseMediaQueryCondition(query.condition, builder); - - // If any of these are undefined, the media query is invalid - if (!condition || condition.some((v) => v === undefined)) { - return false; - } } let mediaQuery: MediaCondition | undefined = platformCondition && condition ? ["&", [platformCondition, condition]] - : platformCondition || condition; + : (platformCondition ?? condition); if (!mediaQuery) { // Unqualified all/screen apply; not all and not screen do not. @@ -59,36 +57,69 @@ export function parseMediaQuery( mediaQuery = ["!", mediaQuery]; } + if (!canMatch(mediaQuery)) { + return false; + } + builder.addMediaQuery(mediaQuery); return true; } -function parseMediaQueryCondition( +// A condition its unknown terms and unresolved operands leave no way to be true compiles to no rule, as an unknown keeps an `and` or a `not` unknown (MQ5 §3.1) +export function canMatch(condition: Readonly): boolean { + return canEvaluateTo(condition, true); +} + +function canEvaluateTo( + condition: Readonly, + truth: boolean, +): boolean { + switch (condition[0]) { + case "?": + return false; + case "!": + return canEvaluateTo(condition[1], !truth); + case "&": + return truth + ? condition[1].every((child) => canEvaluateTo(child, true)) + : condition[1].some((child) => canEvaluateTo(child, false)); + case "|": + return truth + ? condition[1].some((child) => canEvaluateTo(child, true)) + : condition[1].every((child) => canEvaluateTo(child, false)); + case "!!": + return true; + case "[]": { + const [, , start, , end] = condition; + return truth + ? start !== null && end !== null + : start !== null || end !== null; + } + case "=": + case ">": + case ">=": + case "<": + case "<=": + return condition[2] !== null; + default: + condition satisfies never; + return true; + } +} + +export function parseMediaQueryCondition( query: CSSMediaCondition, builder: StylesheetBuilder, -): MediaCondition | undefined { +): MediaCondition { switch (query.type) { - case "feature": { - const feature = parseFeature(query.value, builder); - return feature?.some((value) => value === undefined) - ? undefined - : feature; - } + case "feature": + return parseQueryFeature(query.value, builder) ?? ["?"]; case "not": - const mediaQuery = parseMediaQueryCondition(query.value, builder); - return mediaQuery ? ["!", mediaQuery] : undefined; - case "operation": - const parsed = query.conditions.map((c) => - parseMediaQueryCondition(c, builder), + return ["!", parseMediaQueryCondition(query.value, builder)]; + case "operation": { + const mediaQueries = query.conditions.map((condition) => + parseMediaQueryCondition(condition, builder), ); - if (query.operator === "and" && parsed.some((c) => !c)) { - return; - } - const mediaQueries = parsed.filter((v): v is MediaCondition => !!v); - - if (mediaQueries.length === 0) { - return; - } switch (query.operator) { case "and": @@ -97,56 +128,104 @@ function parseMediaQueryCondition( return ["|", mediaQueries]; default: query.operator satisfies never; - return; + return ["?"]; } + } default: query satisfies never; + // A lightningcss newer than its types emits conditions they do not list, such as error recovery's , which MQ5 §3.1 leaves unknown + return ["?"]; } +} - return; +// The keywords Media Queries 4 and 5 give these discrete features, and any other keyword leaves the feature unknown (MQ5 §3.1); display-mode is left out, since the library answers it with platform names +const FEATURE_KEYWORDS: ReadonlyMap = new Map([ + ["hover", ["none", "hover"]], + ["any-hover", ["none", "hover"]], + ["pointer", ["none", "coarse", "fine"]], + ["any-pointer", ["none", "coarse", "fine"]], + ["orientation", ["portrait", "landscape"]], + ["prefers-color-scheme", ["light", "dark"]], + ["prefers-reduced-motion", ["no-preference", "reduce"]], + ["scripting", ["none", "initial-only", "enabled"]], + ["update", ["none", "slow", "fast"]], +]); + +function isUnknownKeyword( + name: string, + operand: Readonly, +) { + const keywords = FEATURE_KEYWORDS.get(name); + return ( + keywords !== undefined && + typeof operand === "string" && + !keywords.includes(operand) + ); } -function parseFeature( - feature: QueryFeatureFor_MediaFeatureId, +// A feature holding env() or a keyword it does not take compiles to undefined, which a media query reads as unknown and a container query as no query +export function parseQueryFeature( + feature: + | QueryFeatureFor_MediaFeatureId + | QueryFeatureFor_ContainerSizeFeatureId, builder: StylesheetBuilder, ): MediaCondition | undefined { switch (feature.type) { case "boolean": return ["!!", feature.name]; - case "plain": - return [ - "=", - feature.name, - parseMediaFeatureValue(feature.value, builder), - ]; - case "range": - return [ - parseMediaFeatureOperator(feature.operator), - feature.name, - parseMediaFeatureValue(feature.value, builder), - ]; - case "interval": - return [ - "[]", - feature.name, - parseMediaFeatureValue(feature.start, builder), - parseMediaFeatureOperator(feature.startOperator), - parseMediaFeatureValue(feature.end, builder), - parseMediaFeatureOperator(feature.endOperator), - ]; + case "plain": { + const operand = parseFeatureOperand(feature.value, builder); + return operand === undefined || isUnknownKeyword(feature.name, operand) + ? undefined + : ["=", feature.name, operand]; + } + case "range": { + const operand = parseFeatureOperand(feature.value, builder); + return operand === undefined + ? undefined + : [parseMediaFeatureOperator(feature.operator), feature.name, operand]; + } + case "interval": { + const start = parseFeatureOperand(feature.start, builder); + const end = parseFeatureOperand(feature.end, builder); + return start === undefined || end === undefined + ? undefined + : [ + "[]", + feature.name, + start, + parseMediaFeatureOperator(feature.startOperator), + end, + parseMediaFeatureOperator(feature.endOperator), + ]; + } default: feature satisfies never; + return undefined; } - return; } -export function parseMediaFeatureValue( +// No browser takes env() as a media feature's value, and Chromium and WebKit drop a container query holding one, so a feature holding it compiles to undefined; any other value the compiler cannot resolve compiles to null, which leaves its term unknown +function parseFeatureOperand( + value: CSSMediaFeatureValue, + builder: StylesheetBuilder, +): MediaFeatureOperand | undefined { + return value.type === "env" + ? undefined + : (parseMediaFeatureValue(value, builder) ?? null); +} + +function parseMediaFeatureValue( value: CSSMediaFeatureValue, builder: StylesheetBuilder, ): StyleDescriptor { switch (value.type) { + // is the integer 0 or 1 (MQ4 §5.3) case "boolean": + return value.value ? 1 : 0; case "ident": + // Keywords are ASCII case-insensitive (css-values-4 §4.1) + return asciiLowerCase(value.value); case "integer": case "number": return value.value; @@ -176,12 +255,14 @@ export function parseMediaFeatureValue( } case "ratio": case "env": + return undefined; + default: + value satisfies never; + return undefined; } - - return; } -export function parseMediaFeatureOperator( +function parseMediaFeatureOperator( operator: CSSMediaFeatureComparison, ): MediaFeatureComparison { switch (operator) { @@ -197,7 +278,9 @@ export function parseMediaFeatureOperator( return "<="; default: operator satisfies never; - throw new Error(`Unknown MediaFeatureComparison operator ${operator}`); + throw new Error( + `Unknown MediaFeatureComparison operator ${JSON.stringify(operator)}`, + ); } } diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts new file mode 100644 index 00000000..eccbdc03 --- /dev/null +++ b/src/native/conditions/compare.ts @@ -0,0 +1,151 @@ +import type { + MediaCondition, + MediaFeatureComparison, + MediaFeatureOperand, +} from "react-native-css/compiler"; + +import { and, negate, or, UNKNOWN, type Truth } from "./kleene"; + +export type MediaInterval = Extract; + +export type MediaComparison = Extract< + MediaCondition, + [MediaFeatureComparison, ...unknown[]] +>; + +export type MediaFeatureName = MediaComparison[1]; + +// undefined for a feature the runtime does not measure +export type MeasuredFeatureValue = number | string | undefined; + +// A media query and a container query differ only in how a feature is read and compared; the context is what a reading needs, passed along so an evaluation allocates nothing +export interface FeatureReader { + readonly read: ( + name: MediaFeatureName, + context: Context, + ) => MeasuredFeatureValue; + readonly compare: ( + comparison: Readonly, + context: Context, + ) => Truth; +} + +export function evaluateCondition( + condition: Readonly, + reader: FeatureReader, + context: Context, +): Truth { + switch (condition[0]) { + case "?": + return UNKNOWN; + case "!!": + return isTruthyFeatureValue(reader.read(condition[1], context)); + case "[]": + return testMediaFeatureInterval( + condition, + reader.read(condition[1], context), + ); + case "!": + return negate(evaluateCondition(condition[1], reader, context)); + // Evaluating a term subscribes to what it reads, so a decided conjunction or disjunction evaluates no further terms + case "&": { + let truth: Truth = true; + for (const term of condition[1]) { + truth = and(truth, evaluateCondition(term, reader, context)); + if (truth === false) { + return false; + } + } + return truth; + } + case "|": { + let truth: Truth = false; + for (const term of condition[1]) { + truth = or(truth, evaluateCondition(term, reader, context)); + if (truth === true) { + return true; + } + } + return truth; + } + case ">": + case ">=": + case "<": + case "<=": + case "=": + return reader.compare(condition, context); + default: + condition satisfies never; + return UNKNOWN; + } +} + +// Media and container queries share this, so each operator has one meaning; an unmeasured feature or an unresolved operand is unknown (MQ5 §3.1) +export function compareMediaFeature( + operator: MediaFeatureComparison, + left: Readonly | undefined, + right: Readonly | undefined, +): Truth { + if ( + left === undefined || + left === null || + right === undefined || + right === null + ) { + return UNKNOWN; + } + + // A descriptor the runtime does not resolve, such as an em or a calc(), has no value to compare with + if (typeof left === "object" || typeof right === "object") { + return UNKNOWN; + } + + // = also compares keywords such as landscape + if (operator === "=") { + return left === right; + } + + if (typeof left !== "number" || typeof right !== "number") { + return UNKNOWN; + } + + switch (operator) { + case ">": + return left > right; + case ">=": + return left >= right; + case "<": + return left < right; + case "<=": + return left <= right; + default: + operator satisfies never; + return UNKNOWN; + } +} + +// The compiler emits a range pair in source order: start, operator, value, operator, end; it is read by index, since Babel compiles a destructured tuple to a helper call that costs more than the comparisons +export function testMediaFeatureInterval( + condition: Readonly, + value: MeasuredFeatureValue, +): Truth { + return and( + compareMediaFeature(condition[3], condition[2], value), + compareMediaFeature(condition[5], value, condition[4]), + ); +} + +// A feature in a boolean context is false for zero, none and the no-preference of a user preference, and true for every other value (MQ5 §2.4.2, §12) +export function isTruthyFeatureValue(value: MeasuredFeatureValue): Truth { + switch (typeof value) { + case "undefined": + return UNKNOWN; + case "number": + return value !== 0 && !Number.isNaN(value); + case "string": + return value !== "none" && value !== "no-preference"; + default: + value satisfies never; + return UNKNOWN; + } +} diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index e25df564..c4ba97b7 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -1,10 +1,7 @@ -/* eslint-disable */ -import type { MediaFeatureNameFor_ContainerSizeFeatureId } from "lightningcss"; import type { ContainerQuery, MediaCondition, PseudoClassesQuery, - StyleDescriptor, } from "react-native-css/compiler"; import { @@ -17,7 +14,15 @@ import { type Getter, } from "../reactivity"; import { testAttributes } from "./attributes"; +import { + compareMediaFeature, + evaluateCondition, + type FeatureReader, + type MeasuredFeatureValue, + type MediaFeatureName, +} from "./compare"; import type { RenderGuard } from "./guards"; +import { matches, type Truth } from "./kleene"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -39,7 +44,7 @@ export function testContainerQuery( get: Getter, ): boolean { const name = query.n ?? DEFAULT_CONTAINER_NAME; - const container = inheritedContainers[name]!; + const container = inheritedContainers[name]; guards.push(["c", name, container]); @@ -53,7 +58,10 @@ export function testContainerQuery( return false; } - if (query.m && !testContainerMediaCondition(query.m, container.key, get)) { + if ( + query.m && + !matches(testContainerMediaCondition(query.m, container.key, get)) + ) { return false; } @@ -82,66 +90,37 @@ function testContainerPseudoCondition( } function testContainerMediaCondition( - condition: MediaCondition, + condition: Readonly, containerKey: WeakKey, get: Getter, -): boolean { - switch (condition[0]) { - case "!": - return !testContainerMediaCondition(condition[1], containerKey, get); - case "&": - return condition[1].every((query) => { - return testContainerMediaCondition(query, containerKey, get); - }); - case "|": - return condition[1].some((query) => { - return testContainerMediaCondition(query, containerKey, get); - }); - case "!!": - return false; - case "[]": - return false; - case ">": - case ">=": - case "<": - case "<=": - case "=": { - const left = getContainerFeatureValue(condition[1], containerKey, get); - const right = condition[2]; - - if (condition[0] === "=") { - return left === right; - } - - if (typeof left !== "number" || typeof right !== "number") { - return false; - } +): Truth { + return evaluateCondition(condition, CONTAINER_FEATURES, { + containerKey, + get, + }); +} - switch (condition[0]) { - case ">": - return left > right; - case ">=": - return left >= right; - case "<": - return left < right; - case "<=": - return left <= right; - default: - condition[0] satisfies never; - return false; - } - } - default: - condition satisfies never; - return false; - } +interface ContainerFeatureContext { + readonly containerKey: WeakKey; + readonly get: Getter; } +const CONTAINER_FEATURES: FeatureReader = { + read: (name, { containerKey, get }) => + getContainerFeatureValue(name, containerKey, get), + compare: (comparison, { containerKey, get }) => + compareMediaFeature( + comparison[0], + getContainerFeatureValue(comparison[1], containerKey, get), + comparison[2], + ), +}; + function getContainerFeatureValue( - name: MediaFeatureNameFor_ContainerSizeFeatureId, + name: MediaFeatureName, containerKey: WeakKey, get: Getter, -): StyleDescriptor { +): MeasuredFeatureValue { switch (name) { case "width": return get(containerWidthFamily(containerKey)); @@ -152,13 +131,14 @@ function getContainerFeatureValue( const height = get(containerHeightFamily(containerKey)); return width / height; } - case "orientation": + case "orientation": { const width = get(containerWidthFamily(containerKey)); const height = get(containerHeightFamily(containerKey)); return width > height ? "landscape" : "portrait"; + } case "inline-size": case "block-size": default: - return; + return undefined; } } diff --git a/src/native/conditions/guards.ts b/src/native/conditions/guards.ts index d3c9dd84..f4e6fc64 100644 --- a/src/native/conditions/guards.ts +++ b/src/native/conditions/guards.ts @@ -9,7 +9,7 @@ export type RenderGuard = | ["a", string, any] | ["d", string, any] | ["v", string, any] - | ["c", string, WeakKey]; + | ["c", string, ContainerContextValue[string] | undefined]; export function testGuards( state: ComponentState, diff --git a/src/native/conditions/kleene.ts b/src/native/conditions/kleene.ts new file mode 100644 index 00000000..2b0819d4 --- /dev/null +++ b/src/native/conditions/kleene.ts @@ -0,0 +1,27 @@ +// Kleene three-valued logic, which MQ5 §3.1 gives a condition holding a term the runtime cannot decide +export type Truth = boolean | "unknown"; + +export const UNKNOWN = "unknown"; + +export function negate(value: Truth): Truth { + return value === UNKNOWN ? UNKNOWN : !value; +} + +export function and(left: Truth, right: Truth): Truth { + if (left === false || right === false) { + return false; + } + return left === UNKNOWN || right === UNKNOWN ? UNKNOWN : true; +} + +export function or(left: Truth, right: Truth): Truth { + if (left === true || right === true) { + return true; + } + return left === UNKNOWN || right === UNKNOWN ? UNKNOWN : false; +} + +// A rule applies only on true, so unknown converts to false (MQ5 §3.1) +export function matches(value: Truth): boolean { + return value === true; +} diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..3f2b2266 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,99 +1,130 @@ -/* eslint-disable */ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { + compareMediaFeature, + evaluateCondition, + type FeatureReader, + type MeasuredFeatureValue, + type MediaComparison, + type MediaFeatureName, +} from "./compare"; +import { matches, type Truth } from "./kleene"; -export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { - return mediaQueries.every((query) => test(query, get)); -} +// The color feature counts bits per color component +const BITS_PER_COLOR_COMPONENT = 8; -function test(mediaQuery: MediaCondition, get: Getter): Boolean { - switch (mediaQuery[0]) { - case "[]": - case "!!": +export function testMediaQuery( + mediaQueries: readonly MediaCondition[], + get: Getter, +): boolean { + for (const query of mediaQueries) { + if (!matches(evaluateCondition(query, MEDIA_FEATURES, get))) { return false; - case "!": - return !test(mediaQuery[1], get); - case "&": - return mediaQuery[1].every((query) => { - return test(query, get); - }); - case "|": - return mediaQuery[1].some((query) => { - return test(query, get); - }); - case ">": - case ">=": - case "<": - case "<=": - case "=": { - return testComparison(mediaQuery, get); } } + return true; } -function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { +function testComparison( + mediaQuery: Readonly, + get: Getter, +): Truth { + // Read by index, since Babel compiles a destructured tuple to a helper call that costs more than the comparison + const operator = mediaQuery[0]; + const name = mediaQuery[1]; const value = mediaQuery[2]; - switch (mediaQuery[1]) { + switch (name) { + // dir and platform are this library's conditions rather than media features, so neither has a boolean context case "dir": - return value === (I18nManager.isRTL ? "rtl" : "ltr"); - case "hover": - return true; + return compareMediaFeature( + operator, + I18nManager.isRTL ? "rtl" : "ltr", + value, + ); case "platform": - return value === "native" || value === Platform.OS; - case "prefers-color-scheme": { - return value === get(colorScheme); - } case "display-mode": - return value === "native" || Platform.OS === value; - case "min-width": - return typeof value === "number" && get(vw) >= value; - case "max-width": - return typeof value === "number" && get(vw) <= value; - case "min-height": - return typeof value === "number" && get(vh) >= value; - case "max-height": - return typeof value === "number" && get(vh) <= value; - case "orientation": - return value === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); - } - - if (typeof value !== "number") { - return false; + return ( + value === "native" || compareMediaFeature(operator, Platform.OS, value) + ); + default: + return compareMediaFeature( + operator, + getMediaFeatureValue(name, get), + value, + ); } +} - let left: number | undefined; - const right = value; - - switch (mediaQuery[1]) { +function getMediaFeatureValue( + name: MediaFeatureName, + get: Getter, +): MeasuredFeatureValue { + switch (name) { + case "hover": + case "any-hover": + return PRIMARY_INPUT_FEATURES[primaryInput()].hover; + case "pointer": + case "any-pointer": + return PRIMARY_INPUT_FEATURES[primaryInput()].pointer; + case "display-mode": + return Platform.OS; + case "prefers-color-scheme": + return get(colorScheme) ?? undefined; + case "color": + return BITS_PER_COLOR_COMPONENT; + // A React Native screen is a full-color bitmap that redraws as fast as scripts change it + case "color-index": + case "monochrome": + case "grid": + return 0; + case "scripting": + return "enabled"; + case "update": + return "fast"; case "width": - left = get(vw); - break; + return get(vw); case "height": - left = get(vh); - break; + return get(vh); + // -webkit-device-pixel-ratio is resolution in dppx (https://compat.spec.whatwg.org/#css-media-queries-webkit-device-pixel-ratio) case "resolution": - left = PixelRatio.get(); - break; + case "-webkit-device-pixel-ratio": + return PixelRatio.get(); + case "orientation": + return get(vh) < get(vw) ? "landscape" : "portrait"; default: - return false; + return undefined; } +} - switch (mediaQuery[0]) { - case "=": - return left === right; - case ">": - return left > right; - case ">=": - return left >= right; - case "<": - return left < right; - case "<=": - return left <= right; - default: - return false; +const MEDIA_FEATURES: FeatureReader = { + read: getMediaFeatureValue, + compare: testComparison, +}; + +type PrimaryInput = "touchscreen" | "mouse" | "remote"; + +// A TV's remote moves focus rather than a pointer, and Mac Catalyst runs an iOS app under a mouse +function primaryInput(): PrimaryInput { + if (Platform.isTV) { + return "remote"; + } + + if (Platform.OS === "ios") { + return Platform.isMacCatalyst === true ? "mouse" : "touchscreen"; } + + return Platform.OS === "android" ? "touchscreen" : "mouse"; } + +// MQ5 §7 answers for the primary input, as Safari and Chrome do on a phone or tablet; React Native reports no other input device, so any-pointer and any-hover answer for the primary input too +const PRIMARY_INPUT_FEATURES = { + touchscreen: { pointer: "coarse", hover: "none" }, + mouse: { pointer: "fine", hover: "hover" }, + remote: { pointer: "none", hover: "none" }, +} as const satisfies Readonly< + Record +>; 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()); +}