diff --git a/README.md b/README.md index 2d6831f3..d73294b8 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/src/lib/default-config.ts b/src/lib/default-config.ts index df98ea3e..d02a218b 100644 --- a/src/lib/default-config.ts +++ b/src/lib/default-config.ts @@ -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') @@ -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 @@ -89,6 +91,7 @@ export function getDefaultConfig() { brightness: [isInteger], borderColor: [colors], borderRadius: ['none', '', 'full', isTshirtSize, isArbitraryLength], + borderSpacing: [spacing], borderWidth: getLengthWithEmpty(), contrast: [isInteger], grayscale: getZeroAndEmpty(), @@ -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 @@ -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 @@ -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 @@ -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'], diff --git a/src/lib/merge-classlist.ts b/src/lib/merge-classlist.ts index 48c54b26..665f642e 100644 --- a/src/lib/merge-classlist.ts +++ b/src/lib/merge-classlist.ts @@ -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 @@ -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() @@ -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 { @@ -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, } @@ -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 @@ -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 @@ -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 +} diff --git a/tests/arbitrary-variants.test.ts b/tests/arbitrary-variants.test.ts new file mode 100644 index 00000000..7743825b --- /dev/null +++ b/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]') +}) diff --git a/tests/class-map.test.ts b/tests/class-map.test.ts index 085115bf..340349e5 100644 --- a/tests/class-map.test.ts +++ b/tests/class-map.test.ts @@ -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', diff --git a/tests/readme-examples.test.ts b/tests/readme-examples.test.ts index 8f739060..65d942a2 100644 --- a/tests/readme-examples.test.ts +++ b/tests/readme-examples.test.ts @@ -3,10 +3,10 @@ import fs from 'fs' import { twMerge } from '../src' const twMergeExampleRegex = - /twMerge\((?[\w\s\-:[\]#(),!\n'"]+?)\)(?!.*(?.+)['"]/g + /twMerge\((?[\w\s\-:[\]#(),!&\n'"]+?)\)(?!.*(?.+)['"]/g test('readme examples', () => { - expect.assertions(21) + expect.assertions(24) return fs.promises .readFile(`${__dirname}/../README.md`, { encoding: 'utf-8' })