@mgcrea/react-native-tailwind 0.12.1 → 0.14.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 (104) hide show
  1. package/README.md +45 -2031
  2. package/dist/babel/index.cjs +1726 -1094
  3. package/dist/babel/plugin/componentScope.d.ts +26 -0
  4. package/dist/babel/plugin/componentScope.ts +87 -0
  5. package/dist/babel/plugin/state.d.ts +123 -0
  6. package/dist/babel/plugin/state.ts +185 -0
  7. package/dist/babel/plugin/visitors/className.d.ts +11 -0
  8. package/{src/babel/plugin.test.ts → dist/babel/plugin/visitors/className.test.ts} +285 -572
  9. package/dist/babel/plugin/visitors/className.ts +652 -0
  10. package/dist/babel/plugin/visitors/className.windowDimensions.test.ts +406 -0
  11. package/dist/babel/plugin/visitors/imports.d.ts +11 -0
  12. package/dist/babel/plugin/visitors/imports.test.ts +88 -0
  13. package/dist/babel/plugin/visitors/imports.ts +116 -0
  14. package/dist/babel/plugin/visitors/program.d.ts +15 -0
  15. package/dist/babel/plugin/visitors/program.test.ts +325 -0
  16. package/dist/babel/plugin/visitors/program.ts +116 -0
  17. package/dist/babel/plugin/visitors/tw.d.ts +16 -0
  18. package/dist/babel/plugin/visitors/tw.test.ts +771 -0
  19. package/dist/babel/plugin/visitors/tw.ts +148 -0
  20. package/dist/babel/plugin.d.ts +3 -96
  21. package/dist/babel/plugin.test.ts +470 -0
  22. package/dist/babel/plugin.ts +28 -963
  23. package/dist/babel/utils/colorSchemeModifierProcessing.ts +11 -0
  24. package/dist/babel/utils/componentSupport.test.ts +20 -7
  25. package/dist/babel/utils/componentSupport.ts +2 -0
  26. package/dist/babel/utils/directionalModifierProcessing.d.ts +34 -0
  27. package/dist/babel/utils/directionalModifierProcessing.ts +99 -0
  28. package/dist/babel/utils/modifierProcessing.ts +21 -0
  29. package/dist/babel/utils/platformModifierProcessing.ts +11 -0
  30. package/dist/babel/utils/styleInjection.d.ts +31 -0
  31. package/dist/babel/utils/styleInjection.ts +253 -7
  32. package/dist/babel/utils/twProcessing.d.ts +2 -0
  33. package/dist/babel/utils/twProcessing.ts +103 -3
  34. package/dist/babel/utils/windowDimensionsProcessing.d.ts +56 -0
  35. package/dist/babel/utils/windowDimensionsProcessing.ts +121 -0
  36. package/dist/components/TouchableOpacity.d.ts +35 -0
  37. package/dist/components/TouchableOpacity.js +1 -0
  38. package/dist/components/index.d.ts +3 -0
  39. package/dist/components/index.js +1 -0
  40. package/dist/config/markers.d.ts +5 -0
  41. package/dist/config/markers.js +1 -0
  42. package/dist/index.d.ts +2 -5
  43. package/dist/index.js +1 -1
  44. package/dist/parser/borders.d.ts +3 -1
  45. package/dist/parser/borders.js +1 -1
  46. package/dist/parser/borders.test.js +1 -1
  47. package/dist/parser/colors.js +1 -1
  48. package/dist/parser/colors.test.js +1 -1
  49. package/dist/parser/index.d.ts +2 -2
  50. package/dist/parser/index.js +1 -1
  51. package/dist/parser/layout.js +1 -1
  52. package/dist/parser/layout.test.js +1 -1
  53. package/dist/parser/modifiers.d.ts +32 -2
  54. package/dist/parser/modifiers.js +1 -1
  55. package/dist/parser/modifiers.test.js +1 -1
  56. package/dist/parser/sizing.js +1 -1
  57. package/dist/parser/spacing.d.ts +1 -1
  58. package/dist/parser/spacing.js +1 -1
  59. package/dist/parser/spacing.test.js +1 -1
  60. package/dist/parser/typography.test.js +1 -1
  61. package/dist/runtime.cjs +1 -1
  62. package/dist/runtime.cjs.map +4 -4
  63. package/dist/runtime.js +1 -1
  64. package/dist/runtime.js.map +4 -4
  65. package/package.json +6 -6
  66. package/src/babel/plugin/componentScope.ts +87 -0
  67. package/src/babel/plugin/state.ts +185 -0
  68. package/src/babel/plugin/visitors/className.test.ts +1625 -0
  69. package/src/babel/plugin/visitors/className.ts +652 -0
  70. package/src/babel/plugin/visitors/className.windowDimensions.test.ts +406 -0
  71. package/src/babel/plugin/visitors/imports.test.ts +88 -0
  72. package/src/babel/plugin/visitors/imports.ts +116 -0
  73. package/src/babel/plugin/visitors/program.test.ts +325 -0
  74. package/src/babel/plugin/visitors/program.ts +116 -0
  75. package/src/babel/plugin/visitors/tw.test.ts +771 -0
  76. package/src/babel/plugin/visitors/tw.ts +148 -0
  77. package/src/babel/plugin.ts +28 -963
  78. package/src/babel/utils/colorSchemeModifierProcessing.ts +11 -0
  79. package/src/babel/utils/componentSupport.test.ts +20 -7
  80. package/src/babel/utils/componentSupport.ts +2 -0
  81. package/src/babel/utils/directionalModifierProcessing.ts +99 -0
  82. package/src/babel/utils/modifierProcessing.ts +21 -0
  83. package/src/babel/utils/platformModifierProcessing.ts +11 -0
  84. package/src/babel/utils/styleInjection.ts +253 -7
  85. package/src/babel/utils/twProcessing.ts +103 -3
  86. package/src/babel/utils/windowDimensionsProcessing.ts +121 -0
  87. package/src/components/TouchableOpacity.tsx +71 -0
  88. package/src/components/index.ts +3 -0
  89. package/src/config/markers.ts +5 -0
  90. package/src/index.ts +4 -5
  91. package/src/parser/borders.test.ts +162 -0
  92. package/src/parser/borders.ts +67 -9
  93. package/src/parser/colors.test.ts +249 -0
  94. package/src/parser/colors.ts +38 -0
  95. package/src/parser/index.ts +4 -2
  96. package/src/parser/layout.test.ts +74 -0
  97. package/src/parser/layout.ts +94 -0
  98. package/src/parser/modifiers.test.ts +206 -0
  99. package/src/parser/modifiers.ts +62 -3
  100. package/src/parser/sizing.ts +11 -0
  101. package/src/parser/spacing.test.ts +66 -0
  102. package/src/parser/spacing.ts +15 -5
  103. package/src/parser/typography.test.ts +8 -0
  104. package/src/parser/typography.ts +4 -0
@@ -0,0 +1,1625 @@
1
+ /* eslint-disable @typescript-eslint/no-empty-function */
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { transform } from "../../../../test/helpers/babelTransform.js";
4
+
5
+ describe("className visitor - basic transformation", () => {
6
+ it("should still transform className props", () => {
7
+ const input = `
8
+ import { View } from 'react-native';
9
+ export function Component() {
10
+ return <View className="m-4 p-2 bg-blue-500" />;
11
+ }
12
+ `;
13
+
14
+ const output = transform(input, undefined, true); // Enable JSX
15
+
16
+ // Should have StyleSheet
17
+ expect(output).toContain("StyleSheet.create");
18
+ expect(output).toContain("_twStyles");
19
+
20
+ // Should replace className with style
21
+ expect(output).not.toContain("className");
22
+ expect(output).toContain("style:");
23
+ });
24
+
25
+ it("should work with both tw and className in same file", () => {
26
+ const input = `
27
+ import { tw } from '@mgcrea/react-native-tailwind';
28
+ import { View } from 'react-native';
29
+
30
+ const styles = tw\`bg-red-500\`;
31
+
32
+ export function Component() {
33
+ return <View className="m-4 p-2" />;
34
+ }
35
+ `;
36
+
37
+ const output = transform(input, undefined, true); // Enable JSX
38
+
39
+ // Should have both styles in StyleSheet
40
+ expect(output).toContain("_bg_red_500");
41
+ expect(output).toContain("_m_4_p_2");
42
+ });
43
+
44
+ it("should merge className with function-based style prop", () => {
45
+ const input = `
46
+ import { TextInput } from 'react-native';
47
+ export function Component() {
48
+ return (
49
+ <TextInput
50
+ className="border border-gray-300 bg-gray-100"
51
+ style={({ focused, disabled }) => [
52
+ baseStyles,
53
+ focused && focusedStyles,
54
+ ]}
55
+ />
56
+ );
57
+ }
58
+ `;
59
+
60
+ const output = transform(input, undefined, true); // Enable JSX
61
+
62
+ // Should have StyleSheet with className styles
63
+ expect(output).toContain("StyleSheet.create");
64
+ // Style keys are sorted alphabetically: bg-gray-100 comes before border
65
+ expect(output).toContain("_bg_gray_100_border_border_gray_300");
66
+
67
+ // Should create a wrapper function that merges both
68
+ // The wrapper should call the original function and merge results
69
+ expect(output).toContain("_state");
70
+ expect(output).toContain("_twStyles._bg_gray_100_border_border_gray_300");
71
+
72
+ // Should not have className in output
73
+ expect(output).not.toContain("className");
74
+
75
+ // Should have a function that accepts state and returns an array
76
+ expect(output).toMatch(/_state\s*=>/);
77
+ });
78
+
79
+ it("should preserve 'use client' directive when injecting StyleSheet.create", () => {
80
+ const input = `
81
+ 'use client';
82
+ import { View } from 'react-native';
83
+ export function Component() {
84
+ return <View className="m-4 p-2" />;
85
+ }
86
+ `;
87
+
88
+ const output = transform(input, undefined, true);
89
+
90
+ // 'use client' should be the first statement
91
+ const lines = output.split("\n").filter((l: string) => l.trim());
92
+ const useClientIndex = lines.findIndex(
93
+ (l: string) => l.includes("'use client'") || l.includes('"use client"'),
94
+ );
95
+ expect(useClientIndex).toBe(0);
96
+
97
+ // StyleSheet.create should be in the output
98
+ expect(output).toContain("StyleSheet.create");
99
+ expect(output).toContain("_twStyles");
100
+
101
+ // Imports should come after 'use client', before StyleSheet.create
102
+ const importIndex = lines.findIndex((l: string) => l.includes("import"));
103
+ const styleSheetIndex = lines.findIndex((l: string) => l.includes("StyleSheet.create"));
104
+ expect(importIndex).toBeGreaterThan(useClientIndex);
105
+ expect(styleSheetIndex).toBeGreaterThan(importIndex);
106
+ });
107
+
108
+ it("should merge dynamic className with function-based style prop", () => {
109
+ const input = `
110
+ import { TextInput } from 'react-native';
111
+ export function Component({ isError }) {
112
+ return (
113
+ <TextInput
114
+ className={\`border \${isError ? 'border-red-500' : 'border-gray-300'}\`}
115
+ style={({ focused }) => [
116
+ baseStyles,
117
+ focused && focusedStyles,
118
+ ]}
119
+ />
120
+ );
121
+ }
122
+ `;
123
+
124
+ const output = transform(input, undefined, true); // Enable JSX
125
+
126
+ // Should have StyleSheet with both className styles
127
+ expect(output).toContain("StyleSheet.create");
128
+ expect(output).toContain("_border");
129
+ expect(output).toContain("_border_red_500");
130
+ expect(output).toContain("_border_gray_300");
131
+
132
+ // Should create a wrapper function that merges dynamic styles with function result
133
+ expect(output).toContain("_state");
134
+
135
+ // Should not have className in output
136
+ expect(output).not.toContain("className");
137
+ });
138
+
139
+ it('should transform className={"..."} (string literal in expression container)', () => {
140
+ const input = `
141
+ import { View } from 'react-native';
142
+ export function Component() {
143
+ return <View className={"flex-row items-center justify-start"} />;
144
+ }
145
+ `;
146
+
147
+ const output = transform(input, undefined, true);
148
+
149
+ // Should have StyleSheet
150
+ expect(output).toContain("StyleSheet.create");
151
+ expect(output).toContain("_twStyles");
152
+
153
+ // Should replace className with style
154
+ expect(output).not.toContain("className");
155
+ expect(output).toContain("style:");
156
+
157
+ // Should have the expected style keys
158
+ expect(output).toContain("_flex_row_items_center_justify_start");
159
+ });
160
+
161
+ it('should transform className={"..."} with modifiers', () => {
162
+ const input = `
163
+ import { Pressable } from 'react-native';
164
+ export function Component() {
165
+ return <Pressable className={"bg-blue-500 active:bg-blue-700 p-4"} />;
166
+ }
167
+ `;
168
+
169
+ const output = transform(input, undefined, true);
170
+
171
+ // Should have StyleSheet with both base and active styles
172
+ expect(output).toContain("_bg_blue_500_p_4");
173
+ expect(output).toContain("_active_bg_blue_700");
174
+
175
+ // Should have style function for active modifier (Pressable uses 'pressed' parameter)
176
+ expect(output).toMatch(/(pressed|_state)/);
177
+
178
+ // Should not have className in output
179
+ expect(output).not.toContain("className");
180
+ });
181
+
182
+ it('should transform className={"..."} with platform modifiers', () => {
183
+ const input = `
184
+ import { View } from 'react-native';
185
+ export function Component() {
186
+ return <View className={"p-4 ios:p-6 android:p-8"} />;
187
+ }
188
+ `;
189
+
190
+ const output = transform(input, undefined, true);
191
+
192
+ // Should have Platform import
193
+ expect(output).toContain("Platform");
194
+ expect(output).toMatch(/from ['"]react-native['"]/); // Match both single and double quotes
195
+
196
+ // Should have Platform.select
197
+ expect(output).toContain("Platform.select");
198
+
199
+ // Should have platform-specific styles
200
+ expect(output).toContain("_ios_p_6");
201
+ expect(output).toContain("_android_p_8");
202
+
203
+ // Should not have className in output
204
+ expect(output).not.toContain("className");
205
+ });
206
+
207
+ it('should handle empty className={""}', () => {
208
+ const input = `
209
+ import { View } from 'react-native';
210
+ export function Component() {
211
+ return <View className={""} />;
212
+ }
213
+ `;
214
+
215
+ const output = transform(input, undefined, true);
216
+
217
+ // Should remove empty className attribute entirely
218
+ expect(output).not.toContain("className");
219
+ expect(output).not.toContain("style=");
220
+ });
221
+ });
222
+
223
+ describe("className visitor - placeholder: modifier", () => {
224
+ it("should transform placeholder:text-{color} to placeholderTextColor prop", () => {
225
+ const input = `
226
+ import { TextInput } from 'react-native';
227
+ export function Component() {
228
+ return (
229
+ <TextInput
230
+ className="border-2 placeholder:text-gray-400"
231
+ placeholder="Email"
232
+ />
233
+ );
234
+ }
235
+ `;
236
+
237
+ const output = transform(input, undefined, true);
238
+
239
+ // Should have placeholderTextColor prop with correct hex value (from custom palette)
240
+ expect(output).toContain('placeholderTextColor: "#99a1af"');
241
+
242
+ // Should still have style for border-2
243
+ expect(output).toContain("StyleSheet.create");
244
+ expect(output).toContain("_border_2");
245
+
246
+ // Should not have className in output
247
+ expect(output).not.toContain("className");
248
+ });
249
+
250
+ it("should support placeholder colors with opacity", () => {
251
+ const input = `
252
+ import { TextInput } from 'react-native';
253
+ export function Component() {
254
+ return <TextInput className="placeholder:text-red-500/50" />;
255
+ }
256
+ `;
257
+
258
+ const output = transform(input, undefined, true);
259
+
260
+ // Should have 8-digit hex with alpha channel (custom palette red-500, uppercased)
261
+ expect(output).toContain('placeholderTextColor: "#FB2C3680"');
262
+ });
263
+
264
+ it("should support arbitrary placeholder colors", () => {
265
+ const input = `
266
+ import { TextInput } from 'react-native';
267
+ export function Component() {
268
+ return <TextInput className="placeholder:text-[#ff0000]" />;
269
+ }
270
+ `;
271
+
272
+ const output = transform(input, undefined, true);
273
+
274
+ expect(output).toContain('placeholderTextColor: "#ff0000"');
275
+ });
276
+
277
+ it("should combine placeholder: with other modifiers", () => {
278
+ const input = `
279
+ import { TextInput } from 'react-native';
280
+ export function Component() {
281
+ return (
282
+ <TextInput
283
+ className="border-2 focus:border-blue-500 placeholder:text-gray-400"
284
+ placeholder="Email"
285
+ />
286
+ );
287
+ }
288
+ `;
289
+
290
+ const output = transform(input, undefined, true);
291
+
292
+ // Should have placeholderTextColor prop (custom palette gray-400)
293
+ expect(output).toContain('placeholderTextColor: "#99a1af"');
294
+
295
+ // Should have focus: modifier handling (style function)
296
+ expect(output).toContain("focused");
297
+ expect(output).toMatch(/style[\s\S]*=>/); // Style function
298
+
299
+ // Should not have className
300
+ expect(output).not.toContain("className");
301
+ });
302
+
303
+ it("should handle multiple placeholder: classes (last wins)", () => {
304
+ const input = `
305
+ import { TextInput } from 'react-native';
306
+ export function Component() {
307
+ return (
308
+ <TextInput className="placeholder:text-red-500 placeholder:text-blue-500" />
309
+ );
310
+ }
311
+ `;
312
+
313
+ const output = transform(input, undefined, true);
314
+
315
+ // Blue should win (last color, custom palette blue-500)
316
+ expect(output).toContain('placeholderTextColor: "#2b7fff"');
317
+ });
318
+
319
+ it("should ignore non-text utilities in placeholder: modifier", () => {
320
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
321
+
322
+ const input = `
323
+ import { TextInput } from 'react-native';
324
+ export function Component() {
325
+ return (
326
+ <TextInput className="placeholder:font-bold placeholder:text-gray-400" />
327
+ );
328
+ }
329
+ `;
330
+
331
+ const output = transform(input, undefined, true);
332
+
333
+ // Should still have the valid text color (custom palette gray-400)
334
+ expect(output).toContain('placeholderTextColor: "#99a1af"');
335
+
336
+ // Should not have font-bold anywhere
337
+ expect(output).not.toContain("fontWeight");
338
+
339
+ consoleSpy.mockRestore();
340
+ });
341
+
342
+ it.skip("should work with custom colors", () => {
343
+ // Note: This test would require setting up a tailwind.config file
344
+ // For now, we'll skip custom color testing in Babel tests
345
+ // Custom colors are tested in the parser tests
346
+ });
347
+
348
+ it("should not transform placeholder: on non-TextInput elements", () => {
349
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
350
+
351
+ const input = `
352
+ import { View } from 'react-native';
353
+ export function Component() {
354
+ return <View className="placeholder:text-gray-400" />;
355
+ }
356
+ `;
357
+
358
+ const output = transform(input, undefined, true);
359
+
360
+ // Should not have placeholderTextColor prop (View doesn't support it)
361
+ expect(output).not.toContain("placeholderTextColor");
362
+
363
+ // Should warn about unsupported modifier
364
+ // (The warning happens because View doesn't support any modifiers)
365
+
366
+ consoleSpy.mockRestore();
367
+ });
368
+ });
369
+
370
+ describe("className visitor - platform modifiers", () => {
371
+ it("should transform platform modifiers to Platform.select()", () => {
372
+ const input = `
373
+ import React from 'react';
374
+ import { View } from 'react-native';
375
+
376
+ export function Component() {
377
+ return (
378
+ <View className="p-4 ios:p-6 android:p-8" />
379
+ );
380
+ }
381
+ `;
382
+
383
+ const output = transform(input, undefined, true);
384
+
385
+ // Should import Platform from react-native
386
+ expect(output).toContain("Platform");
387
+ expect(output).toMatch(/import.*Platform.*from ['"]react-native['"]/);
388
+
389
+ // Should generate Platform.select()
390
+ expect(output).toContain("Platform.select");
391
+
392
+ // Should have base padding style
393
+ expect(output).toContain("_p_4");
394
+
395
+ // Should have iOS and Android specific styles
396
+ expect(output).toContain("_ios_p_6");
397
+ expect(output).toContain("_android_p_8");
398
+
399
+ // Should have correct style values in StyleSheet.create
400
+ expect(output).toMatch(/padding:\s*16/); // p-4
401
+ expect(output).toMatch(/padding:\s*24/); // p-6 (ios)
402
+ expect(output).toMatch(/padding:\s*32/); // p-8 (android)
403
+ });
404
+
405
+ it("should support multiple platform modifiers on same element", () => {
406
+ const input = `
407
+ import React from 'react';
408
+ import { View } from 'react-native';
409
+
410
+ export function Component() {
411
+ return (
412
+ <View className="bg-white ios:bg-blue-50 android:bg-green-50 p-4 ios:p-6 android:p-8" />
413
+ );
414
+ }
415
+ `;
416
+
417
+ const output = transform(input, undefined, true);
418
+
419
+ // Should have Platform import
420
+ expect(output).toContain("Platform");
421
+
422
+ // Should have base styles (combined key)
423
+ expect(output).toContain("_bg_white_p_4");
424
+
425
+ // Should have iOS specific styles (combined key for multiple ios: modifiers)
426
+ expect(output).toContain("_ios_bg_blue_50_p_6");
427
+
428
+ // Should have Android specific styles (combined key for multiple android: modifiers)
429
+ expect(output).toContain("_android_bg_green_50_p_8");
430
+
431
+ // Should contain Platform.select with both platforms
432
+ expect(output).toMatch(/Platform\.select\s*\(\s*\{[\s\S]*ios:/);
433
+ expect(output).toMatch(/Platform\.select\s*\(\s*\{[\s\S]*android:/);
434
+ });
435
+
436
+ it("should support web platform modifier", () => {
437
+ const input = `
438
+ import React from 'react';
439
+ import { View } from 'react-native';
440
+
441
+ export function Component() {
442
+ return (
443
+ <View className="p-4 web:p-2" />
444
+ );
445
+ }
446
+ `;
447
+
448
+ const output = transform(input, undefined, true);
449
+
450
+ // Should have Platform.select with web
451
+ expect(output).toContain("Platform.select");
452
+ expect(output).toContain("web:");
453
+ expect(output).toContain("_web_p_2");
454
+ });
455
+
456
+ it("should work with platform modifiers on all components", () => {
457
+ const input = `
458
+ import React from 'react';
459
+ import { View, Text, ScrollView } from 'react-native';
460
+
461
+ export function Component() {
462
+ return (
463
+ <View className="ios:bg-blue-500 android:bg-green-500">
464
+ <Text className="ios:text-lg android:text-xl">Platform text</Text>
465
+ <ScrollView contentContainerClassName="ios:p-4 android:p-8" />
466
+ </View>
467
+ );
468
+ }
469
+ `;
470
+
471
+ const output = transform(input, undefined, true);
472
+
473
+ // Should work on View - check for Platform.select separately (not checking style= format)
474
+ expect(output).toContain("Platform.select");
475
+
476
+ // Should work on Text
477
+ expect(output).toContain("_ios_text_lg");
478
+ expect(output).toContain("_android_text_xl");
479
+
480
+ // Should work on ScrollView contentContainerStyle
481
+ expect(output).toContain("contentContainerStyle");
482
+ });
483
+
484
+ it("should combine platform modifiers with state modifiers", () => {
485
+ const input = `
486
+ import React from 'react';
487
+ import { Pressable, Text } from 'react-native';
488
+
489
+ export function Component() {
490
+ return (
491
+ <Pressable className="bg-blue-500 active:bg-blue-700 ios:shadow-md android:shadow-sm p-4">
492
+ <Text className="text-white">Button</Text>
493
+ </Pressable>
494
+ );
495
+ }
496
+ `;
497
+
498
+ const output = transform(input, undefined, true);
499
+
500
+ // Should have Platform.select for platform modifiers
501
+ expect(output).toContain("Platform.select");
502
+ expect(output).toContain("_ios_shadow_md");
503
+ expect(output).toContain("_android_shadow_sm");
504
+
505
+ // Should have state modifier function for active
506
+ expect(output).toMatch(/\(\s*\{\s*pressed\s*\}\s*\)\s*=>/);
507
+ expect(output).toContain("pressed");
508
+ expect(output).toContain("_active_bg_blue_700");
509
+
510
+ // Should have base styles
511
+ expect(output).toContain("_bg_blue_500");
512
+ expect(output).toContain("_p_4");
513
+ });
514
+
515
+ it("should handle platform-specific colors", () => {
516
+ const input = `
517
+ import React from 'react';
518
+ import { View, Text } from 'react-native';
519
+
520
+ export function Component() {
521
+ return (
522
+ <View className="bg-gray-100 ios:bg-blue-50 android:bg-green-50">
523
+ <Text className="text-gray-900 ios:text-blue-900 android:text-green-900">
524
+ Platform colors
525
+ </Text>
526
+ </View>
527
+ );
528
+ }
529
+ `;
530
+
531
+ const output = transform(input, undefined, true);
532
+
533
+ // Should have color values in StyleSheet
534
+ expect(output).toMatch(/#[0-9A-F]{6}/i); // Hex color format
535
+
536
+ // Should have platform-specific color classes
537
+ expect(output).toContain("_ios_text_blue_900");
538
+ expect(output).toContain("_android_text_green_900");
539
+ });
540
+
541
+ it("should only add Platform import once when needed", () => {
542
+ const input = `
543
+ import React from 'react';
544
+ import { View } from 'react-native';
545
+
546
+ export function Component() {
547
+ return (
548
+ <>
549
+ <View className="ios:p-4" />
550
+ <View className="android:p-8" />
551
+ <View className="ios:bg-blue-500" />
552
+ </>
553
+ );
554
+ }
555
+ `;
556
+
557
+ const output = transform(input, undefined, true);
558
+
559
+ // Should have Platform import
560
+ expect(output).toContain("Platform");
561
+
562
+ // Count how many times Platform is imported (should be once)
563
+ const platformImports = output.match(/import.*Platform.*from ['"]react-native['"]/g);
564
+ expect(platformImports).toHaveLength(1);
565
+ });
566
+
567
+ it("should merge with existing Platform import", () => {
568
+ const input = `
569
+ import React from 'react';
570
+ import { View, Platform } from 'react-native';
571
+
572
+ export function Component() {
573
+ return <View className="ios:p-4 android:p-8" />;
574
+ }
575
+ `;
576
+
577
+ const output = transform(input, undefined, true);
578
+
579
+ // Should still use Platform.select
580
+ expect(output).toContain("Platform.select");
581
+
582
+ // Should not duplicate Platform import - Platform appears in import and Platform.select calls
583
+ expect(output).toMatch(/Platform.*react-native/);
584
+ });
585
+
586
+ it("should handle platform modifiers without base classes", () => {
587
+ const input = `
588
+ import React from 'react';
589
+ import { View } from 'react-native';
590
+
591
+ export function Component() {
592
+ return <View className="ios:p-6 android:p-8" />;
593
+ }
594
+ `;
595
+
596
+ const output = transform(input, undefined, true);
597
+
598
+ // Should only have Platform.select, no base style
599
+ expect(output).toContain("Platform.select");
600
+ expect(output).toContain("_ios_p_6");
601
+ expect(output).toContain("_android_p_8");
602
+
603
+ // Should not have generic padding without platform prefix
604
+ // Check that non-platform-prefixed style keys don't exist
605
+ expect(output).not.toMatch(/(?<!_ios|_android|_web)_p_4:/);
606
+ expect(output).not.toMatch(/(?<!_ios|_android|_web)_p_6:/);
607
+ expect(output).not.toMatch(/(?<!_ios|_android|_web)_p_8:/);
608
+ });
609
+ });
610
+
611
+ describe("className visitor - color scheme modifiers", () => {
612
+ it("should transform dark: modifier to conditional expression", () => {
613
+ const input = `
614
+ import React from 'react';
615
+ import { View } from 'react-native';
616
+
617
+ export function Component() {
618
+ return (
619
+ <View className="bg-white dark:bg-gray-900" />
620
+ );
621
+ }
622
+ `;
623
+
624
+ const output = transform(input, undefined, true);
625
+
626
+ // Should import useColorScheme
627
+ expect(output).toContain("useColorScheme");
628
+ expect(output).toMatch(/import.*useColorScheme.*from ['"]react-native['"]/);
629
+
630
+ // Should inject colorScheme hook in component
631
+ expect(output).toContain("_twColorScheme");
632
+ expect(output).toContain("useColorScheme()");
633
+
634
+ // Should have base bg-white style
635
+ expect(output).toContain("_bg_white");
636
+
637
+ // Should have dark:bg-gray-900 style
638
+ expect(output).toContain("_dark_bg_gray_900");
639
+
640
+ // Should generate conditional: _twColorScheme === 'dark' && ...
641
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
642
+ });
643
+
644
+ it("should support both dark: and light: modifiers", () => {
645
+ const input = `
646
+ import React from 'react';
647
+ import { View } from 'react-native';
648
+
649
+ export function Component() {
650
+ return (
651
+ <View className="bg-gray-100 dark:bg-gray-900 light:bg-white" />
652
+ );
653
+ }
654
+ `;
655
+
656
+ const output = transform(input, undefined, true);
657
+
658
+ // Should have all three styles
659
+ expect(output).toContain("_bg_gray_100");
660
+ expect(output).toContain("_dark_bg_gray_900");
661
+ expect(output).toContain("_light_bg_white");
662
+
663
+ // Should have both conditionals
664
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
665
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]light['"]/);
666
+ });
667
+
668
+ it("should inject hook once for multiple elements with color scheme modifiers", () => {
669
+ const input = `
670
+ import React from 'react';
671
+ import { View, Text } from 'react-native';
672
+
673
+ export function Component() {
674
+ return (
675
+ <>
676
+ <View className="dark:bg-gray-900" />
677
+ <Text className="dark:text-white" />
678
+ <View className="light:bg-white" />
679
+ </>
680
+ );
681
+ }
682
+ `;
683
+
684
+ const output = transform(input, undefined, true);
685
+
686
+ // Count occurrences of useColorScheme() call - should be exactly 1
687
+ const hookCallMatches = output.match(/=\s*useColorScheme\(\)/g);
688
+ expect(hookCallMatches).toHaveLength(1);
689
+
690
+ // Should have color scheme variable
691
+ expect(output).toContain("_twColorScheme");
692
+ });
693
+
694
+ it("should work with color scheme and platform modifiers together", () => {
695
+ const input = `
696
+ import React from 'react';
697
+ import { View } from 'react-native';
698
+
699
+ export function Component() {
700
+ return (
701
+ <View className="p-4 ios:p-6 dark:bg-gray-900" />
702
+ );
703
+ }
704
+ `;
705
+
706
+ const output = transform(input, undefined, true);
707
+
708
+ // Should have Platform import
709
+ expect(output).toContain("Platform");
710
+
711
+ // Should have useColorScheme import
712
+ expect(output).toContain("useColorScheme");
713
+
714
+ // Should have Platform.select for ios:
715
+ expect(output).toContain("Platform.select");
716
+ expect(output).toContain("_ios_p_6");
717
+
718
+ // Should have color scheme conditional for dark:
719
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
720
+ expect(output).toContain("_dark_bg_gray_900");
721
+ });
722
+
723
+ it("should only add useColorScheme import once when needed", () => {
724
+ const input = `
725
+ import React from 'react';
726
+ import { View } from 'react-native';
727
+
728
+ export function Component() {
729
+ return (
730
+ <>
731
+ <View className="dark:bg-black" />
732
+ <View className="light:bg-white" />
733
+ </>
734
+ );
735
+ }
736
+ `;
737
+
738
+ const output = transform(input, undefined, true);
739
+
740
+ // Count useColorScheme imports
741
+ const importMatches = output.match(/import.*useColorScheme.*from ['"]react-native['"]/g);
742
+ expect(importMatches).toHaveLength(1);
743
+ });
744
+
745
+ it("should merge with existing useColorScheme import", () => {
746
+ const input = `
747
+ import React from 'react';
748
+ import { View, useColorScheme } from 'react-native';
749
+
750
+ export function Component() {
751
+ return <View className="dark:bg-gray-900" />;
752
+ }
753
+ `;
754
+
755
+ const output = transform(input, undefined, true);
756
+
757
+ // Should still use useColorScheme
758
+ expect(output).toContain("useColorScheme");
759
+
760
+ // Should inject hook call
761
+ expect(output).toContain("_twColorScheme");
762
+ expect(output).toContain("useColorScheme()");
763
+ });
764
+
765
+ it("should work with concise arrow functions", () => {
766
+ const input = `
767
+ import React from 'react';
768
+ import { View } from 'react-native';
769
+
770
+ const Component = () => <View className="dark:bg-gray-900" />;
771
+ `;
772
+
773
+ const output = transform(input, undefined, true);
774
+
775
+ // Should inject useColorScheme import
776
+ expect(output).toContain("useColorScheme");
777
+
778
+ // Should convert concise arrow to block statement and inject hook
779
+ expect(output).toContain("_twColorScheme");
780
+ expect(output).toContain("useColorScheme()");
781
+ expect(output).toContain("return");
782
+
783
+ // Should have the style
784
+ expect(output).toContain("_dark_bg_gray_900");
785
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
786
+ });
787
+
788
+ it("should inject hook at component level when dark: used in nested callback", () => {
789
+ const input = `
790
+ import React from 'react';
791
+ import { View } from 'react-native';
792
+
793
+ export function Component() {
794
+ const items = [1, 2, 3];
795
+ return (
796
+ <View>
797
+ {items.map(item => (
798
+ <View key={item} className="dark:bg-gray-900" />
799
+ ))}
800
+ </View>
801
+ );
802
+ }
803
+ `;
804
+
805
+ const output = transform(input, undefined, true);
806
+
807
+ // Should inject hook at Component level (not in map callback)
808
+ expect(output).toContain("_twColorScheme");
809
+ expect(output).toContain("useColorScheme()");
810
+
811
+ // Hook should be injected in Component function, not in map callback
812
+ // Count occurrences - should be exactly 1 at Component level
813
+ const hookCallMatches = output.match(/=\s*useColorScheme\(\)/g);
814
+ expect(hookCallMatches).toHaveLength(1);
815
+
816
+ // Should still generate conditional expression
817
+ expect(output).toContain("_dark_bg_gray_900");
818
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
819
+ });
820
+
821
+ it("should handle dynamic expressions with dark:/light: modifiers", () => {
822
+ const input = `
823
+ import React from 'react';
824
+ import { View } from 'react-native';
825
+
826
+ export function Component({ isActive }) {
827
+ return (
828
+ <View className={\`p-4 \${isActive ? "dark:bg-blue-500" : "dark:bg-gray-900"}\`} />
829
+ );
830
+ }
831
+ `;
832
+
833
+ const output = transform(input, undefined, true);
834
+
835
+ // Should inject useColorScheme
836
+ expect(output).toContain("useColorScheme");
837
+ expect(output).toContain("_twColorScheme");
838
+
839
+ // Should have both dark styles
840
+ expect(output).toContain("_dark_bg_blue_500");
841
+ expect(output).toContain("_dark_bg_gray_900");
842
+
843
+ // Should have conditional expressions for color scheme
844
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
845
+ });
846
+
847
+ it("should handle dynamic expressions with platform modifiers", () => {
848
+ const input = `
849
+ import React from 'react';
850
+ import { View } from 'react-native';
851
+
852
+ export function Component({ isLarge }) {
853
+ return (
854
+ <View className={\`p-4 \${isLarge ? "ios:p-8" : "ios:p-6"}\`} />
855
+ );
856
+ }
857
+ `;
858
+
859
+ const output = transform(input, undefined, true);
860
+
861
+ // Should inject Platform import
862
+ expect(output).toContain("Platform");
863
+
864
+ // Should have both ios styles
865
+ expect(output).toContain("_ios_p_8");
866
+ expect(output).toContain("_ios_p_6");
867
+
868
+ // Should have Platform.select
869
+ expect(output).toContain("Platform.select");
870
+ });
871
+
872
+ it("should skip color scheme modifiers when used outside component scope", () => {
873
+ // Suppress console.warn for this test
874
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
875
+
876
+ const input = `
877
+ import React from 'react';
878
+ import { View } from 'react-native';
879
+
880
+ // Class component - no function component scope
881
+ class MyComponent extends React.Component {
882
+ render() {
883
+ return <View className="p-4 dark:bg-gray-900" />;
884
+ }
885
+ }
886
+ `;
887
+
888
+ const output = transform(input, undefined, true);
889
+
890
+ // Should warn about invalid context
891
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
892
+ expect.stringContaining("dark:/light: modifiers require a function component scope"),
893
+ );
894
+
895
+ // Should NOT inject useColorScheme import (no valid component scope)
896
+ expect(output).not.toContain("useColorScheme");
897
+
898
+ // Should NOT have _twColorScheme variable reference (would cause ReferenceError)
899
+ expect(output).not.toContain("_twColorScheme");
900
+
901
+ // Should NOT have dark: style conditional (skipped due to no component scope)
902
+ expect(output).not.toContain("_dark_bg_gray_900");
903
+
904
+ // Should still transform base classes (p-4)
905
+ expect(output).toContain("_p_4");
906
+
907
+ consoleWarnSpy.mockRestore();
908
+ });
909
+ });
910
+
911
+ describe("className visitor - custom color scheme hook", () => {
912
+ it("should use custom import source for color scheme hook", () => {
913
+ const input = `
914
+ import React from 'react';
915
+ import { View } from 'react-native';
916
+
917
+ export function Component() {
918
+ return <View className="dark:bg-gray-900" />;
919
+ }
920
+ `;
921
+
922
+ const output = transform(
923
+ input,
924
+ {
925
+ colorScheme: {
926
+ importFrom: "@/hooks/useColorScheme",
927
+ importName: "useColorScheme",
928
+ },
929
+ },
930
+ true,
931
+ );
932
+
933
+ // Should import from custom source
934
+ expect(output).toContain('from "@/hooks/useColorScheme"');
935
+ expect(output).not.toContain('useColorScheme } from "react-native"');
936
+
937
+ // Should inject hook call
938
+ expect(output).toContain("_twColorScheme = useColorScheme()");
939
+
940
+ // Should have conditional styling
941
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
942
+ });
943
+
944
+ it("should use custom hook name", () => {
945
+ const input = `
946
+ import React from 'react';
947
+ import { View } from 'react-native';
948
+
949
+ export function Component() {
950
+ return <View className="dark:bg-gray-900" />;
951
+ }
952
+ `;
953
+
954
+ const output = transform(
955
+ input,
956
+ {
957
+ colorScheme: {
958
+ importFrom: "@react-navigation/native",
959
+ importName: "useTheme",
960
+ },
961
+ },
962
+ true,
963
+ );
964
+
965
+ // Should import useTheme from React Navigation
966
+ expect(output).toContain('from "@react-navigation/native"');
967
+ expect(output).toContain("useTheme");
968
+
969
+ // Should call useTheme hook
970
+ expect(output).toContain("_twColorScheme = useTheme()");
971
+
972
+ // Should have conditional styling
973
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
974
+ });
975
+
976
+ it("should merge custom hook with existing import from same source", () => {
977
+ const input = `
978
+ import React from 'react';
979
+ import { View, Text } from 'react-native';
980
+ import { useNavigation } from '@react-navigation/native';
981
+
982
+ export function Component() {
983
+ const navigation = useNavigation();
984
+ return (
985
+ <View className="dark:bg-gray-900">
986
+ <Text onPress={() => navigation.navigate('Home')}>Go Home</Text>
987
+ </View>
988
+ );
989
+ }
990
+ `;
991
+
992
+ const output = transform(
993
+ input,
994
+ {
995
+ colorScheme: {
996
+ importFrom: "@react-navigation/native",
997
+ importName: "useTheme",
998
+ },
999
+ },
1000
+ true,
1001
+ );
1002
+
1003
+ // Should merge with existing import (both useNavigation and useTheme in same import)
1004
+ expect(output).toMatch(
1005
+ /import\s+\{\s*useNavigation[^}]*useTheme[^}]*\}\s+from\s+['"]@react-navigation\/native['"]/,
1006
+ );
1007
+ expect(output).toContain("useNavigation()");
1008
+ expect(output).toContain("useTheme()");
1009
+
1010
+ // Should only have one import from that source
1011
+ const importCount = (output.match(/@react-navigation\/native/g) ?? []).length;
1012
+ expect(importCount).toBe(1);
1013
+ });
1014
+
1015
+ it("should not duplicate custom hook if already imported", () => {
1016
+ const input = `
1017
+ import React from 'react';
1018
+ import { View } from 'react-native';
1019
+ import { useColorScheme } from '@/hooks/useColorScheme';
1020
+
1021
+ export function Component() {
1022
+ return <View className="dark:bg-gray-900" />;
1023
+ }
1024
+ `;
1025
+
1026
+ const output = transform(
1027
+ input,
1028
+ {
1029
+ colorScheme: {
1030
+ importFrom: "@/hooks/useColorScheme",
1031
+ importName: "useColorScheme",
1032
+ },
1033
+ },
1034
+ true,
1035
+ );
1036
+
1037
+ // Should not add duplicate import
1038
+ const importMatches = output.match(/import.*useColorScheme.*from ['"]@\/hooks\/useColorScheme['"]/g);
1039
+ expect(importMatches).toHaveLength(1);
1040
+
1041
+ // Should still inject hook call
1042
+ expect(output).toContain("_twColorScheme = useColorScheme()");
1043
+ });
1044
+
1045
+ it("should use react-native by default when no custom config provided", () => {
1046
+ const input = `
1047
+ import React from 'react';
1048
+ import { View } from 'react-native';
1049
+
1050
+ export function Component() {
1051
+ return <View className="dark:bg-gray-900" />;
1052
+ }
1053
+ `;
1054
+
1055
+ const output = transform(input, undefined, true);
1056
+
1057
+ // Should use default react-native import (can be single or double quotes)
1058
+ expect(output).toMatch(/useColorScheme\s*}\s*from\s+['"]react-native['"]/);
1059
+ expect(output).not.toContain("@/hooks");
1060
+ expect(output).not.toContain("@react-navigation");
1061
+
1062
+ // Should inject hook call with default name
1063
+ expect(output).toContain("_twColorScheme = useColorScheme()");
1064
+ });
1065
+
1066
+ it("should create separate import when only type-only import exists", () => {
1067
+ const input = `
1068
+ import React from 'react';
1069
+ import { View } from 'react-native';
1070
+ import type { NavigationProp } from '@react-navigation/native';
1071
+
1072
+ export function Component() {
1073
+ return <View className="dark:bg-gray-900" />;
1074
+ }
1075
+ `;
1076
+
1077
+ const output = transform(
1078
+ input,
1079
+ {
1080
+ colorScheme: {
1081
+ importFrom: "@react-navigation/native",
1082
+ importName: "useTheme",
1083
+ },
1084
+ },
1085
+ true,
1086
+ );
1087
+
1088
+ // TypeScript preset strips type-only imports, but the important thing is:
1089
+ // 1. useTheme hook is imported (not skipped thinking it was already imported)
1090
+ // 2. Hook is correctly called in the component
1091
+ expect(output).toMatch(/import\s+\{\s*useTheme\s*\}\s+from\s+['"]@react-navigation\/native['"]/);
1092
+ expect(output).toContain("_twColorScheme = useTheme()");
1093
+ });
1094
+
1095
+ it("should use aliased identifier when hook is already imported with alias", () => {
1096
+ const input = `
1097
+ import React from 'react';
1098
+ import { View, Text } from 'react-native';
1099
+ import { useTheme as navTheme } from '@react-navigation/native';
1100
+
1101
+ export function Component() {
1102
+ const theme = navTheme();
1103
+ return (
1104
+ <View className="dark:bg-gray-900">
1105
+ <Text>{theme.dark ? 'Dark' : 'Light'}</Text>
1106
+ </View>
1107
+ );
1108
+ }
1109
+ `;
1110
+
1111
+ const output = transform(
1112
+ input,
1113
+ {
1114
+ colorScheme: {
1115
+ importFrom: "@react-navigation/native",
1116
+ importName: "useTheme",
1117
+ },
1118
+ },
1119
+ true,
1120
+ );
1121
+
1122
+ // Should not add duplicate import
1123
+ const importMatches = output.match(
1124
+ /import\s+\{[^}]*useTheme[^}]*\}\s+from\s+['"]@react-navigation\/native['"]/g,
1125
+ );
1126
+ expect(importMatches).toHaveLength(1);
1127
+
1128
+ // Should still have the aliased import
1129
+ expect(output).toMatch(/useTheme\s+as\s+navTheme/);
1130
+
1131
+ // Should call the aliased name (navTheme), not the export name (useTheme)
1132
+ // Both the user's code and our injected hook should use navTheme
1133
+ expect(output).toContain("_twColorScheme = navTheme()");
1134
+ expect(output).not.toContain("_twColorScheme = useTheme()");
1135
+ });
1136
+
1137
+ it("should not treat type-only imports as having the hook", () => {
1138
+ const input = `
1139
+ import React from 'react';
1140
+ import { View } from 'react-native';
1141
+ import type { useColorScheme } from 'react-native';
1142
+
1143
+ export function Component() {
1144
+ return <View className="dark:bg-gray-900" />;
1145
+ }
1146
+ `;
1147
+
1148
+ const output = transform(input, undefined, true);
1149
+
1150
+ // Should add a VALUE import for useColorScheme (type import doesn't count)
1151
+ expect(output).toMatch(/import\s+\{[^}]*useColorScheme[^}]*\}\s+from\s+['"]react-native['"]/);
1152
+
1153
+ // Should inject the hook
1154
+ expect(output).toContain("_twColorScheme = useColorScheme()");
1155
+
1156
+ // Should have both type-only and value imports in output
1157
+ // (TypeScript preset keeps type imports for type checking)
1158
+ const colorSchemeMatches = output.match(/useColorScheme/g);
1159
+ expect(colorSchemeMatches).toBeTruthy();
1160
+ if (colorSchemeMatches) {
1161
+ expect(colorSchemeMatches.length).toBeGreaterThanOrEqual(2); // At least in import and hook call
1162
+ }
1163
+ });
1164
+
1165
+ it("should handle both type-only and aliased imports together", () => {
1166
+ const input = `
1167
+ import React from 'react';
1168
+ import { View, Text } from 'react-native';
1169
+ import type { Theme } from '@react-navigation/native';
1170
+ import { useTheme as getNavTheme } from '@react-navigation/native';
1171
+
1172
+ export function Component() {
1173
+ const theme = getNavTheme();
1174
+ return (
1175
+ <View className="dark:bg-gray-900">
1176
+ <Text>{theme.dark ? 'Dark Mode' : 'Light Mode'}</Text>
1177
+ </View>
1178
+ );
1179
+ }
1180
+ `;
1181
+
1182
+ const output = transform(
1183
+ input,
1184
+ {
1185
+ colorScheme: {
1186
+ importFrom: "@react-navigation/native",
1187
+ importName: "useTheme",
1188
+ },
1189
+ },
1190
+ true,
1191
+ );
1192
+
1193
+ // TypeScript preset strips type-only imports
1194
+ // The important thing is: should not add duplicate import, and should use aliased name
1195
+ expect(output).toMatch(
1196
+ /import\s+\{[^}]*useTheme\s+as\s+getNavTheme[^}]*\}\s+from\s+['"]@react-navigation\/native['"]/,
1197
+ );
1198
+
1199
+ // Should not add duplicate import - useTheme should only appear in the aliased import
1200
+ const useThemeImports = output.match(
1201
+ /import\s+\{[^}]*useTheme[^}]*\}\s+from\s+['"]@react-navigation\/native['"]/g,
1202
+ );
1203
+ expect(useThemeImports).toHaveLength(1);
1204
+
1205
+ // Should call the aliased name for both user code and our injected hook
1206
+ expect(output).toContain("_twColorScheme = getNavTheme()");
1207
+ expect(output).not.toContain("_twColorScheme = useTheme()");
1208
+ });
1209
+ });
1210
+
1211
+ describe("className visitor - directional border colors", () => {
1212
+ it("should transform directional border colors with preset values", () => {
1213
+ const input = `
1214
+ import { View } from 'react-native';
1215
+ export function Component() {
1216
+ return <View className="border-t-red-500 border-l-blue-500" />;
1217
+ }
1218
+ `;
1219
+
1220
+ const output = transform(input, undefined, true);
1221
+
1222
+ // Should have StyleSheet
1223
+ expect(output).toContain("StyleSheet.create");
1224
+
1225
+ // Should generate styles with borderTopColor and borderLeftColor
1226
+ expect(output).toMatch(/borderTopColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1227
+ expect(output).toMatch(/borderLeftColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1228
+
1229
+ // Should not have className in output
1230
+ expect(output).not.toContain("className");
1231
+ });
1232
+
1233
+ it("should combine directional border width and color", () => {
1234
+ const input = `
1235
+ import { View } from 'react-native';
1236
+ export function Component() {
1237
+ return <View className="border-l-2 border-l-red-500" />;
1238
+ }
1239
+ `;
1240
+
1241
+ const output = transform(input, undefined, true);
1242
+
1243
+ // Should have both borderLeftWidth and borderLeftColor in the StyleSheet
1244
+ expect(output).toMatch(/borderLeftWidth[:\s]*2/);
1245
+ expect(output).toMatch(/borderLeftColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1246
+
1247
+ // Should not have className in output
1248
+ expect(output).not.toContain("className");
1249
+ });
1250
+
1251
+ it("should support directional border colors with opacity", () => {
1252
+ const input = `
1253
+ import { View } from 'react-native';
1254
+ export function Component() {
1255
+ return <View className="border-t-red-500/50 border-b-blue-500/80" />;
1256
+ }
1257
+ `;
1258
+
1259
+ const output = transform(input, undefined, true);
1260
+
1261
+ // Should have 8-digit hex colors with alpha channel
1262
+ expect(output).toMatch(/borderTopColor[:\s]*['"]#[0-9A-F]{8}['"]/i);
1263
+ expect(output).toMatch(/borderBottomColor[:\s]*['"]#[0-9A-F]{8}['"]/i);
1264
+ });
1265
+
1266
+ it("should support directional border colors with arbitrary hex values", () => {
1267
+ const input = `
1268
+ import { View } from 'react-native';
1269
+ export function Component() {
1270
+ return <View className="border-t-[#ff0000] border-r-[#abc]" />;
1271
+ }
1272
+ `;
1273
+
1274
+ const output = transform(input, undefined, true);
1275
+
1276
+ // Should have borderTopColor and borderRightColor
1277
+ expect(output).toMatch(/borderTopColor[:\s]*['"]#[0-9a-fA-F]{6}['"]/);
1278
+ expect(output).toMatch(/borderRightColor[:\s]*['"]#[0-9a-fA-F]{6}['"]/);
1279
+ });
1280
+
1281
+ it("should support all four directional border colors", () => {
1282
+ const input = `
1283
+ import { View } from 'react-native';
1284
+ export function Component() {
1285
+ return (
1286
+ <View className="border-t-red-500 border-r-blue-500 border-b-green-500 border-l-yellow-500" />
1287
+ );
1288
+ }
1289
+ `;
1290
+
1291
+ const output = transform(input, undefined, true);
1292
+
1293
+ // Should have all four directional color properties
1294
+ expect(output).toMatch(/borderTopColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1295
+ expect(output).toMatch(/borderRightColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1296
+ expect(output).toMatch(/borderBottomColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1297
+ expect(output).toMatch(/borderLeftColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1298
+ });
1299
+
1300
+ it("should combine directional widths, colors, and general border color", () => {
1301
+ const input = `
1302
+ import { View } from 'react-native';
1303
+ export function Component() {
1304
+ return (
1305
+ <View className="border border-gray-300 border-l-4 border-l-blue-500" />
1306
+ );
1307
+ }
1308
+ `;
1309
+
1310
+ const output = transform(input, undefined, true);
1311
+
1312
+ // Should have general border properties
1313
+ expect(output).toMatch(/borderWidth[:\s]*1/);
1314
+ expect(output).toMatch(/borderColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1315
+
1316
+ // Should have directional left border properties
1317
+ expect(output).toMatch(/borderLeftWidth[:\s]*4/);
1318
+ expect(output).toMatch(/borderLeftColor[:\s]*['"]#[0-9A-F]{6}['"]/i);
1319
+ });
1320
+
1321
+ it("should work with dynamic className containing directional border colors", () => {
1322
+ const input = `
1323
+ import { View } from 'react-native';
1324
+ export function Component({ isError }) {
1325
+ return (
1326
+ <View className={\`border-t-2 \${isError ? 'border-t-red-500' : 'border-t-gray-300'}\`} />
1327
+ );
1328
+ }
1329
+ `;
1330
+
1331
+ const output = transform(input, undefined, true);
1332
+
1333
+ // Should have StyleSheet with both color options
1334
+ expect(output).toContain("_border_t_2");
1335
+ expect(output).toContain("_border_t_red_500");
1336
+ expect(output).toContain("_border_t_gray_300");
1337
+
1338
+ // Should have conditional expression with both styles
1339
+ expect(output).toMatch(/isError\s*\?\s*_twStyles\._border_t_red_500/);
1340
+ });
1341
+ });
1342
+
1343
+ describe("className visitor - directional modifiers (RTL/LTR)", () => {
1344
+ it("should transform rtl: modifier and inject I18nManager import", () => {
1345
+ const input = `
1346
+ import { View } from 'react-native';
1347
+ export function Component() {
1348
+ return <View className="rtl:mr-4" />;
1349
+ }
1350
+ `;
1351
+
1352
+ const output = transform(input, undefined, true);
1353
+
1354
+ // Should import I18nManager
1355
+ expect(output).toContain("I18nManager");
1356
+
1357
+ // Should declare _twIsRTL variable
1358
+ expect(output).toContain("_twIsRTL");
1359
+ expect(output).toContain("I18nManager.isRTL");
1360
+
1361
+ // Should have StyleSheet with rtl style
1362
+ expect(output).toContain("StyleSheet.create");
1363
+ expect(output).toContain("_rtl_mr_4");
1364
+
1365
+ // Should have conditional for RTL
1366
+ expect(output).toMatch(/_twIsRTL\s*&&\s*_twStyles\._rtl_mr_4/);
1367
+ });
1368
+
1369
+ it("should transform ltr: modifier with negated conditional", () => {
1370
+ const input = `
1371
+ import { View } from 'react-native';
1372
+ export function Component() {
1373
+ return <View className="ltr:ml-4" />;
1374
+ }
1375
+ `;
1376
+
1377
+ const output = transform(input, undefined, true);
1378
+
1379
+ // Should import I18nManager
1380
+ expect(output).toContain("I18nManager");
1381
+
1382
+ // Should have StyleSheet with ltr style
1383
+ expect(output).toContain("_ltr_ml_4");
1384
+
1385
+ // Should have negated conditional for LTR (!_twIsRTL)
1386
+ expect(output).toMatch(/!\s*_twIsRTL\s*&&\s*_twStyles\._ltr_ml_4/);
1387
+ });
1388
+
1389
+ it("should combine rtl: and ltr: modifiers", () => {
1390
+ const input = `
1391
+ import { View } from 'react-native';
1392
+ export function Component() {
1393
+ return <View className="rtl:mr-4 ltr:ml-4" />;
1394
+ }
1395
+ `;
1396
+
1397
+ const output = transform(input, undefined, true);
1398
+
1399
+ // Should have both styles
1400
+ expect(output).toContain("_rtl_mr_4");
1401
+ expect(output).toContain("_ltr_ml_4");
1402
+
1403
+ // Should have both conditionals
1404
+ expect(output).toMatch(/_twIsRTL\s*&&\s*_twStyles\._rtl_mr_4/);
1405
+ expect(output).toMatch(/!\s*_twIsRTL\s*&&\s*_twStyles\._ltr_ml_4/);
1406
+ });
1407
+
1408
+ it("should combine directional modifiers with base classes", () => {
1409
+ const input = `
1410
+ import { View } from 'react-native';
1411
+ export function Component() {
1412
+ return <View className="p-4 bg-white rtl:pr-8 ltr:pl-8" />;
1413
+ }
1414
+ `;
1415
+
1416
+ const output = transform(input, undefined, true);
1417
+
1418
+ // Should have base style
1419
+ expect(output).toContain("_bg_white_p_4");
1420
+
1421
+ // Should have directional styles
1422
+ expect(output).toContain("_rtl_pr_8");
1423
+ expect(output).toContain("_ltr_pl_8");
1424
+
1425
+ // Should generate an array with base and conditional styles
1426
+ expect(output).toMatch(/style:\s*\[/);
1427
+ });
1428
+
1429
+ it("should combine directional modifiers with platform modifiers", () => {
1430
+ const input = `
1431
+ import { View } from 'react-native';
1432
+ export function Component() {
1433
+ return <View className="p-4 ios:p-6 rtl:mr-4" />;
1434
+ }
1435
+ `;
1436
+
1437
+ const output = transform(input, undefined, true);
1438
+
1439
+ // Should have Platform import
1440
+ expect(output).toContain("Platform");
1441
+
1442
+ // Should have I18nManager import
1443
+ expect(output).toContain("I18nManager");
1444
+
1445
+ // Should have all styles
1446
+ expect(output).toContain("_p_4");
1447
+ expect(output).toContain("_ios_p_6");
1448
+ expect(output).toContain("_rtl_mr_4");
1449
+
1450
+ // Should have Platform.select
1451
+ expect(output).toContain("Platform.select");
1452
+
1453
+ // Should have RTL conditional
1454
+ expect(output).toMatch(/_twIsRTL\s*&&/);
1455
+ });
1456
+
1457
+ it("should not add I18nManager import if already present", () => {
1458
+ const input = `
1459
+ import { View, I18nManager } from 'react-native';
1460
+ export function Component() {
1461
+ return <View className="rtl:mr-4" />;
1462
+ }
1463
+ `;
1464
+
1465
+ const output = transform(input, undefined, true);
1466
+
1467
+ // Should have only one I18nManager import (merged, not duplicated)
1468
+ const i18nMatches = output.match(/I18nManager/g);
1469
+ // Should have I18nManager in: import, variable declaration, and style usage
1470
+ expect(i18nMatches).toBeTruthy();
1471
+ // Should not have duplicate imports
1472
+ expect(output).not.toMatch(/import\s*\{[^}]*I18nManager[^}]*I18nManager[^}]*\}/);
1473
+ });
1474
+
1475
+ it("should work with directional logical properties", () => {
1476
+ const input = `
1477
+ import { View } from 'react-native';
1478
+ export function Component() {
1479
+ return <View className="rtl:ms-4 ltr:me-4" />;
1480
+ }
1481
+ `;
1482
+
1483
+ const output = transform(input, undefined, true);
1484
+
1485
+ // Should have logical property styles
1486
+ expect(output).toContain("_rtl_ms_4");
1487
+ expect(output).toContain("_ltr_me_4");
1488
+
1489
+ // Should contain marginStart and marginEnd in the StyleSheet
1490
+ expect(output).toContain("marginStart");
1491
+ expect(output).toContain("marginEnd");
1492
+ });
1493
+
1494
+ it("should combine directional modifiers with color scheme modifiers", () => {
1495
+ const input = `
1496
+ import { View } from 'react-native';
1497
+ export function Component() {
1498
+ return <View className="bg-white dark:bg-gray-900 rtl:pr-4" />;
1499
+ }
1500
+ `;
1501
+
1502
+ const output = transform(input, undefined, true);
1503
+
1504
+ // Should have useColorScheme
1505
+ expect(output).toContain("useColorScheme");
1506
+
1507
+ // Should have I18nManager
1508
+ expect(output).toContain("I18nManager");
1509
+
1510
+ // Should have all styles
1511
+ expect(output).toContain("_bg_white");
1512
+ expect(output).toContain("_dark_bg_gray_900");
1513
+ expect(output).toContain("_rtl_pr_4");
1514
+
1515
+ // Should have both conditionals
1516
+ expect(output).toMatch(/_twColorScheme\s*===\s*["']dark["']/);
1517
+ expect(output).toMatch(/_twIsRTL\s*&&/);
1518
+ });
1519
+
1520
+ it("should handle aliased I18nManager import", () => {
1521
+ const input = `
1522
+ import { View, I18nManager as RTL } from 'react-native';
1523
+ export function Component() {
1524
+ // Use RTL somewhere so TypeScript doesn't strip the unused import
1525
+ const isRtl = RTL.isRTL;
1526
+ return <View className="rtl:mr-4" />;
1527
+ }
1528
+ `;
1529
+
1530
+ const output = transform(input, undefined, true);
1531
+
1532
+ // Should use the aliased identifier RTL.isRTL instead of I18nManager.isRTL
1533
+ expect(output).toContain("RTL.isRTL");
1534
+ // Should preserve the aliased import
1535
+ expect(output).toContain("I18nManager as RTL");
1536
+ // Should not add a separate I18nManager import without alias
1537
+ expect(output).not.toMatch(/I18nManager,|,\s*I18nManager\s*[,}]/);
1538
+ });
1539
+
1540
+ it("should preserve 'use client' directive when injecting I18nManager variable", () => {
1541
+ const input = `
1542
+ 'use client';
1543
+ import { View } from 'react-native';
1544
+ export function Component() {
1545
+ return <View className="rtl:mr-4" />;
1546
+ }
1547
+ `;
1548
+
1549
+ const output = transform(input, undefined, true);
1550
+
1551
+ // 'use client' should be the first statement
1552
+ const lines = output.split("\n").filter((l: string) => l.trim());
1553
+ const useClientIndex = lines.findIndex(
1554
+ (l: string) => l.includes("'use client'") || l.includes('"use client"'),
1555
+ );
1556
+ expect(useClientIndex).toBe(0);
1557
+
1558
+ // I18nManager variable should come after imports, not before 'use client'
1559
+ expect(output).toContain("_twIsRTL");
1560
+ expect(output).toContain("I18nManager.isRTL");
1561
+ });
1562
+
1563
+ it("should preserve 'use strict' directive when injecting I18nManager variable", () => {
1564
+ const input = `
1565
+ 'use strict';
1566
+ import { View } from 'react-native';
1567
+ export function Component() {
1568
+ return <View className="rtl:mr-4" />;
1569
+ }
1570
+ `;
1571
+
1572
+ const output = transform(input, undefined, true);
1573
+
1574
+ // 'use strict' should be preserved at the top
1575
+ const lines = output.split("\n").filter((l: string) => l.trim());
1576
+ const useStrictIndex = lines.findIndex(
1577
+ (l: string) => l.includes("'use strict'") || l.includes('"use strict"'),
1578
+ );
1579
+ expect(useStrictIndex).toBe(0);
1580
+
1581
+ // I18nManager variable should work correctly
1582
+ expect(output).toContain("_twIsRTL");
1583
+ expect(output).toContain("I18nManager.isRTL");
1584
+ });
1585
+
1586
+ it("should expand text-start to directional modifiers", () => {
1587
+ const input = `
1588
+ import { Text } from 'react-native';
1589
+ export function Component() {
1590
+ return <Text className="text-start" />;
1591
+ }
1592
+ `;
1593
+
1594
+ const output = transform(input, undefined, true);
1595
+
1596
+ // Should have I18nManager import (text-start expands to ltr:/rtl: modifiers)
1597
+ expect(output).toContain("I18nManager");
1598
+
1599
+ // Should have both ltr and rtl styles
1600
+ expect(output).toContain("_ltr_text_left");
1601
+ expect(output).toContain("_rtl_text_right");
1602
+
1603
+ // Should have conditionals for both
1604
+ expect(output).toMatch(/_twIsRTL\s*&&/);
1605
+ expect(output).toMatch(/!\s*_twIsRTL\s*&&/);
1606
+ });
1607
+
1608
+ it("should expand text-end to directional modifiers", () => {
1609
+ const input = `
1610
+ import { Text } from 'react-native';
1611
+ export function Component() {
1612
+ return <Text className="text-end" />;
1613
+ }
1614
+ `;
1615
+
1616
+ const output = transform(input, undefined, true);
1617
+
1618
+ // Should have I18nManager import
1619
+ expect(output).toContain("I18nManager");
1620
+
1621
+ // text-end expands to ltr:text-right rtl:text-left
1622
+ expect(output).toContain("_ltr_text_right");
1623
+ expect(output).toContain("_rtl_text_left");
1624
+ });
1625
+ });