diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx new file mode 100644 index 00000000..ebb1a59c --- /dev/null +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, screen } from "@testing-library/react-native"; + +import { Switch, TextInput, View } from "../../../components"; +import { render } from "./_tailwind"; + +const testID = "component"; + +test("hover", async () => { + await render(); + + const component = screen.getByTestId(testID); + + expect(component).toHaveStyle(undefined); + + fireEvent(component, "hoverIn"); + expect(component).toHaveStyle({ color: "#fff" }); + + fireEvent(component, "hoverOut"); + expect(component).toHaveStyle(undefined); +}); + +test("focus", async () => { + await render(); + + const component = screen.getByTestId(testID); + + expect(component).toHaveStyle(undefined); + + fireEvent(component, "focus"); + expect(component).toHaveStyle({ color: "#fff" }); + + fireEvent(component, "blur"); + expect(component).toHaveStyle(undefined); +}); + +test("active", async () => { + await render(); + + const component = screen.getByTestId(testID); + + expect(component).toHaveStyle(undefined); + + 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" }); +}); + +test("selection", async () => { + await render(); + + const component = screen.getByTestId(testID); + expect(component.props).toEqual({ + testID, + selectionColor: "#000", + children: undefined, + style: {}, + }); +}); + +test("ltr:", async () => { + await render(); + + const component = screen.getByTestId(testID); + expect(component).toHaveStyle({ + color: "#000", + }); +}); + +test("placeholder", async () => { + await render( + , + ); + + const component = screen.getByTestId(testID); + expect(component.props).toEqual({ + testID, + placeholderTextColor: "#000", + children: undefined, + style: {}, + }); +}); + +test("disabled", async () => { + const { rerender } = await render( + , + ); + + const component = screen.getByTestId(testID); + expect(component.props).toEqual( + expect.objectContaining({ + testID, + style: { + height: 31, + width: 51, + }, + }), + ); + + rerender(); + + expect(component.props).toEqual( + expect.objectContaining({ + testID, + style: [ + { + height: 31, + width: 51, + }, + { + backgroundColor: "#000", + }, + ], + }), + ); + + rerender( + , + ); + + expect(component.props).toEqual( + expect.objectContaining({ + testID, + style: { + height: 31, + width: 51, + }, + }), + ); +}); diff --git a/src/compiler/attributes.test.ts b/src/compiler/attributes.test.ts deleted file mode 100644 index c444b142..00000000 --- a/src/compiler/attributes.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { compile } from "../compiler"; - -test("multiple classes", () => { - const compiled = compile(` -.my-class.test { - color: red; -}`); - - expect(compiled.stylesheet()).toStrictEqual({ - s: [ - [ - "test", - [ - { - aq: [["a", "className", "*=", "my-class"]], - d: [ - { - color: "#f00", - }, - ], - s: [1, 2], - v: [["__rn-css-color", "#f00"]], - }, - ], - ], - ], - }); -}); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 6a7f1cdd..16accbfe 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -177,7 +177,7 @@ export type MediaCondition = // Comparison | [ MediaFeatureComparison, - MediaFeatureNameFor_MediaFeatureId | MediaFeatureNameFor_MediaFeatureId, + MediaFeatureNameFor_MediaFeatureId | "dir", StyleDescriptor, ] // [Start, End] diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts new file mode 100644 index 00000000..3ee0867f --- /dev/null +++ b/src/compiler/pseudo-elements.ts @@ -0,0 +1,54 @@ +import { isStyleFunction } from "../runtime/utils"; +import type { StyleDeclaration, StyleRule } from "./compiler.types"; + +export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { + if (!rule.d) { + return; + } + + rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { + return modifyStyleDeclaration(declaration, "color", "selectionColor"); + }); + + return rule; +} + +export function modifyRuleForPlaceholder( + rule: StyleRule, +): StyleRule | undefined { + if (!rule.d) { + return; + } + + rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { + return modifyStyleDeclaration(declaration, "color", "placeholderTextColor"); + }); + + return rule; +} + +function modifyStyleDeclaration( + declaration: StyleDeclaration, + from: string, + to: string, +): StyleDeclaration[] { + if (Array.isArray(declaration)) { + if (isStyleFunction(declaration) && declaration[2] === from) { + declaration = [...declaration] as StyleDeclaration; + declaration[2] = [to]; + return [declaration]; + } else if (declaration[1] === from) { + declaration = [...declaration] as StyleDeclaration; + declaration[1] = [to]; + return [declaration]; + } + } else if (typeof declaration === "object") { + const { color: selectionColor, ...rest } = declaration; + + if (selectionColor) { + return [rest, [selectionColor, [to]]] as StyleDeclaration[]; + } + } + + return [declaration]; +} diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 5fd8c0ee..17570e99 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -1,4 +1,4 @@ -import type { Selector, SelectorList } from "lightningcss"; +import type { AttrOperation, Selector, SelectorList } from "lightningcss"; import { Specificity } from "../runtime/utils"; import type { @@ -19,6 +19,7 @@ interface ReactNativeClassNameSelector { containerQuery?: ContainerQuery[]; pseudoClassesQuery?: PseudoClassesQuery; attributeQuery?: AttributeQuery[]; + pseudoElementQuery?: string[]; } interface ReactNativeGlobalSelector { @@ -32,10 +33,12 @@ type PartialSelector = Partial & { const containerQueryMap = new WeakMap(); const attributeQueryMap = new WeakMap(); +const mediaQueryMap = new WeakMap(); const pseudoClassesQueryMap = new WeakMap(); type ContainerQueryWithSpecificity = ContainerQuery & { specificity: SpecificityArray; + m?: MediaCondition; }; export function getClassNameSelectors( @@ -123,8 +126,19 @@ function parseComponents( return []; } case "pseudo-element": { - // TODO: Support ::selection, ::placeholder, etc - return []; + switch (component.kind) { + case "selection": + case "placeholder": { + specificity[Specificity.PseudoElements] = + (specificity[Specificity.PseudoElements] ?? 0) + 1; + root.pseudoElementQuery ??= []; + root.pseudoElementQuery.push(component.kind); + return parseComponents(rest, options, root, ref, specificity); + } + default: { + return []; + } + } } case "pseudo-class": { switch (component.kind) { @@ -185,12 +199,16 @@ function parseComponents( return parents.flatMap((parent) => { const originalParent = { ...parent }; - return isWhereContainerQueries.map(({ specificity, ...query }) => { + return isWhereContainerQueries.map((containerQuery) => { + const { specificity, m, ...query } = containerQuery; parent = { ...originalParent }; parent.specificity = [...originalParent.specificity]; - parent.containerQuery = [ - ...(originalParent.containerQuery ?? []), - ]; + + if (m && m.length > 1) { + parent.mediaQuery = originalParent.mediaQuery + ? [["&", [...originalParent.mediaQuery, m]]] + : [m]; + } if (component.kind === "is") { for (let i = 0; i < specificity.length; i++) { @@ -202,7 +220,12 @@ function parseComponents( } } - parent.containerQuery.push(query); + if (query.a || query.p || query.n !== undefined) { + parent.containerQuery = [ + ...(originalParent.containerQuery ?? []), + ]; + parent.containerQuery.push(query); + } return parent; }); @@ -214,47 +237,63 @@ function parseComponents( } } case "attribute": { - // 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", 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 (component.name === "dir") { + if (!component.operation) { + return []; + } + const operator = operatorMap[component.operation.operator]; + + if (operator !== "=") { + return []; } - if (operator) { - // Append the operator onto the attribute query - attributeQuery.push(operator, component.operation.value); + + getMediaQuery(ref).push([operator, "dir", component.operation.value]); + 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", 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); + } } + getAttributeQuery(ref).push(attributeQuery); + specificity[Specificity.ClassName] = + (specificity[Specificity.ClassName] ?? 0) + 1; + return parseComponents(rest, options, root, ref, specificity); } - getAttributeQuery(ref).push(attributeQuery); - specificity[Specificity.ClassName] = - (specificity[Specificity.ClassName] ?? 0) + 1; - return parseComponents(rest, options, root, ref, specificity); } case "class": { if (component.name === options.selectorPrefix) { @@ -354,6 +393,13 @@ function parseIsWhereComponents( // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; switch (component.kind) { + case "dir": { + queries ??= [{ specificity: [] }]; + queries.forEach((query) => { + getMediaQuery(query).push(["=", "dir", component.direction]); + }); + return parseIsWhereComponents(type, selector, index + 1, queries); + } case "hover": { queries ??= [{ specificity: [] }]; queries.forEach((query) => { @@ -409,6 +455,10 @@ function parseIsWhereComponents( } } case "attribute": { + if (component.name === "dir") { + return null; + } + if (type !== "where") { // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; @@ -419,34 +469,9 @@ function parseIsWhereComponents( : // Everything else is turned into `attribute` queries ["a", 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); - } + const operator = operatorMap[component.operation.operator]; + // Append the operator onto the attribute query + attributeQuery.push(operator, component.operation.value); } queries ??= [{ specificity: [] }]; for (const query of queries) { @@ -515,6 +540,24 @@ function getAttributeQuery( return attributeQuery; } +function getMediaQuery( + key: PartialSelector | ContainerQuery, +): MediaCondition[] { + let mediaQuery = mediaQueryMap.get(key); + if (!mediaQuery) { + if ("type" in key) { + mediaQuery = []; + key.mediaQuery = mediaQuery; + } else { + mediaQuery = []; + key.m ??= ["&", mediaQuery]; + } + mediaQueryMap.set(key, mediaQuery); + } + + return mediaQuery; +} + function isRootVariableSelector([first, second]: Selector) { return ( first && !second && first.type === "pseudo-class" && first.kind === "root" @@ -535,3 +578,12 @@ type CamelCase = S extends `${infer P1}-${infer P2}${infer P3}` ? `${Lowercase}${Uppercase}${CamelCase}` : Lowercase; + +const operatorMap: Record = { + "equal": "=", + "includes": "~=", + "dash-match": "|=", + "prefix": "^=", + "substring": "*=", + "suffix": "$=", +}; diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index 6a4e8aac..f7d7031c 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -21,6 +21,10 @@ import type { VariableRecord, VariableValue, } from "./compiler.types"; +import { + modifyRuleForPlaceholder, + modifyRuleForSelection, +} from "./pseudo-elements"; import { getClassNameSelectors, toRNProperty } from "./selector-builder"; type BuilderMode = "style" | "media" | "container" | "keyframes"; @@ -452,7 +456,19 @@ export class StylesheetBuilder { for (const selector of normalizedSelectors) { // We are going to be apply the current rule to n selectors, so we clone the rule - const rule = this.cloneRule(this.rule); + let rule: StyleRule | undefined = this.cloneRule(this.rule); + + if (selector.type === "className" && selector.pseudoElementQuery) { + if (selector.pseudoElementQuery.includes("selection")) { + rule = modifyRuleForSelection(rule); + } else if (selector.pseudoElementQuery.includes("placeholder")) { + rule = modifyRuleForPlaceholder(rule); + } + } + + if (!rule) { + continue; + } if (selector.type === "className") { const { diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx new file mode 100644 index 00000000..eb6428f0 --- /dev/null +++ b/src/components/Switch.tsx @@ -0,0 +1,17 @@ +import { Switch as RNSwitch, type SwitchProps } from "react-native"; + +import { + useCssElement, + type StyledConfiguration, + type StyledProps, +} from "../runtime"; + +const mapping = { + className: "style", +} satisfies StyledConfiguration; + +export function Switch(props: StyledProps) { + return useCssElement(RNSwitch, props, mapping); +} + +export default Switch; diff --git a/src/components/index.tsx b/src/components/index.tsx index abc372b8..31f03339 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -4,6 +4,7 @@ export { ActivityIndicator } from "./ActivityIndicator"; export { Button } from "./Button"; export { FlatList } from "./FlatList"; export { Image } from "./Image"; +export { Switch } from "./Switch"; export { Text } from "./Text"; export { TextInput } from "./TextInput"; export { View } from "./View"; diff --git a/src/runtime/native/conditions/index.ts b/src/runtime/native/conditions/index.ts index 4240e872..eb6c98a8 100644 --- a/src/runtime/native/conditions/index.ts +++ b/src/runtime/native/conditions/index.ts @@ -39,10 +39,12 @@ export function testRule( } function pseudoClasses(query: PseudoClassesQuery, get: Getter) { - if (query.h && !get(hoverFamily(get))) { + // IMPORTANT: active needs: to be first. Modifies a global value + // that we use to determine if the component should be a pressable + if (query.a && !get(activeFamily(get))) { return false; } - if (query.a && !get(activeFamily(get))) { + if (query.h && !get(hoverFamily(get))) { return false; } if (query.f && !get(focusFamily(get))) { diff --git a/src/runtime/native/conditions/media-query.ts b/src/runtime/native/conditions/media-query.ts index 57d1b476..dd5dcb8b 100644 --- a/src/runtime/native/conditions/media-query.ts +++ b/src/runtime/native/conditions/media-query.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -import { PixelRatio, Platform } from "react-native"; +import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "../../../compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; @@ -34,35 +34,39 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { - let left: number | undefined; - const right = mediaQuery[2]; + const value = mediaQuery[2]; switch (mediaQuery[1]) { + case "dir": + return (I18nManager.isRTL && value === "rtl") || value === "ltr"; case "hover": return true; case "platform": - return right === "native" || right === Platform.OS; + return value === "native" || value === Platform.OS; case "prefers-color-scheme": { - return right === get(colorScheme); + return value === get(colorScheme); } case "display-mode": - return right === "native" || Platform.OS === right; + return value === "native" || Platform.OS === value; case "min-width": - return typeof right === "number" && get(vw) >= right; + return typeof value === "number" && get(vw) >= value; case "max-width": - return typeof right === "number" && get(vw) <= right; + return typeof value === "number" && get(vw) <= value; case "min-height": - return typeof right === "number" && get(vh) >= right; + return typeof value === "number" && get(vh) >= value; case "max-height": - return typeof right === "number" && get(vh) <= right; + return typeof value === "number" && get(vh) <= value; case "orientation": - return right === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); + return value === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); } - if (typeof right !== "number") { + if (typeof value !== "number") { return false; } + let left: number | undefined; + const right = value; + switch (mediaQuery[1]) { case "width": left = get(vw);