├── README.md ├── src ├── utils │ ├── selectors.tsx │ └── functions.tsx ├── components │ ├── Tag.tsx │ └── ContentEditable.tsx ├── styles │ └── index.scss └── index.tsx ├── example ├── index.html └── index.tsx ├── tslint.json ├── LICENSE.txt ├── tsconfig.json ├── package.json ├── .npmignore └── .gitignore /README.md: -------------------------------------------------------------------------------- 1 | # React Tag Input 2 | 3 | React Tag Input is a robust, minimal and performant input field for creating multiple tags. 4 | 5 | [See demo & documentation](https://betterstack.dev/projects/react-tag-input) 6 | -------------------------------------------------------------------------------- /src/utils/selectors.tsx: -------------------------------------------------------------------------------- 1 | 2 | export const classSelectors = { 3 | wrapper: "react-tag-input", 4 | input: "react-tag-input__input", 5 | tag: "react-tag-input__tag", 6 | tagContent: "react-tag-input__tag__content", 7 | tagRemove: "react-tag-input__tag__remove", 8 | tagRemoveReadOnly: "react-tag-input__tag__remove-readonly", 9 | }; 10 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Tag Input Demo 6 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/utils/functions.tsx: -------------------------------------------------------------------------------- 1 | 2 | export function removeLineBreaks(value: string) { 3 | return value.replace(/(\r\n|\n|\r)/gm, ""); 4 | } 5 | 6 | // TAKEN FROM - https://github.com/janl/mustache.js/blob/master/mustache.js#L55 7 | const htmlEntityMap = { 8 | "&": "&", 9 | "<": "<", 10 | ">": ">", 11 | '"': """, 12 | "'": "'", 13 | "/": "/", 14 | "`": "`", 15 | "=": "=", 16 | }; 17 | export function escapeHtml(value: string) { 18 | return String(value).replace(/[&<>"'`=\/]/g, (s) => { 19 | // @ts-ignore 20 | return htmlEntityMap[s]; 21 | }); 22 | } 23 | 24 | export function safeHtmlString(value: string) { 25 | return escapeHtml(removeLineBreaks(value)); 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint:latest" 6 | ], 7 | "jsRules": {}, 8 | "rules": { 9 | "member-access": false, 10 | "object-literal-sort-keys": false, 11 | "no-trailing-whitespace": false, 12 | "ordered-imports": false, 13 | "one-line": false, 14 | "no-console": false, 15 | "max-line-length": false, 16 | "object-literal-shorthand": false, 17 | "interface-name": false, 18 | "no-empty-interface": false, 19 | "no-implicit-dependencies": false, 20 | "no-var-requires": false, 21 | "no-submodule-imports": false, 22 | "max-classes-per-file": false 23 | }, 24 | "rulesDirectory": [] 25 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tarun Ramesh 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. -------------------------------------------------------------------------------- /src/components/Tag.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {classSelectors} from "../utils/selectors"; 3 | import {ContentEditable} from "./ContentEditable"; 4 | 5 | interface Props { 6 | value: string; 7 | index: number; 8 | editable: boolean; 9 | readOnly: boolean; 10 | inputRef: React.RefObject; 11 | update: (i: number, value: string) => void; 12 | remove: (i: number) => void; 13 | validator?: (val: string) => boolean; 14 | removeOnBackspace?: boolean; 15 | } 16 | 17 | export class Tag extends React.Component { 18 | 19 | innerEditableRef: React.RefObject = React.createRef(); 20 | 21 | remove = () => this.props.remove(this.props.index); 22 | 23 | render() { 24 | 25 | const { value, index, editable, inputRef, validator, update, readOnly, removeOnBackspace } = this.props; 26 | 27 | const tagRemoveClass = !readOnly ? 28 | classSelectors.tagRemove : `${classSelectors.tagRemove} ${classSelectors.tagRemoveReadOnly}`; 29 | 30 | return ( 31 |
32 | {!editable &&
{value}
} 33 | {editable && ( 34 | update(index, newValue)} 40 | remove={this.remove} 41 | validator={validator} 42 | removeOnBackspace={removeOnBackspace} 43 | /> 44 | )} 45 |
46 |
47 | ); 48 | 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | 4 | "baseUrl": ".", 5 | "rootDir": "./src", 6 | "outDir": "./build/module", 7 | "sourceMap": true, 8 | "inlineSources": true, 9 | "declaration": true, 10 | 11 | /* MODULES */ 12 | "module": "es6", 13 | "target": "es5", 14 | "lib": [ "es5", "dom" ], 15 | "jsx": "react", 16 | "allowSyntheticDefaultImports": true, 17 | "esModuleInterop": true, 18 | "moduleResolution": "node", 19 | 20 | /* Strict Type-Checking Options */ 21 | "strict": true, 22 | "noImplicitAny": true, 23 | "strictNullChecks": true, 24 | "noImplicitThis": true, 25 | "alwaysStrict": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "removeComments": true, 28 | "experimentalDecorators": true, 29 | 30 | /* Additional Checks */ 31 | "noUnusedLocals": false, /* Report errors on unused locals. */ 32 | "noUnusedParameters": false, /* Report errors on unused parameters. */ 33 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 34 | "noFallthroughCasesInSwitch": true, 35 | 36 | "plugins": [ 37 | { 38 | "name": "tslint-language-service", 39 | "disableNoUnusedVariableRule": false, 40 | "supressWhileTypeErrorsPresent": false 41 | } 42 | ] 43 | 44 | }, 45 | 46 | "include": [ 47 | "./src/**/*" 48 | ], 49 | "exclude": [ 50 | "node_modules", 51 | "dist" 52 | ] 53 | 54 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pathofdev/react-tag-input", 3 | "version": "1.0.7", 4 | "description": "A simple tag input component for React with editable tags", 5 | "main": "./build/umd/index.min.js", 6 | "module": "./build/module/index.js", 7 | "types": "./build/module/index.d.ts", 8 | "private": false, 9 | "sideEffects": false, 10 | "homepage": "https://pathof.dev/projects/react-tag-input", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/pathofdev/react-tag-input" 14 | }, 15 | "scripts": { 16 | "module-build": "tsc", 17 | "umd-build": "rollup ./build/module/index.js --format umd -m --name 'ReactTagInput' -g react:React --file ./build/umd/index.js", 18 | "umd-minify": "uglifyjs ./build/umd/index.js -o ./build/umd/index.min.js --source-map url", 19 | "scss-build": "sass src/styles/index.scss build/index.css --style compressed --source-map", 20 | "build": "rm -rf build && npm run module-build && npm run umd-build && npm run umd-minify && npm run scss-build", 21 | "push": "git push origin master --tags && npm run build && npm publish", 22 | "example": "parcel ./example/index.html" 23 | }, 24 | "keywords": [ 25 | "input tag", 26 | "tag input", 27 | "react input tag", 28 | "react tag input component", 29 | "react tag input" 30 | ], 31 | "author": "pathof.dev", 32 | "license": "MIT", 33 | "devDependencies": { 34 | "@types/react": "^16.8.25", 35 | "@types/react-dom": "^16.8.5", 36 | "parcel": "^1.12.3", 37 | "react": "^16.8.6", 38 | "react-dom": "^16.8.6", 39 | "rollup": "^1.19.4", 40 | "sass": "^1.2.3", 41 | "tslint": "^5.18.0", 42 | "tslint-language-service": "^0.9.9", 43 | "typescript": "^3.5.3", 44 | "uglify-js": "^3.6.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | .idea/ 12 | 13 | dist/ 14 | 15 | keys/ 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | *.lcov 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # TypeScript v1 declaration files 53 | typings/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Optional eslint cache 62 | .eslintcache 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache/ 79 | 80 | # next.js build output 81 | .next 82 | 83 | # nuxt.js build output 84 | .nuxt 85 | 86 | # vuepress build output 87 | .vuepress/dist 88 | 89 | # Serverless directories 90 | .serverless/ 91 | 92 | # FuseBox cache 93 | .fusebox/ 94 | 95 | # DynamoDB Local files 96 | .dynamodb/ 97 | 98 | ### Sass template 99 | .sass-cache/ 100 | *.css.map 101 | *.sass.map 102 | *.scss.map 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | .idea/ 12 | 13 | dist/ 14 | build/ 15 | 16 | keys/ 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | *.lcov 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | jspm_packages/ 52 | 53 | # TypeScript v1 declaration files 54 | typings/ 55 | 56 | # TypeScript cache 57 | *.tsbuildinfo 58 | 59 | # Optional npm cache directory 60 | .npm 61 | 62 | # Optional eslint cache 63 | .eslintcache 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache/ 80 | 81 | # next.js build output 82 | .next 83 | 84 | # nuxt.js build output 85 | .nuxt 86 | 87 | # vuepress build output 88 | .vuepress/dist 89 | 90 | # Serverless directories 91 | .serverless/ 92 | 93 | # FuseBox cache 94 | .fusebox/ 95 | 96 | # DynamoDB Local files 97 | .dynamodb/ 98 | 99 | ### Sass template 100 | .sass-cache/ 101 | *.css.map 102 | *.sass.map 103 | *.scss.map 104 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | 2 | $reactTagColorGray: #e1e1e1; 3 | $reactTagColorText: #333; 4 | $reactTagHeight: 2.375em; 5 | $reactTagFontSize: 1em; 6 | $reactTagBorderRadius: 3px; 7 | $reactTagMarginPadding: 0.1875em; 8 | 9 | .react-tag-input { 10 | 11 | box-sizing: border-box; 12 | position: relative; 13 | width: 100%; 14 | height: auto; 15 | min-height: $reactTagHeight; 16 | padding: $reactTagMarginPadding $reactTagMarginPadding * 2; 17 | overflow-y: auto; 18 | 19 | display: flex; 20 | flex-wrap: wrap; 21 | align-items: center; 22 | 23 | font-size: 1rem; 24 | background: white; 25 | color: $reactTagColorText; 26 | border: 1px solid $reactTagColorGray; 27 | border-radius: $reactTagBorderRadius; 28 | 29 | * { box-sizing: border-box; } 30 | 31 | > * { margin: $reactTagMarginPadding; } 32 | 33 | @at-root #{&}__input { 34 | 35 | width: auto; 36 | flex-grow: 1; 37 | height: $reactTagHeight - 0.5; 38 | padding: 0 0 0 $reactTagMarginPadding; 39 | margin: 0 $reactTagMarginPadding; 40 | 41 | font-size: $reactTagFontSize; 42 | line-height: 1; 43 | 44 | background: transparent; 45 | color: $reactTagColorText; 46 | border: none; 47 | border-radius: $reactTagBorderRadius; 48 | outline: 0; 49 | box-shadow: none; 50 | -webkit-appearance: none; 51 | 52 | &::placeholder, &:-moz-placeholder, &:-ms-input-placeholder, &::-moz-placeholder, &::-webkit-input-placeholder { 53 | color: $reactTagColorText; 54 | } 55 | &:focus { 56 | border: none; 57 | } 58 | 59 | } 60 | 61 | @at-root #{&}__tag { 62 | position: relative; 63 | display: flex; 64 | align-items: center; 65 | font-size: $reactTagFontSize - 0.15; 66 | line-height: 1; 67 | background: $reactTagColorGray; 68 | border-radius: $reactTagBorderRadius; 69 | } 70 | 71 | @at-root #{&}__tag__content { 72 | outline: 0; 73 | border: none; 74 | white-space: nowrap; 75 | padding: 0 $reactTagMarginPadding * 2.5; 76 | } 77 | 78 | @at-root #{&}__tag__remove { 79 | 80 | position: relative; 81 | height: $reactTagFontSize * 2; 82 | width: $reactTagFontSize * 2; 83 | 84 | font-size: $reactTagFontSize - 0.15; 85 | cursor: pointer; 86 | background: darken($reactTagColorGray, 5%); 87 | border-top-right-radius: $reactTagBorderRadius; 88 | border-bottom-right-radius: $reactTagBorderRadius; 89 | 90 | &:before, &:after { 91 | position: absolute; 92 | top: 50%; 93 | left: 50%; 94 | content: ' '; 95 | height: $reactTagFontSize - 0.1; 96 | width: 0.15em; 97 | background-color: $reactTagColorText; 98 | } 99 | &:before { 100 | transform: translateX(-50%) translateY(-50%) rotate(45deg); 101 | } 102 | &:after { 103 | transform: translateX(-50%) translateY(-50%) rotate(-45deg); 104 | } 105 | 106 | 107 | @at-root #{&}-readonly { 108 | width: 0; 109 | &:before, &:after { 110 | content: ''; 111 | width: 0; 112 | } 113 | } 114 | 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import ReactTagInput, {ReactTagInputProps} from "../src/index"; 4 | import "../src/styles/index.scss"; 5 | 6 | const root = document.getElementById("root"); 7 | 8 | const initialSettings: ReactTagInputProps = { 9 | tags: [], 10 | onChange: (tags) => {}, 11 | placeholder: "Types and press enter", 12 | maxTags: 10, 13 | editable: true, 14 | readOnly: false, 15 | removeOnBackspace: true, 16 | validator: undefined, 17 | }; 18 | 19 | function Example() { 20 | const [tags, setTags] = React.useState(["machine-1", "machine-2"]); 21 | const [settings, setSettings] = React.useState(initialSettings); 22 | console.log(tags, settings); 23 | return ( 24 | <> 25 | setTags(value)} 29 | /> 30 | 31 |
32 | 33 |
34 | 42 | 43 | 51 |
52 | 53 |
54 | 62 | 63 | 71 | 72 | 80 | 81 | 92 |
93 | 94 |
95 | 96 | ); 97 | } 98 | 99 | ReactDOM.render(, root); 100 | -------------------------------------------------------------------------------- /src/components/ContentEditable.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {safeHtmlString} from "../utils/functions"; 3 | 4 | interface Props { 5 | value: string; 6 | className: string; 7 | innerEditableRef: React.RefObject; 8 | inputRef: React.RefObject; 9 | change: (value: string) => void; 10 | remove: () => void; 11 | validator?: (value: string) => boolean; 12 | removeOnBackspace?: boolean; 13 | } 14 | 15 | export class ContentEditable extends React.Component { 16 | 17 | // Track focus state of editable tag 18 | focused: boolean = false; 19 | 20 | // Track if element has been removed from DOM 21 | removed: boolean = false; 22 | 23 | // Save value before input is focused / user starts typing 24 | preFocusedValue: string = ""; 25 | 26 | componentDidMount() { 27 | this.preFocusedValue = this.getValue(); 28 | } 29 | 30 | onPaste = (e: React.ClipboardEvent) => { 31 | 32 | // Cancel paste event 33 | e.preventDefault(); 34 | 35 | // Remove formatting from clipboard contents 36 | const text = e.clipboardData.getData("text/plain"); 37 | 38 | // Insert text manually from paste command 39 | document.execCommand("insertHTML", false, safeHtmlString(text)); 40 | 41 | } 42 | 43 | onFocus = () => { 44 | this.preFocusedValue = this.getValue(); 45 | this.focused = true; 46 | } 47 | 48 | onBlur = () => { 49 | 50 | this.focused = false; 51 | 52 | const ref = this.props.innerEditableRef.current; 53 | const { validator, change } = this.props; 54 | 55 | if (!this.removed && ref) { 56 | 57 | // On blur, if no content in tag, remove it 58 | if (ref.innerText === "") { 59 | this.props.remove(); 60 | return; 61 | } 62 | 63 | // Validate input if needed 64 | if (validator) { 65 | const valid = validator(this.getValue()); 66 | // If invalidate, switch ref back to pre focused value 67 | if (!valid) { 68 | ref.innerText = this.preFocusedValue; 69 | return; 70 | } 71 | } 72 | 73 | change(ref.innerText); 74 | 75 | } 76 | 77 | } 78 | 79 | onKeyDown = (e: React.KeyboardEvent) => { 80 | 81 | // On enter, focus main tag input 82 | if (e.keyCode === 13) { 83 | e.preventDefault(); 84 | this.focusInputRef(); 85 | return; 86 | } 87 | 88 | // On backspace, if no content in ref, remove tag and focus main tag input 89 | const { removeOnBackspace } = this.props; 90 | const value = this.getValue(); 91 | if (removeOnBackspace && e.keyCode === 8 && value === "") { 92 | this.removed = true; 93 | this.props.remove(); 94 | this.focusInputRef(); 95 | return; 96 | } 97 | 98 | } 99 | 100 | getValue = () => { 101 | const ref = this.getRef(); 102 | return ref ? ref.innerText : ""; 103 | } 104 | 105 | getRef = () => { 106 | return this.props.innerEditableRef.current; 107 | } 108 | 109 | focusInputRef = () => { 110 | const { inputRef } = this.props; 111 | if (inputRef && inputRef.current) { 112 | inputRef.current.focus(); 113 | } 114 | } 115 | 116 | render() { 117 | const { value, className, innerEditableRef } = this.props; 118 | return ( 119 |
129 | ); 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Tag} from "./components/Tag"; 3 | import {classSelectors} from "./utils/selectors"; 4 | 5 | type Tags = string[]; 6 | 7 | export interface ReactTagInputProps { 8 | tags: Tags; 9 | onChange: (tags: Tags) => void; 10 | placeholder?: string; 11 | maxTags?: number; 12 | validator?: (val: string) => boolean; 13 | editable?: boolean; 14 | readOnly?: boolean; 15 | removeOnBackspace?: boolean; 16 | } 17 | 18 | interface State { 19 | input: string; 20 | } 21 | 22 | export default class ReactTagInput extends React.Component { 23 | 24 | state = { input: "" }; 25 | 26 | // Ref for input element 27 | inputRef: React.RefObject = React.createRef(); 28 | 29 | onInputChange = (e: React.ChangeEvent) => { 30 | this.setState({ input: e.target.value }); 31 | } 32 | 33 | onInputKeyDown = (e: React.KeyboardEvent) => { 34 | 35 | const { input } = this.state; 36 | const { validator, removeOnBackspace } = this.props; 37 | 38 | // On enter 39 | if (e.keyCode === 13) { 40 | 41 | // Prevent form submission if tag input is nested in
42 | e.preventDefault(); 43 | 44 | // If input is blank, do nothing 45 | if (input === "") { return; } 46 | 47 | // Check if input is valid 48 | const valid = validator !== undefined ? validator(input) : true; 49 | if (!valid) { 50 | return; 51 | } 52 | 53 | // Add input to tag list 54 | this.addTag(input); 55 | 56 | } 57 | // On backspace or delete 58 | else if (removeOnBackspace && (e.keyCode === 8 || e.keyCode === 46)) { 59 | 60 | // If currently typing, do nothing 61 | if (input !== "") { 62 | return; 63 | } 64 | 65 | // If input is blank, remove previous tag 66 | this.removeTag(this.props.tags.length - 1); 67 | 68 | } 69 | 70 | } 71 | 72 | addTag = (value: string) => { 73 | const tags = [ ...this.props.tags ]; 74 | if (!tags.includes(value)) { 75 | tags.push(value); 76 | this.props.onChange(tags); 77 | } 78 | this.setState({ input: "" }); 79 | } 80 | 81 | removeTag = (i: number) => { 82 | const tags = [ ...this.props.tags ]; 83 | tags.splice(i, 1); 84 | this.props.onChange(tags); 85 | } 86 | 87 | updateTag = (i: number, value: string) => { 88 | const tags = [...this.props.tags]; 89 | const numOccurencesOfValue = tags.reduce((prev, currentValue, index) => prev + (currentValue === value && index !== i ? 1 : 0) , 0); 90 | if (numOccurencesOfValue > 0) { 91 | tags.splice(i, 1); 92 | } else { 93 | tags[i] = value; 94 | } 95 | this.props.onChange(tags); 96 | } 97 | 98 | render() { 99 | 100 | const { input } = this.state; 101 | 102 | const { tags, placeholder, maxTags, editable, readOnly, validator, removeOnBackspace } = this.props; 103 | 104 | const maxTagsReached = maxTags !== undefined ? tags.length >= maxTags : false; 105 | 106 | const isEditable = readOnly ? false : (editable || false); 107 | 108 | const showInput = !readOnly && !maxTagsReached; 109 | 110 | return ( 111 |
112 | {tags.map((tag, i) => ( 113 | 125 | ))} 126 | {showInput && 127 | 135 | } 136 |
137 | ); 138 | 139 | } 140 | 141 | } 142 | --------------------------------------------------------------------------------