From ec7416b9db83109a549ac34385525bbd1bedde4e Mon Sep 17 00:00:00 2001 From: Alexander Kachkaev Date: Thu, 10 Aug 2023 11:59:31 +0100 Subject: [PATCH 01/15] Improve ThemeObject key type --- src/lib/types.ts | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index 3bee64a0..dcc82ff5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -60,7 +60,41 @@ export interface ConfigExtension extends Partial { extend?: Partial } -export type ThemeObject = Record +/** + * 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 SupportedThemeScale = + | 'colors' + | 'spacing' + | 'blur' + | 'brightness' + | 'borderColor' + | 'borderRadius' + | 'borderSpacing' + | 'borderWidth' + | 'contrast' + | 'grayscale' + | 'hueRotate' + | 'invert' + | 'gap' + | 'gradientColorStops' + | 'gradientColorStopPositions' + | 'inset' + | 'margin' + | 'opacity' + | 'padding' + | 'saturate' + | 'scale' + | 'sepia' + | 'skew' + | 'space' + | 'translate' + +export type ThemeObject = Record export type ClassGroupId = string export type ClassGroup = readonly ClassDefinition[] type ClassDefinition = string | ClassValidator | ThemeGetter | ClassObject From c2bfcbbdaae3b800eb2270cc2c8fe74d438efdd0 Mon Sep 17 00:00:00 2001 From: Alexander Kachkaev Date: Thu, 10 Aug 2023 12:19:22 +0100 Subject: [PATCH 02/15] Fix type --- src/lib/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index dcc82ff5..4e382f6f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -94,7 +94,7 @@ export type SupportedThemeScale = | 'space' | 'translate' -export type ThemeObject = Record +export type ThemeObject = Partial> export type ClassGroupId = string export type ClassGroup = readonly ClassDefinition[] type ClassDefinition = string | ClassValidator | ThemeGetter | ClassObject From 79c5fd434e64427f796ba89c238d368cfffbcc63 Mon Sep 17 00:00:00 2001 From: Alexander Kachkaev Date: Thu, 10 Aug 2023 12:31:49 +0100 Subject: [PATCH 03/15] Backticks --- src/lib/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index 4e382f6f..924755a9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -61,7 +61,7 @@ export interface ConfigExtension extends Partial { } /** - * If you want to use a scale that is not supported in the ThemeObject type, + * 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 From 14fb44c319fcd46f34a7b19281900378d16ed110 Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 17:38:43 +0200 Subject: [PATCH 04/15] add generic types --- src/lib/class-utils.ts | 36 ++-- src/lib/config-utils.ts | 4 +- src/lib/create-tailwind-merge.ts | 15 +- src/lib/default-config.ts | 4 +- src/lib/extend-tailwind-merge.ts | 16 +- src/lib/from-theme.ts | 15 +- src/lib/merge-configs.ts | 22 +- src/lib/modifier-utils.ts | 4 +- src/lib/types.ts | 338 +++++++++++++++++++++++++++++-- 9 files changed, 393 insertions(+), 61 deletions(-) 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..ba155ff7 100644 --- a/src/lib/from-theme.ts +++ b/src/lib/from-theme.ts @@ -1,7 +1,16 @@ -import { ThemeGetter, ThemeObject } from './types' +import { + DefaultThemeGroupIds as DefaultThemeGroupIdsOuter, + NoInfer, + ThemeGetter, + ThemeObject, +} from './types' -export function fromTheme(key: string): ThemeGetter { - const themeGetter = (theme: ThemeObject) => theme[key] || [] +export function fromTheme< + AdditionalThemeGroupIds extends string = never, + DefaultThemeGroupIds extends string = DefaultThemeGroupIdsOuter, +>(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..8eb947a8 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 924755a9..56975298 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: ThemeObject /** * Object with groups of classes. * @example @@ -39,27 +41,55 @@ interface ConfigGroups { * 'other-group': [{ 'look-at-me': ['other', 'group']}] * } */ - classGroups: Record + classGroups: Record> /** * 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: Partial> /** * 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: Partial> } -export interface ConfigExtension extends Partial { - override?: Partial - extend?: Partial +export interface ConfigExtension + extends Partial { + override?: PartialPartial> + extend?: PartialPartial> } +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 + isThemeGetter: true +} +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`. @@ -67,7 +97,7 @@ export interface ConfigExtension extends Partial { * @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 SupportedThemeScale = +export type DefaultThemeGroupIds = | 'colors' | 'spacing' | 'blur' @@ -94,13 +124,281 @@ export type SupportedThemeScale = | 'space' | 'translate' -export type ThemeObject = Partial> -export type ClassGroupId = string -export type ClassGroup = readonly ClassDefinition[] -type ClassDefinition = string | ClassValidator | ThemeGetter | ClassObject -export type ClassValidator = (classPart: string) => boolean -export interface ThemeGetter { - (theme: ThemeObject): ClassGroup - isThemeGetter: true -} -type ClassObject = Record +export type DefaultClassGroupIds = + | 'aspect' + | 'container' + | 'columns' + | 'break-after' + | 'break-before' + | 'break-inside' + | 'box-decoration' + | 'box' + | 'display' + | 'float' + | 'clear' + | 'isolation' + | 'object-fit' + | 'object-position' + | 'overflow' + | 'overflow-x' + | 'overflow-y' + | 'overscroll' + | 'overscroll-x' + | 'overscroll-y' + | 'position' + | 'inset' + | 'inset-x' + | 'inset-y' + | 'start' + | 'end' + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'visibility' + | 'z' + | 'basis' + | 'flex-direction' + | 'flex-wrap' + | 'flex' + | 'grow' + | 'shrink' + | 'order' + | 'grid-cols' + | 'col-start-end' + | 'col-start' + | 'col-end' + | 'grid-rows' + | 'row-start-end' + | 'row-start' + | 'row-end' + | 'grid-flow' + | 'auto-cols' + | 'auto-rows' + | 'gap' + | 'gap-x' + | 'gap-y' + | 'justify-content' + | 'justify-items' + | 'justify-self' + | 'align-content' + | 'align-items' + | 'align-self' + | 'place-content' + | 'place-items' + | 'place-self' + | 'p' + | 'px' + | 'py' + | 'ps' + | 'pe' + | 'pt' + | 'pr' + | 'pb' + | 'pl' + | 'm' + | 'mx' + | 'my' + | 'ms' + | 'me' + | 'mt' + | 'mr' + | 'mb' + | 'ml' + | 'space-x' + | 'space-x-reverse' + | 'space-y' + | 'space-y-reverse' + | 'w' + | 'min-w' + | 'max-w' + | 'h' + | 'min-h' + | 'max-h' + | 'font-size' + | 'font-smoothing' + | 'font-style' + | 'font-weight' + | 'font-family' + | 'fvn-normal' + | 'fvn-ordinal' + | 'fvn-slashed-zero' + | 'fvn-figure' + | 'fvn-spacing' + | 'fvn-fraction' + | 'tracking' + | 'line-clamp' + | 'leading' + | 'list-image' + | 'list-style-type' + | 'list-style-position' + | 'placeholder-color' + | 'placeholder-opacity' + | 'text-alignment' + | 'text-color' + | 'text-opacity' + | 'text-decoration' + | 'text-decoration-style' + | 'text-decoration-thickness' + | 'underline-offset' + | 'text-decoration-color' + | 'text-transform' + | 'text-overflow' + | 'indent' + | 'vertical-align' + | 'whitespace' + | 'break' + | 'hyphens' + | 'content' + | 'bg-attachment' + | 'bg-clip' + | 'bg-opacity' + | 'bg-origin' + | 'bg-position' + | 'bg-repeat' + | 'bg-size' + | 'bg-image' + | 'bg-color' + | 'gradient-from-pos' + | 'gradient-via-pos' + | 'gradient-to-pos' + | 'gradient-from' + | 'gradient-via' + | 'gradient-to' + | 'rounded' + | 'rounded-s' + | 'rounded-e' + | 'rounded-t' + | 'rounded-r' + | 'rounded-b' + | 'rounded-l' + | 'rounded-ss' + | 'rounded-se' + | 'rounded-ee' + | 'rounded-es' + | 'rounded-tl' + | 'rounded-tr' + | 'rounded-br' + | 'rounded-bl' + | 'border-w' + | 'border-w-x' + | 'border-w-y' + | 'border-w-s' + | 'border-w-e' + | 'border-w-t' + | 'border-w-r' + | 'border-w-b' + | 'border-w-l' + | 'border-opacity' + | 'border-style' + | 'divide-x' + | 'divide-x-reverse' + | 'divide-y' + | 'divide-y-reverse' + | 'divide-opacity' + | 'divide-style' + | 'border-color' + | 'border-color-x' + | 'border-color-y' + | 'border-color-t' + | 'border-color-r' + | 'border-color-b' + | 'border-color-l' + | 'divide-color' + | 'outline-style' + | 'outline-offset' + | 'outline-w' + | 'outline-color' + | 'ring-w' + | 'ring-w-inset' + | 'ring-color' + | 'ring-opacity' + | 'ring-offset-w' + | 'ring-offset-color' + | 'shadow' + | 'shadow-color' + | 'opacity' + | 'mix-blend' + | 'bg-blend' + | 'filter' + | 'blur' + | 'brightness' + | 'contrast' + | 'drop-shadow' + | 'grayscale' + | 'hue-rotate' + | 'invert' + | 'saturate' + | 'sepia' + | 'backdrop-filter' + | 'backdrop-blur' + | 'backdrop-brightness' + | 'backdrop-contrast' + | 'backdrop-grayscale' + | 'backdrop-hue-rotate' + | 'backdrop-invert' + | 'backdrop-opacity' + | 'backdrop-saturate' + | 'backdrop-sepia' + | 'border-collapse' + | 'border-spacing' + | 'border-spacing-x' + | 'border-spacing-y' + | 'table-layout' + | 'caption' + | 'transition' + | 'duration' + | 'ease' + | 'delay' + | 'animate' + | 'transform' + | 'scale' + | 'scale-x' + | 'scale-y' + | 'rotate' + | 'translate-x' + | 'translate-y' + | 'skew-x' + | 'skew-y' + | 'transform-origin' + | 'accent' + | 'appearance' + | 'cursor' + | 'caret-color' + | 'pointer-events' + | 'resize' + | 'scroll-behavior' + | 'scroll-m' + | 'scroll-mx' + | 'scroll-my' + | 'scroll-ms' + | 'scroll-me' + | 'scroll-mt' + | 'scroll-mr' + | 'scroll-mb' + | 'scroll-ml' + | 'scroll-p' + | 'scroll-px' + | 'scroll-py' + | 'scroll-ps' + | 'scroll-pe' + | 'scroll-pt' + | 'scroll-pr' + | 'scroll-pb' + | 'scroll-pl' + | 'snap-align' + | 'snap-stop' + | 'snap-type' + | 'snap-strictness' + | 'touch' + | 'select' + | 'will-change' + | 'fill' + | 'stroke-w' + | 'stroke' + | 'sr' + +export type GenericClassGroupIds = string +export type GenericThemeGroupIds = string + +export type GenericConfig = Config From 88d06e218c876abdc212e840c06e941d9cab2d1d Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 17:40:36 +0200 Subject: [PATCH 05/15] adjust existing tests to generics --- tests/default-config.test.ts | 5 +++-- tests/extend-tailwind-merge.test.ts | 6 +++--- tests/merge-configs.test.ts | 4 ++-- tests/public-api.test.ts | 18 +++++++++++++----- tests/theme.test.ts | 4 ++-- 5 files changed, 23 insertions(+), 14 deletions(-) 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/merge-configs.test.ts b/tests/merge-configs.test.ts index 5e98ceb0..7cfc1340 100644 --- a/tests/merge-configs.test.ts +++ b/tests/merge-configs.test.ts @@ -2,7 +2,7 @@ import { mergeConfigs } from '../src' test('mergeConfigs has correct behavior', () => { expect( - mergeConfigs( + mergeConfigs( { cacheSize: 50, prefix: 'tw-', @@ -37,7 +37,7 @@ test('mergeConfigs has correct behavior', () => { groupToOverride2: undefined!, }, conflictingClassGroups: { - toOverride: ['groupOverridden'], + toOverride: ['groupOverridden'] as const, }, conflictingClassGroupModifiers: { toOverride: ['overridden-2'], diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index c9a8eaa9..8889c5d9 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -1,6 +1,5 @@ import { ClassNameValue, - Config, createTailwindMerge, extendTailwindMerge, fromTheme, @@ -10,6 +9,7 @@ import { twMerge, validators, } from '../src' +import { Config, DefaultClassGroupIds, DefaultThemeGroupIds } from '../src/lib/types' test('has correct export types', () => { expect(twMerge).toStrictEqual(expect.any(Function)) @@ -36,7 +36,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 +179,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')] }], }, }, }) From 29b758c8bc992fabcae8edf16dfdafc5866e18b9 Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 17:43:03 +0200 Subject: [PATCH 06/15] add DefaultClassGroupIds and DefaultThemeGroupIds to library exports --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From bfe98457b34cecb028e39b81708e5ef9708cf21b Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 17:45:23 +0200 Subject: [PATCH 07/15] add public types to public-api test --- tests/public-api.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index 8889c5d9..eaacfb01 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -1,5 +1,8 @@ import { ClassNameValue, + Config, + DefaultClassGroupIds, + DefaultThemeGroupIds, createTailwindMerge, extendTailwindMerge, fromTheme, @@ -9,7 +12,6 @@ import { twMerge, validators, } from '../src' -import { Config, DefaultClassGroupIds, DefaultThemeGroupIds } from '../src/lib/types' test('has correct export types', () => { expect(twMerge).toStrictEqual(expect.any(Function)) From 295000203cd39e2c8f8a187d59593b9c313be3a8 Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 18:16:23 +0200 Subject: [PATCH 08/15] prevent type inference through config groups --- src/lib/types.ts | 487 ++++++++++++++++++++++++----------------------- 1 file changed, 246 insertions(+), 241 deletions(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index 56975298..33df0e00 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -30,7 +30,7 @@ interface ConfigGroups + theme: NoInfer> /** * Object with groups of classes. * @example @@ -41,20 +41,22 @@ interface ConfigGroups> + 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: Partial> + 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: Partial> + conflictingClassGroupModifiers: NoInfer< + Partial> + > } export interface ConfigExtension @@ -98,22 +100,21 @@ export type NoInfer = [T][T extends any ? 0 : never] * (the list of supported keys may vary between `tailwind-merge` versions) */ export type DefaultThemeGroupIds = - | 'colors' - | 'spacing' | 'blur' - | 'brightness' | 'borderColor' | 'borderRadius' | 'borderSpacing' | 'borderWidth' + | 'brightness' + | 'colors' | 'contrast' - | 'grayscale' - | 'hueRotate' - | 'invert' | 'gap' - | 'gradientColorStops' | 'gradientColorStopPositions' + | 'gradientColorStops' + | 'grayscale' + | 'hueRotate' | 'inset' + | 'invert' | 'margin' | 'opacity' | 'padding' @@ -122,281 +123,285 @@ export type DefaultThemeGroupIds = | 'sepia' | 'skew' | 'space' + | 'spacing' | 'translate' export type DefaultClassGroupIds = + | 'accent' + /** + * Test test + */ + | 'align-content' + | 'align-items' + | 'align-self' + | 'animate' + | 'appearance' | 'aspect' - | 'container' - | 'columns' + | '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' - | 'box-decoration' - | 'box' - | 'display' - | 'float' + | 'break' + | 'brightness' + | 'caption' + | 'caret-color' | 'clear' - | 'isolation' - | 'object-fit' - | 'object-position' - | 'overflow' - | 'overflow-x' - | 'overflow-y' - | 'overscroll' - | 'overscroll-x' - | 'overscroll-y' - | 'position' - | 'inset' - | 'inset-x' - | 'inset-y' - | 'start' + | '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' - | 'top' - | 'right' - | 'bottom' - | 'left' - | 'visibility' - | 'z' - | 'basis' + | 'fill' + | 'filter' | 'flex-direction' | 'flex-wrap' | 'flex' - | 'grow' - | 'shrink' - | 'order' - | 'grid-cols' - | 'col-start-end' - | 'col-start' - | 'col-end' - | 'grid-rows' - | 'row-start-end' - | 'row-start' - | 'row-end' - | 'grid-flow' - | 'auto-cols' - | 'auto-rows' - | 'gap' - | 'gap-x' - | 'gap-y' - | 'justify-content' - | 'justify-items' - | 'justify-self' - | 'align-content' - | 'align-items' - | 'align-self' - | 'place-content' - | 'place-items' - | 'place-self' - | 'p' - | 'px' - | 'py' - | 'ps' - | 'pe' - | 'pt' - | 'pr' - | 'pb' - | 'pl' - | 'm' - | 'mx' - | 'my' - | 'ms' - | 'me' - | 'mt' - | 'mr' - | 'mb' - | 'ml' - | 'space-x' - | 'space-x-reverse' - | 'space-y' - | 'space-y-reverse' - | 'w' - | 'min-w' - | 'max-w' - | 'h' - | 'min-h' - | 'max-h' + | 'float' + | 'font-family' | 'font-size' | 'font-smoothing' | 'font-style' | 'font-weight' - | 'font-family' + | 'fvn-figure' + | 'fvn-fraction' | 'fvn-normal' | 'fvn-ordinal' | 'fvn-slashed-zero' - | 'fvn-figure' | 'fvn-spacing' - | 'fvn-fraction' - | 'tracking' - | 'line-clamp' + | '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-type' | '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' - | 'text-alignment' - | 'text-color' - | 'text-opacity' - | 'text-decoration' - | 'text-decoration-style' - | 'text-decoration-thickness' - | 'underline-offset' - | 'text-decoration-color' - | 'text-transform' - | 'text-overflow' - | 'indent' - | 'vertical-align' - | 'whitespace' - | 'break' - | 'hyphens' - | 'content' - | 'bg-attachment' - | 'bg-clip' - | 'bg-opacity' - | 'bg-origin' - | 'bg-position' - | 'bg-repeat' - | 'bg-size' - | 'bg-image' - | 'bg-color' - | 'gradient-from-pos' - | 'gradient-via-pos' - | 'gradient-to-pos' - | 'gradient-from' - | 'gradient-via' - | 'gradient-to' - | 'rounded' - | 'rounded-s' - | 'rounded-e' - | 'rounded-t' - | 'rounded-r' + | '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-l' - | 'rounded-ss' - | 'rounded-se' + | '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-br' - | 'rounded-bl' - | 'border-w' - | 'border-w-x' - | 'border-w-y' - | 'border-w-s' - | 'border-w-e' - | 'border-w-t' - | 'border-w-r' - | 'border-w-b' - | 'border-w-l' - | 'border-opacity' - | 'border-style' - | 'divide-x' - | 'divide-x-reverse' - | 'divide-y' - | 'divide-y-reverse' - | 'divide-opacity' - | 'divide-style' - | 'border-color' - | 'border-color-x' - | 'border-color-y' - | 'border-color-t' - | 'border-color-r' - | 'border-color-b' - | 'border-color-l' - | 'divide-color' - | 'outline-style' - | 'outline-offset' - | 'outline-w' - | 'outline-color' - | 'ring-w' - | 'ring-w-inset' - | 'ring-color' - | 'ring-opacity' - | 'ring-offset-w' - | 'ring-offset-color' - | 'shadow' - | 'shadow-color' - | 'opacity' - | 'mix-blend' - | 'bg-blend' - | 'filter' - | 'blur' - | 'brightness' - | 'contrast' - | 'drop-shadow' - | 'grayscale' - | 'hue-rotate' - | 'invert' + | 'rounded' + | 'row-end' + | 'row-start-end' + | 'row-start' | 'saturate' - | 'sepia' - | 'backdrop-filter' - | 'backdrop-blur' - | 'backdrop-brightness' - | 'backdrop-contrast' - | 'backdrop-grayscale' - | 'backdrop-hue-rotate' - | 'backdrop-invert' - | 'backdrop-opacity' - | 'backdrop-saturate' - | 'backdrop-sepia' - | 'border-collapse' - | 'border-spacing' - | 'border-spacing-x' - | 'border-spacing-y' - | 'table-layout' - | 'caption' - | 'transition' - | 'duration' - | 'ease' - | 'delay' - | 'animate' - | 'transform' - | 'scale' | 'scale-x' | 'scale-y' - | 'rotate' - | 'translate-x' - | 'translate-y' - | 'skew-x' - | 'skew-y' - | 'transform-origin' - | 'accent' - | 'appearance' - | 'cursor' - | 'caret-color' - | 'pointer-events' - | 'resize' + | 'scale' | 'scroll-behavior' | 'scroll-m' - | 'scroll-mx' - | 'scroll-my' - | 'scroll-ms' - | 'scroll-me' - | 'scroll-mt' - | 'scroll-mr' | 'scroll-mb' + | 'scroll-me' | 'scroll-ml' + | 'scroll-mr' + | 'scroll-ms' + | 'scroll-mt' + | 'scroll-mx' + | 'scroll-my' | 'scroll-p' - | 'scroll-px' - | 'scroll-py' - | 'scroll-ps' - | 'scroll-pe' - | 'scroll-pt' - | 'scroll-pr' | '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-type' | 'snap-strictness' - | 'touch' - | 'select' - | 'will-change' - | 'fill' + | 'snap-type' + | 'space-x-reverse' + | 'space-x' + | 'space-y-reverse' + | 'space-y' + | 'sr' + | 'start' | 'stroke-w' | 'stroke' - | 'sr' + | '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 From 346110d1bbe6fe0025a4c5bcdafb9e59171caede Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 18:17:07 +0200 Subject: [PATCH 09/15] add tests for type generics --- tests/merge-configs.test.ts | 4 +- tests/type-generics.test.ts | 172 ++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 tests/type-generics.test.ts diff --git a/tests/merge-configs.test.ts b/tests/merge-configs.test.ts index 7cfc1340..5e98ceb0 100644 --- a/tests/merge-configs.test.ts +++ b/tests/merge-configs.test.ts @@ -2,7 +2,7 @@ import { mergeConfigs } from '../src' test('mergeConfigs has correct behavior', () => { expect( - mergeConfigs( + mergeConfigs( { cacheSize: 50, prefix: 'tw-', @@ -37,7 +37,7 @@ test('mergeConfigs has correct behavior', () => { groupToOverride2: undefined!, }, conflictingClassGroups: { - toOverride: ['groupOverridden'] as const, + toOverride: ['groupOverridden'], }, conflictingClassGroupModifiers: { toOverride: ['overridden-2'], diff --git a/tests/type-generics.test.ts b/tests/type-generics.test.ts new file mode 100644 index 00000000..c0993b29 --- /dev/null +++ b/tests/type-generics.test.ts @@ -0,0 +1,172 @@ +import { extendTailwindMerge, fromTheme, getDefaultConfig, mergeConfigs } from '../src' + +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('') +}) + +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)) +}) From ea367b4c5b9164f29d1e0b61f246f5f426683706 Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 18:31:38 +0200 Subject: [PATCH 10/15] add documentation for type generics of fromTheme --- docs/api-reference.md | 22 ++++++++++++++++------ src/lib/from-theme.ts | 13 ++++--------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index dd8e0c42..7f24a316 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,34 @@ 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`. + +`fromTheme` can be used like this: ```ts -extendTailwindMerge({ +type MyClassGroupIds = 'my-group' | 'my-group-x' +type MyThemeGroupIds = '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')] }], }, }, }) diff --git a/src/lib/from-theme.ts b/src/lib/from-theme.ts index ba155ff7..92a5b0dd 100644 --- a/src/lib/from-theme.ts +++ b/src/lib/from-theme.ts @@ -1,15 +1,10 @@ -import { - DefaultThemeGroupIds as DefaultThemeGroupIdsOuter, - NoInfer, - ThemeGetter, - ThemeObject, -} from './types' +import { DefaultThemeGroupIds, NoInfer, ThemeGetter, ThemeObject } from './types' export function fromTheme< AdditionalThemeGroupIds extends string = never, - DefaultThemeGroupIds extends string = DefaultThemeGroupIdsOuter, ->(key: NoInfer): ThemeGetter { - const themeGetter = (theme: ThemeObject) => + DefaultThemeGroupIdsInner extends string = DefaultThemeGroupIds, +>(key: NoInfer): ThemeGetter { + const themeGetter = (theme: ThemeObject) => theme[key] || [] themeGetter.isThemeGetter = true as const From ca450e46059b713f5d776afacdc7cdca6650f090 Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 18:43:52 +0200 Subject: [PATCH 11/15] allow passing single generic argument to mergeConfigs --- src/lib/merge-configs.ts | 2 +- tests/type-generics.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/merge-configs.ts b/src/lib/merge-configs.ts index 8eb947a8..df2d765f 100644 --- a/src/lib/merge-configs.ts +++ b/src/lib/merge-configs.ts @@ -4,7 +4,7 @@ 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( +export function mergeConfigs( baseConfig: GenericConfig, { cacheSize, diff --git a/tests/type-generics.test.ts b/tests/type-generics.test.ts index c0993b29..07a46e59 100644 --- a/tests/type-generics.test.ts +++ b/tests/type-generics.test.ts @@ -169,4 +169,8 @@ test('mergeConfigs type generics work correctly', () => { const config2 = mergeConfigs<'very', 'strict'>(getDefaultConfig(), {}) expect(config2).toEqual(expect.any(Object)) + + const config3 = mergeConfigs<'single-arg'>(getDefaultConfig(), {}) + + expect(config3).toEqual(expect.any(Object)) }) From 24a9e6217760d46bc4a837394b14f222679ce37a Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 18:44:48 +0200 Subject: [PATCH 12/15] add docs for type generics of mergeConfigs --- docs/api-reference.md | 21 ++++++++++++++------- docs/writing-plugins.md | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 7f24a316..073b7a37 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -58,7 +58,7 @@ 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`. -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`. +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: @@ -251,14 +251,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 @@ -266,10 +271,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] }], + } }, }), ) 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'] }], From 17210c81767444af2d9b961c1a2aafb4611dbe06 Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 19:00:14 +0200 Subject: [PATCH 13/15] check for functional arguments to extendTailwindMerge in type generics tests --- tests/type-generics.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/type-generics.test.ts b/tests/type-generics.test.ts index 07a46e59..0ff25baa 100644 --- a/tests/type-generics.test.ts +++ b/tests/type-generics.test.ts @@ -1,4 +1,5 @@ import { extendTailwindMerge, fromTheme, getDefaultConfig, mergeConfigs } from '../src' +import { GenericConfig } from '../src/lib/types' test('extendTailwindMerge type generics work correctly', () => { const tailwindMerge1 = extendTailwindMerge({ @@ -65,6 +66,10 @@ test('extendTailwindMerge type generics work correctly', () => { }) expect(tailwindMerge2('')).toBe('') + + const tailwindMerge3 = extendTailwindMerge((v: GenericConfig) => v, getDefaultConfig) + + expect(tailwindMerge3('')).toBe('') }) test('fromTheme type generics work correctly', () => { From 45b4e80a8b8cd0225067d8758655340979170550 Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 19:00:47 +0200 Subject: [PATCH 14/15] add docs to type generics of extendTailwindMerge --- docs/api-reference.md | 43 +++++++++++++++++++++++++++++++------------ docs/configuration.md | 2 +- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 073b7a37..2eb3d235 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -63,19 +63,24 @@ When using TypeScript, the function only allows passing the default theme group `fromTheme` can be used like this: ```ts -type MyClassGroupIds = 'my-group' | 'my-group-x' -type MyThemeGroupIds = 'my-scale' +type AdditionalClassGroupIds = 'my-group' | 'my-group-x' +type AdditionalThemeGroupIds = 'my-scale' -extendTailwindMerge({ +extendTailwindMerge({ extend: { theme: { 'my-scale': ['foo', 'bar'], }, classGroups: { 'my-group': [ - { 'my-group': [fromTheme('my-scale'), fromTheme('spacing')] }, + { + 'my-group': [ + fromTheme('my-scale'), + fromTheme('spacing'), + ], + }, ], - 'my-group-x': [{ 'my-group-x': [fromTheme('my-scale')] }], + 'my-group-x': [{ 'my-group-x': [fromTheme('my-scale')] }], }, }, }) @@ -84,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. @@ -98,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, @@ -259,7 +278,7 @@ function mergeConfigs @@ -327,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: {}, From b1149379c670ce9e73e9a519b2e1c0f55989fca9 Mon Sep 17 00:00:00 2001 From: Dany Castillo <31006608+dcastil@users.noreply.github.com> Date: Sun, 20 Aug 2023 19:07:26 +0200 Subject: [PATCH 15/15] remove testing comment --- src/lib/types.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index 33df0e00..a2c1be35 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -128,9 +128,6 @@ export type DefaultThemeGroupIds = export type DefaultClassGroupIds = | 'accent' - /** - * Test test - */ | 'align-content' | 'align-items' | 'align-self'