this.props.onTagDeleted(i)}\n style={this.props.getTagStyle(tag)}/>\n );\n });\n },\n\n onBlur(e) {\n this.props.closePanel();\n if (typeof this.props.onBlur === 'function') {\n this.props.onBlur(e);\n }\n },\n\n render() {\n const placeholder = this.props.placeholder || '';\n let size = this.props.value.length === 0 ?\n placeholder.length :\n this.props.value.length;\n return (\n \n {this.getTags()}\n
\n
\n
\n );\n }\n});\n\nexport default Input;\n\n\n\n/** WEBPACK FOOTER **\n ** ./src/Input.jsx\n **/","import React from 'react';\n\nimport Category from './Category.jsx';\n\nconst { PropTypes } = React;\n\nconst Panel = React.createClass({\n propTypes: {\n categories: PropTypes.arrayOf(PropTypes.object).isRequired,\n selection: PropTypes.object.isRequired,\n onAdd: PropTypes.func.isRequired,\n input: PropTypes.string.isRequired,\n addNew: PropTypes.bool,\n getTagStyle: PropTypes.func,\n getCreateNewText: PropTypes.func\n },\n\n getCategories() {\n return this.props.categories.map((c, i) => {\n return (\n \n );\n });\n },\n\n render() {\n return (\n \n {this.getCategories()}\n
\n );\n }\n});\n\nexport default Panel;\n\n\n\n/** WEBPACK FOOTER **\n ** ./src/Panel.jsx\n **/","export var TAB = 9;\nexport var ENTER = 13;\nexport var BACKSPACE = 8;\nexport var LEFT = 37;\nexport var UP = 38;\nexport var RIGHT = 39;\nexport var DOWN = 40;\nexport var COMMA = 188;\n\n\n\n/** WEBPACK FOOTER **\n ** ./src/keyboard.js\n **/"],"sourceRoot":""}
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Manual test
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import Input from './src/index';
5 |
6 | const categories = [
7 | {
8 | id: 'animals',
9 | title: 'Animals',
10 | type: 'animal',
11 | items: ['Dog', 'Cat', 'Bird', 'Dolphin', 'Apes']
12 | },
13 | {
14 | id: 'something',
15 | title: 'Something cool',
16 | items: ['Something cool'],
17 | single: true
18 | },
19 | {
20 | id: 'food',
21 | title: 'food',
22 | type: 'food',
23 | items: ['Apple', 'Banana', 'Grapes', 'Pear']
24 | },
25 | {
26 | id: 'professions',
27 | title: 'Professions',
28 | type: 'profession',
29 | items: ['Waiter', 'Writer', 'Hairdresser', 'Policeman']
30 | }
31 | ];
32 |
33 | function transformTag(tag) {
34 | const categoryMatches = categories.filter(category => category.id === tag.category);
35 | const categoryTitle = categoryMatches[0].title;
36 | return `${categoryTitle}/${tag.title}`;
37 | }
38 |
39 |
40 | function getTagStyle(tag){
41 | if (tag.title === "rhino") {
42 | return {
43 | base: {
44 | backgroundColor: "gray",
45 | color: "lightgray"
46 | }
47 | }
48 | return {}
49 | }
50 | }
51 |
52 | function getCreateNewText(title, text){
53 | return `create new ${title} "${text}"`
54 | }
55 |
56 | const Wrap = React.createClass({
57 | getInitialState() {
58 | return {
59 | editable: true,
60 | tags: [{
61 | title: "rhino",
62 | category: 'animals'
63 | }]
64 | };
65 | },
66 |
67 | toggleEdit(e) {
68 | e.preventDefault();
69 | e.stopPropagation();
70 | this.setState({ editable: !this.state.editable });
71 | },
72 |
73 | render() {
74 | return (
75 |
76 |
77 | {this.state.editable
78 | ? {
85 | console.log('Changed', tags);
86 | this.setState({tags});
87 | }}
88 | onBlur={() => {
89 | console.log('Blur');
90 | }}
91 | transformTag={transformTag}
92 | getCreateNewText={getCreateNewText}
93 | />
94 | : Not editable}
95 |
96 | );
97 | }
98 | });
99 |
100 | ReactDOM.render(
101 | React.createElement(Wrap, {}),
102 | document.getElementById('app')
103 | );
104 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-categorized-tag-input",
3 | "version": "2.1.2",
4 | "description": "React.js component for making tag autocompletion inputs with categorized results.",
5 | "main": "categorized-tag-input.js",
6 | "scripts": {
7 | "test": "mocha --compilers js:babel/register --recursive",
8 | "build": "NODE_ENV=production ./node_modules/.bin/webpack --config webpack.config.js",
9 | "runtest": "node server.js",
10 | "prepublish": "npm run build"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/mvader/react-categorized-tag-input.git"
15 | },
16 | "keywords": [
17 | "tag",
18 | "autocomplete",
19 | "input",
20 | "categorized",
21 | "react"
22 | ],
23 | "author": "Miguel Molina ",
24 | "license": "MIT",
25 | "bugs": {
26 | "url": "https://github.com/mvader/react-categorized-tag-input/issues"
27 | },
28 | "homepage": "https://github.com/mvader/react-categorized-tag-input#readme",
29 | "devDependencies": {
30 | "babel": "^5.8.23",
31 | "babel-core": "^5.8.25",
32 | "babel-loader": "^5.3.2",
33 | "expect": "^1.12.0",
34 | "jsdom": "<4.0.0",
35 | "mocha": "^2.3.3",
36 | "mocha-jsdom": "^1.0.0",
37 | "react": "^15.1.0",
38 | "react-dom": "^15.1.0",
39 | "react-hot-loader": "^1.3.0",
40 | "webpack": "^1.12.2",
41 | "webpack-dev-server": "^1.12.0"
42 | },
43 | "dependencies": {
44 | "exenv": "^1.2.0",
45 | "react-addons-test-utils": "^15.1.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var WebpackDevServer = require('webpack-dev-server');
3 | var config = require('./webpack.test.config');
4 |
5 | new WebpackDevServer(webpack(config), {
6 | publicPath: config.output.publicPath,
7 | hot: true,
8 | historyApiFallback: true
9 | }).listen(5000, 'localhost', function (err) {
10 | if (err) {
11 | console.log(err);
12 | }
13 | console.log('Listening at localhost:5000');
14 | });
15 |
--------------------------------------------------------------------------------
/src/CategorizedTagInput.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Input from './Input.jsx';
4 | import Panel from './Panel.jsx';
5 | import * as key from './keyboard';
6 |
7 | const { PropTypes } = React;
8 |
9 | export function isCategoryItemValid(i) {
10 | return typeof i === 'string' && i.trim().length > 0;
11 | }
12 |
13 | export function isCategoryValid(c) {
14 | return typeof c === 'object'
15 | && c.id
16 | && c.title
17 | && c.items
18 | && Array.isArray(c.items)
19 | && c.items.every(isCategoryItemValid)
20 | && (c.type || c.single);
21 | }
22 |
23 | const CategorizedTagInput = React.createClass({
24 | propTypes: {
25 | addNew: PropTypes.bool,
26 | categories: PropTypes.arrayOf(PropTypes.object).isRequired,
27 | transformTag: PropTypes.func,
28 | value: PropTypes.arrayOf(PropTypes.object),
29 | onBlur: PropTypes.func,
30 | onChange: PropTypes.func,
31 | placeholder: PropTypes.string,
32 | getTagStyle: PropTypes.func,
33 | getCreateNewText: PropTypes.func
34 | },
35 |
36 | getInitialState() {
37 | return {
38 | value: '',
39 | selection: {
40 | item: 0,
41 | category: 0
42 | },
43 | panelOpened: false,
44 | categories: [],
45 | addNew: this.props.addNew === undefined ? true : this.props.addNew
46 | };
47 | },
48 |
49 | getDefaultProps() {
50 | return {
51 | onChange(newTags){
52 | // do nothing
53 | }
54 | };
55 | },
56 |
57 | componentWillMount() {
58 | if (!this.props.categories.every(isCategoryValid)) {
59 | throw new Error('invalid categories source provided for react-categorized-tag-input');
60 | }
61 | },
62 |
63 | componentWillUnmount() {
64 | if (this.timeout) {
65 | clearTimeout(this.timeout);
66 | }
67 | },
68 |
69 | filterCategories(input) {
70 | let categories = this.props.categories.map(c => {
71 | c = Object.assign({}, c, {
72 | items: c.items.filter(this.filterItems(input))
73 | });
74 | return (c.items.length === 0 && (!this.state.addNew || c.single)) ? null : c;
75 | }).filter(c => c !== null);
76 |
77 | let selection = this.state.selection;
78 | if (this.state.selection.category >= categories.length) {
79 | selection = {
80 | category: 0,
81 | item: 0
82 | };
83 | } else {
84 | if (selection.item >= categories[selection.category].items.length) {
85 | selection.item = 0;
86 | }
87 | }
88 |
89 | this.setState({
90 | categories,
91 | selection
92 | });
93 | },
94 |
95 | filterItems(input) {
96 | return function (i) {
97 | if (input.length === 1) {
98 | return i.toLowerCase().trim() === input;
99 | }
100 | return i.toLowerCase().indexOf(input.trim().toLowerCase()) >= 0;
101 | };
102 | },
103 |
104 | openPanel() {
105 | this.setState({ panelOpened: true });
106 | },
107 |
108 | closePanel() {
109 | // Prevent the panel from hiding before the click action takes place
110 | if (this.timeout) {
111 | clearTimeout(this.timeout);
112 | }
113 | this.timeout = setTimeout(() => {
114 | this.timeout = undefined;
115 | this.setState({ panelOpened: false });
116 | }, 150);
117 | },
118 |
119 | onValueChange(e) {
120 | let value = e.target.value;
121 | this.setState({ value, panelOpened: value.trim().length > 0 || !isNaN(Number(value.trim())) });
122 | this.filterCategories(value);
123 | },
124 |
125 | onTagDeleted(i) {
126 | const newTags = this.props.value.slice()
127 | newTags.splice(i, 1)
128 | this.props.onChange(newTags)
129 | },
130 |
131 | onAdd(newTag) {
132 | const newTags = this.props.value.concat([newTag]);
133 | this.setState({
134 | value: '',
135 | panelOpened: true
136 | });
137 |
138 | this.refs.input.focusInput();
139 | this.props.onChange(newTags);
140 | },
141 |
142 | addSelectedTag() {
143 | if (!(this.state.panelOpened && this.state.value.length > 0)) {
144 | return;
145 | }
146 |
147 | const category = this.state.categories[this.state.selection.category];
148 | const title = category.items[this.state.selection.item];
149 | this.onAdd({
150 | category: category.id,
151 | title: title || this.state.value
152 | });
153 | },
154 |
155 | handleBackspace(e) {
156 | if (this.state.value.trim().length === 0) {
157 | e.preventDefault();
158 | this.onTagDeleted(this.props.value.length - 1);
159 | }
160 | },
161 |
162 | handleArrowLeft() {
163 | let result = this.state.selection.item - 1;
164 | this.setState({selection: {
165 | category: this.state.selection.category,
166 | item: result >= 0 ? result : 0
167 | }});
168 | },
169 |
170 | handleArrowUp() {
171 | let result = this.state.selection.category - 1;
172 | this.setState({selection: {
173 | category: result >= 0 ? result : 0,
174 | item: 0
175 | }});
176 | },
177 |
178 | handleArrowRight() {
179 | let result = this.state.selection.item + 1;
180 | let cat = this.state.categories[this.state.selection.category];
181 | this.setState({selection: {
182 | category: this.state.selection.category,
183 | item: result <= cat.items.length ? result : cat.items.length
184 | }});
185 | },
186 |
187 | handleArrowDown() {
188 | let result = this.state.selection.category + 1;
189 | let cats = this.state.categories;
190 | this.setState({selection: {
191 | category: result < cats.length ? result : cats.length - 1,
192 | item: 0
193 | }});
194 | },
195 |
196 | onKeyDown(e) {
197 | let result;
198 | switch (e.keyCode) {
199 | case key.TAB:
200 | case key.ENTER:
201 | if (!this.state.value){
202 | // enable normal tab/enter behavior
203 | // (don't preventDefault)
204 | break;
205 | }
206 | case key.COMMA:
207 | e.preventDefault();
208 | this.addSelectedTag();
209 | break;
210 | case key.BACKSPACE:
211 | this.handleBackspace(e);
212 | break;
213 | case key.LEFT:
214 | this.handleArrowLeft();
215 | break;
216 | case key.UP:
217 | this.handleArrowUp();
218 | break;
219 | case key.RIGHT:
220 | this.handleArrowRight();
221 | break;
222 | case key.DOWN:
223 | this.handleArrowDown();
224 | break;
225 | }
226 | },
227 |
228 | render() {
229 | return (
230 |
231 |
238 | {this.state.panelOpened && this.state.value.length > 0 ?
: ''}
243 |
244 | );
245 | }
246 | });
247 |
248 | export default CategorizedTagInput;
249 |
--------------------------------------------------------------------------------
/src/Category.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Tag from './Tag.jsx';
4 |
5 | const { PropTypes } = React;
6 |
7 | const getCreateNewText = (title, text) => `Create new ${title} "${text}"`
8 |
9 | const Category = React.createClass({
10 | propTypes: {
11 | items: PropTypes.array.isRequired,
12 | category: PropTypes.oneOfType([
13 | PropTypes.string,
14 | PropTypes.number
15 | ]).isRequired,
16 | title: PropTypes.string.isRequired,
17 | selected: PropTypes.bool.isRequired,
18 | selectedItem: PropTypes.number.isRequired,
19 | input: PropTypes.string.isRequired,
20 | addNew: PropTypes.bool,
21 | type: PropTypes.string,
22 | onAdd: PropTypes.func.isRequired,
23 | single: PropTypes.bool,
24 | getTagStyle: PropTypes.func,
25 | getCreateNewText: PropTypes.func
26 | },
27 |
28 | onAdd(title) {
29 | return () => {
30 | this.props.onAdd({
31 | category: this.props.category,
32 | title: title
33 | });
34 | };
35 | },
36 |
37 | onCreateNew(e) {
38 | e.preventDefault();
39 | this.onAdd(this.props.input)();
40 | },
41 |
42 | getTagStyle(item) {
43 | return this.props.getTagStyle ? this.props.getTagStyle(item) : {}
44 | },
45 |
46 | itemToTag(item, i) {
47 | return (
48 |
51 | );
52 | },
53 |
54 | fullMatchInItems() {
55 | for (let i = 0, len = this.props.items.length; i < len; i++) {
56 | if (this.props.items[i] === this.props.input) {
57 | return true;
58 | }
59 | }
60 | return false;
61 | },
62 |
63 | getItems() {
64 | return {
65 | items: this.props.items.map(this.itemToTag),
66 | fullMatch: this.fullMatchInItems(),
67 | };
68 | },
69 |
70 | isSelected(i) {
71 | return this.props.selected &&
72 | (i === this.props.selectedItem || this.props.single);
73 | },
74 |
75 | getAddBtn(fullMatch, selected) {
76 | const title = this.props.type || this.props.title;
77 | const text = this.props.input;
78 | const getText = this.props.getCreateNewText || getCreateNewText;
79 | if (this.props.addNew && !fullMatch && !this.props.single) {
80 | return [
81 | this.props.items.length > 0 ?
82 | or :
83 | null,
84 |
89 | ];
90 | }
91 |
92 | return null;
93 | },
94 |
95 | render() {
96 | let { items, fullMatch } = this.getItems();
97 | let addBtn = this.getAddBtn(
98 | fullMatch,
99 | (items.length === 0 || this.props.selectedItem >= items.length) &&
100 | this.props.selected
101 | );
102 |
103 | return (
104 |
105 |
{this.props.title}
106 |
107 | {items}
108 | {addBtn}
109 |
110 |
111 | );
112 | }
113 | });
114 |
115 | export default Category;
116 |
--------------------------------------------------------------------------------
/src/Input.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Tag from './Tag.jsx';
4 |
5 | const { PropTypes } = React;
6 |
7 | const Input = React.createClass({
8 | propTypes: {
9 | openPanel: PropTypes.func.isRequired,
10 | closePanel: PropTypes.func.isRequired,
11 | onValueChange: PropTypes.func.isRequired,
12 | onTagDeleted: PropTypes.func.isRequired,
13 | onKeyDown: PropTypes.func.isRequired,
14 | value: PropTypes.string.isRequired,
15 | tags: PropTypes.arrayOf(PropTypes.object).isRequired,
16 | placeholder: PropTypes.string,
17 | onBlur: PropTypes.func,
18 | getTagStyle: PropTypes.func,
19 | transformTag: PropTypes.func
20 | },
21 |
22 | focusInput() {
23 | this.refs.input.focus();
24 | },
25 |
26 | getDefaultProps() {
27 | return {
28 | getTagStyle(tag) {
29 | // empty style object by default
30 | return {};
31 | },
32 | transformTag(tag){
33 | return tag.title;
34 | }
35 | };
36 | },
37 |
38 | getTags() {
39 |
40 | return this.props.tags.map((tag, i) => {
41 | return (
42 | this.props.onTagDeleted(i)}
45 | style={this.props.getTagStyle(tag)}/>
46 | );
47 | });
48 | },
49 |
50 | onBlur(e) {
51 | this.props.closePanel();
52 | if (typeof this.props.onBlur === 'function') {
53 | this.props.onBlur(e);
54 | }
55 | },
56 |
57 | render() {
58 | const placeholder = this.props.placeholder || '';
59 | let size = this.props.value.length === 0 ?
60 | placeholder.length :
61 | this.props.value.length;
62 | return (
63 |
64 | {this.getTags()}
65 |
71 |
72 |
73 | );
74 | }
75 | });
76 |
77 | export default Input;
78 |
--------------------------------------------------------------------------------
/src/Panel.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Category from './Category.jsx';
4 |
5 | const { PropTypes } = React;
6 |
7 | const Panel = React.createClass({
8 | propTypes: {
9 | categories: PropTypes.arrayOf(PropTypes.object).isRequired,
10 | selection: PropTypes.object.isRequired,
11 | onAdd: PropTypes.func.isRequired,
12 | input: PropTypes.string.isRequired,
13 | addNew: PropTypes.bool,
14 | getTagStyle: PropTypes.func,
15 | getCreateNewText: PropTypes.func
16 | },
17 |
18 | getCategories() {
19 | return this.props.categories.map((c, i) => {
20 | return (
21 |
28 | );
29 | });
30 | },
31 |
32 | render() {
33 | return (
34 |
35 | {this.getCategories()}
36 |
37 | );
38 | }
39 | });
40 |
41 | export default Panel;
42 |
--------------------------------------------------------------------------------
/src/Tag.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const { PropTypes } = React;
4 |
5 | const Tag = React.createClass({
6 | propTypes: {
7 | selected: PropTypes.bool,
8 | input: PropTypes.string.isRequired,
9 | text: PropTypes.string.isRequired,
10 | addable: PropTypes.bool,
11 | deletable: PropTypes.bool,
12 | onAdd: PropTypes.func,
13 | onDelete: PropTypes.func,
14 | style: PropTypes.object
15 | },
16 |
17 | // helps tests pass
18 | getDefaultProps() {
19 | return {
20 | text: ''
21 | };
22 | },
23 |
24 | tagContent() {
25 | let content = [];
26 | let startIndex = this.props.text.trim().toLowerCase()
27 | .indexOf(this.props.input.trim().toLowerCase());
28 | let endIndex = startIndex + this.props.input.length;
29 |
30 | if (startIndex > 0) {
31 | content.push(
32 | {this.props.text.substring(0, startIndex)}
33 | );
34 | }
35 |
36 | content.push(
37 | {this.props.text.substring(startIndex, endIndex)}
38 | );
39 |
40 | if (endIndex < this.props.text.length) {
41 | content.push(
42 | {this.props.text.substring(endIndex)}
43 | );
44 | }
45 |
46 | return content;
47 | },
48 |
49 | onClick(e) {
50 | e.preventDefault();
51 | if (this.props.addable) {
52 | this.props.onAdd(e);
53 | }
54 | },
55 |
56 | onDelete(e) {
57 | // Prevents onClick event of the whole tag from being triggered
58 | e.preventDefault();
59 | e.stopPropagation();
60 | this.props.onDelete(e);
61 | },
62 |
63 | getDeleteBtn() {
64 | const style = this.props.style || {}
65 | const deleteStyle = style.delete ? style.delete : {}
66 |
67 | return (
68 |
71 | );
72 | },
73 |
74 | render() {
75 | let deleteBtn = null;
76 | if (this.props.deletable) {
77 | deleteBtn = this.getDeleteBtn();
78 | }
79 | let cls = 'cti__tag' + (this.props.selected ? ' cti-selected' : '');
80 |
81 | const style = this.props.style || {}
82 |
83 | return (
84 |
85 |
86 | {this.tagContent()}
87 |
88 | {deleteBtn}
89 |
90 | );
91 | }
92 | });
93 |
94 | export default Tag;
95 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import CategorizedTagInput from './CategorizedTagInput.jsx';
2 | export default CategorizedTagInput;
3 |
--------------------------------------------------------------------------------
/src/keyboard.js:
--------------------------------------------------------------------------------
1 | export var TAB = 9;
2 | export var ENTER = 13;
3 | export var BACKSPACE = 8;
4 | export var LEFT = 37;
5 | export var UP = 38;
6 | export var RIGHT = 39;
7 | export var DOWN = 40;
8 | export var COMMA = 188;
9 |
--------------------------------------------------------------------------------
/test/CategorizedTagInput_spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import React from 'react';
3 | import TestUtils from 'react-addons-test-utils';
4 | import jsdomReact from './jsdomReact';
5 |
6 | import CategorizedTagInput from '../src/CategorizedTagInput.jsx';
7 |
8 |
9 | describe('CategorizedTagInput', () => {
10 | jsdomReact();
11 | });
12 |
--------------------------------------------------------------------------------
/test/Category_spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import React from 'react';
3 | import TestUtils from 'react-addons-test-utils';
4 | import jsdomReact from './jsdomReact';
5 |
6 | import Category from '../src/Category.jsx';
7 |
8 |
9 | function category(props) {
10 | return TestUtils.renderIntoDocument(React.createElement(Category, props));
11 | }
12 |
13 | function props(p = {}) {
14 | return Object.assign({}, {
15 | items: ['foo', 'foobarbaz'],
16 | input: 'fo',
17 | title: 'Things',
18 | selected: true,
19 | selectedItem: 1,
20 | addNew: true,
21 | type: 'thing',
22 | category: 1,
23 | onAdd: () => {}
24 | }, p);
25 | }
26 |
27 | function findAddBtn(c) {
28 | return TestUtils.findRenderedDOMComponentWithClass(c, 'cti__category__add-item');
29 | }
30 |
31 | function findTags(c) {
32 | return TestUtils.scryRenderedDOMComponentsWithClass(c, 'cti__tag');
33 | }
34 |
35 | describe('Category', () => {
36 | jsdomReact();
37 |
38 | describe('when addNew is true', () => {
39 | it('should show the add new button', () => {
40 | let c = category(props());
41 | let btn = findAddBtn(c);
42 |
43 | expect(btn).toNotBe(undefined);
44 | expect(btn.innerHTML).toBe('Create new thing "fo"');
45 | });
46 |
47 | describe('and is a full match', () => {
48 | it('there should be no add new button', () => {
49 | let c = category(props({ input: 'foo' }));
50 | expect(() => {
51 | let btn = findAddBtn(c);
52 | }).toThrow(/.*/);
53 | });
54 | });
55 | });
56 |
57 | describe('when addNew is false', () => {
58 | it('there should be no add new button', () => {
59 | let c = category(props({ addNew: false }));
60 | expect(() => {
61 | let btn = findAddBtn(c);
62 | }).toThrow(/.*/);
63 | });
64 | });
65 |
66 | it('should only show the items that match the input', () => {
67 | let c = category(props());
68 | let tags = findTags(c);
69 | expect(tags.length).toBe(2);
70 | expect(tags[0].textContent).toBe('foo');
71 | expect(tags[1].textContent).toBe('foobarbaz');
72 |
73 | c = category(props({ input: 'bar', items: ['bar', 'foobarbaz'] }));
74 | tags = findTags(c);
75 | expect(tags.length).toBe(2);
76 | expect(tags[0].textContent).toBe('bar');
77 | expect(tags[1].textContent).toBe('foobarbaz');
78 |
79 | c = category(props({ input: 'ksajdfhskjf', items: [] }));
80 | tags = findTags(c);
81 | expect(tags.length).toBe(0);
82 | });
83 |
84 | describe('when there are no matching elements', () => {
85 | describe('and addNew is true', () => {
86 | it('should not show any tags, just the new button', () => {
87 | let c = category(props({ input: 'asd', items: [] }));
88 | expect(findTags(c).length).toBe(0);
89 | let btn = findAddBtn(c);
90 | expect(btn).toNotBe(undefined);
91 | expect(btn.innerHTML).toBe('Create new thing "asd"');
92 | });
93 | it('should generate a message using getCreateNewText if provided', () => {
94 | const getCreateNewText = (title, text) => `Hacer nuevo ${title} "${text}"`
95 | const c = category(props({ input: 'asd', items: [], getCreateNewText}));
96 | expect(findTags(c).length).toBe(0);
97 | const btn = findAddBtn(c);
98 | expect(btn).toNotBe(undefined);
99 | expect(btn.innerHTML).toBe('Hacer nuevo thing "asd"');
100 | });
101 | });
102 | });
103 |
104 | describe('when a tag is clicked', () => {
105 | it('should trigger onAdd with category and title', done => {
106 | let c = category(props({
107 | onAdd(o) {
108 | expect(o.category).toBe(1);
109 | expect(o.title).toBe('foo');
110 | done();
111 | }
112 | }));
113 | let tags = findTags(c);
114 | TestUtils.Simulate.click(tags[0]);
115 | });
116 | });
117 |
118 | describe('when category is selected', () => {
119 | function isSelected(elem) {
120 | return elem.className.split(' ')[1] === 'cti-selected';
121 | }
122 |
123 | describe('and an item is selected', () => {
124 | it('should have a selected class', () => {
125 | let c = category(props());
126 | let tags = findTags(c);
127 | expect(isSelected(tags[0])).toBe(false);
128 | expect(isSelected(tags[1])).toBe(true);
129 | });
130 | });
131 |
132 | describe('and the selected item is bigger than the number of elements', () => {
133 | it('should select the create button', () => {
134 | let c = category(props({ selectedItem: 2 }));
135 | let tags = findTags(c);
136 | expect(isSelected(tags[0])).toBe(false);
137 | expect(isSelected(tags[1])).toBe(false);
138 |
139 | expect(isSelected(findAddBtn(c))).toBe(true);
140 | });
141 | });
142 |
143 | describe('and there is no matched item', () => {
144 | it('should select the create button', () => {
145 | let c = category(props({ selectedItem: 0, items: [], input: 'asd' }));
146 | expect(isSelected(findAddBtn(c))).toBe(true);
147 | });
148 | });
149 | });
150 | });
151 |
--------------------------------------------------------------------------------
/test/Input_spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import React from 'react';
3 | import TestUtils from 'react-addons-test-utils';
4 | import jsdomReact from './jsdomReact';
5 |
6 | import Input from '../src/Input.jsx';
7 |
8 |
9 | function createInput(props) {
10 | return TestUtils.renderIntoDocument(React.createElement(Input, props));
11 | }
12 |
13 | function props(p = {}) {
14 | return Object.assign({}, {
15 | tags: ['foo', 'bar'],
16 | value: 'baz',
17 | onKeyDown: () => {},
18 | onTagDeleted: () => {},
19 | onValueChange: () => {},
20 | openPanel: () => {},
21 | closePanel: () => {},
22 | }, p);
23 | }
24 |
25 | function findInput(i) {
26 | return TestUtils.findRenderedDOMComponentWithClass(i, 'cti__input__input');
27 | }
28 |
29 | function findTags(i) {
30 | return TestUtils.scryRenderedDOMComponentsWithClass(i, 'cti__tag');
31 | }
32 |
33 | // TODO: can't test autoresize or focus because JSDom does not implement
34 | // layouting or focus/blur
35 |
36 | describe('Input', () => {
37 | jsdomReact();
38 |
39 | it('should render the given tags and the value', () => {
40 | let i = createInput(props());
41 | let tags = findTags(i);
42 | expect(tags.length).toBe(2);
43 |
44 | let input = findInput(i);
45 | expect(input.value).toBe(props().value)
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/test/Panel_spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import React from 'react';
3 | import TestUtils from 'react-addons-test-utils';
4 | import jsdomReact from './jsdomReact';
5 |
6 | import Panel from '../src/Panel.jsx';
7 |
8 |
9 | function panel(props) {
10 | return TestUtils.renderIntoDocument(React.createElement(Panel, props));
11 | }
12 |
13 | function findCategories(p) {
14 | return TestUtils.scryRenderedDOMComponentsWithClass(p, 'cti__category');
15 | }
16 |
17 | function props(p = {}) {
18 | return Object.assign({}, {
19 | categories: [
20 | {
21 | id: 1,
22 | items: ['rabbit'],
23 | title: 'Things',
24 | type: 'thing'
25 | },
26 | {
27 | id: 2,
28 | items: ['rab'],
29 | title: 'Reversed things',
30 | type: 'reversed thing'
31 | }
32 | ],
33 | selection: { category: 1, item: 1 },
34 | onAdd: () => {},
35 | input: 'ra',
36 | addNew: true
37 | }, p);
38 | }
39 |
40 | describe('Panel', () => {
41 | jsdomReact();
42 |
43 | it('should render categories', () => {
44 | let p = panel(props());
45 | expect(findCategories(p).length).toBe(2);
46 | });
47 |
48 | it('should select the corresponding item', () => {
49 | let p = panel(props());
50 | let selected = TestUtils.scryRenderedDOMComponentsWithClass(p, 'cti-selected');
51 | expect(selected.length).toBe(1);
52 | expect(selected[0].textContent).toBe('Create new reversed thing "ra"');
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/test/Tag_spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 | import TestUtils from 'react-addons-test-utils';
5 | import jsdomReact from './jsdomReact';
6 |
7 | import Tag from '../src/Tag.jsx';
8 |
9 | function tag(props) {
10 | return TestUtils.renderIntoDocument(React.createElement(Tag, props));
11 | }
12 |
13 | function findContentSpans(t) {
14 | return TestUtils.scryRenderedDOMComponentsWithTag(t, 'span');
15 | }
16 |
17 | function props(props) {
18 | return Object.assign({}, {
19 | input: 'foo',
20 | text: 'fooable',
21 | }, props);
22 | }
23 |
24 | describe('Tag', () => {
25 | jsdomReact();
26 |
27 | describe('with the input at the start', () => {
28 | it('should have two spans, the first is the match', () => {
29 | let t = tag({
30 | input: 'foo',
31 | text: 'fooable',
32 | });
33 |
34 | let spans = findContentSpans(t);
35 | expect(spans.length).toBe(2);
36 | expect(spans[0].className).toBe('cti__tag__content--match');
37 | expect(spans[0].innerHTML).toBe('foo');
38 | expect(spans[1].className).toBe('cti__tag__content--regular');
39 | expect(spans[1].innerHTML).toBe('able');
40 | });
41 | });
42 |
43 | describe('with the input at the middle', () => {
44 | it('should have three spans, the second is the match', () => {
45 | let t = tag({
46 | input: 'oab',
47 | text: 'fooable',
48 | });
49 |
50 | let spans = findContentSpans(t);
51 | expect(spans.length).toBe(3);
52 | expect(spans[0].className).toBe('cti__tag__content--regular');
53 | expect(spans[0].innerHTML).toBe('fo');
54 | expect(spans[1].className).toBe('cti__tag__content--match');
55 | expect(spans[1].innerHTML).toBe('oab');
56 | expect(spans[2].className).toBe('cti__tag__content--regular');
57 | expect(spans[2].innerHTML).toBe('le');
58 | });
59 | });
60 |
61 | describe('with the input at the end', () => {
62 | it('should have two spans, the last is the match', () => {
63 | let t = tag({
64 | input: 'able',
65 | text: 'fooable',
66 | });
67 |
68 | let spans = findContentSpans(t);
69 | expect(spans.length).toBe(2);
70 | expect(spans[0].className).toBe('cti__tag__content--regular');
71 | expect(spans[0].innerHTML).toBe('foo');
72 | expect(spans[1].className).toBe('cti__tag__content--match');
73 | expect(spans[1].innerHTML).toBe('able');
74 | });
75 | });
76 |
77 | describe('if the tag is addable', () => {
78 | it('should trigger onAdd callback', done => {
79 | let added = false;
80 | let t = tag(props({
81 | addable: true,
82 | onAdd: () => {
83 | added = true;
84 | }
85 | }));
86 |
87 | TestUtils.Simulate.click(ReactDOM.findDOMNode(t));
88 |
89 | setImmediate(() => {
90 | expect(added).toBe(true);
91 | done();
92 | });
93 | });
94 | });
95 |
96 | describe('if the tag is deletable', () => {
97 | function findDelete(t) {
98 | return TestUtils.findRenderedDOMComponentWithClass(t, 'cti__tag__delete')
99 | ;
100 | }
101 |
102 | it('should trigger onDelete callback', done => {
103 | let deleted = false;
104 | let t = tag(props({
105 | deletable: true,
106 | onDelete: () => {
107 | deleted = true;
108 | }
109 | }));
110 |
111 | TestUtils.Simulate.click(findDelete(t));
112 |
113 | setImmediate(() => {
114 | expect(deleted).toBe(true);
115 | done();
116 | });
117 | });
118 |
119 | describe('and addable', () => {
120 | it('should trigger onDelete callback but not onAdd', done => {
121 | let added = false;
122 | let deleted = false;
123 | let t = tag(props({
124 | addable: true,
125 | deletable: true,
126 | onAdd: () => {
127 | added = true;
128 | },
129 | onDelete: () => {
130 | deleted = true;
131 | }
132 | }));
133 |
134 | TestUtils.Simulate.click(findDelete(t));
135 |
136 | setImmediate(() => {
137 | expect(added).toBe(false);
138 | expect(deleted).toBe(true);
139 | done();
140 | });
141 | });
142 | });
143 | });
144 |
145 | describe('when is selected', () => {
146 | it('should have the class cti-selected', () => {
147 | let t = tag(props({
148 | selected: true
149 | }));
150 |
151 | expect(ReactDOM.findDOMNode(t).className.split(' ')[1]).toBe('cti-selected');
152 | });
153 | });
154 |
155 | describe('when a getTagStyle func is provided', () => {
156 | it('should apply the correct styles', () => {
157 | const style = {
158 | base: {
159 | color: "red"
160 | },
161 | content: {
162 | color: "green"
163 | },
164 | "delete": {
165 | color: "blue"
166 | }
167 | }
168 |
169 | const t = tag(props({
170 | style
171 | }))
172 |
173 | const domNode = ReactDOM.findDOMNode(t)
174 | expect(domNode.style.color).toBe("red")
175 |
176 | })
177 | })
178 | });
179 |
--------------------------------------------------------------------------------
/test/jsdomReact.js:
--------------------------------------------------------------------------------
1 | import ExecutionEnvironment from 'exenv';
2 | import React from 'react';
3 | import jsdom from 'mocha-jsdom';
4 |
5 | export default function jsdomReact() {
6 | jsdom();
7 | ExecutionEnvironment.canUseDOM = true;
8 | }
9 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 |
3 | module.exports = {
4 | entry: './src/index.js',
5 |
6 | output: {
7 | library: 'CategorizedTagInput',
8 | libraryTarget: 'umd',
9 | filename: 'categorized-tag-input.js'
10 | },
11 |
12 | externals: [
13 | {
14 | react: {
15 | root: 'React',
16 | commonjs2: 'react',
17 | commonjs: 'react',
18 | amd: 'react'
19 | }
20 | }
21 | ],
22 |
23 | devtool: 'source-map',
24 |
25 | module: {
26 | loaders: [
27 | { test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel' }
28 | ]
29 | },
30 |
31 | node: {
32 | Buffer: false
33 | },
34 |
35 | plugins: [
36 | new webpack.optimize.OccurenceOrderPlugin(),
37 | new webpack.DefinePlugin({
38 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
39 | }),
40 | new webpack.optimize.UglifyJsPlugin({
41 | compress: {
42 | warnings: false
43 | }
44 | })
45 | ]
46 | };
47 |
--------------------------------------------------------------------------------
/webpack.test.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var path = require('path');
3 |
4 | module.exports = {
5 | entry: [
6 | 'webpack-dev-server/client?http://localhost:5000',
7 | 'webpack/hot/dev-server',
8 | './index'
9 | ],
10 | output: {
11 | path: path.join(__dirname, '/'),
12 | filename: 'bundle.js',
13 | publicPath: '/'
14 | },
15 | resolve: {
16 | extensions: ['', '.js', '.jsx']
17 | },
18 | devtool: 'eval-source-map',
19 | plugins: [
20 | new webpack.HotModuleReplacementPlugin(),
21 | new webpack.NoErrorsPlugin()
22 | ],
23 | module: {
24 | loaders: [
25 | {
26 | test: /\.jsx?$/,
27 | loaders: ['react-hot', 'babel'],
28 | exclude: /node_modules/
29 | }
30 | ]
31 | }
32 | };
33 |
--------------------------------------------------------------------------------