├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── README.md ├── copy-paste-demo.gif ├── demo.gif ├── demo ├── .gitignore ├── package-lock.json ├── package.json ├── public │ └── index.html └── src │ ├── Demo.css │ ├── Demo.jsx │ ├── SimpleTag.css │ ├── TagsInput.css │ ├── index.css │ └── index.js ├── package-lock.json ├── package.json ├── src ├── SimpleTag.jsx ├── TagsInput.jsx └── __tests__ │ ├── TagsInput.jsx │ └── __snapshots__ │ └── TagsInput.jsx.snap └── yarn.lock /.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 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo 2 | node_modules 3 | examples 4 | coverage 5 | demo.gif 6 | -------------------------------------------------------------------------------- /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 | ![demo](https://raw.githubusercontent.com/sentisis/react-tags-input/master/demo.gif) 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 | ![copy-paste-demo](https://raw.githubusercontent.com/sentisis/react-tags-input/master/copy-paste-demo.gif) 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 | -------------------------------------------------------------------------------- /copy-paste-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lang-ai/react-tags-input/eda35cc45420bd38a3b3210dd1555faff0794a97/copy-paste-demo.gif -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lang-ai/react-tags-input/eda35cc45420bd38a3b3210dd1555faff0794a97/demo.gif -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Tags Input demo 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 |