Skip to content

Commit f543405

Browse files
authored
Merge pull request #103 from dcastil/feature/101/add-support-for-tailwind-v3.1
Add support for Tailwind v3.1
2 parents 5bcc14b + 7bf25cf commit f543405

6 files changed

+197
-29
lines changed

README.md

+14-1
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 v3.0 (if you use Tailwind v2, use [tailwind-merge v0.9.0](https://github.com/dcastil/tailwind-merge/tree/v0.9.0))
19+
- Supports Tailwind v3.0 up to v3.1 (if you use Tailwind v2, use [tailwind-merge v0.9.0](https://github.com/dcastil/tailwind-merge/tree/v0.9.0))
2020
- Works in Node >=12 and all modern browsers
2121
- Fully typed
2222
- [Check bundle size on Bundlephobia](https://bundlephobia.com/package/tailwind-merge)
@@ -114,6 +114,19 @@ twMerge('[padding:1rem] p-8') // → '[padding:1rem] p-8'
114114

115115
Watch out for mixing arbitrary properties which could be expressed as Tailwind classes. tailwind-merge does not resolve conflicts between arbitrary properties and their matching Tailwind classes to keep the bundle size small.
116116

117+
### Supports arbitrary variants
118+
119+
```ts
120+
twMerge('[&:nth-child(3)]:py-0 [&:nth-child(3)]:py-4') // → '[&:nth-child(3)]:py-4'
121+
twMerge('dark:hover:[&:nth-child(3)]:py-0 hover:dark:[&:nth-child(3)]:py-4')
122+
// → 'hover:dark:[&:nth-child(3)]:py-4'
123+
124+
// Don't do this!
125+
twMerge('[&:focus]:ring focus:ring-4') // → '[&:focus]:ring focus:ring-4'
126+
```
127+
128+
Similarly to arbitrary properties, tailwind-merge does not resolve conflicts between arbitrary variants and their matching predefined modifiers for bundle size reasons.
129+
117130
### Supports important modifier
118131

119132
```ts

src/lib/default-config.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function getDefaultConfig() {
2020
const brightness = fromTheme('brightness')
2121
const borderColor = fromTheme('borderColor')
2222
const borderRadius = fromTheme('borderRadius')
23+
const borderSpacing = fromTheme('borderSpacing')
2324
const borderWidth = fromTheme('borderWidth')
2425
const contrast = fromTheme('contrast')
2526
const grayscale = fromTheme('grayscale')
@@ -74,6 +75,7 @@ export function getDefaultConfig() {
7475
'saturation',
7576
'color',
7677
'luminosity',
78+
'plus-lighter',
7779
] as const
7880
const getAlign = () => ['start', 'end', 'center', 'between', 'around', 'evenly'] as const
7981
const getZeroAndEmpty = () => ['', '0', isArbitraryValue] as const
@@ -89,6 +91,7 @@ export function getDefaultConfig() {
8991
brightness: [isInteger],
9092
borderColor: [colors],
9193
borderRadius: ['none', '', 'full', isTshirtSize, isArbitraryLength],
94+
borderSpacing: [spacing],
9295
borderWidth: getLengthWithEmpty(),
9396
contrast: [isInteger],
9497
grayscale: getZeroAndEmpty(),
@@ -361,7 +364,7 @@ export function getDefaultConfig() {
361364
* Grid Auto Flow
362365
* @see https://tailwindcss.com/docs/grid-auto-flow
363366
*/
364-
'grid-flow': [{ 'grid-flow': ['row', 'col', 'row-dense', 'col-dense'] }],
367+
'grid-flow': [{ 'grid-flow': ['row', 'col', 'dense', 'row-dense', 'col-dense'] }],
365368
/**
366369
* Grid Auto Columns
367370
* @see https://tailwindcss.com/docs/grid-auto-columns
@@ -689,7 +692,7 @@ export function getDefaultConfig() {
689692
* Text Alignment
690693
* @see https://tailwindcss.com/docs/text-align
691694
*/
692-
'text-alignment': [{ text: ['left', 'center', 'right', 'justify'] }],
695+
'text-alignment': [{ text: ['left', 'center', 'right', 'justify', 'start', 'end'] }],
693696
/**
694697
* Text Color
695698
* @see https://tailwindcss.com/docs/text-color
@@ -1190,6 +1193,21 @@ export function getDefaultConfig() {
11901193
* @see https://tailwindcss.com/docs/border-collapse
11911194
*/
11921195
'border-collapse': [{ border: ['collapse', 'separate'] }],
1196+
/**
1197+
* Border Spacing
1198+
* @see https://tailwindcss.com/docs/border-spacing
1199+
*/
1200+
'border-spacing': [{ 'border-spacing': [borderSpacing] }],
1201+
/**
1202+
* Border Spacing X
1203+
* @see https://tailwindcss.com/docs/border-spacing
1204+
*/
1205+
'border-spacing-x': [{ 'border-spacing-x': [borderSpacing] }],
1206+
/**
1207+
* Border Spacing Y
1208+
* @see https://tailwindcss.com/docs/border-spacing
1209+
*/
1210+
'border-spacing-y': [{ 'border-spacing-y': [borderSpacing] }],
11931211
/**
11941212
* Table Layout
11951213
* @see https://tailwindcss.com/docs/table-layout
@@ -1561,6 +1579,7 @@ export function getDefaultConfig() {
15611579
'rounded-r': ['rounded-tr', 'rounded-br'],
15621580
'rounded-b': ['rounded-br', 'rounded-bl'],
15631581
'rounded-l': ['rounded-tl', 'rounded-bl'],
1582+
'border-spacing': ['border-spacing-x', 'border-spacing-y'],
15641583
'border-w': ['border-w-t', 'border-w-r', 'border-w-b', 'border-w-l'],
15651584
'border-w-x': ['border-w-r', 'border-w-l'],
15661585
'border-w-y': ['border-w-t', 'border-w-b'],

src/lib/merge-classlist.ts

+74-24
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@ import { ConfigUtils } from './config-utils'
22

33
const SPLIT_CLASSES_REGEX = /\s+/
44
const IMPORTANT_MODIFIER = '!'
5-
// Regex is needed, so we don't match against colons in labels for arbitrary values like `text-[color:var(--mystery-var)]`
6-
// I'd prefer to use a negative lookbehind for all supported labels, but lookbehinds don't have good browser support yet. More info: https://caniuse.com/js-regexp-lookbehind
7-
const MODIFIER_SEPARATOR_REGEX = /:(?![^[]*\])/
8-
const MODIFIER_SEPARATOR = ':'
95

106
export function mergeClassList(classList: string, configUtils: ConfigUtils) {
117
const { getClassGroupId, getConflictingClassGroupIds } = configUtils
@@ -15,7 +11,7 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
1511
* `{importantModifier}{variantModifiers}{classGroupId}`
1612
* @example 'float'
1713
* @example 'hover:focus:bg-color'
18-
* @example '!md:pr'
14+
* @example 'md:!pr'
1915
*/
2016
const classGroupsInConflict = new Set<string>()
2117

@@ -24,16 +20,10 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
2420
.trim()
2521
.split(SPLIT_CLASSES_REGEX)
2622
.map((originalClassName) => {
27-
const modifiers = originalClassName.split(MODIFIER_SEPARATOR_REGEX)
28-
const classNameWithImportantModifier = modifiers.pop()!
23+
const { modifiers, hasImportantModifier, baseClassName } =
24+
splitModifiers(originalClassName)
2925

30-
const hasImportantModifier =
31-
classNameWithImportantModifier.startsWith(IMPORTANT_MODIFIER)
32-
const className = hasImportantModifier
33-
? classNameWithImportantModifier.substring(1)
34-
: classNameWithImportantModifier
35-
36-
const classGroupId = getClassGroupId(className)
26+
const classGroupId = getClassGroupId(baseClassName)
3727

3828
if (!classGroupId) {
3929
return {
@@ -42,18 +32,15 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
4232
}
4333
}
4434

45-
const variantModifier =
46-
modifiers.length === 0
47-
? ''
48-
: modifiers.sort().concat('').join(MODIFIER_SEPARATOR)
35+
const variantModifier = sortModifiers(modifiers).join('')
4936

50-
const fullModifier = hasImportantModifier
51-
? IMPORTANT_MODIFIER + variantModifier
37+
const modifierId = hasImportantModifier
38+
? variantModifier + IMPORTANT_MODIFIER
5239
: variantModifier
5340

5441
return {
5542
isTailwindClass: true as const,
56-
modifier: fullModifier,
43+
modifierId,
5744
classGroupId,
5845
originalClassName,
5946
}
@@ -65,9 +52,9 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
6552
return true
6653
}
6754

68-
const { modifier, classGroupId } = parsed
55+
const { modifierId, classGroupId } = parsed
6956

70-
const classId = `${modifier}:${classGroupId}`
57+
const classId = `${modifierId}${classGroupId}`
7158

7259
if (classGroupsInConflict.has(classId)) {
7360
return false
@@ -76,7 +63,7 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
7663
classGroupsInConflict.add(classId)
7764

7865
getConflictingClassGroupIds(classGroupId).forEach((group) =>
79-
classGroupsInConflict.add(`${modifier}:${group}`)
66+
classGroupsInConflict.add(`${modifierId}${group}`)
8067
)
8168

8269
return true
@@ -86,3 +73,66 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
8673
.join(' ')
8774
)
8875
}
76+
77+
function splitModifiers(className: string) {
78+
const modifiers = []
79+
80+
let bracketDepth = 0
81+
let modifierStart = 0
82+
83+
for (const match of className.matchAll(/[:[\]]/g)) {
84+
if (match[0] === ':') {
85+
if (bracketDepth === 0) {
86+
const nextModifierStart = match.index! + 1
87+
modifiers.push(className.substring(modifierStart, nextModifierStart))
88+
modifierStart = nextModifierStart
89+
}
90+
} else if (match[0] === '[') {
91+
bracketDepth++
92+
} else if (match[0] === ']') {
93+
bracketDepth--
94+
}
95+
}
96+
97+
const baseClassNameWithImportantModifier =
98+
modifiers.length === 0 ? className : className.substring(modifierStart)
99+
const hasImportantModifier = baseClassNameWithImportantModifier.startsWith(IMPORTANT_MODIFIER)
100+
const baseClassName = hasImportantModifier
101+
? baseClassNameWithImportantModifier.substring(1)
102+
: baseClassNameWithImportantModifier
103+
104+
return {
105+
modifiers,
106+
hasImportantModifier,
107+
baseClassName,
108+
}
109+
}
110+
111+
/**
112+
* Sorts modifiers according to following schema:
113+
* - Predefined modifiers are sorted alphabetically
114+
* - When an arbitrary variant appears, it's important to preserve which modifiers are before and after it
115+
*/
116+
function sortModifiers(modifiers: string[]) {
117+
if (modifiers.length <= 1) {
118+
return modifiers
119+
}
120+
121+
const sortedModifiers = []
122+
let unsortedModifiers: string[] = []
123+
124+
modifiers.forEach((modifier) => {
125+
const isArbitraryVariant = modifier[0] === '['
126+
127+
if (isArbitraryVariant) {
128+
sortedModifiers.push(...unsortedModifiers.sort(), modifier)
129+
unsortedModifiers = []
130+
} else {
131+
unsortedModifiers.push(modifier)
132+
}
133+
})
134+
135+
sortedModifiers.push(...unsortedModifiers.sort())
136+
137+
return sortedModifiers
138+
}

tests/arbitrary-variants.test.ts

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { twMerge } from '../src'
2+
3+
test('basic arbitrary variants', () => {
4+
expect(twMerge('[&>*]:underline [&>*]:line-through')).toBe('[&>*]:line-through')
5+
expect(twMerge('[&>*]:underline [&>*]:line-through [&_div]:line-through')).toBe(
6+
'[&>*]:line-through [&_div]:line-through'
7+
)
8+
})
9+
10+
test('arbitrary variants with modifiers', () => {
11+
expect(twMerge('dark:lg:hover:[&>*]:underline dark:lg:hover:[&>*]:line-through')).toBe(
12+
'dark:lg:hover:[&>*]:line-through'
13+
)
14+
expect(twMerge('dark:lg:hover:[&>*]:underline dark:hover:lg:[&>*]:line-through')).toBe(
15+
'dark:hover:lg:[&>*]:line-through'
16+
)
17+
// Whether a modifier is before or after arbitrary variant matters
18+
expect(twMerge('hover:[&>*]:underline [&>*]:hover:line-through')).toBe(
19+
'hover:[&>*]:underline [&>*]:hover:line-through'
20+
)
21+
expect(
22+
twMerge(
23+
'hover:dark:[&>*]:underline dark:hover:[&>*]:underline dark:[&>*]:hover:line-through'
24+
)
25+
).toBe('dark:hover:[&>*]:underline dark:[&>*]:hover:line-through')
26+
})
27+
28+
test('arbitrary variants with complex syntax in them', () => {
29+
expect(
30+
twMerge(
31+
'[@media_screen{@media(hover:hover)}]:underline [@media_screen{@media(hover:hover)}]:line-through'
32+
)
33+
).toBe('[@media_screen{@media(hover:hover)}]:line-through')
34+
expect(
35+
twMerge(
36+
'hover:[@media_screen{@media(hover:hover)}]:underline hover:[@media_screen{@media(hover:hover)}]:line-through'
37+
)
38+
).toBe('hover:[@media_screen{@media(hover:hover)}]:line-through')
39+
})
40+
41+
test('arbitrary variants with attribute selectors', () => {
42+
expect(twMerge('[&[data-open]]:underline [&[data-open]]:line-through')).toBe(
43+
'[&[data-open]]:line-through'
44+
)
45+
})
46+
47+
test('arbitrary variants with multiple attribute selectors', () => {
48+
expect(
49+
twMerge(
50+
'[&[data-foo][data-bar]:not([data-baz])]:underline [&[data-foo][data-bar]:not([data-baz])]:line-through'
51+
)
52+
).toBe('[&[data-foo][data-bar]:not([data-baz])]:line-through')
53+
})
54+
55+
test('multiple arbitrary variants', () => {
56+
expect(twMerge('[&>*]:[&_div]:underline [&>*]:[&_div]:line-through')).toBe(
57+
'[&>*]:[&_div]:line-through'
58+
)
59+
expect(twMerge('[&>*]:[&_div]:underline [&_div]:[&>*]:line-through')).toBe(
60+
'[&>*]:[&_div]:underline [&_div]:[&>*]:line-through'
61+
)
62+
expect(
63+
twMerge(
64+
'hover:dark:[&>*]:focus:disabled:[&_div]:underline dark:hover:[&>*]:disabled:focus:[&_div]:line-through'
65+
)
66+
).toBe('dark:hover:[&>*]:disabled:focus:[&_div]:line-through')
67+
expect(
68+
twMerge(
69+
'hover:dark:[&>*]:focus:[&_div]:disabled:underline dark:hover:[&>*]:disabled:focus:[&_div]:line-through'
70+
)
71+
).toBe(
72+
'hover:dark:[&>*]:focus:[&_div]:disabled:underline dark:hover:[&>*]:disabled:focus:[&_div]:line-through'
73+
)
74+
})
75+
76+
test('arbitrary variants with arbitrary properties', () => {
77+
expect(twMerge('[&>*]:[color:red] [&>*]:[color:blue]')).toBe('[&>*]:[color:blue]')
78+
expect(
79+
twMerge(
80+
'[&[data-foo][data-bar]:not([data-baz])]:nod:noa:[color:red] [&[data-foo][data-bar]:not([data-baz])]:noa:nod:[color:blue]'
81+
)
82+
).toBe('[&[data-foo][data-bar]:not([data-baz])]:noa:nod:[color:blue]')
83+
})

tests/class-map.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ test('class map has correct class groups at first part', () => {
6161
'border-color-x',
6262
'border-color-y',
6363
'border-opacity',
64+
'border-spacing',
65+
'border-spacing-x',
66+
'border-spacing-y',
6467
'border-style',
6568
'border-w',
6669
'border-w-b',

tests/readme-examples.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import fs from 'fs'
33
import { twMerge } from '../src'
44

55
const twMergeExampleRegex =
6-
/twMerge\((?<arguments>[\w\s\-:[\]#(),!\n'"]+?)\)(?!.*(?<!\/\/.*)')\s*\n?\s*\/\/\s*\s*['"](?<result>.+)['"]/g
6+
/twMerge\((?<arguments>[\w\s\-:[\]#(),!&\n'"]+?)\)(?!.*(?<!\/\/.*)')\s*\n?\s*\/\/\s*\s*['"](?<result>.+)['"]/g
77

88
test('readme examples', () => {
9-
expect.assertions(21)
9+
expect.assertions(24)
1010

1111
return fs.promises
1212
.readFile(`${__dirname}/../README.md`, { encoding: 'utf-8' })

0 commit comments

Comments
 (0)