├── .nvmrc ├── .husky ├── pre-commit └── commit-msg ├── .vscode └── settings.json ├── src ├── config.js ├── index.js ├── lite.d.ts ├── lite.js ├── __tests__ │ ├── utils.test.ts │ ├── createTV.test.ts │ ├── defaultConfig.test.ts │ ├── tv-no-twmerge.test.ts │ ├── cn.test.ts │ └── tv.test.ts ├── config.d.ts ├── state.js ├── utils.d.ts ├── tw-merge.js ├── utils.js ├── index.d.ts ├── types.d.ts └── core.js ├── .github ├── assets │ └── isotipo.png ├── workflows │ ├── ci.yml │ ├── commitlint.yml │ └── release.yml ├── actions │ └── install │ │ └── action.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ └── bug_report.md ├── .bumprc ├── .npmrc ├── .prettierignore ├── .editorconfig ├── .eslintignore ├── .prettierrc.json ├── tsup.config.js ├── jest.config.js ├── tsconfig.json ├── .commitlintrc.mjs ├── .gitignore ├── clean-package.config.json ├── copy-types.cjs ├── .lintstagedrc.mjs ├── setupTests.ts ├── LICENSE ├── MIGRATION-V3.md ├── RELEASING.md ├── MIGRATION-V2.md ├── .eslintrc.json ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── package.json ├── CHANGELOG.md ├── README.md └── benchmark.js /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.18.0 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged -c .lintstagedrc.mjs 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm commitlint --config .commitlintrc.mjs --edit ${1} 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export const defaultConfig = { 2 | twMerge: true, 3 | twMergeConfig: {}, 4 | }; 5 | -------------------------------------------------------------------------------- /.github/assets/isotipo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heroui-inc/tailwind-variants/HEAD/.github/assets/isotipo.png -------------------------------------------------------------------------------- /.bumprc: -------------------------------------------------------------------------------- 1 | { 2 | "commit": true, 3 | "confirm": true, 4 | "push": true, 5 | "tag": true, 6 | "release": "prompt", 7 | "all": true 8 | } -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | enable-pre-post-scripts=true 3 | lockfile=true 4 | save-exact=true 5 | strict-peer-dependencies=false 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | examples 3 | node_modules 4 | plop 5 | coverage 6 | build 7 | scripts 8 | pnpm-lock.yaml 9 | !.commitlintrc.cjs 10 | !.lintstagedrc.cjs 11 | !jest.config.js 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | max_line_length = 100 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | indent_style = space 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.css 2 | examples/* 3 | dist 4 | esm/* 5 | public/* 6 | tests/* 7 | scripts/* 8 | *.config.js 9 | .DS_Store 10 | node_modules 11 | build 12 | !.commitlintrc.cjs 13 | !.lintstagedrc.cjs 14 | !jest.config.js 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {getTailwindVariants} from "./core.js"; 2 | import {cn, cnMerge} from "./tw-merge.js"; 3 | import {cx} from "./utils.js"; 4 | import {defaultConfig} from "./config.js"; 5 | 6 | export const {createTV, tv} = getTailwindVariants(cnMerge); 7 | 8 | export {cn, cnMerge, cx, defaultConfig}; 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc.json", 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "semi": true, 6 | "useTabs": false, 7 | "singleQuote": false, 8 | "bracketSpacing": false, 9 | "endOfLine": "auto", 10 | "arrowParens": "always", 11 | "trailingComma": "all" 12 | } 13 | -------------------------------------------------------------------------------- /tsup.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.js", "src/lite.js", "src/utils.js"], 5 | format: ["cjs", "esm"], 6 | dts: false, 7 | clean: true, 8 | minify: false, 9 | treeshake: true, 10 | splitting: true, 11 | external: ["tailwind-merge"], 12 | }); 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | rootDir: "src", 3 | globals: { 4 | "ts-jest": { 5 | tsconfig: "tsconfig.json", 6 | }, 7 | }, 8 | transform: { 9 | "^.+\\.(t|j)sx?$": "@swc/jest", 10 | }, 11 | setupFilesAfterEnv: ["/../setupTests.ts"], 12 | extensionsToTreatAsEsm: [".ts"], 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /src/lite.d.ts: -------------------------------------------------------------------------------- 1 | import type {CnOptions, CnReturn, TVLite} from "./types.d.ts"; 2 | 3 | export type * from "./types.d.ts"; 4 | 5 | // util function 6 | export declare const cx: (...classes: T) => CnReturn; 7 | 8 | export declare const cn: (...classes: T) => (config?: any) => CnReturn; 9 | 10 | // main function 11 | export declare const tv: TVLite; 12 | 13 | export declare function createTV(): TVLite; 14 | -------------------------------------------------------------------------------- /src/lite.js: -------------------------------------------------------------------------------- 1 | import {cx} from "./utils.js"; 2 | import {getTailwindVariants} from "./core.js"; 3 | import {defaultConfig} from "./config.js"; 4 | 5 | export const cnAdapter = (...classnames) => { 6 | return (_config) => { 7 | const base = cx(classnames); 8 | 9 | return base || undefined; 10 | }; 11 | }; 12 | 13 | export const {createTV, tv} = getTailwindVariants(cnAdapter); 14 | 15 | export {cnAdapter as cn, cx, defaultConfig}; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "include": ["**/*.ts"], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "types": ["jest"], 7 | "target": "esnext", 8 | "module": "esnext", 9 | "isolatedModules": true, 10 | "esModuleInterop": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "strict": true, 14 | "exactOptionalPropertyTypes": true, 15 | "declaration": true, 16 | "allowJs": false, 17 | "outDir": "./dist" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | pnpm: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | script: [lint, test, build] 16 | steps: 17 | - name: Checkout Codebase 18 | uses: actions/checkout@v4 19 | 20 | - name: Install dependencies 21 | uses: ./.github/actions/install 22 | 23 | - name: Run Script ${{ matrix.script }} 24 | run: pnpm ${{ matrix.script }} 25 | -------------------------------------------------------------------------------- /.commitlintrc.mjs: -------------------------------------------------------------------------------- 1 | import conventional from "@commitlint/config-conventional"; 2 | 3 | 4 | const commitLintConfig = { 5 | extends: ["@commitlint/config-conventional"], 6 | plugins: ["commitlint-plugin-function-rules"], 7 | helpUrl: 8 | "https://github.com/jrgarciadev/tailwind-variants/blob/main/CONTRIBUTING.MD#commit-convention", 9 | rules: { 10 | ...conventional.rules, 11 | "type-enum": [ 12 | 2, 13 | "always", 14 | ["feat", "feature", "fix", "refactor", "docs", "build", "test", "ci", "chore"], 15 | ], 16 | "function-rules/header-max-length": [0], 17 | }, 18 | }; 19 | 20 | export default commitLintConfig; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | coverage 12 | 13 | 14 | # production 15 | /build 16 | dist/ 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .yarn-integrity 33 | .idea 34 | .now 35 | dist 36 | esm 37 | examples/**/yarn.lock 38 | examples/**/out 39 | examples/**/.next 40 | types 41 | 42 | # package 43 | tailwind-variants-*.tgz -------------------------------------------------------------------------------- /src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, describe, test} from "@jest/globals"; 2 | 3 | import {falsyToString} from "../utils"; 4 | 5 | describe("falsyToString", () => { 6 | test("should return a string when given a boolean", () => { 7 | expect(falsyToString(true)).toBe("true"); 8 | expect(falsyToString(false)).toBe("false"); 9 | }); 10 | 11 | test("should return 0 when given 0", () => { 12 | expect(falsyToString(0)).toBe("0"); 13 | }); 14 | 15 | test("should return the original value when given a value other than 0 or a boolean", () => { 16 | expect(falsyToString("test")).toBe("test"); 17 | expect(falsyToString(4)).toBe(4); 18 | expect(falsyToString(null)).toBe(null); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /clean-package.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "remove": ["tsup", "packageManager"], 3 | "replace": { 4 | "main": "./dist/index.cjs", 5 | "module": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "exports": { 8 | ".": { 9 | "types": "./dist/index.d.ts", 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs" 12 | }, 13 | "./lite": { 14 | "types": "./dist/lite.d.ts", 15 | "import": "./dist/lite.js", 16 | "require": "./dist/lite.cjs" 17 | }, 18 | "./utils": { 19 | "types": "./dist/utils.d.ts", 20 | "import": "./dist/utils.js", 21 | "require": "./dist/utils.cjs" 22 | }, 23 | "./dist/*": "./dist/*", 24 | "./package.json": "./package.json" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/config.d.ts: -------------------------------------------------------------------------------- 1 | import type {extendTailwindMerge} from "tailwind-merge"; 2 | 3 | type MergeConfig = Parameters[0]; 4 | type LegacyMergeConfig = Extract["extend"]; 5 | 6 | export type TWMergeConfig = MergeConfig & LegacyMergeConfig; 7 | 8 | export type TWMConfig = { 9 | /** 10 | * Whether to merge the class names with `tailwind-merge` library. 11 | * It's avoid to have duplicate tailwind classes. (Recommended) 12 | * @see https://github.com/dcastil/tailwind-merge/blob/v2.2.0/README.md 13 | * @default true 14 | */ 15 | twMerge?: boolean; 16 | /** 17 | * The config object for `tailwind-merge` library. 18 | * @see https://github.com/dcastil/tailwind-merge/blob/v2.2.0/docs/configuration.md 19 | */ 20 | twMergeConfig?: TWMergeConfig; 21 | }; 22 | 23 | export type TVConfig = TWMConfig; 24 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | name: commitlint 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | commitlint: 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest] 10 | runs-on: ${{ matrix.os }} 11 | environment: Preview 12 | 13 | steps: 14 | - name: Checkout codebase 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Install dependencies 20 | uses: ./.github/actions/install 21 | 22 | - name: Run commitlint 23 | id: run_commitlint 24 | uses: wagoid/commitlint-github-action@v6 25 | with: 26 | configFile: .commitlintrc.mjs 27 | env: 28 | NODE_PATH: ${{ github.workspace }}/node_modules 29 | 30 | - name: Show outputs 31 | if: ${{ always() }} 32 | run: echo ${{ toJSON(steps.run_commitlint.outputs.results) }} 33 | -------------------------------------------------------------------------------- /src/state.js: -------------------------------------------------------------------------------- 1 | function createState() { 2 | let cachedTwMerge = null; 3 | let cachedTwMergeConfig = {}; 4 | let didTwMergeConfigChange = false; 5 | 6 | return { 7 | get cachedTwMerge() { 8 | return cachedTwMerge; 9 | }, 10 | 11 | set cachedTwMerge(value) { 12 | cachedTwMerge = value; 13 | }, 14 | 15 | get cachedTwMergeConfig() { 16 | return cachedTwMergeConfig; 17 | }, 18 | 19 | set cachedTwMergeConfig(value) { 20 | cachedTwMergeConfig = value; 21 | }, 22 | 23 | get didTwMergeConfigChange() { 24 | return didTwMergeConfigChange; 25 | }, 26 | 27 | set didTwMergeConfigChange(value) { 28 | didTwMergeConfigChange = value; 29 | }, 30 | 31 | reset() { 32 | cachedTwMerge = null; 33 | cachedTwMergeConfig = {}; 34 | didTwMergeConfigChange = false; 35 | }, 36 | }; 37 | } 38 | 39 | const state = createState(); 40 | 41 | export {state}; 42 | -------------------------------------------------------------------------------- /.github/actions/install/action.yml: -------------------------------------------------------------------------------- 1 | name: "Install" 2 | description: "Sets up Node.js and runs install" 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Setup pnpm 8 | uses: pnpm/action-setup@v4 9 | with: 10 | run_install: false 11 | 12 | - name: Setup node 13 | uses: actions/setup-node@v4 14 | with: 15 | cache: "pnpm" 16 | check-latest: true 17 | node-version-file: ".nvmrc" 18 | 19 | - name: Get pnpm store directory 20 | shell: bash 21 | run: | 22 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 23 | 24 | - name: Setup pnpm cache 25 | uses: actions/cache@v4 26 | with: 27 | path: ${{ env.STORE_PATH }} 28 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 29 | restore-keys: | 30 | ${{ runner.os }}-pnpm-store- 31 | 32 | - name: Install dependencies 33 | shell: bash 34 | run: pnpm install --frozen-lockfile 35 | -------------------------------------------------------------------------------- /copy-types.cjs: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const srcPath = path.join(__dirname, "src"); 5 | const typesPath = path.join(__dirname, "dist"); 6 | 7 | // Check if the "types" folder exists, if not, create it 8 | if (!fs.existsSync(typesPath)) { 9 | fs.mkdirSync(typesPath); 10 | } 11 | 12 | // Read the files in the "src" folder 13 | fs.readdir(srcPath, (err, files) => { 14 | if (err) { 15 | console.error(err); 16 | 17 | return; 18 | } 19 | 20 | // Iterate through the files in the "src" folder 21 | files.forEach((file) => { 22 | // Check if the file is a .d.ts file 23 | if (file.endsWith(".d.ts")) { 24 | // Construct the source and destination file paths 25 | const srcFile = path.join(srcPath, file); 26 | const destFile = path.join(typesPath, file); 27 | 28 | // Copy the file 29 | fs.copyFile(srcFile, destFile, (err) => { 30 | if (err) { 31 | console.error(err); 32 | } 33 | }); 34 | } 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /.lintstagedrc.mjs: -------------------------------------------------------------------------------- 1 | import {relative} from "path"; 2 | 3 | import {ESLint} from "eslint"; 4 | 5 | const removeIgnoredFiles = async (files) => { 6 | const cwd = process.cwd(); 7 | const eslint = new ESLint(); 8 | const relativePaths = files.map((file) => relative(cwd, file)); 9 | const isIgnored = await Promise.all(relativePaths.map((file) => eslint.isPathIgnored(file))); 10 | const filteredFiles = files.filter((_, i) => !isIgnored[i]); 11 | 12 | return filteredFiles.join(" "); 13 | }; 14 | 15 | 16 | const lintStaged = { 17 | // *.!(js|ts|jsx|tsx|d.ts) 18 | "**/*.{js,cjs,mjs,ts,jsx,tsx,json,md}": async (files) => { 19 | const filesToLint = await removeIgnoredFiles(files); 20 | 21 | return [`prettier --config .prettierrc.json --ignore-path --write ${filesToLint}`]; 22 | }, 23 | "**/*.{js,cjs,mjs,ts,jsx,tsx}": async (files) => { 24 | const filesToLint = await removeIgnoredFiles(files); 25 | 26 | return [`eslint -c .eslintrc.json --max-warnings=0 --fix ${filesToLint}`]; 27 | }, 28 | }; 29 | 30 | export default lintStaged; 31 | -------------------------------------------------------------------------------- /src/utils.d.ts: -------------------------------------------------------------------------------- 1 | import type {CnOptions, CnReturn} from "./types.d.ts"; 2 | 3 | export declare const falsyToString: (value: T) => T | string; 4 | 5 | export declare const isEmptyObject: (obj: unknown) => boolean; 6 | 7 | export declare const flatArray: (array: unknown[]) => T[]; 8 | 9 | export declare const flatMergeArrays: (...arrays: unknown[][]) => T[]; 10 | 11 | export declare const mergeObjects: ( 12 | obj1: T, 13 | obj2: U, 14 | ) => Record; 15 | 16 | export declare const removeExtraSpaces: (str: string) => string; 17 | 18 | export declare const isEqual: (obj1: object, obj2: object) => boolean; 19 | 20 | export declare const isBoolean: (value: unknown) => boolean; 21 | 22 | export declare const joinObjects: < 23 | T extends Record, 24 | U extends Record, 25 | >( 26 | obj1: T, 27 | obj2: U, 28 | ) => T & U; 29 | 30 | export declare const flat: (arr: unknown[], target: T[]) => void; 31 | 32 | export declare const cx: (...classes: T) => CnReturn; 33 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Description 4 | 5 | 6 | 7 | ### Additional context 8 | 9 | 10 | 11 | --- 12 | 13 | ### What is the purpose of this pull request? 14 | 15 | 16 | 17 | - [ ] Bug fix 18 | - [ ] New Feature 19 | - [ ] Documentation update 20 | - [ ] Other 21 | 22 | ### Before submitting the PR, please make sure you do the following 23 | 24 | - [ ] Read the [Contributing Guidelines](https://github.com/jrgarciadev/tailwind-variants/blob/main/CONTRIBUTING.md). 25 | - [ ] Follow the [Style Guide](https://github.com/jrgarciadev/tailwind-variants/blob/main/CONTRIBUTING.md#style-guide). 26 | - [ ] Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate. 27 | - [ ] Provide a description in this PR that addresses **what** the PR is solving, or reference the issue that it solves (e.g. `fixes #123`). 28 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import {expect} from "@jest/globals"; 2 | 3 | function parseClasses(result: string | string[]) { 4 | return (typeof result === "string" ? result.split(" ") : result).slice().sort(); 5 | } 6 | 7 | expect.extend({ 8 | toHaveClass(received, expected) { 9 | expected = parseClasses(expected); 10 | received = parseClasses(received); 11 | 12 | return { 13 | pass: this.equals(expected, received) && expected.length === received.length, 14 | message: () => { 15 | return ( 16 | this.utils.matcherHint( 17 | `${this.isNot ? ".not" : ""}.toHaveClass`, 18 | "element", 19 | this.utils.printExpected(expected.join(" ")), 20 | ) + 21 | "\n\n" + 22 | this.utils.printDiffOrStringify( 23 | expected, 24 | received, 25 | "Expected", 26 | "Received", 27 | this.expand !== false, 28 | ) 29 | ); 30 | }, 31 | }; 32 | }, 33 | }); 34 | 35 | declare module "expect" { 36 | interface Matchers { 37 | toHaveClass(expected: string | string[]): R; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tailwid Variants 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | workflow_dispatch: # Allows manual triggering of the workflow 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set node 21 | uses: actions/setup-node@v4 22 | with: 23 | registry-url: https://registry.npmjs.org/ 24 | node-version: lts/* 25 | 26 | - run: npx changelogithub 27 | env: 28 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 29 | continue-on-error: true # Don't block the release, release notes can be added manually 30 | 31 | - name: Setup pnpm 32 | uses: pnpm/action-setup@v2 33 | 34 | - name: Install dependencies 35 | run: pnpm install --frozen-lockfile 36 | 37 | - name: Publish to npm 38 | run: | 39 | npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN 40 | npm publish 41 | env: 42 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} -------------------------------------------------------------------------------- /MIGRATION-V3.md: -------------------------------------------------------------------------------- 1 | # Migration Guide: v2 to v3 2 | 3 | ## Breaking Changes 4 | 5 | ### Introduction of `/lite` entry point (no `tailwind-merge`) 6 | 7 | In v3, `tailwind-variants` is now offered in two builds: 8 | 9 | - **Original build** – includes `tailwind-merge` (same as before) 10 | - **Lite build** – excludes `tailwind-merge` for a smaller bundle and faster runtime 11 | 12 | #### What changed? 13 | 14 | - `tailwind-merge` is no longer lazily loaded; it's statically included in the original build only 15 | - Lite build completely removes `tailwind-merge` and its config 16 | - `createTV`, `tv`, and `cn` in the lite build **no longer accept `config` (tailwind-merge config)** 17 | 18 | #### Migration Steps 19 | 20 | If you use the default configuration with `twMerge: true` (conflict resolution enabled), make sure to install `tailwind-merge` in your project: 21 | 22 | ```bash 23 | # npm 24 | npm install tailwind-merge 25 | 26 | # yarn 27 | yarn add tailwind-merge 28 | 29 | # pnpm 30 | pnpm add tailwind-merge 31 | ``` 32 | 33 | If you do not need conflict resolution, switch to the lite build by importing from `tailwind-variants/lite`: 34 | 35 | ```ts 36 | import {createTV, tv, cn, cx} from "tailwind-variants/lite"; 37 | ``` 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | 18 | 19 | **Describe the bug** 20 | A clear and concise description of what the bug is. 21 | 22 | **To Reproduce** 23 | Steps to reproduce the behavior: 24 | 25 | 1. Go to '...' 26 | 2. Click on '....' 27 | 3. Scroll down to '....' 28 | 4. See error 29 | 30 | **Expected behavior** 31 | A clear and concise description of what you expected to happen. 32 | 33 | **Screenshots** 34 | If applicable, add screenshots to help explain your problem. 35 | 36 | **Desktop (please complete the following information):** 37 | 38 | - OS: [e.g. iOS] 39 | - Browser [e.g. chrome, safari] 40 | - Version [e.g. 22] 41 | 42 | **Smartphone (please complete the following information):** 43 | 44 | - Device: [e.g. iPhone6] 45 | - OS: [e.g. iOS8.1] 46 | - Browser [e.g. stock browser, safari] 47 | - Version [e.g. 22] 48 | 49 | **Additional context** 50 | Add any other context about the problem here. 51 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | This project uses automated releases with `bumpp` and GitHub Actions. 4 | 5 | ## How to Release 6 | 7 | ### 1. Local Release (Recommended) 8 | 9 | Run the release script: 10 | 11 | ```bash 12 | pnpm run release 13 | ``` 14 | 15 | This will: 16 | 1. Prompt you to select the version bump (patch/minor/major) 17 | 2. Update the version in package.json 18 | 3. Generate/update CHANGELOG.md 19 | 4. Create a git commit with the version bump 20 | 5. Create a git tag 21 | 6. Push the commit and tag to GitHub 22 | 7. The GitHub Action will automatically publish to npm 23 | 24 | ### 2. Manual Workflow Trigger 25 | 26 | You can also trigger the release workflow manually from GitHub: 27 | 28 | 1. Go to Actions → Release workflow 29 | 2. Click "Run workflow" 30 | 3. This will publish the current version to npm 31 | 32 | ## Prerequisites 33 | 34 | - You must have push access to the repository 35 | - The repository must have the `NPM_TOKEN` secret configured 36 | - Conventional commits are recommended for better changelog generation 37 | 38 | ## Version Bump Guidelines 39 | 40 | - **Patch** (x.x.1): Bug fixes, documentation updates 41 | - **Minor** (x.1.0): New features, non-breaking changes 42 | - **Major** (1.0.0): Breaking changes 43 | 44 | ## Troubleshooting 45 | 46 | If the release fails: 47 | 48 | 1. Check GitHub Actions logs 49 | 2. Ensure NPM_TOKEN is valid 50 | 3. Verify you have proper permissions 51 | 4. Make sure all tests pass before releasing -------------------------------------------------------------------------------- /src/__tests__/createTV.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, describe, test} from "@jest/globals"; 2 | 3 | import {createTV as createTVFull} from "../index"; 4 | import {createTV as createTVLite} from "../lite"; 5 | 6 | const variants = [ 7 | {name: "full - tailwind-merge", createTV: createTVFull, mode: "full"}, 8 | {name: "lite - without tailwind-merge", createTV: createTVLite, mode: "lite"}, 9 | ]; 10 | 11 | describe.each(variants)("createTV - $name", ({createTV, mode}) => { 12 | test("should respect twMerge config when creating tv instance", () => { 13 | const tv = createTV({twMerge: false}); 14 | const h1 = tv({ 15 | base: "text-3xl font-bold text-blue-400 text-xl text-blue-200", 16 | }); 17 | 18 | // twMerge disabled: classes should not be merged or overridden 19 | expect(h1()).toHaveClass("text-3xl font-bold text-blue-400 text-xl text-blue-200"); 20 | }); 21 | 22 | test("should override twMerge config on tv call", () => { 23 | const tv = createTV({twMerge: false}); 24 | const h1 = tv( 25 | {base: "text-3xl font-bold text-blue-400 text-xl text-blue-200"}, 26 | {twMerge: true}, 27 | ); 28 | 29 | // twMerge enabled on full mode merges conflicting classes 30 | // lite mode does not support merging, returns original classes 31 | const expected = 32 | mode === "lite" 33 | ? "text-3xl font-bold text-blue-400 text-xl text-blue-200" 34 | : "font-bold text-xl text-blue-200"; 35 | 36 | expect(h1()).toHaveClass(expected); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /MIGRATION-V2.md: -------------------------------------------------------------------------------- 1 | # Migration Guide: v1 to v2 2 | 3 | ## Breaking Changes 4 | 5 | ### tailwind-merge is now an optional peer dependency 6 | 7 | In v2, we've made `tailwind-merge` an optional peer dependency to reduce bundle size for users who don't need Tailwind CSS conflict resolution. 8 | 9 | #### What changed? 10 | 11 | - `tailwind-merge` is no longer bundled with tailwind-variants 12 | - Users who want conflict resolution must install it separately 13 | - Users who don't need conflict resolution can save ~3KB in bundle size 14 | 15 | #### Migration Steps 16 | 17 | If you use the default configuration with `twMerge: true` (conflict resolution enabled): 18 | 19 | ```bash 20 | # npm 21 | npm install tailwind-merge 22 | 23 | # yarn 24 | yarn add tailwind-merge 25 | 26 | # pnpm 27 | pnpm add tailwind-merge 28 | ``` 29 | 30 | If you don't need conflict resolution, disable it in your config: 31 | 32 | ```js 33 | const button = tv( 34 | { 35 | base: "px-4 py-2 rounded", 36 | variants: { 37 | color: { 38 | primary: "bg-blue-500 text-white", 39 | secondary: "bg-gray-500 text-white", 40 | }, 41 | }, 42 | }, 43 | { 44 | twMerge: false, // Disable conflict resolution 45 | }, 46 | ); 47 | ``` 48 | 49 | ## Performance Improvements 50 | 51 | v2 also includes significant performance optimizations: 52 | 53 | - **37-62% faster** for most operations 54 | - Optimized object creation and array operations 55 | - Reduced function call overhead 56 | - Better memory usage 57 | 58 | All existing APIs remain the same, so no code changes are required beyond the tailwind-merge installation. 59 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc.json", 3 | "env": { 4 | "browser": false, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "extends": ["plugin:prettier/recommended"], 9 | "plugins": ["prettier", "import", "@typescript-eslint"], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaFeatures": { 13 | "jsx": true 14 | }, 15 | "ecmaVersion": 12, 16 | "sourceType": "module" 17 | }, 18 | "settings": { 19 | "react": { 20 | "version": "detect" 21 | } 22 | }, 23 | "overrides": [ 24 | { 25 | "files": ["*/__tests__/**"], 26 | "plugins": ["jest"], 27 | "extends": ["plugin:jest/recommended"] 28 | } 29 | ], 30 | "rules": { 31 | "no-console": "warn", 32 | "prettier/prettier": "warn", 33 | "@typescript-eslint/no-unused-vars": [ 34 | "warn", 35 | { 36 | "args": "after-used", 37 | "ignoreRestSiblings": false, 38 | "argsIgnorePattern": "^_.*?$" 39 | } 40 | ], 41 | "import/order": [ 42 | "warn", 43 | { 44 | "groups": [ 45 | "type", 46 | "builtin", 47 | "object", 48 | "external", 49 | "internal", 50 | "parent", 51 | "sibling", 52 | "index" 53 | ], 54 | "pathGroups": [ 55 | { 56 | "pattern": "~/**", 57 | "group": "external", 58 | "position": "after" 59 | } 60 | ], 61 | "newlines-between": "always" 62 | } 63 | ], 64 | "padding-line-between-statements": [ 65 | "warn", 66 | {"blankLine": "always", "prev": "*", "next": "return"}, 67 | {"blankLine": "always", "prev": ["const", "let", "var"], "next": "*"}, 68 | { 69 | "blankLine": "any", 70 | "prev": ["const", "let", "var"], 71 | "next": ["const", "let", "var"] 72 | } 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/tw-merge.js: -------------------------------------------------------------------------------- 1 | import {twMerge as twMergeBase, extendTailwindMerge} from "tailwind-merge"; 2 | 3 | import {isEmptyObject, cx} from "./utils.js"; 4 | import {state} from "./state.js"; 5 | 6 | export const createTwMerge = (cachedTwMergeConfig) => { 7 | return isEmptyObject(cachedTwMergeConfig) 8 | ? twMergeBase 9 | : extendTailwindMerge({ 10 | ...cachedTwMergeConfig, 11 | extend: { 12 | theme: cachedTwMergeConfig.theme, 13 | classGroups: cachedTwMergeConfig.classGroups, 14 | conflictingClassGroupModifiers: cachedTwMergeConfig.conflictingClassGroupModifiers, 15 | conflictingClassGroups: cachedTwMergeConfig.conflictingClassGroups, 16 | ...cachedTwMergeConfig.extend, 17 | }, 18 | }); 19 | }; 20 | 21 | const executeMerge = (classnames, config) => { 22 | const base = cx(classnames); 23 | 24 | if (!base || !(config?.twMerge ?? true)) return base; 25 | 26 | if (!state.cachedTwMerge || state.didTwMergeConfigChange) { 27 | state.didTwMergeConfigChange = false; 28 | 29 | state.cachedTwMerge = createTwMerge(state.cachedTwMergeConfig); 30 | } 31 | 32 | return state.cachedTwMerge(base) || undefined; 33 | }; 34 | 35 | /** 36 | * Combines class names and merges conflicting Tailwind CSS classes using `tailwind-merge`. 37 | * Uses default twMerge config. For custom config, use `cnMerge` instead. 38 | * @param classnames - Class names to combine (strings, arrays, objects, etc.) 39 | * @returns A merged class string, or `undefined` if no valid classes are provided 40 | */ 41 | export const cn = (...classnames) => { 42 | return executeMerge(classnames, {}); 43 | }; 44 | 45 | /** 46 | * Combines class names and merges conflicting Tailwind CSS classes using `tailwind-merge`. 47 | * Supports custom twMerge config via the second function call. 48 | * @param classnames - Class names to combine (strings, arrays, objects, etc.) 49 | * @returns A function that accepts optional twMerge config and returns the merged class string 50 | * @example 51 | * ```ts 52 | * cnMerge('bg-red-500', 'bg-blue-500')({twMerge: true}) // => 'bg-blue-500' 53 | * cnMerge('px-2', 'px-4')({twMerge: false}) // => 'px-2 px-4' 54 | * ``` 55 | */ 56 | export const cnMerge = (...classnames) => { 57 | return (config) => executeMerge(classnames, config); 58 | }; 59 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome, and thanks for your interest in contributing! Please take a moment to review the following: 4 | 5 | ## Style Guide 6 | 7 | - **Commits** follow the ["Conventional Commits" specification](https://www.conventionalcommits.org/en/v1.0.0/). This allows for changelogs to be generated automatically upon release. 8 | - **Code** is formatted via [Prettier](https://prettier.io/) 9 | - **JavaScript** is written as [TypeScript](https://www.typescriptlang.org/) where possible. 10 | 11 | ## Getting Started 12 | 13 | ### Setup 14 | 15 | 1. [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo) and clone to your machine. 16 | 2. Create a new branch with your contribution. 17 | 3. Install [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) on your machine. 18 | 4. In the repo, install dependencies via: 19 | ```sh 20 | pnpm i 21 | ``` 22 | 5. Voilà, you're ready to go! 23 | 24 | ### Scripts 25 | 26 | - `pnpm build` – production build 27 | - `pnpm check` – type checks 28 | - `pnpm test` – runs jest, watching for file changes 29 | 30 | ### Commit Convention 31 | 32 | Before you create a Pull Request, please check whether your commits comply with 33 | the commit conventions used in this repository. 34 | 35 | When you create a commit we kindly ask you to follow the convention 36 | `category(scope or module): message` in your commit message while using one of 37 | the following categories: 38 | 39 | - `feat / feature`: all changes that introduce completely new code or new 40 | features 41 | - `fix`: changes that fix a bug (ideally you will additionally reference an 42 | issue if present) 43 | - `refactor`: any code related change that is not a fix nor a feature 44 | - `build`: all changes regarding the build of the software, changes to 45 | dependencies or the addition of new dependencies 46 | - `test`: all changes regarding tests (adding new tests or changing existing 47 | ones) 48 | - `ci`: all changes regarding the configuration of continuous integration (i.e. 49 | github actions, ci system) 50 | - `chore`: all changes to the repository that do not fit into any of the above 51 | categories 52 | 53 | e.g. `feat(components): add new prop to the avatar component` 54 | 55 | If you are interested in the detailed specification you can visit 56 | https://www.conventionalcommits.org/ or check out the 57 | [Angular Commit Message Guidelines](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines). 58 | 59 | ## Releases 60 | 61 | A trade-off with using a personal repo is that permissions are fairly locked-down. In the mean-time releases will be made manually by the project owner. 62 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jrgarciadev@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwind-variants", 3 | "version": "3.2.2", 4 | "description": "🦄 Tailwindcss first-class variant API", 5 | "keywords": [ 6 | "tailwindcss", 7 | "classes", 8 | "responsive", 9 | "variants", 10 | "styled", 11 | "styles" 12 | ], 13 | "author": "Junior Garcia ", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/heroui-inc/tailwind-variants" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/heroui-inc/tailwind-variants/issues" 21 | }, 22 | "type": "module", 23 | "main": "./dist/index.cjs", 24 | "module": "./dist/index.js", 25 | "types": "./dist/index.d.ts", 26 | "exports": { 27 | ".": { 28 | "types": "./dist/index.d.ts", 29 | "import": "./dist/index.js", 30 | "require": "./dist/index.cjs" 31 | }, 32 | "./lite": { 33 | "types": "./dist/lite.d.ts", 34 | "import": "./dist/lite.js", 35 | "require": "./dist/lite.cjs" 36 | }, 37 | "./utils": { 38 | "types": "./dist/utils.d.ts", 39 | "import": "./dist/utils.js", 40 | "require": "./dist/utils.cjs" 41 | }, 42 | "./dist/*": "./dist/*", 43 | "./package.json": "./package.json" 44 | }, 45 | "sideEffects": false, 46 | "files": [ 47 | "dist", 48 | "README.md", 49 | "LICENSE" 50 | ], 51 | "publishConfig": { 52 | "access": "public" 53 | }, 54 | "scripts": { 55 | "dev": "tsup --watch", 56 | "build": "tsup && node copy-types.cjs", 57 | "typecheck": "tsc --noEmit", 58 | "prepack": "clean-package", 59 | "benchmark": "node benchmark.js", 60 | "postpack": "clean-package restore", 61 | "lint": "eslint . src/**/*.{js,ts}", 62 | "lint:fix": "eslint --fix . src/**/*.{js,ts}", 63 | "test": "jest --verbose", 64 | "test:watch": "jest --watch --no-verbose", 65 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s --commit-path .", 66 | "release": "bumpp --execute='pnpm run changelog' --all", 67 | "prepublishOnly": "pnpm run build", 68 | "release:check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build" 69 | }, 70 | "devDependencies": { 71 | "@commitlint/cli": "19.5.0", 72 | "@commitlint/config-conventional": "19.5.0", 73 | "@jest/globals": "29.7.0", 74 | "@swc-node/jest": "1.8.12", 75 | "@swc/cli": "0.5.0", 76 | "@swc/core": "1.9.2", 77 | "@swc/helpers": "0.5.15", 78 | "@swc/jest": "0.2.37", 79 | "@types/jest": "29.5.14", 80 | "@types/node": "22.9.0", 81 | "@typescript-eslint/eslint-plugin": "8.14.0", 82 | "@typescript-eslint/parser": "8.14.0", 83 | "benchmark": "2.1.4", 84 | "bumpp": "10.2.0", 85 | "changelogithub": "13.16.0", 86 | "class-variance-authority": "0.7.0", 87 | "clean-package": "2.2.0", 88 | "conventional-changelog-cli": "5.0.0", 89 | "eslint": "8.57.0", 90 | "eslint-config-prettier": "9.1.0", 91 | "eslint-config-ts-lambdas": "1.2.3", 92 | "eslint-import-resolver-typescript": "3.6.3", 93 | "eslint-plugin-import": "2.31.0", 94 | "eslint-plugin-jest": "28.9.0", 95 | "eslint-plugin-node": "11.1.0", 96 | "eslint-plugin-prettier": "5.2.1", 97 | "eslint-plugin-promise": "7.1.0", 98 | "expect": "29.7.0", 99 | "jest": "29.7.0", 100 | "postcss": "8.5.6", 101 | "prettier": "3.3.3", 102 | "prettier-eslint": "16.3.0", 103 | "prettier-eslint-cli": "8.0.1", 104 | "tailwindcss": "4.1.11", 105 | "ts-node": "10.9.2", 106 | "tsup": "8.5.0", 107 | "typescript": "5.6.3" 108 | }, 109 | "peerDependencies": { 110 | "tailwind-merge": ">=3.0.0", 111 | "tailwindcss": "*" 112 | }, 113 | "peerDependenciesMeta": { 114 | "tailwind-merge": { 115 | "optional": true 116 | } 117 | }, 118 | "engines": { 119 | "node": ">=16.x", 120 | "pnpm": ">=7.x" 121 | }, 122 | "packageManager": "pnpm@10.14.0" 123 | } 124 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const SPACE_REGEX = /\s+/g; 2 | 3 | export const removeExtraSpaces = (str) => { 4 | if (typeof str !== "string" || !str) return str; 5 | 6 | return str.replace(SPACE_REGEX, " ").trim(); 7 | }; 8 | 9 | // cx - simple class name concatenation (like clsx) 10 | export const cx = (...classnames) => { 11 | const classList = []; 12 | 13 | // recursively process input 14 | const buildClassString = (input) => { 15 | // skip null, undefined, or invalid numbers 16 | if (!input && input !== 0 && input !== 0n) return; 17 | 18 | if (Array.isArray(input)) { 19 | // handle array elements 20 | for (let i = 0, len = input.length; i < len; i++) buildClassString(input[i]); 21 | 22 | return; 23 | } 24 | 25 | const type = typeof input; 26 | 27 | if (type === "string" || type === "number" || type === "bigint") { 28 | // skip nan 29 | if (type === "number" && input !== input) return; 30 | classList.push(String(input)); // add to class list 31 | } else if (type === "object") { 32 | // add keys with truthy values 33 | const keys = Object.keys(input); 34 | 35 | for (let i = 0, len = keys.length; i < len; i++) { 36 | const key = keys[i]; 37 | 38 | if (input[key]) classList.push(key); 39 | } 40 | } 41 | }; 42 | 43 | // process all args 44 | for (let i = 0, len = classnames.length; i < len; i++) { 45 | const c = classnames[i]; 46 | 47 | if (c !== null && c !== undefined) buildClassString(c); 48 | } 49 | 50 | // join classes and remove extra spaces 51 | return classList.length > 0 ? removeExtraSpaces(classList.join(" ")) : undefined; 52 | }; 53 | 54 | export const falsyToString = (value) => 55 | value === false ? "false" : value === true ? "true" : value === 0 ? "0" : value; 56 | 57 | export const isEmptyObject = (obj) => { 58 | if (!obj || typeof obj !== "object") return true; 59 | for (const _ in obj) return false; 60 | 61 | return true; 62 | }; 63 | 64 | export const isEqual = (obj1, obj2) => { 65 | if (obj1 === obj2) return true; 66 | if (!obj1 || !obj2) return false; 67 | 68 | const keys1 = Object.keys(obj1); 69 | const keys2 = Object.keys(obj2); 70 | 71 | if (keys1.length !== keys2.length) return false; 72 | 73 | for (let i = 0; i < keys1.length; i++) { 74 | const key = keys1[i]; 75 | 76 | if (!keys2.includes(key)) return false; 77 | if (obj1[key] !== obj2[key]) return false; 78 | } 79 | 80 | return true; 81 | }; 82 | 83 | export const isBoolean = (value) => value === true || value === false; 84 | 85 | export const joinObjects = (obj1, obj2) => { 86 | for (const key in obj2) { 87 | if (Object.prototype.hasOwnProperty.call(obj2, key)) { 88 | const val2 = obj2[key]; 89 | 90 | if (key in obj1) { 91 | obj1[key] = cx(obj1[key], val2); 92 | } else { 93 | obj1[key] = val2; 94 | } 95 | } 96 | } 97 | 98 | return obj1; 99 | }; 100 | 101 | export const flat = (arr, target) => { 102 | for (let i = 0; i < arr.length; i++) { 103 | const el = arr[i]; 104 | 105 | if (Array.isArray(el)) flat(el, target); 106 | else if (el) target.push(el); 107 | } 108 | }; 109 | 110 | export function flatArray(arr) { 111 | const flattened = []; 112 | 113 | flat(arr, flattened); 114 | 115 | return flattened; 116 | } 117 | 118 | export const flatMergeArrays = (...arrays) => { 119 | const result = []; 120 | 121 | flat(arrays, result); 122 | const filtered = []; 123 | 124 | for (let i = 0; i < result.length; i++) { 125 | if (result[i]) filtered.push(result[i]); 126 | } 127 | 128 | return filtered; 129 | }; 130 | 131 | export const mergeObjects = (obj1, obj2) => { 132 | const result = {}; 133 | 134 | for (const key in obj1) { 135 | const val1 = obj1[key]; 136 | 137 | if (key in obj2) { 138 | const val2 = obj2[key]; 139 | 140 | if (Array.isArray(val1) || Array.isArray(val2)) { 141 | result[key] = flatMergeArrays(val2, val1); 142 | } else if (typeof val1 === "object" && typeof val2 === "object" && val1 && val2) { 143 | result[key] = mergeObjects(val1, val2); 144 | } else { 145 | result[key] = val2 + " " + val1; 146 | } 147 | } else { 148 | result[key] = val1; 149 | } 150 | } 151 | 152 | for (const key in obj2) { 153 | if (!(key in obj1)) { 154 | result[key] = obj2[key]; 155 | } 156 | } 157 | 158 | return result; 159 | }; 160 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [3.2.2](https://github.com/heroui-inc/tailwind-variants/compare/v3.2.1...v3.2.2) (2025-11-22) 2 | 3 | 4 | 5 | ## [3.2.1](https://github.com/heroui-inc/tailwind-variants/compare/v3.2.0...v3.2.1) (2025-11-22) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * update cn function type and import cx from tailwind-variants/lite ([#285](https://github.com/heroui-inc/tailwind-variants/issues/285)) ([3a3afce](https://github.com/heroui-inc/tailwind-variants/commit/3a3afce7888a5f7594d4b6a206796bf7b5ac0a8d)) 11 | 12 | 13 | 14 | # [3.2.0](https://github.com/heroui-inc/tailwind-variants/compare/v3.1.1...v3.2.0) (2025-11-22) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * export defaultConfig as value and remove responsiveVariants ([#284](https://github.com/heroui-inc/tailwind-variants/issues/284)) ([65ee73c](https://github.com/heroui-inc/tailwind-variants/commit/65ee73cc80eb1813d582ede6091f849fa572317e)) 20 | * make twMerge default to true in cn function ([#283](https://github.com/heroui-inc/tailwind-variants/issues/283)) ([1659aa7](https://github.com/heroui-inc/tailwind-variants/commit/1659aa7acccdc0a2ccdd4597a54c988866ca1d64)) 21 | * no longer minifyng the code ([#282](https://github.com/heroui-inc/tailwind-variants/issues/282)) ([34c62f4](https://github.com/heroui-inc/tailwind-variants/commit/34c62f48a72680b2cde9d06d9e1e64520db0c55b)) 22 | 23 | 24 | ### Features 25 | 26 | * add cx function and refactor cn to use tailwind-merge ([#278](https://github.com/heroui-inc/tailwind-variants/issues/278)) ([8ec5f6f](https://github.com/heroui-inc/tailwind-variants/commit/8ec5f6fbd0c808675838fb71a6e32e8a570159cf)) 27 | 28 | 29 | 30 | ## [3.1.1](https://github.com/heroui-inc/tailwind-variants/compare/v3.1.0...v3.1.1) (2025-09-08) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * use 'type' for type-only imports and specify file extensions ([#272](https://github.com/heroui-inc/tailwind-variants/issues/272)) ([58aa71e](https://github.com/heroui-inc/tailwind-variants/commit/58aa71eaf1e9d9cf4954fad786b3b8e9e36775ca)) 36 | 37 | 38 | 39 | # [3.1.0](https://github.com/heroui-inc/tailwind-variants/compare/v3.0.0...v3.1.0) (2025-08-25) 40 | 41 | 42 | ### Features 43 | 44 | * export config types ([#267](https://github.com/heroui-inc/tailwind-variants/issues/267)) ([5fd06fa](https://github.com/heroui-inc/tailwind-variants/commit/5fd06face1211a63b85b782f8948bb543ef66c9b)) 45 | 46 | 47 | 48 | # [3.0.0](https://github.com/heroui-inc/tailwind-variants/compare/v2.1.0...v3.0.0) (2025-08-24) 49 | 50 | 51 | ### Features 52 | 53 | * split tv into original and lite versions ([#264](https://github.com/heroui-inc/tailwind-variants/issues/264)) ([0eb65ba](https://github.com/heroui-inc/tailwind-variants/commit/0eb65bab81842f27dc9fc09c04f12eb2b5584cc9)) 54 | 55 | 56 | 57 | # [2.1.0](https://github.com/heroui-inc/tailwind-variants/compare/v2.0.1...v2.1.0) (2025-07-31) 58 | 59 | 60 | ### Features 61 | 62 | * implement lazy loading for tailwind-merge module ([#257](https://github.com/heroui-inc/tailwind-variants/issues/257)) ([e80c23a](https://github.com/heroui-inc/tailwind-variants/commit/e80c23a4b585936f7b5fca2c5c383b8ddaa7d405)) 63 | 64 | 65 | 66 | ## [2.0.1](https://github.com/heroui-inc/tailwind-variants/compare/v2.0.0...v2.0.1) (2025-07-28) 67 | 68 | 69 | 70 | # [2.0.0](https://github.com/heroui-inc/tailwind-variants/compare/v1.0.0...v2.0.0) (2025-07-27) 71 | 72 | 73 | 74 | # [2.0.0](https://github.com/heroui-inc/tailwind-variants/compare/v1.0.0...v2.0.0) (2025-07-27) 75 | 76 | 77 | 78 | # Changelog 79 | 80 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 81 | 82 | ## [2.0.0](https://github.com/heroui-inc/tailwind-variants/compare/v1.1.0...v2.0.0) (2025-07-27) 83 | 84 | ### ⚠ BREAKING CHANGES 85 | 86 | * **deps:** tailwind-merge is now an optional peer dependency. Users who want Tailwind CSS conflict resolution must install it separately: 87 | ```bash 88 | npm install tailwind-merge 89 | ``` 90 | 91 | ### Features 92 | 93 | * **performance:** Significant performance optimizations (37-62% faster for most operations) 94 | * **bundle:** Reduced bundle size from 5.8KB to 5.2KB (10% smaller) 95 | * **deps:** Made tailwind-merge an optional peer dependency 96 | 97 | ### Performance Improvements 98 | 99 | * Replaced array methods with for loops for better performance 100 | * Optimized object property checks using `in` operator 101 | * Improved `isEmptyObject` implementation 102 | * Better `isEqual` implementation without JSON.stringify 103 | * Reduced object allocations and temporary variables 104 | * Cached regex patterns 105 | * Streamlined string operations 106 | 107 | For migration instructions, see [MIGRATION-V2.md](./MIGRATION-V2.md) -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import type {TVConfig, TWMConfig, TWMergeConfig} from "./config.d.ts"; 2 | import type {CnOptions, CnReturn, TV} from "./types.d.ts"; 3 | 4 | export type * from "./types.d.ts"; 5 | 6 | /** 7 | * Combines class names into a single string. Similar to `clsx` - simple concatenation without merging conflicting classes. 8 | * @param classes - Class names to combine. Accepts strings, numbers, arrays, objects, and nested structures. 9 | * @returns A space-separated string of class names, or `undefined` if no valid classes are provided 10 | * @example 11 | * ```ts 12 | * // Simple concatenation 13 | * cx('text-xl', 'font-bold') // => 'text-xl font-bold' 14 | * 15 | * // Handles arrays and objects 16 | * cx(['px-4', 'py-2'], { 'bg-blue-500': true, 'text-white': false }) // => 'px-4 py-2 bg-blue-500' 17 | * 18 | * // Ignores falsy values (except 0) 19 | * cx('text-xl', false && 'font-bold', null, undefined) // => 'text-xl' 20 | * ``` 21 | */ 22 | export declare const cx: (...classes: T) => CnReturn; 23 | 24 | /** 25 | * Combines class names and merges conflicting Tailwind CSS classes using `tailwind-merge`. 26 | * Uses default twMerge config. For custom config, use `cnMerge` instead. 27 | * @param classes - Class names to combine (strings, arrays, objects, etc.) 28 | * @returns A merged class string, or `undefined` if no valid classes are provided 29 | * @example 30 | * ```ts 31 | * // Simple usage with default twMerge config 32 | * cn('bg-red-500', 'bg-blue-500') // => 'bg-blue-500' 33 | * cn('px-2', 'px-4', 'py-2') // => 'px-4 py-2' 34 | * 35 | * // For custom twMerge config, use cnMerge instead 36 | * cnMerge('px-2', 'px-4')({twMerge: false}) // => 'px-2 px-4' 37 | * ``` 38 | */ 39 | export declare const cn: (...classes: T) => CnReturn; 40 | 41 | /** 42 | * Combines class names and merges conflicting Tailwind CSS classes using `tailwind-merge`. 43 | * Supports custom twMerge config via the second function call. 44 | * @param classes - Class names to combine (strings, arrays, objects, etc.) 45 | * @returns A function that accepts optional twMerge config and returns the merged class string 46 | * @example 47 | * ```ts 48 | * // With custom config 49 | * cnMerge('bg-red-500', 'bg-blue-500')({twMerge: true}) // => 'bg-blue-500' 50 | * cnMerge('px-2', 'px-4')({twMerge: false}) // => 'px-2 px-4' 51 | * 52 | * // With twMergeConfig 53 | * cnMerge('px-2', 'px-4')({twMergeConfig: {...}}) // => merged with custom config 54 | * ``` 55 | */ 56 | export declare const cnMerge: ( 57 | ...classes: T 58 | ) => (config?: TWMConfig) => CnReturn; 59 | 60 | /** 61 | * Creates a variant-aware component function with Tailwind CSS classes. 62 | * Supports variants, slots, compound variants, and component composition. 63 | * @example 64 | * ```ts 65 | * const button = tv({ 66 | * base: "font-medium rounded-full", 67 | * variants: { 68 | * color: { 69 | * primary: "bg-blue-500 text-white", 70 | * secondary: "bg-purple-500 text-white", 71 | * }, 72 | * size: { 73 | * sm: "text-sm px-3 py-1", 74 | * md: "text-base px-4 py-2", 75 | * }, 76 | * }, 77 | * defaultVariants: { 78 | * color: "primary", 79 | * size: "md", 80 | * }, 81 | * }); 82 | * 83 | * button({ color: "secondary", size: "sm" }) // => 'font-medium rounded-full bg-purple-500 text-white text-sm px-3 py-1' 84 | * ``` 85 | * @see https://www.tailwind-variants.org/docs/getting-started 86 | */ 87 | export declare const tv: TV; 88 | 89 | /** 90 | * Creates a configured `tv` instance with custom default configuration. 91 | * Useful when you want to set default `twMerge` or `twMergeConfig` options for all components. 92 | * @param config - Configuration object with default settings for `twMerge` and `twMergeConfig` 93 | * @returns A configured `tv` function that uses the provided defaults 94 | * @example 95 | * ```ts 96 | * // Create a tv instance with twMerge disabled by default 97 | * const tv = createTV({ twMerge: false }); 98 | * 99 | * const button = tv({ 100 | * base: "px-4 py-2", 101 | * variants: { 102 | * color: { 103 | * primary: "bg-blue-500", 104 | * }, 105 | * }, 106 | * }); 107 | * 108 | * // Can still override config per component 109 | * const buttonWithMerge = tv( 110 | * { 111 | * base: "px-4 py-2", 112 | * variants: { color: { primary: "bg-blue-500" } }, 113 | * }, 114 | * { twMerge: true } 115 | * ); 116 | * ``` 117 | */ 118 | export declare function createTV(config: TVConfig): TV; 119 | 120 | /** 121 | * Default configuration object for tailwind-variants. 122 | * Can be modified to set global defaults for all components. 123 | * @example 124 | * ```ts 125 | * import { defaultConfig } from "tailwind-variants"; 126 | * 127 | * defaultConfig.twMergeConfig = { 128 | * extend: { 129 | * theme: { 130 | * spacing: ["medium", "large"], 131 | * }, 132 | * }, 133 | * }; 134 | * ``` 135 | */ 136 | export declare const defaultConfig: TVConfig; 137 | 138 | // types 139 | export type {TVConfig, TWMConfig, TWMergeConfig}; 140 | -------------------------------------------------------------------------------- /src/__tests__/defaultConfig.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, describe, test, beforeEach, afterEach} from "@jest/globals"; 2 | 3 | import {defaultConfig, tv, createTV} from "../index"; 4 | 5 | describe("defaultConfig", () => { 6 | // Store original values to restore after each test 7 | const originalTwMerge = defaultConfig.twMerge ?? true; 8 | const originalTwMergeConfig = {...defaultConfig.twMergeConfig}; 9 | 10 | beforeEach(() => { 11 | // Reset to original values before each test 12 | defaultConfig.twMerge = originalTwMerge; 13 | defaultConfig.twMergeConfig = {...originalTwMergeConfig}; 14 | }); 15 | 16 | afterEach(() => { 17 | // Ensure cleanup after each test 18 | defaultConfig.twMerge = originalTwMerge; 19 | defaultConfig.twMergeConfig = {...originalTwMergeConfig}; 20 | }); 21 | 22 | test("should be importable as a value (not just a type)", () => { 23 | expect(defaultConfig).toBeDefined(); 24 | expect(typeof defaultConfig).toBe("object"); 25 | expect(defaultConfig).toHaveProperty("twMerge"); 26 | expect(defaultConfig).toHaveProperty("twMergeConfig"); 27 | }); 28 | 29 | test("should have default values", () => { 30 | expect(defaultConfig.twMerge).toBe(true); 31 | expect(defaultConfig.twMergeConfig).toEqual({}); 32 | }); 33 | 34 | test("should allow modification of twMergeConfig", () => { 35 | const customConfig = { 36 | extend: { 37 | theme: { 38 | spacing: ["medium", "large"], 39 | }, 40 | }, 41 | }; 42 | 43 | defaultConfig.twMergeConfig = customConfig; 44 | 45 | expect(defaultConfig.twMergeConfig).toEqual(customConfig); 46 | expect(defaultConfig.twMergeConfig.extend?.theme?.spacing).toEqual(["medium", "large"]); 47 | }); 48 | 49 | test("should allow modification of twMerge property", () => { 50 | defaultConfig.twMerge = false; 51 | expect(defaultConfig.twMerge).toBe(false); 52 | 53 | defaultConfig.twMerge = true; 54 | expect(defaultConfig.twMerge).toBe(true); 55 | }); 56 | 57 | test("should affect tv behavior when twMergeConfig is modified", () => { 58 | // Set up a custom twMergeConfig 59 | defaultConfig.twMergeConfig = { 60 | extend: { 61 | theme: { 62 | spacing: ["medium", "large"], 63 | }, 64 | }, 65 | }; 66 | 67 | const button = tv({ 68 | base: "px-medium py-large", 69 | }); 70 | 71 | // The custom config should be used 72 | expect(button()).toBeDefined(); 73 | }); 74 | 75 | test("should allow nested modifications of twMergeConfig", () => { 76 | defaultConfig.twMergeConfig = { 77 | extend: { 78 | theme: { 79 | spacing: ["small"], 80 | }, 81 | }, 82 | }; 83 | 84 | // Modify nested properties 85 | if (defaultConfig.twMergeConfig.extend?.theme) { 86 | defaultConfig.twMergeConfig.extend.theme.spacing = ["small", "medium", "large"]; 87 | } 88 | 89 | expect(defaultConfig.twMergeConfig.extend?.theme?.spacing).toEqual([ 90 | "small", 91 | "medium", 92 | "large", 93 | ]); 94 | }); 95 | 96 | test("should work with createTV when defaultConfig is modified", () => { 97 | defaultConfig.twMerge = false; 98 | 99 | const tv = createTV({}); 100 | const h1 = tv({ 101 | base: "text-3xl font-bold text-blue-400 text-xl text-blue-200", 102 | }); 103 | 104 | // Since defaultConfig.twMerge is false and no override is provided, 105 | // classes should not be merged 106 | expect(h1()).toContain("text-3xl"); 107 | expect(h1()).toContain("text-xl"); 108 | }); 109 | 110 | test("should allow setting twMergeConfig with extend.classGroups", () => { 111 | const configWithClassGroups = { 112 | extend: { 113 | classGroups: { 114 | shadow: [ 115 | { 116 | shadow: ["small", "medium", "large"], 117 | }, 118 | ], 119 | }, 120 | }, 121 | }; 122 | 123 | defaultConfig.twMergeConfig = configWithClassGroups; 124 | 125 | expect(defaultConfig.twMergeConfig.extend?.classGroups).toBeDefined(); 126 | expect(defaultConfig.twMergeConfig.extend?.classGroups?.shadow).toEqual([ 127 | {shadow: ["small", "medium", "large"]}, 128 | ]); 129 | }); 130 | 131 | test("should persist modifications across multiple tv calls", () => { 132 | defaultConfig.twMergeConfig = { 133 | extend: { 134 | theme: { 135 | spacing: ["custom-spacing"], 136 | }, 137 | }, 138 | }; 139 | 140 | const button1 = tv({base: "px-custom-spacing"}); 141 | const button2 = tv({base: "py-custom-spacing"}); 142 | 143 | // Both should use the modified config 144 | expect(button1()).toBeDefined(); 145 | expect(button2()).toBeDefined(); 146 | }); 147 | 148 | test("should allow complete replacement of twMergeConfig object", () => { 149 | const newConfig = { 150 | extend: { 151 | theme: { 152 | opacity: ["disabled"], 153 | spacing: ["unit", "unit-2"], 154 | }, 155 | }, 156 | }; 157 | 158 | defaultConfig.twMergeConfig = newConfig; 159 | 160 | expect(defaultConfig.twMergeConfig).toEqual(newConfig); 161 | expect(defaultConfig.twMergeConfig.extend?.theme?.opacity).toEqual(["disabled"]); 162 | expect(defaultConfig.twMergeConfig.extend?.theme?.spacing).toEqual(["unit", "unit-2"]); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | tailwind-variants 4 |

tailwind-variants

5 | 6 |

7 |

8 | The power of Tailwind combined with a first-class variant API.

9 | 10 | npm downloads 11 | 12 | 13 | NPM Version 14 | 15 | 16 | License 17 | 18 |

19 | 20 | ## Features 21 | 22 | - First-class variant API 23 | - Slots support 24 | - Composition support 25 | - Fully typed 26 | - Framework agnostic 27 | - Automatic conflict resolution 28 | - Tailwindcss V4 support 29 | 30 | ## Documentation 31 | 32 | For full documentation, visit [tailwind-variants.org](https://tailwind-variants.org) 33 | 34 | > ❕ Note: `Tailwindcss V4` no longer supports the `config.content.transform` so we remove the `responsive variants` feature 35 | > 36 | > If you want to use `responsive variants`, you need to add it manually to your classname. 37 | 38 | ## Quick Start 39 | 40 | 1. Installation: 41 | To use Tailwind Variants in your project, you can install it as a dependency: 42 | 43 | ```bash 44 | yarn add tailwind-variants 45 | # or 46 | npm i tailwind-variants 47 | # or 48 | pnpm add tailwind-variants 49 | ``` 50 | 51 | **Optional:** If you want automatic conflict resolution, also install `tailwind-merge`: 52 | 53 | ```bash 54 | yarn add tailwind-merge 55 | # or 56 | npm i tailwind-merge 57 | # or 58 | pnpm add tailwind-merge 59 | ``` 60 | 61 | > **⚠️ Compatibility Note:** Supports Tailwind CSS v4.x (requires `tailwind-merge` v3.x). If you use Tailwind CSS v3.x, use tailwind-variants v0.x with [tailwind-merge v2.6.0](https://github.com/dcastil/tailwind-merge/tree/v2.6.0). 62 | 63 | > **💡 Lite mode (v3):** For smaller bundle size and faster runtime without conflict resolution, use the `/lite` import: 64 | > 65 | > ```js 66 | > import {tv} from "tailwind-variants/lite"; 67 | > ``` 68 | 69 | > **⚠️ Upgrading?** 70 | > 71 | > - From v2 to v3: See the [v3 migration guide](./MIGRATION-V3.md) 72 | > - From v1 to v2: See the [v2 migration guide](./MIGRATION-V2.md) 73 | 74 | 2. Usage: 75 | 76 | ```js 77 | import {tv} from "tailwind-variants"; 78 | 79 | const button = tv({ 80 | base: "font-medium bg-blue-500 text-white rounded-full active:opacity-80", 81 | variants: { 82 | color: { 83 | primary: "bg-blue-500 text-white", 84 | secondary: "bg-purple-500 text-white", 85 | }, 86 | size: { 87 | sm: "text-sm", 88 | md: "text-base", 89 | lg: "px-4 py-3 text-lg", 90 | }, 91 | }, 92 | compoundVariants: [ 93 | { 94 | size: ["sm", "md"], 95 | class: "px-3 py-1", 96 | }, 97 | ], 98 | defaultVariants: { 99 | size: "md", 100 | color: "primary", 101 | }, 102 | }); 103 | 104 | return ; 105 | ``` 106 | 107 | ## Utility Functions 108 | 109 | Tailwind Variants provides several utility functions for combining and merging class names: 110 | 111 | ### `cx` - Simple Concatenation 112 | 113 | Combines class names without merging conflicting classes (similar to `clsx`): 114 | 115 | ```js 116 | import {cx} from "tailwind-variants"; 117 | 118 | cx("text-xl", "font-bold"); // => "text-xl font-bold" 119 | cx("px-2", "px-4"); // => "px-2 px-4" (no merging) 120 | ``` 121 | 122 | ### `cn` - Merge with Default Config 123 | 124 | > **Updated in v3.2.2** - Now returns a string directly (no function call needed) 125 | 126 | Combines class names and merges conflicting Tailwind CSS classes using the default `tailwind-merge` config. Returns a string directly: 127 | 128 | ```js 129 | import {cn} from "tailwind-variants"; 130 | 131 | cn("bg-red-500", "bg-blue-500"); // => "bg-blue-500" 132 | cn("px-2", "px-4", "py-2"); // => "px-4 py-2" 133 | ``` 134 | 135 | ### `cnMerge` - Merge with Custom Config 136 | 137 | > **Available from v3.2.2** 138 | 139 | Combines class names and merges conflicting Tailwind CSS classes with support for custom `twMerge` configuration via a second function call: 140 | 141 | ```js 142 | import {cnMerge} from "tailwind-variants"; 143 | 144 | // Disable merging 145 | cnMerge("px-2", "px-4")({twMerge: false}) // => "px-2 px-4" 146 | 147 | // Enable merging explicitly 148 | cnMerge("bg-red-500", "bg-blue-500")({twMerge: true}) // => "bg-blue-500" 149 | 150 | // Use custom twMergeConfig 151 | cnMerge("px-2", "px-4")({twMergeConfig: {...}}) // => merged with custom config 152 | ``` 153 | 154 | **When to use which:** 155 | 156 | - Use `cx` when you want simple concatenation without any merging 157 | - Use `cn` for most cases when you want automatic conflict resolution with default settings 158 | - Use `cnMerge` when you need to customize the merge behavior (disable merging, custom config, etc.) 159 | 160 | ## Acknowledgements 161 | 162 | - [**cva**](https://github.com/joe-bell/cva) ([Joe Bell](https://github.com/joe-bell)) 163 | This project as started as an extension of Joe's work on `cva` – a great tool for generating variants for a single element with Tailwind CSS. Big shoutout to [Joe Bell](https://github.com/joe-bell) and [contributors](https://github.com/joe-bell/cva/graphs/contributors) you guys rock! 🤘 - we recommend to use `cva` if don't need any of the **Tailwind Variants** features listed [here](https://www.tailwind-variants.org/docs/comparison). 164 | 165 | - [**Stitches**](https://stitches.dev/) ([Modulz](https://modulz.app)) 166 | The pioneers of the `variants` API movement. Inmense thanks to [Modulz](https://modulz.app) for their work on Stitches and the community around it. 🙏 167 | 168 | ## Community 169 | 170 | We're excited to see the community adopt HeroUI, raise issues, and provide feedback. Whether it's a feature request, bug report, or a project to showcase, please get involved! 171 | 172 | - [Discord](https://discord.gg/9b6yyZKmH4) 173 | - [Twitter](https://twitter.com/getnextui) 174 | - [GitHub Discussions](https://github.com/heroui-inc/tailwind-variants/discussions) 175 | 176 | ## Contributing 177 | 178 | Contributions are always welcome! 179 | 180 | Please follow our [contributing guidelines](./CONTRIBUTING.md). 181 | 182 | Please adhere to this project's [CODE_OF_CONDUCT](./CODE_OF_CONDUCT.md). 183 | 184 | ## Authors 185 | 186 | - Junior garcia ([@jrgarciadev](https://github.com/jrgaciadev)) 187 | - Tianen Pang ([@tianenpang](https://github.com/tianenpang)) 188 | 189 | ## License 190 | 191 | Licensed under the MIT License. 192 | 193 | See [LICENSE](./LICENSE.md) for more information. 194 | -------------------------------------------------------------------------------- /src/__tests__/tv-no-twmerge.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, describe, test, jest} from "@jest/globals"; 2 | 3 | import {tv as tvFull} from "../index"; 4 | import {tv as tvLite} from "../lite"; 5 | 6 | const variants = [ 7 | {name: "full - tailwind-merge", tv: tvFull, mode: "full"}, 8 | {name: "lite - without tailwind-merge", tv: tvLite, mode: "lite"}, 9 | ]; 10 | 11 | describe.each(variants)("Tailwind Variants (TV) - twMerge: false - $name", ({tv}) => { 12 | test("should keep all classes including conflicting ones when twMerge is false", () => { 13 | const button = tv( 14 | { 15 | base: "px-4 px-2 py-2 py-4 bg-blue-500 bg-red-500", 16 | }, 17 | { 18 | twMerge: false, 19 | }, 20 | ); 21 | 22 | // When twMerge is false, all classes should be preserved 23 | // including conflicting ones like px-4 and px-2 24 | expect(button()).toBe("px-4 px-2 py-2 py-4 bg-blue-500 bg-red-500"); 25 | }); 26 | 27 | test("should not resolve conflicts in variants when twMerge is false", () => { 28 | const button = tv( 29 | { 30 | base: "font-medium", 31 | variants: { 32 | size: { 33 | sm: "text-sm text-xs px-2 px-3", 34 | md: "text-base text-md px-4 px-5", 35 | }, 36 | color: { 37 | primary: "bg-blue-500 bg-blue-600 text-white text-gray-100", 38 | secondary: "bg-gray-500 bg-gray-600", 39 | }, 40 | }, 41 | }, 42 | { 43 | twMerge: false, 44 | }, 45 | ); 46 | 47 | // All conflicting classes should be preserved 48 | expect(button({size: "sm"})).toBe("font-medium text-sm text-xs px-2 px-3"); 49 | expect(button({size: "md", color: "primary"})).toBe( 50 | "font-medium text-base text-md px-4 px-5 bg-blue-500 bg-blue-600 text-white text-gray-100", 51 | ); 52 | }); 53 | 54 | test("should not resolve conflicts in compound variants when twMerge is false", () => { 55 | const button = tv( 56 | { 57 | base: "font-semibold", 58 | variants: { 59 | size: { 60 | sm: "px-2", 61 | md: "px-4", 62 | }, 63 | variant: { 64 | primary: "bg-blue-500", 65 | secondary: "bg-gray-500", 66 | }, 67 | }, 68 | compoundVariants: [ 69 | { 70 | size: "sm", 71 | variant: "primary", 72 | class: "bg-blue-600 bg-blue-700 px-3 px-4", 73 | }, 74 | ], 75 | }, 76 | { 77 | twMerge: false, 78 | }, 79 | ); 80 | 81 | // Compound variant classes should be added without resolving conflicts 82 | expect(button({size: "sm", variant: "primary"})).toBe( 83 | "font-semibold px-2 bg-blue-500 bg-blue-600 bg-blue-700 px-3 px-4", 84 | ); 85 | }); 86 | 87 | test("should not resolve conflicts in slots when twMerge is false", () => { 88 | const card = tv( 89 | { 90 | slots: { 91 | base: "rounded-lg rounded-xl p-4 p-6", 92 | header: "text-lg text-xl font-bold font-semibold", 93 | body: "text-gray-600 text-gray-700 mt-2 mt-4", 94 | }, 95 | }, 96 | { 97 | twMerge: false, 98 | }, 99 | ); 100 | 101 | const slots = card(); 102 | 103 | // All conflicting classes in slots should be preserved 104 | expect(slots.base()).toBe("rounded-lg rounded-xl p-4 p-6"); 105 | expect(slots.header()).toBe("text-lg text-xl font-bold font-semibold"); 106 | expect(slots.body()).toBe("text-gray-600 text-gray-700 mt-2 mt-4"); 107 | }); 108 | 109 | test("should not resolve conflicts with class/className props when twMerge is false", () => { 110 | const button = tv( 111 | { 112 | base: "px-4 py-2 rounded", 113 | }, 114 | { 115 | twMerge: false, 116 | }, 117 | ); 118 | 119 | // Additional classes should be appended without conflict resolution 120 | expect(button({class: "px-2 py-4 rounded-lg"})).toBe("px-4 py-2 rounded px-2 py-4 rounded-lg"); 121 | expect(button({className: "px-6 py-1 rounded-xl"})).toBe( 122 | "px-4 py-2 rounded px-6 py-1 rounded-xl", 123 | ); 124 | }); 125 | 126 | test("should work with non-tailwind classes when twMerge is false", () => { 127 | const button = tv( 128 | { 129 | base: "button", 130 | variants: { 131 | size: { 132 | sm: "button--sm", 133 | md: "button--md", 134 | lg: "button--lg", 135 | }, 136 | variant: { 137 | primary: "button--primary", 138 | secondary: "button--secondary", 139 | }, 140 | }, 141 | }, 142 | { 143 | twMerge: false, 144 | }, 145 | ); 146 | 147 | expect(button()).toBe("button"); 148 | expect(button({size: "sm"})).toBe("button button--sm"); 149 | expect(button({size: "lg", variant: "secondary"})).toBe("button button--lg button--secondary"); 150 | }); 151 | 152 | test("should handle empty/falsy values correctly when twMerge is false", () => { 153 | const button = tv( 154 | { 155 | base: "base", 156 | variants: { 157 | size: { 158 | sm: "small", 159 | md: "", 160 | lg: null, 161 | }, 162 | }, 163 | }, 164 | { 165 | twMerge: false, 166 | }, 167 | ); 168 | 169 | expect(button({size: "sm"})).toBe("base small"); 170 | expect(button({size: "md"})).toBe("base"); 171 | expect(button({size: "lg"})).toBe("base"); 172 | }); 173 | 174 | test("should handle arrays of classes when twMerge is false", () => { 175 | const button = tv( 176 | { 177 | base: ["px-4", "py-2", ["rounded", ["bg-blue-500"]]], 178 | variants: { 179 | size: { 180 | sm: ["text-sm", ["px-2", "py-1"]], 181 | }, 182 | }, 183 | }, 184 | { 185 | twMerge: false, 186 | }, 187 | ); 188 | 189 | expect(button()).toBe("px-4 py-2 rounded bg-blue-500"); 190 | expect(button({size: "sm"})).toBe("px-4 py-2 rounded bg-blue-500 text-sm px-2 py-1"); 191 | }); 192 | 193 | test("should not require tailwind-merge in bundle when twMerge is false", () => { 194 | // This test verifies that the createTwMerge function is not called 195 | // when twMerge is false. In a real bundle, this would mean 196 | // tailwind-merge is not included. 197 | const mockCreateTwMerge = jest.fn(); 198 | 199 | // Override require for this test 200 | const originalRequire = require; 201 | 202 | (global as any).require = (module: string) => { 203 | if (module === "./cn.js") { 204 | return {createTwMerge: mockCreateTwMerge}; 205 | } 206 | 207 | return originalRequire(module); 208 | }; 209 | 210 | const button = tv( 211 | { 212 | base: "px-4 py-2", 213 | }, 214 | { 215 | twMerge: false, 216 | }, 217 | ); 218 | 219 | button(); 220 | 221 | // createTwMerge should not be called when twMerge is false 222 | expect(mockCreateTwMerge).not.toHaveBeenCalled(); 223 | 224 | // Restore original require 225 | (global as any).require = originalRequire; 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type {ClassNameValue as ClassValue} from "tailwind-merge"; 2 | import type {TVConfig} from "./config.d.ts"; 3 | 4 | /** 5 | * ---------------------------------------- 6 | * Base Types 7 | * ---------------------------------------- 8 | */ 9 | 10 | export type {ClassValue}; 11 | 12 | export type ClassProp = 13 | | {class?: V; className?: never} 14 | | {class?: never; className?: V}; 15 | 16 | type TVBaseName = "base"; 17 | 18 | type TVScreens = "initial"; 19 | 20 | type TVSlots = Record | undefined; 21 | 22 | /** 23 | * ---------------------------------------------------------------------- 24 | * Utils 25 | * ---------------------------------------------------------------------- 26 | */ 27 | 28 | export type OmitUndefined = T extends undefined ? never : T; 29 | 30 | export type StringToBoolean = T extends "true" | "false" ? boolean : T; 31 | 32 | type CnClassValue = 33 | | string 34 | | number 35 | | bigint 36 | | boolean 37 | | null 38 | | undefined 39 | | CnClassDictionary 40 | | CnClassArray; 41 | 42 | interface CnClassDictionary { 43 | [key: string]: any; 44 | } 45 | 46 | interface CnClassArray extends Array {} 47 | 48 | export type CnOptions = CnClassValue[]; 49 | 50 | export type CnReturn = string | undefined; 51 | 52 | // compare if the value is true or array of values 53 | export type isTrueOrArray = T extends true | unknown[] ? true : false; 54 | 55 | export type WithInitialScreen> = ["initial", ...T]; 56 | 57 | /** 58 | * ---------------------------------------------------------------------- 59 | * TV Types 60 | * ---------------------------------------------------------------------- 61 | */ 62 | 63 | type TVSlotsWithBase = B extends undefined 64 | ? keyof S 65 | : keyof S | TVBaseName; 66 | 67 | type SlotsClassValue = { 68 | [K in TVSlotsWithBase]?: ClassValue; 69 | }; 70 | 71 | type TVVariantsDefault = S extends undefined 72 | ? {} 73 | : { 74 | [key: string]: { 75 | [key: string]: S extends TVSlots ? SlotsClassValue | ClassValue : ClassValue; 76 | }; 77 | }; 78 | 79 | export type TVVariants< 80 | S extends TVSlots | undefined, 81 | B extends ClassValue | undefined = undefined, 82 | EV extends TVVariants | undefined = undefined, 83 | ES extends TVSlots | undefined = undefined, 84 | > = EV extends undefined 85 | ? TVVariantsDefault 86 | : 87 | | { 88 | [K in keyof EV]: { 89 | [K2 in keyof EV[K]]: S extends TVSlots 90 | ? SlotsClassValue | ClassValue 91 | : ClassValue; 92 | }; 93 | } 94 | | TVVariantsDefault; 95 | 96 | export type TVCompoundVariants< 97 | V extends TVVariants, 98 | S extends TVSlots, 99 | B extends ClassValue, 100 | EV extends TVVariants, 101 | ES extends TVSlots, 102 | > = Array< 103 | { 104 | [K in keyof V | keyof EV]?: 105 | | (K extends keyof V ? StringToBoolean : never) 106 | | (K extends keyof EV ? StringToBoolean : never) 107 | | (K extends keyof V ? StringToBoolean[] : never); 108 | } & ClassProp | ClassValue> 109 | >; 110 | 111 | export type TVCompoundSlots< 112 | V extends TVVariants, 113 | S extends TVSlots, 114 | B extends ClassValue, 115 | > = Array< 116 | V extends undefined 117 | ? { 118 | slots: Array>; 119 | } & ClassProp 120 | : { 121 | slots: Array>; 122 | } & { 123 | [K in keyof V]?: StringToBoolean | StringToBoolean[]; 124 | } & ClassProp 125 | >; 126 | 127 | export type TVDefaultVariants< 128 | V extends TVVariants, 129 | S extends TVSlots, 130 | EV extends TVVariants, 131 | ES extends TVSlots, 132 | > = { 133 | [K in keyof V | keyof EV]?: 134 | | (K extends keyof V ? StringToBoolean : never) 135 | | (K extends keyof EV ? StringToBoolean : never); 136 | }; 137 | 138 | export type TVScreenPropsValue, S extends TVSlots, K extends keyof V> = { 139 | [Screen in TVScreens]?: StringToBoolean; 140 | }; 141 | 142 | export type TVProps< 143 | V extends TVVariants, 144 | S extends TVSlots, 145 | EV extends TVVariants, 146 | ES extends TVSlots, 147 | > = EV extends undefined 148 | ? V extends undefined 149 | ? ClassProp 150 | : { 151 | [K in keyof V]?: StringToBoolean | undefined; 152 | } & ClassProp 153 | : V extends undefined 154 | ? { 155 | [K in keyof EV]?: StringToBoolean | undefined; 156 | } & ClassProp 157 | : { 158 | [K in keyof V | keyof EV]?: 159 | | (K extends keyof V ? StringToBoolean : never) 160 | | (K extends keyof EV ? StringToBoolean : never) 161 | | undefined; 162 | } & ClassProp; 163 | 164 | export type TVVariantKeys, S extends TVSlots> = V extends Object 165 | ? Array 166 | : undefined; 167 | 168 | export type TVReturnProps< 169 | V extends TVVariants, 170 | S extends TVSlots, 171 | B extends ClassValue, 172 | EV extends TVVariants, 173 | ES extends TVSlots, 174 | // @ts-expect-error 175 | E extends TVReturnType = undefined, 176 | > = { 177 | extend: E; 178 | base: B; 179 | slots: S; 180 | variants: V; 181 | defaultVariants: TVDefaultVariants; 182 | compoundVariants: TVCompoundVariants; 183 | compoundSlots: TVCompoundSlots; 184 | variantKeys: TVVariantKeys; 185 | }; 186 | 187 | type HasSlots = S extends undefined 188 | ? ES extends undefined 189 | ? false 190 | : true 191 | : true; 192 | 193 | export type TVReturnType< 194 | V extends TVVariants, 195 | S extends TVSlots, 196 | B extends ClassValue, 197 | EV extends TVVariants, 198 | ES extends TVSlots, 199 | // @ts-expect-error 200 | E extends TVReturnType = undefined, 201 | > = { 202 | (props?: TVProps): HasSlots extends true 203 | ? { 204 | [K in keyof (ES extends undefined ? {} : ES)]: ( 205 | slotProps?: TVProps, 206 | ) => string; 207 | } & { 208 | [K in keyof (S extends undefined ? {} : S)]: (slotProps?: TVProps) => string; 209 | } & { 210 | [K in TVSlotsWithBase<{}, B>]: (slotProps?: TVProps) => string; 211 | } 212 | : string; 213 | } & TVReturnProps; 214 | 215 | export type TV = { 216 | < 217 | V extends TVVariants, 218 | CV extends TVCompoundVariants, 219 | DV extends TVDefaultVariants, 220 | B extends ClassValue = undefined, 221 | S extends TVSlots = undefined, 222 | // @ts-expect-error 223 | E extends TVReturnType = TVReturnType< 224 | V, 225 | S, 226 | B, 227 | // @ts-expect-error 228 | EV extends undefined ? {} : EV, 229 | // @ts-expect-error 230 | ES extends undefined ? {} : ES 231 | >, 232 | EV extends TVVariants = E["variants"], 233 | ES extends TVSlots = E["slots"] extends TVSlots ? E["slots"] : undefined, 234 | >( 235 | options: { 236 | /** 237 | * Extend allows for easy composition of components. 238 | * @see https://www.tailwind-variants.org/docs/composing-components 239 | */ 240 | extend?: E; 241 | /** 242 | * Base allows you to set a base class for a component. 243 | */ 244 | base?: B; 245 | /** 246 | * Slots allow you to separate a component into multiple parts. 247 | * @see https://www.tailwind-variants.org/docs/slots 248 | */ 249 | slots?: S; 250 | /** 251 | * Variants allow you to create multiple versions of the same component. 252 | * @see https://www.tailwind-variants.org/docs/variants#adding-variants 253 | */ 254 | variants?: V; 255 | /** 256 | * Compound variants allow you to apply classes to multiple variants at once. 257 | * @see https://www.tailwind-variants.org/docs/variants#compound-variants 258 | */ 259 | compoundVariants?: CV; 260 | /** 261 | * Compound slots allow you to apply classes to multiple slots at once. 262 | */ 263 | compoundSlots?: TVCompoundSlots; 264 | /** 265 | * Default variants allow you to set default variants for a component. 266 | * @see https://www.tailwind-variants.org/docs/variants#default-variants 267 | */ 268 | defaultVariants?: DV; 269 | }, 270 | /** 271 | * The config object allows you to modify the default configuration. 272 | * @see https://www.tailwind-variants.org/docs/api-reference#config-optional 273 | */ 274 | config?: TVConfig, 275 | ): TVReturnType; 276 | }; 277 | 278 | export type TVLite = { 279 | < 280 | V extends TVVariants, 281 | CV extends TVCompoundVariants, 282 | DV extends TVDefaultVariants, 283 | B extends ClassValue = undefined, 284 | S extends TVSlots = undefined, 285 | // @ts-expect-error 286 | E extends TVReturnType = TVReturnType< 287 | V, 288 | S, 289 | B, 290 | // @ts-expect-error 291 | EV extends undefined ? {} : EV, 292 | // @ts-expect-error 293 | ES extends undefined ? {} : ES 294 | >, 295 | EV extends TVVariants = E["variants"], 296 | ES extends TVSlots = E["slots"] extends TVSlots ? E["slots"] : undefined, 297 | >(options: { 298 | /** 299 | * Extend allows for easy composition of components. 300 | * @see https://www.tailwind-variants.org/docs/composing-components 301 | */ 302 | extend?: E; 303 | /** 304 | * Base allows you to set a base class for a component. 305 | */ 306 | base?: B; 307 | /** 308 | * Slots allow you to separate a component into multiple parts. 309 | * @see https://www.tailwind-variants.org/docs/slots 310 | */ 311 | slots?: S; 312 | /** 313 | * Variants allow you to create multiple versions of the same component. 314 | * @see https://www.tailwind-variants.org/docs/variants#adding-variants 315 | */ 316 | variants?: V; 317 | /** 318 | * Compound variants allow you to apply classes to multiple variants at once. 319 | * @see https://www.tailwind-variants.org/docs/variants#compound-variants 320 | */ 321 | compoundVariants?: CV; 322 | /** 323 | * Compound slots allow you to apply classes to multiple slots at once. 324 | */ 325 | compoundSlots?: TVCompoundSlots; 326 | /** 327 | * Default variants allow you to set default variants for a component. 328 | * @see https://www.tailwind-variants.org/docs/variants#default-variants 329 | */ 330 | defaultVariants?: DV; 331 | }): TVReturnType; 332 | }; 333 | 334 | export type VariantProps any> = Omit< 335 | OmitUndefined[0]>, 336 | "class" | "className" 337 | >; 338 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | import { 2 | isEqual, 3 | isEmptyObject, 4 | falsyToString, 5 | mergeObjects, 6 | flatMergeArrays, 7 | joinObjects, 8 | cx, 9 | } from "./utils.js"; 10 | import {defaultConfig} from "./config.js"; 11 | import {state} from "./state.js"; 12 | 13 | export const getTailwindVariants = (cn) => { 14 | const tv = (options, configProp) => { 15 | const { 16 | extend = null, 17 | slots: slotProps = {}, 18 | variants: variantsProps = {}, 19 | compoundVariants: compoundVariantsProps = [], 20 | compoundSlots = [], 21 | defaultVariants: defaultVariantsProps = {}, 22 | } = options; 23 | 24 | const config = {...defaultConfig, ...configProp}; 25 | 26 | const base = extend?.base ? cx(extend.base, options?.base) : options?.base; 27 | const variants = 28 | extend?.variants && !isEmptyObject(extend.variants) 29 | ? mergeObjects(variantsProps, extend.variants) 30 | : variantsProps; 31 | const defaultVariants = 32 | extend?.defaultVariants && !isEmptyObject(extend.defaultVariants) 33 | ? {...extend.defaultVariants, ...defaultVariantsProps} 34 | : defaultVariantsProps; 35 | 36 | // save twMergeConfig to the cache 37 | if ( 38 | !isEmptyObject(config.twMergeConfig) && 39 | !isEqual(config.twMergeConfig, state.cachedTwMergeConfig) 40 | ) { 41 | state.didTwMergeConfigChange = true; 42 | state.cachedTwMergeConfig = config.twMergeConfig; 43 | } 44 | 45 | const isExtendedSlotsEmpty = isEmptyObject(extend?.slots); 46 | const componentSlots = !isEmptyObject(slotProps) 47 | ? { 48 | // add "base" to the slots object 49 | base: cx(options?.base, isExtendedSlotsEmpty && extend?.base), 50 | ...slotProps, 51 | } 52 | : {}; 53 | 54 | // merge slots with the "extended" slots 55 | const slots = isExtendedSlotsEmpty 56 | ? componentSlots 57 | : joinObjects( 58 | {...extend?.slots}, 59 | isEmptyObject(componentSlots) ? {base: options?.base} : componentSlots, 60 | ); 61 | 62 | // merge compoundVariants with the "extended" compoundVariants 63 | const compoundVariants = isEmptyObject(extend?.compoundVariants) 64 | ? compoundVariantsProps 65 | : flatMergeArrays(extend?.compoundVariants, compoundVariantsProps); 66 | 67 | const component = (props) => { 68 | if (isEmptyObject(variants) && isEmptyObject(slotProps) && isExtendedSlotsEmpty) { 69 | return cn(base, props?.class, props?.className)(config); 70 | } 71 | 72 | if (compoundVariants && !Array.isArray(compoundVariants)) { 73 | throw new TypeError( 74 | `The "compoundVariants" prop must be an array. Received: ${typeof compoundVariants}`, 75 | ); 76 | } 77 | 78 | if (compoundSlots && !Array.isArray(compoundSlots)) { 79 | throw new TypeError( 80 | `The "compoundSlots" prop must be an array. Received: ${typeof compoundSlots}`, 81 | ); 82 | } 83 | 84 | const getVariantValue = (variant, vrs = variants, _slotKey = null, slotProps = null) => { 85 | const variantObj = vrs[variant]; 86 | 87 | if (!variantObj || isEmptyObject(variantObj)) { 88 | return null; 89 | } 90 | 91 | const variantProp = slotProps?.[variant] ?? props?.[variant]; 92 | 93 | if (variantProp === null) return null; 94 | 95 | const variantKey = falsyToString(variantProp); 96 | 97 | // If variant key is an object (responsive variants), ignore it as they're no longer supported 98 | if (typeof variantKey === "object") { 99 | return null; 100 | } 101 | 102 | const defaultVariantProp = defaultVariants?.[variant]; 103 | const key = variantKey != null ? variantKey : falsyToString(defaultVariantProp); 104 | 105 | const value = variantObj[key || "false"]; 106 | 107 | return value; 108 | }; 109 | 110 | const getVariantClassNames = () => { 111 | if (!variants) return null; 112 | 113 | const keys = Object.keys(variants); 114 | const result = []; 115 | 116 | for (let i = 0; i < keys.length; i++) { 117 | const value = getVariantValue(keys[i], variants); 118 | 119 | if (value) result.push(value); 120 | } 121 | 122 | return result; 123 | }; 124 | 125 | const getVariantClassNamesBySlotKey = (slotKey, slotProps) => { 126 | if (!variants || typeof variants !== "object") return null; 127 | 128 | const result = []; 129 | 130 | for (const variant in variants) { 131 | const variantValue = getVariantValue(variant, variants, slotKey, slotProps); 132 | 133 | const value = 134 | slotKey === "base" && typeof variantValue === "string" 135 | ? variantValue 136 | : variantValue && variantValue[slotKey]; 137 | 138 | if (value) result.push(value); 139 | } 140 | 141 | return result; 142 | }; 143 | 144 | const propsWithoutUndefined = {}; 145 | 146 | for (const prop in props) { 147 | const value = props[prop]; 148 | 149 | if (value !== undefined) propsWithoutUndefined[prop] = value; 150 | } 151 | 152 | const getCompleteProps = (key, slotProps) => { 153 | const initialProp = 154 | typeof props?.[key] === "object" 155 | ? { 156 | [key]: props[key]?.initial, 157 | } 158 | : {}; 159 | 160 | return { 161 | ...defaultVariants, 162 | ...propsWithoutUndefined, 163 | ...initialProp, 164 | ...slotProps, 165 | }; 166 | }; 167 | 168 | const getCompoundVariantsValue = (cv = [], slotProps) => { 169 | const result = []; 170 | const cvLength = cv.length; 171 | 172 | for (let i = 0; i < cvLength; i++) { 173 | const {class: tvClass, className: tvClassName, ...compoundVariantOptions} = cv[i]; 174 | let isValid = true; 175 | const completeProps = getCompleteProps(null, slotProps); 176 | 177 | for (const key in compoundVariantOptions) { 178 | const value = compoundVariantOptions[key]; 179 | const completePropsValue = completeProps[key]; 180 | 181 | if (Array.isArray(value)) { 182 | if (!value.includes(completePropsValue)) { 183 | isValid = false; 184 | break; 185 | } 186 | } else { 187 | if ( 188 | (value == null || value === false) && 189 | (completePropsValue == null || completePropsValue === false) 190 | ) 191 | continue; 192 | 193 | if (completePropsValue !== value) { 194 | isValid = false; 195 | break; 196 | } 197 | } 198 | } 199 | 200 | if (isValid) { 201 | if (tvClass) result.push(tvClass); 202 | if (tvClassName) result.push(tvClassName); 203 | } 204 | } 205 | 206 | return result; 207 | }; 208 | 209 | const getCompoundVariantClassNamesBySlot = (slotProps) => { 210 | const compoundClassNames = getCompoundVariantsValue(compoundVariants, slotProps); 211 | 212 | if (!Array.isArray(compoundClassNames)) return compoundClassNames; 213 | 214 | const result = {}; 215 | const cnFn = cn; 216 | 217 | for (let i = 0; i < compoundClassNames.length; i++) { 218 | const className = compoundClassNames[i]; 219 | 220 | if (typeof className === "string") { 221 | result.base = cnFn(result.base, className)(config); 222 | } else if (typeof className === "object") { 223 | for (const slot in className) { 224 | result[slot] = cnFn(result[slot], className[slot])(config); 225 | } 226 | } 227 | } 228 | 229 | return result; 230 | }; 231 | 232 | const getCompoundSlotClassNameBySlot = (slotProps) => { 233 | if (compoundSlots.length < 1) return null; 234 | 235 | const result = {}; 236 | const completeProps = getCompleteProps(null, slotProps); 237 | 238 | for (let i = 0; i < compoundSlots.length; i++) { 239 | const { 240 | slots = [], 241 | class: slotClass, 242 | className: slotClassName, 243 | ...slotVariants 244 | } = compoundSlots[i]; 245 | 246 | if (!isEmptyObject(slotVariants)) { 247 | let isValid = true; 248 | 249 | for (const key in slotVariants) { 250 | const completePropsValue = completeProps[key]; 251 | const slotVariantValue = slotVariants[key]; 252 | 253 | if ( 254 | completePropsValue === undefined || 255 | (Array.isArray(slotVariantValue) 256 | ? !slotVariantValue.includes(completePropsValue) 257 | : slotVariantValue !== completePropsValue) 258 | ) { 259 | isValid = false; 260 | break; 261 | } 262 | } 263 | 264 | if (!isValid) continue; 265 | } 266 | 267 | for (let j = 0; j < slots.length; j++) { 268 | const slotName = slots[j]; 269 | 270 | if (!result[slotName]) result[slotName] = []; 271 | result[slotName].push([slotClass, slotClassName]); 272 | } 273 | } 274 | 275 | return result; 276 | }; 277 | 278 | // with slots 279 | if (!isEmptyObject(slotProps) || !isExtendedSlotsEmpty) { 280 | const slotsFns = {}; 281 | 282 | if (typeof slots === "object" && !isEmptyObject(slots)) { 283 | const cnFn = cn; 284 | 285 | for (const slotKey in slots) { 286 | slotsFns[slotKey] = (slotProps) => { 287 | const compoundVariantClasses = getCompoundVariantClassNamesBySlot(slotProps); 288 | const compoundSlotClasses = getCompoundSlotClassNameBySlot(slotProps); 289 | 290 | return cnFn( 291 | slots[slotKey], 292 | getVariantClassNamesBySlotKey(slotKey, slotProps), 293 | compoundVariantClasses ? compoundVariantClasses[slotKey] : undefined, 294 | compoundSlotClasses ? compoundSlotClasses[slotKey] : undefined, 295 | slotProps?.class, 296 | slotProps?.className, 297 | )(config); 298 | }; 299 | } 300 | } 301 | 302 | return slotsFns; 303 | } 304 | 305 | // normal variants 306 | return cn( 307 | base, 308 | getVariantClassNames(), 309 | getCompoundVariantsValue(compoundVariants), 310 | props?.class, 311 | props?.className, 312 | )(config); 313 | }; 314 | 315 | const getVariantKeys = () => { 316 | if (!variants || typeof variants !== "object") return; 317 | 318 | return Object.keys(variants); 319 | }; 320 | 321 | component.variantKeys = getVariantKeys(); 322 | component.extend = extend; 323 | component.base = base; 324 | component.slots = slots; 325 | component.variants = variants; 326 | component.defaultVariants = defaultVariants; 327 | component.compoundSlots = compoundSlots; 328 | component.compoundVariants = compoundVariants; 329 | 330 | return component; 331 | }; 332 | 333 | const createTV = (configProp) => { 334 | return (options, config) => tv(options, config ? mergeObjects(configProp, config) : configProp); 335 | }; 336 | 337 | return { 338 | tv, 339 | createTV, 340 | }; 341 | }; 342 | -------------------------------------------------------------------------------- /src/__tests__/cn.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, describe, test} from "@jest/globals"; 2 | 3 | import {cn, cnMerge, cx as cxFull} from "../index"; 4 | import {cn as cnLite, cx as cxLite} from "../lite"; 5 | import {cx as cxUtils} from "../utils"; 6 | 7 | const cxVariants = [ 8 | {name: "main index", cx: cxFull}, 9 | {name: "lite", cx: cxLite}, 10 | {name: "utils", cx: cxUtils}, 11 | ]; 12 | 13 | describe("cn function from lite (simple concatenation)", () => { 14 | test("should join strings and ignore falsy values", () => { 15 | expect(cnLite("text-xl", false && "font-bold", "text-center")()).toBe("text-xl text-center"); 16 | expect(cnLite("text-xl", undefined, null, 0, "")()).toBe("text-xl 0"); 17 | }); 18 | 19 | test("should join arrays of class names", () => { 20 | expect(cnLite(["px-4", "py-2"], "bg-blue-500")()).toBe("px-4 py-2 bg-blue-500"); 21 | expect(cnLite(["px-4", false, ["hover:bg-red-500", null, "rounded-lg"]])()).toBe( 22 | "px-4 hover:bg-red-500 rounded-lg", 23 | ); 24 | }); 25 | 26 | test("should handle nested arrays", () => { 27 | expect( 28 | cnLite(["px-4", ["py-2", ["bg-blue-500", ["rounded-lg", false, ["shadow-md"]]]]])(), 29 | ).toBe("px-4 py-2 bg-blue-500 rounded-lg shadow-md"); 30 | }); 31 | 32 | test("should join objects with truthy values as keys", () => { 33 | expect(cnLite({"text-sm": true, "font-bold": false, "bg-green-200": 1, "m-0": 0})()).toBe( 34 | "text-sm bg-green-200", 35 | ); 36 | }); 37 | 38 | test("should handle mixed arguments correctly", () => { 39 | expect( 40 | cnLite( 41 | "text-lg", 42 | ["px-3", {"hover:bg-yellow-300": true, "focus:outline-none": false}], 43 | {"rounded-md": true, "shadow-md": null}, 44 | "leading-tight", 45 | )(), 46 | ).toBe("text-lg px-3 hover:bg-yellow-300 rounded-md leading-tight"); 47 | }); 48 | 49 | test("should handle numbers and bigint", () => { 50 | expect(cnLite(123, "text-base", 0n, {border: true})()).toBe("123 text-base 0 border"); 51 | }); 52 | 53 | test("should return undefined for no input", () => { 54 | expect(cnLite()()).toBeUndefined(); 55 | }); 56 | 57 | test("should return '0' for zero and ignore other falsy", () => { 58 | expect(cnLite(false, null, undefined, "", 0)()).toBe("0"); 59 | }); 60 | 61 | test("should normalize template strings with irregular whitespace", () => { 62 | const input = ` 63 | px-4 64 | py-2 65 | 66 | bg-blue-500 67 | rounded-lg 68 | `; 69 | 70 | expect(cnLite(input)()).toBe("px-4 py-2 bg-blue-500 rounded-lg"); 71 | 72 | expect( 73 | cnLite( 74 | ` text-center 75 | font-semibold `, 76 | ["text-sm", ` uppercase `], 77 | {"shadow-lg": true, "opacity-50": false}, 78 | )(), 79 | ).toBe("text-center font-semibold text-sm uppercase shadow-lg"); 80 | }); 81 | 82 | test("should handle empty and falsy values correctly", () => { 83 | expect(cnLite("", null, undefined, false, NaN, 0, "0")()).toBe("0 0"); 84 | }); 85 | }); 86 | 87 | describe("cn function with tailwind-merge (main index)", () => { 88 | test("should merge conflicting tailwind classes by default", () => { 89 | const result = cn("px-2", "px-4", "py-2"); 90 | 91 | expect(result).toBe("px-4 py-2"); 92 | }); 93 | 94 | test("should merge text color classes by default", () => { 95 | const result = cn("text-red-500", "text-blue-500"); 96 | 97 | expect(result).toBe("text-blue-500"); 98 | }); 99 | 100 | test("should merge background color classes by default", () => { 101 | const result = cn("bg-red-500", "bg-blue-500"); 102 | 103 | expect(result).toBe("bg-blue-500"); 104 | }); 105 | 106 | test("should merge multiple conflicting classes", () => { 107 | const result = cn("px-2 py-1 text-sm", "px-4 py-2 text-lg"); 108 | 109 | expect(result).toBe("px-4 py-2 text-lg"); 110 | }); 111 | 112 | test("should handle non-conflicting classes", () => { 113 | const result = cn("px-2", "py-2", "text-sm"); 114 | 115 | expect(result).toBe("px-2 py-2 text-sm"); 116 | }); 117 | 118 | test("should return undefined when no classes provided", () => { 119 | const result = cn(); 120 | 121 | expect(result).toBeUndefined(); 122 | }); 123 | 124 | test("should handle arrays with tailwind-merge", () => { 125 | const result = cn(["px-2", "px-4"], "py-2"); 126 | 127 | expect(result).toBe("px-4 py-2"); 128 | }); 129 | 130 | test("should handle objects with tailwind-merge", () => { 131 | const result = cn({"px-2": true, "px-4": true, "py-2": true}); 132 | 133 | expect(result).toBe("px-4 py-2"); 134 | }); 135 | 136 | test("should handle complex className with conditional object classes", () => { 137 | const selectedZoom: string = "a"; 138 | const key: string = "b"; 139 | 140 | const result = cn( 141 | "text-foreground ease-in-out-quad absolute left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2 scale-75 text-[21px] font-medium opacity-0 transition-[scale,opacity] duration-[300ms] ease-[cubic-bezier(0.33,1,0.68,1)] data-[selected=true]:scale-100 data-[selected=true]:opacity-100 data-[selected=true]:delay-200", 142 | { 143 | "sr-only": selectedZoom !== key, 144 | }, 145 | ); 146 | 147 | expect(result).toContain("text-foreground"); 148 | expect(result).toContain("sr-only"); 149 | expect(typeof result).toBe("string"); 150 | }); 151 | 152 | test("should handle conditional object classes when condition is false", () => { 153 | const selectedZoom: string = "a"; 154 | const key: string = "a"; 155 | 156 | const result = cn("text-xl font-bold", { 157 | "sr-only": selectedZoom !== key, 158 | }); 159 | 160 | expect(result).toBe("text-xl font-bold"); 161 | expect(result).not.toContain("sr-only"); 162 | }); 163 | }); 164 | 165 | describe("cnMerge function with tailwind-merge config", () => { 166 | test("should merge conflicting tailwind classes when twMerge is true", () => { 167 | const result = cnMerge("px-2", "px-4", "py-2")({twMerge: true}); 168 | 169 | expect(result).toBe("px-4 py-2"); 170 | }); 171 | 172 | test("should not merge classes when twMerge is false", () => { 173 | const result = cnMerge("px-2", "px-4", "py-2")({twMerge: false}); 174 | 175 | expect(result).toBe("px-2 px-4 py-2"); 176 | }); 177 | 178 | test("should merge text color classes", () => { 179 | const result = cnMerge("text-red-500", "text-blue-500")({twMerge: true}); 180 | 181 | expect(result).toBe("text-blue-500"); 182 | }); 183 | 184 | test("should merge background color classes", () => { 185 | const result = cnMerge("bg-red-500", "bg-blue-500")({twMerge: true}); 186 | 187 | expect(result).toBe("bg-blue-500"); 188 | }); 189 | 190 | test("should merge multiple conflicting classes", () => { 191 | const result = cnMerge("px-2 py-1 text-sm", "px-4 py-2 text-lg")({twMerge: true}); 192 | 193 | expect(result).toBe("px-4 py-2 text-lg"); 194 | }); 195 | 196 | test("should handle non-conflicting classes", () => { 197 | const result = cnMerge("px-2", "py-2", "text-sm")({twMerge: true}); 198 | 199 | expect(result).toBe("px-2 py-2 text-sm"); 200 | }); 201 | 202 | test("should return undefined when no classes provided", () => { 203 | const result = cnMerge()({twMerge: true}); 204 | 205 | expect(result).toBeUndefined(); 206 | }); 207 | 208 | test("should handle arrays with tailwind-merge", () => { 209 | const result = cnMerge(["px-2", "px-4"], "py-2")({twMerge: true}); 210 | 211 | expect(result).toBe("px-4 py-2"); 212 | }); 213 | 214 | test("should handle objects with tailwind-merge", () => { 215 | const result = cnMerge({"px-2": true, "px-4": true, "py-2": true})({twMerge: true}); 216 | 217 | expect(result).toBe("px-4 py-2"); 218 | }); 219 | 220 | test("should merge classes by default when no config is provided", () => { 221 | const result = cnMerge("px-2", "px-4", "py-2")(); 222 | 223 | expect(result).toBe("px-4 py-2"); 224 | }); 225 | 226 | test("should merge classes when config is undefined", () => { 227 | const result = cnMerge("px-2", "px-4", "py-2")(undefined); 228 | 229 | expect(result).toBe("px-4 py-2"); 230 | }); 231 | 232 | test("should merge classes when config is empty object (defaults to true)", () => { 233 | const result = cnMerge("px-2", "px-4", "py-2")({}); 234 | 235 | expect(result).toBe("px-4 py-2"); 236 | }); 237 | 238 | test("should not merge classes when twMerge is explicitly false", () => { 239 | const result = cnMerge("px-2", "px-4", "py-2")({twMerge: false}); 240 | 241 | expect(result).toBe("px-2 px-4 py-2"); 242 | }); 243 | 244 | test("should merge classes when twMerge is explicitly true", () => { 245 | const result = cnMerge("px-2", "px-4", "py-2")({twMerge: true}); 246 | 247 | expect(result).toBe("px-4 py-2"); 248 | }); 249 | 250 | test("should handle complex className with conditional object classes", () => { 251 | const selectedZoom: string = "a"; 252 | const key: string = "b"; 253 | 254 | const result = cnMerge( 255 | "text-foreground ease-in-out-quad absolute left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2 scale-75 text-[21px] font-medium opacity-0 transition-[scale,opacity] duration-[300ms] ease-[cubic-bezier(0.33,1,0.68,1)] data-[selected=true]:scale-100 data-[selected=true]:opacity-100 data-[selected=true]:delay-200", 256 | { 257 | "sr-only": selectedZoom !== key, 258 | }, 259 | )(); 260 | 261 | expect(result).toContain("text-foreground"); 262 | expect(result).toContain("sr-only"); 263 | expect(typeof result).toBe("string"); 264 | }); 265 | }); 266 | 267 | describe.each(cxVariants)("cx function - $name", ({cx}) => { 268 | test("should join strings and ignore falsy values", () => { 269 | expect(cx("text-xl", false && "font-bold", "text-center")).toBe("text-xl text-center"); 270 | expect(cx("text-xl", undefined, null, 0, "")).toBe("text-xl 0"); 271 | }); 272 | 273 | test("should join arrays of class names", () => { 274 | expect(cx(["px-4", "py-2"], "bg-blue-500")).toBe("px-4 py-2 bg-blue-500"); 275 | expect(cx(["px-4", false, ["hover:bg-red-500", null, "rounded-lg"]])).toBe( 276 | "px-4 hover:bg-red-500 rounded-lg", 277 | ); 278 | }); 279 | 280 | test("should handle nested arrays", () => { 281 | expect(cx(["px-4", ["py-2", ["bg-blue-500", ["rounded-lg", false, ["shadow-md"]]]]])).toBe( 282 | "px-4 py-2 bg-blue-500 rounded-lg shadow-md", 283 | ); 284 | }); 285 | 286 | test("should join objects with truthy values as keys", () => { 287 | expect(cx({"text-sm": true, "font-bold": false, "bg-green-200": 1, "m-0": 0})).toBe( 288 | "text-sm bg-green-200", 289 | ); 290 | }); 291 | 292 | test("should handle mixed arguments correctly", () => { 293 | expect( 294 | cx( 295 | "text-lg", 296 | ["px-3", {"hover:bg-yellow-300": true, "focus:outline-none": false}], 297 | {"rounded-md": true, "shadow-md": null}, 298 | "leading-tight", 299 | ), 300 | ).toBe("text-lg px-3 hover:bg-yellow-300 rounded-md leading-tight"); 301 | }); 302 | 303 | test("should handle numbers and bigint", () => { 304 | expect(cx(123, "text-base", 0n, {border: true})).toBe("123 text-base 0 border"); 305 | }); 306 | 307 | test("should return undefined for no input", () => { 308 | expect(cx()).toBeUndefined(); 309 | }); 310 | 311 | test("should return '0' for zero and ignore other falsy", () => { 312 | expect(cx(false, null, undefined, "", 0)).toBe("0"); 313 | }); 314 | 315 | test("should normalize template strings with irregular whitespace", () => { 316 | const input = ` 317 | px-4 318 | py-2 319 | 320 | bg-blue-500 321 | rounded-lg 322 | `; 323 | 324 | expect(cx(input)).toBe("px-4 py-2 bg-blue-500 rounded-lg"); 325 | 326 | expect( 327 | cx( 328 | ` text-center 329 | font-semibold `, 330 | ["text-sm", ` uppercase `], 331 | {"shadow-lg": true, "opacity-50": false}, 332 | ), 333 | ).toBe("text-center font-semibold text-sm uppercase shadow-lg"); 334 | }); 335 | 336 | test("should handle empty and falsy values correctly", () => { 337 | expect(cx("", null, undefined, false, NaN, 0, "0")).toBe("0 0"); 338 | }); 339 | 340 | test("should NOT merge conflicting classes (simple concatenation)", () => { 341 | // cx should just concatenate, not merge 342 | expect(cx("px-2", "px-4", "py-2")).toBe("px-2 px-4 py-2"); 343 | }); 344 | 345 | test("should handle conflicting classes without merging", () => { 346 | expect(cx("text-red-500", "text-blue-500")).toBe("text-red-500 text-blue-500"); 347 | }); 348 | }); 349 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import Benchmark from "benchmark"; 3 | import {cva} from "class-variance-authority"; 4 | import {extendTailwindMerge} from "tailwind-merge"; 5 | 6 | import {tv, cx, cn} from "./src/index.js"; 7 | 8 | const suite = new Benchmark.Suite(); 9 | 10 | const COMMON_UNITS = ["small", "medium", "large"]; 11 | 12 | const twMergeConfig = { 13 | theme: { 14 | opacity: ["disabled"], 15 | spacing: [ 16 | "divider", 17 | "unit", 18 | "unit-2", 19 | "unit-4", 20 | "unit-6", 21 | "unit-8", 22 | "unit-10", 23 | "unit-12", 24 | "unit-14", 25 | ], 26 | borderWidth: COMMON_UNITS, 27 | borderRadius: COMMON_UNITS, 28 | }, 29 | classGroups: { 30 | shadow: [{shadow: COMMON_UNITS}], 31 | "font-size": [{text: ["tiny", ...COMMON_UNITS]}], 32 | "bg-image": ["bg-stripe-gradient"], 33 | "min-w": [ 34 | { 35 | "min-w": ["unit", "unit-2", "unit-4", "unit-6", "unit-8", "unit-10", "unit-12", "unit-14"], 36 | }, 37 | ], 38 | }, 39 | }; 40 | 41 | // without slots no custom tw-merge config 42 | const noSlots = { 43 | avatar: tv({ 44 | base: "relative flex shrink-0 overflow-hidden rounded-full", 45 | variants: { 46 | size: { 47 | xs: "h-6 w-6", 48 | sm: "h-8 w-8", 49 | md: "h-10 w-10", 50 | lg: "h-12 w-12", 51 | xl: "h-14 w-14", 52 | }, 53 | }, 54 | defaultVariants: { 55 | size: "md", 56 | }, 57 | compoundVariants: [ 58 | { 59 | size: ["xs", "sm"], 60 | class: "ring-1", 61 | }, 62 | { 63 | size: ["md", "lg", "xl", "2xl"], 64 | class: "ring-2", 65 | }, 66 | ], 67 | }), 68 | image: tv({ 69 | base: "aspect-square h-full w-full", 70 | variants: { 71 | withBorder: { 72 | true: "border-1.5 border-white", 73 | }, 74 | }, 75 | }), 76 | fallback: tv({ 77 | base: "flex h-full w-full items-center justify-center rounded-full bg-muted", 78 | variants: { 79 | size: { 80 | xs: "text-xs", 81 | sm: "text-sm", 82 | md: "text-base", 83 | lg: "text-lg", 84 | xl: "text-xl", 85 | }, 86 | }, 87 | defaultVariants: { 88 | size: "md", 89 | }, 90 | }), 91 | }; 92 | 93 | // without slots & no tw-merge enabled 94 | const noSlotsNoTwMerge = { 95 | avatar: tv( 96 | { 97 | base: "relative flex shrink-0 overflow-hidden rounded-full", 98 | variants: { 99 | size: { 100 | xs: "h-6 w-6", 101 | sm: "h-8 w-8", 102 | md: "h-10 w-10", 103 | lg: "h-12 w-12", 104 | xl: "h-14 w-14", 105 | }, 106 | }, 107 | defaultVariants: { 108 | size: "md", 109 | }, 110 | compoundVariants: [ 111 | { 112 | size: ["xs", "sm"], 113 | class: "ring-1", 114 | }, 115 | { 116 | size: ["md", "lg", "xl", "2xl"], 117 | class: "ring-2", 118 | }, 119 | ], 120 | }, 121 | { 122 | twMerge: false, 123 | }, 124 | ), 125 | image: tv( 126 | { 127 | base: "aspect-square h-full w-full", 128 | variants: { 129 | withBorder: { 130 | true: "border-1.5 border-white", 131 | }, 132 | }, 133 | }, 134 | { 135 | twMerge: false, 136 | }, 137 | ), 138 | fallback: tv( 139 | { 140 | base: "flex h-full w-full items-center justify-center rounded-full bg-muted", 141 | variants: { 142 | size: { 143 | xs: "text-xs", 144 | sm: "text-sm", 145 | md: "text-base", 146 | lg: "text-lg", 147 | xl: "text-xl", 148 | }, 149 | }, 150 | defaultVariants: { 151 | size: "md", 152 | }, 153 | }, 154 | { 155 | twMerge: false, 156 | }, 157 | ), 158 | }; 159 | 160 | // without slots & custom tw-merge config 161 | const noSlotsWithCustomConfig = { 162 | avatar: tv( 163 | { 164 | base: "relative flex shrink-0 overflow-hidden rounded-full", 165 | variants: { 166 | size: { 167 | xs: "h-unit-6 w-unit-6", 168 | sm: "h-unit-8 w-unit-8", 169 | md: "h-unit-10 w-unit-10", 170 | lg: "h-unit-12 w-unit-12", 171 | xl: "h-unit-14 w-unit-14", 172 | }, 173 | }, 174 | defaultVariants: { 175 | size: "md", 176 | }, 177 | compoundVariants: [ 178 | { 179 | size: ["xs", "sm"], 180 | class: "ring-1", 181 | }, 182 | { 183 | size: ["md", "lg", "xl", "2xl"], 184 | class: "ring-2", 185 | }, 186 | ], 187 | }, 188 | { 189 | twMergeConfig, 190 | }, 191 | ), 192 | image: tv({ 193 | base: "aspect-square h-full w-full", 194 | variants: { 195 | withBorder: { 196 | true: "border-1.5 border-white", 197 | }, 198 | }, 199 | }), 200 | fallback: tv( 201 | { 202 | base: "flex h-full w-full items-center justify-center rounded-full bg-muted", 203 | variants: { 204 | size: { 205 | sm: "text-small", 206 | md: "text-medium", 207 | lg: "text-large", 208 | }, 209 | }, 210 | defaultVariants: { 211 | size: "md", 212 | }, 213 | }, 214 | { 215 | twMergeConfig, 216 | }, 217 | ), 218 | }; 219 | 220 | // with slots no custom tw-merge config 221 | export const avatar = (twMerge = true) => 222 | tv( 223 | { 224 | slots: { 225 | base: "relative flex shrink-0 overflow-hidden rounded-full", 226 | image: "aspect-square h-full w-full", 227 | fallback: "flex h-full w-full items-center justify-center rounded-full bg-muted", 228 | }, 229 | variants: { 230 | withBorder: { 231 | true: { 232 | image: "border-1.5 border-white", 233 | }, 234 | }, 235 | size: { 236 | xs: { 237 | base: "h-6 w-6", 238 | fallback: "text-xs", 239 | }, 240 | sm: { 241 | base: "h-8 w-8", 242 | fallback: "text-sm", 243 | }, 244 | md: { 245 | base: "h-10 w-10", 246 | fallback: "text-base", 247 | }, 248 | lg: { 249 | base: "h-12 w-12", 250 | fallback: "text-large", 251 | }, 252 | xl: { 253 | base: "h-14 w-14", 254 | fallback: "text-xl", 255 | }, 256 | }, 257 | }, 258 | defaultVariants: { 259 | size: "md", 260 | withBorder: false, 261 | }, 262 | compoundVariants: [ 263 | { 264 | size: ["xs", "sm"], 265 | class: "ring-1", 266 | }, 267 | { 268 | size: ["md", "lg", "xl", "2xl"], 269 | class: "ring-2", 270 | }, 271 | ], 272 | }, 273 | { 274 | twMerge, 275 | }, 276 | ); 277 | 278 | // with slots & custom tw-merge config 279 | export const avatarWithCustomConfig = tv( 280 | { 281 | slots: { 282 | base: "relative flex shrink-0 overflow-hidden rounded-full", 283 | image: "aspect-square h-full w-full", 284 | fallback: "flex h-full w-full items-center justify-center rounded-full bg-muted", 285 | }, 286 | variants: { 287 | withBorder: { 288 | true: { 289 | image: "border-1.5 border-white", 290 | }, 291 | }, 292 | size: { 293 | sm: { 294 | base: "h-unit-8 w-unit-8", 295 | fallback: "text-small", 296 | }, 297 | md: { 298 | base: "h-unit-10 w-unit-10", 299 | fallback: "text-medium", 300 | }, 301 | lg: { 302 | base: "h-unit-12 w-unit-12", 303 | fallback: "text-large", 304 | }, 305 | }, 306 | }, 307 | defaultVariants: { 308 | size: "md", 309 | withBorder: false, 310 | }, 311 | compoundVariants: [ 312 | { 313 | size: ["sm"], 314 | class: "ring-1", 315 | }, 316 | { 317 | size: ["md", "lg"], 318 | class: "ring-2", 319 | }, 320 | ], 321 | }, 322 | { 323 | twMergeConfig, 324 | }, 325 | ); 326 | 327 | // CVA without tw-merge config 328 | const cvaNoMerge = { 329 | avatar: cva("relative flex shrink-0 overflow-hidden rounded-full", { 330 | variants: { 331 | size: { 332 | xs: "h-6 w-6", 333 | sm: "h-8 w-8", 334 | md: "h-10 w-10", 335 | lg: "h-12 w-12", 336 | xl: "h-14 w-14", 337 | }, 338 | }, 339 | defaultVariants: { 340 | size: "md", 341 | }, 342 | compoundVariants: [ 343 | { 344 | size: ["xs", "sm"], 345 | class: "ring-1", 346 | }, 347 | { 348 | size: ["md", "lg", "xl", "2xl"], 349 | class: "ring-2", 350 | }, 351 | ], 352 | }), 353 | image: cva("aspect-square h-full w-full", { 354 | variants: { 355 | withBorder: { 356 | true: "border-1.5 border-white", 357 | }, 358 | }, 359 | }), 360 | fallback: cva("flex h-full w-full items-center justify-center rounded-full bg-muted", { 361 | variants: { 362 | size: { 363 | xs: "text-xs", 364 | sm: "text-sm", 365 | md: "text-base", 366 | lg: "text-lg", 367 | xl: "text-xl", 368 | }, 369 | }, 370 | defaultVariants: { 371 | size: "md", 372 | }, 373 | }), 374 | }; 375 | 376 | const cvaMerge = extendTailwindMerge({extend: twMergeConfig}); 377 | 378 | // Test data for cx benchmarks 379 | const simpleClasses = ["text-xl", "font-bold", "text-center", "px-4", "py-2"]; 380 | const arrayClasses = [["px-4", "py-2"], "bg-blue-500", ["rounded-lg", "shadow-md"]]; 381 | const objectClasses = { 382 | "text-sm": true, 383 | "font-bold": false, 384 | "bg-green-200": 1, 385 | "m-0": 0, 386 | "px-2": true, 387 | "py-2": true, 388 | }; 389 | const mixedClasses = [ 390 | "text-lg", 391 | ["px-3", {"hover:bg-yellow-300": true, "focus:outline-none": false}], 392 | {"rounded-md": true, "shadow-md": null}, 393 | "leading-tight", 394 | ]; 395 | const nestedArrays = [ 396 | "px-4", 397 | ["py-2", ["bg-blue-500", ["rounded-lg", false, ["shadow-md", ["text-white"]]]]], 398 | ]; 399 | const withFalsy = ["text-xl", false && "font-bold", "text-center", undefined, null, 0, "", "px-4"]; 400 | 401 | // add tests 402 | suite 403 | .add("TV without slots & tw-merge (enabled)", function () { 404 | noSlots.avatar({size: "md"}); 405 | noSlots.fallback(); 406 | noSlots.image(); 407 | }) 408 | .add("TV without slots & tw-merge (disabled)", function () { 409 | noSlotsNoTwMerge.avatar({size: "md"}); 410 | noSlotsNoTwMerge.fallback(); 411 | noSlotsNoTwMerge.image(); 412 | }) 413 | .add("TV with slots & tw-merge (enabled)", function () { 414 | const {base, fallback, image} = avatar(true)({size: "md"}); 415 | 416 | base(); 417 | fallback(); 418 | image(); 419 | }) 420 | .add("TV with slots & tw-merge (disabled)", function () { 421 | const {base, fallback, image} = avatar(false)({size: "md"}); 422 | 423 | base(); 424 | fallback(); 425 | image(); 426 | }) 427 | .add("TV without slots & custom tw-merge config", function () { 428 | noSlotsWithCustomConfig.avatar({size: "md"}); 429 | noSlotsWithCustomConfig.fallback(); 430 | noSlotsWithCustomConfig.image(); 431 | }) 432 | .add("TV with slots & custom tw-merge config", function () { 433 | const {base, fallback, image} = avatarWithCustomConfig({size: "md"}); 434 | 435 | base(); 436 | fallback(); 437 | image(); 438 | }) 439 | .add("CVA without slots & tw-merge (enabled)", function () { 440 | cvaMerge(cvaNoMerge.avatar({size: "md"})); 441 | cvaMerge(cvaNoMerge.fallback()); 442 | cvaMerge(cvaNoMerge.image()); 443 | }) 444 | .add("CVA without slots & tw-merge (disabled)", function () { 445 | cvaNoMerge.avatar({size: "md"}); 446 | cvaNoMerge.fallback(); 447 | cvaNoMerge.image(); 448 | }) 449 | .add("cx - simple strings", function () { 450 | cx(...simpleClasses); 451 | }) 452 | .add("cx - arrays", function () { 453 | cx(...arrayClasses); 454 | }) 455 | .add("cx - objects", function () { 456 | cx(objectClasses); 457 | }) 458 | .add("cx - mixed arguments", function () { 459 | cx(...mixedClasses); 460 | }) 461 | .add("cx - nested arrays", function () { 462 | cx(...nestedArrays); 463 | }) 464 | .add("cx - with falsy values", function () { 465 | cx(...withFalsy); 466 | }) 467 | .add("cn - simple strings (with tw-merge)", function () { 468 | cn(...simpleClasses)({twMerge: true}); 469 | }) 470 | .add("cn - arrays (with tw-merge)", function () { 471 | cn(...arrayClasses)({twMerge: true}); 472 | }) 473 | .add("cn - objects (with tw-merge)", function () { 474 | cn(objectClasses)({twMerge: true}); 475 | }) 476 | .add("cn - mixed arguments (with tw-merge)", function () { 477 | cn(...mixedClasses)({twMerge: true}); 478 | }) 479 | .add("cn - nested arrays (with tw-merge)", function () { 480 | cn(...nestedArrays)({twMerge: true}); 481 | }) 482 | .add("cn - with falsy values (with tw-merge)", function () { 483 | cn(...withFalsy)({twMerge: true}); 484 | }) 485 | .add("cn - simple strings (without tw-merge)", function () { 486 | cn(...simpleClasses)({twMerge: false}); 487 | }) 488 | 489 | // add listeners 490 | .on("cycle", function (event) { 491 | console.log(String(event.target)); 492 | }) 493 | .on("complete", function () { 494 | console.log("Fastest is " + this.filter("fastest").map("name")); 495 | }) 496 | // run async 497 | .run({async: true}); 498 | -------------------------------------------------------------------------------- /src/__tests__/tv.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, describe, test} from "@jest/globals"; 2 | 3 | import {tv, cnMerge} from "../index"; 4 | 5 | const COMMON_UNITS = ["small", "medium", "large"]; 6 | 7 | const twMergeConfig = { 8 | extend: { 9 | theme: { 10 | opacity: ["disabled"], 11 | spacing: ["divider", "unit", "unit-2", "unit-4", "unit-6"], 12 | borderWidth: COMMON_UNITS, 13 | borderRadius: COMMON_UNITS, 14 | }, 15 | classGroups: { 16 | shadow: [{shadow: COMMON_UNITS}], 17 | "font-size": [{text: ["tiny", ...COMMON_UNITS]}], 18 | "bg-image": ["bg-stripe-gradient"], 19 | "min-w": [ 20 | { 21 | "min-w": ["unit", "unit-2", "unit-4", "unit-6"], 22 | }, 23 | ], 24 | }, 25 | }, 26 | }; 27 | 28 | describe("Tailwind Variants (TV) - Default", () => { 29 | test("should work with nested arrays", () => { 30 | const menu = tv({ 31 | base: ["base--styles-1", ["base--styles-2", ["base--styles-3"]]], 32 | slots: { 33 | item: ["slots--item-1", ["slots--item-2", ["slots--item-3"]]], 34 | }, 35 | variants: { 36 | color: { 37 | primary: { 38 | item: [ 39 | "item--color--primary-1", 40 | ["item--color--primary-2", ["item--color--primary-3"]], 41 | ], 42 | }, 43 | }, 44 | }, 45 | }); 46 | 47 | const popover = tv({ 48 | variants: { 49 | isOpen: { 50 | true: ["isOpen--true-1", ["isOpen--true-2", ["isOpen--true-3"]]], 51 | false: ["isOpen--false-1", ["isOpen--false-2", ["isOpen--false-3"]]], 52 | }, 53 | }, 54 | }); 55 | 56 | const {base, item} = menu({color: "primary"}); 57 | 58 | expect(base()).toHaveClass(["base--styles-1", "base--styles-2", "base--styles-3"]); 59 | expect(item()).toHaveClass([ 60 | "slots--item-1", 61 | "slots--item-2", 62 | "slots--item-3", 63 | "item--color--primary-1", 64 | "item--color--primary-2", 65 | "item--color--primary-3", 66 | ]); 67 | expect(popover({isOpen: true})).toHaveClass([ 68 | "isOpen--true-1", 69 | "isOpen--true-2", 70 | "isOpen--true-3", 71 | ]); 72 | expect(popover({isOpen: false})).toHaveClass([ 73 | "isOpen--false-1", 74 | "isOpen--false-2", 75 | "isOpen--false-3", 76 | ]); 77 | }); 78 | 79 | test("should work without variants", () => { 80 | const h1 = tv({ 81 | base: "text-3xl font-bold", 82 | }); 83 | 84 | const expectedResult = "text-3xl font-bold"; 85 | const result = h1(); 86 | 87 | expect(result).toBe(expectedResult); 88 | }); 89 | 90 | test("should work with variants", () => { 91 | const h1 = tv({ 92 | base: "text-3xl font-bold", 93 | variants: { 94 | isBig: { 95 | true: "text-5xl", 96 | false: "text-2xl", 97 | }, 98 | color: { 99 | red: "text-red-500", 100 | blue: "text-blue-500", 101 | }, 102 | }, 103 | }); 104 | 105 | const result = h1({ 106 | isBig: true, 107 | color: "blue", 108 | }); 109 | 110 | const expectedResult = ["text-5xl", "font-bold", "text-blue-500"]; 111 | 112 | expect(result).toHaveClass(expectedResult); 113 | }); 114 | 115 | test("should work with variantKeys", () => { 116 | const h1 = tv({ 117 | base: "text-3xl font-bold", 118 | variants: { 119 | isBig: { 120 | true: "text-5xl", 121 | false: "text-2xl", 122 | }, 123 | color: { 124 | red: "text-red-500", 125 | blue: "text-blue-500", 126 | }, 127 | }, 128 | }); 129 | 130 | const expectedResult = ["isBig", "color"]; 131 | 132 | expect(h1.variantKeys).toHaveClass(expectedResult); 133 | }); 134 | 135 | test("should work with compoundVariants", () => { 136 | const h1 = tv({ 137 | base: "text-3xl font-bold", 138 | variants: { 139 | isBig: { 140 | true: "text-5xl", 141 | false: "text-2xl", 142 | }, 143 | color: { 144 | red: "text-red-500", 145 | blue: "text-blue-500", 146 | }, 147 | }, 148 | compoundVariants: [ 149 | { 150 | isBig: true, 151 | color: "red", 152 | class: "bg-red-500", 153 | }, 154 | { 155 | isBig: false, 156 | color: "red", 157 | class: "underline", 158 | }, 159 | ], 160 | }); 161 | 162 | expect( 163 | h1({ 164 | isBig: true, 165 | color: "red", 166 | }), 167 | ).toHaveClass(["text-5xl", "font-bold", "text-red-500", "bg-red-500"]); 168 | 169 | expect( 170 | h1({ 171 | isBig: false, 172 | color: "red", 173 | }), 174 | ).toHaveClass(["text-2xl", "font-bold", "text-red-500", "underline"]); 175 | 176 | expect( 177 | h1({ 178 | color: "red", 179 | }), 180 | ).toHaveClass(["text-2xl", "font-bold", "text-red-500", "underline"]); 181 | }); 182 | 183 | test("should throw error if the compoundVariants is not an array", () => { 184 | expect( 185 | tv({ 186 | base: "text-3xl font-bold", 187 | variants: { 188 | isBig: { 189 | true: "text-5xl", 190 | false: "text-2xl", 191 | }, 192 | color: { 193 | red: "text-red-500", 194 | blue: "text-blue-500", 195 | }, 196 | }, 197 | // @ts-expect-error 198 | compoundVariants: {}, 199 | }), 200 | ).toThrow(); 201 | }); 202 | 203 | test("should work with custom class & className", () => { 204 | const h1 = tv({ 205 | base: "text-3xl font-bold", 206 | }); 207 | 208 | const expectedResult = ["text-xl", "font-bold"]; 209 | 210 | const result1 = h1({ 211 | className: "text-xl", 212 | }); 213 | 214 | const result2 = h1({ 215 | class: "text-xl", 216 | }); 217 | 218 | expect(result1).toHaveClass(expectedResult); 219 | expect(result2).toHaveClass(expectedResult); 220 | }); 221 | 222 | test("should work without anything", () => { 223 | const styles = tv({}); 224 | const expectedResult = undefined; 225 | 226 | expect(styles()).toBe(expectedResult); 227 | }); 228 | 229 | test("should work correctly with twMerge", () => { 230 | const h1 = tv({ 231 | base: "text-3xl font-bold text-blue-400 text-xl text-blue-200", 232 | }); 233 | 234 | const expectedResult = ["font-bold", "text-xl", "text-blue-200"]; 235 | 236 | expect(h1()).toHaveClass(expectedResult); 237 | }); 238 | 239 | test("should work correctly without twMerge", () => { 240 | const h1 = tv( 241 | { 242 | base: "text-3xl font-bold text-blue-400 text-xl text-blue-200", 243 | }, 244 | { 245 | twMerge: false, 246 | }, 247 | ); 248 | 249 | const expectedResult = ["text-3xl", "font-bold", "text-blue-400", "text-xl", "text-blue-200"]; 250 | 251 | expect(h1()).toHaveClass(expectedResult); 252 | }); 253 | 254 | test("should work without defaultsVariants", () => { 255 | const button = tv({ 256 | base: "button", 257 | variants: { 258 | variant: { 259 | primary: "button--primary", 260 | secondary: "button--secondary", 261 | warning: "button--warning", 262 | error: "button--danger", 263 | }, 264 | isDisabled: { 265 | true: "button--disabled", 266 | false: "button--enabled", 267 | }, 268 | size: { 269 | small: "button--small", 270 | medium: "button--medium", 271 | large: "button--large", 272 | }, 273 | }, 274 | compoundVariants: [ 275 | { 276 | variant: "secondary", 277 | size: "small", 278 | class: "button--secondary-small", 279 | }, 280 | { 281 | variant: "warning", 282 | isDisabled: false, 283 | class: "button--warning-enabled", 284 | }, 285 | { 286 | variant: "warning", 287 | isDisabled: true, 288 | class: "button--warning-disabled", 289 | }, 290 | { 291 | variant: ["warning", "error"], 292 | class: "button--warning-danger", 293 | }, 294 | { 295 | variant: ["warning", "error"], 296 | size: "medium", 297 | class: "button--warning-danger-medium", 298 | }, 299 | ], 300 | }); 301 | 302 | const expectedResult = [ 303 | "button", 304 | "button--secondary", 305 | "button--small", 306 | "button--enabled", 307 | "button--secondary-small", 308 | ]; 309 | 310 | expect(button({variant: "secondary", size: "small", isDisabled: false})).toHaveClass( 311 | expectedResult, 312 | ); 313 | }); 314 | 315 | test("should work with simple variants", () => { 316 | const h1 = tv({ 317 | base: "text-3xl font-bold underline", 318 | variants: { 319 | color: { 320 | red: "text-red-500", 321 | blue: "text-blue-500", 322 | green: "text-green-500", 323 | }, 324 | isUnderline: { 325 | true: "underline", 326 | false: "no-underline", 327 | }, 328 | }, 329 | }); 330 | 331 | const expectedResult = "text-3xl font-bold text-green-500 no-underline"; 332 | 333 | expect(h1({color: "green", isUnderline: false})).toBe(expectedResult); 334 | }); 335 | 336 | test("should support boolean variants", () => { 337 | const h1 = tv({ 338 | base: "text-3xl", 339 | variants: { 340 | bool: { 341 | true: "underline", 342 | false: "truncate", 343 | }, 344 | }, 345 | }); 346 | 347 | expect(h1()).toHaveClass(["text-3xl", "truncate"]); 348 | expect(h1({bool: true})).toHaveClass(["text-3xl", "underline"]); 349 | expect(h1({bool: false})).toHaveClass(["text-3xl", "truncate"]); 350 | expect(h1({bool: undefined})).toHaveClass(["text-3xl", "truncate"]); 351 | }); 352 | 353 | test("should support false only variant", () => { 354 | const h1 = tv({ 355 | base: "text-3xl", 356 | variants: { 357 | bool: { 358 | false: "truncate", 359 | }, 360 | }, 361 | }); 362 | 363 | expect(h1()).toHaveClass(["text-3xl", "truncate"]); 364 | expect(h1({bool: true})).toHaveClass(["text-3xl"]); 365 | expect(h1({bool: false})).toHaveClass(["text-3xl", "truncate"]); 366 | expect(h1({bool: undefined})).toHaveClass(["text-3xl", "truncate"]); 367 | }); 368 | 369 | test("should support false only variant -- default variant", () => { 370 | const h1 = tv({ 371 | base: "text-3xl", 372 | variants: { 373 | bool: { 374 | false: "truncate", 375 | }, 376 | }, 377 | defaultVariants: { 378 | bool: true, 379 | }, 380 | }); 381 | 382 | expect(h1()).toHaveClass(["text-3xl"]); 383 | expect(h1({bool: true})).toHaveClass(["text-3xl"]); 384 | expect(h1({bool: false})).toHaveClass(["text-3xl", "truncate"]); 385 | expect(h1({bool: undefined})).toHaveClass(["text-3xl"]); 386 | }); 387 | 388 | test("should support boolean variants -- default variants", () => { 389 | const h1 = tv({ 390 | base: "text-3xl", 391 | variants: { 392 | bool: { 393 | true: "underline", 394 | false: "truncate", 395 | }, 396 | }, 397 | defaultVariants: { 398 | bool: true, 399 | }, 400 | }); 401 | 402 | expect(h1()).toHaveClass(["text-3xl", "underline"]); 403 | expect(h1({bool: true})).toHaveClass(["text-3xl", "underline"]); 404 | expect(h1({bool: false})).toHaveClass(["text-3xl", "truncate"]); 405 | expect(h1({bool: undefined})).toHaveClass(["text-3xl", "underline"]); 406 | }); 407 | 408 | test("should support boolean variants -- missing false variant", () => { 409 | const h1 = tv({ 410 | base: "text-3xl", 411 | variants: { 412 | bool: { 413 | true: "underline", 414 | }, 415 | }, 416 | }); 417 | 418 | expect(h1()).toHaveClass(["text-3xl"]); 419 | expect(h1({bool: true})).toHaveClass(["text-3xl", "underline"]); 420 | expect(h1({bool: false})).toHaveClass(["text-3xl"]); 421 | expect(h1({bool: undefined})).toHaveClass(["text-3xl"]); 422 | }); 423 | 424 | test("should support boolean variants -- missing false variant -- default variants", () => { 425 | const h1 = tv({ 426 | base: "text-3xl", 427 | variants: { 428 | bool: { 429 | true: "underline", 430 | }, 431 | }, 432 | defaultVariants: { 433 | bool: true, 434 | }, 435 | }); 436 | 437 | expect(h1()).toHaveClass(["text-3xl", "underline"]); 438 | expect(h1({bool: true})).toHaveClass(["text-3xl", "underline"]); 439 | expect(h1({bool: false})).toHaveClass(["text-3xl"]); 440 | expect(h1({bool: undefined})).toHaveClass(["text-3xl", "underline"]); 441 | }); 442 | }); 443 | 444 | describe("Tailwind Variants (TV) - Slots", () => { 445 | test("should work with slots -- default variants", () => { 446 | const menu = tv({ 447 | base: "text-3xl font-bold underline", 448 | slots: { 449 | title: "text-2xl", 450 | item: "text-xl", 451 | list: "list-none", 452 | wrapper: "flex flex-col", 453 | }, 454 | variants: { 455 | color: { 456 | primary: "color--primary", 457 | secondary: { 458 | title: "color--primary-title", 459 | item: "color--primary-item", 460 | list: "color--primary-list", 461 | wrapper: "color--primary-wrapper", 462 | }, 463 | }, 464 | size: { 465 | xs: "size--xs", 466 | sm: "size--sm", 467 | md: { 468 | title: "size--md-title", 469 | }, 470 | }, 471 | isDisabled: { 472 | true: { 473 | title: "disabled--title", 474 | }, 475 | false: { 476 | item: "enabled--item", 477 | }, 478 | }, 479 | }, 480 | defaultVariants: { 481 | color: "primary", 482 | size: "sm", 483 | isDisabled: false, 484 | }, 485 | }); 486 | 487 | // with default values 488 | const {base, title, item, list, wrapper} = menu(); 489 | 490 | expect(base()).toHaveClass([ 491 | "text-3xl", 492 | "font-bold", 493 | "underline", 494 | "color--primary", 495 | "size--sm", 496 | ]); 497 | expect(title()).toHaveClass(["text-2xl"]); 498 | expect(item()).toHaveClass(["text-xl", "enabled--item"]); 499 | expect(list()).toHaveClass(["list-none"]); 500 | expect(wrapper()).toHaveClass(["flex", "flex-col"]); 501 | }); 502 | 503 | test("should work with empty slots", () => { 504 | const menu = tv({ 505 | slots: { 506 | base: "", 507 | title: "", 508 | item: "", 509 | list: "", 510 | }, 511 | }); 512 | 513 | const {base, title, item, list} = menu(); 514 | 515 | const expectedResult = undefined; 516 | 517 | expect(base()).toBe(expectedResult); 518 | expect(title()).toBe(expectedResult); 519 | expect(item()).toBe(expectedResult); 520 | expect(list()).toBe(expectedResult); 521 | }); 522 | 523 | test("should work with slots -- default variants -- custom class & className", () => { 524 | const menu = tv({ 525 | slots: { 526 | base: "text-3xl font-bold underline", 527 | title: "text-2xl", 528 | item: "text-xl", 529 | list: "list-none", 530 | wrapper: "flex flex-col", 531 | }, 532 | variants: { 533 | color: { 534 | primary: { 535 | base: "bg-blue-500", 536 | }, 537 | secondary: { 538 | title: "text-white", 539 | item: "bg-purple-100", 540 | list: "bg-purple-200", 541 | wrapper: "bg-transparent", 542 | }, 543 | }, 544 | size: { 545 | xs: { 546 | base: "text-xs", 547 | }, 548 | sm: { 549 | base: "text-sm", 550 | }, 551 | md: { 552 | title: "text-md", 553 | }, 554 | }, 555 | isDisabled: { 556 | true: { 557 | title: "opacity-50", 558 | }, 559 | false: { 560 | item: "opacity-100", 561 | }, 562 | }, 563 | }, 564 | defaultVariants: { 565 | color: "primary", 566 | size: "sm", 567 | isDisabled: false, 568 | }, 569 | }); 570 | 571 | // with default values 572 | const {base, title, item, list, wrapper} = menu(); 573 | 574 | // base 575 | expect(base({class: "text-lg"})).toHaveClass([ 576 | "font-bold", 577 | "underline", 578 | "bg-blue-500", 579 | "text-lg", 580 | ]); 581 | expect(base({className: "text-lg"})).toHaveClass([ 582 | "font-bold", 583 | "underline", 584 | "bg-blue-500", 585 | "text-lg", 586 | ]); 587 | // title 588 | expect(title({class: "text-2xl"})).toHaveClass(["text-2xl"]); 589 | expect(title({className: "text-2xl"})).toHaveClass(["text-2xl"]); 590 | // item 591 | expect(item({class: "text-sm"})).toHaveClass(["text-sm", "opacity-100"]); 592 | expect(list({className: "bg-blue-50"})).toHaveClass(["list-none", "bg-blue-50"]); 593 | // list 594 | expect(wrapper({class: "flex-row"})).toHaveClass(["flex", "flex-row"]); 595 | expect(wrapper({className: "flex-row"})).toHaveClass(["flex", "flex-row"]); 596 | }); 597 | 598 | test("should work with slots -- custom variants", () => { 599 | const menu = tv({ 600 | base: "text-3xl font-bold underline", 601 | slots: { 602 | title: "text-2xl", 603 | item: "text-xl", 604 | list: "list-none", 605 | wrapper: "flex flex-col", 606 | }, 607 | variants: { 608 | color: { 609 | primary: "color--primary", 610 | secondary: { 611 | base: "color--secondary-base", 612 | title: "color--secondary-title", 613 | item: "color--secondary-item", 614 | list: "color--secondary-list", 615 | wrapper: "color--secondary-wrapper", 616 | }, 617 | }, 618 | size: { 619 | xs: "size--xs", 620 | sm: "size--sm", 621 | md: { 622 | title: "size--md-title", 623 | }, 624 | }, 625 | isDisabled: { 626 | true: { 627 | title: "disabled--title", 628 | }, 629 | false: { 630 | item: "enabled--item", 631 | }, 632 | }, 633 | }, 634 | defaultVariants: { 635 | color: "primary", 636 | size: "sm", 637 | isDisabled: false, 638 | }, 639 | }); 640 | 641 | // with custom props 642 | const {base, title, item, list, wrapper} = menu({ 643 | color: "secondary", 644 | size: "md", 645 | }); 646 | 647 | expect(base()).toHaveClass(["text-3xl", "font-bold", "underline", "color--secondary-base"]); 648 | expect(title()).toHaveClass(["text-2xl", "size--md-title", "color--secondary-title"]); 649 | expect(item()).toHaveClass(["text-xl", "color--secondary-item", "enabled--item"]); 650 | expect(list()).toHaveClass(["list-none", "color--secondary-list"]); 651 | expect(wrapper()).toHaveClass(["flex", "flex-col", "color--secondary-wrapper"]); 652 | }); 653 | 654 | test("should work with slots -- custom variants -- custom class & className", () => { 655 | const menu = tv({ 656 | slots: { 657 | base: "text-3xl font-bold underline", 658 | title: "text-2xl", 659 | item: "text-xl", 660 | list: "list-none", 661 | wrapper: "flex flex-col", 662 | }, 663 | variants: { 664 | color: { 665 | primary: { 666 | base: "bg-blue-500", 667 | }, 668 | secondary: { 669 | title: "text-white", 670 | item: "bg-purple-100", 671 | list: "bg-purple-200", 672 | wrapper: "bg-transparent", 673 | }, 674 | }, 675 | size: { 676 | xs: { 677 | base: "text-xs", 678 | }, 679 | sm: { 680 | base: "text-sm", 681 | }, 682 | md: { 683 | base: "text-md", 684 | title: "text-md", 685 | }, 686 | }, 687 | isDisabled: { 688 | true: { 689 | title: "opacity-50", 690 | }, 691 | false: { 692 | item: "opacity-100", 693 | }, 694 | }, 695 | }, 696 | defaultVariants: { 697 | color: "primary", 698 | size: "sm", 699 | isDisabled: false, 700 | }, 701 | }); 702 | 703 | // with default values 704 | const {base, title, item, list, wrapper} = menu({ 705 | color: "secondary", 706 | size: "md", 707 | }); 708 | 709 | // base 710 | expect(base({class: "text-xl"})).toHaveClass(["text-xl", "font-bold", "underline"]); 711 | expect(base({className: "text-xl"})).toHaveClass(["text-xl", "font-bold", "underline"]); 712 | // title 713 | expect(title({class: "text-2xl"})).toHaveClass(["text-2xl", "text-white"]); 714 | expect(title({className: "text-2xl"})).toHaveClass(["text-2xl", "text-white"]); 715 | //item 716 | expect(item({class: "bg-purple-50"})).toHaveClass(["text-xl", "bg-purple-50", "opacity-100"]); 717 | expect(item({className: "bg-purple-50"})).toHaveClass([ 718 | "text-xl", 719 | "bg-purple-50", 720 | "opacity-100", 721 | ]); 722 | // list 723 | expect(list({class: "bg-purple-100"})).toHaveClass(["list-none", "bg-purple-100"]); 724 | expect(list({className: "bg-purple-100"})).toHaveClass(["list-none", "bg-purple-100"]); 725 | // wrapper 726 | expect(wrapper({class: "bg-purple-900 flex-row"})).toHaveClass([ 727 | "flex", 728 | "bg-purple-900", 729 | "flex-row", 730 | ]); 731 | expect(wrapper({className: "bg-purple-900 flex-row"})).toHaveClass([ 732 | "flex", 733 | "bg-purple-900", 734 | "flex-row", 735 | ]); 736 | }); 737 | 738 | test("should work with slots and compoundVariants", () => { 739 | const menu = tv({ 740 | base: "text-3xl font-bold underline", 741 | slots: { 742 | title: "text-2xl", 743 | item: "text-xl", 744 | list: "list-none", 745 | wrapper: "flex flex-col", 746 | }, 747 | variants: { 748 | color: { 749 | primary: "color--primary", 750 | secondary: { 751 | base: "color--secondary-base", 752 | title: "color--secondary-title", 753 | item: "color--secondary-item", 754 | list: "color--secondary-list", 755 | wrapper: "color--secondary-wrapper", 756 | }, 757 | }, 758 | size: { 759 | xs: "size--xs", 760 | 761 | sm: "size--sm", 762 | md: { 763 | title: "size--md-title", 764 | }, 765 | }, 766 | isDisabled: { 767 | true: { 768 | title: "disabled--title", 769 | }, 770 | false: { 771 | item: "enabled--item", 772 | }, 773 | }, 774 | }, 775 | defaultVariants: { 776 | color: "primary", 777 | size: "sm", 778 | isDisabled: false, 779 | }, 780 | compoundVariants: [ 781 | { 782 | color: "secondary", 783 | size: "md", 784 | class: { 785 | base: "compound--base", 786 | title: "compound--title", 787 | item: "compound--item", 788 | list: "compound--list", 789 | wrapper: "compound--wrapper", 790 | }, 791 | }, 792 | ], 793 | }); 794 | 795 | const {base, title, item, list, wrapper} = menu({ 796 | color: "secondary", 797 | size: "md", 798 | }); 799 | 800 | expect(base()).toHaveClass([ 801 | "text-3xl", 802 | "font-bold", 803 | "underline", 804 | "color--secondary-base", 805 | "compound--base", 806 | ]); 807 | expect(title()).toHaveClass([ 808 | "text-2xl", 809 | "size--md-title", 810 | "color--secondary-title", 811 | "compound--title", 812 | ]); 813 | expect(item()).toHaveClass([ 814 | "text-xl", 815 | "color--secondary-item", 816 | "enabled--item", 817 | "compound--item", 818 | ]); 819 | expect(list()).toHaveClass(["list-none", "color--secondary-list", "compound--list"]); 820 | expect(wrapper()).toHaveClass([ 821 | "flex", 822 | "flex-col", 823 | "color--secondary-wrapper", 824 | "compound--wrapper", 825 | ]); 826 | }); 827 | 828 | test("should support slot level variant overrides", () => { 829 | const menu = tv({ 830 | base: "text-3xl", 831 | slots: { 832 | title: "text-2xl", 833 | }, 834 | variants: { 835 | color: { 836 | primary: { 837 | base: "color--primary-base", 838 | title: "color--primary-title", 839 | }, 840 | secondary: { 841 | base: "color--secondary-base", 842 | title: "color--secondary-title", 843 | }, 844 | }, 845 | }, 846 | defaultVariants: { 847 | color: "primary", 848 | }, 849 | }); 850 | 851 | const {base, title} = menu(); 852 | 853 | expect(base()).toHaveClass(["text-3xl", "color--primary-base"]); 854 | expect(title()).toHaveClass(["text-2xl", "color--primary-title"]); 855 | expect(base({color: "secondary"})).toHaveClass(["text-3xl", "color--secondary-base"]); 856 | expect(title({color: "secondary"})).toHaveClass(["text-2xl", "color--secondary-title"]); 857 | }); 858 | 859 | test("should support slot level variant overrides - compoundSlots", () => { 860 | const menu = tv({ 861 | base: "text-3xl", 862 | slots: { 863 | title: "text-2xl", 864 | subtitle: "text-xl", 865 | }, 866 | variants: { 867 | color: { 868 | primary: { 869 | base: "color--primary-base", 870 | title: "color--primary-title", 871 | subtitle: "color--primary-subtitle", 872 | }, 873 | secondary: { 874 | base: "color--secondary-base", 875 | title: "color--secondary-title", 876 | subtitle: "color--secondary-subtitle", 877 | }, 878 | }, 879 | }, 880 | compoundSlots: [ 881 | { 882 | slots: ["title", "subtitle"], 883 | color: "secondary", 884 | class: ["truncate"], 885 | }, 886 | ], 887 | defaultVariants: { 888 | color: "primary", 889 | }, 890 | }); 891 | 892 | const {base, title, subtitle} = menu(); 893 | 894 | expect(base()).toHaveClass(["text-3xl", "color--primary-base"]); 895 | expect(title()).toHaveClass(["text-2xl", "color--primary-title"]); 896 | expect(subtitle()).toHaveClass(["text-xl", "color--primary-subtitle"]); 897 | expect(base({color: "secondary"})).toHaveClass(["text-3xl", "color--secondary-base"]); 898 | expect(title({color: "secondary"})).toHaveClass([ 899 | "text-2xl", 900 | "color--secondary-title", 901 | "truncate", 902 | ]); 903 | expect(subtitle({color: "secondary"})).toHaveClass([ 904 | "text-xl", 905 | "color--secondary-subtitle", 906 | "truncate", 907 | ]); 908 | }); 909 | 910 | test("should support slot level variant and array variants overrides - compoundSlots", () => { 911 | const menu = tv({ 912 | slots: { 913 | base: "flex flex-wrap", 914 | cursor: ["absolute", "flex", "overflow-visible"], 915 | }, 916 | variants: { 917 | size: { 918 | xs: {}, 919 | sm: {}, 920 | }, 921 | }, 922 | compoundSlots: [ 923 | { 924 | slots: ["base"], 925 | size: ["xs", "sm"], 926 | class: "w-7 h-7 text-xs", 927 | }, 928 | ], 929 | }); 930 | 931 | const {base, cursor} = menu(); 932 | 933 | expect(base()).toEqual("flex flex-wrap"); 934 | expect(base({size: "xs"})).toEqual("flex flex-wrap w-7 h-7 text-xs"); 935 | expect(base({size: "sm"})).toEqual("flex flex-wrap w-7 h-7 text-xs"); 936 | expect(cursor()).toEqual("absolute flex overflow-visible"); 937 | }); 938 | 939 | test("should not override the default classes when the variant doesn't match - compoundSlots", () => { 940 | const tabs = tv({ 941 | slots: { 942 | base: "inline-flex", 943 | tabList: ["flex"], 944 | tab: ["z-0", "w-full", "px-3", "py-1", "flex", "group", "relative"], 945 | tabContent: ["relative", "z-10", "text-inherit", "whitespace-nowrap"], 946 | cursor: ["absolute", "z-0", "bg-white"], 947 | panel: ["py-3", "px-1", "outline-none"], 948 | }, 949 | variants: { 950 | variant: { 951 | solid: {}, 952 | light: {}, 953 | underlined: {}, 954 | bordered: {}, 955 | }, 956 | color: { 957 | default: {}, 958 | primary: {}, 959 | secondary: {}, 960 | success: {}, 961 | warning: {}, 962 | danger: {}, 963 | }, 964 | size: { 965 | sm: { 966 | tabList: "rounded-md", 967 | tab: "h-7 text-xs rounded-sm", 968 | cursor: "rounded-sm", 969 | }, 970 | md: { 971 | tabList: "rounded-md", 972 | tab: "h-8 text-sm rounded-sm", 973 | cursor: "rounded-sm", 974 | }, 975 | lg: { 976 | tabList: "rounded-lg", 977 | tab: "h-9 text-md rounded-md", 978 | cursor: "rounded-md", 979 | }, 980 | }, 981 | radius: { 982 | none: { 983 | tabList: "rounded-none", 984 | tab: "rounded-none", 985 | cursor: "rounded-none", 986 | }, 987 | sm: { 988 | tabList: "rounded-md", 989 | tab: "rounded-sm", 990 | cursor: "rounded-sm", 991 | }, 992 | md: { 993 | tabList: "rounded-md", 994 | tab: "rounded-sm", 995 | cursor: "rounded-sm", 996 | }, 997 | lg: { 998 | tabList: "rounded-lg", 999 | tab: "rounded-md", 1000 | cursor: "rounded-md", 1001 | }, 1002 | full: { 1003 | tabList: "rounded-full", 1004 | tab: "rounded-full", 1005 | cursor: "rounded-full", 1006 | }, 1007 | }, 1008 | }, 1009 | defaultVariants: { 1010 | color: "default", 1011 | variant: "solid", 1012 | size: "md", 1013 | }, 1014 | compoundSlots: [ 1015 | { 1016 | variant: "underlined", 1017 | slots: ["tab", "tabList", "cursor"], 1018 | class: ["rounded-none"], 1019 | }, 1020 | ], 1021 | }); 1022 | 1023 | const {tab, tabList, cursor} = tabs(); 1024 | 1025 | expect(tab()).toHaveClass([ 1026 | "z-0", 1027 | "w-full", 1028 | "px-3", 1029 | "py-1", 1030 | "h-8", 1031 | "flex", 1032 | "group", 1033 | "relative", 1034 | "text-sm", 1035 | "rounded-sm", 1036 | ]); 1037 | expect(tabList()).toHaveClass(["flex", "rounded-md"]); 1038 | expect(cursor()).toHaveClass(["absolute", "z-0", "bg-white", "rounded-sm"]); 1039 | }); 1040 | 1041 | test("should override the default classes when the variant matches - compoundSlots", () => { 1042 | const tabs = tv({ 1043 | slots: { 1044 | base: "inline-flex", 1045 | tabList: ["flex"], 1046 | tab: ["z-0", "w-full", "px-3", "py-1", "flex", "group", "relative"], 1047 | tabContent: ["relative", "z-10", "text-inherit", "whitespace-nowrap"], 1048 | cursor: ["absolute", "z-0", "bg-white"], 1049 | panel: ["py-3", "px-1", "outline-none"], 1050 | }, 1051 | variants: { 1052 | variant: { 1053 | solid: {}, 1054 | light: {}, 1055 | underlined: {}, 1056 | bordered: {}, 1057 | }, 1058 | color: { 1059 | default: {}, 1060 | primary: {}, 1061 | secondary: {}, 1062 | success: {}, 1063 | warning: {}, 1064 | danger: {}, 1065 | }, 1066 | size: { 1067 | sm: { 1068 | tabList: "rounded-md", 1069 | tab: "h-7 text-xs rounded-sm", 1070 | cursor: "rounded-sm", 1071 | }, 1072 | md: { 1073 | tabList: "rounded-md", 1074 | tab: "h-8 text-sm rounded-sm", 1075 | cursor: "rounded-sm", 1076 | }, 1077 | lg: { 1078 | tabList: "rounded-lg", 1079 | tab: "h-9 text-md rounded-md", 1080 | cursor: "rounded-md", 1081 | }, 1082 | }, 1083 | radius: { 1084 | none: { 1085 | tabList: "rounded-none", 1086 | tab: "rounded-none", 1087 | cursor: "rounded-none", 1088 | }, 1089 | sm: { 1090 | tabList: "rounded-md", 1091 | tab: "rounded-sm", 1092 | cursor: "rounded-sm", 1093 | }, 1094 | md: { 1095 | tabList: "rounded-md", 1096 | tab: "rounded-sm", 1097 | cursor: "rounded-sm", 1098 | }, 1099 | lg: { 1100 | tabList: "rounded-lg", 1101 | tab: "rounded-md", 1102 | cursor: "rounded-md", 1103 | }, 1104 | full: { 1105 | tabList: "rounded-full", 1106 | tab: "rounded-full", 1107 | cursor: "rounded-full", 1108 | }, 1109 | }, 1110 | }, 1111 | defaultVariants: { 1112 | color: "default", 1113 | variant: "solid", 1114 | size: "md", 1115 | }, 1116 | compoundSlots: [ 1117 | { 1118 | variant: "underlined", 1119 | slots: ["tab", "tabList", "cursor"], 1120 | class: ["rounded-none"], 1121 | }, 1122 | ], 1123 | }); 1124 | 1125 | const {tab, tabList, cursor} = tabs({variant: "underlined"}); 1126 | 1127 | expect(tab()).toHaveClass([ 1128 | "z-0", 1129 | "w-full", 1130 | "px-3", 1131 | "py-1", 1132 | "h-8", 1133 | "flex", 1134 | "group", 1135 | "relative", 1136 | "text-sm", 1137 | "rounded-none", 1138 | ]); 1139 | expect(tabList()).toHaveClass(["flex", "rounded-none"]); 1140 | expect(cursor()).toHaveClass(["absolute", "z-0", "bg-white", "rounded-none"]); 1141 | }); 1142 | 1143 | test("should support slot level variant overrides - compoundVariants", () => { 1144 | const menu = tv({ 1145 | base: "text-3xl", 1146 | slots: { 1147 | title: "text-2xl", 1148 | }, 1149 | variants: { 1150 | color: { 1151 | primary: { 1152 | base: "color--primary-base", 1153 | title: "color--primary-title", 1154 | }, 1155 | secondary: { 1156 | base: "color--secondary-base", 1157 | title: "color--secondary-title", 1158 | }, 1159 | }, 1160 | }, 1161 | compoundVariants: [ 1162 | { 1163 | color: "secondary", 1164 | class: { 1165 | title: "truncate", 1166 | }, 1167 | }, 1168 | ], 1169 | defaultVariants: { 1170 | color: "primary", 1171 | }, 1172 | }); 1173 | 1174 | const {base, title} = menu(); 1175 | 1176 | expect(base()).toHaveClass(["text-3xl", "color--primary-base"]); 1177 | expect(title()).toHaveClass(["text-2xl", "color--primary-title"]); 1178 | expect(base({color: "secondary"})).toHaveClass(["text-3xl", "color--secondary-base"]); 1179 | expect(title({color: "secondary"})).toHaveClass([ 1180 | "text-2xl", 1181 | "color--secondary-title", 1182 | "truncate", 1183 | ]); 1184 | }); 1185 | 1186 | test("should work with native prototype", () => { 1187 | const avatar = tv({ 1188 | slots: { 1189 | concat: "bg-white", 1190 | link: "cursor-pointer", 1191 | map: "bg-black", 1192 | }, 1193 | variants: { 1194 | size: { 1195 | md: { 1196 | concat: "size-10", 1197 | link: "size-10", 1198 | map: "size-10", 1199 | }, 1200 | }, 1201 | }, 1202 | }); 1203 | 1204 | const {concat, link, map} = avatar({size: "md"}); 1205 | 1206 | expect(concat()).toBe("bg-white size-10"); 1207 | expect(link()).toBe("cursor-pointer size-10"); 1208 | expect(map()).toBe("bg-black size-10"); 1209 | }); 1210 | }); 1211 | 1212 | describe("Tailwind Variants (TV) - Compound Slots", () => { 1213 | test("should work with compound slots -- without variants", () => { 1214 | const pagination = tv({ 1215 | slots: { 1216 | base: "flex flex-wrap relative gap-1 max-w-fit", 1217 | item: "", 1218 | prev: "", 1219 | next: "", 1220 | cursor: ["absolute", "flex", "overflow-visible"], 1221 | }, 1222 | compoundSlots: [ 1223 | { 1224 | slots: ["item", "prev", "next"], 1225 | class: ["flex", "flex-wrap", "truncate"], 1226 | }, 1227 | ], 1228 | }); 1229 | // with default values 1230 | const {base, item, prev, next, cursor} = pagination(); 1231 | 1232 | expect(base()).toHaveClass(["flex", "flex-wrap", "relative", "gap-1", "max-w-fit"]); 1233 | expect(item()).toHaveClass(["flex", "flex-wrap", "truncate"]); 1234 | expect(prev()).toHaveClass(["flex", "flex-wrap", "truncate"]); 1235 | expect(next()).toHaveClass(["flex", "flex-wrap", "truncate"]); 1236 | expect(cursor()).toHaveClass(["absolute", "flex", "overflow-visible"]); 1237 | }); 1238 | 1239 | test("should work with compound slots -- with a single variant -- defaultVariants", () => { 1240 | const pagination = tv({ 1241 | slots: { 1242 | base: "flex flex-wrap relative gap-1 max-w-fit", 1243 | item: "", 1244 | prev: "", 1245 | next: "", 1246 | cursor: ["absolute", "flex", "overflow-visible"], 1247 | }, 1248 | variants: { 1249 | size: { 1250 | xs: {}, 1251 | sm: {}, 1252 | md: {}, 1253 | lg: {}, 1254 | xl: {}, 1255 | }, 1256 | }, 1257 | compoundSlots: [ 1258 | { 1259 | slots: ["item", "prev", "next"], 1260 | class: ["flex", "flex-wrap", "truncate"], 1261 | }, 1262 | { 1263 | slots: ["item", "prev", "next"], 1264 | size: "xs", 1265 | class: "w-7 h-7 text-xs", 1266 | }, 1267 | ], 1268 | defaultVariants: { 1269 | size: "xs", 1270 | }, 1271 | }); 1272 | // with default values 1273 | const {base, item, prev, next, cursor} = pagination(); 1274 | 1275 | expect(base()).toHaveClass(["flex", "flex-wrap", "relative", "gap-1", "max-w-fit"]); 1276 | expect(item()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1277 | expect(prev()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1278 | expect(next()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1279 | expect(cursor()).toHaveClass(["absolute", "flex", "overflow-visible"]); 1280 | }); 1281 | 1282 | test("should work with compound slots -- with a single variant -- prop variant", () => { 1283 | const pagination = tv({ 1284 | slots: { 1285 | base: "flex flex-wrap relative gap-1 max-w-fit", 1286 | item: "", 1287 | prev: "", 1288 | next: "", 1289 | cursor: ["absolute", "flex", "overflow-visible"], 1290 | }, 1291 | variants: { 1292 | size: { 1293 | xs: {}, 1294 | sm: {}, 1295 | md: {}, 1296 | lg: {}, 1297 | xl: {}, 1298 | }, 1299 | }, 1300 | compoundSlots: [ 1301 | { 1302 | slots: ["item", "prev", "next"], 1303 | class: ["flex", "flex-wrap", "truncate"], 1304 | }, 1305 | { 1306 | slots: ["item", "prev", "next"], 1307 | size: "xs", 1308 | class: "w-7 h-7 text-xs", 1309 | }, 1310 | ], 1311 | defaultVariants: { 1312 | size: "sm", 1313 | }, 1314 | }); 1315 | // with default values 1316 | const {base, item, prev, next, cursor} = pagination({ 1317 | size: "xs", 1318 | }); 1319 | 1320 | expect(base()).toHaveClass(["flex", "flex-wrap", "relative", "gap-1", "max-w-fit"]); 1321 | expect(item()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1322 | expect(prev()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1323 | expect(next()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1324 | expect(cursor()).toHaveClass(["absolute", "flex", "overflow-visible"]); 1325 | }); 1326 | 1327 | test("should work with compound slots -- with a single variant -- boolean variant", () => { 1328 | const nav = tv({ 1329 | base: "base", 1330 | slots: { 1331 | toggle: "slot--toggle", 1332 | item: "slot--item", 1333 | }, 1334 | variants: { 1335 | isActive: { 1336 | true: "", 1337 | }, 1338 | }, 1339 | compoundSlots: [ 1340 | { 1341 | slots: ["item", "toggle"], 1342 | class: "compound--item-toggle", 1343 | }, 1344 | { 1345 | slots: ["item", "toggle"], 1346 | isActive: true, 1347 | class: "compound--item-toggle--active", 1348 | }, 1349 | ], 1350 | }); 1351 | 1352 | let styles = nav({isActive: false}); 1353 | 1354 | expect(styles.base()).toHaveClass(["base"]); 1355 | expect(styles.toggle()).toHaveClass(["slot--toggle", "compound--item-toggle"]); 1356 | expect(styles.item()).toHaveClass(["slot--item", "compound--item-toggle"]); 1357 | 1358 | styles = nav({isActive: true}); 1359 | 1360 | expect(styles.base()).toHaveClass(["base"]); 1361 | expect(styles.toggle()).toHaveClass([ 1362 | "slot--toggle", 1363 | "compound--item-toggle", 1364 | "compound--item-toggle--active", 1365 | ]); 1366 | expect(styles.item()).toHaveClass([ 1367 | "slot--item", 1368 | "compound--item-toggle", 1369 | "compound--item-toggle--active", 1370 | ]); 1371 | }); 1372 | 1373 | test("should work with compound slots -- with multiple variants -- defaultVariants", () => { 1374 | const pagination = tv({ 1375 | slots: { 1376 | base: "flex flex-wrap relative gap-1 max-w-fit", 1377 | item: "", 1378 | prev: "", 1379 | next: "", 1380 | cursor: ["absolute", "flex", "overflow-visible"], 1381 | }, 1382 | variants: { 1383 | size: { 1384 | xs: {}, 1385 | sm: {}, 1386 | md: {}, 1387 | lg: {}, 1388 | xl: {}, 1389 | }, 1390 | color: { 1391 | primary: {}, 1392 | secondary: {}, 1393 | }, 1394 | isBig: { 1395 | true: {}, 1396 | }, 1397 | }, 1398 | compoundSlots: [ 1399 | { 1400 | slots: ["item", "prev", "next"], 1401 | class: ["flex", "flex-wrap", "truncate"], 1402 | }, 1403 | { 1404 | slots: ["item", "prev", "next"], 1405 | size: "xs", 1406 | color: "primary", 1407 | isBig: false, 1408 | class: "w-7 h-7 text-xs", 1409 | }, 1410 | ], 1411 | defaultVariants: { 1412 | size: "xs", 1413 | color: "primary", 1414 | isBig: false, 1415 | }, 1416 | }); 1417 | // with default values 1418 | const {base, item, prev, next, cursor} = pagination(); 1419 | 1420 | expect(base()).toHaveClass(["flex", "flex-wrap", "relative", "gap-1", "max-w-fit"]); 1421 | expect(item()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1422 | expect(prev()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1423 | expect(next()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1424 | expect(cursor()).toHaveClass(["absolute", "flex", "overflow-visible"]); 1425 | }); 1426 | 1427 | test("should work with compound slots -- with multiple variants -- prop variants", () => { 1428 | const pagination = tv({ 1429 | slots: { 1430 | base: "flex flex-wrap relative gap-1 max-w-fit", 1431 | item: "", 1432 | prev: "", 1433 | next: "", 1434 | cursor: ["absolute", "flex", "overflow-visible"], 1435 | }, 1436 | variants: { 1437 | size: { 1438 | xs: {}, 1439 | sm: {}, 1440 | md: {}, 1441 | lg: {}, 1442 | xl: {}, 1443 | }, 1444 | color: { 1445 | primary: {}, 1446 | secondary: {}, 1447 | }, 1448 | isBig: { 1449 | true: {}, 1450 | }, 1451 | }, 1452 | compoundSlots: [ 1453 | { 1454 | slots: ["item", "prev", "next"], 1455 | class: ["flex", "flex-wrap", "truncate"], 1456 | }, 1457 | { 1458 | slots: ["item", "prev", "next"], 1459 | size: "xs", 1460 | color: "primary", 1461 | isBig: true, 1462 | class: "w-7 h-7 text-xs", 1463 | }, 1464 | ], 1465 | defaultVariants: { 1466 | size: "sm", 1467 | color: "secondary", 1468 | isBig: false, 1469 | }, 1470 | }); 1471 | // with default values 1472 | const {base, item, prev, next, cursor} = pagination({ 1473 | size: "xs", 1474 | color: "primary", 1475 | isBig: true, 1476 | }); 1477 | 1478 | expect(base()).toHaveClass(["flex", "flex-wrap", "relative", "gap-1", "max-w-fit"]); 1479 | expect(item()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1480 | expect(prev()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1481 | expect(next()).toHaveClass(["flex", "flex-wrap", "truncate", "w-7", "h-7", "text-xs"]); 1482 | expect(cursor()).toHaveClass(["absolute", "flex", "overflow-visible"]); 1483 | }); 1484 | }); 1485 | 1486 | describe("Tailwind Variants (TV) - Extends", () => { 1487 | test("should include the extended classes", () => { 1488 | const p = tv({ 1489 | base: "text-base text-green-500", 1490 | }); 1491 | 1492 | const h1 = tv({ 1493 | extend: p, 1494 | base: "text-3xl font-bold", 1495 | }); 1496 | 1497 | const result = h1(); 1498 | const expectedResult = ["text-3xl", "font-bold", "text-green-500"]; 1499 | 1500 | expect(result).toHaveClass(expectedResult); 1501 | }); 1502 | 1503 | test("should include the extended classes with variants", () => { 1504 | const p = tv({ 1505 | base: "p--base text-base text-green-500", 1506 | variants: { 1507 | isBig: { 1508 | true: "text-5xl", 1509 | false: "text-2xl", 1510 | }, 1511 | color: { 1512 | red: "text-red-500", 1513 | blue: "text-blue-500", 1514 | }, 1515 | }, 1516 | }); 1517 | 1518 | const h1 = tv({ 1519 | extend: p, 1520 | base: "text-3xl font-bold", 1521 | variants: { 1522 | color: { 1523 | purple: "text-purple-500", 1524 | green: "text-green-500", 1525 | }, 1526 | }, 1527 | }); 1528 | 1529 | const result = h1({ 1530 | isBig: true, 1531 | color: "red", 1532 | }); 1533 | 1534 | const expectedResult = ["font-bold", "text-red-500", "text-5xl", "p--base"]; 1535 | 1536 | expect(result).toHaveClass(expectedResult); 1537 | }); 1538 | 1539 | test("should include nested the extended classes", () => { 1540 | const base = tv({ 1541 | base: "text-base", 1542 | variants: { 1543 | color: { 1544 | red: "color--red", 1545 | }, 1546 | }, 1547 | }); 1548 | 1549 | const p = tv({ 1550 | extend: base, 1551 | base: "text-green-500", 1552 | variants: { 1553 | color: { 1554 | blue: "color--blue", 1555 | yellow: "color--yellow", 1556 | }, 1557 | }, 1558 | }); 1559 | 1560 | const h1 = tv({ 1561 | extend: p, 1562 | base: "text-3xl font-bold", 1563 | variants: { 1564 | color: { 1565 | green: "color--green", 1566 | }, 1567 | }, 1568 | }); 1569 | 1570 | const result = h1({ 1571 | // @ts-ignore TODO: should have the grand parent variants 1572 | color: "red", 1573 | }); 1574 | 1575 | const expectedResult = ["text-3xl", "font-bold", "text-green-500", "color--red"]; 1576 | 1577 | expect(result).toHaveClass(expectedResult); 1578 | 1579 | const result2 = h1({ 1580 | color: "blue", 1581 | }); 1582 | 1583 | const expectedResult2 = ["text-3xl", "font-bold", "text-green-500", "color--blue"]; 1584 | 1585 | expect(result2).toHaveClass(expectedResult2); 1586 | 1587 | const result3 = h1({ 1588 | color: "green", 1589 | }); 1590 | 1591 | const expectedResult3 = ["text-3xl", "font-bold", "text-green-500", "color--green"]; 1592 | 1593 | expect(result3).toHaveClass(expectedResult3); 1594 | }); 1595 | 1596 | test("should override the extended classes with variants", () => { 1597 | const p = tv({ 1598 | base: "text-base text-green-500", 1599 | variants: { 1600 | isBig: { 1601 | true: "text-5xl", 1602 | false: "text-2xl", 1603 | }, 1604 | color: { 1605 | red: "text-red-500 bg-red-100 tracking-normal", 1606 | blue: "text-blue-500", 1607 | }, 1608 | }, 1609 | }); 1610 | 1611 | const h1 = tv({ 1612 | extend: p, 1613 | base: "text-3xl font-bold", 1614 | variants: { 1615 | color: { 1616 | red: ["text-red-200", "bg-red-200"], 1617 | green: "text-green-500", 1618 | }, 1619 | }, 1620 | }); 1621 | 1622 | const result = h1({ 1623 | isBig: true, 1624 | color: "red", 1625 | }); 1626 | 1627 | const expectedResult = [ 1628 | "font-bold", 1629 | "text-red-200", 1630 | "bg-red-200", 1631 | "tracking-normal", 1632 | "text-5xl", 1633 | ]; 1634 | 1635 | expect(result).toHaveClass(expectedResult); 1636 | }); 1637 | 1638 | test("should include the extended classes with defaultVariants - parent", () => { 1639 | const p = tv({ 1640 | base: "text-base text-green-500", 1641 | variants: { 1642 | isBig: { 1643 | true: "text-5xl", 1644 | false: "text-2xl", 1645 | }, 1646 | color: { 1647 | red: "text-red-500", 1648 | blue: "text-blue-500", 1649 | }, 1650 | }, 1651 | defaultVariants: { 1652 | isBig: true, 1653 | color: "red", 1654 | }, 1655 | }); 1656 | 1657 | const h1 = tv({ 1658 | extend: p, 1659 | base: "text-3xl font-bold", 1660 | variants: { 1661 | color: { 1662 | purple: "text-purple-500", 1663 | green: "text-green-500", 1664 | }, 1665 | }, 1666 | }); 1667 | 1668 | const result = h1(); 1669 | 1670 | const expectedResult = ["font-bold", "text-red-500", "text-5xl"]; 1671 | 1672 | expect(result).toHaveClass(expectedResult); 1673 | }); 1674 | 1675 | test("should include the extended classes with defaultVariants - children", () => { 1676 | const p = tv({ 1677 | base: "text-base text-green-500", 1678 | variants: { 1679 | isBig: { 1680 | true: "text-5xl", 1681 | false: "text-2xl", 1682 | }, 1683 | color: { 1684 | red: "text-red-500", 1685 | blue: "text-blue-500", 1686 | }, 1687 | }, 1688 | }); 1689 | 1690 | const h1 = tv({ 1691 | extend: p, 1692 | base: "text-3xl font-bold", 1693 | variants: { 1694 | color: { 1695 | purple: "text-purple-500", 1696 | green: "text-green-500", 1697 | }, 1698 | }, 1699 | defaultVariants: { 1700 | isBig: true, 1701 | color: "red", 1702 | }, 1703 | }); 1704 | 1705 | const result = h1(); 1706 | 1707 | const expectedResult = ["font-bold", "text-red-500", "text-5xl"]; 1708 | 1709 | expect(result).toHaveClass(expectedResult); 1710 | }); 1711 | 1712 | test("should override the extended defaultVariants - children", () => { 1713 | const p = tv({ 1714 | base: "text-base text-green-500", 1715 | variants: { 1716 | isBig: { 1717 | true: "text-5xl", 1718 | false: "text-2xl", 1719 | }, 1720 | color: { 1721 | red: "text-red-500", 1722 | blue: "text-blue-500", 1723 | }, 1724 | }, 1725 | defaultVariants: { 1726 | isBig: true, 1727 | color: "blue", 1728 | }, 1729 | }); 1730 | 1731 | const h1 = tv({ 1732 | extend: p, 1733 | base: "text-3xl font-bold", 1734 | variants: { 1735 | color: { 1736 | purple: "text-purple-500", 1737 | green: "text-green-500", 1738 | }, 1739 | }, 1740 | defaultVariants: { 1741 | isBig: false, 1742 | color: "red", 1743 | }, 1744 | }); 1745 | 1746 | const result = h1(); 1747 | 1748 | const expectedResult = ["font-bold", "text-red-500", "text-2xl"]; 1749 | 1750 | expect(result).toHaveClass(expectedResult); 1751 | }); 1752 | 1753 | test("should include the extended classes with compoundVariants - parent", () => { 1754 | const p = tv({ 1755 | base: "text-base text-green-500", 1756 | variants: { 1757 | isBig: { 1758 | true: "text-5xl", 1759 | false: "text-2xl", 1760 | }, 1761 | color: { 1762 | red: "text-red-500", 1763 | blue: "text-blue-500", 1764 | }, 1765 | }, 1766 | defaultVariants: { 1767 | isBig: true, 1768 | color: "red", 1769 | }, 1770 | compoundVariants: [ 1771 | { 1772 | isBig: true, 1773 | color: "red", 1774 | class: "bg-red-500", 1775 | }, 1776 | ], 1777 | }); 1778 | 1779 | const h1 = tv({ 1780 | extend: p, 1781 | base: "text-3xl font-bold", 1782 | variants: { 1783 | color: { 1784 | purple: "text-purple-500", 1785 | green: "text-green-500", 1786 | }, 1787 | }, 1788 | }); 1789 | 1790 | const result = h1(); 1791 | 1792 | const expectedResult = ["font-bold", "text-red-500", "bg-red-500", "text-5xl"]; 1793 | 1794 | expect(result).toHaveClass(expectedResult); 1795 | }); 1796 | 1797 | test("should include the extended classes with compoundVariants - children", () => { 1798 | const p = tv({ 1799 | base: "text-base text-green-500", 1800 | variants: { 1801 | isBig: { 1802 | true: "text-5xl", 1803 | false: "text-2xl", 1804 | }, 1805 | color: { 1806 | red: "text-red-500", 1807 | blue: "text-blue-500", 1808 | }, 1809 | }, 1810 | defaultVariants: { 1811 | isBig: true, 1812 | color: "red", 1813 | }, 1814 | }); 1815 | 1816 | const h1 = tv({ 1817 | extend: p, 1818 | base: "text-3xl font-bold", 1819 | variants: { 1820 | color: { 1821 | purple: "text-purple-500", 1822 | green: "text-green-500", 1823 | }, 1824 | }, 1825 | defaultVariants: { 1826 | color: "green", 1827 | }, 1828 | compoundVariants: [ 1829 | { 1830 | isBig: true, 1831 | color: "green", 1832 | class: "bg-green-500", 1833 | }, 1834 | ], 1835 | }); 1836 | 1837 | const result = h1(); 1838 | 1839 | const expectedResult = ["font-bold", "bg-green-500", "text-green-500", "text-5xl"]; 1840 | 1841 | expect(result).toHaveClass(expectedResult); 1842 | }); 1843 | 1844 | test("should override the extended classes with compoundVariants - children", () => { 1845 | const p = tv({ 1846 | base: "text-base text-green-500", 1847 | variants: { 1848 | isBig: { 1849 | true: "text-5xl", 1850 | false: "text-2xl", 1851 | }, 1852 | color: { 1853 | red: "text-red-500", 1854 | blue: "text-blue-500", 1855 | }, 1856 | }, 1857 | defaultVariants: { 1858 | isBig: true, 1859 | color: "red", 1860 | }, 1861 | compoundVariants: [ 1862 | { 1863 | isBig: true, 1864 | color: "red", 1865 | class: "bg-red-500", 1866 | }, 1867 | ], 1868 | }); 1869 | 1870 | const h1 = tv({ 1871 | extend: p, 1872 | base: "text-3xl font-bold", 1873 | variants: { 1874 | color: { 1875 | purple: "text-purple-500", 1876 | green: "text-green-500", 1877 | }, 1878 | }, 1879 | compoundVariants: [ 1880 | { 1881 | isBig: true, 1882 | color: "red", 1883 | class: "bg-red-600", 1884 | }, 1885 | ], 1886 | }); 1887 | 1888 | const result = h1(); 1889 | 1890 | const expectedResult = ["font-bold", "bg-red-600", "text-red-500", "text-5xl"]; 1891 | 1892 | expect(result).toHaveClass(expectedResult); 1893 | }); 1894 | 1895 | test("should override the extended classes with variants and compoundVariants, using array", () => { 1896 | const p = tv({ 1897 | base: "text-base text-green-500", 1898 | variants: { 1899 | isBig: { 1900 | true: "text-5xl", 1901 | false: ["text-2xl"], 1902 | }, 1903 | color: { 1904 | red: ["text-red-500 bg-red-100", "tracking-normal"], 1905 | blue: "text-blue-500", 1906 | }, 1907 | }, 1908 | defaultVariants: { 1909 | isBig: true, 1910 | color: "red", 1911 | }, 1912 | compoundVariants: [ 1913 | { 1914 | isBig: true, 1915 | color: "red", 1916 | class: "bg-red-500", 1917 | }, 1918 | { 1919 | isBig: false, 1920 | color: "red", 1921 | class: ["bg-red-500"], 1922 | }, 1923 | { 1924 | isBig: true, 1925 | color: "blue", 1926 | class: ["bg-blue-500"], 1927 | }, 1928 | { 1929 | isBig: false, 1930 | color: "blue", 1931 | class: "bg-blue-500", 1932 | }, 1933 | ], 1934 | }); 1935 | 1936 | const h1 = tv({ 1937 | extend: p, 1938 | base: "text-3xl font-bold", 1939 | variants: { 1940 | isBig: { 1941 | true: "text-7xl", 1942 | false: "text-3xl", 1943 | }, 1944 | color: { 1945 | red: ["text-red-200", "bg-red-200"], 1946 | green: ["text-green-500"], 1947 | }, 1948 | }, 1949 | compoundVariants: [ 1950 | { 1951 | isBig: true, 1952 | color: "red", 1953 | class: "bg-red-600", 1954 | }, 1955 | { 1956 | isBig: false, 1957 | color: "red", 1958 | class: "bg-red-600", 1959 | }, 1960 | { 1961 | isBig: true, 1962 | color: "blue", 1963 | class: ["bg-blue-600"], 1964 | }, 1965 | { 1966 | isBig: false, 1967 | color: "blue", 1968 | class: ["bg-blue-600"], 1969 | }, 1970 | ], 1971 | }); 1972 | 1973 | expect(h1({isBig: true, color: "red"})).toHaveClass([ 1974 | "font-bold", 1975 | "text-red-200", 1976 | "bg-red-600", 1977 | "tracking-normal", 1978 | "text-7xl", 1979 | ]); 1980 | 1981 | expect(h1({isBig: true, color: "blue"})).toHaveClass([ 1982 | "font-bold", 1983 | "text-blue-500", 1984 | "bg-blue-600", 1985 | "text-7xl", 1986 | ]); 1987 | 1988 | expect(h1({isBig: false, color: "red"})).toHaveClass([ 1989 | "font-bold", 1990 | "text-red-200", 1991 | "bg-red-600", 1992 | "tracking-normal", 1993 | "text-3xl", 1994 | ]); 1995 | 1996 | expect(h1({isBig: false, color: "blue"})).toHaveClass([ 1997 | "font-bold", 1998 | "text-blue-500", 1999 | "bg-blue-600", 2000 | "text-3xl", 2001 | ]); 2002 | }); 2003 | 2004 | test("should include the extended slots w/o children slots", () => { 2005 | const menuBase = tv({ 2006 | base: "base--menuBase", 2007 | slots: { 2008 | title: "title--menuBase", 2009 | item: "item--menuBase", 2010 | list: "list--menuBase", 2011 | wrapper: "wrapper--menuBase", 2012 | }, 2013 | }); 2014 | 2015 | const menu = tv({ 2016 | extend: menuBase, 2017 | base: "base--menu", 2018 | }); 2019 | 2020 | // with default values 2021 | const {base, title, item, list, wrapper} = menu(); 2022 | 2023 | expect(base()).toHaveClass(["base--menuBase", "base--menu"]); 2024 | expect(title()).toHaveClass(["title--menuBase"]); 2025 | expect(item()).toHaveClass(["item--menuBase"]); 2026 | expect(list()).toHaveClass(["list--menuBase"]); 2027 | expect(wrapper()).toHaveClass(["wrapper--menuBase"]); 2028 | }); 2029 | 2030 | test("should include the extended slots w/ variants -- parent", () => { 2031 | const menuBase = tv({ 2032 | base: "base--menuBase", 2033 | slots: { 2034 | title: "title--menuBase", 2035 | item: "item--menuBase", 2036 | list: "list--menuBase", 2037 | wrapper: "wrapper--menuBase", 2038 | }, 2039 | variants: { 2040 | isBig: { 2041 | true: { 2042 | title: "title--isBig--menu", 2043 | item: "item--isBig--menu", 2044 | list: "list--isBig--menu", 2045 | wrapper: "wrapper--isBig--menu", 2046 | }, 2047 | false: "isBig--menu", 2048 | }, 2049 | }, 2050 | }); 2051 | 2052 | const menu = tv({ 2053 | extend: menuBase, 2054 | base: "base--menu", 2055 | }); 2056 | 2057 | const {base, title, item, list, wrapper} = menu({ 2058 | isBig: true, 2059 | }); 2060 | 2061 | expect(base()).toHaveClass(["base--menuBase", "base--menu"]); 2062 | expect(title()).toHaveClass(["title--menuBase", "title--isBig--menu"]); 2063 | expect(item()).toHaveClass(["item--menuBase", "item--isBig--menu"]); 2064 | expect(list()).toHaveClass(["list--menuBase", "list--isBig--menu"]); 2065 | expect(wrapper()).toHaveClass(["wrapper--menuBase", "wrapper--isBig--menu"]); 2066 | }); 2067 | 2068 | test("should include the extended slots w/ variants -- children", () => { 2069 | const menuBase = tv({ 2070 | base: "base--menuBase", 2071 | slots: { 2072 | title: "title--menuBase", 2073 | item: "item--menuBase", 2074 | list: "list--menuBase", 2075 | wrapper: "wrapper--menuBase", 2076 | }, 2077 | }); 2078 | 2079 | const menu = tv({ 2080 | extend: menuBase, 2081 | base: "base--menu", 2082 | variants: { 2083 | isBig: { 2084 | true: { 2085 | title: "title--isBig--menu", 2086 | item: "item--isBig--menu", 2087 | list: "list--isBig--menu", 2088 | wrapper: "wrapper--isBig--menu", 2089 | }, 2090 | false: "isBig--menu", 2091 | }, 2092 | }, 2093 | }); 2094 | 2095 | const {base, title, item, list, wrapper} = menu({ 2096 | isBig: true, 2097 | }); 2098 | 2099 | expect(base()).toHaveClass(["base--menuBase", "base--menu"]); 2100 | expect(title()).toHaveClass(["title--menuBase", "title--isBig--menu"]); 2101 | expect(item()).toHaveClass(["item--menuBase", "item--isBig--menu"]); 2102 | expect(list()).toHaveClass(["list--menuBase", "list--isBig--menu"]); 2103 | expect(wrapper()).toHaveClass(["wrapper--menuBase", "wrapper--isBig--menu"]); 2104 | }); 2105 | 2106 | test("should include the extended slots w/ children slots (same names)", () => { 2107 | const menuBase = tv({ 2108 | base: "base--menuBase", 2109 | slots: { 2110 | title: "title--menuBase", 2111 | item: "item--menuBase", 2112 | list: "list--menuBase", 2113 | wrapper: "wrapper--menuBase", 2114 | }, 2115 | }); 2116 | 2117 | const menu = tv({ 2118 | extend: menuBase, 2119 | base: "base--menu", 2120 | slots: { 2121 | title: "title--menu", 2122 | item: "item--menu", 2123 | list: "list--menu", 2124 | wrapper: "wrapper--menu", 2125 | }, 2126 | }); 2127 | 2128 | // with default values 2129 | let res = menu(); 2130 | 2131 | expect(res.base()).toHaveClass(["base--menuBase", "base--menu"]); 2132 | expect(res.title()).toHaveClass(["title--menuBase", "title--menu"]); 2133 | expect(res.item()).toHaveClass(["item--menuBase", "item--menu"]); 2134 | expect(res.list()).toHaveClass(["list--menuBase", "list--menu"]); 2135 | expect(res.wrapper()).toHaveClass(["wrapper--menuBase", "wrapper--menu"]); 2136 | 2137 | res = menuBase(); 2138 | 2139 | expect(res.base()).toBe("base--menuBase"); 2140 | expect(res.title()).toBe("title--menuBase"); 2141 | expect(res.item()).toBe("item--menuBase"); 2142 | expect(res.list()).toBe("list--menuBase"); 2143 | expect(res.wrapper()).toBe("wrapper--menuBase"); 2144 | }); 2145 | 2146 | test("should include the extended slots w/ children slots (additional)", () => { 2147 | const menuBase = tv({ 2148 | base: "base--menuBase", 2149 | slots: { 2150 | title: "title--menuBase", 2151 | item: "item--menuBase", 2152 | list: "list--menuBase", 2153 | wrapper: "wrapper--menuBase", 2154 | }, 2155 | }); 2156 | 2157 | const menu = tv({ 2158 | extend: menuBase, 2159 | base: "base--menu", 2160 | slots: { 2161 | title: "title--menu", 2162 | item: "item--menu", 2163 | list: "list--menu", 2164 | wrapper: "wrapper--menu", 2165 | extra: "extra--menu", 2166 | }, 2167 | }); 2168 | 2169 | // with default values 2170 | const {base, title, item, list, wrapper, extra} = menu(); 2171 | 2172 | expect(base()).toHaveClass(["base--menuBase", "base--menu"]); 2173 | expect(title()).toHaveClass(["title--menuBase", "title--menu"]); 2174 | expect(item()).toHaveClass(["item--menuBase", "item--menu"]); 2175 | expect(list()).toHaveClass(["list--menuBase", "list--menu"]); 2176 | expect(wrapper()).toHaveClass(["wrapper--menuBase", "wrapper--menu"]); 2177 | expect(extra()).toHaveClass(["extra--menu"]); 2178 | }); 2179 | 2180 | test("should include the extended variants w/slots and defaultVariants -- parent", () => { 2181 | const menuBase = tv({ 2182 | base: "base--menuBase", 2183 | slots: { 2184 | title: "title--menuBase", 2185 | item: "item--menuBase", 2186 | list: "list--menuBase", 2187 | wrapper: "wrapper--menuBase", 2188 | }, 2189 | variants: { 2190 | isBig: { 2191 | true: { 2192 | title: "isBig--title--menuBase", 2193 | item: "isBig--item--menuBase", 2194 | list: "isBig--list--menuBase", 2195 | wrapper: "isBig--wrapper--menuBase", 2196 | }, 2197 | }, 2198 | }, 2199 | defaultVariants: { 2200 | isBig: true, 2201 | }, 2202 | }); 2203 | 2204 | const menu = tv({ 2205 | extend: menuBase, 2206 | base: "base--menu", 2207 | slots: { 2208 | title: "title--menu", 2209 | item: "item--menu", 2210 | list: "list--menu", 2211 | wrapper: "wrapper--menu", 2212 | }, 2213 | }); 2214 | 2215 | // with default values 2216 | const {base, title, item, list, wrapper} = menu(); 2217 | 2218 | expect(base()).toHaveClass(["base--menuBase", "base--menu"]); 2219 | expect(title()).toHaveClass(["title--menuBase", "title--menu", "isBig--title--menuBase"]); 2220 | expect(item()).toHaveClass(["item--menuBase", "item--menu", "isBig--item--menuBase"]); 2221 | expect(list()).toHaveClass(["list--menuBase", "list--menu", "isBig--list--menuBase"]); 2222 | expect(wrapper()).toHaveClass([ 2223 | "wrapper--menuBase", 2224 | "wrapper--menu", 2225 | "isBig--wrapper--menuBase", 2226 | ]); 2227 | }); 2228 | 2229 | test("should include the extended variants w/slots and defaultVariants -- children", () => { 2230 | const menuBase = tv({ 2231 | base: "base--menuBase", 2232 | slots: { 2233 | title: "title--menuBase", 2234 | item: "item--menuBase", 2235 | list: "list--menuBase", 2236 | wrapper: "wrapper--menuBase", 2237 | }, 2238 | variants: { 2239 | isBig: { 2240 | true: { 2241 | title: "isBig--title--menuBase", 2242 | item: "isBig--item--menuBase", 2243 | list: "isBig--list--menuBase", 2244 | wrapper: "isBig--wrapper--menuBase", 2245 | }, 2246 | }, 2247 | }, 2248 | }); 2249 | 2250 | const menu = tv({ 2251 | extend: menuBase, 2252 | base: "base--menu", 2253 | slots: { 2254 | title: "title--menu", 2255 | item: "item--menu", 2256 | list: "list--menu", 2257 | wrapper: "wrapper--menu", 2258 | }, 2259 | defaultVariants: { 2260 | isBig: true, 2261 | }, 2262 | }); 2263 | 2264 | // with default values 2265 | const {base, title, item, list, wrapper} = menu(); 2266 | 2267 | expect(base()).toHaveClass(["base--menuBase", "base--menu"]); 2268 | expect(title()).toHaveClass(["title--menuBase", "title--menu", "isBig--title--menuBase"]); 2269 | expect(item()).toHaveClass(["item--menuBase", "item--menu", "isBig--item--menuBase"]); 2270 | expect(list()).toHaveClass(["list--menuBase", "list--menu", "isBig--list--menuBase"]); 2271 | expect(wrapper()).toHaveClass([ 2272 | "wrapper--menuBase", 2273 | "wrapper--menu", 2274 | "isBig--wrapper--menuBase", 2275 | ]); 2276 | }); 2277 | 2278 | test("should include the extended variants w/slots and compoundVariants -- parent", () => { 2279 | const menuBase = tv({ 2280 | base: "base--menuBase", 2281 | slots: { 2282 | title: "title--menuBase", 2283 | item: "item--menuBase", 2284 | list: "list--menuBase", 2285 | wrapper: "wrapper--menuBase", 2286 | }, 2287 | variants: { 2288 | color: { 2289 | red: { 2290 | title: "color--red--title--menuBase", 2291 | item: "color--red--item--menuBase", 2292 | list: "color--red--list--menuBase", 2293 | wrapper: "color--red--wrapper--menuBase", 2294 | }, 2295 | blue: { 2296 | title: "color--blue--title--menuBase", 2297 | item: "color--blue--item--menuBase", 2298 | list: "color--blue--list--menuBase", 2299 | wrapper: "color--blue--wrapper--menuBase", 2300 | }, 2301 | }, 2302 | isBig: { 2303 | true: { 2304 | title: "isBig--title--menuBase", 2305 | item: "isBig--item--menuBase", 2306 | list: "isBig--list--menuBase", 2307 | wrapper: "isBig--wrapper--menuBase", 2308 | }, 2309 | }, 2310 | }, 2311 | defaultVariants: { 2312 | isBig: true, 2313 | color: "blue", 2314 | }, 2315 | compoundVariants: [ 2316 | { 2317 | color: "red", 2318 | isBig: true, 2319 | class: { 2320 | title: "color--red--isBig--title--menuBase", 2321 | item: "color--red--isBig--item--menuBase", 2322 | list: "color--red--isBig--list--menuBase", 2323 | wrapper: "color--red--isBig--wrapper--menuBase", 2324 | }, 2325 | }, 2326 | ], 2327 | }); 2328 | 2329 | const menu = tv({ 2330 | extend: menuBase, 2331 | base: "base--menu", 2332 | slots: { 2333 | title: "title--menu", 2334 | item: "item--menu", 2335 | list: "list--menu", 2336 | wrapper: "wrapper--menu", 2337 | }, 2338 | }); 2339 | 2340 | // with default values 2341 | const {base, title, item, list, wrapper} = menu({ 2342 | color: "red", 2343 | }); 2344 | 2345 | expect(base()).toHaveClass(["base--menuBase", "base--menu"]); 2346 | expect(title()).toHaveClass([ 2347 | "title--menuBase", 2348 | "title--menu", 2349 | "isBig--title--menuBase", 2350 | "color--red--title--menuBase", 2351 | "color--red--isBig--title--menuBase", 2352 | ]); 2353 | expect(item()).toHaveClass([ 2354 | "item--menuBase", 2355 | "item--menu", 2356 | "isBig--item--menuBase", 2357 | "color--red--item--menuBase", 2358 | "color--red--isBig--item--menuBase", 2359 | ]); 2360 | expect(list()).toHaveClass([ 2361 | "list--menuBase", 2362 | "list--menu", 2363 | "isBig--list--menuBase", 2364 | "color--red--list--menuBase", 2365 | "color--red--isBig--list--menuBase", 2366 | ]); 2367 | expect(wrapper()).toHaveClass([ 2368 | "wrapper--menuBase", 2369 | "wrapper--menu", 2370 | "isBig--wrapper--menuBase", 2371 | "color--red--wrapper--menuBase", 2372 | "color--red--isBig--wrapper--menuBase", 2373 | ]); 2374 | }); 2375 | 2376 | test("should include the extended variants w/slots and compoundVariants -- children", () => { 2377 | const menuBase = tv({ 2378 | base: "base--menuBase", 2379 | slots: { 2380 | title: "title--menuBase", 2381 | item: "item--menuBase", 2382 | list: "list--menuBase", 2383 | wrapper: "wrapper--menuBase", 2384 | }, 2385 | variants: { 2386 | color: { 2387 | red: { 2388 | title: "color--red--title--menuBase", 2389 | item: "color--red--item--menuBase", 2390 | list: "color--red--list--menuBase", 2391 | wrapper: "color--red--wrapper--menuBase", 2392 | }, 2393 | blue: { 2394 | title: "color--blue--title--menuBase", 2395 | item: "color--blue--item--menuBase", 2396 | list: "color--blue--list--menuBase", 2397 | wrapper: "color--blue--wrapper--menuBase", 2398 | }, 2399 | }, 2400 | isBig: { 2401 | true: { 2402 | title: "isBig--title--menuBase", 2403 | item: "isBig--item--menuBase", 2404 | list: "isBig--list--menuBase", 2405 | wrapper: "isBig--wrapper--menuBase", 2406 | }, 2407 | }, 2408 | }, 2409 | defaultVariants: { 2410 | isBig: true, 2411 | color: "blue", 2412 | }, 2413 | }); 2414 | 2415 | const menu = tv({ 2416 | extend: menuBase, 2417 | base: "base--menu", 2418 | slots: { 2419 | title: "title--menu", 2420 | item: "item--menu", 2421 | list: "list--menu", 2422 | wrapper: "wrapper--menu", 2423 | }, 2424 | compoundVariants: [ 2425 | { 2426 | color: "red", 2427 | isBig: true, 2428 | class: { 2429 | title: "color--red--isBig--title--menuBase", 2430 | item: "color--red--isBig--item--menuBase", 2431 | list: "color--red--isBig--list--menuBase", 2432 | wrapper: "color--red--isBig--wrapper--menuBase", 2433 | }, 2434 | }, 2435 | ], 2436 | }); 2437 | 2438 | // with default values 2439 | const {base, title, item, list, wrapper} = menu({ 2440 | color: "red", 2441 | }); 2442 | 2443 | expect(base()).toHaveClass(["base--menuBase", "base--menu"]); 2444 | expect(title()).toHaveClass([ 2445 | "title--menuBase", 2446 | "title--menu", 2447 | "isBig--title--menuBase", 2448 | "color--red--title--menuBase", 2449 | "color--red--isBig--title--menuBase", 2450 | ]); 2451 | expect(item()).toHaveClass([ 2452 | "item--menuBase", 2453 | "item--menu", 2454 | "isBig--item--menuBase", 2455 | "color--red--item--menuBase", 2456 | "color--red--isBig--item--menuBase", 2457 | ]); 2458 | expect(list()).toHaveClass([ 2459 | "list--menuBase", 2460 | "list--menu", 2461 | "isBig--list--menuBase", 2462 | "color--red--list--menuBase", 2463 | "color--red--isBig--list--menuBase", 2464 | ]); 2465 | expect(wrapper()).toHaveClass([ 2466 | "wrapper--menuBase", 2467 | "wrapper--menu", 2468 | "isBig--wrapper--menuBase", 2469 | "color--red--wrapper--menuBase", 2470 | "color--red--isBig--wrapper--menuBase", 2471 | ]); 2472 | }); 2473 | 2474 | test("should work with cn", () => { 2475 | const tvResult = ["w-fit", "h-fit"]; 2476 | const custom = ["w-full"]; 2477 | 2478 | const resultWithoutMerge = cnMerge(tvResult.concat(custom))({twMerge: false}); 2479 | const resultWithMerge = cnMerge(tvResult.concat(custom))({twMerge: true}); 2480 | const emptyResultWithoutMerge = cnMerge([].concat([]))({twMerge: false}); 2481 | const emptyResultWithMerge = cnMerge([].concat([]))({twMerge: true}); 2482 | 2483 | expect(resultWithoutMerge).toBe("w-fit h-fit w-full"); 2484 | expect(resultWithMerge).toBe("h-fit w-full"); 2485 | expect(emptyResultWithoutMerge).toBe(undefined); 2486 | expect(emptyResultWithMerge).toBe(undefined); 2487 | }); 2488 | 2489 | test("should support parent w/slots when base does not have slots", () => { 2490 | const menuBase = tv({base: "menuBase"}); 2491 | const menu = tv({ 2492 | extend: menuBase, 2493 | base: "menu", 2494 | slots: { 2495 | title: "title", 2496 | }, 2497 | }); 2498 | 2499 | const {base, title} = menu(); 2500 | 2501 | expect(base()).toHaveClass(["menuBase", "menu"]); 2502 | expect(title()).toHaveClass(["title"]); 2503 | }); 2504 | 2505 | it("should support multi-level extends", () => { 2506 | const themeButton = tv({ 2507 | base: "font-medium", 2508 | variants: { 2509 | color: { 2510 | primary: "text-blue-500", 2511 | }, 2512 | disabled: { 2513 | true: "opacity-50", 2514 | }, 2515 | }, 2516 | compoundVariants: [ 2517 | { 2518 | color: "primary", 2519 | disabled: true, 2520 | class: "bg-black", 2521 | }, 2522 | ], 2523 | defaultVariants: { 2524 | color: "primary", 2525 | disabled: true, 2526 | }, 2527 | }); 2528 | 2529 | const appButton = tv({extend: themeButton}); 2530 | const button = tv({extend: appButton}); 2531 | 2532 | expect(appButton()).toHaveClass("font-medium text-blue-500 opacity-50 bg-black"); 2533 | expect(button()).toHaveClass("font-medium text-blue-500 opacity-50 bg-black"); 2534 | }); 2535 | }); 2536 | 2537 | describe("Tailwind Variants (TV) - Tailwind Merge", () => { 2538 | it("should merge the tailwind classes correctly", () => { 2539 | const styles = tv({ 2540 | base: "text-base text-yellow-400", 2541 | variants: { 2542 | color: { 2543 | red: "text-red-500", 2544 | blue: "text-blue-500", 2545 | }, 2546 | }, 2547 | }); 2548 | 2549 | const result = styles({ 2550 | color: "red", 2551 | }); 2552 | 2553 | expect(result).toHaveClass(["text-base", "text-red-500"]); 2554 | }); 2555 | 2556 | it("should support custom config", () => { 2557 | const styles = tv( 2558 | { 2559 | base: "text-small text-yellow-400 w-unit", 2560 | variants: { 2561 | size: { 2562 | small: "text-small w-unit-2", 2563 | medium: "text-medium w-unit-4", 2564 | large: "text-large w-unit-6", 2565 | }, 2566 | color: { 2567 | red: "text-red-500", 2568 | blue: "text-blue-500", 2569 | }, 2570 | }, 2571 | }, 2572 | { 2573 | twMergeConfig, 2574 | }, 2575 | ); 2576 | 2577 | const result = styles({ 2578 | size: "medium", 2579 | color: "blue", 2580 | }); 2581 | 2582 | expect(result).toHaveClass(["text-medium", "text-blue-500", "w-unit-4"]); 2583 | }); 2584 | 2585 | it("should support legacy custom config", () => { 2586 | const styles = tv( 2587 | { 2588 | base: "text-small text-yellow-400 w-unit", 2589 | variants: { 2590 | size: { 2591 | small: "text-small w-unit-2", 2592 | medium: "text-medium w-unit-4", 2593 | large: "text-large w-unit-6", 2594 | }, 2595 | color: { 2596 | red: "text-red-500", 2597 | blue: "text-blue-500", 2598 | }, 2599 | }, 2600 | }, 2601 | { 2602 | twMergeConfig: { 2603 | theme: { 2604 | opacity: ["disabled"], 2605 | spacing: ["divider", "unit", "unit-2", "unit-4", "unit-6"], 2606 | borderWidth: COMMON_UNITS, 2607 | borderRadius: COMMON_UNITS, 2608 | }, 2609 | classGroups: { 2610 | shadow: [{shadow: COMMON_UNITS}], 2611 | "font-size": [{text: ["tiny", ...COMMON_UNITS]}], 2612 | "bg-image": ["bg-stripe-gradient"], 2613 | "min-w": [ 2614 | { 2615 | "min-w": ["unit", "unit-2", "unit-4", "unit-6"], 2616 | }, 2617 | ], 2618 | }, 2619 | }, 2620 | }, 2621 | ); 2622 | 2623 | const result = styles({ 2624 | size: "medium", 2625 | color: "blue", 2626 | }); 2627 | 2628 | expect(result).toHaveClass(["text-medium", "text-blue-500", "w-unit-4"]); 2629 | }); 2630 | }); 2631 | --------------------------------------------------------------------------------