Skip to content

Commit 858ad0e

Browse files
authored
Merge pull request #565 from dcastil/feature/556/add-support-for-tailwind-css-v4.1
Add support for Tailwind CSS v4.1
2 parents b7527c4 + 442502d commit 858ad0e

9 files changed

+419
-68
lines changed

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
1616
// → 'hover:bg-dark-red p-3 bg-[#B91C1C]'
1717
```
1818

19-
- Supports Tailwind v4.0 (if you use Tailwind v3, use [tailwind-merge v2.6.0](https://github.com/dcastil/tailwind-merge/tree/v2.6.0))
19+
- Supports Tailwind v4.0 up to v4.1 (if you use Tailwind v3, use [tailwind-merge v2.6.0](https://github.com/dcastil/tailwind-merge/tree/v2.6.0))
2020
- Works in all modern browsers and maintained Node versions
2121
- Fully typed
2222
- [Check bundle size on Bundlephobia](https://bundlephobia.com/package/tailwind-merge)

docs/features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ twMerge('hover:p-2 hover:p-4') // → 'hover:p-4'
3333
twMerge('hover:focus:p-2 focus:hover:p-4') // → 'focus:hover:p-4'
3434
```
3535

36-
The order of standard modifiers does not matter for tailwind-merge.
36+
tailwind-merge knows when the order of standard modifiers matters and when not and resolves conflicts accordingly.
3737

3838
### Supports arbitrary values
3939

src/lib/default-config.ts

Lines changed: 204 additions & 47 deletions
Large diffs are not rendered by default.

src/lib/types.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,18 @@ interface ConfigGroupsPart<ClassGroupIds extends string, ThemeGroupIds extends s
107107
/**
108108
* Conflicting classes across groups.
109109
*
110-
* The key is ID of class group which creates conflict, values are IDs of class groups which receive a conflict.
111-
* A class group ID is the key of a class group in classGroups object.
110+
* The key is the ID of a class group which creates a conflict, values are IDs of class groups which receive a conflict. That means if a class from from the key ID is present, all preceding classes from the values are removed.
111+
*
112+
* A class group ID is the key of a class group in the classGroups object.
113+
*
112114
* @example { gap: ['gap-x', 'gap-y'] }
113115
*/
114116
conflictingClassGroups: NoInfer<Partial<Record<ClassGroupIds, readonly ClassGroupIds[]>>>
115117
/**
116118
* Postfix modifiers conflicting with other class groups.
117119
*
118120
* A class group ID is the key of a class group in classGroups object.
121+
*
119122
* @example { 'font-size': ['leading'] }
120123
*/
121124
conflictingClassGroupModifiers: NoInfer<
@@ -196,6 +199,7 @@ export type DefaultThemeGroupIds =
196199
| 'shadow'
197200
| 'spacing'
198201
| 'text'
202+
| 'text-shadow'
199203
| 'tracking'
200204

201205
/**
@@ -285,6 +289,7 @@ export type DefaultClassGroupIds =
285289
| 'divide-y-reverse'
286290
| 'divide-y'
287291
| 'drop-shadow'
292+
| 'drop-shadow-color'
288293
| 'duration'
289294
| 'ease'
290295
| 'end'
@@ -345,6 +350,57 @@ export type DefaultClassGroupIds =
345350
| 'list-style-position'
346351
| 'list-style-type'
347352
| 'm'
353+
| 'mask-clip'
354+
| 'mask-composite'
355+
| 'mask-image-b-from-color'
356+
| 'mask-image-b-from-pos'
357+
| 'mask-image-b-to-color'
358+
| 'mask-image-b-to-pos'
359+
| 'mask-image-conic-from-color'
360+
| 'mask-image-conic-from-pos'
361+
| 'mask-image-conic-pos'
362+
| 'mask-image-conic-to-color'
363+
| 'mask-image-conic-to-pos'
364+
| 'mask-image-l-from-color'
365+
| 'mask-image-l-from-pos'
366+
| 'mask-image-l-to-color'
367+
| 'mask-image-l-to-pos'
368+
| 'mask-image-linear-from-color'
369+
| 'mask-image-linear-from-pos'
370+
| 'mask-image-linear-pos'
371+
| 'mask-image-linear-to-color'
372+
| 'mask-image-linear-to-pos'
373+
| 'mask-image-r-from-color'
374+
| 'mask-image-r-from-pos'
375+
| 'mask-image-r-to-color'
376+
| 'mask-image-r-to-pos'
377+
| 'mask-image-radial-from-color'
378+
| 'mask-image-radial-from-pos'
379+
| 'mask-image-radial-pos'
380+
| 'mask-image-radial-shape'
381+
| 'mask-image-radial-size'
382+
| 'mask-image-radial-to-color'
383+
| 'mask-image-radial-to-pos'
384+
| 'mask-image-radial'
385+
| 'mask-image-t-from-color'
386+
| 'mask-image-t-from-pos'
387+
| 'mask-image-t-to-color'
388+
| 'mask-image-t-to-pos'
389+
| 'mask-image-x-from-color'
390+
| 'mask-image-x-from-pos'
391+
| 'mask-image-x-to-color'
392+
| 'mask-image-x-to-pos'
393+
| 'mask-image-y-from-color'
394+
| 'mask-image-y-from-pos'
395+
| 'mask-image-y-to-color'
396+
| 'mask-image-y-to-pos'
397+
| 'mask-image'
398+
| 'mask-mode'
399+
| 'mask-origin'
400+
| 'mask-position'
401+
| 'mask-repeat'
402+
| 'mask-size'
403+
| 'mask-type'
348404
| 'max-h'
349405
| 'max-w'
350406
| 'mb'
@@ -472,6 +528,8 @@ export type DefaultClassGroupIds =
472528
| 'text-decoration-thickness'
473529
| 'text-decoration'
474530
| 'text-overflow'
531+
| 'text-shadow'
532+
| 'text-shadow-color'
475533
| 'text-transform'
476534
| 'text-wrap'
477535
| 'top'
@@ -496,6 +554,7 @@ export type DefaultClassGroupIds =
496554
| 'w'
497555
| 'whitespace'
498556
| 'will-change'
557+
| 'wrap'
499558
| 'z'
500559

501560
export type AnyClassGroupIds = string

src/lib/validators.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ const imageRegex =
1212

1313
export const isFraction = (value: string) => fractionRegex.test(value)
1414

15-
export const isNumber = (value: string) => Boolean(value) && !Number.isNaN(Number(value))
15+
export const isNumber = (value: string) => !!value && !Number.isNaN(Number(value))
1616

17-
export const isInteger = (value: string) => Boolean(value) && Number.isInteger(Number(value))
17+
export const isInteger = (value: string) => !!value && Number.isInteger(Number(value))
1818

1919
export const isPercent = (value: string) => value.endsWith('%') && isNumber(value.slice(0, -1))
2020

@@ -52,7 +52,8 @@ export const isArbitraryPosition = (value: string) =>
5252

5353
export const isArbitraryImage = (value: string) => getIsArbitraryValue(value, isLabelImage, isImage)
5454

55-
export const isArbitraryShadow = (value: string) => getIsArbitraryValue(value, isNever, isShadow)
55+
export const isArbitraryShadow = (value: string) =>
56+
getIsArbitraryValue(value, isLabelShadow, isShadow)
5657

5758
export const isArbitraryVariable = (value: string) => arbitraryVariableRegex.test(value)
5859

@@ -112,15 +113,11 @@ const getIsArbitraryVariable = (
112113

113114
// Labels
114115

115-
const isLabelPosition = (label: string) => label === 'position'
116+
const isLabelPosition = (label: string) => label === 'position' || label === 'percentage'
116117

117-
const imageLabels = new Set(['image', 'url'])
118+
const isLabelImage = (label: string) => label === 'image' || label === 'url'
118119

119-
const isLabelImage = (label: string) => imageLabels.has(label)
120-
121-
const sizeLabels = new Set(['length', 'size', 'percentage'])
122-
123-
const isLabelSize = (label: string) => sizeLabels.has(label)
120+
const isLabelSize = (label: string) => label === 'length' || label === 'size' || label === 'bg-size'
124121

125122
const isLabelLength = (label: string) => label === 'length'
126123

tests/arbitrary-values.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ test('handles ambiguous arbitrary values correctly', () => {
6767
expect(twMerge('text-2xl text-[calc(theme(fontSize.4xl)/1.125)]')).toBe(
6868
'text-[calc(theme(fontSize.4xl)/1.125)]',
6969
)
70-
expect(twMerge('bg-cover bg-[percentage:30%] bg-[length:200px_100px]')).toBe(
71-
'bg-[length:200px_100px]',
72-
)
70+
expect(
71+
twMerge('bg-cover bg-[percentage:30%] bg-[size:200px_100px] bg-[length:200px_100px]'),
72+
).toBe('bg-[percentage:30%] bg-[length:200px_100px]')
7373
expect(
7474
twMerge(
7575
'bg-none bg-[url(.)] bg-[image:.] bg-[url:.] bg-[linear-gradient(.)] bg-linear-to-r',

tests/class-map.test.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ test('class map has correct class groups at first part', () => {
103103
'divide-y',
104104
'divide-y-reverse',
105105
],
106-
drop: ['drop-shadow'],
106+
drop: ['drop-shadow', 'drop-shadow-color'],
107107
duration: ['duration'],
108108
ease: ['ease'],
109109
end: ['end'],
@@ -150,6 +150,59 @@ test('class map has correct class groups at first part', () => {
150150
list: ['display', 'list-image', 'list-style-position', 'list-style-type'],
151151
lowercase: ['text-transform'],
152152
m: ['m'],
153+
mask: [
154+
'mask-clip',
155+
'mask-composite',
156+
'mask-image',
157+
'mask-image-b-from-color',
158+
'mask-image-b-from-pos',
159+
'mask-image-b-to-color',
160+
'mask-image-b-to-pos',
161+
'mask-image-conic-from-color',
162+
'mask-image-conic-from-pos',
163+
'mask-image-conic-pos',
164+
'mask-image-conic-to-color',
165+
'mask-image-conic-to-pos',
166+
'mask-image-l-from-color',
167+
'mask-image-l-from-pos',
168+
'mask-image-l-to-color',
169+
'mask-image-l-to-pos',
170+
'mask-image-linear-from-color',
171+
'mask-image-linear-from-pos',
172+
'mask-image-linear-pos',
173+
'mask-image-linear-to-color',
174+
'mask-image-linear-to-pos',
175+
'mask-image-r-from-color',
176+
'mask-image-r-from-pos',
177+
'mask-image-r-to-color',
178+
'mask-image-r-to-pos',
179+
'mask-image-radial',
180+
'mask-image-radial-from-color',
181+
'mask-image-radial-from-pos',
182+
'mask-image-radial-pos',
183+
'mask-image-radial-shape',
184+
'mask-image-radial-size',
185+
'mask-image-radial-to-color',
186+
'mask-image-radial-to-pos',
187+
'mask-image-t-from-color',
188+
'mask-image-t-from-pos',
189+
'mask-image-t-to-color',
190+
'mask-image-t-to-pos',
191+
'mask-image-x-from-color',
192+
'mask-image-x-from-pos',
193+
'mask-image-x-to-color',
194+
'mask-image-x-to-pos',
195+
'mask-image-y-from-color',
196+
'mask-image-y-from-pos',
197+
'mask-image-y-to-color',
198+
'mask-image-y-to-pos',
199+
'mask-mode',
200+
'mask-origin',
201+
'mask-position',
202+
'mask-repeat',
203+
'mask-size',
204+
'mask-type',
205+
],
153206
max: ['max-h', 'max-w'],
154207
mb: ['mb'],
155208
me: ['me'],
@@ -254,7 +307,15 @@ test('class map has correct class groups at first part', () => {
254307
subpixel: ['font-smoothing'],
255308
table: ['display', 'table-layout'],
256309
tabular: ['fvn-spacing'],
257-
text: ['font-size', 'text-alignment', 'text-color', 'text-overflow', 'text-wrap'],
310+
text: [
311+
'font-size',
312+
'text-alignment',
313+
'text-color',
314+
'text-overflow',
315+
'text-shadow',
316+
'text-shadow-color',
317+
'text-wrap',
318+
],
258319
to: ['gradient-to', 'gradient-to-pos'],
259320
top: ['top'],
260321
touch: ['touch', 'touch-pz', 'touch-x', 'touch-y'],
@@ -270,6 +331,7 @@ test('class map has correct class groups at first part', () => {
270331
w: ['w'],
271332
whitespace: ['whitespace'],
272333
will: ['will-change'],
334+
wrap: ['wrap'],
273335
z: ['z'],
274336
})
275337
})

tests/tailwind-css-versions.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,77 @@ test('supports Tailwind CSS v4.0 features', () => {
8787
'via-red-500 via-(length:--mobile-header-gradient)',
8888
)
8989
})
90+
91+
test('supports Tailwind CSS v4.1 features', () => {
92+
expect(twMerge('items-baseline items-baseline-last')).toBe('items-baseline-last')
93+
expect(twMerge('self-baseline self-baseline-last')).toBe('self-baseline-last')
94+
expect(twMerge('place-content-center place-content-end-safe place-content-center-safe')).toBe(
95+
'place-content-center-safe',
96+
)
97+
expect(twMerge('items-center-safe items-baseline items-end-safe')).toBe('items-end-safe')
98+
expect(twMerge('wrap-break-word wrap-normal wrap-anywhere')).toBe('wrap-anywhere')
99+
expect(twMerge('text-shadow-none text-shadow-2xl')).toBe('text-shadow-2xl')
100+
expect(
101+
twMerge(
102+
'text-shadow-none text-shadow-md text-shadow-red text-shadow-red-500 shadow-red shadow-3xs',
103+
),
104+
).toBe('text-shadow-md text-shadow-red-500 shadow-red shadow-3xs')
105+
expect(twMerge('mask-add mask-subtract')).toBe('mask-subtract')
106+
expect(
107+
twMerge(
108+
// mask-image
109+
'mask-(--foo) mask-[foo] mask-none',
110+
// mask-image-linear-pos
111+
'mask-linear-1 mask-linear-2',
112+
// mask-image-linear-from-pos
113+
'mask-linear-from-[position:test] mask-linear-from-3',
114+
// mask-image-linear-to-pos
115+
'mask-linear-to-[position:test] mask-linear-to-3',
116+
// mask-image-linear-from-color
117+
'mask-linear-from-color-red mask-linear-from-color-3',
118+
// mask-image-linear-to-color
119+
'mask-linear-to-color-red mask-linear-to-color-3',
120+
// mask-image-t-from-pos
121+
'mask-t-from-[position:test] mask-t-from-3',
122+
// mask-image-t-to-pos
123+
'mask-t-to-[position:test] mask-t-to-3',
124+
// mask-image-t-from-color
125+
'mask-t-from-color-red mask-t-from-color-3',
126+
// mask-image-radial
127+
'mask-radial-(--test) mask-radial-[test]',
128+
// mask-image-radial-from-pos
129+
'mask-radial-from-[position:test] mask-radial-from-3',
130+
// mask-image-radial-to-pos
131+
'mask-radial-to-[position:test] mask-radial-to-3',
132+
// mask-image-radial-from-color
133+
'mask-radial-from-color-red mask-radial-from-color-3',
134+
),
135+
).toBe(
136+
'mask-none mask-linear-2 mask-linear-from-3 mask-linear-to-3 mask-linear-from-color-3 mask-linear-to-color-3 mask-t-from-3 mask-t-to-3 mask-t-from-color-3 mask-radial-[test] mask-radial-from-3 mask-radial-to-3 mask-radial-from-color-3',
137+
)
138+
expect(
139+
twMerge(
140+
// mask-image
141+
'mask-(--something) mask-[something]',
142+
// mask-position
143+
'mask-top-left mask-center mask-(position:--var) mask-[position:1px_1px] mask-position-(--var) mask-position-[1px_1px]',
144+
),
145+
).toBe('mask-[something] mask-position-[1px_1px]')
146+
expect(
147+
twMerge(
148+
// mask-image
149+
'mask-(--something) mask-[something]',
150+
// mask-size
151+
'mask-auto mask-[size:foo] mask-(size:--foo) mask-size-[foo] mask-size-(--foo) mask-cover mask-contain',
152+
),
153+
).toBe('mask-[something] mask-contain')
154+
expect(twMerge('mask-type-luminance mask-type-alpha')).toBe('mask-type-alpha')
155+
expect(twMerge('shadow-md shadow-lg/25 text-shadow-md text-shadow-lg/25')).toBe(
156+
'shadow-lg/25 text-shadow-lg/25',
157+
)
158+
expect(
159+
twMerge('drop-shadow-some-color drop-shadow-[#123456] drop-shadow-lg drop-shadow-[10px_0]'),
160+
).toBe('drop-shadow-[#123456] drop-shadow-[10px_0]')
161+
expect(twMerge('drop-shadow-[#123456] drop-shadow-some-color')).toBe('drop-shadow-some-color')
162+
expect(twMerge('drop-shadow-2xl drop-shadow-[shadow:foo]')).toBe('drop-shadow-[shadow:foo]')
163+
})

tests/validators.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ test('isArbitraryNumber', () => {
9595
test('isArbitraryPosition', () => {
9696
expect(isArbitraryPosition('[position:2px]')).toBe(true)
9797
expect(isArbitraryPosition('[position:bla]')).toBe(true)
98+
expect(isArbitraryPosition('[percentage:bla]')).toBe(true)
9899

99100
expect(isArbitraryPosition('[2px]')).toBe(false)
100101
expect(isArbitraryPosition('[bla]')).toBe(false)
@@ -120,11 +121,11 @@ test('isArbitrarySize', () => {
120121
expect(isArbitrarySize('[size:2px]')).toBe(true)
121122
expect(isArbitrarySize('[size:bla]')).toBe(true)
122123
expect(isArbitrarySize('[length:bla]')).toBe(true)
123-
expect(isArbitrarySize('[percentage:bla]')).toBe(true)
124124

125125
expect(isArbitrarySize('[2px]')).toBe(false)
126126
expect(isArbitrarySize('[bla]')).toBe(false)
127127
expect(isArbitrarySize('size:2px')).toBe(false)
128+
expect(isArbitrarySize('[percentage:bla]')).toBe(false)
128129
})
129130

130131
test('isArbitraryValue', () => {
@@ -187,6 +188,7 @@ test('isArbitraryVariablePosition', () => {
187188
expect(isArbitraryVariablePosition('(other:test)')).toBe(false)
188189
expect(isArbitraryVariablePosition('(test)')).toBe(false)
189190
expect(isArbitraryVariablePosition('position:test')).toBe(false)
191+
expect(isArbitraryVariablePosition('percentage:test')).toBe(false)
190192
})
191193

192194
test('isArbitraryVariableShadow', () => {
@@ -200,11 +202,11 @@ test('isArbitraryVariableShadow', () => {
200202
test('isArbitraryVariableSize', () => {
201203
expect(isArbitraryVariableSize('(size:test)')).toBe(true)
202204
expect(isArbitraryVariableSize('(length:test)')).toBe(true)
203-
expect(isArbitraryVariableSize('(percentage:test)')).toBe(true)
204205

205206
expect(isArbitraryVariableSize('(other:test)')).toBe(false)
206207
expect(isArbitraryVariableSize('(test)')).toBe(false)
207208
expect(isArbitraryVariableSize('size:test')).toBe(false)
209+
expect(isArbitraryVariableSize('(percentage:test)')).toBe(false)
208210
})
209211

210212
test('isFraction', () => {

0 commit comments

Comments
 (0)