@mgcrea/react-native-tailwind 0.8.0 → 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.
- package/README.md +152 -0
- package/dist/babel/config-loader.ts +2 -0
- package/dist/babel/index.cjs +178 -5
- package/dist/babel/plugin.d.ts +2 -0
- package/dist/babel/plugin.test.ts +241 -0
- package/dist/babel/plugin.ts +187 -10
- package/dist/babel/utils/attributeMatchers.test.ts +294 -0
- package/dist/babel/utils/componentSupport.test.ts +426 -0
- package/dist/babel/utils/platformModifierProcessing.d.ts +30 -0
- package/dist/babel/utils/platformModifierProcessing.ts +80 -0
- package/dist/babel/utils/styleInjection.d.ts +4 -0
- package/dist/babel/utils/styleInjection.ts +28 -0
- package/dist/babel/utils/styleTransforms.ts +1 -0
- package/dist/parser/colors.test.js +1 -1
- package/dist/parser/index.d.ts +2 -2
- package/dist/parser/index.js +1 -1
- package/dist/parser/modifiers.d.ts +20 -2
- package/dist/parser/modifiers.js +1 -1
- package/dist/runtime.cjs +1 -1
- package/dist/runtime.cjs.map +4 -4
- package/dist/runtime.js +1 -1
- package/dist/runtime.js.map +4 -4
- package/dist/stubs/tw.test.js +1 -0
- package/dist/utils/flattenColors.d.ts +1 -0
- package/dist/utils/flattenColors.js +1 -1
- package/dist/utils/flattenColors.test.js +1 -1
- package/package.json +6 -5
- package/src/babel/config-loader.ts +2 -0
- package/src/babel/plugin.test.ts +241 -0
- package/src/babel/plugin.ts +187 -10
- package/src/babel/utils/attributeMatchers.test.ts +294 -0
- package/src/babel/utils/componentSupport.test.ts +426 -0
- package/src/babel/utils/platformModifierProcessing.ts +80 -0
- package/src/babel/utils/styleInjection.ts +28 -0
- package/src/babel/utils/styleTransforms.ts +1 -0
- package/src/parser/aspectRatio.ts +1 -0
- package/src/parser/borders.ts +2 -0
- package/src/parser/colors.test.ts +32 -0
- package/src/parser/colors.ts +2 -0
- package/src/parser/index.ts +10 -3
- package/src/parser/layout.ts +2 -0
- package/src/parser/modifiers.ts +38 -4
- package/src/parser/placeholder.ts +1 -0
- package/src/parser/sizing.ts +1 -0
- package/src/parser/spacing.ts +1 -0
- package/src/parser/transforms.ts +5 -0
- package/src/parser/typography.ts +2 -0
- package/src/stubs/tw.test.ts +27 -0
- package/src/utils/flattenColors.test.ts +100 -0
- package/src/utils/flattenColors.ts +3 -1
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { parseSync } from "@babel/core";
|
|
2
|
+
import * as t from "@babel/types";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { getComponentModifierSupport, getStatePropertyForModifier } from "./componentSupport";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helper to create a JSXOpeningElement from JSX code
|
|
8
|
+
*/
|
|
9
|
+
function createJSXElement(code: string): t.JSXOpeningElement {
|
|
10
|
+
const ast = parseSync(code, {
|
|
11
|
+
sourceType: "module",
|
|
12
|
+
plugins: [["@babel/plugin-syntax-jsx", {}]],
|
|
13
|
+
filename: "test.tsx",
|
|
14
|
+
configFile: false,
|
|
15
|
+
babelrc: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!ast) {
|
|
19
|
+
throw new Error(`Failed to parse: ${code}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Find the JSXOpeningElement in the AST
|
|
23
|
+
let element: t.JSXOpeningElement | null = null;
|
|
24
|
+
|
|
25
|
+
const traverse = (node: t.Node) => {
|
|
26
|
+
if (t.isJSXOpeningElement(node)) {
|
|
27
|
+
element = node;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const key in node) {
|
|
31
|
+
// @ts-expect-error - Dynamic key access
|
|
32
|
+
if (node[key] && typeof node[key] === "object") {
|
|
33
|
+
// @ts-expect-error - Dynamic key access
|
|
34
|
+
traverse(node[key] as t.Node);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
traverse(ast);
|
|
40
|
+
|
|
41
|
+
if (!element) {
|
|
42
|
+
throw new Error(`Could not find JSXOpeningElement in: ${code}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return element;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("getComponentModifierSupport", () => {
|
|
49
|
+
describe("Supported components", () => {
|
|
50
|
+
it("should recognize Pressable component", () => {
|
|
51
|
+
const element = createJSXElement("<Pressable />");
|
|
52
|
+
const result = getComponentModifierSupport(element, t);
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
component: "Pressable",
|
|
56
|
+
supportedModifiers: ["active", "hover", "focus", "disabled"],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should recognize TextInput component", () => {
|
|
61
|
+
const element = createJSXElement("<TextInput />");
|
|
62
|
+
const result = getComponentModifierSupport(element, t);
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
component: "TextInput",
|
|
66
|
+
supportedModifiers: ["focus", "disabled", "placeholder"],
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should recognize Pressable with attributes", () => {
|
|
71
|
+
const element = createJSXElement('<Pressable className="m-4" onPress={handlePress} />');
|
|
72
|
+
const result = getComponentModifierSupport(element, t);
|
|
73
|
+
|
|
74
|
+
expect(result).toEqual({
|
|
75
|
+
component: "Pressable",
|
|
76
|
+
supportedModifiers: ["active", "hover", "focus", "disabled"],
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should recognize TextInput with attributes", () => {
|
|
81
|
+
const element = createJSXElement('<TextInput className="border" placeholder="Email" />');
|
|
82
|
+
const result = getComponentModifierSupport(element, t);
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual({
|
|
85
|
+
component: "TextInput",
|
|
86
|
+
supportedModifiers: ["focus", "disabled", "placeholder"],
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("Member expressions", () => {
|
|
92
|
+
it("should recognize ReactNative.Pressable", () => {
|
|
93
|
+
const element = createJSXElement("<ReactNative.Pressable />");
|
|
94
|
+
const result = getComponentModifierSupport(element, t);
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual({
|
|
97
|
+
component: "Pressable",
|
|
98
|
+
supportedModifiers: ["active", "hover", "focus", "disabled"],
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should recognize RN.TextInput", () => {
|
|
103
|
+
const element = createJSXElement("<RN.TextInput />");
|
|
104
|
+
const result = getComponentModifierSupport(element, t);
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual({
|
|
107
|
+
component: "TextInput",
|
|
108
|
+
supportedModifiers: ["focus", "disabled", "placeholder"],
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should recognize nested member expressions", () => {
|
|
113
|
+
const element = createJSXElement("<Components.Input.TextInput />");
|
|
114
|
+
const result = getComponentModifierSupport(element, t);
|
|
115
|
+
|
|
116
|
+
// Should extract "TextInput" from the rightmost property
|
|
117
|
+
expect(result).toEqual({
|
|
118
|
+
component: "TextInput",
|
|
119
|
+
supportedModifiers: ["focus", "disabled", "placeholder"],
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should recognize Pressable in namespaced imports", () => {
|
|
124
|
+
const element = createJSXElement("<UI.Pressable />");
|
|
125
|
+
const result = getComponentModifierSupport(element, t);
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual({
|
|
128
|
+
component: "Pressable",
|
|
129
|
+
supportedModifiers: ["active", "hover", "focus", "disabled"],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("Unsupported components", () => {
|
|
135
|
+
it("should return null for View", () => {
|
|
136
|
+
const element = createJSXElement("<View />");
|
|
137
|
+
const result = getComponentModifierSupport(element, t);
|
|
138
|
+
|
|
139
|
+
expect(result).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should return null for TouchableOpacity", () => {
|
|
143
|
+
const element = createJSXElement("<TouchableOpacity />");
|
|
144
|
+
const result = getComponentModifierSupport(element, t);
|
|
145
|
+
|
|
146
|
+
expect(result).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should return null for custom components", () => {
|
|
150
|
+
const element = createJSXElement("<CustomButton />");
|
|
151
|
+
const result = getComponentModifierSupport(element, t);
|
|
152
|
+
|
|
153
|
+
expect(result).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should return null for Text", () => {
|
|
157
|
+
const element = createJSXElement("<Text />");
|
|
158
|
+
const result = getComponentModifierSupport(element, t);
|
|
159
|
+
|
|
160
|
+
expect(result).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should return null for Image", () => {
|
|
164
|
+
const element = createJSXElement("<Image />");
|
|
165
|
+
const result = getComponentModifierSupport(element, t);
|
|
166
|
+
|
|
167
|
+
expect(result).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("Edge cases", () => {
|
|
172
|
+
it("should be case-sensitive", () => {
|
|
173
|
+
// lowercase "pressable" should not match
|
|
174
|
+
const element = createJSXElement("<pressable />");
|
|
175
|
+
const result = getComponentModifierSupport(element, t);
|
|
176
|
+
|
|
177
|
+
expect(result).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should not match similar names", () => {
|
|
181
|
+
const element1 = createJSXElement("<PressableButton />");
|
|
182
|
+
const result1 = getComponentModifierSupport(element1, t);
|
|
183
|
+
expect(result1).toBeNull();
|
|
184
|
+
|
|
185
|
+
const element2 = createJSXElement("<MyPressable />");
|
|
186
|
+
const result2 = getComponentModifierSupport(element2, t);
|
|
187
|
+
expect(result2).toBeNull();
|
|
188
|
+
|
|
189
|
+
const element3 = createJSXElement("<TextInputField />");
|
|
190
|
+
const result3 = getComponentModifierSupport(element3, t);
|
|
191
|
+
expect(result3).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should handle self-closing tags", () => {
|
|
195
|
+
const element = createJSXElement("<Pressable />");
|
|
196
|
+
const result = getComponentModifierSupport(element, t);
|
|
197
|
+
|
|
198
|
+
expect(result).not.toBeNull();
|
|
199
|
+
expect(result?.component).toBe("Pressable");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("should return null for non-JSXOpeningElement nodes", () => {
|
|
203
|
+
// Test with a random node type
|
|
204
|
+
const identifier = t.identifier("foo");
|
|
205
|
+
const result = getComponentModifierSupport(identifier, t);
|
|
206
|
+
|
|
207
|
+
expect(result).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should return null for JSXFragment", () => {
|
|
211
|
+
// JSXFragment doesn't have a JSXOpeningElement, so create a mock fragment
|
|
212
|
+
const fragment = {
|
|
213
|
+
type: "JSXFragment",
|
|
214
|
+
openingFragment: {},
|
|
215
|
+
closingFragment: {},
|
|
216
|
+
children: [],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const result = getComponentModifierSupport(fragment as t.Node, t);
|
|
220
|
+
expect(result).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("Modifier support differences", () => {
|
|
225
|
+
it("should show Pressable supports active modifier but TextInput does not", () => {
|
|
226
|
+
const pressable = createJSXElement("<Pressable />");
|
|
227
|
+
const textInput = createJSXElement("<TextInput />");
|
|
228
|
+
|
|
229
|
+
const pressableResult = getComponentModifierSupport(pressable, t);
|
|
230
|
+
const textInputResult = getComponentModifierSupport(textInput, t);
|
|
231
|
+
|
|
232
|
+
expect(pressableResult?.supportedModifiers).toContain("active");
|
|
233
|
+
expect(textInputResult?.supportedModifiers).not.toContain("active");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should show both support focus modifier", () => {
|
|
237
|
+
const pressable = createJSXElement("<Pressable />");
|
|
238
|
+
const textInput = createJSXElement("<TextInput />");
|
|
239
|
+
|
|
240
|
+
const pressableResult = getComponentModifierSupport(pressable, t);
|
|
241
|
+
const textInputResult = getComponentModifierSupport(textInput, t);
|
|
242
|
+
|
|
243
|
+
expect(pressableResult?.supportedModifiers).toContain("focus");
|
|
244
|
+
expect(textInputResult?.supportedModifiers).toContain("focus");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should show TextInput supports placeholder but Pressable does not", () => {
|
|
248
|
+
const pressable = createJSXElement("<Pressable />");
|
|
249
|
+
const textInput = createJSXElement("<TextInput />");
|
|
250
|
+
|
|
251
|
+
const pressableResult = getComponentModifierSupport(pressable, t);
|
|
252
|
+
const textInputResult = getComponentModifierSupport(textInput, t);
|
|
253
|
+
|
|
254
|
+
expect(pressableResult?.supportedModifiers).not.toContain("placeholder");
|
|
255
|
+
expect(textInputResult?.supportedModifiers).toContain("placeholder");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("should show both support disabled modifier", () => {
|
|
259
|
+
const pressable = createJSXElement("<Pressable />");
|
|
260
|
+
const textInput = createJSXElement("<TextInput />");
|
|
261
|
+
|
|
262
|
+
const pressableResult = getComponentModifierSupport(pressable, t);
|
|
263
|
+
const textInputResult = getComponentModifierSupport(textInput, t);
|
|
264
|
+
|
|
265
|
+
expect(pressableResult?.supportedModifiers).toContain("disabled");
|
|
266
|
+
expect(textInputResult?.supportedModifiers).toContain("disabled");
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("getStatePropertyForModifier", () => {
|
|
272
|
+
it("should map active to pressed", () => {
|
|
273
|
+
expect(getStatePropertyForModifier("active")).toBe("pressed");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("should map hover to hovered", () => {
|
|
277
|
+
expect(getStatePropertyForModifier("hover")).toBe("hovered");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("should map focus to focused", () => {
|
|
281
|
+
expect(getStatePropertyForModifier("focus")).toBe("focused");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("should map disabled to disabled", () => {
|
|
285
|
+
expect(getStatePropertyForModifier("disabled")).toBe("disabled");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("should return pressed as fallback for unknown modifiers", () => {
|
|
289
|
+
// @ts-expect-error - Testing fallback with invalid modifier
|
|
290
|
+
expect(getStatePropertyForModifier("unknown")).toBe("pressed");
|
|
291
|
+
|
|
292
|
+
// @ts-expect-error - Testing fallback with invalid modifier
|
|
293
|
+
expect(getStatePropertyForModifier("invalid")).toBe("pressed");
|
|
294
|
+
|
|
295
|
+
// @ts-expect-error - Testing fallback with invalid modifier
|
|
296
|
+
expect(getStatePropertyForModifier("")).toBe("pressed");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("should handle all Pressable modifier states", () => {
|
|
300
|
+
// Pressable supports: active, hover, focus, disabled
|
|
301
|
+
const pressableModifiers: Array<"active" | "hover" | "focus" | "disabled"> = [
|
|
302
|
+
"active",
|
|
303
|
+
"hover",
|
|
304
|
+
"focus",
|
|
305
|
+
"disabled",
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
const expectedMapping = {
|
|
309
|
+
active: "pressed",
|
|
310
|
+
hover: "hovered",
|
|
311
|
+
focus: "focused",
|
|
312
|
+
disabled: "disabled",
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
for (const modifier of pressableModifiers) {
|
|
316
|
+
expect(getStatePropertyForModifier(modifier)).toBe(expectedMapping[modifier]);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("should handle all TextInput modifier states", () => {
|
|
321
|
+
// TextInput supports: focus, disabled, placeholder
|
|
322
|
+
// Note: placeholder doesn't have a state property (it's a prop, not state)
|
|
323
|
+
const textInputModifiers: Array<"focus" | "disabled"> = ["focus", "disabled"];
|
|
324
|
+
|
|
325
|
+
const expectedMapping = {
|
|
326
|
+
focus: "focused",
|
|
327
|
+
disabled: "disabled",
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
for (const modifier of textInputModifiers) {
|
|
331
|
+
expect(getStatePropertyForModifier(modifier)).toBe(expectedMapping[modifier]);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("Integration - Real-world scenarios", () => {
|
|
337
|
+
it("should correctly identify modifiers for a Pressable button", () => {
|
|
338
|
+
const element = createJSXElement(
|
|
339
|
+
'<Pressable className="active:bg-blue-700 hover:bg-blue-600 disabled:bg-gray-300" />',
|
|
340
|
+
);
|
|
341
|
+
const result = getComponentModifierSupport(element, t);
|
|
342
|
+
|
|
343
|
+
expect(result).not.toBeNull();
|
|
344
|
+
expect(result?.component).toBe("Pressable");
|
|
345
|
+
|
|
346
|
+
// Verify all used modifiers are supported
|
|
347
|
+
expect(result?.supportedModifiers).toContain("active");
|
|
348
|
+
expect(result?.supportedModifiers).toContain("hover");
|
|
349
|
+
expect(result?.supportedModifiers).toContain("disabled");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("should correctly identify modifiers for a TextInput field", () => {
|
|
353
|
+
const element = createJSXElement(
|
|
354
|
+
'<TextInput className="focus:border-blue-500 disabled:bg-gray-100 placeholder:text-gray-400" />',
|
|
355
|
+
);
|
|
356
|
+
const result = getComponentModifierSupport(element, t);
|
|
357
|
+
|
|
358
|
+
expect(result).not.toBeNull();
|
|
359
|
+
expect(result?.component).toBe("TextInput");
|
|
360
|
+
|
|
361
|
+
// Verify all used modifiers are supported
|
|
362
|
+
expect(result?.supportedModifiers).toContain("focus");
|
|
363
|
+
expect(result?.supportedModifiers).toContain("disabled");
|
|
364
|
+
expect(result?.supportedModifiers).toContain("placeholder");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("should handle namespaced components from imports", () => {
|
|
368
|
+
const element = createJSXElement('<RN.Pressable className="active:opacity-80" />');
|
|
369
|
+
const result = getComponentModifierSupport(element, t);
|
|
370
|
+
|
|
371
|
+
expect(result).not.toBeNull();
|
|
372
|
+
expect(result?.component).toBe("Pressable");
|
|
373
|
+
expect(result?.supportedModifiers).toContain("active");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("should return null for unsupported components with modifiers", () => {
|
|
377
|
+
const element = createJSXElement('<View className="hover:bg-blue-500" />');
|
|
378
|
+
const result = getComponentModifierSupport(element, t);
|
|
379
|
+
|
|
380
|
+
// View doesn't support modifiers
|
|
381
|
+
expect(result).toBeNull();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("should map all Pressable modifiers to correct state properties", () => {
|
|
385
|
+
const element = createJSXElement("<Pressable />");
|
|
386
|
+
const result = getComponentModifierSupport(element, t);
|
|
387
|
+
|
|
388
|
+
expect(result).not.toBeNull();
|
|
389
|
+
|
|
390
|
+
// Test each supported modifier maps correctly
|
|
391
|
+
const modifiers = result?.supportedModifiers as Array<"active" | "hover" | "focus" | "disabled">;
|
|
392
|
+
for (const modifier of modifiers) {
|
|
393
|
+
const stateProp = getStatePropertyForModifier(modifier);
|
|
394
|
+
expect(stateProp).toBeTruthy();
|
|
395
|
+
expect(typeof stateProp).toBe("string");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Verify the mappings
|
|
399
|
+
expect(getStatePropertyForModifier("active")).toBe("pressed");
|
|
400
|
+
expect(getStatePropertyForModifier("hover")).toBe("hovered");
|
|
401
|
+
expect(getStatePropertyForModifier("focus")).toBe("focused");
|
|
402
|
+
expect(getStatePropertyForModifier("disabled")).toBe("disabled");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("should map all TextInput modifiers to correct state properties", () => {
|
|
406
|
+
const element = createJSXElement("<TextInput />");
|
|
407
|
+
const result = getComponentModifierSupport(element, t);
|
|
408
|
+
|
|
409
|
+
expect(result).not.toBeNull();
|
|
410
|
+
|
|
411
|
+
// Filter out placeholder as it doesn't have a state property
|
|
412
|
+
const stateModifiers = result?.supportedModifiers.filter((m) => m !== "placeholder") as Array<
|
|
413
|
+
"focus" | "disabled"
|
|
414
|
+
>;
|
|
415
|
+
|
|
416
|
+
for (const modifier of stateModifiers) {
|
|
417
|
+
const stateProp = getStatePropertyForModifier(modifier);
|
|
418
|
+
expect(stateProp).toBeTruthy();
|
|
419
|
+
expect(typeof stateProp).toBe("string");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Verify the mappings
|
|
423
|
+
expect(getStatePropertyForModifier("focus")).toBe("focused");
|
|
424
|
+
expect(getStatePropertyForModifier("disabled")).toBe("disabled");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for processing platform modifiers (ios:, android:, web:)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type * as BabelTypes from "@babel/types";
|
|
6
|
+
import type { ParsedModifier, PlatformModifierType } from "../../parser/index.js";
|
|
7
|
+
import type { StyleObject } from "../../types/core.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Plugin state interface (subset needed for platform modifier processing)
|
|
11
|
+
*/
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
13
|
+
export interface PlatformModifierProcessingState {
|
|
14
|
+
styleRegistry: Map<string, StyleObject>;
|
|
15
|
+
customColors: Record<string, string>;
|
|
16
|
+
stylesIdentifier: string;
|
|
17
|
+
needsPlatformImport: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Process platform modifiers and generate Platform.select() expression
|
|
22
|
+
*
|
|
23
|
+
* @param platformModifiers - Array of parsed platform modifiers
|
|
24
|
+
* @param state - Plugin state
|
|
25
|
+
* @param parseClassName - Function to parse class names into style objects
|
|
26
|
+
* @param generateStyleKey - Function to generate unique style keys
|
|
27
|
+
* @param t - Babel types
|
|
28
|
+
* @returns AST node for Platform.select() call
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* Input: [{ modifier: "ios", baseClass: "shadow-lg" }, { modifier: "android", baseClass: "elevation-4" }]
|
|
32
|
+
* Output: Platform.select({ ios: styles._ios_shadow_lg, android: styles._android_elevation_4 })
|
|
33
|
+
*/
|
|
34
|
+
export function processPlatformModifiers(
|
|
35
|
+
platformModifiers: ParsedModifier[],
|
|
36
|
+
state: PlatformModifierProcessingState,
|
|
37
|
+
parseClassName: (className: string, customColors: Record<string, string>) => StyleObject,
|
|
38
|
+
generateStyleKey: (className: string) => string,
|
|
39
|
+
t: typeof BabelTypes,
|
|
40
|
+
): BabelTypes.Expression {
|
|
41
|
+
// Mark that we need Platform import
|
|
42
|
+
state.needsPlatformImport = true;
|
|
43
|
+
|
|
44
|
+
// Group modifiers by platform
|
|
45
|
+
const modifiersByPlatform = new Map<PlatformModifierType, ParsedModifier[]>();
|
|
46
|
+
|
|
47
|
+
for (const mod of platformModifiers) {
|
|
48
|
+
const platform = mod.modifier as PlatformModifierType;
|
|
49
|
+
if (!modifiersByPlatform.has(platform)) {
|
|
50
|
+
modifiersByPlatform.set(platform, []);
|
|
51
|
+
}
|
|
52
|
+
const platformGroup = modifiersByPlatform.get(platform);
|
|
53
|
+
if (platformGroup) {
|
|
54
|
+
platformGroup.push(mod);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build Platform.select() object properties
|
|
59
|
+
const selectProperties: BabelTypes.ObjectProperty[] = [];
|
|
60
|
+
|
|
61
|
+
for (const [platform, modifiers] of modifiersByPlatform) {
|
|
62
|
+
// Parse all classes for this platform together
|
|
63
|
+
const classNames = modifiers.map((m) => m.baseClass).join(" ");
|
|
64
|
+
const styleObject = parseClassName(classNames, state.customColors);
|
|
65
|
+
const styleKey = generateStyleKey(`${platform}_${classNames}`);
|
|
66
|
+
|
|
67
|
+
// Register style in the registry
|
|
68
|
+
state.styleRegistry.set(styleKey, styleObject);
|
|
69
|
+
|
|
70
|
+
// Create property: ios: styles._ios_shadow_lg
|
|
71
|
+
const styleReference = t.memberExpression(t.identifier(state.stylesIdentifier), t.identifier(styleKey));
|
|
72
|
+
|
|
73
|
+
selectProperties.push(t.objectProperty(t.identifier(platform), styleReference));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Create Platform.select({ ios: ..., android: ... })
|
|
77
|
+
return t.callExpression(t.memberExpression(t.identifier("Platform"), t.identifier("select")), [
|
|
78
|
+
t.objectExpression(selectProperties),
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
@@ -19,6 +19,34 @@ export function addStyleSheetImport(path: NodePath<BabelTypes.Program>, t: typeo
|
|
|
19
19
|
path.unshiftContainer("body", importDeclaration);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Add Platform import to the file or merge with existing react-native import
|
|
24
|
+
*/
|
|
25
|
+
export function addPlatformImport(path: NodePath<BabelTypes.Program>, t: typeof BabelTypes): void {
|
|
26
|
+
// Check if there's already a react-native import
|
|
27
|
+
const body = path.node.body;
|
|
28
|
+
let reactNativeImport: BabelTypes.ImportDeclaration | null = null;
|
|
29
|
+
|
|
30
|
+
for (const statement of body) {
|
|
31
|
+
if (t.isImportDeclaration(statement) && statement.source.value === "react-native") {
|
|
32
|
+
reactNativeImport = statement;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (reactNativeImport) {
|
|
38
|
+
// Add Platform to existing react-native import
|
|
39
|
+
reactNativeImport.specifiers.push(t.importSpecifier(t.identifier("Platform"), t.identifier("Platform")));
|
|
40
|
+
} else {
|
|
41
|
+
// Create new react-native import with Platform
|
|
42
|
+
const importDeclaration = t.importDeclaration(
|
|
43
|
+
[t.importSpecifier(t.identifier("Platform"), t.identifier("Platform"))],
|
|
44
|
+
t.stringLiteral("react-native"),
|
|
45
|
+
);
|
|
46
|
+
path.unshiftContainer("body", importDeclaration);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* Inject StyleSheet.create with all collected styles at the top of the file
|
|
24
52
|
* This ensures the styles object is defined before any code that references it
|
|
@@ -242,6 +242,7 @@ export function addOrMergePlaceholderTextColorProp(
|
|
|
242
242
|
if (existingProp) {
|
|
243
243
|
// If explicit prop exists, don't override it (explicit props take precedence)
|
|
244
244
|
// This matches the behavior of style prop precedence
|
|
245
|
+
/* v8 ignore next 5 */
|
|
245
246
|
if (process.env.NODE_ENV !== "production") {
|
|
246
247
|
console.warn(
|
|
247
248
|
`[react-native-tailwind] placeholderTextColor prop will be overridden by className placeholder: modifier. ` +
|
|
@@ -26,6 +26,7 @@ function parseArbitraryAspectRatio(value: string): number | null {
|
|
|
26
26
|
const denominator = Number.parseInt(match[2], 10);
|
|
27
27
|
|
|
28
28
|
if (denominator === 0) {
|
|
29
|
+
/* v8 ignore next 3 */
|
|
29
30
|
if (process.env.NODE_ENV !== "production") {
|
|
30
31
|
console.warn(`[react-native-tailwind] Invalid aspect ratio: ${value}. Denominator cannot be zero.`);
|
|
31
32
|
}
|
package/src/parser/borders.ts
CHANGED
|
@@ -69,6 +69,7 @@ function parseArbitraryBorderWidth(value: string): number | null {
|
|
|
69
69
|
|
|
70
70
|
// Warn about unsupported formats
|
|
71
71
|
if (value.startsWith("[") && value.endsWith("]")) {
|
|
72
|
+
/* v8 ignore next 5 */
|
|
72
73
|
if (process.env.NODE_ENV !== "production") {
|
|
73
74
|
console.warn(
|
|
74
75
|
`[react-native-tailwind] Unsupported arbitrary border width value: ${value}. Only px values are supported (e.g., [8px] or [8]).`,
|
|
@@ -93,6 +94,7 @@ function parseArbitraryBorderRadius(value: string): number | null {
|
|
|
93
94
|
|
|
94
95
|
// Warn about unsupported formats
|
|
95
96
|
if (value.startsWith("[") && value.endsWith("]")) {
|
|
97
|
+
/* v8 ignore next 5 */
|
|
96
98
|
if (process.env.NODE_ENV !== "production") {
|
|
97
99
|
console.warn(
|
|
98
100
|
`[react-native-tailwind] Unsupported arbitrary border radius value: ${value}. Only px values are supported (e.g., [12px] or [12]).`,
|
|
@@ -168,6 +168,38 @@ describe("parseColor - custom colors", () => {
|
|
|
168
168
|
expect(parseColor("bg-blue-500", overrideColors)).toEqual({ backgroundColor: "#FF0000" });
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
it("should support custom colors with DEFAULT key from tailwind.config", () => {
|
|
172
|
+
// Simulates what flattenColors() produces from:
|
|
173
|
+
// { primary: { DEFAULT: "#1bacb5", 50: "#eefdfd", ... } }
|
|
174
|
+
const customColorsWithDefault = {
|
|
175
|
+
primary: "#1bacb5", // DEFAULT becomes the parent key
|
|
176
|
+
"primary-50": "#eefdfd",
|
|
177
|
+
"primary-100": "#d4f9f9",
|
|
178
|
+
"primary-500": "#1bacb5",
|
|
179
|
+
"primary-900": "#1e4f5b",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Test that bg-primary uses the DEFAULT value
|
|
183
|
+
expect(parseColor("bg-primary", customColorsWithDefault)).toEqual({
|
|
184
|
+
backgroundColor: "#1bacb5",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Test that bg-primary-50 uses the shade value
|
|
188
|
+
expect(parseColor("bg-primary-50", customColorsWithDefault)).toEqual({
|
|
189
|
+
backgroundColor: "#eefdfd",
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Test with text colors
|
|
193
|
+
expect(parseColor("text-primary", customColorsWithDefault)).toEqual({
|
|
194
|
+
color: "#1bacb5",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Test with border colors
|
|
198
|
+
expect(parseColor("border-primary", customColorsWithDefault)).toEqual({
|
|
199
|
+
borderColor: "#1bacb5",
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
171
203
|
it("should fallback to preset colors when custom color not found", () => {
|
|
172
204
|
expect(parseColor("bg-red-500", customColors)).toEqual({ backgroundColor: COLORS["red-500"] });
|
|
173
205
|
});
|
package/src/parser/colors.ts
CHANGED
|
@@ -70,6 +70,7 @@ function parseArbitraryColor(value: string): string | null {
|
|
|
70
70
|
|
|
71
71
|
// Warn about unsupported formats
|
|
72
72
|
if (value.startsWith("[") && value.endsWith("]")) {
|
|
73
|
+
/* v8 ignore next 5 */
|
|
73
74
|
if (process.env.NODE_ENV !== "production") {
|
|
74
75
|
console.warn(
|
|
75
76
|
`[react-native-tailwind] Unsupported arbitrary color value: ${value}. Only hex colors are supported (e.g., [#ff0000], [#f00], or [#ff0000aa]).`,
|
|
@@ -101,6 +102,7 @@ export function parseColor(cls: string, customColors?: Record<string, string>):
|
|
|
101
102
|
|
|
102
103
|
// Validate opacity range (0-100)
|
|
103
104
|
if (opacity < 0 || opacity > 100) {
|
|
105
|
+
/* v8 ignore next 5 */
|
|
104
106
|
if (process.env.NODE_ENV !== "production") {
|
|
105
107
|
console.warn(
|
|
106
108
|
`[react-native-tailwind] Invalid opacity value: ${opacity}. Opacity must be between 0 and 100.`,
|
package/src/parser/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function parseClass(cls: string, customColors?: Record<string, string>):
|
|
|
42
42
|
// Try each parser in order
|
|
43
43
|
// Note: parseBorder must come before parseColor to avoid border-[3px] being parsed as a color
|
|
44
44
|
// parseColor gets custom colors, others don't need it
|
|
45
|
-
const parsers: (
|
|
45
|
+
const parsers: Array<(cls: string) => StyleObject | null> = [
|
|
46
46
|
parseSpacing,
|
|
47
47
|
parseBorder,
|
|
48
48
|
(cls: string) => parseColor(cls, customColors),
|
|
@@ -62,6 +62,7 @@ export function parseClass(cls: string, customColors?: Record<string, string>):
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// Warn about unknown class in development
|
|
65
|
+
/* v8 ignore next 3 */
|
|
65
66
|
if (process.env.NODE_ENV !== "production") {
|
|
66
67
|
console.warn(`[react-native-tailwind] Unknown class: "${cls}"`);
|
|
67
68
|
}
|
|
@@ -82,5 +83,11 @@ export { parseTransform } from "./transforms";
|
|
|
82
83
|
export { parseTypography } from "./typography";
|
|
83
84
|
|
|
84
85
|
// Re-export modifier utilities
|
|
85
|
-
export {
|
|
86
|
-
|
|
86
|
+
export {
|
|
87
|
+
hasModifier,
|
|
88
|
+
isPlatformModifier,
|
|
89
|
+
isStateModifier,
|
|
90
|
+
parseModifier,
|
|
91
|
+
splitModifierClasses,
|
|
92
|
+
} from "./modifiers";
|
|
93
|
+
export type { ModifierType, ParsedModifier, PlatformModifierType, StateModifierType } from "./modifiers";
|
package/src/parser/layout.ts
CHANGED
|
@@ -23,6 +23,7 @@ function parseArbitraryInset(value: string): number | string | null {
|
|
|
23
23
|
|
|
24
24
|
// Unsupported units (rem, em, vh, vw, etc.) - warn and reject
|
|
25
25
|
if (value.startsWith("[") && value.endsWith("]")) {
|
|
26
|
+
/* v8 ignore next 5 */
|
|
26
27
|
if (process.env.NODE_ENV !== "production") {
|
|
27
28
|
console.warn(
|
|
28
29
|
`[react-native-tailwind] Unsupported arbitrary inset unit: ${value}. Only px and % are supported.`,
|
|
@@ -47,6 +48,7 @@ function parseArbitraryZIndex(value: string): number | null {
|
|
|
47
48
|
|
|
48
49
|
// Unsupported format - warn and reject
|
|
49
50
|
if (value.startsWith("[") && value.endsWith("]")) {
|
|
51
|
+
/* v8 ignore next 5 */
|
|
50
52
|
if (process.env.NODE_ENV !== "production") {
|
|
51
53
|
console.warn(
|
|
52
54
|
`[react-native-tailwind] Invalid arbitrary z-index: ${value}. Only integers are supported.`,
|