├── .babelrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── example.gif ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json └── src ├── examples └── App.js ├── index.js ├── lib ├── TagInput.js ├── index.js └── styled │ ├── Input.js │ ├── Tag.js │ ├── TagDelete.js │ └── Wrapper.js ├── setupTests.js └── test └── TagInput.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-app"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://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.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | /dist -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | cache: 5 | directories: 6 | - node_modules 7 | script: 8 | - npm test 9 | - npm run build 10 | after_script: 11 | - npm run coverage 12 | - npm i coveralls && cat ./coverage/lcov.info | coveralls -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kevin 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Tag Input 2 | 3 | A simple (but fully customizable) react tag input component built using styled components. 4 | 5 | [![Build Status](https://travis-ci.com/leekevinyg/react-tag-input.svg?branch=master)](https://travis-ci.com/leekevinyg/react-tag-input) 6 | [![Coverage Status](https://coveralls.io/repos/github/leekevinyg/react-tag-input/badge.svg?branch=master)](https://coveralls.io/github/leekevinyg/react-tag-input?branch=master) 7 | [![npm version](https://badge.fury.io/js/reactjs-tag-input.svg)](https://badge.fury.io/js/reactjs-tag-input) 8 | 9 | - Demo 10 | - Installation 11 | - Usage 12 | - API 13 | 14 | 15 | # Demo 16 | 17 | [![example](https://github.com/leekevinyg/react-tag-input/blob/master/example.gif?raw=true)](https://leekevinyg.github.io/react-tag-input/) 18 | 19 | [Interactive Demo](https://leekevinyg.github.io/react-tag-input/) 20 | 21 | 22 | # Installation 23 | 24 | ```npm i reactjs-tag-input --save``` 25 | 26 | 27 | # Usage 28 | 29 | ``` 30 | 31 | import { TagInput } from 'reactjs-tag-input' 32 | 33 | class Example extends React.Component { 34 | constructor(props) { 35 | super(props); 36 | this.state = {tags: []} 37 | this.onTagsChanged = this.onTagsChanged.bind(this); 38 | } 39 | 40 | onTagsChanged(tags) { 41 | this.setState({tags}) 42 | } 43 | 44 | render() { 45 | return 46 | } 47 | } 48 | 49 | ``` 50 | 51 | An example with pre-rendered tags can be found [here](https://github.com/leekevinyg/react-tag-input/blob/master/src/examples/App.js). 52 | 53 | 54 | # API 55 | 56 | This component exposes the following props: 57 | 58 | * **tags (required)** 59 | 60 | An array of tags to be rendered in the input. If not empty, this must be an array of js objects. The objects are required to have a ```displayValue``` property specifying what should be displayed in the tag input to represent the item. 61 | 62 | * **onTagsChanged (required)** 63 | 64 | A function that gets called when tags are added or deleted in the input. The function gets the new tag array as it's argument. 65 | 66 | * **onInputChanged** 67 | 68 | A function that gets passed to the internal input ```onChange``` attribute. 69 | 70 | * **wrapperStyle** 71 | 72 | A js template string to be used to override the default component wrapper styles. The example below will override the wrapper ```background``` and disable the default ```box-shadow```. The template string can be anything that you would normally pass down as a [styled-component](https://www.styled-components.com/docs/basics#getting-started "Styled Component") configuration. 73 | 74 | ``` 75 | 79 | 80 | ``` 81 | 82 | * **inputStyle** 83 | 84 | A js template string to be used to override the default component input styles. The example below will override the input ```background``` color and override the placeholder text styling in webkit based browsers. The template string can be anything that you would normally pass down as a [styled-component](https://www.styled-components.com/docs/basics#getting-started "Styled Component") configuration. 85 | 86 | ``` 87 | 95 | 96 | ``` 97 | 98 | * **tagStyle** 99 | 100 | A js template string to be used to override the default component tag styles. The example below will override the tag ```background``` color. The template string can be anything that you would normally pass down as a [styled-component](https://www.styled-components.com/docs/basics#getting-started "Styled Component") configuration. 101 | 102 | ``` 103 | 106 | 107 | ``` 108 | 109 | * **tagDeleteStyle** 110 | 111 | A js template string to be used to override the default component that is rendered next to each tag for deletion purposes. The example below will override the tag ```font-size``` property. The template string can be anything that you would normally pass down as a [styled-component](https://www.styled-components.com/docs/basics#getting-started "Styled Component") configuration. 112 | 113 | ``` 114 | 117 | 118 | ``` 119 | 120 | * **hideInputPlaceholderTextIfTagsPresent** 121 | 122 | A boolean flag to indicate whether the input placeholder text should be hidden if there are tags present. Defaults to true. 123 | 124 | * **tagDeleteIcon** 125 | 126 | A react component that will be rendered next to each tag to allow for the deletion of it. Defaults to ' x'. 127 | 128 | ``` 129 | import CustomTagDeleteIcon from './assets/TagDeleteIcon.png'; 130 | 131 | 132 | ``` 133 | 134 | * **addTagOnEnterKeyPress** 135 | 136 | A boolean flag to control whether hitting the enter will will add a tag into the input. Defaults to true. 137 | 138 | ``` 139 | 140 | 141 | ``` 142 | * **placeholder** 143 | 144 | Defaults to "Type something and hit enter...", but can be overridden with this prop 145 | 146 | ``` 147 | 148 | ``` 149 | 150 | # Contributing 151 | 152 | To start up a dev env that runs the example react app utilizing the component, checkout the project from the [github homepage](https://github.com/leekevinyg/react-tag-input) and run: 153 | 154 | * ``` npm install ``` 155 | * ``` npm start ``` 156 | 157 | To build the example app: 158 | 159 | * ``` npm run build-examples ``` 160 | 161 | The output of the above command is what is running at the [interactive demo](https://leekevinyg.github.io/react-tag-input/). 162 | 163 | To build for an npm registry: 164 | 165 | * ``` npm run build ```. 166 | 167 | When this command is run from ```src/lib```, contents in the ```src/lib``` folder will be bundled and outputted to the ```dist``` folder, which can be deployed to an npm registry of your choice. 168 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leekevinyg/react-tag-input/037a05e7d35fcb461e357a989e92b5fab7670d4a/example.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactjs-tag-input", 3 | "version": "2.0.13", 4 | "homepage": "http://leekevinyg.github.io/react-tag-input", 5 | "repository": "https://github.com/leekevinyg/react-tag-input", 6 | "license": "MIT", 7 | "keywords": [ 8 | "react", 9 | "tag", 10 | "tags", 11 | "input", 12 | "pills", 13 | "pill", 14 | "javascript" 15 | ], 16 | "main": "dist/index.js", 17 | "module": "dist/index.js", 18 | "files": [ 19 | "dist" 20 | ], 21 | "devDependencies": { 22 | "babel-cli": "^6.26.0", 23 | "enzyme": "^3.8.0", 24 | "enzyme-adapter-react-16": "^1.7.1", 25 | "gh-pages": "^2.0.1", 26 | "react": "^16.3.2", 27 | "react-dom": "^16.3.2", 28 | "react-scripts": "1.1.4", 29 | "react-test-renderer": "^16.3.2" 30 | }, 31 | "peerDependencies": { 32 | "react": "^16.3.2", 33 | "react-dom": "^16.3.2" 34 | }, 35 | "scripts": { 36 | "start": "react-scripts start", 37 | "build-examples": "react-scripts build", 38 | "test": "react-scripts test --env=jsdom", 39 | "coverage": "npm run test -- --coverage", 40 | "eject": "react-scripts eject", 41 | "build": "rm -rf dist && NODE_ENV=production babel src/lib --out-dir dist --copy-files --ignore __tests__,spec.js,test.js,__snapshots__", 42 | "predeploy": "npm run build-examples", 43 | "deploy": "gh-pages -d build" 44 | }, 45 | "dependencies": { 46 | "prop-types": "^15.6.2", 47 | "styled-components": "^4.1.3" 48 | }, 49 | "jest": { 50 | "collectCoverageFrom": [ 51 | "src/lib/**/*.{js}" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leekevinyg/react-tag-input/037a05e7d35fcb461e357a989e92b5fab7670d4a/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/examples/App.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import styled from 'styled-components'; 3 | import { TagInput } from "../lib"; 4 | 5 | const exampleTags = [{ 6 | id: 1, 7 | displayValue: 'Bruce Lee', 8 | }, { 9 | id: 2, 10 | displayValue: 'Royce Gracie', 11 | }]; 12 | 13 | const onTagsChanged = (newTags) => { 14 | console.log('tags changed to: ', newTags); 15 | }; 16 | 17 | const onInputChanged = (e) => { 18 | console.log(`input value is now: ${e.target.value}`); 19 | } 20 | 21 | const GithubRibbon = styled.div` 22 | position: absolute; 23 | font-family: 'Hind', sans-serif; 24 | font-size: 20px; 25 | top:-35px; 26 | right:-114px; 27 | transform-origin: top left; 28 | transform: rotate(45deg); 29 | background-color: #37393A; 30 | `; 31 | 32 | const GithubRibbonLink = styled.a` 33 | display: inline-block; 34 | width: 250px; 35 | color: #fff; 36 | font-size: 0.8em; 37 | letter-spacing: 0.06em; 38 | text-decoration: none; 39 | text-align: center; 40 | line-height: 30px; 41 | `; 42 | 43 | const App = () => ( 44 | 45 | 46 | 47 | 48 | Fork me on github 49 | 50 | 51 | 52 | ); 53 | 54 | export default App; 55 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./examples/App"; 4 | 5 | ReactDOM.render(, document.getElementById("root")); 6 | -------------------------------------------------------------------------------- /src/lib/TagInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components' 4 | import Wrapper from './styled/Wrapper'; 5 | import Tag from './styled/Tag'; 6 | import Input from './styled/Input'; 7 | import TagDelete from './styled/TagDelete'; 8 | 9 | class TagInput extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { 13 | selectedTags: [], 14 | } 15 | this.renderTags = this.renderTags.bind(this); 16 | this.onInputKeyUp = this.onInputKeyUp.bind(this); 17 | this.onInputKeyDown = this.onInputKeyDown.bind(this); 18 | this.focusInput = this.focusInput.bind(this); 19 | this.removeTag = this.removeTag.bind(this); 20 | this.input = React.createRef(); 21 | } 22 | 23 | componentDidMount () { 24 | const { tags } = this.props; 25 | const propTags = tags.map((tag, index) => { 26 | return { 27 | index, 28 | ...tag, 29 | } 30 | }); 31 | 32 | this.setState((state) => ({ 33 | selectedTags: [ 34 | ...state.selectedTags, 35 | ...propTags, 36 | ] 37 | })); 38 | this.focusInput(); 39 | } 40 | 41 | onInputKeyUp (e) { 42 | const { addTagOnEnterKeyPressed, onTagsChanged} = this.props; 43 | const inputValue = e.target.value; 44 | const inputNotEmpty = inputValue && inputValue.trim() !== ''; 45 | const addTag = () => { 46 | this.setState((state) => ({ 47 | selectedTags: [ 48 | ...state.selectedTags, { 49 | index: state.selectedTags.length + 1, 50 | displayValue: inputValue, 51 | }, 52 | ] 53 | }), () => { 54 | const { selectedTags } = this.state; 55 | 56 | this.clearInput(); 57 | onTagsChanged(selectedTags); 58 | }); 59 | } 60 | 61 | if (e.key === 'Enter' && inputNotEmpty && addTagOnEnterKeyPressed) { 62 | addTag(); 63 | } 64 | } 65 | 66 | onInputKeyDown (e) { 67 | const { onTagsChanged } = this.props; 68 | const deleteLastTag = () => { 69 | this.setState((state) => ({ 70 | selectedTags: state.selectedTags.splice(0, state.selectedTags.length - 1), 71 | }), () => { 72 | const { selectedTags } = this.state; 73 | onTagsChanged(selectedTags); 74 | }); 75 | }; 76 | 77 | if (e.key === 'Backspace' && e.target.selectionStart === 0) { 78 | deleteLastTag(); 79 | } 80 | } 81 | 82 | clearInput () { 83 | this.input.value = ''; 84 | } 85 | 86 | focusInput () { 87 | this.input.focus(); 88 | } 89 | 90 | removeTag (index) { 91 | this.setState((state) => ({ 92 | selectedTags: state.selectedTags.filter(tag => tag.index !== index), 93 | }), () => { 94 | const { selectedTags } = this.state; 95 | const { onTagsChanged } = this.props; 96 | onTagsChanged(selectedTags); 97 | }); 98 | } 99 | 100 | renderTags () { 101 | const { selectedTags } = this.state; 102 | const TagComponent = this.getTagStyledComponent(); 103 | const Delete = this.getTagDeleteComponent(); 104 | const DeleteIcon = this.getDeleteIcon(); 105 | 106 | return selectedTags.length > 0 ? 107 | selectedTags.map((tag, index) => 108 | 109 | {tag.displayValue} 110 | this.removeTag(tag.index)}>{DeleteIcon} 111 | 112 | ) : 113 | null; 114 | } 115 | 116 | renderPlaceholder () { 117 | const { selectedTags } = this.state; 118 | const { placeholder, hideInputPlaceholderTextIfTagsPresent } = this.props; 119 | 120 | return hideInputPlaceholderTextIfTagsPresent && selectedTags.length > 0 ? null : placeholder; 121 | } 122 | 123 | getDeleteIcon () { 124 | const { tagDeleteIcon } = this.props; 125 | return tagDeleteIcon || ' x'; 126 | } 127 | 128 | getTagDeleteComponent () { 129 | const { tagDeleteStyle } = this.props; 130 | 131 | return tagDeleteStyle ? styled(TagDelete)` 132 | ${tagDeleteStyle} 133 | ` : TagDelete; 134 | } 135 | 136 | getTagStyledComponent () { 137 | const { tagStyle } = this.props; 138 | 139 | return tagStyle ? styled(Tag)` 140 | ${tagStyle} 141 | ` : Tag; 142 | } 143 | 144 | getInputWrapperStyledComponent () { 145 | const { wrapperStyle } = this.props; 146 | 147 | return wrapperStyle ? styled(Wrapper)` 148 | ${wrapperStyle} 149 | ` : Wrapper; 150 | } 151 | 152 | getInputStyledComponent () { 153 | const { inputStyle } = this.props; 154 | 155 | return inputStyle ? styled(Input)` 156 | ${inputStyle} 157 | ` : Input; 158 | } 159 | 160 | render () { 161 | const { onInputChanged } = this.props; 162 | const InputWrapper = this.getInputWrapperStyledComponent(); 163 | const InputComponent = this.getInputStyledComponent(); 164 | 165 | return ( 166 | 167 | {this.renderTags()} 168 | this.input = el} 170 | onChange={onInputChanged} 171 | placeholder={this.renderPlaceholder()} 172 | type="text" 173 | onKeyUp={this.onInputKeyUp} 174 | onKeyDown={this.onInputKeyDown}/> 175 | 176 | ); 177 | } 178 | } 179 | 180 | TagInput.propTypes = { 181 | tags: PropTypes.array.isRequired, 182 | onTagsChanged: PropTypes.func.isRequired, 183 | onInputChange: PropTypes.func, 184 | placeholder: PropTypes.string, 185 | wrapperStyle: PropTypes.string, 186 | inputStyle: PropTypes.string, 187 | tagStyle: PropTypes.string, 188 | tagDeleteStyle: PropTypes.string, 189 | tagDeleteIcon: PropTypes.element, 190 | addTagOnEnterKeyPressed: PropTypes.bool, 191 | hideInputPlaceholderTextIfTagsPresent: PropTypes.bool, 192 | } 193 | 194 | TagInput.defaultProps = { 195 | placeholder: 'Type something and hit enter...', 196 | addTagOnEnterKeyPressed: true, 197 | hideInputPlaceholderTextIfTagsPresent: true, 198 | } 199 | 200 | export default TagInput; -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | import TagInput from "./TagInput"; 2 | 3 | export { TagInput }; 4 | -------------------------------------------------------------------------------- /src/lib/styled/Input.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Input = styled.input` 4 | background: #F1F3F4; 5 | border: none; 6 | border-radius: 3px; 7 | outline: none; 8 | font-size: large; 9 | display: inline-block; 10 | width: 100%; 11 | color: #69626D; 12 | font-weight: 400; 13 | &::-webkit-input-placeholder { 14 | font-weight: 100; 15 | font-style: italic; 16 | color: #69626D; 17 | } 18 | `; 19 | 20 | export default Input; -------------------------------------------------------------------------------- /src/lib/styled/Tag.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Tag = styled.span` 4 | float: left; 5 | background: #77B6EA; 6 | color: #69626D; 7 | border-radius: 5px; 8 | color: white; 9 | padding: 5px; 10 | margin: 0 5px 5px 0; 11 | letter-spacing: 1px; 12 | cursor: pointer; 13 | `; 14 | 15 | export default Tag; -------------------------------------------------------------------------------- /src/lib/styled/TagDelete.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const TagDelete = styled.span.attrs(props => ({ 4 | 'data-test': `tag-delete-${props.index}`, 5 | }))` 6 | color: white; 7 | font-size: 1em; 8 | &:hover { 9 | color: gray; 10 | } 11 | ` 12 | 13 | export default TagDelete; -------------------------------------------------------------------------------- /src/lib/styled/Wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Wrapper = styled.div` 4 | background: #F1F3F4; 5 | box-shadow: 3px 3px 10px rgba(0,0,0, 0.1); 6 | font-family: 'Hind', sans-serif; 7 | font-weight: 400; 8 | border-radius: 20px; 9 | padding: 10px; 10 | font-size: large; 11 | position: absolute; 12 | left: 50%; 13 | top: 50%; 14 | transform: translate(-50%, -50%); 15 | width: 70%; 16 | `; 17 | 18 | export default Wrapper; -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); -------------------------------------------------------------------------------- /src/test/TagInput.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { TagInput } from "../lib"; 4 | 5 | describe('', () => { 6 | let wrapper; 7 | let props; 8 | 9 | beforeEach(() => { 10 | props = { 11 | tags: [{ 12 | id: 1, 13 | displayValue: 'Bruce Lee', 14 | }, { 15 | id: 2, 16 | displayValue: 'Royce Gracie', 17 | }], 18 | onTagsChanged: jest.fn(), 19 | onInputChanged: jest.fn(), 20 | }; 21 | }); 22 | 23 | it('focuses the search input when the component mounts', () => { 24 | const focusInputSpy = jest.spyOn(TagInput.prototype, 'focusInput'); 25 | wrapper = mount(); 26 | wrapper.instance().componentDidMount(); 27 | expect(focusInputSpy).toHaveBeenCalled(); 28 | }); 29 | 30 | it('deletes a tag when x is pressed', () => { 31 | wrapper = mount(); 32 | wrapper.find('[data-test="tag-delete-0"]').simulate("click"); 33 | expect(wrapper.state().selectedTags.length).toBe(1); 34 | }); 35 | 36 | it('adds a tag when enter is pressed', () => { 37 | wrapper = mount(); 38 | const input = wrapper.find('input'); 39 | input.simulate('keyup', { 40 | key: 'Enter', 41 | target: { 42 | value: 'Angela Lee', 43 | }, 44 | }); 45 | expect(wrapper.state().selectedTags.length).toBe(3); 46 | }); 47 | 48 | it('removes a tag when backspace is pressed', () => { 49 | wrapper = mount(); 50 | const input = wrapper.find('input'); 51 | input.simulate('keydown', { 52 | key: 'Backspace', 53 | }); 54 | expect(wrapper.state().selectedTags.length).toBe(1); 55 | }); 56 | }); 57 | --------------------------------------------------------------------------------