├── example ├── index.js ├── Main.jsx ├── BackspaceDeletion.jsx ├── codeSamples │ ├── backspaceDeletion.txt │ ├── tagRejection.txt │ ├── asyncLoading.txt │ └── customRendering.txt ├── TagRejection.jsx ├── Async.jsx ├── styles.scss ├── App.jsx └── CustomRendering.jsx ├── .travis.yml ├── .gitignore ├── src ├── constants.js ├── index.js ├── utils.js ├── cache.js ├── TagBox.jsx ├── Tag.jsx ├── driver.js ├── TagBoxAsync.jsx ├── TagManager.js ├── TagBoxContainer.jsx └── Autocomplete.jsx ├── .babelrc ├── spec ├── .eslintrc ├── Tag.spec.jsx ├── TagBox.spec.jsx ├── TagManager.spec.js └── Autocomplete.spec.jsx ├── lib ├── constants.js ├── cache.js ├── utils.js ├── index.js ├── driver.js ├── Tag.js ├── TagBox.js ├── TagManager.js ├── TagBoxAsync.js ├── TagBoxContainer.js └── Autocomplete.js ├── index.html ├── .eslintrc ├── webpack.config.js ├── LICENSE.md ├── package.json └── README.md /example/index.js: -------------------------------------------------------------------------------- 1 | export Main from './Main' 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | *.swn 4 | node_modules/ 5 | *.log 6 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export default 'react-tag-select/TAG_REJECTED' 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"] 3 | } 4 | 5 | -------------------------------------------------------------------------------- /spec/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "mocha": true 5 | } 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export TagBox from './TagBox' 2 | export TagBoxAsync from './TagBoxAsync' 3 | export TAG_REJECTED from './constants' 4 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = 'react-tag-select/TAG_REJECTED'; -------------------------------------------------------------------------------- /example/Main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | ReactDOM.render(( 6 | 7 | ), document.getElementById('app')) 8 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | 3 | const TagProp = PropTypes.shape({ 4 | value: PropTypes.any.isRequired, 5 | label: PropTypes.string.isRequired 6 | }) 7 | 8 | export default TagProp 9 | -------------------------------------------------------------------------------- /src/cache.js: -------------------------------------------------------------------------------- 1 | export default function cache() { 2 | let map = {} 3 | 4 | const get = query => map[query] 5 | 6 | const add = (input, tags) => { 7 | map[input] = tags 8 | return tags 9 | } 10 | 11 | const clear = () => { 12 | map = {} 13 | } 14 | 15 | return { get, add, clear } 16 | } 17 | -------------------------------------------------------------------------------- /src/TagBox.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import TagProp from './utils' 3 | import TagContainer from './TagBoxContainer' 4 | 5 | export default class TagBox extends TagContainer { 6 | static propTypes = { 7 | tags: PropTypes.arrayOf(TagProp) 8 | } 9 | 10 | tags() { 11 | return this.props.tags 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = cache; 7 | function cache() { 8 | var map = {}; 9 | 10 | var get = function get(query) { 11 | return map[query]; 12 | }; 13 | 14 | var add = function add(input, tags) { 15 | map[input] = tags; 16 | return tags; 17 | }; 18 | 19 | var clear = function clear() { 20 | map = {}; 21 | }; 22 | 23 | return { get: get, add: add, clear: clear }; 24 | } -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _propTypes = require('prop-types'); 8 | 9 | var _propTypes2 = _interopRequireDefault(_propTypes); 10 | 11 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 12 | 13 | var TagProp = _propTypes2.default.shape({ 14 | value: _propTypes2.default.any.isRequired, 15 | label: _propTypes2.default.string.isRequired 16 | }); 17 | 18 | exports.default = TagProp; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ReactTagBox 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Tag.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import TagProp from './utils' 4 | 5 | const defaultRender = (tag, remove) => ( 6 |
  • 7 | 8 | {tag.label} 9 | 10 | 13 |
  • 14 | ) 15 | 16 | export default function Tag({ tag, removeTag, render = defaultRender }) { 17 | return render(tag, () => removeTag(tag)) 18 | } 19 | 20 | Tag.propTypes = { 21 | tag: TagProp.isRequired, 22 | removeTag: PropTypes.func.isRequired 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": "airbnb", 6 | "parser": "babel-eslint", 7 | "plugins": ["react"], 8 | "settings": { 9 | "import/resolver": { 10 | "babel-module": { 11 | "extensions": [".js", ".jsx"] 12 | } 13 | } 14 | }, 15 | "rules": { 16 | "new-cap": [2, { "capIsNewExceptions": ["List", "Map", "Set"] }], 17 | "semi": [2, "never"], 18 | "comma-dangle": [2, "never"], 19 | "no-constant-condition": ["off"], 20 | "no-unused-vars": ["error", { "varsIgnorePattern": "^_" }], 21 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], 22 | "import/no-named-as-default": ["off"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/driver.js: -------------------------------------------------------------------------------- 1 | const ENTER = 13 2 | const LEFT = 37 3 | const UP = 38 4 | const RIGHT = 39 5 | const DOWN = 40 6 | const TAB = 9 7 | const ESC = 27 8 | const BKSPC = 8 9 | 10 | export default function drive(which, tagManager) { 11 | const execute = action => () => action.apply(tagManager) 12 | 13 | const eventMap = { 14 | [ENTER]: execute(tagManager.create), 15 | [RIGHT]: execute(tagManager.next), 16 | [DOWN]: execute(tagManager.next), 17 | [UP]: execute(tagManager.prev), 18 | [LEFT]: execute(tagManager.prev), 19 | [TAB]: execute(tagManager.select), 20 | [ESC]: execute(tagManager.clear), 21 | [BKSPC]: execute(tagManager.deleteLast) 22 | } 23 | 24 | return eventMap[which] 25 | } 26 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.TAG_REJECTED = exports.TagBoxAsync = exports.TagBox = undefined; 7 | 8 | var _TagBox2 = require('./TagBox'); 9 | 10 | var _TagBox3 = _interopRequireDefault(_TagBox2); 11 | 12 | var _TagBoxAsync2 = require('./TagBoxAsync'); 13 | 14 | var _TagBoxAsync3 = _interopRequireDefault(_TagBoxAsync2); 15 | 16 | var _constants = require('./constants'); 17 | 18 | var _constants2 = _interopRequireDefault(_constants); 19 | 20 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 21 | 22 | exports.TagBox = _TagBox3.default; 23 | exports.TagBoxAsync = _TagBoxAsync3.default; 24 | exports.TAG_REJECTED = _constants2.default; -------------------------------------------------------------------------------- /spec/Tag.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Enzyme, { shallow } from 'enzyme' 3 | import Adapter from 'enzyme-adapter-react-16' 4 | import expect, { createSpy } from 'expect' 5 | import Tag from '../src/Tag' 6 | 7 | Enzyme.configure({ adapter: new Adapter() }) 8 | 9 | describe('', () => { 10 | const props = { 11 | tag: { value: 'beer', label: 'beer' }, 12 | removeTag: createSpy() 13 | } 14 | 15 | const wrapper = shallow( 16 | 17 | ) 18 | 19 | context('when remove button is clicked', () => { 20 | const button = wrapper.find('button.remove') 21 | button.simulate('click') 22 | 23 | it('calls removeTag', () => { 24 | expect(props.removeTag).toHaveBeenCalledWith(props.tag) 25 | }) 26 | }) 27 | }) 28 | 29 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | var BrowserSyncPlugin = require('browser-sync-webpack-plugin') 4 | 5 | module.exports = { 6 | entry: './example/index.js', 7 | output: { path: __dirname, filename: 'bundle.js' }, 8 | devtool: "eval-source-map", 9 | resolve: { 10 | extensions: ['', '.js', '.jsx'] 11 | }, 12 | module: { 13 | loaders: [{ 14 | test: /.jsx?$/, 15 | loader: 'babel-loader', 16 | exclude: /node_modules/ 17 | }, { 18 | test: /\.scss$/, 19 | loaders: ["style", "css", "sass"] 20 | }] 21 | }, 22 | devServer: { 23 | historyApiFallback: true 24 | }, 25 | plugins: [ 26 | new BrowserSyncPlugin({ 27 | host: 'localhost', 28 | port: 3000, 29 | proxy: 'http://localhost:8080/' 30 | } 31 | )] 32 | } 33 | 34 | -------------------------------------------------------------------------------- /spec/TagBox.spec.jsx: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React from 'react' 3 | import Enzyme, { shallow } from 'enzyme' 4 | import Adapter from 'enzyme-adapter-react-16' 5 | import expect, { createSpy } from 'expect' 6 | import TagBox from '../src/TagBox' 7 | import Tag from '../src/Tag' 8 | 9 | Enzyme.configure({ adapter: new Adapter() }) 10 | 11 | const sampleTags = List( 12 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 13 | label: t, 14 | value: t 15 | })) 16 | ) 17 | 18 | describe('', () => { 19 | it('renders a for each selected tag', () => { 20 | const props = { 21 | tags: sampleTags.toJS(), 22 | selected: sampleTags.take(3).toJS(), 23 | onSelect: createSpy(), 24 | removeTag: createSpy() 25 | } 26 | 27 | const wrapper = shallow( 28 | 29 | ) 30 | 31 | expect(wrapper.find(Tag).length).toEqual(props.selected.length) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Sam Slotsky 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/TagBoxAsync.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import makeCache from './cache' 3 | import TagContainer from './TagBoxContainer' 4 | 5 | export default class TagBoxAsync extends TagContainer { 6 | static propTypes = { 7 | fetch: PropTypes.func.isRequired 8 | } 9 | 10 | state = { 11 | tags: [], 12 | tag: '', 13 | considering: null, 14 | loading: false 15 | } 16 | 17 | cache = makeCache() 18 | 19 | tags() { 20 | return this.state.tags 21 | } 22 | 23 | loading() { 24 | return this.state.loading 25 | } 26 | 27 | createTag() { 28 | const { tag } = this.state 29 | if (tag) { 30 | this.select({ label: tag }) 31 | this.cache.clear() 32 | } 33 | } 34 | 35 | tagUpdater() { 36 | return e => { 37 | const input = e.target.value 38 | this.setState({ tag: input }) 39 | 40 | const matches = this.cache.get(input) 41 | if (matches) { 42 | return this.setState({ tags: matches }) 43 | } 44 | 45 | this.setState({ loading: true }) 46 | return this.props.fetch(input).then(tags => { 47 | this.setState({ 48 | tags: this.cache.add(input, tags), 49 | loading: false 50 | }) 51 | }) 52 | } 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /lib/driver.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = drive; 7 | 8 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 9 | 10 | var ENTER = 13; 11 | var LEFT = 37; 12 | var UP = 38; 13 | var RIGHT = 39; 14 | var DOWN = 40; 15 | var TAB = 9; 16 | var ESC = 27; 17 | var BKSPC = 8; 18 | 19 | function drive(which, tagManager) { 20 | var _eventMap; 21 | 22 | var execute = function execute(action) { 23 | return function () { 24 | return action.apply(tagManager); 25 | }; 26 | }; 27 | 28 | var eventMap = (_eventMap = {}, _defineProperty(_eventMap, ENTER, execute(tagManager.create)), _defineProperty(_eventMap, RIGHT, execute(tagManager.next)), _defineProperty(_eventMap, DOWN, execute(tagManager.next)), _defineProperty(_eventMap, UP, execute(tagManager.prev)), _defineProperty(_eventMap, LEFT, execute(tagManager.prev)), _defineProperty(_eventMap, TAB, execute(tagManager.select)), _defineProperty(_eventMap, ESC, execute(tagManager.clear)), _defineProperty(_eventMap, BKSPC, execute(tagManager.deleteLast)), _eventMap); 29 | 30 | return eventMap[which]; 31 | } -------------------------------------------------------------------------------- /example/BackspaceDeletion.jsx: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React, { Component } from 'react' 3 | import { TagBox } from '../src' 4 | import './styles.scss' 5 | 6 | const sampleTags = List( 7 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 8 | label: t, 9 | value: t 10 | })) 11 | ) 12 | 13 | export default class BackspaceDeletion extends Component { 14 | state = { 15 | tags: sampleTags, 16 | selected: sampleTags.take(1) 17 | } 18 | 19 | render() { 20 | const { tags, selected } = this.state 21 | const onSelect = tag => { 22 | const newTag = { 23 | label: tag.label, 24 | value: tag.value || tag.label 25 | } 26 | 27 | this.setState({ 28 | selected: selected.push(newTag) 29 | }) 30 | } 31 | 32 | const remove = tag => { 33 | this.setState({ 34 | selected: selected.filter(t => t.value !== tag.value) 35 | }) 36 | } 37 | 38 | const placeholder = selected.isEmpty() ? '' : 39 | "Use the backspace key to delete the last tag" 40 | 41 | return ( 42 | 50 | ) 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /example/codeSamples/backspaceDeletion.txt: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React, { Component } from 'react' 3 | import { TagBox } from '../src' 4 | import './styles.scss' 5 | 6 | const sampleTags = List( 7 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 8 | label: t, 9 | value: t 10 | })) 11 | ) 12 | 13 | export default class BackspaceDeletion extends Component { 14 | state = { 15 | tags: sampleTags, 16 | selected: sampleTags.take(1) 17 | } 18 | 19 | render() { 20 | const { tags, selected } = this.state 21 | const onSelect = tag => { 22 | const newTag = { 23 | label: tag.label, 24 | value: tag.value || tag.label 25 | } 26 | 27 | this.setState({ 28 | selected: selected.push(newTag) 29 | }) 30 | } 31 | 32 | const remove = tag => { 33 | this.setState({ 34 | selected: selected.filter(t => t.value !== tag.value) 35 | }) 36 | } 37 | 38 | const placeholder = selected.isEmpty() ? '' : 39 | "Use the backspace key to delete the last tag" 40 | 41 | return ( 42 | 50 | ) 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /example/TagRejection.jsx: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React, { Component } from 'react' 3 | import { TagBox, TAG_REJECTED } from '../src' 4 | import './styles.scss' 5 | 6 | const sampleTags = List( 7 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 8 | label: t, 9 | value: t 10 | })) 11 | ) 12 | 13 | export default class TagRejection extends Component { 14 | state = { 15 | tags: sampleTags, 16 | selected: sampleTags.take(1) 17 | } 18 | 19 | render() { 20 | const { tags, selected } = this.state 21 | const onSelect = tag => { 22 | if (tag.label.includes('@')) { 23 | return TAG_REJECTED 24 | } 25 | 26 | const newTag = { 27 | label: tag.label, 28 | value: tag.value || tag.label 29 | } 30 | 31 | return this.setState({ 32 | selected: selected.push(newTag) 33 | }) 34 | } 35 | 36 | const remove = tag => { 37 | this.setState({ 38 | selected: selected.filter(t => t.value !== tag.value) 39 | }) 40 | } 41 | 42 | return ( 43 | 51 | ) 52 | } 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /example/codeSamples/tagRejection.txt: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React, { Component } from 'react' 3 | import { TagBox, TAG_REJECTED } from '../src' 4 | import './styles.scss' 5 | 6 | const sampleTags = List( 7 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 8 | label: t, 9 | value: t 10 | })) 11 | ) 12 | 13 | export default class TagRejection extends Component { 14 | state = { 15 | tags: sampleTags, 16 | selected: sampleTags.take(1) 17 | } 18 | 19 | render() { 20 | const { tags, selected } = this.state 21 | const onSelect = tag => { 22 | if (tag.label.includes('@')) { 23 | return TAG_REJECTED 24 | } 25 | 26 | const newTag = { 27 | label: tag.label, 28 | value: tag.value || tag.label 29 | } 30 | 31 | return this.setState({ 32 | selected: selected.push(newTag) 33 | }) 34 | } 35 | 36 | const remove = tag => { 37 | this.setState({ 38 | selected: selected.filter(t => t.value !== tag.value) 39 | }) 40 | } 41 | 42 | return ( 43 | 51 | ) 52 | } 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /lib/Tag.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = Tag; 7 | 8 | var _react = require('react'); 9 | 10 | var _react2 = _interopRequireDefault(_react); 11 | 12 | var _propTypes = require('prop-types'); 13 | 14 | var _propTypes2 = _interopRequireDefault(_propTypes); 15 | 16 | var _utils = require('./utils'); 17 | 18 | var _utils2 = _interopRequireDefault(_utils); 19 | 20 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 21 | 22 | var defaultRender = function defaultRender(tag, remove) { 23 | return _react2.default.createElement( 24 | 'li', 25 | { className: 'tag-box-pill', key: tag.value }, 26 | _react2.default.createElement( 27 | 'span', 28 | { className: 'tag-box-pill-text' }, 29 | tag.label 30 | ), 31 | _react2.default.createElement( 32 | 'button', 33 | { type: 'button', className: 'remove', onClick: remove }, 34 | '\xD7' 35 | ) 36 | ); 37 | }; 38 | 39 | function Tag(_ref) { 40 | var tag = _ref.tag, 41 | removeTag = _ref.removeTag, 42 | _ref$render = _ref.render, 43 | render = _ref$render === undefined ? defaultRender : _ref$render; 44 | 45 | return render(tag, function () { 46 | return removeTag(tag); 47 | }); 48 | } 49 | 50 | Tag.propTypes = { 51 | tag: _utils2.default.isRequired, 52 | removeTag: _propTypes2.default.func.isRequired 53 | }; -------------------------------------------------------------------------------- /example/Async.jsx: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React, { Component } from 'react' 3 | import { TagBoxAsync } from '../src' 4 | import './styles.scss' 5 | 6 | const sampleTags = List( 7 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 8 | label: t, 9 | value: t 10 | })) 11 | ) 12 | 13 | const fetch = input => { 14 | return new Promise(resolve => { 15 | setTimeout(() => { 16 | resolve(sampleTags.filter(t => t.label.includes(input)).toJS()) 17 | }, 1500) 18 | }) 19 | } 20 | 21 | export default class Async extends Component { 22 | state = { 23 | selected: sampleTags.take(1) 24 | } 25 | 26 | render() { 27 | const { selected } = this.state 28 | const onSelect = tag => { 29 | const newTag = { 30 | label: tag.label, 31 | value: tag.value || tag.label 32 | } 33 | 34 | if (selected.map(t => t.value).includes(newTag.value)) { 35 | return 36 | } 37 | 38 | this.setState({ 39 | selected: selected.push(newTag) 40 | }) 41 | } 42 | 43 | const remove = tag => { 44 | this.setState({ 45 | selected: selected.filter(t => t.value !== tag.value) 46 | }) 47 | } 48 | 49 | const placeholder = selected.isEmpty() ? '' : 50 | "Use the backspace key to delete the last tag" 51 | 52 | return ( 53 | 61 | ) 62 | } 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /example/codeSamples/asyncLoading.txt: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React, { Component } from 'react' 3 | import { TagBoxAsync } from '../src' 4 | import './styles.scss' 5 | 6 | const sampleTags = List( 7 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 8 | label: t, 9 | value: t 10 | })) 11 | ) 12 | 13 | const fetch = input => { 14 | return new Promise(resolve => { 15 | setTimeout(() => { 16 | resolve(sampleTags.filter(t => t.label.includes(input)).toJS()) 17 | }, 1500) 18 | }) 19 | } 20 | 21 | export default class Async extends Component { 22 | state = { 23 | selected: sampleTags.take(1) 24 | } 25 | 26 | render() { 27 | const { selected } = this.state 28 | const onSelect = tag => { 29 | const newTag = { 30 | label: tag.label, 31 | value: tag.value || tag.label 32 | } 33 | 34 | if (selected.map(t => t.value).includes(newTag.value)) { 35 | return 36 | } 37 | 38 | this.setState({ 39 | selected: selected.push(newTag) 40 | }) 41 | } 42 | 43 | const remove = tag => { 44 | this.setState({ 45 | selected: selected.filter(t => t.value !== tag.value) 46 | }) 47 | } 48 | 49 | const placeholder = selected.isEmpty() ? '' : 50 | "Use the backspace key to delete the last tag" 51 | 52 | return ( 53 | 61 | ) 62 | } 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/TagManager.js: -------------------------------------------------------------------------------- 1 | export default class TagManager { 2 | constructor(e, tagBox) { 3 | this.event = e 4 | this.tagBox = tagBox 5 | this.tagBoxData = { ...tagBox.props, ...tagBox.state } 6 | } 7 | 8 | execute(action) { 9 | this.event.preventDefault() 10 | action() 11 | } 12 | 13 | prev() { 14 | if (this.tagBoxData.considering) { 15 | this.execute(() => this.tagBox.autocomplete.considerPrevious()) 16 | } 17 | } 18 | 19 | next() { 20 | this.execute(() => this.tagBox.autocomplete.considerNext()) 21 | } 22 | 23 | create() { 24 | const { considering, tags, tag } = this.tagBoxData 25 | 26 | this.execute(() => { 27 | if (considering) { 28 | this.tagBox.select(considering) 29 | } else { 30 | const existingTag = tags.find(t => t.label === tag) 31 | if (existingTag) { 32 | this.tagBox.select(existingTag) 33 | } else { 34 | this.tagBox.createTag() 35 | } 36 | } 37 | }) 38 | } 39 | 40 | select() { 41 | const { considering, tag } = this.tagBoxData 42 | 43 | this.execute(() => { 44 | if (tag) { 45 | if (considering) { 46 | this.tagBox.select(considering) 47 | } else { 48 | this.tagBox.createTag() 49 | } 50 | } 51 | }) 52 | } 53 | 54 | clear() { 55 | this.execute(() => this.tagBox.setState({ tag: '', considering: null })) 56 | } 57 | 58 | deleteLast() { 59 | const { selected, tag, backspaceDelete, removeTag } = this.tagBoxData 60 | 61 | if (tag || !backspaceDelete) { 62 | return 63 | } 64 | 65 | this.execute(() => { 66 | removeTag(selected[selected.length - 1]) 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /example/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../node_modules/codemirror/theme/erlang-dark.css'; 2 | @import '../node_modules/codemirror/lib/codemirror.css'; 3 | 4 | small button { 5 | float: right; 6 | } 7 | 8 | a { 9 | cursor: pointer; 10 | } 11 | 12 | .col-1 { 13 | margin-right: 1rem; 14 | &.right { 15 | width: 50%; 16 | } 17 | } 18 | 19 | .CodeMirror { 20 | height: auto; 21 | padding: 1rem; 22 | border-radius: 0.25rem; 23 | } 24 | 25 | ul { 26 | padding-left: 0px; 27 | } 28 | 29 | li { 30 | list-style: none; 31 | } 32 | 33 | ul.tag-box-pills { 34 | padding-left: 1rem; 35 | 36 | li { 37 | display: inline-block; 38 | margin-right: 1rem; 39 | margin-top: 1rem; 40 | background-color: gold; 41 | border-radius: 0.25rem; 42 | 43 | .remove { 44 | margin-left: 0.5rem; 45 | } 46 | 47 | .tag-box-pill-text { 48 | margin-left: 0.5rem; 49 | } 50 | 51 | button { 52 | padding: 0.25rem 0.75rem; 53 | } 54 | 55 | &.system { 56 | background-color: chocolate; 57 | } 58 | } 59 | } 60 | 61 | ul.autocomplete { 62 | border-bottom: 1px solid #7e69c6; 63 | padding: 1rem; 64 | 65 | li { 66 | cursor: pointer; 67 | 68 | &.considering { 69 | background-color: #7e69c6; 70 | } 71 | 72 | .option-text { 73 | padding-left: 0.5rem; 74 | } 75 | } 76 | } 77 | 78 | .tag-box { 79 | border-top: 1px solid #7e69c6; 80 | border-left: 1px solid #7e69c6; 81 | border-right: 1px solid #7e69c6; 82 | border-radius: 0.25rem 0.25rem 0 0; 83 | 84 | input { 85 | padding: 1rem; 86 | border-bottom: 1px solid #7e69c6; 87 | border-top: none; 88 | border-left: none; 89 | border-right: none; 90 | outline: none; 91 | background-color: transparent; 92 | width: 100%; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /example/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Codemirror from 'react-codemirror' 3 | import 'codemirror/mode/jsx/jsx' 4 | 5 | import BackspaceDeletion from './BackspaceDeletion' 6 | import CustomRendering from './CustomRendering' 7 | import TagRejection from './TagRejection' 8 | import Async from './Async' 9 | import './styles.scss' 10 | 11 | import backspaceDeletion from 'raw!./codeSamples/backspaceDeletion.txt' 12 | import customRendering from 'raw!./codeSamples/customRendering.txt' 13 | import tagRejection from 'raw!./codeSamples/tagRejection.txt' 14 | import asyncLoading from 'raw!./codeSamples/asyncLoading.txt' 15 | 16 | export default class App extends Component { 17 | state = { 18 | sample: backspaceDeletion 19 | } 20 | 21 | render() { 22 | const config = { 23 | mode: 'javascript', 24 | theme: 'erlang-dark', 25 | readOnly: true 26 | } 27 | 28 | const seeCode = code => () => 29 | this.setState({ sample: code }) 30 | 31 | return ( 32 |
    33 |
    34 |

    Backspace Deletion

    35 |
    36 | 37 |
    38 | 39 |

    Custom Rendering

    40 |
    41 | 42 |
    43 | 44 |

    Tag Rejection

    45 |
    46 | 47 |
    48 | 49 |

    Async Loading

    50 |
    51 | 52 |
    53 | 54 |
    55 |
    56 |

    Check the code:

    57 | 58 |
    59 |
    60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /example/CustomRendering.jsx: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React, { Component } from 'react' 3 | import { TagBox } from '../src' 4 | import classNames from 'classnames' 5 | import './styles.scss' 6 | 7 | const systemTags = List( 8 | ['node', 'javascript', 'es6'].map(t => ({ 9 | label: t, 10 | value: t, 11 | system: true 12 | })) 13 | ) 14 | 15 | const userTags = List( 16 | ['foo', 'bar', 'baz'].map(t => ({ 17 | label: t, 18 | value: t 19 | })) 20 | ) 21 | 22 | const sampleTags = systemTags.concat(userTags) 23 | 24 | export default class CustomRendering extends Component { 25 | state = { 26 | tags: sampleTags, 27 | selected: sampleTags.take(4) 28 | } 29 | 30 | render() { 31 | const { tags, selected } = this.state 32 | const onSelect = tag => { 33 | const newTag = { 34 | label: tag.label, 35 | value: tag.value || tag.label 36 | } 37 | 38 | this.setState({ 39 | selected: selected.push(newTag) 40 | }) 41 | } 42 | 43 | const remove = tag => { 44 | this.setState({ 45 | selected: selected.filter(t => t.value !== tag.value) 46 | }) 47 | } 48 | 49 | const renderTag = (tag, remove) => { 50 | const css = classNames('tag-box-pill', { system: tag.system }) 51 | 52 | const btn = tag.system ? ( 53 | 56 | ) : ( 57 | 60 | ) 61 | 62 | return ( 63 |
  • 64 | 65 | {tag.label} 66 | 67 | {btn} 68 |
  • 69 | ) 70 | } 71 | 72 | return ( 73 | 81 | ) 82 | } 83 | } 84 | 85 | 86 | -------------------------------------------------------------------------------- /example/codeSamples/customRendering.txt: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable' 2 | import React, { Component } from 'react' 3 | import { TagBox } from '../src' 4 | import classNames from 'classnames' 5 | import './styles.scss' 6 | 7 | const systemTags = List( 8 | ['node', 'javascript', 'es6'].map(t => ({ 9 | label: t, 10 | value: t, 11 | system: true 12 | })) 13 | ) 14 | 15 | const userTags = List( 16 | ['foo', 'bar', 'baz'].map(t => ({ 17 | label: t, 18 | value: t 19 | })) 20 | ) 21 | 22 | const sampleTags = systemTags.concat(userTags) 23 | 24 | export default class CustomRendering extends Component { 25 | state = { 26 | tags: sampleTags, 27 | selected: sampleTags.take(4) 28 | } 29 | 30 | render() { 31 | const { tags, selected } = this.state 32 | const onSelect = tag => { 33 | const newTag = { 34 | label: tag.label, 35 | value: tag.value || tag.label 36 | } 37 | 38 | this.setState({ 39 | selected: selected.push(newTag) 40 | }) 41 | } 42 | 43 | const remove = tag => { 44 | this.setState({ 45 | selected: selected.filter(t => t.value !== tag.value) 46 | }) 47 | } 48 | 49 | const renderTag = (tag, remove) => { 50 | const css = classNames('tag-box-pill', { system: tag.system }) 51 | 52 | const btn = tag.system ? ( 53 | 56 | ) : ( 57 | 60 | ) 61 | 62 | return ( 63 |
  • 64 | 65 | {tag.label} 66 | 67 | {btn} 68 |
  • 69 | ) 70 | } 71 | 72 | return ( 73 | 81 | ) 82 | } 83 | } 84 | 85 | 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tag-box", 3 | "version": "1.5.0", 4 | "description": "A tagging component in React. Select, create, and delete tags.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --progress --colors -w", 8 | "spec": "mocha './spec/**/*.spec.js*' --compilers js:babel-register --recursive", 9 | "lint": "eslint ./src/** ./spec/**/* --ext=js,jsx --fix", 10 | "compile": "babel -d ./lib/ ./src/", 11 | "test": "npm run lint && npm run spec", 12 | "build-example": "webpack ./src/index.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/sslotsky/react-tag-box.git" 17 | }, 18 | "keywords": [ 19 | "react", 20 | "tags", 21 | "tagging" 22 | ], 23 | "author": "Sam Slotsky", 24 | "license": "MIT", 25 | "dependencies": { 26 | "classnames": "^2.2.5", 27 | "enzyme-adapter-react-16": "^1.1.0", 28 | "prop-types": "^15.6.0" 29 | }, 30 | "peerDependencies": { 31 | "react": "^0.14.8 || ^15.1.0 || ^16.0.0" 32 | }, 33 | "devDependencies": { 34 | "babel-cli": "^6.18.0", 35 | "babel-core": "^6.13.2", 36 | "babel-eslint": "^6.1.2", 37 | "babel-loader": "^6.2.4", 38 | "babel-plugin-module-resolver": "^2.2.0", 39 | "babel-preset-es2015": "^6.13.2", 40 | "babel-preset-react": "^6.11.1", 41 | "babel-preset-stage-0": "^6.5.0", 42 | "babel-register": "^6.11.6", 43 | "browser-sync": "^2.14.0", 44 | "browser-sync-webpack-plugin": "^1.1.2", 45 | "css-loader": "^0.25.0", 46 | "enzyme": "^3.2.0", 47 | "eslint": "^3.2.2", 48 | "eslint-config-airbnb": "^10.0.0", 49 | "eslint-import-resolver-babel-module": "^2.0.1", 50 | "eslint-plugin-import": "^1.13.0", 51 | "eslint-plugin-jsx-a11y": "^2.1.0", 52 | "eslint-plugin-react": "^6.0.0", 53 | "expect": "^1.20.2", 54 | "immutable": "^3.8.1", 55 | "mocha": "^3.1.2", 56 | "node-sass": "^4.7.2", 57 | "raw-loader": "^0.5.1", 58 | "react": "^16.2.0", 59 | "react-codemirror": "^1.0.0", 60 | "react-dom": "^16.2.0", 61 | "sass-loader": "^4.0.2", 62 | "style-loader": "^0.13.1", 63 | "webpack": "^1.13.1", 64 | "webpack-dev-server": "^1.14.1" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/TagBox.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _propTypes = require('prop-types'); 10 | 11 | var _propTypes2 = _interopRequireDefault(_propTypes); 12 | 13 | var _utils = require('./utils'); 14 | 15 | var _utils2 = _interopRequireDefault(_utils); 16 | 17 | var _TagBoxContainer = require('./TagBoxContainer'); 18 | 19 | var _TagBoxContainer2 = _interopRequireDefault(_TagBoxContainer); 20 | 21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 22 | 23 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 24 | 25 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 26 | 27 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 28 | 29 | var TagBox = function (_TagContainer) { 30 | _inherits(TagBox, _TagContainer); 31 | 32 | function TagBox() { 33 | _classCallCheck(this, TagBox); 34 | 35 | return _possibleConstructorReturn(this, (TagBox.__proto__ || Object.getPrototypeOf(TagBox)).apply(this, arguments)); 36 | } 37 | 38 | _createClass(TagBox, [{ 39 | key: 'tags', 40 | value: function tags() { 41 | return this.props.tags; 42 | } 43 | }]); 44 | 45 | return TagBox; 46 | }(_TagBoxContainer2.default); 47 | 48 | TagBox.propTypes = { 49 | tags: _propTypes2.default.arrayOf(_utils2.default) 50 | }; 51 | exports.default = TagBox; -------------------------------------------------------------------------------- /src/TagBoxContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import TagProp from './utils' 4 | import TAG_REJECTED from './constants' 5 | import TagManager from './TagManager' 6 | import drive from './driver' 7 | import Tag from './Tag' 8 | import Autocomplete from './Autocomplete' 9 | 10 | export default class TagBoxContainer extends Component { 11 | static propTypes = { 12 | selected: PropTypes.arrayOf(TagProp), 13 | onSelect: PropTypes.func.isRequired, 14 | renderNewOption: PropTypes.func, 15 | removeTag: PropTypes.func.isRequired, 16 | renderTag: PropTypes.func, 17 | loadingText: PropTypes.string, 18 | selectedText: PropTypes.string, 19 | placeholder: PropTypes.string, 20 | search: PropTypes.func, 21 | exactMatch: PropTypes.func 22 | } 23 | 24 | static defaultProps = { 25 | renderNewOption: input => `Add ${input}...`, 26 | loadingText: 'Loading...', 27 | selectedText: 'Already Selected', 28 | placeHolder: '', 29 | search: (tag, input) => tag.label.includes(input), 30 | exactMatch: (tag, input) => tag.label === input 31 | } 32 | 33 | state = { 34 | tag: '', 35 | considering: null 36 | } 37 | 38 | tagUpdater() { 39 | return e => { 40 | this.setState({ tag: e.target.value }) 41 | } 42 | } 43 | 44 | select(tag) { 45 | const { selected } = this.props 46 | if (!tag || selected.map(t => t.label).includes(tag.label)) { 47 | return 48 | } 49 | 50 | const status = this.props.onSelect(tag) 51 | if (status !== TAG_REJECTED) { 52 | this.setState({ tag: '' }) 53 | } 54 | } 55 | 56 | blurTag() { 57 | const { tag, considering } = this.state 58 | 59 | if (considering) { 60 | this.select(considering) 61 | } else if (tag) { 62 | this.select({ label: tag }) 63 | } 64 | } 65 | 66 | createTag() { 67 | const { tag } = this.state 68 | if (tag) { 69 | this.select({ label: tag }) 70 | } 71 | } 72 | 73 | keyHandler() { 74 | return e => { 75 | const tagManager = new TagManager(e, this) 76 | const action = drive(e.which, tagManager) 77 | 78 | if (action) { 79 | action() 80 | } 81 | } 82 | } 83 | 84 | tags() { 85 | throw new Error('Component must implement the tags() method') 86 | } 87 | 88 | loading() { 89 | return false 90 | } 91 | 92 | render() { 93 | const consider = (option) => { 94 | this.setState({ considering: option }) 95 | } 96 | 97 | const { tag, considering } = this.state 98 | const { selected, removeTag, placeholder, renderTag, search, exactMatch } = this.props 99 | const pills = selected.map(t => ( 100 | 101 | )) 102 | 103 | return ( 104 |
    this.input.focus()}> 105 |
      106 | {pills} 107 |
    108 | { this.input = node }} 110 | value={tag} 111 | onChange={this.tagUpdater()} 112 | onKeyDown={this.keyHandler()} 113 | onBlur={() => this.blurTag()} 114 | placeholder={placeholder} 115 | /> 116 | { this.autocomplete = node }} 119 | input={tag} 120 | loading={this.loading()} 121 | tags={this.tags()} 122 | select={(t) => this.select(t)} 123 | create={() => this.createTag()} 124 | search={(t, input) => search(t, input)} 125 | exactMatch={(t, input) => exactMatch(t, input)} 126 | considering={considering} 127 | consider={consider} 128 | /> 129 |
    130 | ) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/TagManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 8 | 9 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 10 | 11 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 12 | 13 | var TagManager = function () { 14 | function TagManager(e, tagBox) { 15 | _classCallCheck(this, TagManager); 16 | 17 | this.event = e; 18 | this.tagBox = tagBox; 19 | this.tagBoxData = _extends({}, tagBox.props, tagBox.state); 20 | } 21 | 22 | _createClass(TagManager, [{ 23 | key: 'execute', 24 | value: function execute(action) { 25 | this.event.preventDefault(); 26 | action(); 27 | } 28 | }, { 29 | key: 'prev', 30 | value: function prev() { 31 | var _this = this; 32 | 33 | if (this.tagBoxData.considering) { 34 | this.execute(function () { 35 | return _this.tagBox.autocomplete.considerPrevious(); 36 | }); 37 | } 38 | } 39 | }, { 40 | key: 'next', 41 | value: function next() { 42 | var _this2 = this; 43 | 44 | this.execute(function () { 45 | return _this2.tagBox.autocomplete.considerNext(); 46 | }); 47 | } 48 | }, { 49 | key: 'create', 50 | value: function create() { 51 | var _this3 = this; 52 | 53 | var _tagBoxData = this.tagBoxData, 54 | considering = _tagBoxData.considering, 55 | tags = _tagBoxData.tags, 56 | tag = _tagBoxData.tag; 57 | 58 | 59 | this.execute(function () { 60 | if (considering) { 61 | _this3.tagBox.select(considering); 62 | } else { 63 | var existingTag = tags.find(function (t) { 64 | return t.label === tag; 65 | }); 66 | if (existingTag) { 67 | _this3.tagBox.select(existingTag); 68 | } else { 69 | _this3.tagBox.createTag(); 70 | } 71 | } 72 | }); 73 | } 74 | }, { 75 | key: 'select', 76 | value: function select() { 77 | var _this4 = this; 78 | 79 | var _tagBoxData2 = this.tagBoxData, 80 | considering = _tagBoxData2.considering, 81 | tag = _tagBoxData2.tag; 82 | 83 | 84 | this.execute(function () { 85 | if (tag) { 86 | if (considering) { 87 | _this4.tagBox.select(considering); 88 | } else { 89 | _this4.tagBox.createTag(); 90 | } 91 | } 92 | }); 93 | } 94 | }, { 95 | key: 'clear', 96 | value: function clear() { 97 | var _this5 = this; 98 | 99 | this.execute(function () { 100 | return _this5.tagBox.setState({ tag: '', considering: null }); 101 | }); 102 | } 103 | }, { 104 | key: 'deleteLast', 105 | value: function deleteLast() { 106 | var _tagBoxData3 = this.tagBoxData, 107 | selected = _tagBoxData3.selected, 108 | tag = _tagBoxData3.tag, 109 | backspaceDelete = _tagBoxData3.backspaceDelete, 110 | removeTag = _tagBoxData3.removeTag; 111 | 112 | 113 | if (tag || !backspaceDelete) { 114 | return; 115 | } 116 | 117 | this.execute(function () { 118 | removeTag(selected[selected.length - 1]); 119 | }); 120 | } 121 | }]); 122 | 123 | return TagManager; 124 | }(); 125 | 126 | exports.default = TagManager; -------------------------------------------------------------------------------- /lib/TagBoxAsync.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _propTypes = require('prop-types'); 10 | 11 | var _propTypes2 = _interopRequireDefault(_propTypes); 12 | 13 | var _cache = require('./cache'); 14 | 15 | var _cache2 = _interopRequireDefault(_cache); 16 | 17 | var _TagBoxContainer = require('./TagBoxContainer'); 18 | 19 | var _TagBoxContainer2 = _interopRequireDefault(_TagBoxContainer); 20 | 21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 22 | 23 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 24 | 25 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 26 | 27 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 28 | 29 | var TagBoxAsync = function (_TagContainer) { 30 | _inherits(TagBoxAsync, _TagContainer); 31 | 32 | function TagBoxAsync() { 33 | var _ref; 34 | 35 | var _temp, _this, _ret; 36 | 37 | _classCallCheck(this, TagBoxAsync); 38 | 39 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 40 | args[_key] = arguments[_key]; 41 | } 42 | 43 | return _ret = (_temp = (_this = _possibleConstructorReturn(this, (_ref = TagBoxAsync.__proto__ || Object.getPrototypeOf(TagBoxAsync)).call.apply(_ref, [this].concat(args))), _this), _this.state = { 44 | tags: [], 45 | tag: '', 46 | considering: null, 47 | loading: false 48 | }, _this.cache = (0, _cache2.default)(), _temp), _possibleConstructorReturn(_this, _ret); 49 | } 50 | 51 | _createClass(TagBoxAsync, [{ 52 | key: 'tags', 53 | value: function tags() { 54 | return this.state.tags; 55 | } 56 | }, { 57 | key: 'loading', 58 | value: function loading() { 59 | return this.state.loading; 60 | } 61 | }, { 62 | key: 'createTag', 63 | value: function createTag() { 64 | var tag = this.state.tag; 65 | 66 | if (tag) { 67 | this.select({ label: tag }); 68 | this.cache.clear(); 69 | } 70 | } 71 | }, { 72 | key: 'tagUpdater', 73 | value: function tagUpdater() { 74 | var _this2 = this; 75 | 76 | return function (e) { 77 | var input = e.target.value; 78 | _this2.setState({ tag: input }); 79 | 80 | var matches = _this2.cache.get(input); 81 | if (matches) { 82 | return _this2.setState({ tags: matches }); 83 | } 84 | 85 | _this2.setState({ loading: true }); 86 | return _this2.props.fetch(input).then(function (tags) { 87 | _this2.setState({ 88 | tags: _this2.cache.add(input, tags), 89 | loading: false 90 | }); 91 | }); 92 | }; 93 | } 94 | }]); 95 | 96 | return TagBoxAsync; 97 | }(_TagBoxContainer2.default); 98 | 99 | TagBoxAsync.propTypes = { 100 | fetch: _propTypes2.default.func.isRequired 101 | }; 102 | exports.default = TagBoxAsync; -------------------------------------------------------------------------------- /src/Autocomplete.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import classNames from 'classnames' 4 | import TagProp from './utils' 5 | 6 | export default class extends Component { 7 | static propTypes = { 8 | input: PropTypes.string, 9 | selected: PropTypes.arrayOf(TagProp), 10 | tags: PropTypes.arrayOf(TagProp).isRequired, 11 | select: PropTypes.func.isRequired, 12 | create: PropTypes.func.isRequired, 13 | considering: TagProp, 14 | consider: PropTypes.func.isRequired, 15 | renderNewOption: PropTypes.func.isRequired, 16 | loadingText: PropTypes.string, 17 | selectedText: PropTypes.string, 18 | loading: PropTypes.bool, 19 | search: PropTypes.func.isRequired, 20 | exactMatch: PropTypes.func.isRequired 21 | } 22 | 23 | static defaultProps = { 24 | selected: [] 25 | } 26 | 27 | componentWillReceiveProps(nextProps) { 28 | const { tags, input, consider, selected } = nextProps 29 | 30 | if (this.props.input && !input) { 31 | consider(null) 32 | } 33 | 34 | const noChange = (input === this.props.input) && (tags === this.props.tags) 35 | if (!input || noChange) { 36 | return 37 | } 38 | 39 | const matches = tags.filter(t => 40 | this.props.search(t, input) && !selected.includes(t) 41 | ) 42 | 43 | if (matches.length) { 44 | consider(matches[0]) 45 | } else { 46 | consider(null) 47 | } 48 | } 49 | 50 | matchingOptions() { 51 | const { tags, input, selected, search } = this.props 52 | const matches = tags.filter(t => 53 | search(t, input) && !selected.map(s => s.value).includes(t.value) 54 | ) 55 | const values = matches.map(t => t.value) 56 | 57 | return { matches, values } 58 | } 59 | 60 | considerNext() { 61 | const { consider, considering } = this.props 62 | const { matches, values } = this.matchingOptions() 63 | 64 | if (!matches.length) { 65 | return 66 | } 67 | 68 | if (!considering) { 69 | consider(matches[0]) 70 | } else { 71 | const nextIndex = Math.min(matches.length - 1, values.indexOf(considering.value) + 1) 72 | consider(matches[nextIndex]) 73 | } 74 | } 75 | 76 | considerPrevious() { 77 | const { consider, considering } = this.props 78 | const { matches, values } = this.matchingOptions() 79 | 80 | if (!matches.length) { 81 | return 82 | } 83 | 84 | const currentIndex = values.indexOf(considering.value) 85 | if (currentIndex === 0) { 86 | consider(null) 87 | } else { 88 | consider(matches[currentIndex - 1]) 89 | } 90 | } 91 | 92 | render() { 93 | const { 94 | input, 95 | select, 96 | create, 97 | considering, 98 | consider, 99 | renderNewOption, 100 | loadingText, 101 | selectedText, 102 | loading, 103 | selected, 104 | exactMatch 105 | } = 106 | this.props 107 | 108 | if (loading) { 109 | return ( 110 |
      111 |
    • 112 | 113 | {loadingText} 114 | 115 |
    • 116 |
    117 | ) 118 | } 119 | 120 | if (!input) { 121 | return false 122 | } 123 | 124 | const { matches } = this.matchingOptions() 125 | const foundExactMatch = matches.find(t => exactMatch(t, input)) 126 | const alreadySelected = selected.find(t => exactMatch(t, input)) 127 | 128 | const addNewOption = !foundExactMatch && ( 129 |
  • consider(null)} 133 | > 134 | 135 | {renderNewOption(input)} 136 | 137 |
  • 138 | ) 139 | 140 | const selectedNotice = alreadySelected && ( 141 |
  • 142 | 143 | {selectedText} 144 | 145 |
  • 146 | ) 147 | 148 | const content = alreadySelected ? selectedNotice : addNewOption 149 | 150 | const matching = matches.map(t => { 151 | const className = classNames({ 152 | considering: considering === t 153 | }) 154 | 155 | return ( 156 |
  • select(t)} 160 | onMouseOver={() => consider(t)} 161 | > 162 | 163 | {t.label} 164 | 165 |
  • 166 | ) 167 | }) 168 | 169 | return ( 170 |
      171 | {content} 172 | {matching} 173 |
    174 | ) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /spec/TagManager.spec.js: -------------------------------------------------------------------------------- 1 | import expect, { createSpy } from 'expect' 2 | import TagManager from '../src/TagManager' 3 | 4 | function setup(tagBox = {}) { 5 | const event = { 6 | preventDefault: createSpy() 7 | } 8 | 9 | return { tagManager: new TagManager(event, tagBox), tagBox } 10 | } 11 | 12 | describe('TagManager', () => { 13 | describe('.prev()', () => { 14 | context('when there are no suggestions', () => { 15 | const { tagManager, tagBox } = setup({ 16 | state: { 17 | considering: null 18 | }, 19 | autocomplete: { 20 | considerPrevious: createSpy() 21 | } 22 | }) 23 | 24 | tagManager.prev() 25 | 26 | it('does not try to consider the previous suggestion', () => { 27 | expect(tagBox.autocomplete.considerPrevious).toNotHaveBeenCalled() 28 | }) 29 | }) 30 | 31 | context('when considering a suggestion', () => { 32 | const { tagManager, tagBox } = setup({ 33 | state: { 34 | considering: { label: 'beer', value: 'beer' } 35 | }, 36 | autocomplete: { 37 | considerPrevious: createSpy() 38 | } 39 | }) 40 | 41 | tagManager.prev() 42 | 43 | it('considers the previous suggestion', () => { 44 | expect(tagBox.autocomplete.considerPrevious).toHaveBeenCalled() 45 | }) 46 | }) 47 | }) 48 | 49 | describe('.next()', () => { 50 | it('considers the next option', () => { 51 | const { tagManager, tagBox } = setup({ 52 | autocomplete: { 53 | considerNext: createSpy() 54 | } 55 | }) 56 | 57 | tagManager.next() 58 | 59 | expect(tagBox.autocomplete.considerNext).toHaveBeenCalled() 60 | }) 61 | }) 62 | 63 | describe('.create()', () => { 64 | context('when considering a suggestion', () => { 65 | const { tagManager, tagBox } = setup({ 66 | state: { 67 | considering: { label: 'beer', value: 'beer' } 68 | }, 69 | select: createSpy() 70 | }) 71 | 72 | tagManager.create() 73 | 74 | it('selects the suggestion being considered', () => { 75 | expect(tagBox.select).toHaveBeenCalledWith(tagBox.state.considering) 76 | }) 77 | }) 78 | 79 | context('when not considering a suggestion', () => { 80 | context('if the tag is in the suggestion list', () => { 81 | const tag = { label: 'beer', value: 'beer' } 82 | const { tagManager, tagBox } = setup({ 83 | state: { 84 | tag: 'beer' 85 | }, 86 | props: { 87 | tags: [tag] 88 | }, 89 | select: createSpy() 90 | }) 91 | 92 | tagManager.create() 93 | 94 | it('selects from the suggestion list', () => { 95 | expect(tagBox.select).toHaveBeenCalledWith(tag) 96 | }) 97 | }) 98 | 99 | context('if the tag is not in the suggestion list', () => { 100 | const tag = { label: 'beer', value: 'beer' } 101 | const { tagManager, tagBox } = setup({ 102 | state: { 103 | tag: 'whiskey' 104 | }, 105 | props: { 106 | tags: [tag] 107 | }, 108 | createTag: createSpy() 109 | }) 110 | 111 | tagManager.create() 112 | 113 | it('submits a new tag', () => { 114 | expect(tagBox.createTag).toHaveBeenCalled() 115 | }) 116 | }) 117 | }) 118 | }) 119 | 120 | describe('.select()', () => { 121 | context('when there is input', () => { 122 | context('when considering a suggestion', () => { 123 | const { tagManager, tagBox } = setup({ 124 | state: { 125 | tag: 'whis', 126 | considering: { label: 'whiskey', value: 'whiskey' } 127 | }, 128 | select: createSpy() 129 | }) 130 | 131 | tagManager.select() 132 | 133 | it('selects the suggestion being considered', () => { 134 | expect(tagBox.select).toHaveBeenCalledWith(tagBox.state.considering) 135 | }) 136 | }) 137 | 138 | context('when not considering a suggestion', () => { 139 | const { tagManager, tagBox } = setup({ 140 | state: { 141 | tag: 'whiskey' 142 | }, 143 | createTag: createSpy() 144 | }) 145 | 146 | tagManager.select() 147 | 148 | it('submits a new tag', () => { 149 | expect(tagBox.createTag).toHaveBeenCalled() 150 | }) 151 | }) 152 | }) 153 | }) 154 | 155 | describe('.clear()', () => { 156 | it('clears the input and the suggestion', () => { 157 | const { tagManager, tagBox } = setup({ 158 | setState: createSpy() 159 | }) 160 | 161 | tagManager.clear() 162 | 163 | expect(tagBox.setState).toHaveBeenCalledWith({ tag: '', considering: null }) 164 | }) 165 | }) 166 | 167 | describe('.deleteLast()', () => { 168 | context('when there is no input', () => { 169 | context('when backspace deletion is on', () => { 170 | const tag = { label: 'beer', value: 'beer' } 171 | const { tagManager, tagBox } = setup({ 172 | props: { 173 | backspaceDelete: true, 174 | selected: [tag], 175 | removeTag: createSpy() 176 | } 177 | }) 178 | 179 | tagManager.deleteLast() 180 | 181 | it('removes the last tag', () => { 182 | expect(tagBox.props.removeTag).toHaveBeenCalledWith(tag) 183 | }) 184 | }) 185 | }) 186 | }) 187 | }) 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/react-tag-box.svg)](https://www.npmjs.com/package/react-tag-box) 2 | [![npm](https://img.shields.io/npm/dt/react-tag-box.svg)](https://www.npmjs.com/package/react-tag-box) 3 | [![npm](https://img.shields.io/npm/dm/react-tag-box.svg)](https://www.npmjs.com/package/react-tag-box) 4 | [![Build Status](https://travis-ci.org/sslotsky/react-tag-box.svg?branch=master)](https://travis-ci.org/sslotsky/react-tag-box) 5 | [![npm](https://img.shields.io/npm/l/express.svg)](https://github.com/sslotsky/react-tag-box) 6 | 7 | # react-tag-box 8 | 9 | A React component for creating, selecting, and removing tags. This library focuses entirely on tagging rather than attempting to be a generic autocomplete library, because 10 | we think that single responsibility and custom built solutions are good things. 11 | 12 | ## Demo 13 | 14 | Check out our demo on [gh-pages](https://sslotsky.github.io/react-tag-box/). 15 | 16 | ## Usage 17 | 18 | `react-tag-box` manages `Tag` objects in the form of `{ label: String, value: Any }`, and supports both preloaded and asynchronous autocomplete options by providing 19 | two different components: `TagBox` and `TagBoxAsync`. Both components accept the following common properties: 20 | 21 | Property Name | Type | Required | Description 22 | ---|:---:|:---:|:--- 23 | selected | `Array` | true | The list of currently selected tags 24 | onSelect | `function(tag)` | true | Function to be executed when a tag is selected or submitted 25 | removeTag | `function(tag)` | true | Function called when the `remove` button is clicked on a tag 26 | renderNewOption | `function(text)` | false | Function for overriding the default `Add ${input}` prompt 27 | selectedText | `string` | false | Text to display when the search input is already a selected tag. `'Already Selected'` by default. 28 | renderTag | `function(tag, remove)` | false | Function to override default tag rendering 29 | placeholder | `string` | false | Override default placeholder text 30 | backspaceDelete | `bool` | false | Whether or not the backspace key should delete the last tag. `false` by default 31 | search | `function(tag, input)` | false | Function to determine if a given tag should be included in the autocomplete suggestions for a given input. 32 | exactMatch | `function(tag, input)` | false | Function to determine if the tag matches the input. 33 | 34 | ### TagBox 35 | 36 | Users provide the following props in addition to the common props: 37 | 38 | Property Name | Type | Required | Description 39 | ---|:---:|:---:|:--- 40 | tags | `Array` | true | The List of all tags 41 | 42 | #### Example 43 | 44 | ```javascript 45 | import { List } from 'immutable' 46 | import React, { Component } from 'react' 47 | import { TagBox } from 'react-tag-box' 48 | import './styles.scss' 49 | 50 | const sampleTags = List( 51 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 52 | label: t, 53 | value: t 54 | })) 55 | ) 56 | 57 | export default class App extends Component { 58 | state = { 59 | tags: sampleTags, 60 | selected: List.of(sampleTags.first()) 61 | } 62 | 63 | render() { 64 | const { tags, selected } = this.state 65 | const onSelect = tag => { 66 | const newTag = { 67 | label: tag.label, 68 | value: tag.value || tag.label 69 | } 70 | 71 | this.setState({ 72 | selected: selected.push(newTag) 73 | }) 74 | } 75 | 76 | const remove = tag => { 77 | this.setState({ 78 | selected: selected.filter(t => t.value !== tag.value) 79 | }) 80 | } 81 | 82 | // optional 83 | // default behavior is case-sensitive search within tag label, like so: 84 | // (tag, input) => tag.label.includes(input) 85 | const search = (tag, input) => { 86 | tag.label.toLowerCase().includes(input.toLowerCase()) 87 | } 88 | 89 | // optional 90 | // default behavior is case-sensitive match against tag label, like so: 91 | // (tag, input) => tag.label === input 92 | const exactMatch = (tag, input) => { 93 | tag.label.toLowerCase() === input.toLowerCase(); 94 | } 95 | 96 | return ( 97 |
    98 | 106 |
    107 | ) 108 | } 109 | } 110 | ``` 111 | 112 | ### TagBoxAsync 113 | 114 | Users provide the following props in addition to the common props: 115 | 116 | Property Name | Type | Required | Description 117 | ---|:---:|:---:|:--- 118 | fetch | `function(text)` | true | A function that returns a promise which resolves the tags to populate the autocomplete. 119 | loadingText | `string` | false | Text to display when results are being fetched. `'Loading...'` by default. 120 | 121 | #### Example 122 | 123 | ```javascript 124 | import { List } from 'immutable' 125 | import React, { Component } from 'react' 126 | import { TagBoxAsync } from 'react-tag-box' 127 | import './styles.scss' 128 | 129 | // Mock server data. This would normally be in your database. 130 | const sampleTags = List( 131 | ['foo', 'bar', 'baz', 'blitz', 'quux', 'barf', 'balderdash'].map(t => ({ 132 | label: t, 133 | value: t 134 | })) 135 | ) 136 | 137 | // Mock http request. This would normally make a request to your server to fetch matching tags. 138 | const fetch = input => { 139 | return new Promise(resolve => { 140 | setTimeout(() => { 141 | resolve(sampleTags.filter(t => t.label.includes(input)).toJS()) 142 | }, 1500) 143 | }) 144 | } 145 | 146 | export default class Async extends Component { 147 | state = { 148 | selected: sampleTags.take(1) 149 | } 150 | 151 | render() { 152 | const { selected } = this.state 153 | const onSelect = tag => { 154 | const newTag = { 155 | label: tag.label, 156 | value: tag.value || tag.label 157 | } 158 | 159 | this.setState({ 160 | selected: selected.push(newTag) 161 | }) 162 | } 163 | 164 | const remove = tag => { 165 | this.setState({ 166 | selected: selected.filter(t => t.value !== tag.value) 167 | }) 168 | } 169 | 170 | const placeholder = selected.isEmpty() ? '' : 171 | "Use the backspace key to delete the last tag" 172 | 173 | return ( 174 | 182 | ) 183 | } 184 | } 185 | ``` 186 | -------------------------------------------------------------------------------- /spec/Autocomplete.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Enzyme, { shallow } from 'enzyme' 3 | import Adapter from 'enzyme-adapter-react-16' 4 | import expect, { createSpy } from 'expect' 5 | 6 | import Autocomplete from '../src/Autocomplete' 7 | 8 | Enzyme.configure({ adapter: new Adapter() }) 9 | 10 | describe('', () => { 11 | describe('.render()', () => { 12 | const [beer, beerNuts] = [{ 13 | label: 'beer', 14 | value: 'beer' 15 | }, { 16 | label: 'beer nuts', 17 | value: 'beer nuts' 18 | }] 19 | 20 | const props = { 21 | input: 'be', 22 | consider: createSpy(), 23 | considering: beer, 24 | tags: [beer, beerNuts], 25 | renderNewOption: createSpy(), 26 | search: () => [beer, beerNuts], 27 | exactMatch: (input, tag) => tag.label === input 28 | } 29 | 30 | const exactMatchSpy = expect.spyOn(props, 'exactMatch').andCallThrough() 31 | 32 | beforeEach(() => { 33 | exactMatchSpy.restore() 34 | }) 35 | 36 | shallow( 37 | 38 | ) 39 | 40 | it('determines if the input exactly matches a tag', () => { 41 | expect(exactMatchSpy.called) 42 | }) 43 | }) 44 | 45 | describe('.considerNext()', () => { 46 | context('when considering a suggestion', () => { 47 | const [beer, beerNuts] = [{ 48 | label: 'beer', 49 | value: 'beer' 50 | }, { 51 | label: 'beer nuts', 52 | value: 'beer nuts' 53 | }] 54 | 55 | const props = { 56 | input: 'be', 57 | consider: createSpy(), 58 | considering: beer, 59 | tags: [beer, beerNuts], 60 | renderNewOption: createSpy(), 61 | search: () => [beer, beerNuts], 62 | exactMatch: createSpy() 63 | } 64 | 65 | const wrapper = shallow( 66 | 67 | ) 68 | 69 | wrapper.instance().considerNext() 70 | 71 | it('considers the next suggestion', () => { 72 | expect(props.consider).toHaveBeenCalledWith(beerNuts) 73 | }) 74 | }) 75 | 76 | context('when not considering anything', () => { 77 | const tag = { label: 'beer', value: 'beer' } 78 | const props = { 79 | input: 'be', 80 | consider: createSpy(), 81 | tags: [tag], 82 | renderNewOption: createSpy(), 83 | search: () => [tag], 84 | exactMatch: createSpy() 85 | } 86 | 87 | const wrapper = shallow( 88 | 89 | ) 90 | 91 | wrapper.instance().considerNext() 92 | 93 | it('considers the first suggestion', () => { 94 | expect(props.consider).toHaveBeenCalledWith(tag) 95 | }) 96 | }) 97 | }) 98 | 99 | describe('.considerPrevious()', () => { 100 | context('when considering the first suggestion', () => { 101 | const tag = { label: 'beer', value: 'beer' } 102 | const props = { 103 | input: 'be', 104 | consider: createSpy(), 105 | considering: tag, 106 | tags: [tag], 107 | renderNewOption: createSpy(), 108 | search: () => [tag], 109 | exactMatch: createSpy() 110 | } 111 | 112 | const wrapper = shallow( 113 | 114 | ) 115 | 116 | wrapper.instance().considerPrevious() 117 | 118 | it('clears the suggestion', () => { 119 | expect(props.consider).toHaveBeenCalledWith(null) 120 | }) 121 | }) 122 | 123 | context('when considering any other suggestion', () => { 124 | const [beer, beerNuts] = [{ 125 | label: 'beer', 126 | value: 'beer' 127 | }, { 128 | label: 'beer nuts', 129 | value: 'beer nuts' 130 | }] 131 | 132 | const props = { 133 | input: 'be', 134 | consider: createSpy(), 135 | considering: beerNuts, 136 | tags: [beer, beerNuts], 137 | renderNewOption: createSpy(), 138 | search: () => [beer, beerNuts], 139 | exactMatch: createSpy() 140 | } 141 | 142 | const wrapper = shallow( 143 | 144 | ) 145 | 146 | wrapper.instance().considerPrevious() 147 | 148 | it('considers the previous suggestion', () => { 149 | expect(props.consider).toHaveBeenCalledWith(beer) 150 | }) 151 | }) 152 | }) 153 | 154 | describe('.componentWillReceiveProps', () => { 155 | context('when the input is cleared', () => { 156 | const props = { 157 | input: 'b', 158 | consider: createSpy(), 159 | renderNewOption: createSpy(), 160 | tags: [], 161 | search: createSpy(), 162 | exactMatch: createSpy() 163 | } 164 | 165 | const wrapper = shallow( 166 | 167 | ) 168 | 169 | wrapper.instance().componentWillReceiveProps({ ...props, input: '' }) 170 | 171 | it('clears the active suggestion', () => { 172 | expect(props.consider).toHaveBeenCalledWith(null) 173 | }) 174 | }) 175 | 176 | context('when there are suggestions', () => { 177 | const tag = { label: 'beer', value: 'beer' } 178 | const props = { 179 | consider: createSpy(), 180 | renderNewOption: createSpy(), 181 | selected: [], 182 | tags: [tag], 183 | search: () => [tag], 184 | exactMatch: createSpy() 185 | } 186 | const searchSpy = expect.spyOn(props, 'search').andCallThrough() 187 | 188 | beforeEach(() => { 189 | searchSpy.restore() 190 | }) 191 | 192 | const wrapper = shallow( 193 | 194 | ) 195 | 196 | wrapper.instance().componentWillReceiveProps({ ...props, input: 'be' }) 197 | 198 | it('considers the first suggestion', () => { 199 | expect(props.consider).toHaveBeenCalledWith(tag) 200 | }) 201 | 202 | it('searches for suggestions', () => { 203 | expect(searchSpy).toHaveBeenCalledWith(tag, 'be') 204 | }) 205 | }) 206 | 207 | context('when there are no suggestions', () => { 208 | const tag = { label: 'beer', value: 'beer' } 209 | const props = { 210 | consider: createSpy(), 211 | renderNewOption: createSpy(), 212 | tags: [tag], 213 | search: createSpy(), 214 | exactMatch: createSpy() 215 | } 216 | 217 | const wrapper = shallow( 218 | 219 | ) 220 | 221 | wrapper.instance().componentWillReceiveProps({ ...props, input: 'whi' }) 222 | 223 | it('clears the active suggestion', () => { 224 | expect(props.consider).toHaveBeenCalledWith(null) 225 | }) 226 | }) 227 | }) 228 | }) 229 | -------------------------------------------------------------------------------- /lib/TagBoxContainer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 8 | 9 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 10 | 11 | var _react = require('react'); 12 | 13 | var _react2 = _interopRequireDefault(_react); 14 | 15 | var _propTypes = require('prop-types'); 16 | 17 | var _propTypes2 = _interopRequireDefault(_propTypes); 18 | 19 | var _utils = require('./utils'); 20 | 21 | var _utils2 = _interopRequireDefault(_utils); 22 | 23 | var _constants = require('./constants'); 24 | 25 | var _constants2 = _interopRequireDefault(_constants); 26 | 27 | var _TagManager = require('./TagManager'); 28 | 29 | var _TagManager2 = _interopRequireDefault(_TagManager); 30 | 31 | var _driver = require('./driver'); 32 | 33 | var _driver2 = _interopRequireDefault(_driver); 34 | 35 | var _Tag = require('./Tag'); 36 | 37 | var _Tag2 = _interopRequireDefault(_Tag); 38 | 39 | var _Autocomplete = require('./Autocomplete'); 40 | 41 | var _Autocomplete2 = _interopRequireDefault(_Autocomplete); 42 | 43 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 44 | 45 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 46 | 47 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 48 | 49 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 50 | 51 | var TagBoxContainer = function (_Component) { 52 | _inherits(TagBoxContainer, _Component); 53 | 54 | function TagBoxContainer() { 55 | var _ref; 56 | 57 | var _temp, _this, _ret; 58 | 59 | _classCallCheck(this, TagBoxContainer); 60 | 61 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 62 | args[_key] = arguments[_key]; 63 | } 64 | 65 | return _ret = (_temp = (_this = _possibleConstructorReturn(this, (_ref = TagBoxContainer.__proto__ || Object.getPrototypeOf(TagBoxContainer)).call.apply(_ref, [this].concat(args))), _this), _this.state = { 66 | tag: '', 67 | considering: null 68 | }, _temp), _possibleConstructorReturn(_this, _ret); 69 | } 70 | 71 | _createClass(TagBoxContainer, [{ 72 | key: 'tagUpdater', 73 | value: function tagUpdater() { 74 | var _this2 = this; 75 | 76 | return function (e) { 77 | _this2.setState({ tag: e.target.value }); 78 | }; 79 | } 80 | }, { 81 | key: 'select', 82 | value: function select(tag) { 83 | var selected = this.props.selected; 84 | 85 | if (!tag || selected.map(function (t) { 86 | return t.label; 87 | }).includes(tag.label)) { 88 | return; 89 | } 90 | 91 | var status = this.props.onSelect(tag); 92 | if (status !== _constants2.default) { 93 | this.setState({ tag: '' }); 94 | } 95 | } 96 | }, { 97 | key: 'blurTag', 98 | value: function blurTag() { 99 | var _state = this.state, 100 | tag = _state.tag, 101 | considering = _state.considering; 102 | 103 | 104 | if (considering) { 105 | this.select(considering); 106 | } else if (tag) { 107 | this.select({ label: tag }); 108 | } 109 | } 110 | }, { 111 | key: 'createTag', 112 | value: function createTag() { 113 | var tag = this.state.tag; 114 | 115 | if (tag) { 116 | this.select({ label: tag }); 117 | } 118 | } 119 | }, { 120 | key: 'keyHandler', 121 | value: function keyHandler() { 122 | var _this3 = this; 123 | 124 | return function (e) { 125 | var tagManager = new _TagManager2.default(e, _this3); 126 | var action = (0, _driver2.default)(e.which, tagManager); 127 | 128 | if (action) { 129 | action(); 130 | } 131 | }; 132 | } 133 | }, { 134 | key: 'tags', 135 | value: function tags() { 136 | throw new Error('Component must implement the tags() method'); 137 | } 138 | }, { 139 | key: 'loading', 140 | value: function loading() { 141 | return false; 142 | } 143 | }, { 144 | key: 'render', 145 | value: function render() { 146 | var _this4 = this; 147 | 148 | var consider = function consider(option) { 149 | _this4.setState({ considering: option }); 150 | }; 151 | 152 | var _state2 = this.state, 153 | tag = _state2.tag, 154 | considering = _state2.considering; 155 | var _props = this.props, 156 | selected = _props.selected, 157 | removeTag = _props.removeTag, 158 | placeholder = _props.placeholder, 159 | renderTag = _props.renderTag, 160 | _search = _props.search, 161 | _exactMatch = _props.exactMatch; 162 | 163 | var pills = selected.map(function (t) { 164 | return _react2.default.createElement(_Tag2.default, { tag: t, key: t.value, removeTag: removeTag, render: renderTag }); 165 | }); 166 | 167 | return _react2.default.createElement( 168 | 'div', 169 | { className: 'tag-box', onClick: function onClick() { 170 | return _this4.input.focus(); 171 | } }, 172 | _react2.default.createElement( 173 | 'ul', 174 | { className: 'tag-box-pills' }, 175 | pills 176 | ), 177 | _react2.default.createElement('input', { 178 | ref: function ref(node) { 179 | _this4.input = node; 180 | }, 181 | value: tag, 182 | onChange: this.tagUpdater(), 183 | onKeyDown: this.keyHandler(), 184 | onBlur: function onBlur() { 185 | return _this4.blurTag(); 186 | }, 187 | placeholder: placeholder 188 | }), 189 | _react2.default.createElement(_Autocomplete2.default, _extends({}, this.props, { 190 | ref: function ref(node) { 191 | _this4.autocomplete = node; 192 | }, 193 | input: tag, 194 | loading: this.loading(), 195 | tags: this.tags(), 196 | select: function select(t) { 197 | return _this4.select(t); 198 | }, 199 | create: function create() { 200 | return _this4.createTag(); 201 | }, 202 | search: function search(t, input) { 203 | return _search(t, input); 204 | }, 205 | exactMatch: function exactMatch(t, input) { 206 | return _exactMatch(t, input); 207 | }, 208 | considering: considering, 209 | consider: consider 210 | })) 211 | ); 212 | } 213 | }]); 214 | 215 | return TagBoxContainer; 216 | }(_react.Component); 217 | 218 | TagBoxContainer.propTypes = { 219 | selected: _propTypes2.default.arrayOf(_utils2.default), 220 | onSelect: _propTypes2.default.func.isRequired, 221 | renderNewOption: _propTypes2.default.func, 222 | removeTag: _propTypes2.default.func.isRequired, 223 | renderTag: _propTypes2.default.func, 224 | loadingText: _propTypes2.default.string, 225 | selectedText: _propTypes2.default.string, 226 | placeholder: _propTypes2.default.string, 227 | search: _propTypes2.default.func, 228 | exactMatch: _propTypes2.default.func 229 | }; 230 | TagBoxContainer.defaultProps = { 231 | renderNewOption: function renderNewOption(input) { 232 | return 'Add ' + input + '...'; 233 | }, 234 | loadingText: 'Loading...', 235 | selectedText: 'Already Selected', 236 | placeHolder: '', 237 | search: function search(tag, input) { 238 | return tag.label.includes(input); 239 | }, 240 | exactMatch: function exactMatch(tag, input) { 241 | return tag.label === input; 242 | } 243 | }; 244 | exports.default = TagBoxContainer; -------------------------------------------------------------------------------- /lib/Autocomplete.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _react = require('react'); 10 | 11 | var _react2 = _interopRequireDefault(_react); 12 | 13 | var _propTypes = require('prop-types'); 14 | 15 | var _propTypes2 = _interopRequireDefault(_propTypes); 16 | 17 | var _classnames = require('classnames'); 18 | 19 | var _classnames2 = _interopRequireDefault(_classnames); 20 | 21 | var _utils = require('./utils'); 22 | 23 | var _utils2 = _interopRequireDefault(_utils); 24 | 25 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 26 | 27 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 28 | 29 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 30 | 31 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 32 | 33 | var _class = function (_Component) { 34 | _inherits(_class, _Component); 35 | 36 | function _class() { 37 | _classCallCheck(this, _class); 38 | 39 | return _possibleConstructorReturn(this, (_class.__proto__ || Object.getPrototypeOf(_class)).apply(this, arguments)); 40 | } 41 | 42 | _createClass(_class, [{ 43 | key: 'componentWillReceiveProps', 44 | value: function componentWillReceiveProps(nextProps) { 45 | var _this2 = this; 46 | 47 | var tags = nextProps.tags, 48 | input = nextProps.input, 49 | consider = nextProps.consider, 50 | selected = nextProps.selected; 51 | 52 | 53 | if (this.props.input && !input) { 54 | consider(null); 55 | } 56 | 57 | var noChange = input === this.props.input && tags === this.props.tags; 58 | if (!input || noChange) { 59 | return; 60 | } 61 | 62 | var matches = tags.filter(function (t) { 63 | return _this2.props.search(t, input) && !selected.includes(t); 64 | }); 65 | 66 | if (matches.length) { 67 | consider(matches[0]); 68 | } else { 69 | consider(null); 70 | } 71 | } 72 | }, { 73 | key: 'matchingOptions', 74 | value: function matchingOptions() { 75 | var _props = this.props, 76 | tags = _props.tags, 77 | input = _props.input, 78 | selected = _props.selected, 79 | search = _props.search; 80 | 81 | var matches = tags.filter(function (t) { 82 | return search(t, input) && !selected.map(function (s) { 83 | return s.value; 84 | }).includes(t.value); 85 | }); 86 | var values = matches.map(function (t) { 87 | return t.value; 88 | }); 89 | 90 | return { matches: matches, values: values }; 91 | } 92 | }, { 93 | key: 'considerNext', 94 | value: function considerNext() { 95 | var _props2 = this.props, 96 | consider = _props2.consider, 97 | considering = _props2.considering; 98 | 99 | var _matchingOptions = this.matchingOptions(), 100 | matches = _matchingOptions.matches, 101 | values = _matchingOptions.values; 102 | 103 | if (!matches.length) { 104 | return; 105 | } 106 | 107 | if (!considering) { 108 | consider(matches[0]); 109 | } else { 110 | var nextIndex = Math.min(matches.length - 1, values.indexOf(considering.value) + 1); 111 | consider(matches[nextIndex]); 112 | } 113 | } 114 | }, { 115 | key: 'considerPrevious', 116 | value: function considerPrevious() { 117 | var _props3 = this.props, 118 | consider = _props3.consider, 119 | considering = _props3.considering; 120 | 121 | var _matchingOptions2 = this.matchingOptions(), 122 | matches = _matchingOptions2.matches, 123 | values = _matchingOptions2.values; 124 | 125 | if (!matches.length) { 126 | return; 127 | } 128 | 129 | var currentIndex = values.indexOf(considering.value); 130 | if (currentIndex === 0) { 131 | consider(null); 132 | } else { 133 | consider(matches[currentIndex - 1]); 134 | } 135 | } 136 | }, { 137 | key: 'render', 138 | value: function render() { 139 | var _props4 = this.props, 140 | input = _props4.input, 141 | select = _props4.select, 142 | create = _props4.create, 143 | considering = _props4.considering, 144 | consider = _props4.consider, 145 | renderNewOption = _props4.renderNewOption, 146 | loadingText = _props4.loadingText, 147 | selectedText = _props4.selectedText, 148 | loading = _props4.loading, 149 | selected = _props4.selected, 150 | exactMatch = _props4.exactMatch; 151 | 152 | 153 | if (loading) { 154 | return _react2.default.createElement( 155 | 'ul', 156 | { className: 'autocomplete' }, 157 | _react2.default.createElement( 158 | 'li', 159 | null, 160 | _react2.default.createElement( 161 | 'span', 162 | { className: 'option-text' }, 163 | loadingText 164 | ) 165 | ) 166 | ); 167 | } 168 | 169 | if (!input) { 170 | return false; 171 | } 172 | 173 | var _matchingOptions3 = this.matchingOptions(), 174 | matches = _matchingOptions3.matches; 175 | 176 | var foundExactMatch = matches.find(function (t) { 177 | return exactMatch(t, input); 178 | }); 179 | var alreadySelected = selected.find(function (t) { 180 | return exactMatch(t, input); 181 | }); 182 | 183 | var addNewOption = !foundExactMatch && _react2.default.createElement( 184 | 'li', 185 | { 186 | className: (0, _classnames2.default)('add-new', { considering: !considering }), 187 | onClick: create, 188 | onMouseOver: function onMouseOver() { 189 | return consider(null); 190 | } 191 | }, 192 | _react2.default.createElement( 193 | 'span', 194 | { className: 'option-text' }, 195 | renderNewOption(input) 196 | ) 197 | ); 198 | 199 | var selectedNotice = alreadySelected && _react2.default.createElement( 200 | 'li', 201 | null, 202 | _react2.default.createElement( 203 | 'span', 204 | { className: 'option-text' }, 205 | selectedText 206 | ) 207 | ); 208 | 209 | var content = alreadySelected ? selectedNotice : addNewOption; 210 | 211 | var matching = matches.map(function (t) { 212 | var className = (0, _classnames2.default)({ 213 | considering: considering === t 214 | }); 215 | 216 | return _react2.default.createElement( 217 | 'li', 218 | { 219 | key: t.value, 220 | className: className, 221 | onClick: function onClick() { 222 | return select(t); 223 | }, 224 | onMouseOver: function onMouseOver() { 225 | return consider(t); 226 | } 227 | }, 228 | _react2.default.createElement( 229 | 'span', 230 | { className: 'option-text' }, 231 | t.label 232 | ) 233 | ); 234 | }); 235 | 236 | return _react2.default.createElement( 237 | 'ul', 238 | { className: 'autocomplete' }, 239 | content, 240 | matching 241 | ); 242 | } 243 | }]); 244 | 245 | return _class; 246 | }(_react.Component); 247 | 248 | _class.propTypes = { 249 | input: _propTypes2.default.string, 250 | selected: _propTypes2.default.arrayOf(_utils2.default), 251 | tags: _propTypes2.default.arrayOf(_utils2.default).isRequired, 252 | select: _propTypes2.default.func.isRequired, 253 | create: _propTypes2.default.func.isRequired, 254 | considering: _utils2.default, 255 | consider: _propTypes2.default.func.isRequired, 256 | renderNewOption: _propTypes2.default.func.isRequired, 257 | loadingText: _propTypes2.default.string, 258 | selectedText: _propTypes2.default.string, 259 | loading: _propTypes2.default.bool, 260 | search: _propTypes2.default.func.isRequired, 261 | exactMatch: _propTypes2.default.func.isRequired 262 | }; 263 | _class.defaultProps = { 264 | selected: [] 265 | }; 266 | exports.default = _class; --------------------------------------------------------------------------------