Skip to content

Commit

Permalink
Merge pull request #103 from dcastil/feature/101/add-support-for-tail…
Browse files Browse the repository at this point in the history
…wind-v3.1

Add support for Tailwind v3.1
  • Loading branch information
dcastil committed Jun 12, 2022
2 parents 5bcc14b + 7bf25cf commit f543405
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 29 deletions.
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

0 comments on commit f543405

Please sign in to comment.