@mgcrea/react-native-tailwind 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +152 -0
  2. package/dist/babel/config-loader.ts +2 -0
  3. package/dist/babel/index.cjs +177 -4
  4. package/dist/babel/plugin.d.ts +2 -0
  5. package/dist/babel/plugin.test.ts +241 -0
  6. package/dist/babel/plugin.ts +187 -10
  7. package/dist/babel/utils/platformModifierProcessing.d.ts +30 -0
  8. package/dist/babel/utils/platformModifierProcessing.ts +80 -0
  9. package/dist/babel/utils/styleInjection.d.ts +4 -0
  10. package/dist/babel/utils/styleInjection.ts +28 -0
  11. package/dist/babel/utils/styleTransforms.ts +1 -0
  12. package/dist/parser/index.d.ts +2 -2
  13. package/dist/parser/index.js +1 -1
  14. package/dist/parser/modifiers.d.ts +20 -2
  15. package/dist/parser/modifiers.js +1 -1
  16. package/dist/runtime.cjs +1 -1
  17. package/dist/runtime.cjs.map +4 -4
  18. package/dist/runtime.js +1 -1
  19. package/dist/runtime.js.map +4 -4
  20. package/dist/stubs/tw.test.js +1 -0
  21. package/package.json +6 -5
  22. package/src/babel/config-loader.ts +2 -0
  23. package/src/babel/plugin.test.ts +241 -0
  24. package/src/babel/plugin.ts +187 -10
  25. package/src/babel/utils/platformModifierProcessing.ts +80 -0
  26. package/src/babel/utils/styleInjection.ts +28 -0
  27. package/src/babel/utils/styleTransforms.ts +1 -0
  28. package/src/parser/aspectRatio.ts +1 -0
  29. package/src/parser/borders.ts +2 -0
  30. package/src/parser/colors.ts +2 -0
  31. package/src/parser/index.ts +9 -2
  32. package/src/parser/layout.ts +2 -0
  33. package/src/parser/modifiers.ts +38 -4
  34. package/src/parser/placeholder.ts +1 -0
  35. package/src/parser/sizing.ts +1 -0
  36. package/src/parser/spacing.ts +1 -0
  37. package/src/parser/transforms.ts +5 -0
  38. package/src/parser/typography.ts +2 -0
  39. package/src/stubs/tw.test.ts +27 -0
package/README.md CHANGED
@@ -45,6 +45,7 @@ Compile-time Tailwind CSS for React Native with zero runtime overhead. Transform
45
45
  - 🔀 **Dynamic className** — Conditional styles with hybrid compile-time optimization
46
46
  - 🏃 **Runtime option** — Optional `tw` template tag for fully dynamic styling (~25KB)
47
47
  - 🎯 **State modifiers** — `active:`, `hover:`, `focus:`, and `disabled:` modifiers for interactive components
48
+ - 📱 **Platform modifiers** — `ios:`, `android:`, and `web:` modifiers for platform-specific styling
48
49
  - 📜 **Special style props** — Support for `contentContainerClassName`, `columnWrapperClassName`, and more
49
50
  - 🎛️ **Custom attributes** — Configure which props to transform with exact matching or glob patterns
50
51
 
@@ -709,6 +710,157 @@ The enhanced `TextInput` also provides a convenient `disabled` prop that overrid
709
710
  - ✅ **Type-safe** — Full TypeScript autocomplete for all modifiers
710
711
  - ✅ **Works with custom colors** — `focus:border-primary`, `active:bg-secondary`, `disabled:bg-gray-200`, etc.
711
712
 
713
+ ### Platform Modifiers
714
+
715
+ Apply platform-specific styles using `ios:`, `android:`, and `web:` modifiers. These work on **all components** (not just enhanced ones) and compile to `Platform.select()` calls with zero runtime parsing overhead.
716
+
717
+ **Basic Example:**
718
+
719
+ ```tsx
720
+ import { View, Text } from "react-native";
721
+
722
+ export function PlatformCard() {
723
+ return (
724
+ <View className="p-4 ios:p-6 android:p-8 bg-white rounded-lg">
725
+ <Text className="text-base ios:text-blue-600 android:text-green-600">
726
+ Platform-specific styles
727
+ </Text>
728
+ </View>
729
+ );
730
+ }
731
+ ```
732
+
733
+ **Transforms to:**
734
+
735
+ ```tsx
736
+ import { Platform, StyleSheet } from "react-native";
737
+
738
+ <View
739
+ style={[
740
+ _twStyles._bg_white_p_4_rounded_lg,
741
+ Platform.select({
742
+ ios: _twStyles._ios_p_6,
743
+ android: _twStyles._android_p_8,
744
+ }),
745
+ ]}
746
+ >
747
+ <Text
748
+ style={[
749
+ _twStyles._text_base,
750
+ Platform.select({
751
+ ios: _twStyles._ios_text_blue_600,
752
+ android: _twStyles._android_text_green_600,
753
+ }),
754
+ ]}
755
+ >
756
+ Platform-specific styles
757
+ </Text>
758
+ </View>;
759
+
760
+ // Generated styles:
761
+ const _twStyles = StyleSheet.create({
762
+ _bg_white_p_4_rounded_lg: {
763
+ backgroundColor: "#FFFFFF",
764
+ padding: 16,
765
+ borderRadius: 8,
766
+ },
767
+ _ios_p_6: { padding: 24 },
768
+ _android_p_8: { padding: 32 },
769
+ _text_base: { fontSize: 16 },
770
+ _ios_text_blue_600: { color: "#2563EB" },
771
+ _android_text_green_600: { color: "#059669" },
772
+ });
773
+ ```
774
+
775
+ **Common Use Cases:**
776
+
777
+ **Platform-specific colors:**
778
+
779
+ ```tsx
780
+ // Different colors per platform for brand consistency
781
+ <View className="bg-blue-500 ios:bg-blue-600 android:bg-green-600">
782
+ <Text className="text-white">Platform-specific background</Text>
783
+ </View>
784
+ ```
785
+
786
+ **Platform-specific spacing:**
787
+
788
+ ```tsx
789
+ // More padding on Android due to larger default touch targets
790
+ <View className="p-4 ios:p-6 android:p-8">
791
+ <Text>Platform-specific padding</Text>
792
+ </View>
793
+ ```
794
+
795
+ **Combined with base styles:**
796
+
797
+ ```tsx
798
+ // Base styles + platform-specific overrides
799
+ <View className="border-2 border-gray-300 ios:border-blue-500 android:border-green-500 rounded-lg p-4">
800
+ <Text className="text-gray-800 ios:text-blue-800 android:text-green-800">
801
+ Base styles with platform overrides
802
+ </Text>
803
+ </View>
804
+ ```
805
+
806
+ **Multiple platform modifiers:**
807
+
808
+ ```tsx
809
+ // Combine multiple platform-specific styles
810
+ <View className="bg-gray-100 ios:bg-blue-50 android:bg-green-50 p-4 ios:p-6 android:p-8 rounded-lg">
811
+ <Text>Multiple platform styles</Text>
812
+ </View>
813
+ ```
814
+
815
+ **Web platform support:**
816
+
817
+ ```tsx
818
+ // Different styles for React Native Web
819
+ <View className="p-4 ios:p-6 android:p-8 web:p-2">
820
+ <Text className="text-base web:text-lg">Cross-platform styling</Text>
821
+ </View>
822
+ ```
823
+
824
+ **Mixing with state modifiers:**
825
+
826
+ ```tsx
827
+ import { Pressable } from "@mgcrea/react-native-tailwind";
828
+
829
+ // Platform modifiers work alongside state modifiers
830
+ <Pressable className="bg-blue-500 active:bg-blue-700 ios:border-2 android:border-0 p-4 rounded-lg">
831
+ <Text className="text-white">Button with platform + state modifiers</Text>
832
+ </Pressable>;
833
+ ```
834
+
835
+ **Key Features:**
836
+
837
+ - ✅ **Works on all components** — No need for enhanced components (unlike state modifiers)
838
+ - ✅ **Zero runtime overhead** — All parsing happens at compile-time
839
+ - ✅ **Native Platform API** — Uses React Native's `Platform.select()` under the hood
840
+ - ✅ **Type-safe** — Full TypeScript autocomplete for platform modifiers
841
+ - ✅ **Optimized** — Styles deduplicated via `StyleSheet.create`
842
+ - ✅ **Works with custom colors** — `ios:bg-primary`, `android:bg-secondary`, etc.
843
+ - ✅ **Minimal runtime cost** — Only one `Platform.select()` call per element with platform modifiers
844
+
845
+ **Supported Platforms:**
846
+
847
+ | Modifier | Platform | Description |
848
+ | -------- | -------------- | ----------------------------- |
849
+ | `ios:` | iOS | Styles specific to iOS |
850
+ | `android:` | Android | Styles specific to Android |
851
+ | `web:` | React Native Web | Styles for web platform |
852
+
853
+ **How it works:**
854
+
855
+ The Babel plugin:
856
+ 1. Detects platform modifiers during compilation
857
+ 2. Parses all platform-specific classes at compile-time
858
+ 3. Generates `Platform.select()` expressions with references to pre-compiled styles
859
+ 4. Auto-imports `Platform` from `react-native` when needed
860
+ 5. Merges platform styles with base classes and other modifiers in style arrays
861
+
862
+ This approach provides the best of both worlds: compile-time optimization for all styles, with minimal runtime platform detection only for the conditional selection logic.
863
+
712
864
  ### ScrollView Content Container
713
865
 
714
866
  Use `contentContainerClassName` to style the ScrollView's content container:
@@ -72,6 +72,7 @@ export function loadTailwindConfig(configPath: string): TailwindConfig | null {
72
72
  configCache.set(configPath, resolved);
73
73
  return resolved;
74
74
  } catch (error) {
75
+ /* v8 ignore next 3 */
75
76
  if (process.env.NODE_ENV !== "production") {
76
77
  console.warn(`[react-native-tailwind] Failed to load config from ${configPath}:`, error);
77
78
  }
@@ -98,6 +99,7 @@ export function extractCustomColors(filename: string): Record<string, string> {
98
99
  }
99
100
 
100
101
  // Warn if using theme.colors instead of theme.extend.colors
102
+ /* v8 ignore next 5 */
101
103
  if (config.theme.colors && !config.theme.extend?.colors && process.env.NODE_ENV !== "production") {
102
104
  console.warn(
103
105
  "[react-native-tailwind] Using theme.colors will override all default colors. " +
@@ -1730,13 +1730,15 @@ function parsePlaceholderClasses(classes, customColors) {
1730
1730
  }
1731
1731
 
1732
1732
  // src/parser/modifiers.ts
1733
- var SUPPORTED_MODIFIERS = [
1733
+ var STATE_MODIFIERS = [
1734
1734
  "active",
1735
1735
  "hover",
1736
1736
  "focus",
1737
1737
  "disabled",
1738
1738
  "placeholder"
1739
1739
  ];
1740
+ var PLATFORM_MODIFIERS = ["ios", "android", "web"];
1741
+ var SUPPORTED_MODIFIERS = [...STATE_MODIFIERS, ...PLATFORM_MODIFIERS];
1740
1742
  function parseModifier(cls) {
1741
1743
  const colonIndex = cls.indexOf(":");
1742
1744
  if (colonIndex === -1) {
@@ -1758,6 +1760,12 @@ function parseModifier(cls) {
1758
1760
  baseClass
1759
1761
  };
1760
1762
  }
1763
+ function isStateModifier(modifier) {
1764
+ return STATE_MODIFIERS.includes(modifier);
1765
+ }
1766
+ function isPlatformModifier(modifier) {
1767
+ return PLATFORM_MODIFIERS.includes(modifier);
1768
+ }
1761
1769
  function splitModifierClasses(className) {
1762
1770
  const classes = className.trim().split(/\s+/).filter(Boolean);
1763
1771
  const baseClasses = [];
@@ -2118,6 +2126,34 @@ function createStyleFunction(styleExpression, modifierTypes, t) {
2118
2126
  return t.arrowFunctionExpression([param], styleExpression);
2119
2127
  }
2120
2128
 
2129
+ // src/babel/utils/platformModifierProcessing.ts
2130
+ function processPlatformModifiers(platformModifiers, state, parseClassName2, generateStyleKey2, t) {
2131
+ state.needsPlatformImport = true;
2132
+ const modifiersByPlatform = /* @__PURE__ */ new Map();
2133
+ for (const mod of platformModifiers) {
2134
+ const platform = mod.modifier;
2135
+ if (!modifiersByPlatform.has(platform)) {
2136
+ modifiersByPlatform.set(platform, []);
2137
+ }
2138
+ const platformGroup = modifiersByPlatform.get(platform);
2139
+ if (platformGroup) {
2140
+ platformGroup.push(mod);
2141
+ }
2142
+ }
2143
+ const selectProperties = [];
2144
+ for (const [platform, modifiers] of modifiersByPlatform) {
2145
+ const classNames = modifiers.map((m) => m.baseClass).join(" ");
2146
+ const styleObject = parseClassName2(classNames, state.customColors);
2147
+ const styleKey = generateStyleKey2(`${platform}_${classNames}`);
2148
+ state.styleRegistry.set(styleKey, styleObject);
2149
+ const styleReference = t.memberExpression(t.identifier(state.stylesIdentifier), t.identifier(styleKey));
2150
+ selectProperties.push(t.objectProperty(t.identifier(platform), styleReference));
2151
+ }
2152
+ return t.callExpression(t.memberExpression(t.identifier("Platform"), t.identifier("select")), [
2153
+ t.objectExpression(selectProperties)
2154
+ ]);
2155
+ }
2156
+
2121
2157
  // src/babel/utils/styleInjection.ts
2122
2158
  function addStyleSheetImport(path2, t) {
2123
2159
  const importDeclaration = t.importDeclaration(
@@ -2126,6 +2162,25 @@ function addStyleSheetImport(path2, t) {
2126
2162
  );
2127
2163
  path2.unshiftContainer("body", importDeclaration);
2128
2164
  }
2165
+ function addPlatformImport(path2, t) {
2166
+ const body = path2.node.body;
2167
+ let reactNativeImport = null;
2168
+ for (const statement of body) {
2169
+ if (t.isImportDeclaration(statement) && statement.source.value === "react-native") {
2170
+ reactNativeImport = statement;
2171
+ break;
2172
+ }
2173
+ }
2174
+ if (reactNativeImport) {
2175
+ reactNativeImport.specifiers.push(t.importSpecifier(t.identifier("Platform"), t.identifier("Platform")));
2176
+ } else {
2177
+ const importDeclaration = t.importDeclaration(
2178
+ [t.importSpecifier(t.identifier("Platform"), t.identifier("Platform"))],
2179
+ t.stringLiteral("react-native")
2180
+ );
2181
+ path2.unshiftContainer("body", importDeclaration);
2182
+ }
2183
+ }
2129
2184
  function injectStylesAtTop(path2, styleRegistry, stylesIdentifier, t) {
2130
2185
  const styleProperties = [];
2131
2186
  for (const [key, styleObject] of styleRegistry) {
@@ -2355,6 +2410,8 @@ function reactNativeTailwindBabelPlugin({ types: t }, options) {
2355
2410
  state.styleRegistry = /* @__PURE__ */ new Map();
2356
2411
  state.hasClassNames = false;
2357
2412
  state.hasStyleSheetImport = false;
2413
+ state.hasPlatformImport = false;
2414
+ state.needsPlatformImport = false;
2358
2415
  state.supportedAttributes = exactMatches;
2359
2416
  state.attributePatterns = patterns;
2360
2417
  state.stylesIdentifier = stylesIdentifier;
@@ -2372,10 +2429,13 @@ function reactNativeTailwindBabelPlugin({ types: t }, options) {
2372
2429
  if (!state.hasStyleSheetImport) {
2373
2430
  addStyleSheetImport(path2, t);
2374
2431
  }
2432
+ if (state.needsPlatformImport && !state.hasPlatformImport) {
2433
+ addPlatformImport(path2, t);
2434
+ }
2375
2435
  injectStylesAtTop(path2, state.styleRegistry, state.stylesIdentifier, t);
2376
2436
  }
2377
2437
  },
2378
- // Check if StyleSheet is already imported and track tw/twStyle imports
2438
+ // Check if StyleSheet/Platform are already imported and track tw/twStyle imports
2379
2439
  ImportDeclaration(path2, state) {
2380
2440
  const node = path2.node;
2381
2441
  if (node.source.value === "react-native") {
@@ -2386,12 +2446,21 @@ function reactNativeTailwindBabelPlugin({ types: t }, options) {
2386
2446
  }
2387
2447
  return false;
2388
2448
  });
2449
+ const hasPlatform = specifiers.some((spec) => {
2450
+ if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported)) {
2451
+ return spec.imported.name === "Platform";
2452
+ }
2453
+ return false;
2454
+ });
2389
2455
  if (hasStyleSheet) {
2390
2456
  state.hasStyleSheetImport = true;
2391
2457
  } else {
2392
2458
  node.specifiers.push(t.importSpecifier(t.identifier("StyleSheet"), t.identifier("StyleSheet")));
2393
2459
  state.hasStyleSheetImport = true;
2394
2460
  }
2461
+ if (hasPlatform) {
2462
+ state.hasPlatformImport = true;
2463
+ }
2395
2464
  }
2396
2465
  if (node.source.value === "@mgcrea/react-native-tailwind") {
2397
2466
  const specifiers = node.specifiers;
@@ -2494,7 +2563,10 @@ function reactNativeTailwindBabelPlugin({ types: t }, options) {
2494
2563
  state.hasClassNames = true;
2495
2564
  const { baseClasses, modifierClasses } = splitModifierClasses(className);
2496
2565
  const placeholderModifiers = modifierClasses.filter((m) => m.modifier === "placeholder");
2497
- const stateModifiers = modifierClasses.filter((m) => m.modifier !== "placeholder");
2566
+ const platformModifiers = modifierClasses.filter((m) => isPlatformModifier(m.modifier));
2567
+ const stateModifiers = modifierClasses.filter(
2568
+ (m) => isStateModifier(m.modifier) && m.modifier !== "placeholder"
2569
+ );
2498
2570
  if (placeholderModifiers.length > 0) {
2499
2571
  const jsxOpeningElement = path2.parent;
2500
2572
  const componentSupport = getComponentModifierSupport(jsxOpeningElement, t);
@@ -2512,7 +2584,108 @@ function reactNativeTailwindBabelPlugin({ types: t }, options) {
2512
2584
  }
2513
2585
  }
2514
2586
  }
2515
- if (stateModifiers.length > 0) {
2587
+ const hasPlatformModifiers = platformModifiers.length > 0;
2588
+ const hasStateModifiers = stateModifiers.length > 0;
2589
+ const hasBaseClasses = baseClasses.length > 0;
2590
+ if (hasStateModifiers && hasPlatformModifiers) {
2591
+ const jsxOpeningElement = path2.parent;
2592
+ const componentSupport = getComponentModifierSupport(jsxOpeningElement, t);
2593
+ if (componentSupport) {
2594
+ const styleArrayElements = [];
2595
+ if (hasBaseClasses) {
2596
+ const baseClassName = baseClasses.join(" ");
2597
+ const baseStyleObject = parseClassName(baseClassName, state.customColors);
2598
+ const baseStyleKey = generateStyleKey(baseClassName);
2599
+ state.styleRegistry.set(baseStyleKey, baseStyleObject);
2600
+ styleArrayElements.push(
2601
+ t.memberExpression(t.identifier(state.stylesIdentifier), t.identifier(baseStyleKey))
2602
+ );
2603
+ }
2604
+ const platformSelectExpression = processPlatformModifiers(
2605
+ platformModifiers,
2606
+ state,
2607
+ parseClassName,
2608
+ generateStyleKey,
2609
+ t
2610
+ );
2611
+ styleArrayElements.push(platformSelectExpression);
2612
+ const modifiersByType = /* @__PURE__ */ new Map();
2613
+ for (const mod of stateModifiers) {
2614
+ const modType = mod.modifier;
2615
+ if (!modifiersByType.has(modType)) {
2616
+ modifiersByType.set(modType, []);
2617
+ }
2618
+ modifiersByType.get(modType)?.push(mod);
2619
+ }
2620
+ for (const [modifierType, modifiers] of modifiersByType) {
2621
+ if (!componentSupport.supportedModifiers.includes(modifierType)) {
2622
+ continue;
2623
+ }
2624
+ const modifierClassNames = modifiers.map((m) => m.baseClass).join(" ");
2625
+ const modifierStyleObject = parseClassName(modifierClassNames, state.customColors);
2626
+ const modifierStyleKey = generateStyleKey(`${modifierType}_${modifierClassNames}`);
2627
+ state.styleRegistry.set(modifierStyleKey, modifierStyleObject);
2628
+ const stateProperty = getStatePropertyForModifier(modifierType);
2629
+ const conditionalExpression = t.logicalExpression(
2630
+ "&&",
2631
+ t.identifier(stateProperty),
2632
+ t.memberExpression(t.identifier(state.stylesIdentifier), t.identifier(modifierStyleKey))
2633
+ );
2634
+ styleArrayElements.push(conditionalExpression);
2635
+ }
2636
+ const usedModifiers = Array.from(new Set(stateModifiers.map((m) => m.modifier))).filter(
2637
+ (mod) => componentSupport.supportedModifiers.includes(mod)
2638
+ );
2639
+ const styleArrayExpression = t.arrayExpression(styleArrayElements);
2640
+ const styleFunctionExpression = createStyleFunction(styleArrayExpression, usedModifiers, t);
2641
+ const styleAttribute2 = findStyleAttribute(path2, targetStyleProp, t);
2642
+ if (styleAttribute2) {
2643
+ mergeStyleFunctionAttribute(path2, styleAttribute2, styleFunctionExpression, t);
2644
+ } else {
2645
+ replaceWithStyleFunctionAttribute(path2, styleFunctionExpression, targetStyleProp, t);
2646
+ }
2647
+ return;
2648
+ } else {
2649
+ }
2650
+ }
2651
+ if (hasPlatformModifiers && !hasStateModifiers) {
2652
+ const styleExpressions = [];
2653
+ if (hasBaseClasses) {
2654
+ const baseClassName = baseClasses.join(" ");
2655
+ const baseStyleObject = parseClassName(baseClassName, state.customColors);
2656
+ const baseStyleKey = generateStyleKey(baseClassName);
2657
+ state.styleRegistry.set(baseStyleKey, baseStyleObject);
2658
+ styleExpressions.push(
2659
+ t.memberExpression(t.identifier(state.stylesIdentifier), t.identifier(baseStyleKey))
2660
+ );
2661
+ }
2662
+ const platformSelectExpression = processPlatformModifiers(
2663
+ platformModifiers,
2664
+ state,
2665
+ parseClassName,
2666
+ generateStyleKey,
2667
+ t
2668
+ );
2669
+ styleExpressions.push(platformSelectExpression);
2670
+ const styleExpression = styleExpressions.length === 1 ? styleExpressions[0] : t.arrayExpression(styleExpressions);
2671
+ const styleAttribute2 = findStyleAttribute(path2, targetStyleProp, t);
2672
+ if (styleAttribute2) {
2673
+ const existingStyle = styleAttribute2.value;
2674
+ if (t.isJSXExpressionContainer(existingStyle) && !t.isJSXEmptyExpression(existingStyle.expression)) {
2675
+ const existing = existingStyle.expression;
2676
+ const mergedArray = t.isArrayExpression(existing) ? t.arrayExpression([styleExpression, ...existing.elements]) : t.arrayExpression([styleExpression, existing]);
2677
+ styleAttribute2.value = t.jsxExpressionContainer(mergedArray);
2678
+ } else {
2679
+ styleAttribute2.value = t.jsxExpressionContainer(styleExpression);
2680
+ }
2681
+ path2.remove();
2682
+ } else {
2683
+ path2.node.name = t.jsxIdentifier(targetStyleProp);
2684
+ path2.node.value = t.jsxExpressionContainer(styleExpression);
2685
+ }
2686
+ return;
2687
+ }
2688
+ if (hasStateModifiers) {
2516
2689
  const jsxOpeningElement = path2.parent;
2517
2690
  const componentSupport = getComponentModifierSupport(jsxOpeningElement, t);
2518
2691
  if (componentSupport) {
@@ -29,6 +29,8 @@ type PluginState = PluginPass & {
29
29
  styleRegistry: Map<string, StyleObject>;
30
30
  hasClassNames: boolean;
31
31
  hasStyleSheetImport: boolean;
32
+ hasPlatformImport: boolean;
33
+ needsPlatformImport: boolean;
32
34
  customColors: Record<string, string>;
33
35
  supportedAttributes: Set<string>;
34
36
  attributePatterns: RegExp[];
@@ -480,3 +480,244 @@ describe("Babel plugin - placeholder: modifier transformation", () => {
480
480
  consoleSpy.mockRestore();
481
481
  });
482
482
  });
483
+
484
+ describe("Babel plugin - platform modifier transformation", () => {
485
+ it("should transform platform modifiers to Platform.select()", () => {
486
+ const input = `
487
+ import React from 'react';
488
+ import { View } from 'react-native';
489
+
490
+ export function Component() {
491
+ return (
492
+ <View className="p-4 ios:p-6 android:p-8" />
493
+ );
494
+ }
495
+ `;
496
+
497
+ const output = transform(input, undefined, true);
498
+
499
+ // Should import Platform from react-native
500
+ expect(output).toContain("Platform");
501
+ expect(output).toMatch(/import.*Platform.*from ['"]react-native['"]/);
502
+
503
+ // Should generate Platform.select()
504
+ expect(output).toContain("Platform.select");
505
+
506
+ // Should have base padding style
507
+ expect(output).toContain("_p_4");
508
+
509
+ // Should have iOS and Android specific styles
510
+ expect(output).toContain("_ios_p_6");
511
+ expect(output).toContain("_android_p_8");
512
+
513
+ // Should have correct style values in StyleSheet.create
514
+ expect(output).toMatch(/padding:\s*16/); // p-4
515
+ expect(output).toMatch(/padding:\s*24/); // p-6 (ios)
516
+ expect(output).toMatch(/padding:\s*32/); // p-8 (android)
517
+ });
518
+
519
+ it("should support multiple platform modifiers on same element", () => {
520
+ const input = `
521
+ import React from 'react';
522
+ import { View } from 'react-native';
523
+
524
+ export function Component() {
525
+ return (
526
+ <View className="bg-white ios:bg-blue-50 android:bg-green-50 p-4 ios:p-6 android:p-8" />
527
+ );
528
+ }
529
+ `;
530
+
531
+ const output = transform(input, undefined, true);
532
+
533
+ // Should have Platform import
534
+ expect(output).toContain("Platform");
535
+
536
+ // Should have base styles (combined key)
537
+ expect(output).toContain("_bg_white_p_4");
538
+
539
+ // Should have iOS specific styles (combined key for multiple ios: modifiers)
540
+ expect(output).toContain("_ios_bg_blue_50_p_6");
541
+
542
+ // Should have Android specific styles (combined key for multiple android: modifiers)
543
+ expect(output).toContain("_android_bg_green_50_p_8");
544
+
545
+ // Should contain Platform.select with both platforms
546
+ expect(output).toMatch(/Platform\.select\s*\(\s*\{[\s\S]*ios:/);
547
+ expect(output).toMatch(/Platform\.select\s*\(\s*\{[\s\S]*android:/);
548
+ });
549
+
550
+ it("should support web platform modifier", () => {
551
+ const input = `
552
+ import React from 'react';
553
+ import { View } from 'react-native';
554
+
555
+ export function Component() {
556
+ return (
557
+ <View className="p-4 web:p-2" />
558
+ );
559
+ }
560
+ `;
561
+
562
+ const output = transform(input, undefined, true);
563
+
564
+ // Should have Platform.select with web
565
+ expect(output).toContain("Platform.select");
566
+ expect(output).toContain("web:");
567
+ expect(output).toContain("_web_p_2");
568
+ });
569
+
570
+ it("should work with platform modifiers on all components", () => {
571
+ const input = `
572
+ import React from 'react';
573
+ import { View, Text, ScrollView } from 'react-native';
574
+
575
+ export function Component() {
576
+ return (
577
+ <View className="ios:bg-blue-500 android:bg-green-500">
578
+ <Text className="ios:text-lg android:text-xl">Platform text</Text>
579
+ <ScrollView contentContainerClassName="ios:p-4 android:p-8" />
580
+ </View>
581
+ );
582
+ }
583
+ `;
584
+
585
+ const output = transform(input, undefined, true);
586
+
587
+ // Should work on View - check for Platform.select separately (not checking style= format)
588
+ expect(output).toContain("Platform.select");
589
+
590
+ // Should work on Text
591
+ expect(output).toContain("_ios_text_lg");
592
+ expect(output).toContain("_android_text_xl");
593
+
594
+ // Should work on ScrollView contentContainerStyle
595
+ expect(output).toContain("contentContainerStyle");
596
+ });
597
+
598
+ it("should combine platform modifiers with state modifiers", () => {
599
+ const input = `
600
+ import React from 'react';
601
+ import { Pressable, Text } from 'react-native';
602
+
603
+ export function Component() {
604
+ return (
605
+ <Pressable className="bg-blue-500 active:bg-blue-700 ios:shadow-md android:shadow-sm p-4">
606
+ <Text className="text-white">Button</Text>
607
+ </Pressable>
608
+ );
609
+ }
610
+ `;
611
+
612
+ const output = transform(input, undefined, true);
613
+
614
+ // Should have Platform.select for platform modifiers
615
+ expect(output).toContain("Platform.select");
616
+ expect(output).toContain("_ios_shadow_md");
617
+ expect(output).toContain("_android_shadow_sm");
618
+
619
+ // Should have state modifier function for active
620
+ expect(output).toMatch(/\(\s*\{\s*pressed\s*\}\s*\)\s*=>/);
621
+ expect(output).toContain("pressed");
622
+ expect(output).toContain("_active_bg_blue_700");
623
+
624
+ // Should have base styles
625
+ expect(output).toContain("_bg_blue_500");
626
+ expect(output).toContain("_p_4");
627
+ });
628
+
629
+ it("should handle platform-specific colors", () => {
630
+ const input = `
631
+ import React from 'react';
632
+ import { View, Text } from 'react-native';
633
+
634
+ export function Component() {
635
+ return (
636
+ <View className="bg-gray-100 ios:bg-blue-50 android:bg-green-50">
637
+ <Text className="text-gray-900 ios:text-blue-900 android:text-green-900">
638
+ Platform colors
639
+ </Text>
640
+ </View>
641
+ );
642
+ }
643
+ `;
644
+
645
+ const output = transform(input, undefined, true);
646
+
647
+ // Should have color values in StyleSheet
648
+ expect(output).toMatch(/#[0-9A-F]{6}/i); // Hex color format
649
+
650
+ // Should have platform-specific color classes
651
+ expect(output).toContain("_ios_text_blue_900");
652
+ expect(output).toContain("_android_text_green_900");
653
+ });
654
+
655
+ it("should only add Platform import once when needed", () => {
656
+ const input = `
657
+ import React from 'react';
658
+ import { View } from 'react-native';
659
+
660
+ export function Component() {
661
+ return (
662
+ <>
663
+ <View className="ios:p-4" />
664
+ <View className="android:p-8" />
665
+ <View className="ios:bg-blue-500" />
666
+ </>
667
+ );
668
+ }
669
+ `;
670
+
671
+ const output = transform(input, undefined, true);
672
+
673
+ // Should have Platform import
674
+ expect(output).toContain("Platform");
675
+
676
+ // Count how many times Platform is imported (should be once)
677
+ const platformImports = output.match(/import.*Platform.*from ['"]react-native['"]/g);
678
+ expect(platformImports).toHaveLength(1);
679
+ });
680
+
681
+ it("should merge with existing Platform import", () => {
682
+ const input = `
683
+ import React from 'react';
684
+ import { View, Platform } from 'react-native';
685
+
686
+ export function Component() {
687
+ return <View className="ios:p-4 android:p-8" />;
688
+ }
689
+ `;
690
+
691
+ const output = transform(input, undefined, true);
692
+
693
+ // Should still use Platform.select
694
+ expect(output).toContain("Platform.select");
695
+
696
+ // Should not duplicate Platform import - Platform appears in import and Platform.select calls
697
+ expect(output).toMatch(/Platform.*react-native/);
698
+ });
699
+
700
+ it("should handle platform modifiers without base classes", () => {
701
+ const input = `
702
+ import React from 'react';
703
+ import { View } from 'react-native';
704
+
705
+ export function Component() {
706
+ return <View className="ios:p-6 android:p-8" />;
707
+ }
708
+ `;
709
+
710
+ const output = transform(input, undefined, true);
711
+
712
+ // Should only have Platform.select, no base style
713
+ expect(output).toContain("Platform.select");
714
+ expect(output).toContain("_ios_p_6");
715
+ expect(output).toContain("_android_p_8");
716
+
717
+ // Should not have generic padding without platform prefix
718
+ // Check that non-platform-prefixed style keys don't exist
719
+ expect(output).not.toMatch(/(?<!_ios|_android|_web)_p_4:/);
720
+ expect(output).not.toMatch(/(?<!_ios|_android|_web)_p_6:/);
721
+ expect(output).not.toMatch(/(?<!_ios|_android|_web)_p_8:/);
722
+ });
723
+ });