├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── dist └── class-name-builder.js ├── package.json ├── src └── class-name-builder.js └── test └── class-name-builder.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | - "0.12" 5 | - "iojs" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 Luke William Westby 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ClassNameBuilder [![Build Status](https://travis-ci.org/lukewestby/class-name-builder.svg?branch=master)](https://travis-ci.org/lukewestby/class-name-builder) 2 | 3 | A small, chainable, immutable utility for building up class name strings in 4 | application logic. Great for use with React's `className` property or Angular's 5 | `ng-class` directive. Improves code readability by avoiding large, complex sets 6 | of nested conditional statements when generating class names in templates or 7 | application code. 8 | 9 | ## Installation 10 | 11 | ``` 12 | npm install class-name-builder 13 | ``` 14 | 15 | ## Example 16 | 17 | ```javascript 18 | import ClassNameBuilder from 'class-name-builder'; 19 | 20 | let condition = true; 21 | let otherCondition = false; 22 | 23 | const classNames = ClassNameBuilder 24 | .create() 25 | .always('example awesome-example') 26 | .if(condition, 'condition') 27 | .if(otherCondition, 'other-condition') 28 | .else(['not-other-condition', 'array']) 29 | .toString(); 30 | 31 | console.log(classNames); 32 | // "example awesome-example condition not-other-condition array" 33 | ``` 34 | 35 | ## API 36 | 37 | ### Static methods 38 | 39 | * `create(): ClassNameBuilder` : creates a new, empty instance of 40 | `ClassNameBuilder`. `ClassNameBuilder` has no constructor, so this is the only 41 | way to create an instance. 42 | 43 | ### Instance methods 44 | 45 | * `always(value: string | Array): ClassNameBuilder`: creates a new 46 | instance of `ClassNameBuilder` with the given values. If the value is a string, 47 | multiple class names can be included by separating them with one or more spaces, 48 | similar to the `class` HTML attribute. Duplicate class names will be removed in 49 | the case of both a space-separated string and an array. 50 | * `if(condition: any, value: string | Array): ClassNameBuilder`: 51 | creates a new instance of `ClassNameBuilder` with the passed in `value` only 52 | included if the condition is truthy. 53 | * `else(value: string | Array): ClassNameBuilder`: creates a new 54 | instance of `ClassNameBuilder` with the passed in `value` only if the condition 55 | for the preceding `if()` call was falsey. Will throw an error if called without 56 | an immediately preceding call to `if()`. 57 | * `merge(other: ClassNameBuilder): ClassNameBuilder`: creates a new instance of 58 | `ClassNameBuilder` with class names from the passed in instance mixed in with 59 | those in the calling instance. 60 | * `toString(): string`: returns the class names represented by the instance as a 61 | space-separated string. 62 | 63 | ## Development 64 | 65 | ``` 66 | npm install -g gulp && npm install 67 | ``` 68 | 69 | To bundle with `browserify` and `babelify`: 70 | ``` 71 | gulp build 72 | ``` 73 | 74 | To run the unit tests with `karma`: 75 | ``` 76 | gulp test 77 | ``` 78 | -------------------------------------------------------------------------------- /dist/class-name-builder.js: -------------------------------------------------------------------------------- 1 | (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.classNameBuilder = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o} value - the class name(s) to include 17 | * @returns {ClassNameBuilder} the new builder 18 | */ 19 | always: function always(value) { 20 | var classNames = cleanClassNames(value); 21 | return createWithInternals({ 22 | initialData: combine(this._data, classNames) 23 | }); 24 | } 25 | 26 | }, _defineProperty(_classNameBuilderProto, 'if', function _if(condition, value) { 27 | 28 | var classNames = condition ? combine(this._data, cleanClassNames(value)) : clone(this._data); 29 | 30 | return createWithInternals({ 31 | initialData: classNames, 32 | acceptElse: true, 33 | condition: condition 34 | }); 35 | }), _defineProperty(_classNameBuilderProto, 'else', function _else(value) { 36 | 37 | if (!this._acceptElse) { 38 | throw new Error('cannot call else() without first calling if()'); 39 | } 40 | 41 | var classNames = !this._condition ? combine(this._data, cleanClassNames(value)) : clone(this._data); 42 | 43 | return createWithInternals({ 44 | initialData: classNames 45 | }); 46 | }), _defineProperty(_classNameBuilderProto, 'merge', function merge(other) { 47 | 48 | if (!classNameBuilderProto.isPrototypeOf(other)) { 49 | throw new Error('argument must be a ClassNameBuilder instance'); 50 | } 51 | 52 | return createWithInternals({ 53 | initialData: combine(this._data, other._data) 54 | }); 55 | }), _defineProperty(_classNameBuilderProto, 'toString', function toString() { 56 | return this._data.length ? this._data.join(' ') : ''; 57 | }), _classNameBuilderProto); 58 | 59 | /** 60 | * Creates a new builder with an initial data array and optional flag for 61 | * allowing calls to `else()` 62 | * @param {{ acceptElse: boolean, initialData: Array }} 63 | * @returns {ClassNameBuilder} the new builder 64 | */ 65 | function createWithInternals(_ref) { 66 | var _ref$acceptElse = _ref.acceptElse; 67 | var acceptElse = _ref$acceptElse === undefined ? false : _ref$acceptElse; 68 | var condition = _ref.condition; 69 | var initialData = _ref.initialData; 70 | 71 | var classNameBuilder = Object.create(classNameBuilderProto); 72 | classNameBuilder._acceptElse = acceptElse; 73 | classNameBuilder._data = initialData; 74 | classNameBuilder._condition = condition; 75 | return classNameBuilder; 76 | } 77 | 78 | /** 79 | * Creates an empty builder 80 | * @returns {ClassNameBuilder} the new builder 81 | */ 82 | function create() { 83 | return createWithInternals({ 84 | initialData: [] 85 | }); 86 | } 87 | 88 | /** 89 | * Performs a concatenation and uniqueness reduction on two arrays 90 | * @param {Array} first - the first array to merge 91 | * @param {Array} second - the second array to merge 92 | * @returns {Array} the combined arrays 93 | */ 94 | function combine(first, second) { 95 | return unique(first.concat(second)); 96 | } 97 | 98 | /** 99 | * Performaces a uniqueness reduction on an array 100 | * @param {Array} value - the array to reduce 101 | * @returns {Array} an array with unique values 102 | */ 103 | function unique(value) { 104 | return value.reduce(function (memo, current) { 105 | return memo.indexOf(current) !== -1 ? memo : memo.concat(current); 106 | }, []); 107 | } 108 | 109 | /** 110 | * Clones an array 111 | * @param {Array} value - the array to clone 112 | * @returns {Array} a clone of the original array 113 | */ 114 | function clone(value) { 115 | return value.slice(0); 116 | } 117 | 118 | /** 119 | * Converts class name input into an array of strings with space trimmed off 120 | * @param {string | Array} value - the value to convert 121 | * @returns {Array} the split and trimmed values 122 | */ 123 | function cleanClassNames(value) { 124 | var classNamesArray = isString(value) ? splitString(value) : value; 125 | var trimmedNamesArray = trimClassNames(classNamesArray); 126 | var uniqueNamesArray = unique(trimmedNamesArray); 127 | return uniqueNamesArray; 128 | } 129 | 130 | /** 131 | * Determines if a value is a string 132 | * @param {any} value - the value to inspect 133 | * @returns {boolean} whether `value` is a string 134 | */ 135 | function isString(value) { 136 | return typeof value === 'string'; 137 | } 138 | 139 | /** 140 | * Splits a string on one or more spaces 141 | * @param {string} value - the string to split 142 | * @returns {Array} the result of the split 143 | */ 144 | function splitString(value) { 145 | return value.split(/\s+/g); 146 | } 147 | 148 | /** 149 | * Trims the values of an array of strings 150 | * @param {Array} value - the array of values to clean 151 | * @returns {Array} the array of trimmed values 152 | */ 153 | function trimClassNames(value) { 154 | return value.map(function (item) { 155 | return item.trim(); 156 | }); 157 | } 158 | 159 | exports['default'] = { create: create }; 160 | module.exports = exports['default']; 161 | /** 162 | * Creates a new builder which will include the given class name(s) if the 163 | * condition is truthy, or one which will pass through and allow a call to 164 | * `else()` otherwise 165 | * @param {any} condition - the object to evaluate as a boolean 166 | * @param {string | Array} value - the class name(s) to include 167 | * @returns {ClassNameBuilder} the new builder 168 | */ 169 | 170 | /** 171 | * Creates a new builder which will include the given value if a call to 172 | * `if()` has not evaluated its condition to be truthy 173 | * @param {string | Array} value - the class name(s) to include 174 | * @throws if called without calling `if()` immediately before 175 | * @returns {ClassNameBuilder} the new builder 176 | */ 177 | 178 | /** 179 | * Creates a new builder with class names from another builder merged in to 180 | * caller's class names. 181 | * @param {ClassNameBuilder} other - the builder to merge with 182 | * @throws if `other` is not an instance of `ClassNameBuilder` 183 | * @returns {ClassNameBuilder} the new builder with combined class names 184 | */ 185 | 186 | /** 187 | * Converts the builder to a string 188 | * @returns {string} space-delimitted values, or the empty string if empty 189 | */ 190 | 191 | },{}]},{},[1])(1) 192 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "class-name-builder", 3 | "version": "0.1.2", 4 | "description": "A small, chainable, immutable utility for building up class name strings in application logic", 5 | "main": "dist/class-name-builder.js", 6 | "scripts": { 7 | "build": "./node_modules/browserify/bin/cmd.js -e ./src/class-name-builder.js -s class-name-builder -t babelify -o ./dist/class-name-builder.js", 8 | "test": "./node_modules/babel/bin/babel-node.js ./node_modules/tape/bin/tape ./test/**/*" 9 | }, 10 | "author": { 11 | "name": "Luke William Westby", 12 | "email": "lwestby@alumni.nd.edu" 13 | }, 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/lukewestby/class-name-builder.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/lukewestby/class-name-builder/issues" 21 | }, 22 | "devDependencies": { 23 | "babel": "5.8.23", 24 | "babelify": "6.2.0", 25 | "browserify": "11.0.1", 26 | "tape": "4.2.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/class-name-builder.js: -------------------------------------------------------------------------------- 1 | const classNameBuilderProto = { 2 | 3 | /** 4 | * Creates a builder which always includes the given class name or names 5 | * @param {string | Array} value - the class name(s) to include 6 | * @returns {ClassNameBuilder} the new builder 7 | */ 8 | always(value) { 9 | const classNames = cleanClassNames(value); 10 | return createWithInternals({ 11 | initialData: combine(this._data, classNames) 12 | }); 13 | }, 14 | 15 | /** 16 | * Creates a new builder which will include the given class name(s) if the 17 | * condition is truthy, or one which will pass through and allow a call to 18 | * `else()` otherwise 19 | * @param {any} condition - the object to evaluate as a boolean 20 | * @param {string | Array} value - the class name(s) to include 21 | * @returns {ClassNameBuilder} the new builder 22 | */ 23 | ['if'](condition, value) { 24 | 25 | const classNames = condition ? 26 | combine(this._data, cleanClassNames(value)) : 27 | clone(this._data); 28 | 29 | return createWithInternals({ 30 | initialData: classNames, 31 | acceptElse: true, 32 | condition 33 | }); 34 | }, 35 | 36 | /** 37 | * Creates a new builder which will include the given value if a call to 38 | * `if()` has not evaluated its condition to be truthy 39 | * @param {string | Array} value - the class name(s) to include 40 | * @throws if called without calling `if()` immediately before 41 | * @returns {ClassNameBuilder} the new builder 42 | */ 43 | ['else'](value) { 44 | 45 | if(!this._acceptElse) { 46 | throw new Error('cannot call else() without first calling if()'); 47 | } 48 | 49 | const classNames = (!this._condition) ? 50 | combine(this._data, cleanClassNames(value)) : 51 | clone(this._data); 52 | 53 | return createWithInternals({ 54 | initialData: classNames 55 | }); 56 | }, 57 | 58 | /** 59 | * Creates a new builder with class names from another builder merged in to 60 | * caller's class names. 61 | * @param {ClassNameBuilder} other - the builder to merge with 62 | * @throws if `other` is not an instance of `ClassNameBuilder` 63 | * @returns {ClassNameBuilder} the new builder with combined class names 64 | */ 65 | merge(other) { 66 | 67 | if(!classNameBuilderProto.isPrototypeOf(other)) { 68 | throw new Error('argument must be a ClassNameBuilder instance'); 69 | } 70 | 71 | return createWithInternals({ 72 | initialData: combine(this._data, other._data) 73 | }); 74 | }, 75 | 76 | /** 77 | * Converts the builder to a string 78 | * @returns {string} space-delimitted values, or the empty string if empty 79 | */ 80 | toString() { 81 | return this._data.length ? this._data.join(' ') : ''; 82 | } 83 | }; 84 | 85 | /** 86 | * Creates a new builder with an initial data array and optional flag for 87 | * allowing calls to `else()` 88 | * @param {{ acceptElse: boolean, initialData: Array }} 89 | * @returns {ClassNameBuilder} the new builder 90 | */ 91 | function createWithInternals({ acceptElse = false, condition, initialData }) { 92 | const classNameBuilder = Object.create(classNameBuilderProto); 93 | classNameBuilder._acceptElse = acceptElse; 94 | classNameBuilder._data = initialData; 95 | classNameBuilder._condition = condition; 96 | return classNameBuilder; 97 | } 98 | 99 | /** 100 | * Creates an empty builder 101 | * @returns {ClassNameBuilder} the new builder 102 | */ 103 | function create() { 104 | return createWithInternals({ 105 | initialData: [] 106 | }); 107 | } 108 | 109 | /** 110 | * Performs a concatenation and uniqueness reduction on two arrays 111 | * @param {Array} first - the first array to merge 112 | * @param {Array} second - the second array to merge 113 | * @returns {Array} the combined arrays 114 | */ 115 | function combine(first, second) { 116 | return unique(first.concat(second)); 117 | } 118 | 119 | /** 120 | * Performaces a uniqueness reduction on an array 121 | * @param {Array} value - the array to reduce 122 | * @returns {Array} an array with unique values 123 | */ 124 | function unique(value) { 125 | return value.reduce((memo, current) => { 126 | return memo.indexOf(current) !== -1 ? memo : memo.concat(current); 127 | }, []); 128 | } 129 | 130 | /** 131 | * Clones an array 132 | * @param {Array} value - the array to clone 133 | * @returns {Array} a clone of the original array 134 | */ 135 | function clone(value) { 136 | return value.slice(0) 137 | } 138 | 139 | /** 140 | * Converts class name input into an array of strings with space trimmed off 141 | * @param {string | Array} value - the value to convert 142 | * @returns {Array} the split and trimmed values 143 | */ 144 | function cleanClassNames(value) { 145 | const classNamesArray = isString(value) ? splitString(value) : value; 146 | const trimmedNamesArray = trimClassNames(classNamesArray); 147 | const uniqueNamesArray = unique(trimmedNamesArray); 148 | return uniqueNamesArray; 149 | } 150 | 151 | /** 152 | * Determines if a value is a string 153 | * @param {any} value - the value to inspect 154 | * @returns {boolean} whether `value` is a string 155 | */ 156 | function isString(value) { 157 | return typeof value === 'string'; 158 | } 159 | 160 | /** 161 | * Splits a string on one or more spaces 162 | * @param {string} value - the string to split 163 | * @returns {Array} the result of the split 164 | */ 165 | function splitString(value) { 166 | return value.split(/\s+/g); 167 | } 168 | 169 | /** 170 | * Trims the values of an array of strings 171 | * @param {Array} value - the array of values to clean 172 | * @returns {Array} the array of trimmed values 173 | */ 174 | function trimClassNames(value) { 175 | return value.map((item) => item.trim()); 176 | } 177 | 178 | export default { create }; 179 | -------------------------------------------------------------------------------- /test/class-name-builder.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import ClassNameBuilder from '../src/class-name-builder.js'; 3 | 4 | test('create()', (t) => { 5 | t.plan(1); 6 | 7 | const className = ClassNameBuilder 8 | .create() 9 | .toString(); 10 | 11 | t.equal(className, '', 'it should return an empty string from a fresh instance'); 12 | }); 13 | 14 | test('always()', (t) => { 15 | 16 | t.test('basic usage', (t) => { 17 | t.plan(1); 18 | 19 | const className = ClassNameBuilder 20 | .create() 21 | .always('blue') 22 | .toString(); 23 | 24 | t.equal(className, 'blue', 'it should return the given class names when called with always'); 25 | }); 26 | 27 | t.test('array input', (t) => { 28 | t.plan(1); 29 | 30 | const className = ClassNameBuilder 31 | .create() 32 | .always(['blue', 'green']) 33 | .toString(); 34 | 35 | t.equal(className, 'blue green', 'it should accept an array of class names'); 36 | }); 37 | 38 | t.test('string splitting', (t) => { 39 | t.plan(1); 40 | 41 | const className = ClassNameBuilder 42 | .create() 43 | .always('blue green') 44 | .toString(); 45 | 46 | t.equal(className, 'blue green', 'it should split strings on spaces'); 47 | }); 48 | 49 | t.test('deduping', (t) => { 50 | t.plan(1); 51 | 52 | const className = ClassNameBuilder 53 | .create() 54 | .always('blue green blue') 55 | .toString(); 56 | 57 | t.equal(className, 'blue green', 'it should remove duplicates'); 58 | }); 59 | }); 60 | 61 | test('if()', (t) => { 62 | t.plan(1); 63 | 64 | const className = ClassNameBuilder 65 | .create() 66 | .if(true, 'true') 67 | .if(false, 'false') 68 | .toString(); 69 | 70 | t.equal(className, 'true', 'it should return a class name under an if statement only if the condition is true'); 71 | }); 72 | 73 | test('else()', (t) => { 74 | 75 | t.test('false if() value', (t) => { 76 | t.plan(1); 77 | 78 | const className = ClassNameBuilder 79 | .create() 80 | .if(false, 'false').else('true') 81 | .toString(); 82 | 83 | t.equal(className, 'true', 'it should return a class name under the else branch of an if statement if the condition is false'); 84 | }); 85 | 86 | t.test('true if() value', (t) => { 87 | t.plan(1); 88 | 89 | const className = ClassNameBuilder 90 | .create() 91 | .if(true, 'true').else('false') 92 | .toString(); 93 | 94 | t.equal(className, 'true', 'it should not return a class name under an else branch if the condition is true'); 95 | }); 96 | 97 | t.test('incorrect usage', (t) => { 98 | t.plan(1); 99 | 100 | const badUsage = () => { 101 | ClassNameBuilder 102 | .create() 103 | .else('not gonna work'); 104 | }; 105 | 106 | t.throws(badUsage, 'it should throw an error if else is called before if'); 107 | }); 108 | }); 109 | 110 | test('merge()', (t) => { 111 | 112 | t.test('basic usage', (t) => { 113 | t.plan(1); 114 | 115 | const firstBuilder = ClassNameBuilder.create().always('first'); 116 | const secondBuilder = ClassNameBuilder.create().always('second'); 117 | const mergedBuilder = firstBuilder.merge(secondBuilder); 118 | const className = mergedBuilder.toString(); 119 | 120 | t.equal(className, 'first second', 'it should include class names from one builder into another'); 121 | }); 122 | 123 | t.test('incorrect usage', (t) => { 124 | t.plan(1); 125 | 126 | const builder = ClassNameBuilder.create(); 127 | const badUsage = () => builder.merge(1); 128 | 129 | t.throws(badUsage, 'it should throw if something other than a ClassNameBuilder is passed') 130 | }) 131 | }); 132 | --------------------------------------------------------------------------------