import { I18nManager, PixelRatio, Platform } from "react-native"; import type { ContainerCondition, Declaration, MediaFeatureComparison, MediaFeatureValue, MediaQuery, QueryFeatureFor_MediaFeatureId, } from "lightningcss"; import { DEFAULT_CONTAINER_NAME } from "../../shared"; import { AttributeCondition, ExtractedContainerQuery, PseudoClassesQuery, StyleRule, } from "../../types"; import { Effect, ReadableObservable } from "../observable"; import { colorScheme, isReduceMotionEnabled } from "./appearance-observables"; import { ReducerTracking, Refs, SharedState } from "./types"; import { rem, vh, vw } from "./unit-observables"; interface ConditionReference { width: number | ReadableObservable; height: number | ReadableObservable; } /** * Tests a rule against current component's state * @param state * @param rule * @param props * @returns */ export function testRule( rule: StyleRule, refs: Refs, tracking: ReducerTracking, ) { // Does the rule pass all the pseudo classes, media queries, and container queries? if ( rule.pseudoClasses && !testPseudoClasses(refs.sharedState, rule.pseudoClasses, tracking) ) { return false; } if (rule.media && !testMediaQueries(refs.sharedState, tracking, rule.media)) { return false; } if ( rule.containerQuery && !testContainerQuery(refs, tracking, rule.containerQuery) ) { return false; } if (rule.attrs && !testAttributes(refs, tracking, rule.attrs)) { return false; } return true; } export function testMediaQueries( state: SharedState, tracking: ReducerTracking, mediaQueries: MediaQuery[], ) { return mediaQueries.every((query) => testMediaQuery(tracking, query)); } /** * Test a media query against current conditions */ export function testMediaQuery( tracking: ReducerTracking, mediaQuery: MediaQuery, conditionReference: ConditionReference = { width: vw, height: vh, }, ) { const pass = mediaQuery.mediaType !== "print" && testCondition(mediaQuery.condition, conditionReference, tracking); return mediaQuery.qualifier === "not" ? !pass : pass; } export function testPseudoClasses( state: SharedState, meta: PseudoClassesQuery, tracking?: ReducerTracking, ) { /* * Fail if any of these conditions fail * State should already have hover,active,focus on it. * If not, there was a problem with the compiler */ let passing = true; if (meta.hover && state.hover && passing) { passing = state.hover.get(tracking?.effect); } if (meta.active && state.active && passing) { passing = state.active.get(tracking?.effect); } if (meta.focus && state.focus && passing) { passing = state.focus.get(tracking?.effect); } return passing; } export function testContainerQuery( refs: Refs, tracking: ReducerTracking, containerQuery: ExtractedContainerQuery[] | undefined, ) { // If there is no query, we passed if (!containerQuery || containerQuery.length === 0) { return true; } return containerQuery.every((query) => { const container = getContainer(query, refs); const result = testContainer(query, container, tracking); // Track this container tracking.guards.push((nextRefs) => { const nextContainer = getContainer(query, nextRefs); const nextResult = testContainer(query, nextContainer); return container !== nextContainer || result !== nextResult; }); return result; }); } function getContainer( query: ExtractedContainerQuery, refs: Refs, ): SharedState | undefined { return query.name ? refs.containers[query.name] : refs.containers[DEFAULT_CONTAINER_NAME]; } function testContainer( query: ExtractedContainerQuery, container?: SharedState, tracking?: ReducerTracking, ) { if (!container) return false; if ( query.pseudoClasses && !testPseudoClasses(container, query.pseudoClasses, tracking) ) { return false; } if ( query.attrs && !testContainerAttributes(container.originalProps, query.attrs) ) { return false; } // If there is no condition, we passed (maybe only named as specified) if (!query.condition) return true; // Containers will always have a layout interaction const layout = container.layout?.get(tracking?.effect); if (!layout) return false; return testCondition( query.condition, { width: layout[0], height: layout[1], }, tracking, ); } function testContainerAttributes( props: Record | null | undefined, conditions: AttributeCondition[], ) { for (const condition of conditions) { const attrValue = condition.type === "data-attribute" ? props?.["dataSet"]?.[condition.name] : props?.[condition.name]; if (!testAttribute(attrValue, condition)) { return false; } } return true; } /** * Test a media condition against current conditions * This is also used for container queries */ export function testCondition( condition: ContainerCondition | null | undefined, conditionReference: ConditionReference, tracking?: ReducerTracking, ): boolean { if (!condition) return true; if (condition.type === "operation") { if (condition.operator === "and") { return condition.conditions.every((c) => { return testCondition(c, conditionReference, tracking); }); } else { return condition.conditions.some((c) => { return testCondition(c, conditionReference, tracking); }); } } else if (condition.type === "not") { return !testCondition(condition.value, conditionReference, tracking); } else if (condition.type === "style") { // TODO return false; } return testFeature(condition.value, conditionReference, tracking); } function testFeature( feature: QueryFeatureFor_MediaFeatureId, conditionReference: ConditionReference, tracking?: ReducerTracking, ) { switch (feature.type) { case "plain": return testPlainFeature(feature, conditionReference, tracking); case "range": return testRange(feature, conditionReference, tracking); case "boolean": return testBoolean(feature, tracking); case "interval": return false; default: feature satisfies never; } return false; } function testPlainFeature( feature: Extract, ref: ConditionReference, tracking?: ReducerTracking, ) { const value = getMediaFeatureValue(feature.value, tracking); if (value === null) { return false; } switch (feature.name) { case "resolution": return value === PixelRatio.get(); case "display-mode": return value === "native" || Platform.OS === value; case "prefers-color-scheme": return colorScheme.get(tracking?.effect) === value; case "width": return testComparison("equal", ref.width, value, tracking); case "min-width": return testComparison("greater-than-equal", ref.width, value, tracking); case "max-width": return testComparison("less-than-equal", ref.width, value, tracking); case "height": return testComparison("equal", ref.height, value, tracking); case "min-height": return testComparison("greater-than-equal", ref.height, value, tracking); case "max-height": return testComparison("less-than-equal", ref.height, value, tracking); case "orientation": switch (value) { case "landscape": return testComparison("less-than", ref.height, ref.width, tracking); case "portrait": return testComparison( "greater-than-equal", ref.height, ref.width, tracking, ); } default: return false; } } function getMediaFeatureValue( value: MediaFeatureValue, tracking?: ReducerTracking, ) { switch (value.type) { case "number": return value.value; case "length": if (value.value.type === "value") { const length = value.value.value; switch (length.unit) { case "px": return length.value; case "rem": return length.value * rem.get(tracking?.effect); default: return null; } } else { return null; } case "ident": return value.value; case "resolution": // React Native ~160 dp per inch switch (value.value.type) { case "dpi": return value.value.value / 160; case "dpcm": // There are 1in = ~2.54cm return value.value.value / (160 * 2.54); case "dppx": return value.value.value; default: value.value satisfies never; } case "boolean": case "integer": case "ratio": case "env": return null; default: value satisfies never; } } function testRange( feature: Extract, ref: ConditionReference, tracking?: ReducerTracking, ) { const value = getMediaFeatureValue(feature.value, tracking); if (value === null || typeof value !== "number") { return false; } switch (feature.name) { case "height": return testComparison(feature.operator, ref.height, value, tracking); case "width": return testComparison(feature.operator, ref.width, value, tracking); case "resolution": return testComparison(feature.operator, PixelRatio.get(), value); default: return false; } } function testComparison( comparison: MediaFeatureComparison, ref: number | ReadableObservable, value: unknown, tracking?: ReducerTracking, ) { ref = unwrap(ref, tracking?.effect); value = unwrap(value, tracking?.effect); if (typeof value !== "number") return false; switch (comparison) { case "equal": return ref === value; case "greater-than": return ref > value; case "greater-than-equal": return ref >= value; case "less-than": return ref < value; case "less-than-equal": return ref < value; } } function testBoolean( feature: Extract, tracking?: ReducerTracking, ) { switch (feature.name) { case "prefers-reduced-motion": return isReduceMotionEnabled.get(tracking?.effect); case "ltr": return I18nManager.isRTL === false; case "rtl": return I18nManager.isRTL; } return false; } function unwrap(value: T | ReadableObservable, effect?: Effect): T { return value && typeof value === "object" && "get" in value ? value.get(effect) : (value as T); } function testAttributes( refs: Refs, tracking: ReducerTracking, conditions: AttributeCondition[], ) { for (const condition of conditions) { const props = refs.props; const attrValue = condition.type === "data-attribute" ? props?.["dataSet"]?.[condition.name] : props?.[condition.name]; tracking.guards.push((nextRefs) => { const nextValue = condition.type === "data-attribute" ? nextRefs.props?.["dataSet"]?.[condition.name] : nextRefs.props?.[condition.name]; return attrValue !== nextValue; }); if (!testAttribute(attrValue, condition)) { return false; } } return true; } function testAttribute(propValue: any, condition: AttributeCondition) { const operation = condition.operation; if (operation == null) return propValue != null; switch (operation.operator) { /* These are non-standard operators */ case "empty": { // Mostly used for detecting empty children return propValue == null || propValue == ""; } case "truthy": { // Does the attribute exist with a truthy value return Boolean(propValue); } /* These are the standard operators */ case "dash-match": case "prefix": case "substring": case "suffix": return false; case "includes": return propValue?.toString().includes(operation.value); case "equal": { return propValue?.toString() == operation.value; } } }