nestable-tailwind-variants 0.1.4 → 0.2.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 +508 -132
- package/dist/cache.d.ts +7 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +11 -0
- package/dist/cache.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/merge.d.ts +59 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +78 -0
- package/dist/merge.js.map +1 -0
- package/dist/ntv.d.ts +40 -42
- package/dist/ntv.d.ts.map +1 -1
- package/dist/ntv.js +47 -98
- package/dist/ntv.js.map +1 -1
- package/dist/resolver.d.ts +11 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js +52 -0
- package/dist/resolver.js.map +1 -0
- package/dist/types.d.ts +100 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +8 -6
- package/dist/composeNtv.d.ts +0 -47
- package/dist/composeNtv.d.ts.map +0 -1
- package/dist/composeNtv.js +0 -55
- package/dist/composeNtv.js.map +0 -1
- package/dist/utils.d.ts +0 -2
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -4
- package/dist/utils.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,238 +1,614 @@
|
|
|
1
1
|
# nestable-tailwind-variants
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tailwind CSS variant library with nestable conditions for compound styling instead of flat compoundVariants.
|
|
4
4
|
|
|
5
|
-
Inspired by [React Spectrum's
|
|
5
|
+
Inspired by [React Spectrum's style macro](https://github.com/adobe/react-spectrum/tree/main/packages/%40react-spectrum/s2) and [Tailwind Variants](https://www.tailwind-variants.org/).
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Why nestable-tailwind-variants?
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
```
|
|
9
|
+
With [Tailwind Variants](https://www.tailwind-variants.org/), combining variants with [React Aria Components render props](https://react-aria.adobe.com/styling#render-props) like `isHovered` and `isPressed` requires `compoundVariants`. This quickly becomes verbose as combinations grow:
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { tv } from 'tailwind-variants';
|
|
13
|
+
|
|
14
|
+
const button = tv({
|
|
15
|
+
base: 'px-4 py-2 rounded',
|
|
16
|
+
variants: {
|
|
17
|
+
variant: {
|
|
18
|
+
primary: 'bg-blue-500 text-white',
|
|
19
|
+
secondary: 'bg-gray-200 text-gray-800',
|
|
20
|
+
},
|
|
21
|
+
isHovered: { true: '' },
|
|
22
|
+
isPressed: { true: '' },
|
|
23
|
+
},
|
|
24
|
+
compoundVariants: [
|
|
25
|
+
{ variant: 'primary', isHovered: true, class: 'bg-blue-600' },
|
|
26
|
+
{ variant: 'primary', isPressed: true, class: 'bg-blue-700' },
|
|
27
|
+
{ variant: 'secondary', isHovered: true, class: 'bg-gray-300' },
|
|
28
|
+
// ... one entry for every variant × condition combination
|
|
29
|
+
],
|
|
30
|
+
});
|
|
12
31
|
|
|
13
|
-
|
|
32
|
+
button({ variant: 'primary', isHovered: true });
|
|
33
|
+
// => 'px-4 py-2 rounded bg-blue-500 text-white bg-blue-600'
|
|
34
|
+
```
|
|
14
35
|
|
|
15
|
-
|
|
36
|
+
nestable-tailwind-variants solves this by letting you nest conditions directly inside variants, keeping related logic together:
|
|
16
37
|
|
|
17
38
|
```tsx
|
|
18
39
|
import { ntv } from 'nestable-tailwind-variants';
|
|
19
40
|
|
|
20
|
-
interface
|
|
21
|
-
variant
|
|
22
|
-
isHovered
|
|
41
|
+
interface ButtonProps {
|
|
42
|
+
variant: 'primary' | 'secondary';
|
|
43
|
+
isHovered: boolean;
|
|
44
|
+
isPressed: boolean;
|
|
23
45
|
}
|
|
24
46
|
|
|
25
|
-
const button = ntv<
|
|
26
|
-
|
|
47
|
+
const button = ntv<ButtonProps>({
|
|
48
|
+
$base: 'px-4 py-2 rounded',
|
|
27
49
|
variant: {
|
|
28
50
|
primary: {
|
|
29
|
-
default: 'bg-blue-500 text-white',
|
|
51
|
+
$default: 'bg-blue-500 text-white',
|
|
30
52
|
isHovered: 'bg-blue-600',
|
|
53
|
+
isPressed: 'bg-blue-700',
|
|
31
54
|
},
|
|
32
55
|
secondary: {
|
|
33
|
-
default: 'bg-gray-200 text-gray-800',
|
|
56
|
+
$default: 'bg-gray-200 text-gray-800',
|
|
34
57
|
isHovered: 'bg-gray-300',
|
|
35
58
|
},
|
|
36
59
|
},
|
|
37
60
|
});
|
|
38
61
|
|
|
39
|
-
button({ variant: 'primary' });
|
|
40
|
-
// => 'px-4 py-2 rounded font-medium bg-blue-500 text-white'
|
|
41
|
-
|
|
42
62
|
button({ variant: 'primary', isHovered: true });
|
|
43
|
-
// => 'px-4 py-2 rounded
|
|
63
|
+
// => 'px-4 py-2 rounded bg-blue-600'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install nestable-tailwind-variants
|
|
44
70
|
```
|
|
45
71
|
|
|
46
|
-
|
|
72
|
+
## Core Concepts
|
|
47
73
|
|
|
48
|
-
|
|
74
|
+
### `$base` - Always-applied styles
|
|
49
75
|
|
|
50
|
-
|
|
76
|
+
The `$base` property defines styles that are always included in the output:
|
|
51
77
|
|
|
52
|
-
|
|
78
|
+
```ts
|
|
79
|
+
interface CardProps {
|
|
80
|
+
variant: 'elevated' | 'flat';
|
|
81
|
+
}
|
|
53
82
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
primary: 'bg-blue-500 text-white',
|
|
60
|
-
secondary: 'bg-gray-200 text-gray-800',
|
|
61
|
-
},
|
|
62
|
-
isHovered: { true: '' },
|
|
63
|
-
isPressed: { true: '' },
|
|
83
|
+
const card = ntv<CardProps>({
|
|
84
|
+
$base: 'rounded-lg shadow-md p-4',
|
|
85
|
+
variant: {
|
|
86
|
+
elevated: 'shadow-xl',
|
|
87
|
+
flat: 'shadow-none',
|
|
64
88
|
},
|
|
65
|
-
compoundVariants: [
|
|
66
|
-
{ variant: 'primary', isHovered: true, class: 'bg-blue-600' },
|
|
67
|
-
{ variant: 'primary', isPressed: true, class: 'bg-blue-700' },
|
|
68
|
-
{ variant: 'secondary', isHovered: true, class: 'bg-gray-300' },
|
|
69
|
-
{ variant: 'secondary', isPressed: true, class: 'bg-gray-400' },
|
|
70
|
-
],
|
|
71
89
|
});
|
|
90
|
+
|
|
91
|
+
card();
|
|
92
|
+
// => 'rounded-lg shadow-md p-4'
|
|
93
|
+
|
|
94
|
+
card({ variant: 'elevated' });
|
|
95
|
+
// => 'rounded-lg shadow-xl p-4'
|
|
96
|
+
// Note: shadow-md is replaced by shadow-xl via tailwind-merge
|
|
72
97
|
```
|
|
73
98
|
|
|
74
|
-
|
|
99
|
+
### Variants - String-based selection
|
|
75
100
|
|
|
76
|
-
|
|
101
|
+
Define variants as objects mapping variant values to class names:
|
|
77
102
|
|
|
78
|
-
```
|
|
79
|
-
interface
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
103
|
+
```ts
|
|
104
|
+
interface BadgeProps {
|
|
105
|
+
size: 'sm' | 'md' | 'lg';
|
|
106
|
+
color: 'info' | 'success' | 'warning';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const badge = ntv<BadgeProps>({
|
|
110
|
+
$base: 'px-2 py-1 rounded text-sm',
|
|
111
|
+
size: {
|
|
112
|
+
sm: 'text-xs px-1.5',
|
|
113
|
+
md: 'text-sm px-2',
|
|
114
|
+
lg: 'text-base px-3',
|
|
115
|
+
},
|
|
116
|
+
color: {
|
|
117
|
+
info: 'bg-blue-100 text-blue-800',
|
|
118
|
+
success: 'bg-green-100 text-green-800',
|
|
119
|
+
warning: 'bg-yellow-100 text-yellow-800',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
badge({ size: 'lg', color: 'success' });
|
|
124
|
+
// => 'py-1 rounded text-base px-3 bg-green-100 text-green-800'
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Boolean Conditions - `is[A-Z]` / `allows[A-Z]` patterns
|
|
128
|
+
|
|
129
|
+
Keys matching `is[A-Z]*` or `allows[A-Z]*` are treated as boolean conditions:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
interface InputProps {
|
|
133
|
+
isFocused: boolean;
|
|
134
|
+
isDisabled: boolean;
|
|
135
|
+
isInvalid: boolean;
|
|
136
|
+
allowsClearing: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const input = ntv<InputProps>({
|
|
140
|
+
$base: 'border rounded px-3 py-2',
|
|
141
|
+
isFocused: 'ring-2 ring-blue-500 border-blue-500',
|
|
142
|
+
isDisabled: 'bg-gray-100 text-gray-400 cursor-not-allowed',
|
|
143
|
+
isInvalid: 'border-red-500 text-red-600',
|
|
144
|
+
allowsClearing: 'pr-8',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
input({ isFocused: true });
|
|
148
|
+
// => 'border rounded px-3 py-2 ring-2 ring-blue-500 border-blue-500'
|
|
149
|
+
|
|
150
|
+
input({ isDisabled: true, isInvalid: true });
|
|
151
|
+
// => 'border rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed border-red-500 text-red-600'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `$default` - Fallback styles
|
|
155
|
+
|
|
156
|
+
Use `$default` for styles applied when no conditions match:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
interface TextProps {
|
|
160
|
+
variant: 'primary' | 'danger';
|
|
83
161
|
}
|
|
84
162
|
|
|
85
|
-
const
|
|
86
|
-
default: '
|
|
163
|
+
const text = ntv<TextProps>({
|
|
164
|
+
$default: 'text-gray-500',
|
|
87
165
|
variant: {
|
|
166
|
+
primary: 'text-blue-600',
|
|
167
|
+
danger: 'text-red-600',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
text();
|
|
172
|
+
// => 'text-gray-500'
|
|
173
|
+
|
|
174
|
+
text({ variant: 'primary' });
|
|
175
|
+
// => 'text-blue-600'
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
When `$default` is nested inside variants, they accumulate only when no conditions match at each level:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
interface Props {
|
|
182
|
+
variant: 'primary';
|
|
183
|
+
size: 'large';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const styles = ntv<Props>({
|
|
187
|
+
$base: 'base',
|
|
188
|
+
$default: 'root-default',
|
|
189
|
+
variant: {
|
|
190
|
+
$default: 'variant-default',
|
|
88
191
|
primary: {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
192
|
+
size: {
|
|
193
|
+
$default: 'size-default',
|
|
194
|
+
large: 'size-large',
|
|
195
|
+
},
|
|
92
196
|
},
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
styles();
|
|
201
|
+
// => 'base root-default variant-default'
|
|
202
|
+
// No conditions matched, so $defaults accumulate
|
|
203
|
+
|
|
204
|
+
styles({ variant: 'primary' });
|
|
205
|
+
// => 'base size-default'
|
|
206
|
+
// variant matched, so only nested $default is applied
|
|
207
|
+
|
|
208
|
+
styles({ variant: 'primary', size: 'large' });
|
|
209
|
+
// => 'base size-large'
|
|
210
|
+
// Both matched, so no $defaults are applied
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Nested Conditions
|
|
214
|
+
|
|
215
|
+
The core feature of nestable-tailwind-variants is the ability to nest conditions inside variants.
|
|
216
|
+
|
|
217
|
+
### Boolean conditions inside variants
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
interface ChipProps {
|
|
221
|
+
variant: 'filled' | 'outlined';
|
|
222
|
+
isSelected: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const chip = ntv<ChipProps>({
|
|
226
|
+
$base: 'inline-flex items-center rounded-full px-3 py-1',
|
|
227
|
+
variant: {
|
|
228
|
+
filled: {
|
|
229
|
+
$default: 'bg-gray-200 text-gray-800',
|
|
230
|
+
isSelected: 'bg-blue-500 text-white',
|
|
231
|
+
},
|
|
232
|
+
outlined: {
|
|
233
|
+
$default: 'border border-gray-300 text-gray-800',
|
|
234
|
+
isSelected: 'border-blue-500 text-blue-500',
|
|
97
235
|
},
|
|
98
236
|
},
|
|
99
237
|
});
|
|
238
|
+
|
|
239
|
+
chip({ variant: 'filled' });
|
|
240
|
+
// => 'inline-flex items-center rounded-full px-3 py-1 bg-gray-200 text-gray-800'
|
|
241
|
+
|
|
242
|
+
chip({ variant: 'filled', isSelected: true });
|
|
243
|
+
// => 'inline-flex items-center rounded-full px-3 py-1 bg-blue-500 text-white'
|
|
244
|
+
|
|
245
|
+
chip({ variant: 'outlined', isSelected: true });
|
|
246
|
+
// => 'inline-flex items-center rounded-full px-3 py-1 border-blue-500 text-blue-500'
|
|
100
247
|
```
|
|
101
248
|
|
|
102
|
-
|
|
249
|
+
### Multi-level nesting
|
|
103
250
|
|
|
104
|
-
|
|
251
|
+
You can nest conditions to any depth:
|
|
105
252
|
|
|
106
|
-
|
|
253
|
+
```ts
|
|
254
|
+
interface ButtonProps {
|
|
255
|
+
variant: 'primary';
|
|
256
|
+
isHovered: boolean;
|
|
257
|
+
isPressed: boolean;
|
|
258
|
+
isDisabled: boolean;
|
|
259
|
+
}
|
|
107
260
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
261
|
+
const button = ntv<ButtonProps>({
|
|
262
|
+
$base: 'px-4 py-2 rounded font-medium transition-colors',
|
|
263
|
+
variant: {
|
|
264
|
+
primary: {
|
|
265
|
+
$default: 'bg-blue-500 text-white',
|
|
266
|
+
isHovered: 'bg-blue-600',
|
|
267
|
+
isPressed: 'bg-blue-700',
|
|
268
|
+
isDisabled: {
|
|
269
|
+
$default: 'bg-blue-300 cursor-not-allowed',
|
|
270
|
+
isHovered: 'bg-blue-300', // Prevent hover effect when disabled
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
});
|
|
111
275
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
276
|
+
button({ variant: 'primary' });
|
|
277
|
+
// => 'px-4 py-2 rounded font-medium transition-colors bg-blue-500 text-white'
|
|
278
|
+
|
|
279
|
+
button({ variant: 'primary', isHovered: true });
|
|
280
|
+
// => 'px-4 py-2 rounded font-medium transition-colors bg-blue-600'
|
|
281
|
+
|
|
282
|
+
button({ variant: 'primary', isDisabled: true });
|
|
283
|
+
// => 'px-4 py-2 rounded font-medium transition-colors bg-blue-300 cursor-not-allowed'
|
|
284
|
+
|
|
285
|
+
button({ variant: 'primary', isDisabled: true, isHovered: true });
|
|
286
|
+
// => 'px-4 py-2 rounded font-medium transition-colors bg-blue-300'
|
|
119
287
|
```
|
|
120
288
|
|
|
121
289
|
## Composing Styles
|
|
122
290
|
|
|
123
|
-
|
|
291
|
+
Merge multiple style functions into one. Later functions take precedence:
|
|
124
292
|
|
|
125
|
-
```
|
|
126
|
-
import { ntv,
|
|
293
|
+
```ts
|
|
294
|
+
import { ntv, mergeNtv } from 'nestable-tailwind-variants';
|
|
295
|
+
|
|
296
|
+
interface BaseButtonProps {
|
|
297
|
+
size: 'sm' | 'md';
|
|
298
|
+
}
|
|
127
299
|
|
|
128
|
-
const baseButton = ntv<
|
|
129
|
-
|
|
300
|
+
const baseButton = ntv<BaseButtonProps>({
|
|
301
|
+
$base: 'rounded font-medium transition-colors',
|
|
130
302
|
size: {
|
|
131
|
-
sm: 'px-
|
|
132
|
-
|
|
303
|
+
sm: 'px-3 py-1.5 text-sm',
|
|
304
|
+
md: 'px-4 py-2 text-base',
|
|
133
305
|
},
|
|
134
306
|
});
|
|
135
307
|
|
|
136
|
-
|
|
308
|
+
interface ColoredButtonProps {
|
|
309
|
+
variant: 'primary' | 'secondary';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const coloredButton = ntv<ColoredButtonProps>({
|
|
137
313
|
variant: {
|
|
138
|
-
primary: 'bg-blue-500 text-white',
|
|
139
|
-
secondary: 'bg-gray-200 text-gray-800',
|
|
314
|
+
primary: 'bg-blue-500 text-white hover:bg-blue-600',
|
|
315
|
+
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
|
|
140
316
|
},
|
|
141
317
|
});
|
|
142
318
|
|
|
143
|
-
const button =
|
|
319
|
+
const button = mergeNtv(baseButton, coloredButton);
|
|
144
320
|
|
|
145
|
-
button({ size: '
|
|
146
|
-
// => 'rounded font-medium px-4 py-2 text-
|
|
321
|
+
button({ size: 'md', variant: 'primary' });
|
|
322
|
+
// => 'rounded font-medium transition-colors px-4 py-2 text-base bg-blue-500 text-white hover:bg-blue-600'
|
|
147
323
|
```
|
|
148
324
|
|
|
149
|
-
##
|
|
325
|
+
## Passing Additional Classes
|
|
150
326
|
|
|
151
|
-
|
|
327
|
+
Pass additional classes using `class` or `className`:
|
|
152
328
|
|
|
153
|
-
```
|
|
154
|
-
{
|
|
155
|
-
|
|
329
|
+
```ts
|
|
330
|
+
interface BoxProps {
|
|
331
|
+
variant: 'primary' | 'secondary';
|
|
156
332
|
}
|
|
333
|
+
|
|
334
|
+
const box = ntv<BoxProps>({
|
|
335
|
+
$base: 'p-4 rounded',
|
|
336
|
+
variant: {
|
|
337
|
+
primary: 'bg-blue-500',
|
|
338
|
+
secondary: 'bg-gray-200',
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
box({ variant: 'primary', class: 'mt-4 p-8' });
|
|
343
|
+
// => 'rounded bg-blue-500 mt-4 p-8'
|
|
344
|
+
// Note: p-8 overrides p-4 via tailwind-merge
|
|
345
|
+
|
|
346
|
+
box({ variant: 'secondary', className: 'shadow-lg' });
|
|
347
|
+
// => 'p-4 rounded bg-gray-200 shadow-lg'
|
|
157
348
|
```
|
|
158
349
|
|
|
159
|
-
##
|
|
350
|
+
## React Aria Components Integration
|
|
160
351
|
|
|
161
|
-
|
|
352
|
+
nestable-tailwind-variants is designed to work seamlessly with [React Aria Components render props](https://react-aria.adobe.com/styling#render-props):
|
|
162
353
|
|
|
163
|
-
```
|
|
164
|
-
import {
|
|
354
|
+
```tsx
|
|
355
|
+
import {
|
|
356
|
+
Button as RACButton,
|
|
357
|
+
composeRenderProps,
|
|
358
|
+
type ButtonProps as RACButtonProps,
|
|
359
|
+
type ButtonRenderProps,
|
|
360
|
+
} from 'react-aria-components';
|
|
361
|
+
import { ntv } from 'nestable-tailwind-variants';
|
|
165
362
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
363
|
+
interface ButtonStyleProps {
|
|
364
|
+
variant?: 'primary' | 'secondary';
|
|
365
|
+
size?: 'sm' | 'md' | 'lg';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const button = ntv<ButtonRenderProps & ButtonStyleProps>({
|
|
369
|
+
$base:
|
|
370
|
+
'inline-flex items-center justify-center rounded-md px-4 py-2 font-medium transition-colors focus:outline-none',
|
|
371
|
+
variant: {
|
|
372
|
+
primary: {
|
|
373
|
+
$default: 'bg-blue-500 text-white',
|
|
374
|
+
isHovered: 'bg-blue-600',
|
|
375
|
+
isPressed: 'bg-blue-700',
|
|
376
|
+
isFocusVisible: 'ring-2 ring-blue-500 ring-offset-2',
|
|
377
|
+
},
|
|
378
|
+
secondary: {
|
|
379
|
+
$default: 'bg-gray-200 text-gray-800',
|
|
380
|
+
isHovered: 'bg-gray-300',
|
|
381
|
+
isPressed: 'bg-gray-400',
|
|
382
|
+
isFocusVisible: 'ring-2 ring-gray-500 ring-offset-2',
|
|
172
383
|
},
|
|
173
384
|
},
|
|
174
|
-
|
|
385
|
+
size: {
|
|
386
|
+
sm: 'h-8 px-3 text-sm',
|
|
387
|
+
md: 'h-10 px-4 text-base',
|
|
388
|
+
lg: 'h-12 px-6 text-lg',
|
|
389
|
+
},
|
|
390
|
+
isDisabled: 'opacity-50 cursor-not-allowed',
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
function Button({
|
|
394
|
+
variant = 'primary',
|
|
395
|
+
size = 'md',
|
|
396
|
+
children,
|
|
397
|
+
...props
|
|
398
|
+
}: RACButtonProps & ButtonStyleProps) {
|
|
399
|
+
return (
|
|
400
|
+
<RACButton
|
|
401
|
+
{...props}
|
|
402
|
+
className={composeRenderProps(props.className, (className, renderProps) =>
|
|
403
|
+
// renderProps includes isHovered, isPressed, isFocusVisible, isDisabled
|
|
404
|
+
button({ ...renderProps, variant, size, className }),
|
|
405
|
+
)}
|
|
406
|
+
>
|
|
407
|
+
{children}
|
|
408
|
+
</RACButton>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
175
411
|
```
|
|
176
412
|
|
|
177
|
-
##
|
|
413
|
+
## Type Safety
|
|
178
414
|
|
|
179
|
-
|
|
415
|
+
The recommended approach is to define a props type and pass it as a type argument to `ntv`. This provides clear documentation, better IDE support, and ensures type safety:
|
|
180
416
|
|
|
181
|
-
|
|
417
|
+
```ts
|
|
418
|
+
interface ButtonProps {
|
|
419
|
+
variant: 'primary' | 'secondary';
|
|
420
|
+
size: 'sm' | 'md' | 'lg';
|
|
421
|
+
isDisabled: boolean;
|
|
422
|
+
}
|
|
182
423
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
424
|
+
const button = ntv<ButtonProps>({
|
|
425
|
+
$base: 'rounded',
|
|
426
|
+
variant: {
|
|
427
|
+
primary: 'bg-blue-500',
|
|
428
|
+
secondary: 'bg-gray-200',
|
|
429
|
+
},
|
|
430
|
+
size: {
|
|
431
|
+
sm: 'text-sm',
|
|
432
|
+
md: 'text-base',
|
|
433
|
+
lg: 'text-lg',
|
|
434
|
+
},
|
|
435
|
+
isDisabled: 'opacity-50',
|
|
436
|
+
});
|
|
188
437
|
|
|
189
|
-
|
|
438
|
+
button({ variant: 'primary', size: 'lg' }); // ✅ OK
|
|
439
|
+
button({ variant: 'tertiary' }); // ❌ Error: 'tertiary' is not assignable
|
|
440
|
+
```
|
|
190
441
|
|
|
191
|
-
|
|
442
|
+
Omitting the type argument when the scheme has keys disables type checking. Provide explicit type arguments when possible for better type safety.
|
|
192
443
|
|
|
193
|
-
|
|
194
|
-
- Returns `(props: Partial<Props>) => string`
|
|
444
|
+
## Options
|
|
195
445
|
|
|
196
|
-
|
|
446
|
+
`ntv` accepts an options object as the second argument. For `mergeNtv`, use `mergeNtvWithOptions` to pass options.
|
|
197
447
|
|
|
198
|
-
|
|
448
|
+
### Disabling tailwind-merge
|
|
199
449
|
|
|
200
|
-
-
|
|
201
|
-
- `options.twMergeConfig` (`object`) - Custom tailwind-merge configuration
|
|
202
|
-
- Returns customized `ntv` function
|
|
450
|
+
By default, [tailwind-merge](https://github.com/dcastil/tailwind-merge) is used to resolve class conflicts. To disable it, pass `{ twMerge: false }`.
|
|
203
451
|
|
|
204
|
-
|
|
205
|
-
|
|
452
|
+
For `ntv`:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
const styles = ntv(
|
|
456
|
+
{
|
|
457
|
+
$base: 'p-4',
|
|
458
|
+
},
|
|
459
|
+
{ twMerge: false },
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
styles({ class: 'p-8' });
|
|
463
|
+
// => 'p-4 p-8' (no merge, both classes kept)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
For `mergeNtvWithOptions`:
|
|
206
467
|
|
|
207
|
-
|
|
468
|
+
```ts
|
|
469
|
+
const styles = mergeNtvWithOptions(baseStyles, overrideStyles)({ twMerge: false });
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### Custom tailwind-merge configuration
|
|
473
|
+
|
|
474
|
+
If you've extended Tailwind with custom classes (e.g., custom font sizes via `@theme`), you need to tell tailwind-merge about them so it can resolve conflicts correctly:
|
|
475
|
+
|
|
476
|
+
```css
|
|
477
|
+
/* app.css */
|
|
478
|
+
@theme {
|
|
479
|
+
--font-size-huge: 4rem;
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
For `ntv`:
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
const heading = ntv(
|
|
487
|
+
{ $base: 'text-huge' },
|
|
488
|
+
{
|
|
489
|
+
twMergeConfig: {
|
|
490
|
+
extend: {
|
|
491
|
+
classGroups: {
|
|
492
|
+
'font-size': [{ text: ['huge'] }],
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
heading({ class: 'text-6xl' });
|
|
500
|
+
// => 'text-6xl' (text-huge is correctly replaced by text-6xl)
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
For `mergeNtvWithOptions`:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
const styles = mergeNtvWithOptions(
|
|
507
|
+
baseStyles,
|
|
508
|
+
overrideStyles,
|
|
509
|
+
)({
|
|
208
510
|
twMergeConfig: {
|
|
209
511
|
extend: {
|
|
210
|
-
|
|
211
|
-
|
|
512
|
+
classGroups: {
|
|
513
|
+
'font-size': [{ text: ['huge'] }],
|
|
212
514
|
},
|
|
213
515
|
},
|
|
214
516
|
},
|
|
215
517
|
});
|
|
216
518
|
```
|
|
217
519
|
|
|
218
|
-
|
|
520
|
+
Without this configuration, tailwind-merge wouldn't recognize `text-huge` as a font-size class and would keep both `text-huge` and `text-6xl`.
|
|
219
521
|
|
|
220
|
-
|
|
522
|
+
### Pre-configured factories
|
|
221
523
|
|
|
222
|
-
|
|
223
|
-
- `options.twMergeConfig` (`object`) - Custom tailwind-merge configuration
|
|
224
|
-
- Returns customized `composeNtv` function
|
|
524
|
+
Use `createNtv` or `createMergeNtv` to create functions with shared options:
|
|
225
525
|
|
|
226
|
-
```
|
|
227
|
-
|
|
526
|
+
```ts
|
|
527
|
+
import { createNtv, createMergeNtv, type TwMergeConfig } from 'nestable-tailwind-variants';
|
|
228
528
|
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
529
|
+
const twMergeConfig: TwMergeConfig = {
|
|
530
|
+
extend: {
|
|
531
|
+
classGroups: {
|
|
532
|
+
'font-size': [{ text: ['huge', 'tiny'] }],
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const ntv = createNtv({ twMergeConfig });
|
|
538
|
+
const mergeNtv = createMergeNtv({ twMergeConfig });
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
## Tooling
|
|
542
|
+
|
|
543
|
+
### Tailwind CSS IntelliSense
|
|
544
|
+
|
|
545
|
+
Add to `.vscode/settings.json` for [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) class autocomplete:
|
|
546
|
+
|
|
547
|
+
```json
|
|
548
|
+
{
|
|
549
|
+
"tailwindCSS.experimental.classRegex": ["ntv\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### prettier-plugin-tailwindcss
|
|
554
|
+
|
|
555
|
+
Add to your Prettier config for [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) automatic class sorting:
|
|
556
|
+
|
|
557
|
+
```json
|
|
558
|
+
{
|
|
559
|
+
"tailwindFunctions": ["ntv"]
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### eslint-plugin-better-tailwindcss
|
|
564
|
+
|
|
565
|
+
Add to your ESLint config for [eslint-plugin-better-tailwindcss](https://github.com/schoero/eslint-plugin-better-tailwindcss) linting:
|
|
566
|
+
|
|
567
|
+
```js
|
|
568
|
+
import { getDefaultCallees } from 'eslint-plugin-better-tailwindcss/defaults';
|
|
569
|
+
|
|
570
|
+
export default [
|
|
571
|
+
{
|
|
572
|
+
settings: {
|
|
573
|
+
'better-tailwindcss': {
|
|
574
|
+
callees: [...getDefaultCallees(), ['ntv', [{ match: 'objectValues' }]]],
|
|
234
575
|
},
|
|
235
576
|
},
|
|
236
577
|
},
|
|
237
|
-
|
|
578
|
+
];
|
|
238
579
|
```
|
|
580
|
+
|
|
581
|
+
## API Reference
|
|
582
|
+
|
|
583
|
+
### Functions
|
|
584
|
+
|
|
585
|
+
| Function | Description |
|
|
586
|
+
| -------------------------------------------- | ------------------------------------------------------- |
|
|
587
|
+
| `ntv(scheme, options?)` | Create a style function from a scheme |
|
|
588
|
+
| `createNtv(options)` | Create a pre-configured `ntv` with default options |
|
|
589
|
+
| `mergeNtv(...styleFns)` | Merge multiple style functions into one |
|
|
590
|
+
| `mergeNtvWithOptions(...styleFns)(options?)` | Merge style functions with custom options |
|
|
591
|
+
| `createMergeNtv(options)` | Create a pre-configured `mergeNtv` with default options |
|
|
592
|
+
|
|
593
|
+
### Types
|
|
594
|
+
|
|
595
|
+
| Type | Description |
|
|
596
|
+
| --------------- | ------------------------------------------------------------------------------------------- |
|
|
597
|
+
| `ClassValue` | Valid class value (string, array, or object) |
|
|
598
|
+
| `ClassProp` | Props for runtime class override (`{ class?: ClassValue }` or `{ className?: ClassValue }`) |
|
|
599
|
+
| `NtvOptions` | Options for ntv functions (`{ twMerge?: boolean; twMergeConfig?: TwMergeConfig }`) |
|
|
600
|
+
| `TwMergeConfig` | Configuration object for tailwind-merge |
|
|
601
|
+
|
|
602
|
+
### Scheme Properties
|
|
603
|
+
|
|
604
|
+
| Property | Description |
|
|
605
|
+
| -------------- | ---------------------------------------------------- |
|
|
606
|
+
| `$base` | Classes always applied (top-level only) |
|
|
607
|
+
| `$default` | Fallback classes when no conditions match |
|
|
608
|
+
| `is[A-Z]*` | Boolean condition (e.g., `isSelected`, `isDisabled`) |
|
|
609
|
+
| `allows[A-Z]*` | Boolean condition (e.g., `allowsRemoving`) |
|
|
610
|
+
| `[key]` | Variant object mapping values to classes |
|
|
611
|
+
|
|
612
|
+
## License
|
|
613
|
+
|
|
614
|
+
MIT
|