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);