Skip to content

Commit

Permalink
Merge pull request #214 from dcastil/feature/211/add-support-for-post…
Browse files Browse the repository at this point in the history
…fix-modifier

Add support for postfix modifier
  • Loading branch information
dcastil committed Apr 2, 2023
2 parents 6a4efc9 + 65f784b commit d17ec3a
Show file tree
Hide file tree
Showing 11 changed files with 163 additions and 28 deletions.
2 changes: 1 addition & 1 deletion README.md
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
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
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
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
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
@@ -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
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
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
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
@@ -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
@@ -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

0 comments on commit d17ec3a

Please sign in to comment.