├── .eslintrc
├── .npmignore
├── demo.gif
├── copy-paste-demo.gif
├── demo
├── src
│ ├── index.css
│ ├── index.js
│ ├── Demo.css
│ ├── SimpleTag.css
│ ├── Demo.jsx
│ └── TagsInput.css
├── .gitignore
├── public
│ └── index.html
└── package.json
├── .gitignore
├── .babelrc
├── src
├── SimpleTag.jsx
├── __tests__
│ ├── __snapshots__
│ │ └── TagsInput.jsx.snap
│ └── TagsInput.jsx
└── TagsInput.jsx
├── package.json
└── README.md
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb"
3 | }
4 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | demo
2 | node_modules
3 | examples
4 | coverage
5 | demo.gif
6 |
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lang-ai/react-tags-input/HEAD/demo.gif
--------------------------------------------------------------------------------
/copy-paste-demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lang-ai/react-tags-input/HEAD/copy-paste-demo.gif
--------------------------------------------------------------------------------
/demo/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | coverage
4 |
5 | demo/node_modules
6 | demo/build
7 |
8 | .DS_Store
9 | .env
10 | npm-debug.log
11 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "lodash",
4 | "transform-object-rest-spread",
5 | "@babel/plugin-proposal-object-rest-spread"
6 | ],
7 | "presets": ["@babel/env", "@babel/preset-react"]
8 | }
9 |
--------------------------------------------------------------------------------
/demo/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Demo from './Demo';
4 | import './index.css';
5 |
6 | ReactDOM.render(
7 | ,
8 | document.getElementById('root')
9 | );
10 |
--------------------------------------------------------------------------------
/demo/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 |
6 | # testing
7 | coverage
8 |
9 | # production
10 | build
11 |
12 | # misc
13 | .DS_Store
14 | .env
15 | npm-debug.log
16 |
--------------------------------------------------------------------------------
/demo/src/Demo.css:
--------------------------------------------------------------------------------
1 | .Wrapper {
2 | max-width: 450px;
3 | width: 100%;
4 | margin: 2em auto;
5 | }
6 |
7 | h1 {
8 | font-size: 22px;
9 | line-height: 1.2;
10 | margin-top: 0;
11 | margin-bottom: 1em;
12 | color: #333;
13 | }
14 |
15 | h1:not(:first-child) {
16 | margin-top: 2.5em;
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/demo/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | React Tags Input demo
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "gh-pages": "^0.12.0",
7 | "react-scripts": "^1.1.5"
8 | },
9 | "dependencies": {
10 | "react": "^15.4.2",
11 | "react-dom": "^15.4.2"
12 | },
13 | "scripts": {
14 | "start": "react-scripts start",
15 | "build": "react-scripts build",
16 | "test": "react-scripts test --env=jsdom",
17 | "eject": "react-scripts eject",
18 | "deploy": "npm run build&&gh-pages -d build"
19 | },
20 | "homepage": "https://sentisis.github.io/react-tags-input"
21 | }
22 |
--------------------------------------------------------------------------------
/src/SimpleTag.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import classNames from 'classnames';
4 |
5 | const propTypes = {
6 | value: PropTypes.string.isRequired,
7 | onClick: PropTypes.func.isRequired,
8 | };
9 |
10 | function SimpleTag({ onClick, value, special }) {
11 | const className = classNames('SimpleTag', {
12 | 'is-special': special,
13 | });
14 |
15 | return (
16 |
17 | {value}
18 |
19 |
22 |
23 | );
24 | }
25 |
26 | SimpleTag.propTypes = propTypes;
27 |
28 | export default SimpleTag;
29 |
--------------------------------------------------------------------------------
/demo/src/SimpleTag.css:
--------------------------------------------------------------------------------
1 | .SimpleTag {
2 | background-color: #236EA5;
3 | color: white;
4 | font-size: 0.85em;
5 | line-height: 1.8em;
6 | height: 1.8em;
7 | display: inline-block;
8 | padding-left: 0.5em;
9 | border-radius: 3px;
10 | }
11 |
12 | .SimpleTag.is-special {
13 | background-color: #A52351;
14 | }
15 |
16 | .SimpleTag__btn {
17 | border: 0;
18 | background-color: rgba(0, 0, 0, .2);
19 | color: white;
20 | padding: 0 0.5em;
21 | margin-left: 0.5em;
22 | line-height: 1.8em;
23 | height: 1.8em;
24 | font-weight: bold;
25 | display: inline-block;
26 | }
27 |
28 | .SimpleTag__btn:hover {
29 | background-color: rgba(0, 0, 0, .7);
30 | color: white;
31 | }
32 |
33 |
--------------------------------------------------------------------------------
/demo/src/Demo.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import TagsInput from '../../lib/TagsInput';
4 | import './TagsInput.css';
5 | import './SimpleTag.css';
6 | import './Demo.css';
7 |
8 | export default class Demo extends React.Component {
9 | constructor(props) {
10 | super(props);
11 |
12 | this.state = {
13 | basic: [],
14 | full: [],
15 | };
16 | }
17 |
18 | render() {
19 | return (
20 |
21 |
Simple example
22 | this.setState({ basic: tags })}
27 | />
28 |
29 | Complete example
30 | this.setState({ full: tags })}
35 | specialTags
36 | copyButton
37 | />
38 |
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@sentisis/react-tags-input",
3 | "version": "1.0.6",
4 | "description": "React input to create tags",
5 | "main": "src/TagsInput.jsx",
6 | "public": true,
7 | "directories": {
8 | "example": "examples"
9 | },
10 | "scripts": {
11 | "test": "jest",
12 | "build": "babel src -d lib",
13 | "start": "babel src --watch -d lib"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/Sentisis/react-tags-input.git"
18 | },
19 | "keywords": [
20 | "react",
21 | "tags",
22 | "input"
23 | ],
24 | "author": "Alberto Restifo ",
25 | "contributors": [
26 | {
27 | "name": "Fernando Aguero",
28 | "email": "faguero@sentisis.com"
29 | }
30 | ],
31 | "license": "MIT",
32 | "bugs": {
33 | "url": "https://github.com/Sentisis/tags-input/issues"
34 | },
35 | "homepage": "https://github.com/Sentisis/tags-input#readme",
36 | "dependencies": {
37 | "classnames": "^2.2.5",
38 | "clipboard": "^2.0.4",
39 | "lodash": "^4.17.11",
40 | "mousetrap": "^1.6.0",
41 | "prop-types": "^15.5.10",
42 | "react": "^16.6.3"
43 | },
44 | "devDependencies": {
45 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0",
46 | "@babel/core": "^7.6.0",
47 | "@babel/preset-env": "^7.6.0",
48 | "@babel/preset-react": "^7.0.0",
49 | "babel-jest": "^24.5.0",
50 | "babel-loader": "^8.0.6",
51 | "babel-plugin-lodash": "^3.2.11",
52 | "babel-plugin-transform-object-rest-spread": "^6.22.0",
53 | "eslint": "^6.3.0",
54 | "eslint-config-airbnb": "^18.0.1",
55 | "eslint-plugin-import": "^2.14.0",
56 | "eslint-plugin-jsx-a11y": "^6.0.2",
57 | "eslint-plugin-react": "^7.1.0",
58 | "jest": "^24.5.0",
59 | "react-test-renderer": "^16.6.3",
60 | "webpack": "^4.39.3",
61 | "webpack-cli": "^3.3.8"
62 | },
63 | "jest": {
64 | "collectCoverage": true
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/demo/src/TagsInput.css:
--------------------------------------------------------------------------------
1 | .TagsInput,
2 | .TagsInput * {
3 | box-sizing: border-box;
4 | }
5 |
6 | .TagsInput__field {
7 | position: relative;
8 | }
9 |
10 | .TagsInput__input {
11 | display: flex;
12 | flex-flow: row wrap;
13 | width: 100%;
14 | min-height: 41px;
15 | padding: 6px 12px;
16 | font-size: 14px;
17 | line-height: 1.5;
18 | color: #555;
19 | background-color: #fff;
20 | background-image: none;
21 | border: 1px solid #ccc;
22 | border-radius: 4px;
23 | cursor: text;
24 | }
25 |
26 | .TagsInput__input.is-focused {
27 | border-color: #66afe9;
28 | box-shadow: inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);
29 | }
30 |
31 | .TagsInput__text {
32 | font-size: 14px;
33 | display: inline-block;
34 | outline: none;
35 | border: 0;
36 | line-height: 1.5;
37 | width: 0;
38 | background-color: transparent;
39 | margin: 0 0 0 5px;
40 | padding: 0;
41 | }
42 |
43 | .TagsInput__textarea {
44 | outline: none;
45 | background-color: transparent;
46 | display: inline-block;
47 | width: 100%;
48 | border: 0;
49 | line-height: 1.5;
50 | font-size: 14px;
51 | font-weight: 600;
52 | resize: none;
53 | padding: 0;
54 | margin: 0;
55 | overflow: hidden;
56 | }
57 |
58 | .TagsInput__tag {
59 | display: inline-block;
60 | margin: 3px;
61 | }
62 |
63 | .TagsInput__label {
64 | font-size: 14px;
65 | line-height: 1.5;
66 | color: #333;
67 | font-weight: bold;
68 | margin-bottom: 3px;
69 | }
70 |
71 | .TagsInput__header {
72 | display: flex;
73 | justify-content: space-between;
74 | align-items: flex-end;
75 | }
76 |
77 | .TagsInput__special-btn {
78 | padding: 0;
79 | margin: 0;
80 | color: white;
81 | background-color: transparent;
82 | color: #A52351;
83 | border: 1px solid #A52351;
84 | padding: 2px 8px;
85 | border-radius: 2px;
86 | margin-bottom: 5px;
87 | position: relative;
88 | cursor: pointer;
89 | font-weight: bold;
90 | }
91 |
92 | .TagsInput__special-btn:hover,
93 | .TagsInput__special-btn.is-active {
94 | background-color: #A52351;
95 | color: white;
96 | }
97 |
98 | .TagsInput__placeholder {
99 | font-size: 14px;
100 | color: gray;
101 | position: absolute;
102 | top: 50%;
103 | left: 20px;
104 | transform: translateY(-50%);
105 | }
106 |
107 | .TagsInput__footer {
108 | display: flex;
109 | justify-content: space-between;
110 | margin-top: 3px;
111 | }
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # This repository is not being actively maintained
2 |
3 |
4 | React-Tags-Input
5 | ============
6 |
7 | An input control that handles tags interaction with copy-paste and custom type support.
8 |
9 | 
10 |
11 | ## Live Playground
12 |
13 | For examples of the tags input in action, check the [demo page](https://lang-ai.github.io/react-tags-input/)
14 |
15 |
16 | ## Installation
17 |
18 | The easiest way to use it is by installing it from NPM and include it in your own React build process.
19 |
20 | ```javascript
21 | npm install @sentisis/react-tags-input --save
22 | ```
23 |
24 | ## Usage
25 |
26 | Example usage:
27 | ```jsx
28 | import React from 'react';
29 | import TagsInput from '@sentisis/react-tags-input';
30 | // Either a copy of our demo CSS or your custom one
31 | import './TagsInput.css';
32 |
33 | export default class Demo extends React.Component {
34 | constructor(props) {
35 | super(props);
36 |
37 | this.state = {
38 | tags: [],
39 | };
40 | }
41 |
42 | render() {
43 | return (
44 | this.setState({ tags })}
49 | />
50 | );
51 | }
52 | }
53 | ```
54 |
55 | ## API
56 | Currently the component listen to the following keys: enter, esc, backspace, mod+a, mod+c and mod+v (for copy/paste).
57 |
58 | It supports a keyboard-only copy paste (using mod+a).
59 |
60 | 
61 |
62 | Each tag you will be passing should have the following shape:
63 |
64 | | Property | Type | Required | Description |
65 | | -------- | ---- | ----------- | -------- |
66 | | value | `String` | true | Tag value |
67 | | special | `Boolean` | false | Special marks the tag as different. For example a special tag when using the case-sensitive options is a case-sensitive tag |
68 |
69 |
70 | The TagsInput component contains the following properties:
71 |
72 | | Property | Type | Default | Description |
73 | | ---------| ---- | ------- | ----------- |
74 | | tags | `Array` | [] | Array of tags to display |
75 | | label | `String` | undefined | Rendered above the field itself |
76 | | placeholder | `String` | undefined | Input placeholder |
77 | | error | `String` | undefined | Error message rendered below the field. When the field is set it will also have the class `is-error`|
78 | | tagRenderer | `Function` | undefined | Optional function that gets used to render the tag |
79 | | copyButton | `Boolean` | false | Renders a copy to clipboard button |
80 | | copyButtonLabel | `String` | `Copy to clipboard` | Label for the copy to clipboard button |
81 | | blacklistChars | `Array` | [','] | Characters not allowed in the tags. Must always contain `,` |
82 | | specialTags | `Boolean` | false | Enable the creation of special tags |
83 | | specialButtonRenderer | Function | undefined | Function that gets used to render the special button |
84 | | specialButtonLabel | String | `Special` | Label for the special button. Only used when a `specialButtonRenderer` is not defined |
85 | | onChange | Function | noop | Fired when changing the tags with the `tags` array as the argument |
86 | | onBlur | Function | noop | Fired as the standard React SyntheticEvent |
87 | | onFocus | Function | noop | Fired as the standard React SyntheticEvent |
88 | | onSubmit | Function | noop | Fired when the user interaction is considered complete, invoked with `tags` |
89 |
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/TagsInput.jsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`TagsInput snapshots renders a plain input 1`] = `
4 |
31 | `;
32 |
33 | exports[`TagsInput snapshots renders a plain input 2`] = `
34 |
37 |
40 |
44 |
47 |
50 | foo
51 |
57 |
58 |
59 |
73 |
74 |
75 |
76 | `;
77 |
78 | exports[`TagsInput snapshots renders with a complete component 1`] = `
79 |
82 |
85 |
91 |
97 |
98 |
101 |
105 |
108 | foo
109 |
110 |
124 |
125 |
126 |
129 |
134 |
135 |
136 | `;
137 |
138 | exports[`TagsInput snapshots renders with a custom tag 1`] = `
139 |
142 |
145 |
149 |
152 |
155 |
156 |
170 |
171 |
172 |
173 | `;
174 |
--------------------------------------------------------------------------------
/src/__tests__/TagsInput.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 |
4 | import {
5 | default as TagsInput,
6 | parseTags,
7 | stripIds,
8 | getPlainTextTags,
9 | hasBlacklistedChars,
10 | parseValuesWith,
11 | } from '../TagsInput';
12 |
13 | describe('TagsInput', () => {
14 | test('paserTags assigns ids to the tags', () => {
15 | const values = [{ value: 'foo' }, { value: 'bar', special: true }];
16 | const out = parseTags(values);
17 |
18 | expect(out).toHaveLength(2);
19 | expect(out[0]).toEqual(expect.objectContaining({ __id: expect.anything() }));
20 | expect(out[1]).toEqual(expect.objectContaining({ __id: expect.anything() }));
21 | expect(out[0].__id).not.toEqual(out[1].__id);
22 | });
23 |
24 | test('stripIds removes only the generated ids', () => {
25 | const values = [{ value: 'foo', __id: 1 }, { value: 'bar', custom: true, __id: 2 }];
26 | const out = stripIds(values);
27 |
28 | expect(out).toHaveLength(2);
29 | expect(out[0]).toEqual({ value: 'foo' });
30 | expect(out[1]).toEqual({ value: 'bar', custom: true });
31 | });
32 |
33 | test('getPlainTextTags transform tags to plain text', () => {
34 | const values = [{ value: 'foo', __id: 1 }, { value: 'bar', special: true, __id: 2 }];
35 | const out = getPlainTextTags(values);
36 | const expected = 'foo,bar';
37 |
38 | expect(out).toEqual(expected);
39 | });
40 |
41 | test('hasBlacklistedChars catches a string with a blacklisted character', () => {
42 | const bl = [',', '+'];
43 | const hasBl = hasBlacklistedChars(bl);
44 |
45 | expect(hasBl('exa,ple')).toEqual(true);
46 | expect(hasBl('test+')).toEqual(true);
47 | expect(hasBl('clean')).toEqual(false);
48 | });
49 |
50 | test('hasBlacklistedChars catches a string with multiple blacklisted chars', () => {
51 | const bl = [',', '+'];
52 | const hasBl = hasBlacklistedChars(bl);
53 |
54 | expect(hasBl('exa,p+le')).toEqual(true);
55 | expect(hasBl('test+sda/3,2')).toEqual(true);
56 | expect(hasBl('clean')).toEqual(false);
57 | });
58 |
59 | describe('parseValuesWith', () => {
60 | test('parses a single value', () => {
61 | const parseVal = parseValuesWith([]);
62 | const out = parseVal('foo');
63 |
64 | expect(out).toHaveLength(1);
65 | expect(out[0]).toEqual({ value: 'foo' });
66 | });
67 |
68 | test('parses multiple values', () => {
69 | const parseVal = parseValuesWith([]);
70 | const out = parseVal('foo,bar');
71 |
72 | expect(out).toHaveLength(2);
73 | expect(out).toEqual([{ value: 'foo' }, { value: 'bar' }]);
74 | });
75 |
76 | test('ignores blacklisted values', () => {
77 | const parseVal = parseValuesWith(['+']);
78 | const out = parseVal('foo,bar,ev+il');
79 |
80 | expect(out).toHaveLength(2);
81 | expect(out).toEqual([{ value: 'foo' }, { value: 'bar' }]);
82 | });
83 | });
84 |
85 | // Snapshots {{{
86 | describe('snapshots', () => {
87 | test('renders a plain input', () => {
88 | let tree = renderer.create().toJSON();
89 | expect(tree).toMatchSnapshot();
90 |
91 | const tags = [{ value: 'foo' }];
92 | tree = renderer.create().toJSON();
93 | expect(tree).toMatchSnapshot();
94 | });
95 |
96 | test('renders with a complete component', () => {
97 | let tree = renderer.create((
98 |
104 | )).toJSON();
105 | expect(tree).toMatchSnapshot();
106 | });
107 |
108 | test('renders with a custom tag', () => {
109 | const tagRenderer = () => ;
110 | let tree = renderer.create((
111 |
115 | )).toJSON();
116 | expect(tree).toMatchSnapshot();
117 | });
118 | });
119 | // }}}
120 | });
121 |
--------------------------------------------------------------------------------
/src/TagsInput.jsx:
--------------------------------------------------------------------------------
1 | import Clipboard from 'clipboard';
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 | import classNames from 'classnames';
5 | import mousetrap from 'mousetrap';
6 | import {
7 | curry,
8 | filter,
9 | flow,
10 | get,
11 | intersection,
12 | isEmpty,
13 | isEqual,
14 | join,
15 | last,
16 | map,
17 | split,
18 | uniqueId,
19 | reject,
20 | } from 'lodash/fp';
21 |
22 | import SimpleTag from './SimpleTag';
23 |
24 | const KEYBOARD_SHORTCUTS = ['enter', 'esc', 'backspace', 'mod+a', 'mod+v'];
25 |
26 | export const tagShape = PropTypes.shape({
27 | value: PropTypes.string.isRequired,
28 |
29 | // Special marks the tag as different. For example a special tag when using
30 | // the case-sensitive options is a case-sensitive tag
31 | special: PropTypes.bool,
32 | });
33 |
34 | const propTypes = {
35 | // Characters not allowed in the tags, defaults to `[',']` and must always
36 | // contain `,`
37 | blacklistChars: PropTypes.arrayOf(PropTypes.string),
38 |
39 | // Renders a copy to clipboard button
40 | copyButton: PropTypes.bool,
41 |
42 | // Label for the copy to clipboard button
43 | copyButtonLabel: PropTypes.string,
44 |
45 | // Error message rendered below the field. When the field is set it will
46 | // also has the class `is-error`
47 | error: PropTypes.string,
48 |
49 | // Rendered above the field itself
50 | label: PropTypes.string,
51 |
52 | placeholder: PropTypes.string,
53 |
54 | // Array of tags to display
55 | tags: PropTypes.arrayOf(tagShape),
56 |
57 | // Returns a custom way to render the tag
58 | tagRenderer: PropTypes.func,
59 |
60 | // Enable the creation of special tags
61 | specialTags: PropTypes.bool,
62 |
63 | // Returns a custom way to render the special button
64 | specialButtonRenderer: PropTypes.func,
65 |
66 | // Label for the special button.
67 | // Only used when a `specialButtonRenderer` is not defined.
68 | specialButtonLabel: PropTypes.string,
69 |
70 | // Fired when changing the tags with the `tags` array as the argument
71 | onChange: PropTypes.func.isRequired,
72 |
73 | // Same as the standard React SyntheticEvent
74 | onBlur: PropTypes.func,
75 | onFocus: PropTypes.func,
76 |
77 | // Fired when the user interaction is considered complete, invoked with `tags`
78 | onSubmit: PropTypes.func,
79 | };
80 |
81 | const tagRenderer = ({ value, special }, onClick) => (
82 |
83 | );
84 |
85 | const defaultProps = {
86 | blacklistChars: [','],
87 | copyButton: false,
88 | copyButtonLabel: 'Copy to clipboard',
89 | specialButtonLabel: 'Special',
90 | specialTags: false,
91 | tags: [],
92 | tagRenderer,
93 | onBlur: () => {},
94 | onChange: () => {},
95 | onFocus: () => {},
96 | onSubmit: () => {},
97 | };
98 |
99 | export const parseTags = map(t => ({ ...t, __id: uniqueId('tag') }));
100 | export const stripIds = map(t => ({ ...t, __id: undefined }));
101 |
102 | export const getPlainTextTags = flow(
103 | map(get('value')),
104 | join(','),
105 | );
106 |
107 | export const hasBlacklistedChars = curry((blacklist, str) => flow(
108 | split(''),
109 | intersection(blacklist),
110 | i => !isEmpty(i),
111 | )(str));
112 |
113 | export const parseValuesWith = curry((blacklist, str) => flow(
114 | split(','),
115 | map((value) => {
116 | if (hasBlacklistedChars(blacklist, value)) return false;
117 | return { value };
118 | }),
119 | reject(isEmpty)
120 | )(str));
121 |
122 | class TagsInput extends React.Component {
123 |
124 | constructor(props) {
125 | super(props);
126 |
127 | this.state = {
128 | tags: parseTags(props.tags),
129 |
130 | // Value is what the user is typing in the input
131 | value: '',
132 |
133 | // When in focus
134 | isFocused: false,
135 |
136 | // When in selection mode renders a texarea containing comma-separated
137 | // list of tag values
138 | isSelectMode: false,
139 |
140 | // Paste mode is used for handling when the user pastes content
141 | isPasteMode: false,
142 |
143 | // When true the created tags will be marked as special
144 | isSpecial: false,
145 |
146 | // ID internally assigned to the input. You don't want to change this
147 | id: uniqueId('TagsInput_'),
148 | };
149 |
150 | // Input events
151 | this.handleBlurInput = this.handleBlurInput.bind(this);
152 | this.handleFocusInput = this.handleFocusInput.bind(this);
153 | this.handleChangeInput = this.handleChangeInput.bind(this);
154 | this.handleClickFauxInput = this.handleClickFauxInput.bind(this);
155 | this.handleShortcut = this.handleShortcut.bind(this);
156 |
157 | // Textarea for copy/paste
158 | this.handleBlurTextarea = this.handleBlurTextarea.bind(this);
159 | this.handleFocusTextarea = this.handleFocusTextarea.bind(this);
160 |
161 | // Other events
162 | this.handleClickTagButton = this.handleClickTagButton.bind(this);
163 | this.handleClickSpecial = this.handleClickSpecial.bind(this);
164 | }
165 |
166 | componentWillReceiveProps(nextProps) {
167 | if (!isEqual(this.props.tags, nextProps.tags)) {
168 | this.setState({
169 | tags: parseTags(nextProps.tags),
170 | value: '',
171 | isPasteMode: false,
172 | });
173 | }
174 | }
175 |
176 | componentWillUpdate(nextProps, nextState) {
177 | // When the state is in pasteMode and we have a value, parse the value
178 | // and reset it
179 | if (nextState.isPasteMode && nextState.value) {
180 | const parseValue = parseValuesWith(this.props.blacklistChars);
181 | const tags = [...nextProps.tags, ...parseValue(nextState.value)];
182 |
183 | // We can't change the state here, rely on the onChange event to reset
184 | // the state.
185 | //
186 | // It works a folows:
187 | // 1. onChange is fired with new tags
188 | // 2. The element using this component will pass down the new tags
189 | // 3. In the componentWillReceiveProps lyfecycle the value is reset
190 | nextProps.onChange(tags);
191 | }
192 | }
193 |
194 | componentDidUpdate(prevProps, prevState) {
195 | // When going from select mode to normal, focus again on the input.
196 | // This must live in the didUpdate lyfecycle because the filed needs to be
197 | // already renderered with the correct params.
198 | if (prevState.isSelectMode && !this.state.isSelectMode) {
199 | this.input.focus();
200 | }
201 | }
202 |
203 | handleFocusInput() {
204 | // Ignore the event in selectMode
205 | if (this.state.isSelectMode) return;
206 |
207 | this.setState({ isFocused: true });
208 | this.props.onFocus();
209 | this.attachListeners();
210 | }
211 |
212 | handleBlurInput() {
213 | // Ignore the blur event in selectMode
214 | if (this.state.isSelectMode) return;
215 |
216 | this.setState({ isFocused: false });
217 | this.props.onBlur();
218 | this.removeListeners();
219 | }
220 |
221 | handleChangeInput(event) {
222 | const value = event.target.value;
223 | const lastChar = last(value);
224 |
225 | // If the value ends with a comma and it's not just a comma create a new
226 | // tag.
227 | // The order is imorant, this conditions goes before the blacklisting
228 | if (value.length > 1 && lastChar === ',') {
229 | return this.createTag();
230 | }
231 |
232 | // Ignore leading white spaces
233 | if (value.length === 1 && value === ' ') return null;
234 |
235 | if (this.props.blacklistChars.includes(lastChar)) return null;
236 |
237 | return this.setState({ value });
238 | }
239 |
240 | handleClickFauxInput(event) {
241 | if (this.state.isFocused) {
242 | event.preventDefault();
243 | event.stopPropagation();
244 |
245 | return;
246 | }
247 |
248 | this.input.focus();
249 | }
250 |
251 | handleFocusTextarea() {
252 | mousetrap.bind('mod+c', () => this.handleShortcut('mod+c'));
253 | }
254 |
255 | handleBlurTextarea() {
256 | this.setState({ isSelectMode: false });
257 | mousetrap.unbind('mod+c');
258 | }
259 |
260 | /**
261 | * Fire a change event without the passed tag
262 | */
263 | handleClickTagButton(id) {
264 | const tags = flow(
265 | filter(t => t.__id !== id),
266 | stripIds,
267 | )(this.state.tags);
268 |
269 | this.props.onChange(tags);
270 | }
271 |
272 | /**
273 | * Toggle the special state
274 | */
275 | handleClickSpecial() {
276 | this.setState(state => ({
277 | isSpecial: !state.isSpecial,
278 | }));
279 | }
280 |
281 | handleShortcut(type) {
282 | switch (type) {
283 |
284 | // Create a new tag
285 | case 'enter': {
286 | // Whe the user press enter and there is no text currently being
287 | // wrtitten, fire the onSubmit event
288 | if (!this.state.value.length) return this.submitTags();
289 |
290 | return this.createTag();
291 | }
292 |
293 | case 'esc': {
294 | // Reset the input value when pressing esc if we have a value
295 | if (this.state.value) return this.setState({ value: '' });
296 |
297 | return this.input.blur();
298 | }
299 |
300 | // When the value of the input is empty and the user presses backspace,
301 | // delete the previous tag
302 | case 'backspace': {
303 | if (this.state.value.length || !this.props.tags.length) return null;
304 |
305 | // Remove the last tag in the array (in an immutable way)
306 | const tags = [...this.props.tags];
307 | tags.pop();
308 |
309 | return this.props.onChange(tags);
310 | }
311 |
312 | // Select the content of the plaintext field
313 | case 'mod+a': {
314 | // Ignore the event if the user is typing
315 | if (this.state.value.length) return null;
316 |
317 | return this.setState({ isSelectMode: true });
318 | }
319 |
320 | // The user copied to the clipboard, so reset the selcted state
321 | case 'mod+c':
322 | return window.setTimeout(() => this.setState({ isSelectMode: false }), 100);
323 |
324 | // When pasting, we'll receive the data in the next state update
325 | case 'mod+v':
326 | return this.setState({ isPasteMode: true });
327 |
328 | default:
329 | return null;
330 |
331 | }
332 | }
333 |
334 | /**
335 | * Listen for keys and key combinations
336 | */
337 | attachListeners() {
338 | KEYBOARD_SHORTCUTS.forEach(key => (
339 | mousetrap.bind(key, () => this.handleShortcut(key))
340 | ));
341 | }
342 |
343 | /**
344 | * Remove all the document listeners
345 | */
346 | removeListeners() {
347 | KEYBOARD_SHORTCUTS.forEach(key => mousetrap.unbind(key));
348 | }
349 |
350 | /**
351 | * Create a tag from the current state value
352 | */
353 | createTag() {
354 | const tags = [
355 | ...this.state.tags,
356 | {
357 | value: this.state.value,
358 | special: this.state.isSpecial,
359 | },
360 | ];
361 | this.props.onChange(tags);
362 | }
363 |
364 | submitTags() {
365 | const tags = stripIds(this.state.tags);
366 | this.props.onSubmit(tags);
367 | }
368 |
369 | textareaRef(el) {
370 | if (!el) return;
371 |
372 | // Focus on the textarea
373 | el.focus();
374 | }
375 |
376 | copyButtonRef(el) {
377 | if (!el) return;
378 |
379 | if (this.clipboard) this.clipboard.destroy();
380 |
381 | this.clipboard = new Clipboard(el, {
382 | text: () => getPlainTextTags(this.state.tags),
383 | });
384 | }
385 |
386 | /**
387 | * The header block contains:
388 | * - Label
389 | * - Special button with tooltip
390 | */
391 | renderHeaderBlock() {
392 | const {
393 | label,
394 | specialTags,
395 | specialButtonLabel,
396 | error,
397 | specialButtonRenderer,
398 | } = this.props;
399 |
400 | // When neither label or special icon is specified, render nothing
401 | if (!label && !specialTags) return null;
402 |
403 | let labelBlock;
404 | let buttonBlock;
405 |
406 | if (label) {
407 | const labelClassName = classNames('TagsInput__label', {
408 | 'is-error': error,
409 | });
410 |
411 | labelBlock = (
412 |
415 | );
416 | }
417 |
418 | if (specialTags && specialButtonRenderer) {
419 | buttonBlock = specialButtonRenderer(this.state.isSpecial, this.handleClickSpecial);
420 | } else if (specialTags && specialButtonLabel) {
421 | const btnClassName = classNames('TagsInput__special-btn', {
422 | 'is-active': this.state.isSpecial,
423 | });
424 |
425 | buttonBlock = (
426 |
429 | );
430 | }
431 |
432 | return (
433 |
434 | {labelBlock}
435 | {buttonBlock}
436 |
437 | );
438 | }
439 |
440 | /**
441 | * The footer block contains:
442 | * - Copy button
443 | * - Erorr message
444 | */
445 | renderFooterBlock() {
446 | const { copyButton, copyButtonLabel, error } = this.props;
447 |
448 | // Render nothing when neHither an error nor the button in required
449 | if (!copyButton && !error) return null;
450 |
451 | let buttonBlock;
452 | let errorBlock;
453 |
454 | if (copyButton) {
455 | buttonBlock = (
456 |
459 | );
460 | }
461 |
462 | if (error) {
463 | errorBlock = (
464 |
465 | {error}
466 |
467 | );
468 | }
469 |
470 | return (
471 |
472 | {errorBlock}
473 | {buttonBlock}
474 |
475 | );
476 | }
477 |
478 | renderActiveTags() {
479 | const { tagRenderer } = this.props;
480 |
481 | return this.state.tags.map(tag => (
482 |
483 | {tagRenderer(tag, () => this.handleClickTagButton(tag.__id))}
484 |
485 | ));
486 | }
487 |
488 | render() {
489 | const { error, placeholder } = this.props;
490 |
491 | const headerBlock = this.renderHeaderBlock();
492 | const footerBlock = this.renderFooterBlock();
493 |
494 | const inputRef = (el) => { this.input = el; };
495 | const inputWidth = `${this.state.value.length + 1}ch`;
496 | const inputClassName = classNames('TagsInput__input', {
497 | 'is-focused': this.state.isFocused || this.state.isSelectMode,
498 | 'is-selected': this.state.isSelectMode,
499 | 'is-error': error,
500 | });
501 |
502 | let activeTags;
503 |
504 | // In selectMode render a texarea whith the content already pre-selected.
505 | // Why a texarea? Beacause it can wrap to the next line, an input can't
506 | if (this.state.isSelectMode) {
507 | activeTags = (
508 |