├── .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 |
165 |
168 |
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 |
--------------------------------------------------------------------------------