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 postfix modifier #214

Merged
merged 12 commits into from
Apr 2, 2023
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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 up to v3.3 (except line-height shorthand, tracked in [#211](https://github.com/dcastil/tailwind-merge/issues/211); 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.3 (if you use Tailwind v2, use [tailwind-merge v0.9.0](https://github.com/dcastil/tailwind-merge/tree/v0.9.0))
- Works in all modern browsers and Node >=12
- Fully typed
- [Check bundle size on Bundlephobia](https://bundlephobia.com/package/tailwind-merge)
Expand Down
20 changes: 17 additions & 3 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,24 @@ const customTwMerge = extendTailwindMerge({
// Classes here: foo, foo-2, bar-baz, bar-baz-1, bar-baz-2
foo: ['foo', 'foo-2', { 'bar-baz': ['', '1', '2'] }],
// ↓ Functions can also be used to match classes.
// Classes here: qux-auto, qux-1000, qux-1001,
// Classes here: qux-auto, qux-1000, qux-1001,…
bar: [{ qux: ['auto', (value) => Number(value) >= 1000] }],
baz: ['baz-sm', 'baz-md', 'baz-lg'],
},
// ↓ Here you can define additional conflicts across different groups
conflictingClassGroups: {
// ↓ ID of class group which creates a conflict with …
// ↓ … classes from groups with these IDs
// ↓ ID of class group which creates a conflict with…
// ↓ …classes from groups with these IDs
// In this case `twMerge('qux-auto foo') → 'foo'`
foo: ['bar'],
},
// ↓ Here you can define conflicts between the postfix modifier of a group and a different class group.
conflictingClassGroupModifiers: {
// ↓ ID of class group whose postfix modifier creates a conflict with…
// ↓ …classes from groups with these IDs
// In this case `twMerge('qux-auto baz-sm/1000') → 'baz-sm/1000'`
baz: ['bar'],
},
})
```

Expand Down Expand Up @@ -148,11 +157,16 @@ const customTwMerge = createTailwindMerge(() => {
...defaultConfig.classGroups,
foo: ['foo', 'foo-2', { 'bar-baz': ['', '1', '2'] }],
bar: [{ qux: ['auto', (value) => Number(value) >= 1000] }],
baz: ['baz-sm', 'baz-md', 'baz-lg'],
},
conflictingClassGroups: {
...defaultConfig.conflictingClassGroups,
foo: ['bar'],
},
conflictingClassGroupModifiers: {
...defaultConfig.conflictingClassGroupModifiers,
baz: ['bar'],
},
}
})
```
Expand Down
29 changes: 27 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const tailwindMergeConfig = {
conflictingClassGroups: {
// Conflicts between class groups are defined here
},
conflictingClassGroupModifiers: {
// Conflicts between postfox modifier of a class group and another class group are defined here
},
}
}
```

Expand Down Expand Up @@ -92,9 +96,9 @@ Sometimes there are conflicts across Tailwind classes which are more complex tha

One example is the combination of the classes `px-3` (setting `padding-left` and `padding-right`) and `pr-4` (setting `padding-right`).

If they are passed to `twMerge` as `pr-4 px-3`, I think you most likely intend to apply `padding-left` and `padding-right` from the `px-3` class and want `pr-4` to be removed, indicating that both these classes should belong to a single class group.
If they are passed to `twMerge` as `pr-4 px-3`, you most likely intend to apply `padding-left` and `padding-right` from the `px-3` class and want `pr-4` to be removed, indicating that both these classes should belong to a single class group.

But if they are passed to `twMerge` as `px-3 pr-4`, I assume you want to set the `padding-right` from `pr-4` but still want to apply the `padding-left` from `px-3`, so `px-3` shouldn't be removed when inserting the classes in this order, indicating they shouldn't be in the same class group.
But if they are passed to `twMerge` as `px-3 pr-4`, you want to set the `padding-right` from `pr-4` but still want to apply the `padding-left` from `px-3`, so `px-3` shouldn't be removed when inserting the classes in this order, indicating they shouldn't be in the same class group.

To summarize, `px-3` should stand in conflict with `pr-4`, but `pr-4` should not stand in conflict with `px-3`. To achieve this, we need to define asymmetric conflicts across class groups.

Expand All @@ -110,6 +114,18 @@ If a class group _creates_ a conflict, it means that if it appears in a class li

When we think of our example, the `px` class group creates a conflict which is received by the class groups `pr` and `pl`. This way `px-3` removes a preceding `pr-4`, but not the other way around.

### Postfix modifiers conflicting with class groups

Tailwind CSS allows postfix modifiers for some classes. E.g. you can set font-size and line-height together with `text-lg/7` with `/7` being the postfix modifier. This means that any line-height classes preceding a font-size class with a modifier should be removed.

For this tailwind-merge has the `conflictingClassGroupModifiers` object in its config with the same shape as `conflictingClassGroups` explained in the [section above](#conflicting-class-groups). This time the key is the ID of a class group whose modifier _creates_ a conflict and the value is an array of IDs of class groups which _receive_ the conflict.

```ts
const conflictingClassGroupModifiers = {
'font-size': ['leading'],
}
```

### Theme

In the Tailwind config you can modify theme scales. tailwind-merge follows the same keys for the theme scales, but doesn't support all of them. tailwind-merge only supports theme scales which are used in multiple class groups to save bundle size (more info to that in [PR 55](https://github.com/dcastil/tailwind-merge/pull/55)). At the moment these are:
Expand Down Expand Up @@ -158,11 +174,16 @@ const customTwMerge = extendTailwindMerge({
classGroups: {
foo: ['foo', 'foo-2', { 'bar-baz': ['', '1', '2'] }],
bar: [{ qux: ['auto', (value) => Number(value) >= 1000] }],
baz: ['baz-sm', 'baz-md', 'baz-lg'],
},
// ↓ Here you can define additional conflicts across class groups
conflictingClassGroups: {
foo: ['bar'],
},
// ↓ Define conflicts between postfix modifiers and class groups
conflictingClassGroupModifiers: {
baz: ['bar'],
},
})
```

Expand All @@ -181,10 +202,14 @@ const customTwMerge = createTailwindMerge(() => ({
classGroups: {
foo: ['foo', 'foo-2', { 'bar-baz': ['', '1', '2'] }],
bar: [{ qux: ['auto', (value) => Number(value) >= 1000] }],
baz: ['baz-sm', 'baz-md', 'baz-lg'],
},
conflictingClassGroups: {
foo: ['bar'],
},
conflictingClassGroupModifiers: {
baz: ['bar'],
},
}))
```

Expand Down
8 changes: 8 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ twMerge('!p-3 !p-4 p-5') // → '!p-4 p-5'
twMerge('!right-2 !-inset-x-1') // → '!-inset-x-1'
```

## Supports postfix modifiers

```ts
twMerge('text-sm leading-6 text-lg/7') // → 'text-lg/7'
```

## Preserves non-Tailwind classes

```ts
Expand All @@ -100,6 +106,8 @@ twMerge('some-class', [undefined, ['another-class', false]], ['third-class'])
// → 'some-class another-class third-class'
```

Why no object support? [Read here](https://github.com/dcastil/tailwind-merge/discussions/137#discussioncomment-3481605).

---

Next: [Configuration](./configuration.md)
Expand Down
11 changes: 9 additions & 2 deletions src/lib/class-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const CLASS_PART_SEPARATOR = '-'

export function createClassUtils(config: Config) {
const classMap = createClassMap(config)
const { conflictingClassGroups, conflictingClassGroupModifiers = {} } = config

function getClassGroupId(className: string) {
const classParts = className.split(CLASS_PART_SEPARATOR)
Expand All @@ -27,8 +28,14 @@ export function createClassUtils(config: Config) {
return getGroupRecursive(classParts, classMap) || getGroupIdForArbitraryProperty(className)
}

function getConflictingClassGroupIds(classGroupId: ClassGroupId) {
return config.conflictingClassGroups[classGroupId] || []
function getConflictingClassGroupIds(classGroupId: ClassGroupId, hasPostfixModifier: boolean) {
const conflicts = conflictingClassGroups[classGroupId] || []

if (hasPostfixModifier && conflictingClassGroupModifiers[classGroupId]) {
return [...conflicts, ...conflictingClassGroupModifiers[classGroupId]!]
}

return conflicts
}

return {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/default-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fromTheme } from './from-theme'
import { Config } from './types'
import {
isAny,
isArbitraryLength,
Expand Down Expand Up @@ -1763,5 +1764,8 @@ export function getDefaultConfig() {
'scroll-px': ['scroll-pr', 'scroll-pl'],
'scroll-py': ['scroll-pt', 'scroll-pb'],
},
} as const
conflictingClassGroupModifiers: {
'font-size': ['leading'],
},
} as const satisfies Config
}
40 changes: 32 additions & 8 deletions src/lib/merge-classlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,39 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
.trim()
.split(SPLIT_CLASSES_REGEX)
.map((originalClassName) => {
const { modifiers, hasImportantModifier, baseClassName } =
splitModifiers(originalClassName)
const {
modifiers,
hasImportantModifier,
baseClassName,
maybePostfixModifierPosition,
} = splitModifiers(originalClassName)

const classGroupId = getClassGroupId(baseClassName)
let classGroupId = getClassGroupId(
maybePostfixModifierPosition
? baseClassName.substring(0, maybePostfixModifierPosition)
: baseClassName,
)

let hasPostfixModifier = Boolean(maybePostfixModifierPosition)

if (!classGroupId) {
return {
isTailwindClass: false as const,
originalClassName,
if (!maybePostfixModifierPosition) {
return {
isTailwindClass: false as const,
originalClassName,
}
}

classGroupId = getClassGroupId(baseClassName)

if (!classGroupId) {
return {
isTailwindClass: false as const,
originalClassName,
}
}

hasPostfixModifier = false
}

const variantModifier = sortModifiers(modifiers).join(':')
Expand All @@ -43,6 +66,7 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
modifierId,
classGroupId,
originalClassName,
hasPostfixModifier,
}
})
.reverse()
Expand All @@ -52,7 +76,7 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
return true
}

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

const classId = modifierId + classGroupId

Expand All @@ -62,7 +86,7 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {

classGroupsInConflict.add(classId)

getConflictingClassGroupIds(classGroupId).forEach((group) =>
getConflictingClassGroupIds(classGroupId, hasPostfixModifier).forEach((group) =>
classGroupsInConflict.add(modifierId + group),
)

Expand Down
34 changes: 26 additions & 8 deletions src/lib/modifier-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,41 @@ export const IMPORTANT_MODIFIER = '!'

export function createSplitModifiers(config: Config) {
const separator = config.separator || ':'
const isSeparatorSingleCharacter = separator.length === 1
const firstSeparatorCharacter = separator[0]
const separatorLength = separator.length

// splitModifiers inspired by https://github.com/tailwindlabs/tailwindcss/blob/v3.2.2/src/util/splitAtTopLevelOnly.js
return function splitModifiers(className: string) {
const modifiers = []

let bracketDepth = 0
let modifiers = []
let modifierStart = 0
let postfixModifierPosition: number | undefined

for (let index = 0; index < className.length; index++) {
let char = className[index]
let currentCharacter = className[index]

if (bracketDepth === 0 && char === separator[0]) {
if (bracketDepth === 0) {
if (
separator.length === 1 ||
className.slice(index, index + separator.length) === separator
currentCharacter === firstSeparatorCharacter &&
(isSeparatorSingleCharacter ||
className.slice(index, index + separatorLength) === separator)
) {
modifiers.push(className.slice(modifierStart, index))
modifierStart = index + separator.length
modifierStart = index + separatorLength
continue
}

if (currentCharacter === '/') {
postfixModifierPosition = index
continue
}
}

if (char === '[') {
if (currentCharacter === '[') {
bracketDepth++
} else if (char === ']') {
} else if (currentCharacter === ']') {
bracketDepth--
}
}
Expand All @@ -39,10 +51,16 @@ export function createSplitModifiers(config: Config) {
? baseClassNameWithImportantModifier.substring(1)
: baseClassNameWithImportantModifier

const maybePostfixModifierPosition =
postfixModifierPosition && postfixModifierPosition > modifierStart
? postfixModifierPosition - modifierStart
: undefined

return {
modifiers,
hasImportantModifier,
baseClassName,
maybePostfixModifierPosition,
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,16 @@ export interface Config {
/**
* Conflicting classes across groups.
* The key is ID of class group which creates conflict, values are IDs of class groups which receive a conflict.
* A class group is ID is the key of a class group in classGroups object.
* A class group ID is the key of a class group in classGroups object.
* @example { gap: ['gap-x', 'gap-y'] }
*/
conflictingClassGroups: Record<ClassGroupId, readonly ClassGroupId[]>
/**
* Postfix modifiers conflicting with other class groups.
* A class group ID is the key of a class group in classGroups object.
* @example { 'font-size': ['leading'] }
*/
conflictingClassGroupModifiers?: Record<ClassGroupId, readonly ClassGroupId[]>
}

export type ThemeObject = Record<string, ClassGroup>
Expand Down
32 changes: 30 additions & 2 deletions tests/modifiers.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import { twMerge } from '../src'
import { createTailwindMerge, twMerge } from '../src'

test('conflicts across modifiers', () => {
test('conflicts across prefix modifiers', () => {
expect(twMerge('hover:block hover:inline')).toBe('hover:inline')
expect(twMerge('hover:block hover:focus:inline')).toBe('hover:block hover:focus:inline')
expect(twMerge('hover:block hover:focus:inline focus:hover:inline')).toBe(
'hover:block focus:hover:inline',
)
expect(twMerge('focus-within:inline focus-within:block')).toBe('focus-within:block')
})

test('conflicts across postfix modifiers', () => {
expect(twMerge('text-lg/7 text-lg/8')).toBe('text-lg/8')
expect(twMerge('text-lg/none leading-9')).toBe('text-lg/none leading-9')
expect(twMerge('leading-9 text-lg/none')).toBe('text-lg/none')
expect(twMerge('w-full w-1/2')).toBe('w-1/2')

const customTwMerge = createTailwindMerge(() => ({
cacheSize: 10,
theme: {},
classGroups: {
foo: ['foo-1/2', 'foo-2/3'],
bar: ['bar-1', 'bar-2'],
baz: ['baz-1', 'baz-2'],
},
conflictingClassGroups: {},
conflictingClassGroupModifiers: {
baz: ['bar'],
},
}))

expect(customTwMerge('foo-1/2 foo-2/3')).toBe('foo-2/3')
expect(customTwMerge('bar-1 bar-2')).toBe('bar-2')
expect(customTwMerge('bar-1 baz-1')).toBe('bar-1 baz-1')
expect(customTwMerge('bar-1/2 bar-2')).toBe('bar-2')
expect(customTwMerge('bar-2 bar-1/2')).toBe('bar-1/2')
expect(customTwMerge('bar-1 baz-1/2')).toBe('baz-1/2')
})
1 change: 1 addition & 0 deletions tests/tailwind-css-versions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { twMerge } from '../src'

test('supports Tailwind CSS v3.3 features', () => {
expect(twMerge('text-red text-lg/7 text-lg/8')).toBe('text-red text-lg/8')
expect(
twMerge(
'start-0 start-1',
Expand Down