├── .changeset ├── README.md └── config.json ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── src ├── cx.ts ├── index.ts ├── react.ts └── variants.ts ├── test ├── cx.spec.ts ├── react.spec.tsx ├── setup.ts └── variants.spec.ts ├── tsconfig.json └── vite.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | pull_request: 7 | branches: 8 | - "**" 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | - name: Setup PNPM 17 | uses: pnpm/action-setup@v2 18 | with: 19 | version: 7 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 18.x 24 | cache: "pnpm" 25 | - name: Install dependencies 26 | run: pnpm install --frozen-lockfile 27 | - name: CI 28 | run: pnpm run ci 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_call: 4 | secrets: 5 | NPM_TOKEN: 6 | required: true 7 | push: 8 | branches: 9 | - "main" 10 | 11 | concurrency: ${{ github.workflow }}-${{ github.ref }} 12 | 13 | jobs: 14 | build: 15 | name: Release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - name: Setup PNPM 21 | uses: pnpm/action-setup@v2 22 | with: 23 | version: 7 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 18.x 28 | cache: "pnpm" 29 | - name: Install dependencies 30 | run: pnpm install --frozen-lockfile 31 | - name: Create Release Pull Request or Publish 32 | id: changesets 33 | uses: changesets/action@v1 34 | with: 35 | publish: pnpm run release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .changeset 2 | src 3 | test 4 | pnpm-lock.yaml 5 | package-lock.json 6 | tsconfig.json 7 | .gitignore 8 | .github 9 | .changeset 10 | vite.config.ts 11 | .prettierrc -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "arrowParens": "avoid", 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-tailwind-variants 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - d57c9b7: typescript should throw an error when passed compoundVariants or defaultVariants with non-existing variants 8 | 9 | ## 1.0.1 10 | 11 | ### Patch Changes 12 | 13 | - ac2ede4: Fix typings 14 | 15 | ## 1.0.0 16 | 17 | ### Major Changes 18 | 19 | - 9c6f874: BREAKING CHANGE: set type `VariantsConfig` for `config` argument in `styled` function + fix typings 20 | - 8dda492: BREAKING CHANGE: set `variants` in `VariantsConfig` optional + improve type-hints 21 | 22 | ## 0.1.3 23 | 24 | ### Patch Changes 25 | 26 | - 0c1a5c5: Add vite.config.ts and .prettierrc files to npmignore 27 | - 78d9d8c: Fix package homepage and bugs url 28 | 29 | ## 0.1.2 30 | 31 | ### Patch Changes 32 | 33 | - fa364d4: update README.md 34 | 35 | ## 0.1.1 36 | 37 | ### Patch Changes 38 | 39 | - 382b5b8: add test folder to .npmignore 40 | 41 | ## 0.1.0 42 | 43 | ### Minor Changes 44 | 45 | - 2adff2e: initial release 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Salavat Salakhutdinov 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Tailwind Variants 2 | 3 | [![npm package][npm-img]][npm-url] 4 | [![npm bundle size][bundle-size-img]][bundle-size-url] 5 | [![Downloads][downloads-img]][downloads-url] 6 | 7 | `Stitches.js`-like API for creating composable components. You can define a single variant, multiple variants, and even compound variants which allow you to define classes based on a combination of variants. 8 | 9 | This is a modified version of the [`classname-variants`](https://github.com/fgnass/classname-variants/) 10 | 11 | ## Features 12 | 13 | - 📦 Lightweight 14 | - 📜 Fully type-safe 15 | - 💅🏼 Elegant [Stitches-like](https://github.com/stitchesjs/stitches) variants API 16 | - 🗑️ Automatic tailwindcss classes conflict resolution via [tailwind-merge](https://github.com/dcastil/tailwind-merge) 17 | - ♻️ Polymorphic components via [@radix-ui/slot](https://www.radix-ui.com/docs/primitives/utilities/slot) 18 | 19 | ## Installation 20 | 21 | ```bash 22 | npm install tailwind-merge react-tailwind-variants 23 | ``` 24 | 25 | ## Usage 26 | 27 | - [Basics](#basics) 28 | - [Boolean variants](#boolean-variants) 29 | - [Compound variants](#compound-variants) 30 | - [Default variants](#default-variants) 31 | - [Polymorphic components](#polymorphic-components) 32 | - [Composing components](#composing-components) 33 | - [Utilities](#utilities) 34 | - [variants(config)](#variantsconfig) 35 | - [variantProps(config)](#variantpropsconfig) 36 | - [extractVariantsConfig(component)](#extractvariantsconfigcomponent) 37 | - [Typescript utilities](#typescript-utilities) 38 | - [VariantsConfigOf\](#variantsconfigofcomponent) 39 | - [VariantPropsOf\](#variantpropsofcomponent) 40 | - [Tailwind CSS IntelliSense](#tailwind-css-intellisense) 41 | 42 | ### Basics 43 | 44 | Let's assume we want to build a button component with Tailwind CSS that comes in different sizes and colors. 45 | 46 | It consists of some _base classes_ that are always present as well as some optional classes that need to be added depending on the desired _variants_. 47 | 48 | ```tsx 49 | import { styled } from 'react-tailwind-variants'; 50 | 51 | const Button = styled('button', { 52 | base: 'rounded text-white', 53 | variants: { 54 | color: { 55 | brand: 'bg-sky-500', 56 | accent: 'bg-teal-500', 57 | }, 58 | size: { 59 | small: 'px-5 py-3 text-xs', 60 | large: 'px-6 py-4 text-base', 61 | }, 62 | }, 63 | }); 64 | ``` 65 | 66 | The result is a react component: 67 | 68 | ```tsx 69 | 72 | ``` 73 | 74 | Component will be rendered as: 75 | 76 | ```html 77 | 83 | ``` 84 | 85 | As a value for classes, you can use a `"string"`, an `"array"` of strings, or `"null"`: 86 | 87 | ```tsx 88 | import { styled } from 'react-tailwind-variants'; 89 | 90 | const Button = styled('button', { 91 | base: ['rounded', 'text-white'], 92 | variants: { 93 | color: { 94 | none: null, 95 | brand: 'bg-sky-500', 96 | accent: 'bg-teal-500', 97 | }, 98 | }, 99 | }); 100 | ``` 101 | 102 | --- 103 | 104 | ### Boolean variants 105 | 106 | Variants can be of type `boolean` by using `"true"` or/and `"false"` as the key: 107 | 108 | ```tsx 109 | import { styled } from 'react-tailwind-variants'; 110 | 111 | const Button = styled('button', { 112 | base: 'text-white', 113 | variants: { 114 | rounded: { 115 | true: 'rounded-full', 116 | }, 117 | }, 118 | }); 119 | ``` 120 | 121 | --- 122 | 123 | ### Compound variants 124 | 125 | The `compoundVariants` option can be used to apply class names based on a combination of other variants. 126 | 127 | ```tsx 128 | import { styled } from 'react-tailwind-variants'; 129 | 130 | const Button = styled('button', { 131 | base: 'text-base' 132 | variants: { 133 | variant: { 134 | none: null, 135 | filled: 'bg-blue-500 text-white', 136 | outlined: 'border border-blue-500 text-blue-500', 137 | plain: 'bg-transparent text-blue-500', 138 | }, 139 | size: { 140 | sm: 'px-3 py-1.5' 141 | md: 'px-4 py-2' 142 | lg: 'px-6 py-3' 143 | }, 144 | }, 145 | compoundVariants: [ 146 | { 147 | variants: { 148 | variant: ['filled', 'outlined'], 149 | size: 'sm' 150 | }, 151 | className: 'text-sm' 152 | }, 153 | { 154 | // `compoundVariants` className takes 155 | // precedence over `variants`, 156 | // so in this case the class `p-0` 157 | // will override `padding` classes 158 | variants: { 159 | variant: 'none' 160 | }, 161 | className: 'p-0' 162 | }, 163 | ], 164 | }); 165 | ``` 166 | 167 | ```tsx 168 | 169 | 170 | ``` 171 | 172 | will be rendered as: 173 | 174 | ```html 175 | 180 | 181 | ``` 182 | 183 | --- 184 | 185 | ### Default variants 186 | 187 | The `defaultVariants` option can be used to select a variant by default. 188 | All non-boolean variants for which no default values are specified are required. 189 | If no default value is specified for boolean options, it evaluates to "false" 190 | 191 | Below is an example with a component that has a required `size` and an optional `color` variants 192 | 193 | ```tsx 194 | import { styled } from 'react-tailwind-variants'; 195 | 196 | const Button = styled('button', { 197 | variants: { 198 | color: { 199 | brand: 'bg-sky-500', 200 | accent: 'bg-teal-500', 201 | }, 202 | size: { 203 | small: 'px-5 py-3 text-xs', 204 | large: 'px-6 py-4 text-base', 205 | }, 206 | elevated: { 207 | true: 'shadow', 208 | }, 209 | }, 210 | defaultVariants: { 211 | color: 'neutral', 212 | }, 213 | }); 214 | ``` 215 | 216 | --- 217 | 218 | ### Polymorphic components 219 | 220 | If you want to keep all the variants you have defined for a component but want to render a different HTML tag or a different custom component, you can use the `"asChild"` prop to do so: 221 | 222 | ```tsx 223 | import { styled } from 'react-tailwind-variants'; 224 | 225 | const Button = styled('button', { 226 | base: 'rounded text-white', 227 | variants: { 228 | color: { 229 | brand: 'bg-sky-500', 230 | accent: 'bg-teal-500', 231 | }, 232 | size: { 233 | small: 'px-5 py-3 text-xs', 234 | large: 'px-6 py-4 text-base', 235 | }, 236 | }, 237 | }); 238 | ``` 239 | 240 | ```tsx 241 | 246 | ``` 247 | 248 | will be rendered as: 249 | 250 | ```html 251 | 255 | Button as link 256 | 257 | ``` 258 | 259 | --- 260 | 261 | ### Composing components 262 | 263 | Composing one styled component into another. 264 | 265 | 1. Components can be composed via the `styled` function. 266 | 267 | ```tsx 268 | import { styled } from 'react-tailwind-variants'; 269 | 270 | const BaseButton = styled('button', { 271 | base: 'text-center bg-blue-500 text-white', 272 | variants: { 273 | size: { 274 | small: 'px-5 py-3 text-xs', 275 | large: 'px-6 py-4 text-base', 276 | }, 277 | }, 278 | }); 279 | 280 | const Button = styled(BaseButton, { 281 | base: 'rounded text-white', 282 | variants: { 283 | color: { 284 | brand: 'bg-sky-500', 285 | accent: 'bg-teal-500', 286 | }, 287 | }, 288 | }); 289 | ``` 290 | 291 | ```tsx 292 | 295 | ``` 296 | 297 | will be rendered as: 298 | 299 | ```html 300 | 306 | ``` 307 | 308 | 2. You can also achieve the same result using `"asChild"` prop: 309 | 310 | ```tsx 311 | import { styled } from 'react-tailwind-variants'; 312 | 313 | const BaseButton = styled('button', { 314 | base: 'text-center bg-blue-500 text-white', 315 | variants: { 316 | size: { 317 | small: 'px-5 py-3 text-xs', 318 | large: 'px-6 py-4 text-base', 319 | }, 320 | }, 321 | }); 322 | 323 | const Button = styled('button', { 324 | base: 'rounded text-white', 325 | variants: { 326 | color: { 327 | brand: 'bg-sky-500', 328 | accent: 'bg-teal-500', 329 | }, 330 | }, 331 | }); 332 | ``` 333 | 334 | ```tsx 335 | 336 | 339 | 340 | ``` 341 | 342 | will be rendered as: 343 | 344 | ```html 345 | 351 | ``` 352 | 353 | --- 354 | 355 | ### Utilities 356 | 357 | #### `variants(config)` 358 | 359 | The function accepts variants config as argument and returns a `className` builder function 360 | 361 | ```ts 362 | import { variants } from 'react-tailwind-variants'; 363 | 364 | const buttonVariants = variants({ 365 | base: 'rounded text-white', 366 | variants: { 367 | color: { 368 | brand: 'bg-sky-500', 369 | accent: 'bg-teal-500', 370 | }, 371 | size: { 372 | small: 'px-5 py-3 text-xs', 373 | large: 'px-6 py-4 text-base', 374 | }, 375 | }, 376 | }); 377 | 378 | console.log( 379 | buttonVariants({ 380 | color: 'brand', 381 | size: 'small', 382 | className: 'text-sky-900 px-8', 383 | }) 384 | ); 385 | // Console output: 386 | // 'rounded bg-sky-500 py-3 text-xs text-sky-900 px-8' 387 | ``` 388 | 389 | #### `variantProps(config)` 390 | 391 | The function accepts variants config as argument and returns props builder function 392 | 393 | ```ts 394 | import { variantProps } from 'react-tailwind-variants'; 395 | 396 | const buttonVariantProps = variantProps({ 397 | base: 'rounded text-white', 398 | variants: { 399 | color: { 400 | brand: 'bg-sky-500', 401 | accent: 'bg-teal-500', 402 | }, 403 | size: { 404 | small: 'px-5 py-3 text-xs', 405 | large: 'px-6 py-4 text-base', 406 | }, 407 | }, 408 | }); 409 | 410 | console.log( 411 | buttonVariantProps({ 412 | color: 'brand', 413 | size: 'small', 414 | className: 'text-sky-900 px-8', 415 | type: 'button', 416 | onClick: e => { 417 | // ... 418 | }, 419 | }) 420 | ); 421 | // Console output: 422 | // { 423 | // className: 'rounded bg-sky-500 py-3 text-xs text-sky-900 px-8' 424 | // type: "button", 425 | // onClick: ... 426 | // } 427 | ``` 428 | 429 | #### `extractVariantsConfig(component)` 430 | 431 | The function accepts a component from which it extracts the configuration of variants 432 | 433 | ```ts 434 | import { styled, extractVariantsConfig } from 'react-tailwind-variants'; 435 | 436 | const Button = styled('button', { 437 | base: ['rounded', 'text-white'], 438 | variants: { 439 | color: { 440 | none: null, 441 | brand: 'bg-sky-500', 442 | accent: 'bg-teal-500', 443 | }, 444 | }, 445 | }); 446 | 447 | console.log(extractVariantsConfig(Button)); 448 | // Console output: 449 | // { 450 | // base: ['rounded', 'text-white'], 451 | // variants: { 452 | // color: { 453 | // none: null, 454 | // brand: 'bg-sky-500', 455 | // accent: 'bg-teal-500', 456 | // }, 457 | // }, 458 | // } 459 | ``` 460 | 461 | ### Typescript utilities 462 | 463 | #### `VariantsConfigOf` 464 | 465 | A utility that allows you to extract the configuration type from the component type 466 | 467 | ```ts 468 | import { type VariantsConfigOf, styled } from 'react-tailwind-variants'; 469 | 470 | const Button = styled('button', { 471 | base: ['rounded', 'text-white'], 472 | variants: { 473 | color: { 474 | none: null, 475 | brand: 'bg-sky-500', 476 | accent: 'bg-teal-500', 477 | }, 478 | }, 479 | }); 480 | 481 | type ButtonVariantsConfig = VariantsConfigOf; 482 | ``` 483 | 484 | #### `VariantPropsOf` 485 | 486 | A utility that allows you to extract the variant props type from the component type 487 | 488 | ```ts 489 | import { type VariantPropsOf, styled } from 'react-tailwind-variants'; 490 | 491 | const Button = styled('button', { 492 | base: ['rounded', 'text-white'], 493 | variants: { 494 | color: { 495 | none: null, 496 | brand: 'bg-sky-500', 497 | accent: 'bg-teal-500', 498 | }, 499 | }, 500 | }); 501 | 502 | type ButtonVariantProps = VariantPropsOf; 503 | ``` 504 | 505 | ### Tailwind CSS IntelliSense 506 | 507 | In order to get auto-completion for the CSS classes themselves, you can use the [Tailwind CSS IntelliSense](https://github.com/tailwindlabs/tailwindcss-intellisense) plugin for VS Code. In order to make it recognize the strings inside your variants-config, you have to somehow mark them and configure the plugin accordingly. 508 | 509 | One way of doing so is by using tagged template literals: 510 | 511 | ```ts 512 | import { styled, tw } from 'react-tailwind-variants'; 513 | 514 | const Button = styled('button', { 515 | base: tw`px-5 py-2 text-white`, 516 | variants: { 517 | color: { 518 | neutral: tw`bg-slate-500 hover:bg-slate-400`, 519 | accent: tw`bg-teal-500 hover:bg-teal-400`, 520 | }, 521 | }, 522 | }); 523 | ``` 524 | 525 | You can then add the following line to your `settings.json`: 526 | 527 | ``` 528 | "tailwindCSS.experimental.classRegex": ["tw`(\\`|[^`]+?)`"] 529 | ``` 530 | 531 | > **Note** 532 | > The `tw` helper function is just an alias for [`String.raw`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/raw). 533 | 534 | [npm-img]: https://img.shields.io/npm/v/react-tailwind-variants 535 | [npm-url]: https://www.npmjs.com/package/react-tailwind-variants 536 | [bundle-size-img]: https://img.shields.io/bundlephobia/minzip/react-tailwind-variants 537 | [bundle-size-url]: https://bundlephobia.com/package/react-tailwind-variants 538 | [downloads-img]: https://img.shields.io/npm/dt/react-tailwind-variants 539 | [downloads-url]: https://www.npmtrends.com/react-tailwind-variants 540 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tailwind-variants", 3 | "version": "1.0.2", 4 | "description": "React Stitches-like variants API for tailwindcss classes", 5 | "private": false, 6 | "main": "./dist/index.js", 7 | "module": "./dist/index.mjs", 8 | "types": "./dist/index.d.ts", 9 | "scripts": { 10 | "dev": "vitest", 11 | "test": "vitest run", 12 | "build": "tsup src/index.ts --format cjs,esm --dts", 13 | "lint": "tsc", 14 | "ci": "pnpm run lint && pnpm run test && pnpm run build", 15 | "release": "pnpm run ci && changeset publish" 16 | }, 17 | "license": "MIT", 18 | "author": { 19 | "name": "Salavat Salakhutdinov", 20 | "email": "salahutdinov.salavat@gmail.com", 21 | "url": "https://github.com/jackardios" 22 | }, 23 | "keywords": [ 24 | "tailwind", 25 | "tailwindcss", 26 | "css", 27 | "cva", 28 | "classname", 29 | "classname-variants", 30 | "tailwind-variants", 31 | "stitches.js", 32 | "stitches", 33 | "stitches-like", 34 | "variants", 35 | "react" 36 | ], 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/jackardios/react-tailwind-variants.git" 40 | }, 41 | "bugs": { 42 | "url": "https://github.com/jackardios/react-tailwind-variants/issues" 43 | }, 44 | "homepage": "https://github.com/jackardios/react-tailwind-variants#readme", 45 | "devDependencies": { 46 | "@changesets/cli": "^2.26.0", 47 | "@testing-library/jest-dom": "^5.16.5", 48 | "@testing-library/react": "^14.0.0", 49 | "@types/react": "^18.0.28", 50 | "@types/react-dom": "^18.0.11", 51 | "@vitejs/plugin-react": "^3.1.0", 52 | "jsdom": "^21.1.1", 53 | "react-dom": "^18.2.0", 54 | "tsup": "^6.6.3", 55 | "typescript": "^4.9.5", 56 | "vitest": "^0.29.2" 57 | }, 58 | "dependencies": { 59 | "@radix-ui/react-slot": "^1.0.1" 60 | }, 61 | "peerDependencies": { 62 | "react": "^16.8 || ^17.0 || ^18.0", 63 | "tailwind-merge": "^1.10.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/cx.ts: -------------------------------------------------------------------------------- 1 | import { twMerge } from 'tailwind-merge'; 2 | import { type ClassNameValue } from './variants'; 3 | 4 | export type CxOptions = ClassNameValue[]; 5 | export type CxReturn = string; 6 | 7 | export const cx = (...classes: T): CxReturn => 8 | // @ts-ignore 9 | twMerge(classes.flat(Infinity).filter(Boolean).join(' ')); 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { type CxOptions, type CxReturn, cx } from './cx'; 2 | 3 | export { 4 | type VariantsConfig, 5 | type VariantsSchema, 6 | type VariantOptions, 7 | variants, 8 | } from './variants'; 9 | 10 | export { 11 | type StyledComponent, 12 | type VariantPropsOf, 13 | type VariantsConfigOf, 14 | variantProps, 15 | extractVariantsConfig, 16 | styled, 17 | } from './react'; 18 | 19 | /** 20 | * No-op function to mark template literals as tailwind strings. 21 | */ 22 | export const tw = String.raw; 23 | -------------------------------------------------------------------------------- /src/react.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ComponentPropsWithoutRef, 3 | type ComponentRef, 4 | type ElementType, 5 | type ForwardRefExoticComponent, 6 | createElement, 7 | forwardRef, 8 | } from 'react'; 9 | 10 | import { Slot } from '@radix-ui/react-slot'; 11 | 12 | import { 13 | type VariantsConfig, 14 | type VariantsSchema, 15 | type VariantOptions, 16 | variants, 17 | Simplify, 18 | } from './variants'; 19 | 20 | const StyledComponentConfigKey = '$$tailwindVariantsConfig'; 21 | 22 | interface StyledComponentConfigProp> { 23 | readonly [StyledComponentConfigKey]: C; 24 | } 25 | 26 | export type StyledComponent< 27 | ForwardRefComponent extends ForwardRefExoticComponent, 28 | C extends VariantsConfig 29 | > = ForwardRefComponent & StyledComponentConfigProp; 30 | 31 | export function variantProps< 32 | C extends VariantsConfig, 33 | V extends VariantsSchema = NonNullable 34 | >(config: Simplify) { 35 | const variantsHandler = variants(config); 36 | 37 | type Props = VariantOptions & { 38 | className?: string; 39 | }; 40 | 41 | return function

(props: P) { 42 | const result: any = {}; 43 | 44 | // Pass-through all unrelated props 45 | for (let prop in props) { 46 | if ( 47 | !('variants' in config) || 48 | !config.variants || 49 | !(prop in (config.variants as V)) 50 | ) { 51 | result[prop] = props[prop]; 52 | } 53 | } 54 | 55 | // Add the optionally passed className prop for chaining 56 | result.className = variantsHandler(props); 57 | 58 | return result as { className: string } & Omit; 59 | }; 60 | } 61 | 62 | type SlottableProps< 63 | T extends ElementType, 64 | P 65 | > = T extends keyof JSX.IntrinsicElements 66 | ? Omit & { 67 | asChild?: boolean; 68 | } 69 | : P; 70 | 71 | export function styled< 72 | T extends ElementType, 73 | C extends VariantsConfig, 74 | V extends VariantsSchema = NonNullable 75 | >(baseType: T, config: Simplify) { 76 | const propsHandler = variantProps(config); 77 | 78 | type ConfigVariants = VariantOptions; 79 | type Props = SlottableProps< 80 | T, 81 | ConfigVariants & Omit, keyof ConfigVariants> 82 | >; 83 | 84 | const component = forwardRef, Props>((props, ref) => { 85 | // only JSX.IntrinsicElements can be slottable 86 | if (typeof baseType === 'string' && 'asChild' in props && props.asChild) { 87 | const { asChild, ...otherProps } = props; 88 | 89 | return createElement(Slot, { 90 | ...propsHandler(otherProps as any), 91 | ref: ref as any, 92 | }); 93 | } 94 | 95 | return createElement(baseType, { 96 | ...propsHandler(props as any), 97 | ref: ref as any, 98 | }); 99 | }); 100 | 101 | return Object.assign(component, { 102 | [StyledComponentConfigKey]: config, 103 | }) as StyledComponent; 104 | } 105 | 106 | export type VariantsConfigOf< 107 | Component extends StyledComponent, C>, 108 | C extends VariantsConfig = Component[typeof StyledComponentConfigKey], 109 | V extends VariantsSchema = NonNullable 110 | > = C; 111 | 112 | export type VariantPropsOf< 113 | Component extends StyledComponent, C>, 114 | C extends VariantsConfig = Component[typeof StyledComponentConfigKey], 115 | V extends VariantsSchema = NonNullable 116 | > = VariantOptions; 117 | 118 | export function extractVariantsConfig< 119 | C extends VariantsConfig, 120 | V extends VariantsSchema = NonNullable 121 | >(styledComponent: StyledComponent, C>) { 122 | return styledComponent[StyledComponentConfigKey]; 123 | } 124 | -------------------------------------------------------------------------------- /src/variants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a modified version of the `classname-variants` 3 | * See https://github.com/fgnass/classname-variants/ 4 | */ 5 | 6 | import { cx } from './cx'; 7 | 8 | type PickRequiredKeys = { 9 | [K in keyof T]-?: {} extends Pick ? never : K; 10 | }[keyof T]; 11 | type OmitByValue = { 12 | [P in keyof T as T[P] extends Value ? never : P]: T[P]; 13 | }; 14 | type StringToBoolean = T extends 'true' | 'false' ? boolean : T; 15 | export type Simplify = { [KeyType in keyof T]: T[KeyType] }; 16 | 17 | export type ClassNameValue = string | null | undefined | ClassNameValue[]; 18 | 19 | /** 20 | * Definition of the available variants and their options. 21 | * @example 22 | * { 23 | * color: { 24 | * white: "bg-white" 25 | * green: "bg-green-500", 26 | * }, 27 | * size: { 28 | * small: "text-xs", 29 | * large: "text-lg" 30 | * } 31 | * } 32 | */ 33 | export type VariantsSchema = Record>; 34 | 35 | export type VariantsConfig = { 36 | base?: ClassNameValue; 37 | variants?: V; 38 | defaultVariants?: keyof V extends never 39 | ? Record 40 | : Partial>; 41 | compoundVariants?: keyof V extends never ? never[] : CompoundVariant[]; 42 | }; 43 | 44 | /** 45 | * Rules for class names that are applied for certain variant combinations. 46 | */ 47 | interface CompoundVariant { 48 | variants: Partial>; 49 | className: ClassNameValue; 50 | } 51 | 52 | type Variants = { 53 | [Variant in keyof V]: StringToBoolean; 54 | }; 55 | 56 | type VariantsMulti = { 57 | [Variant in keyof V]: 58 | | StringToBoolean 59 | | StringToBoolean[]; 60 | }; 61 | 62 | /** 63 | * Only the boolean variants, i.e. ones that have "true" or "false" as options. 64 | */ 65 | type BooleanVariants< 66 | C extends VariantsConfig, 67 | V extends VariantsSchema = NonNullable 68 | > = { 69 | [Variant in keyof V as V[Variant] extends { true: any } | { false: any } 70 | ? Variant 71 | : never]: V[Variant]; 72 | }; 73 | 74 | /** 75 | * Only the variants for which a default options is set. 76 | */ 77 | type DefaultVariants< 78 | C extends VariantsConfig, 79 | V extends VariantsSchema = NonNullable 80 | > = { 81 | [Variant in keyof V as Variant extends keyof OmitByValue< 82 | C['defaultVariants'], 83 | undefined 84 | > 85 | ? Variant 86 | : never]: V[Variant]; 87 | }; 88 | 89 | /** 90 | * Names of all optional variants, i.e. booleans or ones with default options. 91 | */ 92 | type OptionalVariantNames< 93 | C extends VariantsConfig, 94 | V extends VariantsSchema = NonNullable 95 | > = keyof BooleanVariants | keyof DefaultVariants; 96 | 97 | /** 98 | * Extracts the possible options. 99 | */ 100 | export type VariantOptions< 101 | C extends VariantsConfig, 102 | V extends VariantsSchema = NonNullable 103 | > = keyof V extends never 104 | ? {} 105 | : Required, OptionalVariantNames>> & 106 | Partial, OptionalVariantNames>>; 107 | 108 | type VariantsHandlerFn

= PickRequiredKeys

extends never 109 | ? (props?: P) => string 110 | : (props: P) => string; 111 | 112 | export function variants< 113 | C extends VariantsConfig, 114 | V extends VariantsSchema = NonNullable 115 | >(config: Simplify) { 116 | const { base, variants, compoundVariants, defaultVariants } = config; 117 | 118 | if (!('variants' in config) || !config.variants) { 119 | return (props?: { className?: ClassNameValue }) => 120 | cx(base, props?.className); 121 | } 122 | 123 | function isBooleanVariant(name: keyof V) { 124 | const variant = (variants as V)?.[name]; 125 | return variant && ('false' in variant || 'true' in variant); 126 | } 127 | 128 | return function (props?: { className?: ClassNameValue }) { 129 | const result = [base]; 130 | 131 | const getSelectedVariant = (name: keyof V) => 132 | (props as any)?.[name] ?? 133 | defaultVariants?.[name] ?? 134 | (isBooleanVariant(name) ? false : undefined); 135 | 136 | for (let name in variants) { 137 | const selected = getSelectedVariant(name); 138 | if (selected !== undefined) result.push(variants[name]?.[selected]); 139 | } 140 | 141 | for (let { variants, className } of compoundVariants ?? []) { 142 | function isSelectedVariant(name: string) { 143 | const selected = getSelectedVariant(name); 144 | const cvSelector = variants[name]; 145 | 146 | return Array.isArray(cvSelector) 147 | ? cvSelector.includes(selected) 148 | : selected === cvSelector; 149 | } 150 | 151 | if (Object.keys(variants).every(isSelectedVariant)) { 152 | result.push(className); 153 | } 154 | } 155 | 156 | if (props?.className) { 157 | result.push(props.className); 158 | } 159 | 160 | return cx(result); 161 | } as VariantsHandlerFn< 162 | VariantOptions & { 163 | className?: ClassNameValue; 164 | } 165 | >; 166 | } 167 | -------------------------------------------------------------------------------- /test/cx.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { type CxOptions, cx } from '../src'; 4 | 5 | describe('cx', () => { 6 | describe.each([ 7 | [null, ''], 8 | [undefined, ''], 9 | [ 10 | ['text-lg', undefined, 'xl:text-lg', 'text-xl', undefined, 'bg-gray-100'], 11 | 'xl:text-lg text-xl bg-gray-100', 12 | ], 13 | [ 14 | [ 15 | 'foo', 16 | [ 17 | undefined, 18 | ['text-lg'], 19 | [ 20 | undefined, 21 | [ 22 | 'baz', 23 | 'xl:text-lg', 24 | 'bg-gray-100', 25 | 'quuz', 26 | [[[[[[[[['text-xl', 'grault']]]]], 'garply']]]], 27 | ], 28 | ], 29 | ], 30 | ], 31 | 'foo baz xl:text-lg bg-gray-100 quuz text-xl grault garply', 32 | ], 33 | ])('cx(%o)', (options, expected) => { 34 | test(`returns ${expected}`, () => { 35 | expect(cx(options)).toBe(expected); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/react.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { describe, expect, it, test } from 'vitest'; 3 | import { render, screen } from '@testing-library/react'; 4 | import { variantProps, extractVariantsConfig, styled } from '../src'; 5 | 6 | const StyledComponent = styled('button', { 7 | base: 'px-5 py-2 text-white disabled:bg-gray-400 disabled:text-gray-300', 8 | variants: { 9 | color: { 10 | neutral: 'bg-slate-500 hover:bg-slate-400', 11 | accent: 'bg-teal-500 hover:bg-teal-400', 12 | }, 13 | outlined: { 14 | true: 'border-2', 15 | }, 16 | size: { 17 | small: 'text-xs px-4', 18 | large: 'text-base px-6', 19 | }, 20 | }, 21 | compoundVariants: [ 22 | { 23 | variants: { color: 'accent', outlined: true }, 24 | className: 'border-teal-600', 25 | }, 26 | ], 27 | defaultVariants: { 28 | size: 'small', 29 | }, 30 | }); 31 | 32 | // ============================================ 33 | 34 | const StyledComponentWithoutVariants = styled('div', { 35 | base: 'bg-white p-4 border-2 rounded-lg', 36 | }); 37 | 38 | // ============================================ 39 | 40 | interface CustomComponentProps { 41 | className?: string; 42 | content: string; 43 | } 44 | const CustomComponent = forwardRef( 45 | ({ content, className }, ref) => { 46 | return ( 47 |

48 | {content} 49 |
50 | ); 51 | } 52 | ); 53 | 54 | const StyledComponentExtendingAnotherComponent = styled(CustomComponent, { 55 | base: 'px-5 py-2 w-16 text-white', 56 | variants: { 57 | color: { 58 | neutral: 'bg-slate-500 hover:bg-slate-400', 59 | accent: 'bg-teal-500 hover:bg-teal-400', 60 | }, 61 | }, 62 | }); 63 | 64 | // ============================================ 65 | 66 | const FirstLevelStyledComponent = styled('article', { 67 | base: 'flex', 68 | variants: { 69 | wrap: { 70 | true: 'flex-wrap', 71 | false: '', 72 | }, 73 | indents: { 74 | none: '', 75 | normal: 'px-16 my-4 mx-8', 76 | relaxed: 'px-24 my-8 mx-12', 77 | }, 78 | }, 79 | defaultVariants: { 80 | wrap: true, 81 | }, 82 | }); 83 | 84 | const SecondLevelStyledComponent = styled(FirstLevelStyledComponent, { 85 | base: 'px-8 py-4 text-center', 86 | variants: { 87 | size: { 88 | small: 'w-12 h-10', 89 | large: 'w-14 h-10', 90 | }, 91 | variant: { 92 | elevated: 'rounded-lg shadow', 93 | outlined: 'rounded-sm border', 94 | }, 95 | }, 96 | defaultVariants: { 97 | variant: 'elevated', 98 | }, 99 | }); 100 | 101 | const StyledComponentExtendingDeepStyledComponent = styled( 102 | SecondLevelStyledComponent, 103 | { 104 | base: 'px-5 py-2 w-16 text-white disabled:bg-gray-400 disabled:text-gray-300', 105 | variants: { 106 | color: { 107 | neutral: 'bg-slate-500 hover:bg-slate-400', 108 | accent: 'bg-teal-500 hover:bg-teal-400', 109 | }, 110 | }, 111 | } 112 | ); 113 | 114 | // ============================================ 115 | 116 | styled('div', { 117 | variants: { 118 | color: { 119 | neutral: 'grey', 120 | accent: 'hotpink', 121 | }, 122 | }, 123 | compoundVariants: [ 124 | { 125 | variants: { 126 | // @ts-expect-error 127 | color: 'foobar', 128 | }, 129 | className: '', 130 | }, 131 | ], 132 | }); 133 | 134 | styled('div', { 135 | variants: { 136 | color: { 137 | neutral: 'grey', 138 | accent: 'hotpink', 139 | }, 140 | }, 141 | defaultVariants: { 142 | // @ts-expect-error 143 | color: 'foobar', 144 | }, 145 | }); 146 | 147 | styled('div', { 148 | variants: { 149 | color: { 150 | neutral: 'grey', 151 | accent: 'hotpink', 152 | }, 153 | }, 154 | defaultVariants: { 155 | // @ts-expect-error 156 | test: 'invalid', 157 | }, 158 | }); 159 | 160 | styled('div', { 161 | variants: { 162 | color: { 163 | neutral: 'grey', 164 | accent: 'hotpink', 165 | }, 166 | }, 167 | defaultVariants: { 168 | // @ts-expect-error 169 | outlined: true, 170 | }, 171 | }); 172 | 173 | // ============================================ 174 | 175 | describe('variantProps', () => { 176 | it('should pass-through all unrelated props', () => { 177 | const propsHandler = variantProps({ 178 | base: 'px-5 py-2 text-white disabled:bg-gray-400', 179 | variants: { 180 | color: { 181 | neutral: 'bg-slate-500 hover:bg-slate-400', 182 | accent: 'bg-teal-500 hover:bg-teal-400', 183 | }, 184 | outlined: { 185 | true: 'border-2', 186 | }, 187 | size: { 188 | small: 'text-xs px-4', 189 | large: 'text-base px-6', 190 | }, 191 | }, 192 | compoundVariants: [ 193 | { 194 | variants: { color: 'accent', outlined: true }, 195 | className: 'border-teal-600', 196 | }, 197 | ], 198 | defaultVariants: { 199 | size: 'small', 200 | }, 201 | }); 202 | 203 | expect( 204 | propsHandler({ 205 | color: 'neutral', 206 | // @ts-expect-error 207 | size: 'not-existent', 208 | foo: 'bar', 209 | 'data-test': 'true', 210 | }) 211 | ).toEqual({ 212 | foo: 'bar', 213 | 'data-test': 'true', 214 | className: 215 | 'px-5 py-2 text-white disabled:bg-gray-400 bg-slate-500 hover:bg-slate-400', 216 | }); 217 | 218 | expect( 219 | propsHandler({ 220 | color: 'neutral', 221 | // @ts-expect-error 222 | size: 'not-existent', 223 | foo: 'bar', 224 | className: 'px-8', 225 | }) 226 | ).toEqual({ 227 | foo: 'bar', 228 | className: 229 | 'py-2 text-white disabled:bg-gray-400 bg-slate-500 hover:bg-slate-400 px-8', 230 | }); 231 | 232 | expect( 233 | propsHandler({ 234 | color: 'neutral', 235 | foo: 'bar', 236 | }) 237 | ).toEqual({ 238 | foo: 'bar', 239 | className: 240 | 'py-2 text-white disabled:bg-gray-400 bg-slate-500 hover:bg-slate-400 text-xs px-4', 241 | }); 242 | }); 243 | }); 244 | 245 | describe('extractVariantsConfig', () => { 246 | it('extracts variants config from styled component', () => { 247 | expect(extractVariantsConfig(StyledComponent)).toEqual({ 248 | base: 'px-5 py-2 text-white disabled:bg-gray-400 disabled:text-gray-300', 249 | variants: { 250 | color: { 251 | neutral: 'bg-slate-500 hover:bg-slate-400', 252 | accent: 'bg-teal-500 hover:bg-teal-400', 253 | }, 254 | outlined: { 255 | true: 'border-2', 256 | }, 257 | size: { 258 | small: 'text-xs px-4', 259 | large: 'text-base px-6', 260 | }, 261 | }, 262 | compoundVariants: [ 263 | { 264 | variants: { color: 'accent', outlined: true }, 265 | className: 'border-teal-600', 266 | }, 267 | ], 268 | defaultVariants: { 269 | size: 'small', 270 | }, 271 | }); 272 | }); 273 | 274 | it('extracts variants config from styled component without variants', () => { 275 | expect(extractVariantsConfig(StyledComponentWithoutVariants)).toEqual({ 276 | base: 'bg-white p-4 border-2 rounded-lg', 277 | }); 278 | }); 279 | }); 280 | 281 | describe('styled', () => { 282 | describe('styled component', () => { 283 | test('render without props', () => { 284 | render( 285 | // @ts-expect-error 286 | Button 287 | ); 288 | 289 | expect(screen.getByText('Button')).toMatchInlineSnapshot(` 290 | 295 | `); 296 | }); 297 | 298 | test('render with variants', () => { 299 | render(Button); 300 | 301 | expect(screen.getByText('Button')).toMatchInlineSnapshot(` 302 | 307 | `); 308 | }); 309 | 310 | test('render with variants and unrelated props', () => { 311 | render( 312 | 317 | Button 318 | 319 | ); 320 | 321 | const button = screen.getByText('Button'); 322 | 323 | expect(button).toMatchInlineSnapshot(` 324 | 330 | `); 331 | }); 332 | 333 | test('render as child with variants and unrelated props', () => { 334 | render( 335 | 341 | 342 | Link 343 | 344 | 345 | ); 346 | 347 | const link = screen.getByText('Link'); 348 | 349 | expect(link).toMatchInlineSnapshot(` 350 | 355 | Link 356 | 357 | `); 358 | }); 359 | }); 360 | 361 | describe('styled component without variants', () => { 362 | test('render without props', () => { 363 | render( 364 | Button 365 | ); 366 | 367 | expect(screen.getByText('Button')).toMatchInlineSnapshot(` 368 |
371 | Button 372 |
373 | `); 374 | }); 375 | 376 | test('render with unrelated props', () => { 377 | render( 378 | 382 | Button 383 | 384 | ); 385 | 386 | const button = screen.getByText('Button'); 387 | 388 | expect(button).toMatchInlineSnapshot(` 389 |
393 | Button 394 |
395 | `); 396 | }); 397 | 398 | test('render as child with unrelated props', () => { 399 | render( 400 | 405 | 406 | Link 407 | 408 | 409 | ); 410 | 411 | const link = screen.getByText('Link'); 412 | 413 | expect(link).toMatchInlineSnapshot(` 414 | 419 | Link 420 | 421 | `); 422 | }); 423 | }); 424 | 425 | describe('styled component that extends another deep styled component', () => { 426 | test('render with unrelated props', () => { 427 | render( 428 | // @ts-expect-error 429 | 436 |
437 | Some inner text 438 |
439 |
440 | ); 441 | 442 | expect(screen.getByTestId('StyledComponentExtendingDeepStyledComponent')) 443 | .toMatchInlineSnapshot(` 444 |
449 |
453 | Some inner text 454 |
455 |
456 | `); 457 | }); 458 | 459 | test('render as child with unrelated props', () => { 460 | render( 461 | 470 |
471 |
472 | Some inner text 473 |
474 |
475 |
476 | ); 477 | 478 | expect(screen.getByTestId('StyledComponentExtendingDeepStyledComponent')) 479 | .toMatchInlineSnapshot(` 480 |
486 |
490 | Some inner text 491 |
492 |
493 | `); 494 | }); 495 | }); 496 | 497 | describe('styled component that extends another component', () => { 498 | test('render with unrelated props', () => { 499 | render( 500 | // @ts-expect-error 501 | 507 |
508 | Some inner text 509 |
510 |
511 | ); 512 | 513 | expect(screen.getByTestId('CustomComponent')).toMatchInlineSnapshot(` 514 |
518 | Some content 519 |
520 | `); 521 | }); 522 | 523 | test('render as child with unrelated props', () => { 524 | render( 525 | // @ts-expect-error 526 | 533 |
534 |
535 | Some inner text 536 |
537 |
538 |
539 | ); 540 | 541 | expect(screen.getByTestId('CustomComponent')).toMatchInlineSnapshot(` 542 |
546 | Some content 547 |
548 | `); 549 | }); 550 | }); 551 | }); 552 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { expect, afterEach } from 'vitest'; 2 | import { cleanup } from '@testing-library/react'; 3 | import matchers from '@testing-library/jest-dom/matchers'; 4 | 5 | // extends Vitest's expect method with methods from react-testing-library 6 | expect.extend(matchers); 7 | 8 | // runs a cleanup after each test case (e.g. clearing jsdom) 9 | afterEach(() => { 10 | cleanup(); 11 | }); 12 | -------------------------------------------------------------------------------- /test/variants.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { variants } from '../src'; 3 | 4 | type VariantParameters any> = 5 | Parameters[0]; 6 | 7 | describe('variants', () => { 8 | describe('without base', () => { 9 | describe('without variants', () => { 10 | test('with compoundVariants', () => { 11 | const withoutBaseWithCompoundVariants = variants({ 12 | compoundVariants: [ 13 | // @ts-expect-error 14 | { 15 | variants: { color: 'primary' }, 16 | className: 'text-primary-500', 17 | }, 18 | ], 19 | }); 20 | expect( 21 | withoutBaseWithCompoundVariants({ className: 'bg-blue-500 foobar' }) 22 | ).toEqual('bg-blue-500 foobar'); 23 | 24 | expect(withoutBaseWithCompoundVariants({ className: '' })).toEqual(''); 25 | expect(withoutBaseWithCompoundVariants({})).toEqual(''); 26 | expect(withoutBaseWithCompoundVariants()).toEqual(''); 27 | }); 28 | 29 | test('with defaultVariants', () => { 30 | const withoutBaseWithDefaultVariants = variants({ 31 | defaultVariants: { 32 | // @ts-expect-error 33 | color: 'primary', 34 | }, 35 | }); 36 | expect( 37 | withoutBaseWithDefaultVariants({ className: 'bg-blue-500 foobar' }) 38 | ).toEqual('bg-blue-500 foobar'); 39 | 40 | expect(withoutBaseWithDefaultVariants({ className: '' })).toEqual(''); 41 | expect(withoutBaseWithDefaultVariants({})).toEqual(''); 42 | expect(withoutBaseWithDefaultVariants()).toEqual(''); 43 | }); 44 | 45 | test('without anything', () => { 46 | const withoutAnything = variants({}); 47 | expect(withoutAnything({ className: 'bg-blue-500 foobar' })).toEqual( 48 | 'bg-blue-500 foobar' 49 | ); 50 | 51 | expect(withoutAnything({ className: '' })).toEqual(''); 52 | expect(withoutAnything({})).toEqual(''); 53 | expect(withoutAnything()).toEqual(''); 54 | }); 55 | }); 56 | 57 | describe('without defaults', () => { 58 | const buttonWithoutBaseWithoutDefaultsWithClassNameString = variants({ 59 | variants: { 60 | intent: { 61 | primary: 62 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600', 63 | secondary: 64 | 'button--secondary bg-white text-gray-800 border-gray-400 hover:bg-gray-100', 65 | warning: 66 | 'button--warning bg-yellow-500 border-transparent hover:bg-yellow-600', 67 | danger: 68 | 'button--danger bg-red-500 text-white border-transparent hover:bg-red-600', 69 | }, 70 | disabled: { 71 | true: 'button--disabled opacity-050 cursor-not-allowed', 72 | false: 'button--enabled cursor-pointer', 73 | }, 74 | size: { 75 | none: null, 76 | small: 'button--small text-sm py-1 px-2', 77 | medium: 'button--medium text-base py-2 px-4', 78 | large: 'button--large text-lg py-2.5 px-4', 79 | }, 80 | m: { 81 | 0: 'm-0', 82 | 1: 'm-1', 83 | }, 84 | }, 85 | compoundVariants: [ 86 | { 87 | variants: { 88 | intent: 'primary', 89 | size: 'medium', 90 | }, 91 | className: 'button--primary-medium uppercase', 92 | }, 93 | { 94 | variants: { 95 | intent: 'warning', 96 | disabled: false, 97 | }, 98 | className: 'button--warning-enabled text-gray-800', 99 | }, 100 | { 101 | variants: { 102 | intent: 'warning', 103 | disabled: true, 104 | }, 105 | className: 'button--warning-disabled text-black', 106 | }, 107 | ], 108 | }); 109 | 110 | const buttonWithoutBaseWithoutDefaultsWithClassNameArray = variants({ 111 | variants: { 112 | intent: { 113 | primary: [ 114 | 'button--primary', 115 | 'bg-blue-500', 116 | 'text-white', 117 | 'border-transparent', 118 | 'hover:bg-blue-600', 119 | ], 120 | secondary: [ 121 | 'button--secondary', 122 | 'bg-white', 123 | 'text-gray-800', 124 | 'border-gray-400', 125 | 'hover:bg-gray-100', 126 | ], 127 | warning: [ 128 | 'button--warning', 129 | 'bg-yellow-500', 130 | 'border-transparent', 131 | 'hover:bg-yellow-600', 132 | ], 133 | danger: [ 134 | 'button--danger', 135 | 'bg-red-500', 136 | 'text-white', 137 | 'border-transparent', 138 | 'hover:bg-red-600', 139 | ], 140 | }, 141 | disabled: { 142 | true: ['button--disabled', 'opacity-050', 'cursor-not-allowed'], 143 | false: ['button--enabled', 'cursor-pointer'], 144 | }, 145 | size: { 146 | none: [], 147 | small: ['button--small', 'text-sm', 'py-1', 'px-2'], 148 | medium: ['button--medium', 'text-base', 'py-2', 'px-4'], 149 | large: ['button--large', 'text-lg', 'py-2.5', 'px-4'], 150 | }, 151 | m: { 152 | 0: 'm-0', 153 | 1: 'm-1', 154 | }, 155 | }, 156 | compoundVariants: [ 157 | { 158 | variants: { 159 | intent: 'primary', 160 | size: 'medium', 161 | }, 162 | className: ['button--primary-medium', 'uppercase'], 163 | }, 164 | { 165 | variants: { 166 | intent: 'warning', 167 | disabled: false, 168 | }, 169 | className: ['button--warning-enabled', 'text-gray-800'], 170 | }, 171 | { 172 | variants: { 173 | intent: 'warning', 174 | disabled: true, 175 | }, 176 | className: ['button--warning-disabled', 'text-black'], 177 | }, 178 | ], 179 | }); 180 | 181 | type ButtonWithoutDefaultsWithoutBaseProps = 182 | | VariantParameters< 183 | typeof buttonWithoutBaseWithoutDefaultsWithClassNameString 184 | > 185 | | VariantParameters< 186 | typeof buttonWithoutBaseWithoutDefaultsWithClassNameArray 187 | >; 188 | 189 | describe.each<[ButtonWithoutDefaultsWithoutBaseProps, string]>([ 190 | [ 191 | // @ts-expect-error 192 | undefined, 193 | 'button--enabled cursor-pointer', 194 | ], 195 | [ 196 | // @ts-expect-error 197 | {}, 198 | 'button--enabled cursor-pointer', 199 | ], 200 | [ 201 | // @ts-expect-error 202 | { 203 | aCheekyInvalidProp: 'lol', 204 | } as ButtonWithoutDefaultsWithoutBaseProps, 205 | 'button--enabled cursor-pointer', 206 | ], 207 | [ 208 | // @ts-expect-error 209 | { intent: 'secondary', className: 'text-blue-900' }, 210 | 'button--secondary bg-white border-gray-400 hover:bg-gray-100 button--enabled cursor-pointer text-blue-900', 211 | ], 212 | [ 213 | // @ts-expect-error 214 | { size: 'small', className: 'text-blue-900' }, 215 | 'button--enabled cursor-pointer button--small text-sm py-1 px-2 text-blue-900', 216 | ], 217 | // @ts-expect-error 218 | [{ disabled: true }, 'button--disabled opacity-050 cursor-not-allowed'], 219 | [ 220 | // @ts-expect-error 221 | { 222 | intent: 'secondary', 223 | size: 'none', 224 | className: ['pt-0', 'text-blue-900', 'text-gray-300', 'mt-0'], 225 | }, 226 | 'button--secondary bg-white border-gray-400 hover:bg-gray-100 button--enabled cursor-pointer pt-0 text-gray-300 mt-0', 227 | ], 228 | [ 229 | // @ts-expect-error 230 | { intent: 'danger', size: 'medium' }, 231 | 'button--danger bg-red-500 text-white border-transparent hover:bg-red-600 button--enabled cursor-pointer button--medium text-base py-2 px-4', 232 | ], 233 | [ 234 | // @ts-expect-error 235 | { intent: 'warning', size: 'large' }, 236 | 'button--warning bg-yellow-500 border-transparent hover:bg-yellow-600 button--enabled cursor-pointer button--large text-lg py-2.5 px-4 button--warning-enabled text-gray-800', 237 | ], 238 | [ 239 | // @ts-expect-error 240 | { intent: 'warning', size: 'large', disabled: true }, 241 | 'button--warning bg-yellow-500 border-transparent hover:bg-yellow-600 button--disabled opacity-050 cursor-not-allowed button--large text-lg py-2.5 px-4 button--warning-disabled text-black', 242 | ], 243 | [ 244 | // @ts-expect-error 245 | { intent: 'primary', m: 0 }, 246 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer m-0', 247 | ], 248 | [ 249 | // @ts-expect-error 250 | { intent: 'primary', m: 1 }, 251 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer m-1', 252 | ], 253 | [ 254 | { 255 | size: 'large', 256 | intent: 'primary', 257 | m: 1, 258 | className: 'adhoc-class', 259 | }, 260 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer button--large text-lg py-2.5 px-4 m-1 adhoc-class', 261 | ], 262 | // typings needed 263 | ])('button(%o)', (options, expected) => { 264 | test(`returns ${expected}`, () => { 265 | expect( 266 | buttonWithoutBaseWithoutDefaultsWithClassNameString(options) 267 | ).toBe(expected); 268 | expect( 269 | buttonWithoutBaseWithoutDefaultsWithClassNameArray(options) 270 | ).toBe(expected); 271 | }); 272 | }); 273 | }); 274 | 275 | describe('with defaults', () => { 276 | const buttonWithoutBaseWithDefaultsWithClassNameString = variants({ 277 | variants: { 278 | intent: { 279 | primary: 280 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600', 281 | secondary: 282 | 'button--secondary bg-white text-gray-800 border-gray-400 hover:bg-gray-100', 283 | warning: 284 | 'button--warning bg-yellow-500 border-transparent hover:bg-yellow-600', 285 | danger: 286 | 'button--danger bg-red-500 text-white border-transparent hover:bg-red-600', 287 | }, 288 | disabled: { 289 | true: 'button--disabled opacity-050 cursor-not-allowed', 290 | false: 'button--enabled cursor-pointer', 291 | }, 292 | size: { 293 | none: null, 294 | small: 'button--small text-sm py-1 px-2', 295 | medium: 'button--medium text-base py-2 px-4', 296 | large: 'button--large text-lg py-2.5 px-4', 297 | }, 298 | m: { 299 | 0: 'm-0', 300 | 1: 'm-1', 301 | }, 302 | }, 303 | compoundVariants: [ 304 | { 305 | variants: { 306 | intent: 'primary', 307 | size: 'medium', 308 | }, 309 | className: 'button--primary-medium uppercase', 310 | }, 311 | { 312 | variants: { 313 | intent: 'warning', 314 | disabled: false, 315 | }, 316 | className: 'button--warning-enabled text-gray-800', 317 | }, 318 | { 319 | variants: { 320 | intent: 'warning', 321 | disabled: true, 322 | }, 323 | className: 'button--warning-disabled text-black', 324 | }, 325 | ], 326 | defaultVariants: { 327 | m: 0, 328 | disabled: true, 329 | intent: 'primary', 330 | size: 'medium', 331 | }, 332 | }); 333 | 334 | const buttonWithoutBaseWithDefaultsWithClassNameArray = variants({ 335 | variants: { 336 | intent: { 337 | primary: [ 338 | 'button--primary', 339 | 'bg-blue-500', 340 | 'text-white', 341 | 'border-transparent', 342 | 'hover:bg-blue-600', 343 | ], 344 | secondary: [ 345 | 'button--secondary', 346 | 'bg-white', 347 | 'text-gray-800', 348 | 'border-gray-400', 349 | 'hover:bg-gray-100', 350 | ], 351 | warning: [ 352 | 'button--warning', 353 | 'bg-yellow-500', 354 | 'border-transparent', 355 | 'hover:bg-yellow-600', 356 | ], 357 | danger: [ 358 | 'button--danger', 359 | 'bg-red-500', 360 | 'text-white', 361 | 'border-transparent', 362 | 'hover:bg-red-600', 363 | ], 364 | }, 365 | disabled: { 366 | true: ['button--disabled', 'opacity-050', 'cursor-not-allowed'], 367 | false: ['button--enabled', 'cursor-pointer'], 368 | }, 369 | size: { 370 | none: [], 371 | small: ['button--small', 'text-sm', 'py-1', 'px-2'], 372 | medium: ['button--medium', 'text-base', 'py-2', 'px-4'], 373 | large: ['button--large', 'text-lg', 'py-2.5', 'px-4'], 374 | }, 375 | m: { 376 | 0: 'm-0', 377 | 1: 'm-1', 378 | }, 379 | }, 380 | compoundVariants: [ 381 | { 382 | variants: { 383 | intent: 'primary', 384 | size: 'medium', 385 | }, 386 | className: ['button--primary-medium', 'uppercase'], 387 | }, 388 | { 389 | variants: { 390 | intent: 'warning', 391 | disabled: false, 392 | }, 393 | className: ['button--warning-enabled', 'text-gray-800'], 394 | }, 395 | { 396 | variants: { 397 | intent: 'warning', 398 | disabled: true, 399 | }, 400 | className: ['button--warning-disabled', 'text-black'], 401 | }, 402 | ], 403 | defaultVariants: { 404 | m: 0, 405 | disabled: true, 406 | intent: 'primary', 407 | size: 'medium', 408 | }, 409 | }); 410 | 411 | type ButtonWithDefaultsWithoutBaseProps = 412 | | VariantParameters< 413 | typeof buttonWithoutBaseWithDefaultsWithClassNameString 414 | > 415 | | VariantParameters< 416 | typeof buttonWithoutBaseWithDefaultsWithClassNameArray 417 | >; 418 | 419 | describe.each<[ButtonWithDefaultsWithoutBaseProps, string]>([ 420 | [ 421 | undefined, 422 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 423 | ], 424 | [ 425 | {}, 426 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 427 | ], 428 | [ 429 | { 430 | // @ts-expect-error 431 | aCheekyInvalidProp: 'lol', 432 | }, 433 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 434 | ], 435 | [ 436 | { intent: 'secondary', className: 'text-blue-900' }, 437 | 'button--secondary bg-white border-gray-400 hover:bg-gray-100 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 text-blue-900', 438 | ], 439 | [ 440 | { size: 'small', className: 'text-blue-900' }, 441 | 'button--primary bg-blue-500 border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--small text-sm py-1 px-2 m-0 text-blue-900', 442 | ], 443 | [ 444 | { disabled: false }, 445 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 446 | ], 447 | [ 448 | { 449 | intent: 'secondary', 450 | size: 'none', 451 | className: ['pt-0', 'text-blue-900', 'text-gray-300', 'mt-0'], 452 | }, 453 | 'button--secondary bg-white border-gray-400 hover:bg-gray-100 button--disabled opacity-050 cursor-not-allowed m-0 pt-0 text-gray-300 mt-0', 454 | ], 455 | [ 456 | { intent: 'danger', size: 'medium' }, 457 | 'button--danger bg-red-500 text-white border-transparent hover:bg-red-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0', 458 | ], 459 | [ 460 | { intent: 'warning', size: 'large' }, 461 | 'button--warning bg-yellow-500 border-transparent hover:bg-yellow-600 button--disabled opacity-050 cursor-not-allowed button--large text-lg py-2.5 px-4 m-0 button--warning-disabled text-black', 462 | ], 463 | [ 464 | { intent: 'warning', size: 'large', disabled: false }, 465 | 'button--warning bg-yellow-500 border-transparent hover:bg-yellow-600 button--enabled cursor-pointer button--large text-lg py-2.5 px-4 m-0 button--warning-enabled text-gray-800', 466 | ], 467 | [ 468 | { intent: 'primary', m: 0 }, 469 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 470 | ], 471 | [ 472 | { intent: 'primary', m: 1 }, 473 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-1 button--primary-medium uppercase', 474 | ], 475 | [ 476 | { 477 | size: 'large', 478 | intent: 'primary', 479 | m: 1, 480 | disabled: false, 481 | className: 'adhoc-class', 482 | }, 483 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer button--large text-lg py-2.5 px-4 m-1 adhoc-class', 484 | ], 485 | // typings needed 486 | ])('button(%o)', (options, expected) => { 487 | test(`returns ${expected}`, () => { 488 | expect( 489 | buttonWithoutBaseWithDefaultsWithClassNameString(options) 490 | ).toBe(expected); 491 | expect(buttonWithoutBaseWithDefaultsWithClassNameArray(options)).toBe( 492 | expected 493 | ); 494 | }); 495 | }); 496 | }); 497 | }); 498 | 499 | // ==================================== 500 | 501 | describe('with base', () => { 502 | describe('without variants', () => { 503 | test('with compoundVariants', () => { 504 | const withoutBaseWithCompoundVariants = variants({ 505 | base: 'text-white bg-black', 506 | compoundVariants: [ 507 | // @ts-expect-error 508 | { 509 | variants: { color: 'primary' }, 510 | className: 'text-primary-500', 511 | }, 512 | ], 513 | }); 514 | expect( 515 | withoutBaseWithCompoundVariants({ className: 'bg-blue-500 foobar' }) 516 | ).toEqual('text-white bg-blue-500 foobar'); 517 | 518 | expect(withoutBaseWithCompoundVariants({ className: '' })).toEqual( 519 | 'text-white bg-black' 520 | ); 521 | expect(withoutBaseWithCompoundVariants({})).toEqual( 522 | 'text-white bg-black' 523 | ); 524 | expect(withoutBaseWithCompoundVariants()).toEqual( 525 | 'text-white bg-black' 526 | ); 527 | }); 528 | 529 | test('with defaultVariants', () => { 530 | const withoutBaseWithDefaultVariants = variants({ 531 | base: 'text-white bg-black', 532 | defaultVariants: { 533 | // @ts-expect-error 534 | color: 'primary', 535 | }, 536 | }); 537 | expect( 538 | withoutBaseWithDefaultVariants({ className: 'bg-blue-500 foobar' }) 539 | ).toEqual('text-white bg-blue-500 foobar'); 540 | 541 | expect(withoutBaseWithDefaultVariants({ className: '' })).toEqual( 542 | 'text-white bg-black' 543 | ); 544 | expect(withoutBaseWithDefaultVariants({})).toEqual( 545 | 'text-white bg-black' 546 | ); 547 | expect(withoutBaseWithDefaultVariants()).toEqual('text-white bg-black'); 548 | }); 549 | 550 | test('only with base', () => { 551 | const withoutAnything = variants({ 552 | base: 'text-white bg-black', 553 | }); 554 | expect(withoutAnything({ className: 'bg-blue-500 foobar' })).toEqual( 555 | 'text-white bg-blue-500 foobar' 556 | ); 557 | 558 | expect(withoutAnything({ className: '' })).toEqual( 559 | 'text-white bg-black' 560 | ); 561 | expect(withoutAnything({})).toEqual('text-white bg-black'); 562 | expect(withoutAnything()).toEqual('text-white bg-black'); 563 | }); 564 | }); 565 | 566 | describe('without defaults', () => { 567 | const buttonWithBaseWithoutDefaultsWithClassNameString = variants({ 568 | base: 'text-center bg-purple-600 text-purple-100', 569 | variants: { 570 | intent: { 571 | primary: 572 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600', 573 | secondary: 574 | 'button--secondary bg-white text-gray-800 border-gray-400 hover:bg-gray-100', 575 | warning: 576 | 'button--warning bg-yellow-500 border-transparent hover:bg-yellow-600', 577 | danger: 578 | 'button--danger bg-red-500 text-white border-transparent hover:bg-red-600', 579 | }, 580 | disabled: { 581 | true: 'button--disabled opacity-050 cursor-not-allowed', 582 | false: 'button--enabled cursor-pointer', 583 | }, 584 | size: { 585 | none: null, 586 | small: 'button--small text-sm py-1 px-2', 587 | medium: 'button--medium text-base py-2 px-4', 588 | large: 'button--large text-lg py-2.5 px-4', 589 | }, 590 | m: { 591 | 0: 'm-0', 592 | 1: 'm-1', 593 | }, 594 | }, 595 | compoundVariants: [ 596 | { 597 | variants: { 598 | intent: 'primary', 599 | size: 'medium', 600 | }, 601 | className: 'button--primary-medium uppercase', 602 | }, 603 | { 604 | variants: { 605 | intent: 'warning', 606 | disabled: false, 607 | }, 608 | className: 'button--warning-enabled text-gray-800', 609 | }, 610 | { 611 | variants: { 612 | intent: 'warning', 613 | disabled: true, 614 | }, 615 | className: 'button--warning-disabled text-black', 616 | }, 617 | ], 618 | }); 619 | 620 | const buttonWithBaseWithoutDefaultsWithClassNameArray = variants({ 621 | base: ['text-center', 'bg-purple-600', 'text-purple-100'], 622 | variants: { 623 | intent: { 624 | primary: [ 625 | 'button--primary', 626 | 'bg-blue-500', 627 | 'text-white', 628 | 'border-transparent', 629 | 'hover:bg-blue-600', 630 | ], 631 | secondary: [ 632 | 'button--secondary', 633 | 'bg-white', 634 | 'text-gray-800', 635 | 'border-gray-400', 636 | 'hover:bg-gray-100', 637 | ], 638 | warning: [ 639 | 'button--warning', 640 | 'bg-yellow-500', 641 | 'border-transparent', 642 | 'hover:bg-yellow-600', 643 | ], 644 | danger: [ 645 | 'button--danger', 646 | 'bg-red-500', 647 | 'text-white', 648 | 'border-transparent', 649 | 'hover:bg-red-600', 650 | ], 651 | }, 652 | disabled: { 653 | true: ['button--disabled', 'opacity-050', 'cursor-not-allowed'], 654 | false: ['button--enabled', 'cursor-pointer'], 655 | }, 656 | size: { 657 | none: [], 658 | small: ['button--small', 'text-sm', 'py-1', 'px-2'], 659 | medium: ['button--medium', 'text-base', 'py-2', 'px-4'], 660 | large: ['button--large', 'text-lg', 'py-2.5', 'px-4'], 661 | }, 662 | m: { 663 | 0: 'm-0', 664 | 1: 'm-1', 665 | }, 666 | }, 667 | compoundVariants: [ 668 | { 669 | variants: { 670 | intent: 'primary', 671 | size: 'medium', 672 | }, 673 | className: ['button--primary-medium', 'uppercase'], 674 | }, 675 | { 676 | variants: { 677 | intent: 'warning', 678 | disabled: false, 679 | }, 680 | className: ['button--warning-enabled', 'text-gray-800'], 681 | }, 682 | { 683 | variants: { 684 | intent: 'warning', 685 | disabled: true, 686 | }, 687 | className: ['button--warning-disabled', 'text-black'], 688 | }, 689 | ], 690 | }); 691 | 692 | type ButtonWithoutDefaultsWithBaseProps = 693 | | VariantParameters< 694 | typeof buttonWithBaseWithoutDefaultsWithClassNameString 695 | > 696 | | VariantParameters< 697 | typeof buttonWithBaseWithoutDefaultsWithClassNameArray 698 | >; 699 | 700 | describe.each<[ButtonWithoutDefaultsWithBaseProps, string]>([ 701 | [ 702 | // @ts-expect-error 703 | undefined, 704 | 'text-center bg-purple-600 text-purple-100 button--enabled cursor-pointer', 705 | ], 706 | [ 707 | // @ts-expect-error 708 | {}, 709 | 'text-center bg-purple-600 text-purple-100 button--enabled cursor-pointer', 710 | ], 711 | [ 712 | // @ts-expect-error 713 | { 714 | aCheekyInvalidProp: 'lol', 715 | } as ButtonWithoutDefaultsWithBaseProps, 716 | 'text-center bg-purple-600 text-purple-100 button--enabled cursor-pointer', 717 | ], 718 | [ 719 | // @ts-expect-error 720 | { intent: 'secondary', className: 'text-blue-900' }, 721 | 'text-center button--secondary bg-white border-gray-400 hover:bg-gray-100 button--enabled cursor-pointer text-blue-900', 722 | ], 723 | [ 724 | // @ts-expect-error 725 | { size: 'small', className: 'text-blue-900' }, 726 | 'text-center bg-purple-600 button--enabled cursor-pointer button--small text-sm py-1 px-2 text-blue-900', 727 | ], 728 | [ 729 | // @ts-expect-error 730 | { disabled: true }, 731 | 'text-center bg-purple-600 text-purple-100 button--disabled opacity-050 cursor-not-allowed', 732 | ], 733 | [ 734 | // @ts-expect-error 735 | { 736 | intent: 'secondary', 737 | size: 'none', 738 | className: ['pt-0', 'text-blue-900', 'text-gray-300', 'mt-0'], 739 | }, 740 | 'text-center button--secondary bg-white border-gray-400 hover:bg-gray-100 button--enabled cursor-pointer pt-0 text-gray-300 mt-0', 741 | ], 742 | [ 743 | // @ts-expect-error 744 | { intent: 'danger', size: 'medium' }, 745 | 'text-center button--danger bg-red-500 text-white border-transparent hover:bg-red-600 button--enabled cursor-pointer button--medium text-base py-2 px-4', 746 | ], 747 | [ 748 | // @ts-expect-error 749 | { intent: 'warning', size: 'large' }, 750 | 'text-center button--warning bg-yellow-500 border-transparent hover:bg-yellow-600 button--enabled cursor-pointer button--large text-lg py-2.5 px-4 button--warning-enabled text-gray-800', 751 | ], 752 | [ 753 | // @ts-expect-error 754 | { intent: 'warning', size: 'large', disabled: true }, 755 | 'text-center button--warning bg-yellow-500 border-transparent hover:bg-yellow-600 button--disabled opacity-050 cursor-not-allowed button--large text-lg py-2.5 px-4 button--warning-disabled text-black', 756 | ], 757 | [ 758 | // @ts-expect-error 759 | { intent: 'primary', m: 0 }, 760 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer m-0', 761 | ], 762 | [ 763 | // @ts-expect-error 764 | { intent: 'primary', m: 1 }, 765 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer m-1', 766 | ], 767 | [ 768 | { 769 | size: 'large', 770 | intent: 'primary', 771 | m: 1, 772 | className: 'adhoc-class', 773 | }, 774 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer button--large text-lg py-2.5 px-4 m-1 adhoc-class', 775 | ], 776 | // typings needed 777 | ])('button(%o)', (options, expected) => { 778 | test(`returns ${expected}`, () => { 779 | expect( 780 | buttonWithBaseWithoutDefaultsWithClassNameString(options) 781 | ).toBe(expected); 782 | expect(buttonWithBaseWithoutDefaultsWithClassNameArray(options)).toBe( 783 | expected 784 | ); 785 | }); 786 | }); 787 | }); 788 | 789 | describe('with defaults', () => { 790 | const buttonWithBaseWithDefaultsWithClassNameString = variants({ 791 | base: 'text-center bg-purple-600 text-purple-100', 792 | variants: { 793 | intent: { 794 | primary: 795 | 'button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600', 796 | secondary: 797 | 'button--secondary bg-white text-gray-800 border-gray-400 hover:bg-gray-100', 798 | warning: 799 | 'button--warning bg-yellow-500 border-transparent hover:bg-yellow-600', 800 | danger: 801 | 'button--danger bg-red-500 text-white border-transparent hover:bg-red-600', 802 | }, 803 | disabled: { 804 | true: 'button--disabled opacity-050 cursor-not-allowed', 805 | false: 'button--enabled cursor-pointer', 806 | }, 807 | size: { 808 | none: null, 809 | small: 'button--small text-sm py-1 px-2', 810 | medium: 'button--medium text-base py-2 px-4', 811 | large: 'button--large text-lg py-2.5 px-4', 812 | }, 813 | m: { 814 | 0: 'm-0', 815 | 1: 'm-1', 816 | }, 817 | }, 818 | compoundVariants: [ 819 | { 820 | variants: { 821 | intent: 'primary', 822 | size: 'medium', 823 | }, 824 | className: 'button--primary-medium uppercase', 825 | }, 826 | { 827 | variants: { 828 | intent: 'warning', 829 | disabled: false, 830 | }, 831 | className: 'button--warning-enabled text-gray-800', 832 | }, 833 | { 834 | variants: { 835 | intent: 'warning', 836 | disabled: true, 837 | }, 838 | className: 'button--warning-disabled text-black', 839 | }, 840 | ], 841 | defaultVariants: { 842 | m: 0, 843 | disabled: true, 844 | intent: 'primary', 845 | size: 'medium', 846 | }, 847 | }); 848 | 849 | const buttonWithBaseWithDefaultsWithClassNameArray = variants({ 850 | base: ['text-center', 'bg-purple-600', 'text-purple-100'], 851 | variants: { 852 | intent: { 853 | primary: [ 854 | 'button--primary', 855 | 'bg-blue-500', 856 | 'text-white', 857 | 'border-transparent', 858 | 'hover:bg-blue-600', 859 | ], 860 | secondary: [ 861 | 'button--secondary', 862 | 'bg-white', 863 | 'text-gray-800', 864 | 'border-gray-400', 865 | 'hover:bg-gray-100', 866 | ], 867 | warning: [ 868 | 'button--warning', 869 | 'bg-yellow-500', 870 | 'border-transparent', 871 | 'hover:bg-yellow-600', 872 | ], 873 | danger: [ 874 | 'button--danger', 875 | 'bg-red-500', 876 | 'text-white', 877 | 'border-transparent', 878 | 'hover:bg-red-600', 879 | ], 880 | }, 881 | disabled: { 882 | true: ['button--disabled', 'opacity-050', 'cursor-not-allowed'], 883 | false: ['button--enabled', 'cursor-pointer'], 884 | }, 885 | size: { 886 | none: [], 887 | small: ['button--small', 'text-sm', 'py-1', 'px-2'], 888 | medium: ['button--medium', 'text-base', 'py-2', 'px-4'], 889 | large: ['button--large', 'text-lg', 'py-2.5', 'px-4'], 890 | }, 891 | m: { 892 | 0: 'm-0', 893 | 1: 'm-1', 894 | }, 895 | }, 896 | compoundVariants: [ 897 | { 898 | variants: { 899 | intent: 'primary', 900 | size: 'medium', 901 | }, 902 | className: ['button--primary-medium', 'uppercase'], 903 | }, 904 | { 905 | variants: { 906 | intent: 'warning', 907 | disabled: false, 908 | }, 909 | className: ['button--warning-enabled', 'text-gray-800'], 910 | }, 911 | { 912 | variants: { 913 | intent: 'warning', 914 | disabled: true, 915 | }, 916 | className: ['button--warning-disabled', 'text-black'], 917 | }, 918 | ], 919 | defaultVariants: { 920 | m: 0, 921 | disabled: true, 922 | intent: 'primary', 923 | size: 'medium', 924 | }, 925 | }); 926 | 927 | type ButtonWithDefaultsWithBaseProps = 928 | | VariantParameters< 929 | typeof buttonWithBaseWithDefaultsWithClassNameString 930 | > 931 | | VariantParameters< 932 | typeof buttonWithBaseWithDefaultsWithClassNameArray 933 | >; 934 | 935 | describe.each<[ButtonWithDefaultsWithBaseProps, string]>([ 936 | [ 937 | undefined, 938 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 939 | ], 940 | [ 941 | {}, 942 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 943 | ], 944 | [ 945 | { 946 | // @ts-expect-error 947 | aCheekyInvalidProp: 'lol', 948 | }, 949 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 950 | ], 951 | [ 952 | { intent: 'secondary', className: 'text-blue-900' }, 953 | 'text-center button--secondary bg-white border-gray-400 hover:bg-gray-100 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 text-blue-900', 954 | ], 955 | [ 956 | { size: 'small', className: 'text-blue-900' }, 957 | 'text-center button--primary bg-blue-500 border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--small text-sm py-1 px-2 m-0 text-blue-900', 958 | ], 959 | [ 960 | { disabled: false }, 961 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 962 | ], 963 | [ 964 | { 965 | intent: 'secondary', 966 | size: 'none', 967 | className: ['pt-0', 'text-blue-900', 'text-gray-300', 'mt-0'], 968 | }, 969 | 'text-center button--secondary bg-white border-gray-400 hover:bg-gray-100 button--disabled opacity-050 cursor-not-allowed m-0 pt-0 text-gray-300 mt-0', 970 | ], 971 | [ 972 | { intent: 'danger', size: 'medium' }, 973 | 'text-center button--danger bg-red-500 text-white border-transparent hover:bg-red-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0', 974 | ], 975 | [ 976 | { intent: 'warning', size: 'large' }, 977 | 'text-center button--warning bg-yellow-500 border-transparent hover:bg-yellow-600 button--disabled opacity-050 cursor-not-allowed button--large text-lg py-2.5 px-4 m-0 button--warning-disabled text-black', 978 | ], 979 | [ 980 | { intent: 'warning', size: 'large', disabled: false }, 981 | 'text-center button--warning bg-yellow-500 border-transparent hover:bg-yellow-600 button--enabled cursor-pointer button--large text-lg py-2.5 px-4 m-0 button--warning-enabled text-gray-800', 982 | ], 983 | [ 984 | { intent: 'primary', m: 0 }, 985 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-0 button--primary-medium uppercase', 986 | ], 987 | [ 988 | { intent: 'primary', m: 1 }, 989 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--disabled opacity-050 cursor-not-allowed button--medium text-base py-2 px-4 m-1 button--primary-medium uppercase', 990 | ], 991 | [ 992 | { 993 | size: 'large', 994 | intent: 'primary', 995 | m: 1, 996 | disabled: false, 997 | className: 'adhoc-class', 998 | }, 999 | 'text-center button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600 button--enabled cursor-pointer button--large text-lg py-2.5 px-4 m-1 adhoc-class', 1000 | ], 1001 | // typings needed 1002 | ])('button(%o)', (options, expected) => { 1003 | test(`returns ${expected}`, () => { 1004 | expect(buttonWithBaseWithDefaultsWithClassNameString(options)).toBe( 1005 | expected 1006 | ); 1007 | expect(buttonWithBaseWithDefaultsWithClassNameArray(options)).toBe( 1008 | expected 1009 | ); 1010 | }); 1011 | }); 1012 | }); 1013 | }); 1014 | }); 1015 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "ESNext", 5 | "target": "ES2016", 6 | "lib": ["ESNext", "dom"], 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "declaration": true, 14 | "noEmit": true 15 | }, 16 | "include": ["src"], 17 | "exclude": ["node_modules", "dist"] 18 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vitest/config'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | environment: 'jsdom', 9 | setupFiles: './test/setup.ts', 10 | }, 11 | }); 12 | --------------------------------------------------------------------------------