diff --git a/docs/api-reference.md b/docs/api-reference.md index dd8e0c42..2eb3d235 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -40,7 +40,7 @@ Why no object support? [Read here](https://github.com/dcastil/tailwind-merge/dis ## `getDefaultConfig` ```ts -function getDefaultConfig(): Config +function getDefaultConfig(): satisfies Config ``` Function which returns the default config used by tailwind-merge. The tailwind-merge config is different from the Tailwind config. It is optimized for small bundle size and fast runtime performance because it is expected to run in the browser. @@ -48,24 +48,39 @@ Function which returns the default config used by tailwind-merge. The tailwind-m ## `fromTheme` ```ts -function fromTheme(key: string): ThemeGetter +function fromTheme< + AdditionalThemeGroupIds extends string = never, + DefaultThemeGroupIdsInner extends string = DefaultThemeGroupIds, +>(key: NoInfer): ThemeGetter ``` Function to retrieve values from a theme scale, to be used in class groups. `fromTheme` doesn't return the values from the theme scale, but rather another function which is used by tailwind-merge internally to retrieve the theme values. tailwind-merge can differentiate the theme getter function from a validator because it has a `isThemeGetter` property set to `true`. -It can be used like this: +When using TypeScript, the function only allows passing the default theme group IDs as the `key` argument. If you use custom theme group IDs, you need to pass them as the generic type argument `AdditionalThemeGroupIds`. In case you aren't using the default tailwind-merge config and use a different set of theme group IDs entirely, you can also pass them as the generic type argument `DefaultThemeGroupIdsInner`. If you want to allow any keys, you can call it as `fromTheme('anything-goes-here')`. + +`fromTheme` can be used like this: ```ts -extendTailwindMerge({ +type AdditionalClassGroupIds = 'my-group' | 'my-group-x' +type AdditionalThemeGroupIds = 'my-scale' + +extendTailwindMerge({ extend: { theme: { 'my-scale': ['foo', 'bar'], }, classGroups: { - 'my-group': [{ 'my-group': [fromTheme('my-scale'), fromTheme('spacing')] }], - 'my-group-x': [{ 'my-group-x': [fromTheme('my-scale')] }], + 'my-group': [ + { + 'my-group': [ + fromTheme('my-scale'), + fromTheme('spacing'), + ], + }, + ], + 'my-group-x': [{ 'my-group-x': [fromTheme('my-scale')] }], }, }, }) @@ -74,11 +89,20 @@ extendTailwindMerge({ ## `extendTailwindMerge` ```ts -function extendTailwindMerge( - configExtension: ConfigExtension, - ...createConfig: ((config: Config) => Config)[] +function extendTailwindMerge< + AdditionalClassGroupIds extends string = never, + AdditionalThemeGroupIds extends string = never, +>( + configExtension: ConfigExtension< + DefaultClassGroupIds | AdditionalClassGroupIds, + DefaultThemeGroupIds | AdditionalThemeGroupIds + >, + ...createConfig: ((config: GenericConfig) => GenericConfig)[] ): TailwindMerge -function extendTailwindMerge(...createConfig: ((config: Config) => Config)[]): TailwindMerge +function extendTailwindMerge< + AdditionalClassGroupIds extends string = never, + AdditionalThemeGroupIds extends string = never, +>(...createConfig: ((config: GenericConfig) => GenericConfig)[]): TailwindMerge ``` Function to create merge function with custom config which extends the default config. Use this if you use the default Tailwind config and just modified it in some places. @@ -88,8 +112,13 @@ Function to create merge function with custom config which extends the default c You provide it a `configExtension` object which gets [merged](#mergeconfigs) with the default config. +When using TypeScript and you use custom class group IDs or theme group IDs, you need to pass them as the generic type arguments `AdditionalClassGroupIds` and `AdditionalThemeGroupIds`. This is enforced to prevent accidental use of non-existing class group IDs accidentally. If you want to allow any custom keys without explicitly defining them, you can pass as `string` to both arguments. + ```ts -const customTwMerge = extendTailwindMerge({ +type AdditionalClassGroupIds = 'aspect-w' | 'aspect-h' | 'aspect-reset' +type AdditionalThemeGroupIds = never + +const customTwMerge = extendTailwindMerge({ // ↓ Optional cache size // Here we're disabling the cache cacheSize: 0, @@ -241,14 +270,19 @@ But don't merge configs like that. Use [`mergeConfigs`](#mergeconfigs) instead. ## `mergeConfigs` ```ts -function mergeConfigs(baseConfig: Config, configExtension: Partial): Config +function mergeConfigs( + baseConfig: GenericConfig, + configExtension: ConfigExtension, +): GenericConfig ``` Helper function to merge multiple tailwind-merge configs. Properties with the value `undefined` are skipped. +When using TypeScript, you need to pass a union of all class group IDs and theme group IDs used in `configExtension` as generic arguments to `mergeConfigs` or pass `string` to both arguments to allow any IDs. + ```ts const customTwMerge = createTailwindMerge(getDefaultConfig, (config) => - mergeConfigs(config, { + mergeConfigs<'shadow' | 'animate' | 'prose'>(config, { override: { classGroups: { // ↓ Overriding existing class group @@ -256,10 +290,12 @@ const customTwMerge = createTailwindMerge(getDefaultConfig, (config) => }, } extend: { - // ↓ Adding value to existing class group - animate: ['animate-shimmer'], - // ↓ Adding new class group - prose: [{ prose: ['', validators.isTshirtSize] }], + classGroups: { + // ↓ Adding value to existing class group + animate: ['animate-shimmer'], + // ↓ Adding new class group + prose: [{ prose: ['', validators.isTshirtSize] }], + } }, }), ) @@ -310,7 +346,7 @@ A brief summary for each validator: ## `Config` ```ts -interface Config { … } +interface Config { … } ``` TypeScript type for config object. Useful if you want to build a `createConfig` function but don't want to define it inline in [`extendTailwindMerge`](#extendtailwindmerge) or [`createTailwindMerge`](#createtailwindmerge). diff --git a/docs/configuration.md b/docs/configuration.md index 62c58a19..d860cb7b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -165,7 +165,7 @@ If you only need to slightly modify the default tailwind-merge config, [`extendT ```ts import { extendTailwindMerge } from 'tailwind-merge' -const customTwMerge = extendTailwindMerge({ +const customTwMerge = extendTailwindMerge<'foo' | 'bar' | 'baz'>({ // ↓ Override eleemnts from the default config // It has the same shape as the `extend` object, so we're going to skip it here. override: {}, diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index cf452a39..5da79edb 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -13,7 +13,7 @@ Here is an example of how a plugin could look like: import { mergeConfigs, validators, Config } from 'tailwind-merge' export function withMagic(config: Config): Config { - return mergeConfigs(config, { + return mergeConfigs<'magic.my-group'>(config, { extend: { classGroups: { 'magic.my-group': [{ magic: [validators.isLength, 'wow'] }], diff --git a/src/index.ts b/src/index.ts index 11c4beac..1e043fe6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,5 +5,5 @@ export { fromTheme } from './lib/from-theme' export { mergeConfigs } from './lib/merge-configs' export { twJoin, type ClassNameValue } from './lib/tw-join' export { twMerge } from './lib/tw-merge' -export type { Config } from './lib/types' +export { type Config, type DefaultClassGroupIds, type DefaultThemeGroupIds } from './lib/types' export * as validators from './lib/validators' diff --git a/src/lib/class-utils.ts b/src/lib/class-utils.ts index 3b4c3de3..3b3ae82a 100644 --- a/src/lib/class-utils.ts +++ b/src/lib/class-utils.ts @@ -1,19 +1,28 @@ -import { ClassGroup, ClassGroupId, ClassValidator, Config, ThemeGetter, ThemeObject } from './types' +import { + ClassGroup, + ClassValidator, + Config, + GenericClassGroupIds, + GenericConfig, + GenericThemeGroupIds, + ThemeGetter, + ThemeObject, +} from './types' export interface ClassPartObject { nextPart: Map validators: ClassValidatorObject[] - classGroupId?: ClassGroupId + classGroupId?: GenericClassGroupIds } interface ClassValidatorObject { - classGroupId: ClassGroupId + classGroupId: GenericClassGroupIds validator: ClassValidator } const CLASS_PART_SEPARATOR = '-' -export function createClassUtils(config: Config) { +export function createClassUtils(config: GenericConfig) { const classMap = createClassMap(config) const { conflictingClassGroups, conflictingClassGroupModifiers = {} } = config @@ -28,7 +37,10 @@ export function createClassUtils(config: Config) { return getGroupRecursive(classParts, classMap) || getGroupIdForArbitraryProperty(className) } - function getConflictingClassGroupIds(classGroupId: ClassGroupId, hasPostfixModifier: boolean) { + function getConflictingClassGroupIds( + classGroupId: GenericClassGroupIds, + hasPostfixModifier: boolean, + ) { const conflicts = conflictingClassGroups[classGroupId] || [] if (hasPostfixModifier && conflictingClassGroupModifiers[classGroupId]) { @@ -47,7 +59,7 @@ export function createClassUtils(config: Config) { function getGroupRecursive( classParts: string[], classPartObject: ClassPartObject, -): ClassGroupId | undefined { +): GenericClassGroupIds | undefined { if (classParts.length === 0) { return classPartObject.classGroupId } @@ -91,7 +103,7 @@ function getGroupIdForArbitraryProperty(className: string) { /** * Exported for testing only */ -export function createClassMap(config: Config) { +export function createClassMap(config: Config) { const { theme, prefix } = config const classMap: ClassPartObject = { nextPart: new Map(), @@ -111,10 +123,10 @@ export function createClassMap(config: Config) { } function processClassesRecursively( - classGroup: ClassGroup, + classGroup: ClassGroup, classPartObject: ClassPartObject, - classGroupId: ClassGroupId, - theme: ThemeObject, + classGroupId: GenericClassGroupIds, + theme: ThemeObject, ) { classGroup.forEach((classDefinition) => { if (typeof classDefinition === 'string') { @@ -176,9 +188,9 @@ function isThemeGetter(func: ClassValidator | ThemeGetter): func is ThemeGetter } function getPrefixedClassGroupEntries( - classGroupEntries: Array<[classGroupId: string, classGroup: ClassGroup]>, + classGroupEntries: Array<[classGroupId: string, classGroup: ClassGroup]>, prefix: string | undefined, -): Array<[classGroupId: string, classGroup: ClassGroup]> { +): Array<[classGroupId: string, classGroup: ClassGroup]> { if (!prefix) { return classGroupEntries } diff --git a/src/lib/config-utils.ts b/src/lib/config-utils.ts index 5cbf322a..59a7ab23 100644 --- a/src/lib/config-utils.ts +++ b/src/lib/config-utils.ts @@ -1,11 +1,11 @@ import { createClassUtils } from './class-utils' import { createLruCache } from './lru-cache' import { createSplitModifiers } from './modifier-utils' -import { Config } from './types' +import { GenericConfig } from './types' export type ConfigUtils = ReturnType -export function createConfigUtils(config: Config) { +export function createConfigUtils(config: GenericConfig) { return { cache: createLruCache(config.cacheSize), splitModifiers: createSplitModifiers(config), diff --git a/src/lib/create-tailwind-merge.ts b/src/lib/create-tailwind-merge.ts index 76b5f03b..2acab958 100644 --- a/src/lib/create-tailwind-merge.ts +++ b/src/lib/create-tailwind-merge.ts @@ -1,15 +1,16 @@ import { createConfigUtils } from './config-utils' import { mergeClassList } from './merge-classlist' import { ClassNameValue, twJoin } from './tw-join' -import { Config } from './types' +import { GenericConfig } from './types' -type CreateConfigFirst = () => Config -type CreateConfigSubsequent = (config: Config) => Config +type CreateConfigFirst = () => GenericConfig +type CreateConfigSubsequent = (config: GenericConfig) => GenericConfig type TailwindMerge = (...classLists: ClassNameValue[]) => string type ConfigUtils = ReturnType export function createTailwindMerge( - ...createConfig: [CreateConfigFirst, ...CreateConfigSubsequent[]] + createConfigFirst: CreateConfigFirst, + ...createConfigRest: CreateConfigSubsequent[] ): TailwindMerge { let configUtils: ConfigUtils let cacheGet: ConfigUtils['cache']['get'] @@ -17,11 +18,9 @@ export function createTailwindMerge( let functionToCall = initTailwindMerge function initTailwindMerge(classList: string) { - const [firstCreateConfig, ...restCreateConfig] = createConfig - - const config = restCreateConfig.reduce( + const config = createConfigRest.reduce( (previousConfig, createConfigCurrent) => createConfigCurrent(previousConfig), - firstCreateConfig(), + createConfigFirst() as GenericConfig, ) configUtils = createConfigUtils(config) diff --git a/src/lib/default-config.ts b/src/lib/default-config.ts index a026dcaa..214ab601 100644 --- a/src/lib/default-config.ts +++ b/src/lib/default-config.ts @@ -1,5 +1,5 @@ import { fromTheme } from './from-theme' -import { Config } from './types' +import { Config, DefaultClassGroupIds, DefaultThemeGroupIds } from './types' import { isAny, isArbitraryLength, @@ -1792,5 +1792,5 @@ export function getDefaultConfig() { conflictingClassGroupModifiers: { 'font-size': ['leading'], }, - } as const satisfies Config + } as const satisfies Config } diff --git a/src/lib/extend-tailwind-merge.ts b/src/lib/extend-tailwind-merge.ts index 7e577505..eff4032a 100644 --- a/src/lib/extend-tailwind-merge.ts +++ b/src/lib/extend-tailwind-merge.ts @@ -1,12 +1,20 @@ import { createTailwindMerge } from './create-tailwind-merge' import { getDefaultConfig } from './default-config' import { mergeConfigs } from './merge-configs' -import { Config, ConfigExtension } from './types' +import { ConfigExtension, DefaultClassGroupIds, DefaultThemeGroupIds, GenericConfig } from './types' -type CreateConfigSubsequent = (config: Config) => Config +type CreateConfigSubsequent = (config: GenericConfig) => GenericConfig -export function extendTailwindMerge( - configExtension: ConfigExtension | CreateConfigSubsequent, +export function extendTailwindMerge< + AdditionalClassGroupIds extends string = never, + AdditionalThemeGroupIds extends string = never, +>( + configExtension: + | ConfigExtension< + DefaultClassGroupIds | AdditionalClassGroupIds, + DefaultThemeGroupIds | AdditionalThemeGroupIds + > + | CreateConfigSubsequent, ...createConfig: CreateConfigSubsequent[] ) { return typeof configExtension === 'function' diff --git a/src/lib/from-theme.ts b/src/lib/from-theme.ts index 9128c51d..92a5b0dd 100644 --- a/src/lib/from-theme.ts +++ b/src/lib/from-theme.ts @@ -1,7 +1,11 @@ -import { ThemeGetter, ThemeObject } from './types' +import { DefaultThemeGroupIds, NoInfer, ThemeGetter, ThemeObject } from './types' -export function fromTheme(key: string): ThemeGetter { - const themeGetter = (theme: ThemeObject) => theme[key] || [] +export function fromTheme< + AdditionalThemeGroupIds extends string = never, + DefaultThemeGroupIdsInner extends string = DefaultThemeGroupIds, +>(key: NoInfer): ThemeGetter { + const themeGetter = (theme: ThemeObject) => + theme[key] || [] themeGetter.isThemeGetter = true as const diff --git a/src/lib/merge-configs.ts b/src/lib/merge-configs.ts index c3d41dd2..df2d765f 100644 --- a/src/lib/merge-configs.ts +++ b/src/lib/merge-configs.ts @@ -1,12 +1,18 @@ -import { Config, ConfigExtension } from './types' +import { ConfigExtension, GenericConfig } from './types' /** * @param baseConfig Config where other config will be merged into. This object will be mutated. * @param configExtension Partial config to merge into the `baseConfig`. */ -export function mergeConfigs( - baseConfig: Config, - { cacheSize, prefix, separator, extend = {}, override = {} }: ConfigExtension, +export function mergeConfigs( + baseConfig: GenericConfig, + { + cacheSize, + prefix, + separator, + extend = {}, + override = {}, + }: ConfigExtension, ) { overrideProperty(baseConfig, 'cacheSize', cacheSize) overrideProperty(baseConfig, 'prefix', prefix) @@ -40,8 +46,8 @@ function overrideProperty( } function overrideConfigProperties( - baseObject: Record, - overrideObject: Record | undefined, + baseObject: Partial>, + overrideObject: Partial> | undefined, ) { if (overrideObject) { for (const key in overrideObject) { @@ -51,8 +57,8 @@ function overrideConfigProperties( } function mergeConfigProperties( - baseObject: Record, - mergeObject: Record | undefined, + baseObject: Partial>, + mergeObject: Partial> | undefined, ) { if (mergeObject) { for (const key in mergeObject) { diff --git a/src/lib/modifier-utils.ts b/src/lib/modifier-utils.ts index 2a807972..7709422d 100644 --- a/src/lib/modifier-utils.ts +++ b/src/lib/modifier-utils.ts @@ -1,8 +1,8 @@ -import { Config } from './types' +import { GenericConfig } from './types' export const IMPORTANT_MODIFIER = '!' -export function createSplitModifiers(config: Config) { +export function createSplitModifiers(config: GenericConfig) { const separator = config.separator const isSeparatorSingleCharacter = separator.length === 1 const firstSeparatorCharacter = separator[0] diff --git a/src/lib/types.ts b/src/lib/types.ts index 3bee64a0..a2c1be35 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,6 @@ -export interface Config extends ConfigStatic, ConfigGroups {} +export interface Config + extends ConfigStatic, + ConfigGroups {} interface ConfigStatic { /** @@ -23,12 +25,12 @@ interface ConfigStatic { */ } -interface ConfigGroups { +interface ConfigGroups { /** * Theme scales used in classGroups. * The keys are the same as in the Tailwind config but the values are sometimes defined more broadly. */ - theme: ThemeObject + theme: NoInfer> /** * Object with groups of classes. * @example @@ -39,34 +41,366 @@ interface ConfigGroups { * 'other-group': [{ 'look-at-me': ['other', 'group']}] * } */ - classGroups: Record + classGroups: NoInfer>> /** * 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 ID is the key of a class group in classGroups object. * @example { gap: ['gap-x', 'gap-y'] } */ - conflictingClassGroups: Record + conflictingClassGroups: NoInfer>> /** * 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 + conflictingClassGroupModifiers: NoInfer< + Partial> + > } -export interface ConfigExtension extends Partial { - override?: Partial - extend?: Partial +export interface ConfigExtension + extends Partial { + override?: PartialPartial> + extend?: PartialPartial> } -export type ThemeObject = Record -export type ClassGroupId = string -export type ClassGroup = readonly ClassDefinition[] -type ClassDefinition = string | ClassValidator | ThemeGetter | ClassObject +type PartialPartial = { + [P in keyof T]?: Partial +} + +export type ThemeObject = Record< + ThemeGroupIds, + ClassGroup +> +export type ClassGroup = readonly ClassDefinition[] +type ClassDefinition = + | string + | ClassValidator + | ThemeGetter + | ClassObject export type ClassValidator = (classPart: string) => boolean export interface ThemeGetter { - (theme: ThemeObject): ClassGroup + (theme: ThemeObject): ClassGroup isThemeGetter: true } -type ClassObject = Record +type ClassObject = Record< + string, + readonly ClassDefinition[] +> + +// Hack from https://stackoverflow.com/questions/56687668/a-way-to-disable-type-argument-inference-in-generics/56688073#56688073 +export type NoInfer = [T][T extends any ? 0 : never] + +/** + * If you want to use a scale that is not supported in the `ThemeObject` type, + * consider using `classGroups` instead of `theme`. + * + * @see https://github.com/dcastil/tailwind-merge/blob/main/docs/configuration.md#theme + * (the list of supported keys may vary between `tailwind-merge` versions) + */ +export type DefaultThemeGroupIds = + | 'blur' + | 'borderColor' + | 'borderRadius' + | 'borderSpacing' + | 'borderWidth' + | 'brightness' + | 'colors' + | 'contrast' + | 'gap' + | 'gradientColorStopPositions' + | 'gradientColorStops' + | 'grayscale' + | 'hueRotate' + | 'inset' + | 'invert' + | 'margin' + | 'opacity' + | 'padding' + | 'saturate' + | 'scale' + | 'sepia' + | 'skew' + | 'space' + | 'spacing' + | 'translate' + +export type DefaultClassGroupIds = + | 'accent' + | 'align-content' + | 'align-items' + | 'align-self' + | 'animate' + | 'appearance' + | 'aspect' + | 'auto-cols' + | 'auto-rows' + | 'backdrop-blur' + | 'backdrop-brightness' + | 'backdrop-contrast' + | 'backdrop-filter' + | 'backdrop-grayscale' + | 'backdrop-hue-rotate' + | 'backdrop-invert' + | 'backdrop-opacity' + | 'backdrop-saturate' + | 'backdrop-sepia' + | 'basis' + | 'bg-attachment' + | 'bg-blend' + | 'bg-clip' + | 'bg-color' + | 'bg-image' + | 'bg-opacity' + | 'bg-origin' + | 'bg-position' + | 'bg-repeat' + | 'bg-size' + | 'blur' + | 'border-collapse' + | 'border-color-b' + | 'border-color-l' + | 'border-color-r' + | 'border-color-t' + | 'border-color-x' + | 'border-color-y' + | 'border-color' + | 'border-opacity' + | 'border-spacing-x' + | 'border-spacing-y' + | 'border-spacing' + | 'border-style' + | 'border-w-b' + | 'border-w-e' + | 'border-w-l' + | 'border-w-r' + | 'border-w-s' + | 'border-w-t' + | 'border-w-x' + | 'border-w-y' + | 'border-w' + | 'bottom' + | 'box-decoration' + | 'box' + | 'break-after' + | 'break-before' + | 'break-inside' + | 'break' + | 'brightness' + | 'caption' + | 'caret-color' + | 'clear' + | 'col-end' + | 'col-start-end' + | 'col-start' + | 'columns' + | 'container' + | 'content' + | 'contrast' + | 'cursor' + | 'delay' + | 'display' + | 'divide-color' + | 'divide-opacity' + | 'divide-style' + | 'divide-x-reverse' + | 'divide-x' + | 'divide-y-reverse' + | 'divide-y' + | 'drop-shadow' + | 'duration' + | 'ease' + | 'end' + | 'fill' + | 'filter' + | 'flex-direction' + | 'flex-wrap' + | 'flex' + | 'float' + | 'font-family' + | 'font-size' + | 'font-smoothing' + | 'font-style' + | 'font-weight' + | 'fvn-figure' + | 'fvn-fraction' + | 'fvn-normal' + | 'fvn-ordinal' + | 'fvn-slashed-zero' + | 'fvn-spacing' + | 'gap-x' + | 'gap-y' + | 'gap' + | 'gradient-from-pos' + | 'gradient-from' + | 'gradient-to-pos' + | 'gradient-to' + | 'gradient-via-pos' + | 'gradient-via' + | 'grayscale' + | 'grid-cols' + | 'grid-flow' + | 'grid-rows' + | 'grow' + | 'h' + | 'hue-rotate' + | 'hyphens' + | 'indent' + | 'inset-x' + | 'inset-y' + | 'inset' + | 'invert' + | 'isolation' + | 'justify-content' + | 'justify-items' + | 'justify-self' + | 'leading' + | 'left' + | 'line-clamp' + | 'list-image' + | 'list-style-position' + | 'list-style-type' + | 'm' + | 'max-h' + | 'max-w' + | 'mb' + | 'me' + | 'min-h' + | 'min-w' + | 'mix-blend' + | 'ml' + | 'mr' + | 'ms' + | 'mt' + | 'mx' + | 'my' + | 'object-fit' + | 'object-position' + | 'opacity' + | 'order' + | 'outline-color' + | 'outline-offset' + | 'outline-style' + | 'outline-w' + | 'overflow-x' + | 'overflow-y' + | 'overflow' + | 'overscroll-x' + | 'overscroll-y' + | 'overscroll' + | 'p' + | 'pb' + | 'pe' + | 'pl' + | 'place-content' + | 'place-items' + | 'place-self' + | 'placeholder-color' + | 'placeholder-opacity' + | 'pointer-events' + | 'position' + | 'pr' + | 'ps' + | 'pt' + | 'px' + | 'py' + | 'resize' + | 'right' + | 'ring-color' + | 'ring-offset-color' + | 'ring-offset-w' + | 'ring-opacity' + | 'ring-w-inset' + | 'ring-w' + | 'rotate' + | 'rounded-b' + | 'rounded-bl' + | 'rounded-br' + | 'rounded-e' + | 'rounded-ee' + | 'rounded-es' + | 'rounded-l' + | 'rounded-r' + | 'rounded-s' + | 'rounded-se' + | 'rounded-ss' + | 'rounded-t' + | 'rounded-tl' + | 'rounded-tr' + | 'rounded' + | 'row-end' + | 'row-start-end' + | 'row-start' + | 'saturate' + | 'scale-x' + | 'scale-y' + | 'scale' + | 'scroll-behavior' + | 'scroll-m' + | 'scroll-mb' + | 'scroll-me' + | 'scroll-ml' + | 'scroll-mr' + | 'scroll-ms' + | 'scroll-mt' + | 'scroll-mx' + | 'scroll-my' + | 'scroll-p' + | 'scroll-pb' + | 'scroll-pe' + | 'scroll-pl' + | 'scroll-pr' + | 'scroll-ps' + | 'scroll-pt' + | 'scroll-px' + | 'scroll-py' + | 'select' + | 'sepia' + | 'shadow-color' + | 'shadow' + | 'shrink' + | 'skew-x' + | 'skew-y' + | 'snap-align' + | 'snap-stop' + | 'snap-strictness' + | 'snap-type' + | 'space-x-reverse' + | 'space-x' + | 'space-y-reverse' + | 'space-y' + | 'sr' + | 'start' + | 'stroke-w' + | 'stroke' + | 'table-layout' + | 'text-alignment' + | 'text-color' + | 'text-decoration-color' + | 'text-decoration-style' + | 'text-decoration-thickness' + | 'text-decoration' + | 'text-opacity' + | 'text-overflow' + | 'text-transform' + | 'top' + | 'touch' + | 'tracking' + | 'transform-origin' + | 'transform' + | 'transition' + | 'translate-x' + | 'translate-y' + | 'underline-offset' + | 'vertical-align' + | 'visibility' + | 'w' + | 'whitespace' + | 'will-change' + | 'z' + +export type GenericClassGroupIds = string +export type GenericThemeGroupIds = string + +export type GenericConfig = Config diff --git a/tests/default-config.test.ts b/tests/default-config.test.ts index 5c2620f0..d9dcfcef 100644 --- a/tests/default-config.test.ts +++ b/tests/default-config.test.ts @@ -1,10 +1,11 @@ -import { getDefaultConfig, Config } from '../src' +import { getDefaultConfig } from '../src' +import { Config, DefaultClassGroupIds, DefaultThemeGroupIds } from '../src/lib/types' test('default config has correct types', () => { const defaultConfig = getDefaultConfig() // eslint-disable-next-line @typescript-eslint/no-unused-vars - const genericConfig: Config = defaultConfig + const genericConfig: Config = defaultConfig expect(defaultConfig.cacheSize).toBe(500) // @ts-expect-error diff --git a/tests/extend-tailwind-merge.test.ts b/tests/extend-tailwind-merge.test.ts index 25407900..bbbbfd72 100644 --- a/tests/extend-tailwind-merge.test.ts +++ b/tests/extend-tailwind-merge.test.ts @@ -1,7 +1,7 @@ import { extendTailwindMerge } from '../src' test('extendTailwindMerge works correctly with single config', () => { - const tailwindMerge = extendTailwindMerge({ + const tailwindMerge = extendTailwindMerge({ cacheSize: 20, extend: { classGroups: { @@ -33,7 +33,7 @@ test('extendTailwindMerge works correctly with single config', () => { }) test('extendTailwindMerge works corectly with multiple configs', () => { - const tailwindMerge = extendTailwindMerge( + const tailwindMerge = extendTailwindMerge( { cacheSize: 20, extend: { @@ -107,7 +107,7 @@ test('extendTailwindMerge works correctly with function config', () => { }) test('extendTailwindMerge overrides and extends correctly', () => { - const tailwindMerge = extendTailwindMerge({ + const tailwindMerge = extendTailwindMerge({ cacheSize: 20, override: { classGroups: { diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index c9a8eaa9..eaacfb01 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -1,6 +1,8 @@ import { ClassNameValue, Config, + DefaultClassGroupIds, + DefaultThemeGroupIds, createTailwindMerge, extendTailwindMerge, fromTheme, @@ -36,7 +38,7 @@ test('has correct export types', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const noRun = () => { - const config: Config = getDefaultConfig() + const config: Config = getDefaultConfig() const classNameValue: ClassNameValue = 'some-class' twMerge(classNameValue, classNameValue, classNameValue) @@ -179,9 +181,17 @@ test('extendTailwindMerge has correct inputs and outputs', () => { }) test('fromTheme has correct inputs and outputs', () => { - expect(fromTheme('foo')).toStrictEqual(expect.any(Function)) - expect(fromTheme('foo').isThemeGetter).toBe(true) - expect(fromTheme('foo')({ foo: ['hello'] })).toStrictEqual(['hello']) + expect(fromTheme('spacing')).toStrictEqual(expect.any(Function)) + expect(fromTheme('foo')).toStrictEqual(expect.any(Function)) + expect(fromTheme('foo').isThemeGetter).toBe(true) + expect(fromTheme('foo')({ foo: ['hello'] })).toStrictEqual(['hello']) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const noRun = () => { + // @ts-expect-error + fromTheme('custom-key') + fromTheme<'custom-key'>('custom-key') + } }) test('twJoin has correct inputs and outputs', () => { diff --git a/tests/theme.test.ts b/tests/theme.test.ts index 4d1c8b71..f5a7a63d 100644 --- a/tests/theme.test.ts +++ b/tests/theme.test.ts @@ -15,13 +15,13 @@ test('theme scale can be extended', () => { }) test('theme object can be extended', () => { - const tailwindMerge = extendTailwindMerge({ + const tailwindMerge = extendTailwindMerge({ extend: { theme: { 'my-theme': ['hallo', 'hello'], }, classGroups: { - px: [{ px: [fromTheme('my-theme')] }], + px: [{ px: [fromTheme('my-theme')] }], }, }, }) diff --git a/tests/type-generics.test.ts b/tests/type-generics.test.ts new file mode 100644 index 00000000..0ff25baa --- /dev/null +++ b/tests/type-generics.test.ts @@ -0,0 +1,181 @@ +import { extendTailwindMerge, fromTheme, getDefaultConfig, mergeConfigs } from '../src' +import { GenericConfig } from '../src/lib/types' + +test('extendTailwindMerge type generics work correctly', () => { + const tailwindMerge1 = extendTailwindMerge({ + extend: { + theme: { + spacing: ['my-space'], + // @ts-expect-error + plll: ['something'], + }, + classGroups: { + px: ['px-foo'], + // @ts-expect-error + pxx: ['pxx-foo'], + }, + conflictingClassGroups: { + px: ['p'], + // @ts-expect-error + pxx: ['p'], + }, + conflictingClassGroupModifiers: { + p: [ + 'px', + // @ts-expect-error + 'prr', + ], + }, + }, + }) + + expect(tailwindMerge1('')).toBe('') + + const tailwindMerge2 = extendTailwindMerge<'test1' | 'test2', 'test3'>({ + extend: { + theme: { + spacing: ['my-space'], + // @ts-expect-error + plll: ['something'], + test3: ['bar'], + }, + classGroups: { + px: ['px-foo'], + // @ts-expect-error + pxx: ['pxx-foo'], + test1: ['foo'], + test2: ['bar'], + }, + conflictingClassGroups: { + px: ['p'], + // @ts-expect-error + pxx: ['p'], + test1: ['test2'], + }, + conflictingClassGroupModifiers: { + p: [ + 'px', + // @ts-expect-error + 'prr', + 'test2', + 'test1', + ], + test1: ['test2'], + }, + }, + }) + + expect(tailwindMerge2('')).toBe('') + + const tailwindMerge3 = extendTailwindMerge((v: GenericConfig) => v, getDefaultConfig) + + expect(tailwindMerge3('')).toBe('') +}) + +test('fromTheme type generics work correctly', () => { + expect(fromTheme<'test4'>('test4')).toEqual(expect.any(Function)) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const noRun = () => { + fromTheme('spacing') + fromTheme<'test5' | 'test6'>('test6') + fromTheme('anything') + fromTheme('only-this') + fromTheme<'or-this', 'only-this'>('or-this' as 'only-this' | 'or-this') + + // @ts-expect-error + fromTheme('test5') + // @ts-expect-error + fromTheme('test5' | 'spacing') + // @ts-expect-error + fromTheme('something-else') + // @ts-expect-error + fromTheme('something-else' | 'only-this') + } +}) + +test('mergeConfigs type generics work correctly', () => { + const config1 = mergeConfigs<'foo' | 'bar', 'baz'>( + { + cacheSize: 50, + prefix: 'tw-', + separator: ':', + theme: { + hi: ['ho'], + themeToOverride: ['to-override'], + }, + classGroups: { + fooKey: [{ fooKey: ['one', 'two'] }], + bla: [{ bli: ['blub', 'blublub'] }], + groupToOverride: ['this', 'will', 'be', 'overridden'], + groupToOverride2: ['this', 'will', 'not', 'be', 'overridden'], + }, + conflictingClassGroups: { + toOverride: ['groupToOverride'], + }, + conflictingClassGroupModifiers: { + hello: ['world'], + toOverride: ['groupToOverride-2'], + }, + }, + { + separator: '-', + prefix: undefined, + override: { + theme: { + baz: [], + // @ts-expect-error + nope: [], + }, + classGroups: { + foo: [], + bar: [], + // @ts-expect-error + hiii: [], + }, + conflictingClassGroups: { + foo: [ + 'bar', + // @ts-expect-error + 'lol', + ], + }, + conflictingClassGroupModifiers: { + bar: ['foo'], + // @ts-expect-error + lel: ['foo'], + }, + }, + extend: { + classGroups: { + foo: [], + bar: [], + // @ts-expect-error + hiii: [], + }, + conflictingClassGroups: { + foo: [ + 'bar', + // @ts-expect-error + 'lol', + ], + }, + conflictingClassGroupModifiers: { + bar: ['foo'], + // @ts-expect-error + lel: ['foo'], + }, + }, + }, + ) + + expect(config1).toEqual(expect.any(Object)) + + const config2 = mergeConfigs<'very', 'strict'>(getDefaultConfig(), {}) + + expect(config2).toEqual(expect.any(Object)) + + const config3 = mergeConfigs<'single-arg'>(getDefaultConfig(), {}) + + expect(config3).toEqual(expect.any(Object)) +})