├── 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 |
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 | [](https://www.npmjs.com/package/react-tag-box)
2 | [](https://www.npmjs.com/package/react-tag-box)
3 | [](https://www.npmjs.com/package/react-tag-box)
4 | [](https://travis-ci.org/sslotsky/react-tag-box)
5 | [](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;
--------------------------------------------------------------------------------