diff --git a/README.md b/README.md index ca208e0c..5601ff8f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/api-reference.md b/docs/api-reference.md index 866a6f03..979bbe9c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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'], + }, }) ``` @@ -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'], + }, } }) ``` diff --git a/docs/configuration.md b/docs/configuration.md index 914694a6..5016bdf1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 + }, + } } ``` @@ -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. @@ -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: @@ -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'], + }, }) ``` @@ -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'], + }, })) ``` diff --git a/docs/features.md b/docs/features.md index d41be836..a2a5067e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 @@ -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) diff --git a/src/lib/class-utils.ts b/src/lib/class-utils.ts index 52cf3891..3b4c3de3 100644 --- a/src/lib/class-utils.ts +++ b/src/lib/class-utils.ts @@ -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) @@ -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 { diff --git a/src/lib/default-config.ts b/src/lib/default-config.ts index 53f9161b..2a4152f6 100644 --- a/src/lib/default-config.ts +++ b/src/lib/default-config.ts @@ -1,4 +1,5 @@ import { fromTheme } from './from-theme' +import { Config } from './types' import { isAny, isArbitraryLength, @@ -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 } diff --git a/src/lib/merge-classlist.ts b/src/lib/merge-classlist.ts index 085750e2..75a8d75c 100644 --- a/src/lib/merge-classlist.ts +++ b/src/lib/merge-classlist.ts @@ -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(':') @@ -43,6 +66,7 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) { modifierId, classGroupId, originalClassName, + hasPostfixModifier, } }) .reverse() @@ -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 @@ -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), ) diff --git a/src/lib/modifier-utils.ts b/src/lib/modifier-utils.ts index 3139f360..dc7f62b6 100644 --- a/src/lib/modifier-utils.ts +++ b/src/lib/modifier-utils.ts @@ -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-- } } @@ -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, } } } diff --git a/src/lib/types.ts b/src/lib/types.ts index ccf373e7..7b3d4e72 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 + /** + * 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 } export type ThemeObject = Record diff --git a/tests/modifiers.test.ts b/tests/modifiers.test.ts index 1d819194..6088f2c5 100644 --- a/tests/modifiers.test.ts +++ b/tests/modifiers.test.ts @@ -1,6 +1,6 @@ -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( @@ -8,3 +8,31 @@ test('conflicts across modifiers', () => { ) 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') +}) diff --git a/tests/tailwind-css-versions.test.ts b/tests/tailwind-css-versions.test.ts index e8323661..c4e7c697 100644 --- a/tests/tailwind-css-versions.test.ts +++ b/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',