nestable-tailwind-variants 0.1.3 → 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 +512 -115
- 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 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- 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 +30 -91
- package/dist/ntv.d.ts.map +1 -1
- package/dist/ntv.js +44 -147
- 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 +110 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +12 -9
package/README.md
CHANGED
|
@@ -1,8 +1,67 @@
|
|
|
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
|
+
|
|
7
|
+
## Why nestable-tailwind-variants?
|
|
8
|
+
|
|
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
|
+
});
|
|
31
|
+
|
|
32
|
+
button({ variant: 'primary', isHovered: true });
|
|
33
|
+
// => 'px-4 py-2 rounded bg-blue-500 text-white bg-blue-600'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
nestable-tailwind-variants solves this by letting you nest conditions directly inside variants, keeping related logic together:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { ntv } from 'nestable-tailwind-variants';
|
|
40
|
+
|
|
41
|
+
interface ButtonProps {
|
|
42
|
+
variant: 'primary' | 'secondary';
|
|
43
|
+
isHovered: boolean;
|
|
44
|
+
isPressed: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const button = ntv<ButtonProps>({
|
|
48
|
+
$base: 'px-4 py-2 rounded',
|
|
49
|
+
variant: {
|
|
50
|
+
primary: {
|
|
51
|
+
$default: 'bg-blue-500 text-white',
|
|
52
|
+
isHovered: 'bg-blue-600',
|
|
53
|
+
isPressed: 'bg-blue-700',
|
|
54
|
+
},
|
|
55
|
+
secondary: {
|
|
56
|
+
$default: 'bg-gray-200 text-gray-800',
|
|
57
|
+
isHovered: 'bg-gray-300',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
button({ variant: 'primary', isHovered: true });
|
|
63
|
+
// => 'px-4 py-2 rounded bg-blue-600'
|
|
64
|
+
```
|
|
6
65
|
|
|
7
66
|
## Installation
|
|
8
67
|
|
|
@@ -10,208 +69,546 @@ Inspired by [React Spectrum's `style` macro](https://react-spectrum.adobe.com/st
|
|
|
10
69
|
npm install nestable-tailwind-variants
|
|
11
70
|
```
|
|
12
71
|
|
|
13
|
-
##
|
|
72
|
+
## Core Concepts
|
|
14
73
|
|
|
15
|
-
|
|
74
|
+
### `$base` - Always-applied styles
|
|
16
75
|
|
|
17
|
-
|
|
18
|
-
import { ntv } from 'nestable-tailwind-variants';
|
|
76
|
+
The `$base` property defines styles that are always included in the output:
|
|
19
77
|
|
|
20
|
-
|
|
21
|
-
|
|
78
|
+
```ts
|
|
79
|
+
interface CardProps {
|
|
80
|
+
variant: 'elevated' | 'flat';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const card = ntv<CardProps>({
|
|
84
|
+
$base: 'rounded-lg shadow-md p-4',
|
|
22
85
|
variant: {
|
|
23
|
-
|
|
24
|
-
|
|
86
|
+
elevated: 'shadow-xl',
|
|
87
|
+
flat: 'shadow-none',
|
|
25
88
|
},
|
|
26
89
|
});
|
|
27
90
|
|
|
28
|
-
|
|
29
|
-
// => '
|
|
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
|
|
30
97
|
```
|
|
31
98
|
|
|
32
|
-
|
|
99
|
+
### Variants - String-based selection
|
|
33
100
|
|
|
34
|
-
|
|
101
|
+
Define variants as objects mapping variant values to class names:
|
|
35
102
|
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
},
|
|
40
121
|
});
|
|
41
122
|
|
|
42
|
-
|
|
43
|
-
// => 'bg-
|
|
123
|
+
badge({ size: 'lg', color: 'success' });
|
|
124
|
+
// => 'py-1 rounded text-base px-3 bg-green-100 text-green-800'
|
|
44
125
|
```
|
|
45
126
|
|
|
46
|
-
|
|
127
|
+
### Boolean Conditions - `is[A-Z]` / `allows[A-Z]` patterns
|
|
47
128
|
|
|
48
|
-
|
|
129
|
+
Keys matching `is[A-Z]*` or `allows[A-Z]*` are treated as boolean conditions:
|
|
49
130
|
|
|
50
|
-
```
|
|
51
|
-
interface
|
|
52
|
-
|
|
53
|
-
|
|
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';
|
|
54
161
|
}
|
|
55
162
|
|
|
56
|
-
const
|
|
163
|
+
const text = ntv<TextProps>({
|
|
164
|
+
$default: 'text-gray-500',
|
|
57
165
|
variant: {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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',
|
|
191
|
+
primary: {
|
|
192
|
+
size: {
|
|
193
|
+
$default: 'size-default',
|
|
194
|
+
large: 'size-large',
|
|
195
|
+
},
|
|
61
196
|
},
|
|
62
197
|
},
|
|
63
198
|
});
|
|
64
199
|
|
|
65
|
-
|
|
66
|
-
// => '
|
|
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
|
|
67
207
|
|
|
68
|
-
|
|
69
|
-
// => '
|
|
208
|
+
styles({ variant: 'primary', size: 'large' });
|
|
209
|
+
// => 'base size-large'
|
|
210
|
+
// Both matched, so no $defaults are applied
|
|
70
211
|
```
|
|
71
212
|
|
|
72
|
-
|
|
213
|
+
## Nested Conditions
|
|
73
214
|
|
|
74
|
-
|
|
215
|
+
The core feature of nestable-tailwind-variants is the ability to nest conditions inside variants.
|
|
75
216
|
|
|
76
|
-
|
|
217
|
+
### Boolean conditions inside variants
|
|
77
218
|
|
|
78
|
-
|
|
219
|
+
```ts
|
|
220
|
+
interface ChipProps {
|
|
221
|
+
variant: 'filled' | 'outlined';
|
|
222
|
+
isSelected: boolean;
|
|
223
|
+
}
|
|
79
224
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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',
|
|
87
235
|
},
|
|
88
|
-
isHovered: { true: '' },
|
|
89
|
-
isPressed: { true: '' },
|
|
90
236
|
},
|
|
91
|
-
compoundVariants: [
|
|
92
|
-
{ variant: 'primary', isHovered: true, class: 'bg-blue-600' },
|
|
93
|
-
{ variant: 'primary', isPressed: true, class: 'bg-blue-700' },
|
|
94
|
-
{ variant: 'secondary', isHovered: true, class: 'bg-gray-300' },
|
|
95
|
-
{ variant: 'secondary', isPressed: true, class: 'bg-gray-400' },
|
|
96
|
-
],
|
|
97
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'
|
|
98
247
|
```
|
|
99
248
|
|
|
100
|
-
|
|
249
|
+
### Multi-level nesting
|
|
250
|
+
|
|
251
|
+
You can nest conditions to any depth:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
interface ButtonProps {
|
|
255
|
+
variant: 'primary';
|
|
256
|
+
isHovered: boolean;
|
|
257
|
+
isPressed: boolean;
|
|
258
|
+
isDisabled: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
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
|
+
});
|
|
275
|
+
|
|
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'
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Composing Styles
|
|
290
|
+
|
|
291
|
+
Merge multiple style functions into one. Later functions take precedence:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import { ntv, mergeNtv } from 'nestable-tailwind-variants';
|
|
295
|
+
|
|
296
|
+
interface BaseButtonProps {
|
|
297
|
+
size: 'sm' | 'md';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const baseButton = ntv<BaseButtonProps>({
|
|
301
|
+
$base: 'rounded font-medium transition-colors',
|
|
302
|
+
size: {
|
|
303
|
+
sm: 'px-3 py-1.5 text-sm',
|
|
304
|
+
md: 'px-4 py-2 text-base',
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
interface ColoredButtonProps {
|
|
309
|
+
variant: 'primary' | 'secondary';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const coloredButton = ntv<ColoredButtonProps>({
|
|
313
|
+
variant: {
|
|
314
|
+
primary: 'bg-blue-500 text-white hover:bg-blue-600',
|
|
315
|
+
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const button = mergeNtv(baseButton, coloredButton);
|
|
320
|
+
|
|
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'
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Passing Additional Classes
|
|
326
|
+
|
|
327
|
+
Pass additional classes using `class` or `className`:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
interface BoxProps {
|
|
331
|
+
variant: 'primary' | 'secondary';
|
|
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'
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## React Aria Components Integration
|
|
351
|
+
|
|
352
|
+
nestable-tailwind-variants is designed to work seamlessly with [React Aria Components render props](https://react-aria.adobe.com/styling#render-props):
|
|
101
353
|
|
|
102
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';
|
|
362
|
+
|
|
103
363
|
interface ButtonStyleProps {
|
|
104
364
|
variant?: 'primary' | 'secondary';
|
|
105
|
-
|
|
106
|
-
isPressed?: boolean;
|
|
365
|
+
size?: 'sm' | 'md' | 'lg';
|
|
107
366
|
}
|
|
108
367
|
|
|
109
|
-
const button = ntv<ButtonStyleProps>({
|
|
110
|
-
|
|
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',
|
|
111
371
|
variant: {
|
|
112
372
|
primary: {
|
|
113
|
-
default: 'bg-blue-500 text-white',
|
|
373
|
+
$default: 'bg-blue-500 text-white',
|
|
114
374
|
isHovered: 'bg-blue-600',
|
|
115
375
|
isPressed: 'bg-blue-700',
|
|
376
|
+
isFocusVisible: 'ring-2 ring-blue-500 ring-offset-2',
|
|
116
377
|
},
|
|
117
378
|
secondary: {
|
|
118
|
-
default: 'bg-gray-200 text-gray-800',
|
|
379
|
+
$default: 'bg-gray-200 text-gray-800',
|
|
119
380
|
isHovered: 'bg-gray-300',
|
|
120
381
|
isPressed: 'bg-gray-400',
|
|
382
|
+
isFocusVisible: 'ring-2 ring-gray-500 ring-offset-2',
|
|
121
383
|
},
|
|
122
384
|
},
|
|
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',
|
|
123
391
|
});
|
|
124
|
-
```
|
|
125
392
|
|
|
126
|
-
|
|
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
|
+
}
|
|
411
|
+
```
|
|
127
412
|
|
|
128
|
-
##
|
|
413
|
+
## Type Safety
|
|
129
414
|
|
|
130
|
-
|
|
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:
|
|
131
416
|
|
|
132
|
-
```
|
|
133
|
-
|
|
417
|
+
```ts
|
|
418
|
+
interface ButtonProps {
|
|
419
|
+
variant: 'primary' | 'secondary';
|
|
420
|
+
size: 'sm' | 'md' | 'lg';
|
|
421
|
+
isDisabled: boolean;
|
|
422
|
+
}
|
|
134
423
|
|
|
135
|
-
const
|
|
136
|
-
|
|
424
|
+
const button = ntv<ButtonProps>({
|
|
425
|
+
$base: 'rounded',
|
|
426
|
+
variant: {
|
|
427
|
+
primary: 'bg-blue-500',
|
|
428
|
+
secondary: 'bg-gray-200',
|
|
429
|
+
},
|
|
137
430
|
size: {
|
|
138
|
-
sm: '
|
|
139
|
-
|
|
431
|
+
sm: 'text-sm',
|
|
432
|
+
md: 'text-base',
|
|
433
|
+
lg: 'text-lg',
|
|
140
434
|
},
|
|
435
|
+
isDisabled: 'opacity-50',
|
|
141
436
|
});
|
|
142
437
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
secondary: 'bg-gray-200 text-gray-800',
|
|
147
|
-
},
|
|
148
|
-
});
|
|
438
|
+
button({ variant: 'primary', size: 'lg' }); // ✅ OK
|
|
439
|
+
button({ variant: 'tertiary' }); // ❌ Error: 'tertiary' is not assignable
|
|
440
|
+
```
|
|
149
441
|
|
|
150
|
-
|
|
442
|
+
Omitting the type argument when the scheme has keys disables type checking. Provide explicit type arguments when possible for better type safety.
|
|
151
443
|
|
|
152
|
-
|
|
153
|
-
// => 'rounded font-medium px-4 py-2 text-lg bg-blue-500 text-white'
|
|
154
|
-
```
|
|
444
|
+
## Options
|
|
155
445
|
|
|
156
|
-
|
|
446
|
+
`ntv` accepts an options object as the second argument. For `mergeNtv`, use `mergeNtvWithOptions` to pass options.
|
|
157
447
|
|
|
158
|
-
###
|
|
448
|
+
### Disabling tailwind-merge
|
|
159
449
|
|
|
160
|
-
|
|
450
|
+
By default, [tailwind-merge](https://github.com/dcastil/tailwind-merge) is used to resolve class conflicts. To disable it, pass `{ twMerge: false }`.
|
|
161
451
|
|
|
162
|
-
|
|
163
|
-
- `default` - Base styles (skipped when other conditions match at the same level)
|
|
164
|
-
- `[variantKey]` - Style definitions for each variant value
|
|
165
|
-
- `is*` / `allows*` - Boolean condition styles
|
|
166
|
-
- Returns `(props: Partial<Props>) => string`
|
|
452
|
+
For `ntv`:
|
|
167
453
|
|
|
168
|
-
|
|
454
|
+
```ts
|
|
455
|
+
const styles = ntv(
|
|
456
|
+
{
|
|
457
|
+
$base: 'p-4',
|
|
458
|
+
},
|
|
459
|
+
{ twMerge: false },
|
|
460
|
+
);
|
|
169
461
|
|
|
170
|
-
|
|
462
|
+
styles({ class: 'p-8' });
|
|
463
|
+
// => 'p-4 p-8' (no merge, both classes kept)
|
|
464
|
+
```
|
|
171
465
|
|
|
172
|
-
|
|
173
|
-
- Returns `(props: Partial<Props>) => string`
|
|
466
|
+
For `mergeNtvWithOptions`:
|
|
174
467
|
|
|
175
|
-
|
|
468
|
+
```ts
|
|
469
|
+
const styles = mergeNtvWithOptions(baseStyles, overrideStyles)({ twMerge: false });
|
|
470
|
+
```
|
|
176
471
|
|
|
177
|
-
|
|
472
|
+
### Custom tailwind-merge configuration
|
|
178
473
|
|
|
179
|
-
|
|
180
|
-
- `options.twMergeConfig` (`object`) - Custom tailwind-merge configuration
|
|
181
|
-
- Returns customized `ntv` function
|
|
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:
|
|
182
475
|
|
|
183
|
-
```
|
|
184
|
-
|
|
476
|
+
```css
|
|
477
|
+
/* app.css */
|
|
478
|
+
@theme {
|
|
479
|
+
--font-size-huge: 4rem;
|
|
480
|
+
}
|
|
481
|
+
```
|
|
185
482
|
|
|
186
|
-
|
|
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
|
+
)({
|
|
187
510
|
twMergeConfig: {
|
|
188
511
|
extend: {
|
|
189
|
-
|
|
190
|
-
|
|
512
|
+
classGroups: {
|
|
513
|
+
'font-size': [{ text: ['huge'] }],
|
|
191
514
|
},
|
|
192
515
|
},
|
|
193
516
|
},
|
|
194
517
|
});
|
|
195
518
|
```
|
|
196
519
|
|
|
197
|
-
|
|
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`.
|
|
198
521
|
|
|
199
|
-
|
|
522
|
+
### Pre-configured factories
|
|
200
523
|
|
|
201
|
-
|
|
202
|
-
- `options.twMergeConfig` (`object`) - Custom tailwind-merge configuration
|
|
203
|
-
- Returns customized `composeNtv` function
|
|
524
|
+
Use `createNtv` or `createMergeNtv` to create functions with shared options:
|
|
204
525
|
|
|
205
|
-
```
|
|
206
|
-
|
|
526
|
+
```ts
|
|
527
|
+
import { createNtv, createMergeNtv, type TwMergeConfig } from 'nestable-tailwind-variants';
|
|
207
528
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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' }]]],
|
|
213
575
|
},
|
|
214
576
|
},
|
|
215
577
|
},
|
|
216
|
-
|
|
578
|
+
];
|
|
217
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
|