Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Tailwind v3.1 #103

Merged
merged 13 commits into from Jun 12, 2022
15 changes: 14 additions & 1 deletion README.md
Expand Up @@ -16,7 +16,7 @@ twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
// → 'hover:bg-dark-red p-3 bg-[#B91C1C]'
```

- 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))
- 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))
- Works in Node >=12 and all modern browsers
- Fully typed
- [Check bundle size on Bundlephobia](https://bundlephobia.com/package/tailwind-merge)
Expand Down Expand Up @@ -114,6 +114,19 @@ twMerge('[padding:1rem] p-8') // → '[padding:1rem] p-8'

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.

### Supports arbitrary variants

```ts
twMerge('[&:nth-child(3)]:py-0 [&:nth-child(3)]:py-4') // → '[&:nth-child(3)]:py-4'
twMerge('dark:hover:[&:nth-child(3)]:py-0 hover:dark:[&:nth-child(3)]:py-4')
// → 'hover:dark:[&:nth-child(3)]:py-4'

// Don't do this!
twMerge('[&:focus]:ring focus:ring-4') // → '[&:focus]:ring focus:ring-4'
```

Similarly to arbitrary properties, tailwind-merge does not resolve conflicts between arbitrary variants and their matching predefined modifiers for bundle size reasons.

### Supports important modifier

```ts
Expand Down
23 changes: 21 additions & 2 deletions src/lib/default-config.ts
Expand Up @@ -20,6 +20,7 @@ export function getDefaultConfig() {
const brightness = fromTheme('brightness')
const borderColor = fromTheme('borderColor')
const borderRadius = fromTheme('borderRadius')
const borderSpacing = fromTheme('borderSpacing')
const borderWidth = fromTheme('borderWidth')
const contrast = fromTheme('contrast')
const grayscale = fromTheme('grayscale')
Expand Down Expand Up @@ -74,6 +75,7 @@ export function getDefaultConfig() {
'saturation',
'color',
'luminosity',
'plus-lighter',
] as const
const getAlign = () => ['start', 'end', 'center', 'between', 'around', 'evenly'] as const
const getZeroAndEmpty = () => ['', '0', isArbitraryValue] as const
Expand All @@ -89,6 +91,7 @@ export function getDefaultConfig() {
brightness: [isInteger],
borderColor: [colors],
borderRadius: ['none', '', 'full', isTshirtSize, isArbitraryLength],
borderSpacing: [spacing],
borderWidth: getLengthWithEmpty(),
contrast: [isInteger],
grayscale: getZeroAndEmpty(),
Expand Down Expand Up @@ -361,7 +364,7 @@ export function getDefaultConfig() {
* Grid Auto Flow
* @see https://tailwindcss.com/docs/grid-auto-flow
*/
'grid-flow': [{ 'grid-flow': ['row', 'col', 'row-dense', 'col-dense'] }],
'grid-flow': [{ 'grid-flow': ['row', 'col', 'dense', 'row-dense', 'col-dense'] }],
/**
* Grid Auto Columns
* @see https://tailwindcss.com/docs/grid-auto-columns
Expand Down Expand Up @@ -689,7 +692,7 @@ export function getDefaultConfig() {
* Text Alignment
* @see https://tailwindcss.com/docs/text-align
*/
'text-alignment': [{ text: ['left', 'center', 'right', 'justify'] }],
'text-alignment': [{ text: ['left', 'center', 'right', 'justify', 'start', 'end'] }],
/**
* Text Color
* @see https://tailwindcss.com/docs/text-color
Expand Down Expand Up @@ -1190,6 +1193,21 @@ export function getDefaultConfig() {
* @see https://tailwindcss.com/docs/border-collapse
*/
'border-collapse': [{ border: ['collapse', 'separate'] }],
/**
* Border Spacing
* @see https://tailwindcss.com/docs/border-spacing
*/
'border-spacing': [{ 'border-spacing': [borderSpacing] }],
/**
* Border Spacing X
* @see https://tailwindcss.com/docs/border-spacing
*/
'border-spacing-x': [{ 'border-spacing-x': [borderSpacing] }],
/**
* Border Spacing Y
* @see https://tailwindcss.com/docs/border-spacing
*/
'border-spacing-y': [{ 'border-spacing-y': [borderSpacing] }],
/**
* Table Layout
* @see https://tailwindcss.com/docs/table-layout
Expand Down Expand Up @@ -1561,6 +1579,7 @@ export function getDefaultConfig() {
'rounded-r': ['rounded-tr', 'rounded-br'],
'rounded-b': ['rounded-br', 'rounded-bl'],
'rounded-l': ['rounded-tl', 'rounded-bl'],
'border-spacing': ['border-spacing-x', 'border-spacing-y'],
'border-w': ['border-w-t', 'border-w-r', 'border-w-b', 'border-w-l'],
'border-w-x': ['border-w-r', 'border-w-l'],
'border-w-y': ['border-w-t', 'border-w-b'],
Expand Down
98 changes: 74 additions & 24 deletions src/lib/merge-classlist.ts
Expand Up @@ -2,10 +2,6 @@ import { ConfigUtils } from './config-utils'

const SPLIT_CLASSES_REGEX = /\s+/
const IMPORTANT_MODIFIER = '!'
// Regex is needed, so we don't match against colons in labels for arbitrary values like `text-[color:var(--mystery-var)]`
// 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
const MODIFIER_SEPARATOR_REGEX = /:(?![^[]*\])/
const MODIFIER_SEPARATOR = ':'

export function mergeClassList(classList: string, configUtils: ConfigUtils) {
const { getClassGroupId, getConflictingClassGroupIds } = configUtils
Expand All @@ -15,7 +11,7 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
* `{importantModifier}{variantModifiers}{classGroupId}`
* @example 'float'
* @example 'hover:focus:bg-color'
* @example '!md:pr'
* @example 'md:!pr'
*/
const classGroupsInConflict = new Set<string>()

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

const hasImportantModifier =
classNameWithImportantModifier.startsWith(IMPORTANT_MODIFIER)
const className = hasImportantModifier
? classNameWithImportantModifier.substring(1)
: classNameWithImportantModifier

const classGroupId = getClassGroupId(className)
const classGroupId = getClassGroupId(baseClassName)

if (!classGroupId) {
return {
Expand All @@ -42,18 +32,15 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
}
}

const variantModifier =
modifiers.length === 0
? ''
: modifiers.sort().concat('').join(MODIFIER_SEPARATOR)
const variantModifier = sortModifiers(modifiers).join('')

const fullModifier = hasImportantModifier
? IMPORTANT_MODIFIER + variantModifier
const modifierId = hasImportantModifier
? variantModifier + IMPORTANT_MODIFIER
: variantModifier

return {
isTailwindClass: true as const,
modifier: fullModifier,
modifierId,
classGroupId,
originalClassName,
}
Expand All @@ -65,9 +52,9 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
return true
}

const { modifier, classGroupId } = parsed
const { modifierId, classGroupId } = parsed

const classId = `${modifier}:${classGroupId}`
const classId = `${modifierId}${classGroupId}`

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

getConflictingClassGroupIds(classGroupId).forEach((group) =>
classGroupsInConflict.add(`${modifier}:${group}`)
classGroupsInConflict.add(`${modifierId}${group}`)
)

return true
Expand All @@ -86,3 +73,66 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
.join(' ')
)
}

function splitModifiers(className: string) {
const modifiers = []

let bracketDepth = 0
let modifierStart = 0

for (const match of className.matchAll(/[:[\]]/g)) {
if (match[0] === ':') {
if (bracketDepth === 0) {
const nextModifierStart = match.index! + 1
modifiers.push(className.substring(modifierStart, nextModifierStart))
modifierStart = nextModifierStart
}
} else if (match[0] === '[') {
bracketDepth++
} else if (match[0] === ']') {
bracketDepth--
}
}

const baseClassNameWithImportantModifier =
modifiers.length === 0 ? className : className.substring(modifierStart)
const hasImportantModifier = baseClassNameWithImportantModifier.startsWith(IMPORTANT_MODIFIER)
const baseClassName = hasImportantModifier
? baseClassNameWithImportantModifier.substring(1)
: baseClassNameWithImportantModifier

return {
modifiers,
hasImportantModifier,
baseClassName,
}
}

/**
* Sorts modifiers according to following schema:
* - Predefined modifiers are sorted alphabetically
* - When an arbitrary variant appears, it's important to preserve which modifiers are before and after it
*/
function sortModifiers(modifiers: string[]) {
if (modifiers.length <= 1) {
return modifiers
}

const sortedModifiers = []
let unsortedModifiers: string[] = []

modifiers.forEach((modifier) => {
const isArbitraryVariant = modifier[0] === '['

if (isArbitraryVariant) {
sortedModifiers.push(...unsortedModifiers.sort(), modifier)
unsortedModifiers = []
} else {
unsortedModifiers.push(modifier)
}
})

sortedModifiers.push(...unsortedModifiers.sort())

return sortedModifiers
}
83 changes: 83 additions & 0 deletions tests/arbitrary-variants.test.ts
@@ -0,0 +1,83 @@
import { twMerge } from '../src'

test('basic arbitrary variants', () => {
expect(twMerge('[&>*]:underline [&>*]:line-through')).toBe('[&>*]:line-through')
expect(twMerge('[&>*]:underline [&>*]:line-through [&_div]:line-through')).toBe(
'[&>*]:line-through [&_div]:line-through'
)
})

test('arbitrary variants with modifiers', () => {
expect(twMerge('dark:lg:hover:[&>*]:underline dark:lg:hover:[&>*]:line-through')).toBe(
'dark:lg:hover:[&>*]:line-through'
)
expect(twMerge('dark:lg:hover:[&>*]:underline dark:hover:lg:[&>*]:line-through')).toBe(
'dark:hover:lg:[&>*]:line-through'
)
// Whether a modifier is before or after arbitrary variant matters
expect(twMerge('hover:[&>*]:underline [&>*]:hover:line-through')).toBe(
'hover:[&>*]:underline [&>*]:hover:line-through'
)
expect(
twMerge(
'hover:dark:[&>*]:underline dark:hover:[&>*]:underline dark:[&>*]:hover:line-through'
)
).toBe('dark:hover:[&>*]:underline dark:[&>*]:hover:line-through')
})

test('arbitrary variants with complex syntax in them', () => {
expect(
twMerge(
'[@media_screen{@media(hover:hover)}]:underline [@media_screen{@media(hover:hover)}]:line-through'
)
).toBe('[@media_screen{@media(hover:hover)}]:line-through')
expect(
twMerge(
'hover:[@media_screen{@media(hover:hover)}]:underline hover:[@media_screen{@media(hover:hover)}]:line-through'
)
).toBe('hover:[@media_screen{@media(hover:hover)}]:line-through')
})

test('arbitrary variants with attribute selectors', () => {
expect(twMerge('[&[data-open]]:underline [&[data-open]]:line-through')).toBe(
'[&[data-open]]:line-through'
)
})

test('arbitrary variants with multiple attribute selectors', () => {
expect(
twMerge(
'[&[data-foo][data-bar]:not([data-baz])]:underline [&[data-foo][data-bar]:not([data-baz])]:line-through'
)
).toBe('[&[data-foo][data-bar]:not([data-baz])]:line-through')
})

test('multiple arbitrary variants', () => {
expect(twMerge('[&>*]:[&_div]:underline [&>*]:[&_div]:line-through')).toBe(
'[&>*]:[&_div]:line-through'
)
expect(twMerge('[&>*]:[&_div]:underline [&_div]:[&>*]:line-through')).toBe(
'[&>*]:[&_div]:underline [&_div]:[&>*]:line-through'
)
expect(
twMerge(
'hover:dark:[&>*]:focus:disabled:[&_div]:underline dark:hover:[&>*]:disabled:focus:[&_div]:line-through'
)
).toBe('dark:hover:[&>*]:disabled:focus:[&_div]:line-through')
expect(
twMerge(
'hover:dark:[&>*]:focus:[&_div]:disabled:underline dark:hover:[&>*]:disabled:focus:[&_div]:line-through'
)
).toBe(
'hover:dark:[&>*]:focus:[&_div]:disabled:underline dark:hover:[&>*]:disabled:focus:[&_div]:line-through'
)
})

test('arbitrary variants with arbitrary properties', () => {
expect(twMerge('[&>*]:[color:red] [&>*]:[color:blue]')).toBe('[&>*]:[color:blue]')
expect(
twMerge(
'[&[data-foo][data-bar]:not([data-baz])]:nod:noa:[color:red] [&[data-foo][data-bar]:not([data-baz])]:noa:nod:[color:blue]'
)
).toBe('[&[data-foo][data-bar]:not([data-baz])]:noa:nod:[color:blue]')
})
3 changes: 3 additions & 0 deletions tests/class-map.test.ts
Expand Up @@ -61,6 +61,9 @@ test('class map has correct class groups at first part', () => {
'border-color-x',
'border-color-y',
'border-opacity',
'border-spacing',
'border-spacing-x',
'border-spacing-y',
'border-style',
'border-w',
'border-w-b',
Expand Down
4 changes: 2 additions & 2 deletions tests/readme-examples.test.ts
Expand Up @@ -3,10 +3,10 @@ import fs from 'fs'
import { twMerge } from '../src'

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

test('readme examples', () => {
expect.assertions(21)
expect.assertions(24)

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