Skip to content

Commit a303d27

Browse files
authored
feat(utils): created constructCssClass utility function (#107)
* feat(utils): created constructCssClass utility function * perf(utils): optimized constructCssClass code * style(utils): renamed constructCssClass to composeCssClass * perf(utils): removed fn calls, introduced for loop and join string with +, replaced if with switch
1 parent 786ce23 commit a303d27

6 files changed

Lines changed: 264 additions & 1 deletion

File tree

.eslintrc.cjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ module.exports = {
4545
}
4646
],
4747
'unicorn/no-array-for-each': 'off',
48-
'unicorn/no-array-reduce': 'off'
48+
'unicorn/no-array-reduce': 'off',
49+
'unicorn/no-for-loop': 'off'
4950
},
5051
settings: {
5152
react: {

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './functions'
22
export * from './guards'
3+
export * from './misc'
34
export * from './types'
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/* eslint-disable unicorn/no-null */
2+
import { composeCssClass } from './composeCssClass'
3+
4+
describe('composeCssClass', () => {
5+
// @NOTE: ported from classNames package
6+
it('should return undefined if no arguments are passed', () => {
7+
expect(composeCssClass()).toBeUndefined()
8+
})
9+
10+
test('(compat) keeps object keys with truthy values', () => {
11+
expect(composeCssClass({ a: true, b: false, c: 0, d: null, e: undefined, f: 1 })).toBe('a f')
12+
})
13+
14+
test('(compat) joins arrays of class names and ignore falsy values', () => {
15+
expect(composeCssClass('a', 0, null, undefined, true, 1, 'b')).toBe('a 1 b')
16+
})
17+
18+
test('(compat) supports heterogenous arguments', () => {
19+
expect(composeCssClass({ a: true }, 'b', 0)).toBe('a b')
20+
})
21+
22+
test('(compat) should be trimmed', () => {
23+
expect(composeCssClass('', 'b', {}, '')).toBe('b')
24+
})
25+
26+
test('(compat) returns undefined for an empty configuration', () => {
27+
expect(composeCssClass({})).toBeUndefined()
28+
})
29+
30+
test('(compat) supports an array of class names', () => {
31+
expect(composeCssClass(['a', 'b'])).toBe('a b')
32+
})
33+
34+
test('(compat) joins array arguments with string arguments', () => {
35+
expect(composeCssClass(['a', 'b'], 'c')).toBe('a b c')
36+
expect(composeCssClass('c', ['a', 'b'])).toBe('c a b')
37+
})
38+
39+
test('(compat) handles multiple array arguments', () => {
40+
expect(composeCssClass(['a', 'b'], ['c', 'd'], ['e', 'f'])).toBe('a b c d e f')
41+
})
42+
43+
test('(compat) handles arrays that include falsy and true values', () => {
44+
expect(composeCssClass(['a', 0, null, undefined, false, true, 'b'])).toBe('a b')
45+
})
46+
47+
test('(compat) handles arrays that include arrays', () => {
48+
expect(composeCssClass(['a', ['b', 'c']])).toBe('a b c')
49+
})
50+
51+
test('(compat) handles arrays that include objects', () => {
52+
expect(composeCssClass(['a', { b: true, c: false }])).toBe('a b')
53+
})
54+
55+
test('(compat) handles deep array recursion', () => {
56+
expect(composeCssClass(['a', ['b', ['c', { d: true }]]])).toBe('a b c d')
57+
})
58+
59+
test('(compat) handles arrays that are empty', () => {
60+
expect(composeCssClass(['a', []])).toBe('a')
61+
})
62+
63+
test('(compat) handles nested arrays that have empty nested arrays', () => {
64+
expect(composeCssClass('a', [[]])).toBe('a')
65+
})
66+
67+
test('(compat) handles all types of truthy and falsy property values as expected', () => {
68+
const out = composeCssClass({
69+
// falsy:
70+
null: null,
71+
emptyString: '',
72+
noNumber: Number.NaN,
73+
zero: 0,
74+
negativeZero: -0,
75+
false: false,
76+
undefined: undefined,
77+
78+
// truthy (literally anything else):
79+
nonEmptyString: 'foobar',
80+
whitespace: ' ',
81+
// eslint-disable-next-line @typescript-eslint/unbound-method
82+
function: Object.prototype.toString,
83+
emptyObject: {},
84+
nonEmptyObject: { a: 1, b: 2 },
85+
emptyList: [],
86+
nonEmptyList: [1, 2, 3],
87+
greaterZero: 1
88+
})
89+
90+
expect(out).toBe(
91+
'nonEmptyString whitespace function emptyObject nonEmptyObject emptyList nonEmptyList greaterZero'
92+
)
93+
})
94+
95+
// @NOTE: ported from clsx package
96+
test('strings', () => {
97+
expect(composeCssClass('')).toBeUndefined()
98+
expect(composeCssClass('foo')).toBe('foo')
99+
expect(composeCssClass(true && 'foo')).toBe('foo')
100+
expect(composeCssClass(false && 'foo')).toBeUndefined()
101+
})
102+
103+
test('strings (variadic)', () => {
104+
expect(composeCssClass('')).toBeUndefined()
105+
expect(composeCssClass('foo', 'bar')).toBe('foo bar')
106+
expect(composeCssClass(true && 'foo', false && 'bar', 'baz')).toBe('foo baz')
107+
expect(composeCssClass(false && 'foo', 'bar', 'baz', '')).toBe('bar baz')
108+
})
109+
110+
test('objects', () => {
111+
expect(composeCssClass({})).toBeUndefined()
112+
expect(composeCssClass({ foo: true })).toBe('foo')
113+
expect(composeCssClass({ foo: true, bar: false })).toBe('foo')
114+
expect(composeCssClass({ foo: 'hiya', bar: 1 })).toBe('foo bar')
115+
expect(composeCssClass({ foo: 1, bar: 0, baz: 1 })).toBe('foo baz')
116+
expect(composeCssClass({ '-foo': 1, '--bar': 1 })).toBe('-foo --bar')
117+
})
118+
119+
test('objects (variadic)', () => {
120+
expect(composeCssClass({}, {})).toBeUndefined()
121+
expect(composeCssClass({ foo: 1 }, { bar: 2 })).toBe('foo bar')
122+
expect(composeCssClass({ foo: 1 }, null, { baz: 1, bat: 0 })).toBe('foo baz')
123+
expect(composeCssClass({ foo: 1 }, {}, {}, { bar: 'a' }, { baz: null, bat: Number.POSITIVE_INFINITY })).toBe(
124+
'foo bar bat'
125+
)
126+
})
127+
128+
test('arrays', () => {
129+
expect(composeCssClass([])).toBeUndefined()
130+
expect(composeCssClass(['foo'])).toBe('foo')
131+
expect(composeCssClass(['foo', 'bar'])).toBe('foo bar')
132+
expect(composeCssClass(['foo', 0 && 'bar', 1 && 'baz'])).toBe('foo baz')
133+
})
134+
135+
test('arrays (nested)', () => {
136+
expect(composeCssClass([[[]]])).toBeUndefined()
137+
expect(composeCssClass([[['foo']]])).toBe('foo')
138+
expect(composeCssClass([true, [['foo']]])).toBe('foo')
139+
expect(composeCssClass(['foo', ['bar', ['', [['baz']]]]])).toBe('foo bar baz')
140+
})
141+
142+
test('arrays (variadic)', () => {
143+
expect(composeCssClass([], [])).toBeUndefined()
144+
expect(composeCssClass(['foo'], ['bar'])).toBe('foo bar')
145+
expect(composeCssClass(['foo'], null, ['baz', ''], true, '', [])).toBe('foo baz')
146+
})
147+
148+
test('arrays (no `push` escape)', () => {
149+
expect(composeCssClass({ push: 1 })).toBe('push')
150+
expect(composeCssClass({ pop: true })).toBe('pop')
151+
expect(composeCssClass({ push: true })).toBe('push')
152+
expect(composeCssClass('hello', { world: 1, push: true })).toBe('hello world push')
153+
})
154+
155+
test('functions', () => {
156+
const mockFunction = jest.fn()
157+
// @ts-expect-error - we want to test invalid input
158+
expect(composeCssClass(mockFunction, 'hello')).toBe('hello')
159+
// @ts-expect-error - we want to test invalid input
160+
expect(composeCssClass(mockFunction, 'hello', composeCssClass)).toBe('hello')
161+
// @ts-expect-error - we want to test invalid input
162+
expect(composeCssClass(mockFunction, 'hello', [[composeCssClass], 'world'])).toBe('hello world')
163+
164+
expect(mockFunction).not.toHaveBeenCalled()
165+
})
166+
})
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
export type TComposeCssClassDictionaryValue = Record<string, unknown>
2+
3+
export type TComposeCssClassArrayValue = Array<TComposeCssClassValue>
4+
5+
export type TComposeCssClassValue =
6+
| TComposeCssClassArrayValue
7+
| TComposeCssClassDictionaryValue
8+
| string
9+
| number
10+
| null
11+
| boolean
12+
| undefined
13+
14+
/**
15+
* Composes a CSS class string conditionally from the given values.
16+
* Falsy values are discarded as well as standalone boolean values.
17+
* @param values - The values to construct the CSS class string from. It can take any number of arguments.
18+
* @returns `string` or `undefined` if no value is truthy.
19+
* @example
20+
* Strings:
21+
* ```ts
22+
* composeCssClass('foo', 'bar') // 'foo bar'
23+
* ```
24+
* @example
25+
* Strings(variadic):
26+
* ```ts
27+
* composeCssClass('foo', true && 'bar', 'baz') // 'foo bar baz'
28+
* ```
29+
* @example
30+
* Objects:
31+
* ```ts
32+
* composeCssClass({ foo:true, bar:false, baz:isTrue() }) // 'foo baz'
33+
* ```
34+
* @example
35+
* Objects(variadic):
36+
* ```ts
37+
* composeCssClass({ foo:true }, { bar:false }, null, { '--foobar':'hello' }) // 'foo --foobar'
38+
* ```
39+
* @example
40+
* Arrays:
41+
* ```ts
42+
* composeCssClass(['foo', 0, false, 'bar']) // 'foo bar'
43+
* ```
44+
* @example
45+
* Arrays(variadic):
46+
* ```ts
47+
* composeCssClass(['foo'], ['', 0, false, 'bar'], [['baz', [['hello'], 'there']]]) // 'foo bar baz hello there'
48+
* ```
49+
* @example
50+
* Mixed:
51+
* ```ts
52+
* composeCssClass('foo', [1 && 'bar', { baz:false, bat:null }, ['hello', ['world']]], 'cya') // 'foo bar hello world cya'
53+
* ```
54+
*/
55+
function composeCssClass(...values: Array<TComposeCssClassValue>): string | undefined {
56+
let className = ''
57+
58+
for (let valueIndex = 0, valuesLength = values.length; valueIndex < valuesLength; valueIndex += 1) {
59+
const value = values[valueIndex]
60+
61+
switch (true) {
62+
case !value: {
63+
continue
64+
}
65+
case typeof value === 'string':
66+
case typeof value === 'number': {
67+
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
68+
className = className + (className && ' ') + value
69+
continue
70+
}
71+
case Array.isArray(value): {
72+
const valueFromArray = composeCssClass(...(value as TComposeCssClassArrayValue))
73+
74+
if (valueFromArray) {
75+
className = className + (className && ' ') + valueFromArray
76+
}
77+
continue
78+
}
79+
case typeof value === 'object': {
80+
for (const key in value as TComposeCssClassDictionaryValue) {
81+
if ((value as TComposeCssClassDictionaryValue)[key]) {
82+
className = className + (className && ' ') + key
83+
}
84+
}
85+
continue
86+
}
87+
}
88+
}
89+
90+
return className || undefined
91+
}
92+
93+
export { composeCssClass }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './composeCssClass'

packages/utils/src/misc/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './composeCssClass'

0 commit comments

Comments
 (0)