diff --git a/README.md b/README.md index 68fa9c1..bfcbe03 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,11 @@ 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 v2.0 up to v2.2, support for newer versions will be added continuously +- 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)) - Works in Node >=12 and all modern browsers - Fully typed - [5.2 kB minified + gzipped](https://bundlephobia.com/package/tailwind-merge) (96.7% self, 3.3% hashlru) -## Early development - -This library is in an early pre-v1 development stage and might have some bugs and inconveniences here and there. I use the library in production and intend it to be sufficient for production use, as long as you're fine with some potential breaking changes in minor releases until v1 (lock the version range to patch releases with `~` in your `package.json` to prevent accidental breaking changes). - -I want to keep the library on v0 until I feel confident enough that there aren't any major bugs or flaws in its API and implementation. If you find a bug or something you don't like, please [submit an issue](https://github.com/dcastil/tailwind-merge/issues/new/choose) or a pull request. I'm happy about any kind of feedback! - ## What is it for If you use Tailwind with a component-based UI renderer like [React](https://reactjs.org) or [Vue](https://vuejs.org), you're probably familiar with the situation that you want to change some styles of a component, but only in one place. @@ -100,13 +94,26 @@ twMerge('hover:p-2 hover:p-4') // → 'hover:p-4' twMerge('hover:focus:p-2 focus:hover:p-4') // → 'focus:hover:p-4' ``` -### Supports custom values +### Supports arbitrary values ```ts twMerge('bg-black bg-[color:var(--mystery-var)]') // → 'bg-[color:var(--mystery-var)]' twMerge('grid-cols-[1fr,auto] grid-cols-2') // → 'grid-cols-2' ``` +### Supports arbitrary properties + +```ts +twMerge('[mask-type:luminance] [mask-type:alpha]') // → '[mask-type:alpha]' +twMerge('[--scroll-offset:56px] lg:[--scroll-offset:44px]') +// → '[--scroll-offset:56px] lg:[--scroll-offset:44px]' + +// Don't do this! +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 important modifier ```ts @@ -193,13 +200,13 @@ E.g. here is the overflow class group which results in the classes `overflow-aut const overflowClassGroup = [{ overflow: ['auto', 'hidden', 'visible', 'scroll'] }] ``` -Sometimes it isn't possible to enumerate all elements in a class group. Think of a Tailwind class which allows custom values. In this scenario you can use a validator function which takes a _class part_ and returns a boolean indicating whether a class is part of a class group. +Sometimes it isn't possible to enumerate all elements in a class group. Think of a Tailwind class which allows arbitrary values. In this scenario you can use a validator function which takes a _class part_ and returns a boolean indicating whether a class is part of a class group. E.g. here is the fill class group. ```ts -const isCustomValue = (classPart: string) => /^\[.+\]$/.test(classPart) -const fillClassGroup = [{ fill: ['current', isCustomValue] }] +const isArbitraryValue = (classPart: string) => /^\[.+\]$/.test(classPart) +const fillClassGroup = [{ fill: ['current', isArbitraryValue] }] ``` Because the function is under the `fill` key, it will only get called for classes which start with `fill-`. Also, the function only gets passed the part of the class name which comes after `fill-`, this way you can use the same function in multiple class groups. tailwind-merge exports its own [validators](#validators), so you don't need to recreate them. @@ -506,9 +513,14 @@ const customTwMerge = createTailwindMerge(getDefaultConfig, (config) => ```ts interface Validators { isLength(classPart: string): boolean - isCustomLength(classPart: string): boolean + isArbitraryLength(classPart: string): boolean isInteger(classPart: string): boolean - isCustomValue(classPart: string): boolean + isArbitraryValue(classPart: string): boolean + isTshirtSize(classPart: string): boolean + isArbitrarySize(classPart: string): boolean + isArbitraryPosition(classPart: string): boolean + isArbitraryUrl(classPart: string): boolean + isArbitraryWeight(classPart: string): boolean isAny(classPart: string): boolean } ``` @@ -521,10 +533,15 @@ const paddingClassGroup = [{ p: [validators.isLength] }] A brief summary for each validator: -- `isLength` checks whether a class part is a number (`3`, `1.5`), a fraction (`3/4`), a custom length (`[3%]`, `[4px]`, `[length:var(--my-var)]`), or one of the strings `px`, `full` or `screen`. -- `isCustomLength` checks for custom length values (`[3%]`, `[4px]`, `[length:var(--my-var)]`). -- `isInteger` checks for integer values (`3`) and custom integer values (`[3]`). -- `isCustomValue` checks whether the class part is enclosed in brackets (`[something]`) +- `isLength` checks whether a class part is a number (`3`, `1.5`), a fraction (`3/4`), a arbitrary length (`[3%]`, `[4px]`, `[length:var(--my-var)]`), or one of the strings `px`, `full` or `screen`. +- `isArbitraryLength` checks for arbitrary length values (`[3%]`, `[4px]`, `[length:var(--my-var)]`). +- `isInteger` checks for integer values (`3`) and arbitrary integer values (`[3]`). +- `isArbitraryValue` checks whether the class part is enclosed in brackets (`[something]`) +- `isTshirtSize`checks whether class part is a T-shirt size (`sm`, `xl`), optionally with a preceding number (`2xl`). +- `isArbitrarySize` checks whether class part is arbitrary value which starts with with `size:` (`[size:200px_100px]`) which is necessary for background-size classNames. +- `isArbitraryPosition` checks whether class part is arbitrary value which starts with with `position:` (`[position:200px_100px]`) which is necessary for background-position classNames. +- `isArbitraryUrl` checks whether class part is arbitrary value which starts with `url:` or `url(` (`[url('/path-to-image.png')]`, `url:var(--maybe-a-url-at-runtime)]`) which is necessary for background-image classNames. +- `isArbitraryWeight` checks whether class part is arbitrary value whcih starts with `weight:` or is a number (`[weight:var(--value)]`, `[450]`) which is necessary for font-weight classNames. - `isAny` always returns true. Be careful with this validator as it might match unwanted classes. I use it primarily to match colors or when I'm ceertain there are no other class groups in a namespace. ### `Config` diff --git a/src/class-utils.ts b/src/class-utils.ts index 0b46e25..f6b6038 100644 --- a/src/class-utils.ts +++ b/src/class-utils.ts @@ -24,7 +24,7 @@ export function createClassUtils(config: Config) { classParts.shift() } - return getGroupRecursive(classParts, classMap) + return getGroupRecursive(classParts, classMap) || getGroupIdForArbitraryProperty(className) } function getConflictingClassGroupIds(classGroupId: ClassGroupId) { @@ -64,6 +64,23 @@ function getGroupRecursive( return classPartObject.validators.find(({ validator }) => validator(classRest))?.classGroupId } +const arbitraryPropertyRegex = /^\[(.+)\]$/ + +function getGroupIdForArbitraryProperty(className: string) { + if (arbitraryPropertyRegex.test(className)) { + const arbitraryPropertyClassName = arbitraryPropertyRegex.exec(className)![1] + const property = arbitraryPropertyClassName?.substring( + 0, + arbitraryPropertyClassName.indexOf(':') + ) + + if (property) { + // I use two dots here because one dot is used as prefix for class groups in plugins + return 'arbitrary..' + property + } + } +} + /** * Exported for testing only */ diff --git a/src/default-config.ts b/src/default-config.ts index 72a8034..84be1b8 100644 --- a/src/default-config.ts +++ b/src/default-config.ts @@ -1,5 +1,16 @@ import { fromTheme } from './from-theme' -import { isAny, isCustomLength, isCustomValue, isInteger, isLength } from './validators' +import { + isAny, + isArbitraryLength, + isArbitraryPosition, + isArbitrarySize, + isArbitraryUrl, + isArbitraryValue, + isArbitraryWeight, + isInteger, + isLength, + isTshirtSize, +} from './validators' export function getDefaultConfig() { const colors = fromTheme('colors') @@ -26,10 +37,8 @@ export function getDefaultConfig() { const space = fromTheme('space') const translate = fromTheme('translate') - const getSizesSimple = () => ['sm', 'md', 'lg', 'xl', '2xl'] as const - const getSizesExtended = () => ['3xl', '4xl', '5xl', '6xl', '7xl'] as const const getOverscroll = () => ['auto', 'contain', 'none'] as const - const getOverflow = () => ['auto', 'hidden', 'visible', 'scroll'] as const + const getOverflow = () => ['auto', 'hidden', 'clip', 'visible', 'scroll'] as const const getSpacingWithAuto = () => ['auto', spacing] as const const getLengthWithEmpty = () => ['', isLength] as const const getIntegerWithAuto = () => ['auto', isInteger] as const @@ -45,7 +54,7 @@ export function getDefaultConfig() { 'right-top', 'top', ] as const - const getBorderStyles = () => ['solid', 'dashed', 'dotted', 'double', 'none'] as const + const getLineStyles = () => ['solid', 'dashed', 'dotted', 'double', 'none'] as const const getBlendModes = () => [ { @@ -71,16 +80,18 @@ export function getDefaultConfig() { ] as const const getAlign = () => ['start', 'end', 'center', 'between', 'around', 'evenly'] as const const getZeroAndEmpty = () => ['', '0'] as const + const getBreaks = () => + ['auto', 'avoid', 'all', 'avoid-page', 'page', 'left', 'right', 'column'] as const return { cacheSize: 500, theme: { colors: [isAny], spacing: [isLength], - blur: ['none', '', ...getSizesSimple(), '3xl', isCustomLength], + blur: ['none', '', isTshirtSize, isArbitraryLength], brightness: [isInteger], borderColor: [colors], - borderRadius: ['none', '', ...getSizesSimple(), '3xl', 'full', isCustomLength], + borderRadius: ['none', '', 'full', isTshirtSize, isArbitraryLength], borderWidth: getLengthWithEmpty(), contrast: [isInteger], grayscale: getZeroAndEmpty(), @@ -98,163 +109,44 @@ export function getDefaultConfig() { skew: [isInteger], space: [spacing], translate: [spacing], - - // Tailwind theme keys not in use because they would apply only to a single classGroup: - - // animation: ['none', 'spin', 'ping', 'pulse', 'bounce', isCustomValue], - // backdropBlur: [blur], - // backdropBrightness: [brightness], - // backdropContrast: [contrast], - // backdropGrayscale: [grayscale], - // backdropHueRotate: [hueRotate], - // backdropInvert: [invert], - // backdropOpacity: [opacity], - // backdropSaturate: [saturate], - // backdropSepia: [sepia], - // backgroundColor: [colors], - // backgroundImage: [ - // 'none', - // { 'gradient-to': ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] }, - // ], - // backgroundOpacity: [opacity], - // backgroundPosition: getPositions(), - // backgroundSize: ['auto', 'cover', 'contain'], - // borderOpacity: [opacity], - // boxShadow: ['', ...getSizesSimple(), 'inner', 'none'], - // caretColor: [colors], - // container: ['container'], - // content: [isCustomValue], - // cursor: [ - // 'auto', - // 'default', - // 'pointer', - // 'wait', - // 'text', - // 'move', - // 'help', - // 'not-allowed', - // isCustomValue, - // ], - // divideColor: [borderColor], - // divideOpacity: [borderOpacity], - // divideWidth: [borderWidth], - // dropShadow: ['', ...getSizesSimple(), 'none'], - // fill: ['current', isCustomValue], - // flex: ['1', 'auto', 'initial', 'none', isCustomValue], - // flexGrow: getZeroAndEmpty(), - // flexShrink: getZeroAndEmpty(), - // fontFamily: [isAny], - // fontSize: [ - // 'xs', - // ...getSizesSimple(), - // 'base', - // ...getSizesExtended(), - // '8xl', - // '9xl', - // isCustomLength, - // ], - // fontWeight: [ - // 'thin', - // 'extralight', - // 'light', - // 'normal', - // 'medium', - // 'semibold', - // 'bold', - // 'extrabold', - // 'black', - // ], - // gridAutoColumns: ['auto', 'min', 'max', 'fr', isCustomValue], - // gridAutoRows: ['auto', 'min', 'max', 'fr', isCustomValue], - // gridColumn: ['auto', { span: [isInteger] }], - // gridColumnEnd: getIntegerWithAuto(), - // gridColumnStart: getIntegerWithAuto(), - // gridRow: ['auto', { span: [isInteger] }], - // gridRowStart: getIntegerWithAuto(), - // gridRowEnd: getIntegerWithAuto(), - // gridTemplateColumns: [isAny], - // gridTemplateRows: [isAny], - // height: getSpacingWithAuto(), - // letterSpacing: [ - // 'tighter', - // 'tight', - // 'normal', - // 'wide', - // 'wider', - // 'widest', - // isCustomLength, - // ], - // lineHeight: ['none', 'tight', 'snug', 'normal', 'relaxed', 'loose', isLength], - // listStyleType: ['none', 'disc', 'decimal', isCustomValue], - // maxHeight: [spacing], - // maxWidth: [ - // '0', - // 'none', - // ...getSizesSimple(), - // ...getSizesExtended(), - // 'full', - // 'min', - // 'max', - // 'prose', - // { screen: getSizesSimple() }, - // isCustomLength, - // ], - // minHeight: ['full', 'screen', isLength], - // minWidth: ['full', 'min', 'max', isLength], - // objectPosition: getPositions(), - // order: ['first', 'last', 'none', isInteger], - // outline: ['none', 'white', 'black'], - // placeholderColor: [colors], - // placeholderOpacity: [opacity], - // ringColor: [colors], - // ringOffsetColor: [colors], - // ringOffsetWidth: [isLength], - // ringOpacity: [opacity], - // ringWidth: getLengthWithEmpty(), - // rotate: [isInteger], - // stroke: ['current', isCustomValue], - // strokeWidth: [isLength], - // textColor: [colors], - // textOpacity: [opacity], - // transformOrigin: [ - // 'center', - // 'top', - // 'top-right', - // 'right', - // 'bottom-right', - // 'bottom', - // 'bottom-left', - // 'left', - // 'top-left', - // ], - // transitionDelay: [isInteger], - // transitionDuration: [isInteger], - // transitionProperty: [ - // 'none', - // 'all', - // '', - // 'colors', - // 'opacity', - // 'shadow', - // 'transform', - // isCustomValue, - // ], - // transitionTimingFunction: ['linear', 'in', 'out', 'in-out', isCustomValue], - // width: ['auto', 'min', 'max', spacing], - // zIndex: [isLength], }, classGroups: { // Layout + /** + * Aspect Ratio + * @see https://tailwindcss.com/docs/aspect-ratio + */ + aspect: [{ aspect: ['auto', 'square', 'video', isArbitraryValue] }], /** * Container * @see https://tailwindcss.com/docs/container */ container: ['container'], + /** + * Columns + * @see https://tailwindcss.com/docs/columns + */ + columns: [{ columns: [isTshirtSize] }], + /** + * Break After + * @see https://tailwindcss.com/docs/break-after + */ + 'break-after': [{ 'break-after': getBreaks() }], + /** + * Break Before + * @see https://tailwindcss.com/docs/break-before + */ + 'break-before': [{ 'break-before': getBreaks() }], + /** + * Break Inside + * @see https://tailwindcss.com/docs/break-inside + */ + 'break-inside': [{ 'break-before': ['auto', 'avoid', 'avoid-page', 'avoid-column'] }], /** * Box Decoration Break * @see https://tailwindcss.com/docs/box-decoration-break */ - decoration: [{ decoration: ['slice', 'clone'] }], + 'box-decoration': [{ 'box-decoration': ['slice', 'clone'] }], /** * Box Sizing * @see https://tailwindcss.com/docs/box-sizing @@ -393,6 +285,11 @@ export function getDefaultConfig() { */ z: [{ z: [isLength] }], // Flexbox and Grid + /** + * Flex Basis + * @see https://tailwindcss.com/docs/flex-basis + */ + basis: [{ basis: [spacing] }], /** * Flex Direction * @see https://tailwindcss.com/docs/flex-direction @@ -407,17 +304,17 @@ export function getDefaultConfig() { * Flex * @see https://tailwindcss.com/docs/flex */ - flex: [{ flex: ['1', 'auto', 'initial', 'none', isCustomValue] }], + flex: [{ flex: ['1', 'auto', 'initial', 'none', isArbitraryValue] }], /** * Flex Grow * @see https://tailwindcss.com/docs/flex-grow */ - 'flex-grow': [{ 'flex-grow': getZeroAndEmpty() }], + grow: [{ grow: getZeroAndEmpty() }], /** * Flex Shrink * @see https://tailwindcss.com/docs/flex-shrink */ - 'flex-shrink': [{ 'flex-shrink': getZeroAndEmpty() }], + shrink: [{ shrink: getZeroAndEmpty() }], /** * Order * @see https://tailwindcss.com/docs/order @@ -472,12 +369,12 @@ export function getDefaultConfig() { * Grid Auto Columns * @see https://tailwindcss.com/docs/grid-auto-columns */ - 'auto-cols': [{ 'auto-cols': ['auto', 'min', 'max', 'fr', isCustomValue] }], + 'auto-cols': [{ 'auto-cols': ['auto', 'min', 'max', 'fr', isArbitraryValue] }], /** * Grid Auto Rows * @see https://tailwindcss.com/docs/grid-auto-rows */ - 'auto-rows': [{ 'auto-rows': ['auto', 'min', 'max', 'fr', isCustomValue] }], + 'auto-rows': [{ 'auto-rows': ['auto', 'min', 'max', 'fr', isArbitraryValue] }], /** * Gap * @see https://tailwindcss.com/docs/gap @@ -639,7 +536,7 @@ export function getDefaultConfig() { * Min-Width * @see https://tailwindcss.com/docs/min-width */ - 'min-w': [{ 'min-w': ['full', 'min', 'max', isLength] }], + 'min-w': [{ 'min-w': ['min', 'max', 'fit', isLength] }], /** * Max-Width * @see https://tailwindcss.com/docs/max-width @@ -649,14 +546,14 @@ export function getDefaultConfig() { 'max-w': [ '0', 'none', - ...getSizesSimple(), - ...getSizesExtended(), 'full', 'min', 'max', + 'fit', 'prose', - { screen: getSizesSimple() }, - isCustomLength, + { screen: [isTshirtSize] }, + isTshirtSize, + isArbitraryLength, ], }, ], @@ -669,35 +566,18 @@ export function getDefaultConfig() { * Min-Height * @see https://tailwindcss.com/docs/min-height */ - 'min-h': [{ 'min-h': ['full', 'screen', isLength] }], + 'min-h': [{ 'min-h': ['min', 'max', 'fit', isLength] }], /** * Max-Height * @see https://tailwindcss.com/docs/max-height */ - 'max-h': [{ 'max-h': [spacing] }], + 'max-h': [{ 'max-h': [spacing, 'min', 'max', 'fit'] }], // Typography - /** - * Font Family - * @see https://tailwindcss.com/docs/font-family - */ - 'font-family': [{ font: [isAny] }], /** * Font Size * @see https://tailwindcss.com/docs/font-size */ - 'font-size': [ - { - text: [ - 'xs', - ...getSizesSimple(), - 'base', - ...getSizesExtended(), - '8xl', - '9xl', - isCustomLength, - ], - }, - ], + 'font-size': [{ text: ['base', isTshirtSize, isArbitraryLength] }], /** * Font Smoothing * @see https://tailwindcss.com/docs/font-smoothing @@ -724,9 +604,15 @@ export function getDefaultConfig() { 'bold', 'extrabold', 'black', + isArbitraryWeight, ], }, ], + /** + * Font Family + * @see https://tailwindcss.com/docs/font-family + */ + 'font-family': [{ font: [isAny] }], /** * Font Variant Numeric * @see https://tailwindcss.com/docs/font-variant-numeric @@ -770,7 +656,7 @@ export function getDefaultConfig() { 'wide', 'wider', 'widest', - isCustomLength, + isArbitraryLength, ], }, ], @@ -785,7 +671,7 @@ export function getDefaultConfig() { * List Style Type * @see https://tailwindcss.com/docs/list-style-type */ - 'list-style-type': [{ list: ['none', 'disc', 'decimal', isCustomValue] }], + 'list-style-type': [{ list: ['none', 'disc', 'decimal', isArbitraryValue] }], /** * List Style Position * @see https://tailwindcss.com/docs/list-style-position @@ -821,22 +707,59 @@ export function getDefaultConfig() { * @see https://tailwindcss.com/docs/text-decoration */ 'text-decoration': ['underline', 'line-through', 'no-underline'], + /** + * Text Decoration Style + * @see https://tailwindcss.com/docs/text-decoration-style + */ + 'text-decoration-style': [{ decoration: [...getLineStyles(), 'wavy'] }], + /** + * Text Decoration Thickness + * @see https://tailwindcss.com/docs/text-decoration-thickness + */ + 'text-decoration-thickness': [{ decoration: ['auto', 'from-font', isLength] }], + /** + * Text Decoration Color + * @see https://tailwindcss.com/docs/text-decoration-color + */ + 'text-decoration-color': [{ decoration: [colors] }], /** * Text Transform * @see https://tailwindcss.com/docs/text-transform */ 'text-transform': ['uppercase', 'lowercase', 'capitalize', 'normal-case'], + /** + * Scroll Behavior + * @see https://github.com/tailwindlabs/tailwindcss.com/issues/1016 + */ + 'scroll-behavior': [{ scroll: ['smooth', 'auto'] }], /** * Text Overflow * @see https://tailwindcss.com/docs/text-overflow */ - 'text-overflow': ['truncate', 'overflow-ellipsis', 'overflow-clip'], + 'text-overflow': ['truncate', 'text-ellipsis', 'text-clip'], + /** + * Text Indent + * @see https://tailwindcss.com/docs/text-indent + */ + indent: [{ indent: [spacing] }], /** * Vertical Alignment * @see https://tailwindcss.com/docs/vertical-align */ - 'vertival-alignment': [ - { align: ['baseline', 'top', 'middle', 'bottom', 'text-top', 'text-bottom'] }, + 'vertical-align': [ + { + align: [ + 'baseline', + 'top', + 'middle', + 'bottom', + 'text-top', + 'text-bottom', + 'sub', + 'super', + isArbitraryLength, + ], + }, ], /** * Whitespace @@ -873,7 +796,7 @@ export function getDefaultConfig() { * Background Position * @see https://tailwindcss.com/docs/background-position */ - 'bg-position': [{ bg: getPositions() }], + 'bg-position': [{ bg: [...getPositions(), isArbitraryPosition] }], /** * Background Repeat * @see https://tailwindcss.com/docs/background-repeat @@ -883,13 +806,19 @@ export function getDefaultConfig() { * Background Size * @see https://tailwindcss.com/docs/background-size */ - 'bg-size': [{ bg: ['auto', 'cover', 'contain'] }], + 'bg-size': [{ bg: ['auto', 'cover', 'contain', isArbitrarySize] }], /** * Background Image * @see https://tailwindcss.com/docs/background-image */ 'bg-image': [ - { bg: ['none', { 'gradient-to': ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] }] }, + { + bg: [ + 'none', + { 'gradient-to': ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] }, + isArbitraryUrl, + ], + }, ], /** * Background Blend Mode @@ -967,6 +896,16 @@ export function getDefaultConfig() { * @see https://tailwindcss.com/docs/border-width */ 'border-w': [{ border: [borderWidth] }], + /** + * Border Width X + * @see https://tailwindcss.com/docs/border-width + */ + 'border-w-x': [{ 'border-x': [borderWidth] }], + /** + * Border Width Y + * @see https://tailwindcss.com/docs/border-width + */ + 'border-w-y': [{ 'border-y': [borderWidth] }], /** * Border Width Top * @see https://tailwindcss.com/docs/border-width @@ -996,7 +935,7 @@ export function getDefaultConfig() { * Border Style * @see https://tailwindcss.com/docs/border-style */ - 'border-style': [{ border: getBorderStyles() }], + 'border-style': [{ border: [...getLineStyles(), 'hidden'] }], /** * Divide Width X * @see https://tailwindcss.com/docs/divide-width @@ -1026,12 +965,22 @@ export function getDefaultConfig() { * Divide Style * @see https://tailwindcss.com/docs/divide-style */ - 'divide-style': [{ divide: getBorderStyles() }], + 'divide-style': [{ divide: getLineStyles() }], /** * Border Color * @see https://tailwindcss.com/docs/border-color */ 'border-color': [{ border: [borderColor] }], + /** + * Border Color X + * @see https://tailwindcss.com/docs/border-color + */ + 'border-color-x': [{ 'border-x': [borderColor] }], + /** + * Border Color Y + * @see https://tailwindcss.com/docs/border-color + */ + 'border-color-y': [{ 'border-y': [borderColor] }], /** * Border Color Top * @see https://tailwindcss.com/docs/border-color @@ -1092,7 +1041,12 @@ export function getDefaultConfig() { * Box Shadow * @see https://tailwindcss.com/docs/box-shadow */ - shadow: [{ shadow: ['', ...getSizesSimple(), 'inner', 'none'] }], + shadow: [{ shadow: ['', 'inner', 'none', isTshirtSize] }], + /** + * Box Shadow Color + * @see https://tailwindcss.com/docs/box-shadow-color + */ + 'shadow-color': [{ shadow: [isAny] }], /** * Opacity * @see https://tailwindcss.com/docs/opacity @@ -1128,7 +1082,7 @@ export function getDefaultConfig() { * Drop Shadow * @see https://tailwindcss.com/docs/drop-shadow */ - 'drop-shadow': [{ 'drop-shadow': ['', ...getSizesSimple(), 'none'] }], + 'drop-shadow': [{ 'drop-shadow': ['', 'none', isTshirtSize] }], /** * Grayscale * @see https://tailwindcss.com/docs/grayscale @@ -1230,7 +1184,7 @@ export function getDefaultConfig() { 'opacity', 'shadow', 'transform', - isCustomValue, + isArbitraryValue, ], }, ], @@ -1243,7 +1197,7 @@ export function getDefaultConfig() { * Transition Timing Function * @see https://tailwindcss.com/docs/transition-timing-function */ - ease: [{ ease: ['linear', 'in', 'out', 'in-out', isCustomValue] }], + ease: [{ ease: ['linear', 'in', 'out', 'in-out', isArbitraryValue] }], /** * Transition Delay * @see https://tailwindcss.com/docs/transition-delay @@ -1253,7 +1207,7 @@ export function getDefaultConfig() { * Animation * @see https://tailwindcss.com/docs/animation */ - animate: [{ animate: ['none', 'spin', 'ping', 'pulse', 'bounce', isCustomValue] }], + animate: [{ animate: ['none', 'spin', 'ping', 'pulse', 'bounce', isArbitraryValue] }], // Transforms /** * Transform @@ -1320,6 +1274,11 @@ export function getDefaultConfig() { */ 'skew-y': [{ 'skew-y': [skew] }], // Interactivity + /** + * Accent Color + * @see https://tailwindcss.com/docs/accent-color + */ + accent: [{ accent: ['auto', colors] }], /** * Appearance * @see https://tailwindcss.com/docs/appearance @@ -1340,15 +1299,58 @@ export function getDefaultConfig() { 'move', 'help', 'not-allowed', - isCustomValue, + 'none', + 'context-menu', + 'progress', + 'cell', + 'crosshair', + 'vertical-text', + 'alias', + 'copy', + 'no-drop', + 'grab', + 'grabbing', + 'all-scroll', + 'col-resize', + 'row-resize', + 'n-resize', + 'e-resize', + 's-resize', + 'w-resize', + 'ne-resize', + 'nw-resize', + 'se-resize', + 'sw-resize', + 'ew-resize', + 'ns-resize', + 'nesw-resize', + 'nwse-resize', + 'zoom-in', + 'zoom-out', + isArbitraryValue, ], }, ], /** - * Outline - * @see https://tailwindcss.com/docs/outline + * Outline Width + * @see https://tailwindcss.com/docs/outline-width + */ + 'outline-w': [{ outline: [isLength] }], + /** + * Outline Style + * @see https://tailwindcss.com/docs/outline-style + */ + 'outline-style': [{ outline: ['', ...getLineStyles(), 'hidden'] }], + /** + * Outline Offset + * @see https://tailwindcss.com/docs/outline-offset */ - outline: [{ outline: ['none', 'white', 'black'] }], + 'outline-offset': [{ 'outline-offset': [isLength] }], + /** + * Outline Color + * @see https://tailwindcss.com/docs/outline-color + */ + 'outline-color': [{ outline: [colors] }], /** * Pointer Events * @see https://tailwindcss.com/docs/pointer-events @@ -1360,21 +1362,133 @@ export function getDefaultConfig() { */ resize: [{ resize: ['none', 'y', 'x', ''] }], /** - * Select - * @see https://tailwindcss.com/docs/select + * Scroll Margin + * @see https://tailwindcss.com/docs/scroll-margin + */ + 'scroll-m': [{ 'scroll-m': [spacing] }], + /** + * Scroll Margin X + * @see https://tailwindcss.com/docs/scroll-margin + */ + 'scroll-mx': [{ 'scroll-mx': [spacing] }], + /** + * Scroll Margin Y + * @see https://tailwindcss.com/docs/scroll-margin + */ + 'scroll-my': [{ 'scroll-my': [spacing] }], + /** + * Scroll Margin Top + * @see https://tailwindcss.com/docs/scroll-margin + */ + 'scroll-mt': [{ 'scroll-mt': [spacing] }], + /** + * Scroll Margin Right + * @see https://tailwindcss.com/docs/scroll-margin + */ + 'scroll-mr': [{ 'scroll-mr': [spacing] }], + /** + * Scroll Margin Bottom + * @see https://tailwindcss.com/docs/scroll-margin + */ + 'scroll-mb': [{ 'scroll-mb': [spacing] }], + /** + * Scroll Margin Left + * @see https://tailwindcss.com/docs/scroll-margin + */ + 'scroll-ml': [{ 'scroll-ml': [spacing] }], + /** + * Scroll Padding + * @see https://tailwindcss.com/docs/scroll-padding + */ + 'scroll-p': [{ 'scroll-p': [spacing] }], + /** + * Scroll Padding X + * @see https://tailwindcss.com/docs/scroll-padding + */ + 'scroll-px': [{ 'scroll-px': [spacing] }], + /** + * Scroll Padding Y + * @see https://tailwindcss.com/docs/scroll-padding + */ + 'scroll-py': [{ 'scroll-py': [spacing] }], + /** + * Scroll Padding Top + * @see https://tailwindcss.com/docs/scroll-padding + */ + 'scroll-pt': [{ 'scroll-pt': [spacing] }], + /** + * Scroll Padding Right + * @see https://tailwindcss.com/docs/scroll-padding + */ + 'scroll-pr': [{ 'scroll-pr': [spacing] }], + /** + * Scroll Padding Bottom + * @see https://tailwindcss.com/docs/scroll-padding + */ + 'scroll-pb': [{ 'scroll-pb': [spacing] }], + /** + * Scroll Padding Left + * @see https://tailwindcss.com/docs/scroll-padding + */ + 'scroll-pl': [{ 'scroll-pl': [spacing] }], + /** + * Scroll Snap Align + * @see https://tailwindcss.com/docs/scroll-snap-align + */ + 'snap-align': [{ snap: ['start', 'end', 'center', 'align-none'] }], + /** + * Scroll Snap Stop + * @see https://tailwindcss.com/docs/scroll-snap-stop + */ + 'snap-stop': [{ snap: ['normal', 'always'] }], + /** + * Scroll Snap Type + * @see https://tailwindcss.com/docs/scroll-snap-type + */ + 'snap-type': [{ snap: ['none', 'x', 'y', 'both'] }], + /** + * Scroll Snap Type Strictness + * @see https://tailwindcss.com/docs/scroll-snap-type + */ + 'snap-strictness': [{ snap: ['mandatory', 'proximity'] }], + /** + * Touch Action + * @see https://tailwindcss.com/docs/touch-action + */ + touch: [ + { + touch: [ + 'auto', + 'none', + 'pinch-zoom', + 'manipulation', + { pan: ['x', 'left', 'right', 'y', 'up', 'down'] }, + ], + }, + ], + /** + * User Select + * @see https://tailwindcss.com/docs/user-select */ select: [{ select: ['none', 'text', 'all', 'auto'] }], + /** + * Will Change + * @see https://tailwindcss.com/docs/will-change + */ + 'will-change': [ + { 'will-change': ['auto', 'scroll', 'contents', 'transform', isArbitraryValue] }, + ], // SVG /** * Fill * @see https://tailwindcss.com/docs/fill */ - fill: [{ fill: ['current', isCustomValue] }], + fill: [{ fill: [colors] }], /** * Stroke * @see https://tailwindcss.com/docs/stroke */ - stroke: [{ stroke: ['current', isCustomValue] }], + stroke: [{ stroke: [colors] }], /** * Stroke Width * @see https://tailwindcss.com/docs/stroke-width @@ -1391,7 +1505,7 @@ export function getDefaultConfig() { * Content * @see https://tailwindcss.com/docs/just-in-time-mode#content-utilities */ - content: [{ content: [isCustomValue] }], + content: [{ content: [isArbitraryValue] }], /** * Caret Color * @see https://tailwindcss.com/docs/just-in-time-mode#caret-color-utilities @@ -1404,7 +1518,7 @@ export function getDefaultConfig() { inset: ['inset-x', 'inset-y', 'top', 'right', 'bottom', 'left'], 'inset-x': ['right', 'left'], 'inset-y': ['top', 'bottom'], - flex: ['flex-grow', 'flex-shrink'], + flex: ['basis', 'grow', 'shrink'], 'col-start-end': ['col-start', 'col-end'], 'row-start-end': ['row-start', 'row-end'], gap: ['gap-x', 'gap-y'], @@ -1442,12 +1556,36 @@ export function getDefaultConfig() { 'rounded-b': ['rounded-br', 'rounded-bl'], 'rounded-l': ['rounded-tl', 'rounded-bl'], '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'], 'border-color': [ 'border-color-t', 'border-color-r', 'border-color-b', 'border-color-l', ], + 'border-color-x': ['border-color-r', 'border-color-l'], + 'border-color-y': ['border-color-t', 'border-color-b'], + 'scroll-m': [ + 'scroll-mx', + 'scroll-my', + 'scroll-mt', + 'scroll-mr', + 'scroll-mb', + 'scroll-ml', + ], + 'scroll-mx': ['scroll-mr', 'scroll-ml'], + 'scroll-my': ['scroll-mt', 'scroll-mb'], + 'scroll-p': [ + 'scroll-px', + 'scroll-py', + 'scroll-pt', + 'scroll-pr', + 'scroll-pb', + 'scroll-pl', + ], + 'scroll-px': ['scroll-pr', 'scroll-pl'], + 'scroll-py': ['scroll-pt', 'scroll-pb'], }, } as const } diff --git a/src/merge-classlist.ts b/src/merge-classlist.ts index 9fcf269..b95fa82 100644 --- a/src/merge-classlist.ts +++ b/src/merge-classlist.ts @@ -2,7 +2,7 @@ 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 custom values like `text-[color:var(--mystery-var)]` +// 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 lookbheinds don't have good browser support yet. More info: https://caniuse.com/js-regexp-lookbehind const PREFIX_SEPARATOR_REGEX = /:(?![^[]*\])/ const PREFIX_SEPARATOR = ':' diff --git a/src/validators.ts b/src/validators.ts index 17a84e0..23d39e5 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -1,41 +1,74 @@ -const customValueRegex = /^\[(.+)\]$/ +const arbitraryValueRegex = /^\[(.+)\]$/ const fractionRegex = /^\d+\/\d+$/ const stringLengths = new Set(['px', 'full', 'screen']) +const tshirtUnitRegex = /^(\d+)?(xs|sm|md|lg|xl)$/ const lengthUnitRegex = /\d+(%|px|em|rem|vh|vw|pt|pc|in|cm|mm|cap|ch|ex|lh|rlh|vi|vb|vmin|vmax)/ export function isLength(classPart: string) { return ( - isCustomLength(classPart) || !Number.isNaN(Number(classPart)) || stringLengths.has(classPart) || - fractionRegex.test(classPart) + fractionRegex.test(classPart) || + isArbitraryLength(classPart) ) } -export function isCustomLength(classPart: string) { - const customValue = customValueRegex.exec(classPart)?.[1] +export function isArbitraryLength(classPart: string) { + const arbitraryValue = arbitraryValueRegex.exec(classPart)?.[1] - if (customValue) { - return customValue.startsWith('length:') || lengthUnitRegex.test(customValue) + if (arbitraryValue) { + return arbitraryValue.startsWith('length:') || lengthUnitRegex.test(arbitraryValue) } return false } +export function isArbitrarySize(classPart: string) { + const arbitraryValue = arbitraryValueRegex.exec(classPart)?.[1] + + return arbitraryValue ? arbitraryValue.startsWith('size:') : false +} + +export function isArbitraryPosition(classPart: string) { + const arbitraryValue = arbitraryValueRegex.exec(classPart)?.[1] + + return arbitraryValue ? arbitraryValue.startsWith('position:') : false +} + +export function isArbitraryUrl(classPart: string) { + const arbitraryValue = arbitraryValueRegex.exec(classPart)?.[1] + + return arbitraryValue + ? arbitraryValue.startsWith('url(') || arbitraryValue.startsWith('url:') + : false +} + +export function isArbitraryWeight(classPart: string) { + const arbitraryValue = arbitraryValueRegex.exec(classPart)?.[1] + + return arbitraryValue + ? !Number.isNaN(Number(arbitraryValue)) || arbitraryValue.startsWith('weight:') + : false +} + export function isInteger(classPart: string) { - const customValue = customValueRegex.exec(classPart)?.[1] + const arbitraryValue = arbitraryValueRegex.exec(classPart)?.[1] - if (customValue) { - return Number.isInteger(Number(customValue)) + if (arbitraryValue) { + return Number.isInteger(Number(arbitraryValue)) } return Number.isInteger(Number(classPart)) } -export function isCustomValue(classPart: string) { - return customValueRegex.test(classPart) +export function isArbitraryValue(classPart: string) { + return arbitraryValueRegex.test(classPart) } export function isAny() { return true } + +export function isTshirtSize(classPart: string) { + return tshirtUnitRegex.test(classPart) +} diff --git a/tests/arbitrary-properties.test.ts b/tests/arbitrary-properties.test.ts new file mode 100644 index 0000000..49ea8b2 --- /dev/null +++ b/tests/arbitrary-properties.test.ts @@ -0,0 +1,36 @@ +import { twMerge } from '../src' + +test('handles arbitrary property conflicts correctly', () => { + expect(twMerge('[paint-order:markers] [paint-order:normal]')).toBe('[paint-order:normal]') + expect( + twMerge('[paint-order:markers] [--my-var:2rem] [paint-order:normal] [--my-var:4px]') + ).toBe('[paint-order:normal] [--my-var:4px]') +}) + +test('handles arbitrary property conflicts with prefixes correctly', () => { + expect(twMerge('[paint-order:markers] hover:[paint-order:normal]')).toBe( + '[paint-order:markers] hover:[paint-order:normal]' + ) + expect(twMerge('hover:[paint-order:markers] hover:[paint-order:normal]')).toBe( + 'hover:[paint-order:normal]' + ) + expect(twMerge('hover:focus:[paint-order:markers] focus:hover:[paint-order:normal]')).toBe( + 'focus:hover:[paint-order:normal]' + ) + expect( + twMerge('[paint-order:markers] [paint-order:normal] [--my-var:2rem] lg:[--my-var:4px]') + ).toBe('[paint-order:normal] [--my-var:2rem] lg:[--my-var:4px]') +}) + +test('handles complex arbitrary property conflicts correctly', () => { + expect(twMerge('[-unknown-prop:::123:::] [-unknown-prop:url(https://hi.com)]')).toBe( + '[-unknown-prop:url(https://hi.com)]' + ) +}) + +test('handles important modifier correctly', () => { + expect(twMerge('![some:prop] [some:other]')).toBe('![some:prop] [some:other]') + expect(twMerge('![some:prop] [some:other] [some:one] ![some:another]')).toBe( + '[some:one] ![some:another]' + ) +}) diff --git a/tests/custom-values.test.ts b/tests/arbitrary-values.test.ts similarity index 68% rename from tests/custom-values.test.ts rename to tests/arbitrary-values.test.ts index 96a1728..69da5f8 100644 --- a/tests/custom-values.test.ts +++ b/tests/arbitrary-values.test.ts @@ -1,21 +1,23 @@ import { twMerge } from '../src' -test('handles custom length conflicts correctly', () => { +test('handles arbitrary length conflicts correctly', () => { expect(twMerge('m-[2px] m-[10px]')).toBe('m-[10px]') expect(twMerge('my-[2px] m-[10rem]')).toBe('m-[10rem]') expect(twMerge('cursor-pointer cursor-[grab]')).toBe('cursor-[grab]') - expect(twMerge('m-[2px] m-[calc(100%-var(--custom))]')).toBe('m-[calc(100%-var(--custom))]') + expect(twMerge('m-[2px] m-[calc(100%-var(--arbitrary))]')).toBe( + 'm-[calc(100%-var(--arbitrary))]' + ) expect(twMerge('m-[2px] m-[length:var(--mystery-var)]')).toBe('m-[length:var(--mystery-var)]') }) -test('handles custom length conflicts with labels and prefixes correctly', () => { +test('handles arbitrary length conflicts with labels and prefixes correctly', () => { expect(twMerge('hover:m-[2px] hover:m-[length:var(--c)]')).toBe('hover:m-[length:var(--c)]') expect(twMerge('hover:focus:m-[2px] focus:hover:m-[length:var(--c)]')).toBe( 'focus:hover:m-[length:var(--c)]' ) }) -test('handles complex custom value conflicts correctly', () => { +test('handles complex arbitrary value conflicts correctly', () => { expect(twMerge('grid-rows-[1fr,auto] grid-rows-2')).toBe('grid-rows-2') expect(twMerge('grid-rows-[repeat(20,minmax(0,1fr))] grid-rows-3')).toBe('grid-rows-3') }) diff --git a/tests/class-map.test.ts b/tests/class-map.test.ts index 61b83e9..f501222 100644 --- a/tests/class-map.test.ts +++ b/tests/class-map.test.ts @@ -17,10 +17,12 @@ test('class map has correct class groups at first part', () => { expect(classMap.validators).toHaveLength(0) expect(classGroupsByFirstPart).toEqual({ absolute: ['position'], - align: ['vertival-alignment'], + accent: ['accent'], + align: ['vertical-align'], animate: ['animate'], antialiased: ['font-smoothing'], appearance: ['appearance'], + aspect: ['aspect'], auto: ['auto-cols', 'auto-rows'], backdrop: [ 'backdrop-blur', @@ -34,6 +36,7 @@ test('class map has correct class groups at first part', () => { 'backdrop-saturate', 'backdrop-sepia', ], + basis: ['basis'], bg: [ 'bg-attachment', 'bg-blend', @@ -55,6 +58,8 @@ test('class map has correct class groups at first part', () => { 'border-color-l', 'border-color-r', 'border-color-t', + 'border-color-x', + 'border-color-y', 'border-opacity', 'border-style', 'border-w', @@ -62,21 +67,24 @@ test('class map has correct class groups at first part', () => { 'border-w-l', 'border-w-r', 'border-w-t', + 'border-w-x', + 'border-w-y', ], bottom: ['bottom'], - box: ['box'], - break: ['break'], + box: ['box', 'box-decoration'], + break: ['break', 'break-after', 'break-before', 'break-inside'], brightness: ['brightness'], capitalize: ['text-transform'], caret: ['caret-color'], clear: ['clear'], col: ['col-end', 'col-start', 'col-start-end'], + columns: ['columns'], container: ['container'], content: ['align-content', 'content'], contents: ['display'], contrast: ['contrast'], cursor: ['cursor'], - decoration: ['decoration'], + decoration: ['text-decoration-color', 'text-decoration-style', 'text-decoration-thickness'], delay: ['delay'], diagonal: ['fvn-fraction'], divide: [ @@ -94,7 +102,7 @@ test('class map has correct class groups at first part', () => { fill: ['fill'], filter: ['filter'], fixed: ['position'], - flex: ['display', 'flex', 'flex-direction', 'flex-grow', 'flex-shrink', 'flex-wrap'], + flex: ['display', 'flex', 'flex-direction', 'flex-wrap'], float: ['float'], flow: ['display'], font: ['font-family', 'font-weight'], @@ -102,9 +110,11 @@ test('class map has correct class groups at first part', () => { gap: ['gap', 'gap-x', 'gap-y'], grayscale: ['grayscale'], grid: ['display', 'grid-cols', 'grid-flow', 'grid-rows'], + grow: ['grow'], h: ['h'], hidden: ['display'], hue: ['hue-rotate'], + indent: ['indent'], inline: ['display'], inset: ['inset', 'inset-x', 'inset-y'], invert: ['invert'], @@ -139,8 +149,8 @@ test('class map has correct class groups at first part', () => { order: ['order'], ordinal: ['fvn-ordinal'], origin: ['transform-origin'], - outline: ['outline'], - overflow: ['overflow', 'overflow-x', 'overflow-y', 'text-overflow'], + outline: ['outline-color', 'outline-offset', 'outline-style', 'outline-w'], + overflow: ['overflow', 'overflow-x', 'overflow-y'], overscroll: ['overscroll', 'overscroll-x', 'overscroll-y'], p: ['p'], pb: ['pb'], @@ -179,12 +189,31 @@ test('class map has correct class groups at first part', () => { row: ['row-end', 'row-start', 'row-start-end'], saturate: ['saturate'], scale: ['scale', 'scale-x', 'scale-y'], + scroll: [ + 'scroll-behavior', + 'scroll-m', + 'scroll-mb', + 'scroll-ml', + 'scroll-mr', + 'scroll-mt', + 'scroll-mx', + 'scroll-my', + 'scroll-p', + 'scroll-pb', + 'scroll-pl', + 'scroll-pr', + 'scroll-pt', + 'scroll-px', + 'scroll-py', + ], select: ['select'], self: ['align-self'], sepia: ['sepia'], - shadow: ['shadow'], + shadow: ['shadow', 'shadow-color'], + shrink: ['shrink'], skew: ['skew-x', 'skew-y'], slashed: ['fvn-slashed-zero'], + snap: ['snap-align', 'snap-stop', 'snap-strictness', 'snap-type'], space: ['space-x', 'space-x-reverse', 'space-y', 'space-y-reverse'], sr: ['sr'], stacked: ['fvn-fraction'], @@ -194,9 +223,10 @@ test('class map has correct class groups at first part', () => { subpixel: ['font-smoothing'], table: ['display', 'table-layout'], tabular: ['fvn-spacing'], - text: ['font-size', 'text-alignment', 'text-color', 'text-opacity'], + text: ['font-size', 'text-alignment', 'text-color', 'text-opacity', 'text-overflow'], to: ['gradient-to'], top: ['top'], + touch: ['touch'], tracking: ['tracking'], transform: ['transform'], transition: ['transition'], @@ -208,6 +238,7 @@ test('class map has correct class groups at first part', () => { visible: ['visibility'], w: ['w'], whitespace: ['whitespace'], + will: ['will-change'], z: ['z'], }) }) diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index d6851ac..93b1722 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -15,9 +15,14 @@ test('has correct export types', () => { expect(getDefaultConfig).toStrictEqual(expect.any(Function)) expect(validators).toEqual({ isLength: expect.any(Function), - isCustomLength: expect.any(Function), + isArbitraryLength: expect.any(Function), isInteger: expect.any(Function), - isCustomValue: expect.any(Function), + isArbitraryValue: expect.any(Function), + isTshirtSize: expect.any(Function), + isArbitrarySize: expect.any(Function), + isArbitraryPosition: expect.any(Function), + isArbitraryUrl: expect.any(Function), + isArbitraryWeight: expect.any(Function), isAny: expect.any(Function), }) expect(mergeConfigs).toStrictEqual(expect.any(Function)) @@ -115,10 +120,15 @@ test('createTailwindMerge() has correct inputs and outputs', () => { test('validators have correct inputs and outputs', () => { expect(validators.isLength('')).toEqual(expect.any(Boolean)) - expect(validators.isCustomLength('')).toEqual(expect.any(Boolean)) + expect(validators.isArbitraryLength('')).toEqual(expect.any(Boolean)) expect(validators.isInteger('')).toEqual(expect.any(Boolean)) - expect(validators.isCustomValue('')).toEqual(expect.any(Boolean)) + expect(validators.isArbitraryValue('')).toEqual(expect.any(Boolean)) expect(validators.isAny()).toEqual(expect.any(Boolean)) + expect(validators.isTshirtSize('')).toEqual(expect.any(Boolean)) + expect(validators.isArbitrarySize('')).toEqual(expect.any(Boolean)) + expect(validators.isArbitraryPosition('')).toEqual(expect.any(Boolean)) + expect(validators.isArbitraryUrl('')).toEqual(expect.any(Boolean)) + expect(validators.isArbitraryWeight('')).toEqual(expect.any(Boolean)) }) test('mergeConfigs has correct inputs and outputs', () => { diff --git a/tests/readme-examples.test.ts b/tests/readme-examples.test.ts index ffd5ef6..8f73906 100644 --- a/tests/readme-examples.test.ts +++ b/tests/readme-examples.test.ts @@ -6,7 +6,7 @@ const twMergeExampleRegex = /twMerge\((?[\w\s\-:[\]#(),!\n'"]+?)\)(?!.*(?.+)['"]/g test('readme examples', () => { - expect.assertions(18) + expect.assertions(21) return fs.promises .readFile(`${__dirname}/../README.md`, { encoding: 'utf-8' }) diff --git a/tests/validators.test.ts b/tests/validators.test.ts index f06b2f2..4440f83 100644 --- a/tests/validators.test.ts +++ b/tests/validators.test.ts @@ -1,6 +1,17 @@ import { validators } from '../src' -const { isLength, isCustomLength, isInteger, isCustomValue, isAny } = validators +const { + isLength, + isArbitraryLength, + isInteger, + isArbitraryValue, + isAny, + isTshirtSize, + isArbitrarySize, + isArbitraryPosition, + isArbitraryUrl, + isArbitraryWeight, +} = validators describe('validators', () => { test('isLength', () => { @@ -17,7 +28,7 @@ describe('validators', () => { expect(isLength('[481px]')).toBe(true) expect(isLength('[19.1rem]')).toBe(true) expect(isLength('[50vw]')).toBe(true) - expect(isLength('[length:var(--custom)]')).toBe(true) + expect(isLength('[length:var(--arbitrary)]')).toBe(true) expect(isLength('1d5')).toBe(false) expect(isLength('[1]')).toBe(false) @@ -26,20 +37,20 @@ describe('validators', () => { expect(isLength('one')).toBe(false) }) - test('isCustomLength', () => { - expect(isCustomLength('[3.7%]')).toBe(true) - expect(isCustomLength('[481px]')).toBe(true) - expect(isCustomLength('[19.1rem]')).toBe(true) - expect(isCustomLength('[50vw]')).toBe(true) - expect(isCustomLength('[length:var(--custom)]')).toBe(true) - - expect(isCustomLength('1')).toBe(false) - expect(isCustomLength('3px')).toBe(false) - expect(isCustomLength('1d5')).toBe(false) - expect(isCustomLength('[1]')).toBe(false) - expect(isCustomLength('[12px')).toBe(false) - expect(isCustomLength('12px]')).toBe(false) - expect(isCustomLength('one')).toBe(false) + test('isArbitraryLength', () => { + expect(isArbitraryLength('[3.7%]')).toBe(true) + expect(isArbitraryLength('[481px]')).toBe(true) + expect(isArbitraryLength('[19.1rem]')).toBe(true) + expect(isArbitraryLength('[50vw]')).toBe(true) + expect(isArbitraryLength('[length:var(--arbitrary)]')).toBe(true) + + expect(isArbitraryLength('1')).toBe(false) + expect(isArbitraryLength('3px')).toBe(false) + expect(isArbitraryLength('1d5')).toBe(false) + expect(isArbitraryLength('[1]')).toBe(false) + expect(isArbitraryLength('[12px')).toBe(false) + expect(isArbitraryLength('12px]')).toBe(false) + expect(isArbitraryLength('one')).toBe(false) }) test('isInteger', () => { @@ -60,18 +71,18 @@ describe('validators', () => { expect(isInteger('1px')).toBe(false) }) - test('isCustomValue', () => { - expect(isCustomValue('[1]')).toBe(true) - expect(isCustomValue('[bla]')).toBe(true) - expect(isCustomValue('[not-a-custom-value?]')).toBe(true) - expect(isCustomValue('[auto,auto,minmax(0,1fr),calc(100vw-50%)]')).toBe(true) - - expect(isCustomValue('[]')).toBe(false) - expect(isCustomValue('[1')).toBe(false) - expect(isCustomValue('1]')).toBe(false) - expect(isCustomValue('1')).toBe(false) - expect(isCustomValue('one')).toBe(false) - expect(isCustomValue('o[n]e')).toBe(false) + test('isArbitraryValue', () => { + expect(isArbitraryValue('[1]')).toBe(true) + expect(isArbitraryValue('[bla]')).toBe(true) + expect(isArbitraryValue('[not-an-arbitrary-value?]')).toBe(true) + expect(isArbitraryValue('[auto,auto,minmax(0,1fr),calc(100vw-50%)]')).toBe(true) + + expect(isArbitraryValue('[]')).toBe(false) + expect(isArbitraryValue('[1')).toBe(false) + expect(isArbitraryValue('1]')).toBe(false) + expect(isArbitraryValue('1')).toBe(false) + expect(isArbitraryValue('one')).toBe(false) + expect(isArbitraryValue('o[n]e')).toBe(false) }) test('isAny', () => { @@ -81,4 +92,66 @@ describe('validators', () => { // @ts-expect-error expect(isAny('something')).toBe(true) }) + + test('isTshirtSize', () => { + expect(isTshirtSize('xs')).toBe(true) + expect(isTshirtSize('sm')).toBe(true) + expect(isTshirtSize('md')).toBe(true) + expect(isTshirtSize('lg')).toBe(true) + expect(isTshirtSize('xl')).toBe(true) + expect(isTshirtSize('2xl')).toBe(true) + expect(isTshirtSize('10xl')).toBe(true) + expect(isTshirtSize('2xs')).toBe(true) + expect(isTshirtSize('2lg')).toBe(true) + + expect(isTshirtSize('')).toBe(false) + expect(isTshirtSize('hello')).toBe(false) + expect(isTshirtSize('1')).toBe(false) + expect(isTshirtSize('xl3')).toBe(false) + expect(isTshirtSize('2xl3')).toBe(false) + expect(isTshirtSize('-xl')).toBe(false) + expect(isTshirtSize('[sm]')).toBe(false) + }) + + test('isArbitrarySize', () => { + expect(isArbitrarySize('[size:2px]')).toBe(true) + expect(isArbitrarySize('[size:bla]')).toBe(true) + + expect(isArbitrarySize('[2px]')).toBe(false) + expect(isArbitrarySize('[bla]')).toBe(false) + expect(isArbitrarySize('size:2px')).toBe(false) + }) + + test('isArbitraryPosition', () => { + expect(isArbitraryPosition('[position:2px]')).toBe(true) + expect(isArbitraryPosition('[position:bla]')).toBe(true) + + expect(isArbitraryPosition('[2px]')).toBe(false) + expect(isArbitraryPosition('[bla]')).toBe(false) + expect(isArbitraryPosition('position:2px')).toBe(false) + }) + + test('isArbitraryUrl', () => { + expect(isArbitraryUrl('[url:var(--my-url)]')).toBe(true) + expect(isArbitraryUrl('[url(something)]')).toBe(true) + expect(isArbitraryUrl('[url:bla]')).toBe(true) + + expect(isArbitraryUrl('[var(--my-url)]')).toBe(false) + expect(isArbitraryUrl('[bla]')).toBe(false) + expect(isArbitraryUrl('url:2px')).toBe(false) + expect(isArbitraryUrl('url(2px)')).toBe(false) + }) + + test('isArbitraryWeight', () => { + expect(isArbitraryWeight('[weight:black]')).toBe(true) + expect(isArbitraryWeight('[weight:bla]')).toBe(true) + expect(isArbitraryWeight('[weight:230]')).toBe(true) + expect(isArbitraryWeight('[450]')).toBe(true) + + expect(isArbitraryWeight('[2px]')).toBe(false) + expect(isArbitraryWeight('[bla]')).toBe(false) + expect(isArbitraryWeight('[black]')).toBe(false) + expect(isArbitraryWeight('black')).toBe(false) + expect(isArbitraryWeight('450')).toBe(false) + }) })