├── .nvmrc ├── .gitignore ├── .prettierrc ├── .github └── workflows │ ├── test.yml │ └── publish.yml ├── package.json ├── src ├── index.js └── index.test.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.18.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | dist 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 16.18.0 17 | - run: npm ci 18 | - run: npm test 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clb", 3 | "version": "1.3.3", 4 | "main": "dist/index.cjs.js", 5 | "module": "dist/index.esm.js", 6 | "scripts": { 7 | "build": "node build.js", 8 | "prettier": "prettier --write src/**/*.js", 9 | "test": "jest", 10 | "test:watch": "jest --watch" 11 | }, 12 | "keywords": [ 13 | "classnames" 14 | ], 15 | "files": [ 16 | "dist/*.js" 17 | ], 18 | "author": "Bill Criswell", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "clsx": "^1.2.1", 22 | "esbuild": "^0.17.16", 23 | "jest": "^29.5.0", 24 | "prettier": "2.8.7" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: npm-publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | npm-publish: 8 | name: npm-publish 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v2 13 | - name: Use Node 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 16.18.0 17 | - run: npm ci 18 | - run: npm run build 19 | - name: Publish if version has been updated 20 | uses: pascalgn/npm-publish-action@1.3.9 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const clsx = require('clsx') 2 | const isBoolean = (maybeBoolean) => typeof maybeBoolean === 'boolean' 3 | const toStringIfBoolean = (value) => (isBoolean(value) ? String(value) : value) 4 | const isSimpleSubset = (a, b) => 5 | Object.entries(a).every(([key, value]) => b[key] === value) 6 | 7 | function clb(schema = {}, ...rest) { 8 | const { 9 | base, 10 | defaultVariants = {}, 11 | variants = {}, 12 | compoundVariants = [], 13 | } = schema 14 | 15 | if (Object.keys(variants).length === 0 && compoundVariants.length === 0) { 16 | return clsx(schema, ...rest) 17 | } 18 | 19 | return function (options = {}) { 20 | const optionsWithUndefinedsRemoved = Object.fromEntries( 21 | Object.entries(options).filter(([, value]) => value !== undefined) 22 | ) 23 | 24 | const currentOptions = { 25 | ...defaultVariants, 26 | ...optionsWithUndefinedsRemoved, 27 | } 28 | 29 | return clsx([ 30 | base, 31 | Object.keys(variants).map((variantName) => { 32 | const optionKey = 33 | toStringIfBoolean(options[variantName]) || 34 | defaultVariants[variantName] 35 | return variants[variantName][optionKey] 36 | }), 37 | compoundVariants 38 | .filter(({ classes, ...compoundVariantOptions }) => 39 | isSimpleSubset(compoundVariantOptions, currentOptions) 40 | ) 41 | .map(({ classes }) => classes), 42 | ]) 43 | } 44 | } 45 | 46 | module.exports = clb 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clb 2 | 3 | clb (class list builder) is a small, utility function that builds a class list based on a simple api. 4 | 5 | It's like [classnames](https://github.com/JedWatson/classnames) and [Stitches](https://stitches.dev/) made a really lazy baby. It works really well with [tailwindcss](https://tailwindcss.com/) but will work with any functional / utility / atomic css approach. 6 | 7 | If you're looking for a very similar thing with type support check out https://github.com/joe-bell/cva. 8 | 9 | ## Install It 10 | 11 | ```bash 12 | yarn add clb 13 | npm install clb 14 | ``` 15 | 16 | ## Annotated Examples 17 | 18 | ### Nothing Fancy 19 | 20 | ```js 21 | const clb = require('clb') 22 | 23 | const buttonBuilder = clb({ 24 | 25 | /* This can be anything `classnames` accepts. */ 26 | base: 'font-serif rounded-2xl', 27 | 28 | defaultVariants: { 29 | color: 'gray', 30 | size: 'medium', 31 | spacing: 'medium', 32 | }, 33 | 34 | /* 35 | The value for each variant value below can be anything 36 | `classnames` accepts. 37 | */ 38 | variants: { 39 | color: { 40 | gray: 'text-gray-800 bg-gray-800', 41 | red: 'text-red-800 bg-red-200', 42 | blue: 'text-blue-800 bg-blue-200', 43 | green: 'text-green-800 bg-green-200', 44 | }, 45 | size: { 46 | small: 'text-sm', 47 | medium: 'text-md', 48 | large: 'text-lg', 49 | }, 50 | spacing: { 51 | small: 'p-2', 52 | medium: 'p-4', 53 | large: 'p-6', 54 | }, 55 | }, 56 | }) 57 | 58 | buttonBuilder() 59 | // -> font-serif rounded-2xl text-gray-800 bg-gray-800 text-md p-4 60 | 61 | buttonBuilder({ color: 'red' }) 62 | // -> font-serif rounded-2xl text-red-800 bg-red-800 text-md p-4 63 | 64 | buttonBuilder({ color: 'blue', size: 'large' }) 65 | // -> font-serif rounded-2xl text-blue-800 bg-blue-800 text-lg p-6 66 | ``` 67 | 68 | ### A Little More Fancy Pants 69 | 70 | ```js 71 | const clb = require('clb') 72 | 73 | const buttonBuilder = clb({ 74 | base: 'font-serif rounded-2xl', 75 | defaultVariants: { 76 | color: 'gray', 77 | size: 'small', 78 | }, 79 | variants: { 80 | size: { 81 | small: 'text-sm p-2', 82 | }, 83 | disabled: { 84 | true: 'cursor-not-allowed', 85 | }, 86 | }, 87 | compoundVariants: [ 88 | { color: 'gray', disabled: true, classes: 'text-gray-200 bg-gray-50' }, 89 | { color: 'gray', disabled: false, classes: 'text-gray-800 bg-gray-200' }, 90 | { color: 'red', disabled: true, classes: 'text-red-200 bg-red-50' }, 91 | { color: 'red', disabled: false, classes: 'text-red-800 bg-red-200' }, 92 | { color: 'blue', disabled: true, classes: 'text-blue-200 bg-blue-50' }, 93 | { color: 'blue', disabled: false, classes: 'text-blue-800 bg-blue-200' }, 94 | ], 95 | }) 96 | 97 | buttonBuilder() 98 | // -> font-serif rounded-2xl text-sm p-2 text-gray-800 bg-gray-800 99 | 100 | buttonBuilder({ disabled: true }) 101 | // -> font-serif rounded-2xl text-sm p-2 text-gray-200 bg-gray-50 cursor-not-allowed 102 | 103 | buttonBuilder({ color: 'red', disabled: true }) 104 | // -> font-serif rounded-2xl text-sm p-2 text-red-200 bg-red-50 cursor-not-allowed 105 | ``` 106 | 107 | ### Usage With Vue / React / Others 108 | 109 | None of this code is actually tested but should be *pretty* close. 110 | 111 | **buttonClasses.js** 112 | ```js 113 | import clb from 'clb' 114 | 115 | const buttonBuilder = clb({ 116 | base: 'font-serif rounded-2xl', 117 | defaultVariants: { 118 | color: 'gray', 119 | size: 'small', 120 | }, 121 | variants: { 122 | size: { 123 | small: 'text-sm p-2', 124 | }, 125 | disabled: { 126 | true: 'cursor-not-allowed', 127 | }, 128 | }, 129 | compoundVariants: [ 130 | { color: 'gray', disabled: true, classes: 'text-gray-200 bg-gray-50' }, 131 | { color: 'gray', disabled: false, classes: 'text-gray-800 bg-gray-200' }, 132 | { color: 'red', disabled: true, classes: 'text-red-200 bg-red-50' }, 133 | { color: 'red', disabled: false, classes: 'text-red-800 bg-red-200' }, 134 | { color: 'blue', disabled: true, classes: 'text-blue-200 bg-blue-50' }, 135 | { color: 'blue', disabled: false, classes: 'text-blue-800 bg-blue-200' }, 136 | ], 137 | }) 138 | 139 | export default buttonClasses 140 | ``` 141 | 142 | **Button.jsx** 143 | ```jsx 144 | import buttonClasses from "./buttonClasses" 145 | 146 | const Button = ({ color, disabled }) => ( 147 | 150 | ) 151 | ``` 152 | 153 | **Button.vue** 154 | ```vue 155 | 163 | 164 | 169 | ``` 170 | 171 | **Button.svelte** (thanks [JakeNavith](https://github.com/JakeNavith)) 172 | ```svelte 173 | 178 | 179 | 182 | ``` 183 | 184 | ### Use as clsx 185 | When `clb` doesn't have a `variant` or `compountVariant` key it passes everthing to `clsx`, which is like `classnames` if you're familiar with that. 186 | 187 | ```js 188 | clb('foo', { bar: true }) 189 | // -> foo bar 190 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | const clb = require('./') 2 | 3 | describe('fallback', () => { 4 | test('when no variants or compoudVariants are passed clb is just clsx', () => { 5 | expect(clb('')).toBe('') 6 | expect(clb('foo bar')).toBe('foo bar') 7 | expect(clb(['foo', 'bar'])).toBe('foo bar') 8 | expect(clb(['foo'], ['bar'])).toBe('foo bar') 9 | expect(clb({ foo: true, bar: true }, { baz: true })).toBe('foo bar baz') 10 | }) 11 | }) 12 | 13 | describe('strange edge cases', () => { 14 | test('variant that does not make sense does not error', () => { 15 | const builder = clb({ 16 | variants: { 17 | color: { 18 | blue: 'blue', 19 | }, 20 | }, 21 | }) 22 | 23 | expect(builder({ color: 'GREEN' })).toBe('') 24 | expect(builder({ __DOES_NOT_EXIST__: 'GREEN' })).toBe('') 25 | }) 26 | }) 27 | 28 | describe('basic use cases without default', () => { 29 | const builder = clb({ 30 | base: 'foo', 31 | variants: { 32 | color: { 33 | red: 'text-red-200', 34 | blue: 'text-blue-200', 35 | }, 36 | size: { 37 | small: 'text-sm', 38 | medium: 'text-md', 39 | large: 'text-lg', 40 | }, 41 | disabled: { 42 | true: 'opacity-50', 43 | false: 'opacity-100', 44 | }, 45 | }, 46 | }) 47 | 48 | describe.each([ 49 | [undefined, 'foo'], 50 | [{}, 'foo'], 51 | [{ color: 'red' }, 'foo text-red-200'], 52 | [{ color: 'blue' }, 'foo text-blue-200'], 53 | [{ size: 'small' }, 'foo text-sm'], 54 | [{ size: 'medium' }, 'foo text-md'], 55 | [{ size: 'large' }, 'foo text-lg'], 56 | [{ color: 'red', size: 'large' }, 'foo text-red-200 text-lg'], 57 | [{ disabled: true }, 'foo opacity-50'], 58 | [{ disabled: 'true' }, 'foo opacity-50'], 59 | [{ disabled: false }, 'foo opacity-100'], 60 | [{ disabled: 'false' }, 'foo opacity-100'], 61 | ])('builder(%o)', (options, expected) => { 62 | test(`returns ${expected}`, () => { 63 | expect(builder(options)).toBe(expected) 64 | }) 65 | }) 66 | }) 67 | 68 | describe('basic use cases with defaultVariants', () => { 69 | const builder = clb({ 70 | base: 'foo', 71 | defaultVariants: { 72 | color: 'red', 73 | size: 'medium', 74 | disabled: false, 75 | }, 76 | variants: { 77 | color: { 78 | red: 'text-red-200', 79 | blue: 'text-blue-200', 80 | }, 81 | size: { 82 | small: 'text-sm', 83 | medium: 'text-md', 84 | large: 'text-lg', 85 | }, 86 | disabled: { 87 | true: 'opacity-50', 88 | false: 'opacity-100', 89 | }, 90 | }, 91 | }) 92 | 93 | describe.each([ 94 | [{}, 'foo text-red-200 text-md opacity-100'], 95 | [{ color: 'blue' }, 'foo text-blue-200 text-md opacity-100'], 96 | [{ color: 'blue', size: 'large' }, 'foo text-blue-200 text-lg opacity-100'], 97 | [{ color: 'blue', disabled: true }, 'foo text-blue-200 text-md opacity-50'], 98 | ])('builder(%o)', (options, expected) => { 99 | test(`returns ${expected}`, () => { 100 | expect(builder(options)).toBe(expected) 101 | }) 102 | }) 103 | }) 104 | 105 | describe(`defaultVariants that aren't variants`, () => { 106 | test('weird and likely bad key names like null, undefined', () => { 107 | const builder = clb({ 108 | base: 'foo', 109 | defaultVariants: { 110 | tone: 'neutral', 111 | }, 112 | variants: { 113 | tone: { 114 | null: 'tone-null', 115 | false: 'tone-false', 116 | neutral: 'tone-neutral', 117 | }, 118 | }, 119 | }) 120 | 121 | expect(builder()).toBe('foo tone-neutral') 122 | expect(builder({ tone: 'neutral' })).toBe('foo tone-neutral') 123 | expect(builder({ tone: 'something else' })).toBe('foo') 124 | expect(builder({ tone: null })).toBe('foo tone-neutral') 125 | expect(builder({ tone: undefined })).toBe('foo tone-neutral') 126 | expect(builder({ tone: false })).toBe('foo tone-false') 127 | }) 128 | }) 129 | 130 | describe(`compound variants`, () => { 131 | test('basic', () => { 132 | const builder = clb({ 133 | base: 'base', 134 | compoundVariants: [{ color: 'red', classes: 'neat' }], 135 | }) 136 | 137 | expect(builder({ color: 'blue' })).toBe('base') 138 | expect(builder({ color: 'red' })).toBe('base neat') 139 | }) 140 | 141 | test('multiple', () => { 142 | const builder = clb({ 143 | base: 'base', 144 | compoundVariants: [ 145 | { color: 'red', size: 'small', classes: 'red small' }, 146 | { color: 'blue', size: 'large', classes: 'blue large' }, 147 | { 148 | color: 'blue', 149 | size: 'large', 150 | disabled: true, 151 | classes: 'blue large disabled', 152 | }, 153 | ], 154 | }) 155 | 156 | expect(builder({ color: 'red', size: 'small' })).toBe('base red small') 157 | expect(builder({ color: 'blue', size: 'large' })).toBe('base blue large') 158 | expect(builder({ color: 'blue', size: 'large', disabled: true })).toBe( 159 | 'base blue large blue large disabled' 160 | ) 161 | expect(builder({ color: 'red', size: 'large' })).toBe('base') 162 | expect(builder({ color: 'blue', size: 'small' })).toBe('base') 163 | }) 164 | 165 | test('with the defaults', () => { 166 | const builder = clb({ 167 | base: 'base', 168 | defaultVariants: { 169 | color: 'red', 170 | }, 171 | compoundVariants: [{ size: 'sm', classes: 'red sm' }], 172 | }) 173 | 174 | expect(builder({ size: 'sm' })).toBe('base red sm') 175 | }) 176 | 177 | test('with additional props that do not matter', () => { 178 | const builder = clb({ 179 | base: 'base', 180 | compoundVariants: [{ color: 'red', size: 'sm', classes: 'red sm' }], 181 | }) 182 | 183 | expect(builder({ color: 'red', size: 'sm', random: '12345' })).toBe( 184 | 'base red sm' 185 | ) 186 | }) 187 | 188 | test('option being undefined should use the defaultVariant', () => { 189 | const builder = clb({ 190 | base: 'base', 191 | defaultVariants: { 192 | tone: 'neutral', 193 | }, 194 | compoundVariants: [{ tone: 'neutral', classes: 'neutral' }], 195 | }) 196 | 197 | expect(builder({ tone: undefined })).toBe('base neutral') 198 | expect(builder({ tone: 'neutral' })).toBe('base neutral') 199 | }) 200 | 201 | test('false null 0', () => { 202 | const builder = clb({ 203 | base: 'base', 204 | compoundVariants: [ 205 | { test: false, more: 5, classes: 'test false more five' }, 206 | { test: null, more: 5, classes: 'test null more five' }, 207 | { test: 0, more: 5, classes: 'test zero more five' }, 208 | { 209 | test: undefined, 210 | more: 5, 211 | classes: 'test undefined more five', 212 | }, 213 | ], 214 | }) 215 | 216 | expect(builder({ test: false, more: 5 })).toBe('base test false more five') 217 | expect(builder({ test: null, more: 5 })).toBe('base test null more five') 218 | expect(builder({ test: 0, more: 5 })).toBe('base test zero more five') 219 | expect(builder({ test: undefined, more: 5 })).toBe( 220 | 'base test undefined more five' 221 | ) 222 | }) 223 | }) 224 | --------------------------------------------------------------------------------