├── .nvmrc ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .editorconfig ├── .storybook ├── preview.js ├── preview-head.html └── main.js ├── src ├── use-did-update-effect.tsx ├── classnames.tsx ├── tag.tsx ├── styles.css └── index.tsx ├── tsconfig.json ├── LICENSE ├── stories └── tags-input.stories.tsx ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: harshzalavadiya 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | storybook-static 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/use-did-update-effect.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export function useDidUpdateEffect(fn, inputs) { 4 | const didMountRef = useRef(false); 5 | 6 | useEffect(() => { 7 | if (didMountRef.current) fn(); 8 | else didMountRef.current = true; 9 | }, inputs); 10 | } -------------------------------------------------------------------------------- /src/classnames.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * A minimal utility to combine classes 3 | * 4 | * @export 5 | * @param {(string[] | string | undefined)} obj 6 | * @returns {string} 7 | */ 8 | export default function cc(...obj: (string | number | undefined)[]): string { 9 | return obj.filter(c => c).join(" "); 10 | } 11 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../stories/**/*.stories.mdx", 4 | "../stories/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials", 9 | "@storybook/addon-interactions" 10 | ], 11 | "framework": "@storybook/react" 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "noImplicitAny": false, 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "baseUrl": "./", 18 | "paths": { 19 | "@": ["./"], 20 | "*": ["src/*", "node_modules/*"] 21 | }, 22 | "jsx": "react", 23 | "esModuleInterop": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/tag.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cc from "./classnames"; 3 | 4 | interface TagProps { 5 | text: string; 6 | remove: any; 7 | disabled?: boolean; 8 | className?: string; 9 | } 10 | 11 | export default function Tag({ text, remove, disabled, className }: TagProps) { 12 | const handleOnRemove = e => { 13 | e.stopPropagation(); 14 | remove(text); 15 | }; 16 | 17 | return ( 18 | 19 | {text} 20 | {!disabled && ( 21 | 28 | )} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 16 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 16.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Build 30 | run: yarn build 31 | env: 32 | CI: true 33 | 34 | - name: Publish 35 | if: startsWith(github.ref, 'refs/tags/') 36 | run: echo "//registry.npmjs.org/:_authToken=$NPM_AUTH_TOKEN" > ~/.npmrc && npm publish --access public 37 | env: 38 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Harsh Zalavadiya 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/styles.css: -------------------------------------------------------------------------------- 1 | .rti--container * { 2 | box-sizing: border-box; 3 | transition: all 0.2s ease; 4 | } 5 | 6 | .rti--container { 7 | --rti-bg: #fff; 8 | --rti-border: #ccc; 9 | --rti-main: #3182ce; 10 | --rti-radius: 0.375rem; 11 | --rti-s: 0.5rem; 12 | --rti-tag: #edf2f7; 13 | --rti-tag-remove: #e53e3e; 14 | --rti-tag-padding: 0.15rem 0.25rem; 15 | 16 | /* Container Styles */ 17 | align-items: center; 18 | background: var(--rti-bg); 19 | border: 1px solid var(--rti-border); 20 | border-radius: var(--rti-radius); 21 | display: flex; 22 | flex-wrap: wrap; 23 | gap: var(--rti-s); 24 | line-height: 1.4; 25 | padding: var(--rti-s); 26 | } 27 | 28 | .rti--container:focus-within { 29 | border-color: var(--rti-main); 30 | box-shadow: var(--rti-main) 0px 0px 0px 1px; 31 | } 32 | 33 | .rti--input { 34 | border: 0; 35 | outline: 0; 36 | font-size: inherit; 37 | line-height: inherit; 38 | width: 50%; 39 | } 40 | 41 | .rti--tag { 42 | align-items: center; 43 | background: var(--rti-tag); 44 | border-radius: var(--rti-radius); 45 | display: inline-flex; 46 | justify-content: center; 47 | padding: var(--rti-tag-padding); 48 | } 49 | 50 | .rti--tag button { 51 | background: none; 52 | border: 0; 53 | border-radius: 50%; 54 | cursor: pointer; 55 | line-height: inherit; 56 | padding: 0 var(--rti-s); 57 | } 58 | 59 | .rti--tag button:hover { 60 | color: var(--rti-tag-remove); 61 | } -------------------------------------------------------------------------------- /stories/tags-input.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import { TagsInput } from "../src"; 4 | 5 | export default { 6 | title: "Tags Input", 7 | }; 8 | 9 | export const Page = () => { 10 | const [selected, setSelected] = useState(["papaya"]); 11 | const [disabled, setDisabled] = useState(false); 12 | const [isEditOnRemove, setisEditOnRemove] = useState(false); 13 | 14 | const beforeAddValidate = text => { 15 | if (text.length < 3) { 16 | alert("too short!"); 17 | return false; 18 | } 19 | return true; 20 | }; 21 | 22 | return ( 23 |
24 |

Add Fruits

25 |
{JSON.stringify(selected)}
26 | 35 |
36 | 42 |
Disable: {JSON.stringify(disabled)}
43 |
44 |
45 | 51 |
Keep Words on Backspace: {JSON.stringify(isEditOnRemove)}
52 |
53 |
54 | 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tag-input-component", 3 | "description": "lightweight component for tag(s) input", 4 | "version": "2.0.2", 5 | "license": "MIT", 6 | "author": "Harsh Zalavadiya", 7 | "repository": "https://github.com/hc-oss/react-tag-input-component", 8 | "main": "./dist/index.js", 9 | "module": "./dist/esm/index.js", 10 | "types": "./dist/index.d.ts", 11 | "scripts": { 12 | "build": "tsup src/index.tsx --inject-style --legacy-output --minify --format esm,cjs --dts --external react", 13 | "dev": "tsup src/index.tsx --inject-style --legacy-output --format esm,cjs --watch --dts --external react", 14 | "lint": "eslint src --fix", 15 | "storybook": "export NODE_OPTIONS=--openssl-legacy-provider; start-storybook -p 6006", 16 | "build-storybook": "export NODE_OPTIONS=--openssl-legacy-provider; build-storybook" 17 | }, 18 | "peerDependencies": { 19 | "react": "^16 || ^17 || ^18", 20 | "react-dom": "^16 || ^17 || ^18" 21 | }, 22 | "dependencies": {}, 23 | "devDependencies": { 24 | "@size-limit/preset-small-lib": "^8.1.0", 25 | "@storybook/addon-actions": "^6.5.12", 26 | "@storybook/addon-essentials": "^6.5.12", 27 | "@storybook/addon-knobs": "^6.4.0", 28 | "@storybook/addon-links": "^6.5.12", 29 | "@storybook/react": "^6.5.12", 30 | "@types/react": "^18.0.21", 31 | "@types/react-dom": "^18.0.6", 32 | "@typescript-eslint/eslint-plugin": "^5.38.1", 33 | "@typescript-eslint/parser": "^5.38.1", 34 | "eslint": "8.24.0", 35 | "eslint-plugin-prettier": "^4.2.1", 36 | "eslint-plugin-react": "^7.31.8", 37 | "eslint-plugin-simple-import-sort": "^8.0.0", 38 | "eslint-plugin-storybook": "^0.6.4", 39 | "postcss": "^8.4.17", 40 | "prettier": "^2.7.1", 41 | "react": "^18.2.0", 42 | "react-dom": "^18.2.0", 43 | "size-limit": "^8.1.0", 44 | "storybook-addon-turbo-build": "^1.1.0", 45 | "tsup": "^6.2.3", 46 | "typescript": "^4.8.4" 47 | }, 48 | "prettier": { 49 | "printWidth": 80, 50 | "semi": true, 51 | "singleQuote": false, 52 | "trailingComma": "es5" 53 | }, 54 | "files": [ 55 | "dist/**" 56 | ], 57 | "browserslist": [ 58 | "defaults", 59 | "not IE 11", 60 | "maintained node versions" 61 | ], 62 | "size-limit": [ 63 | { 64 | "path": "dist/index.js", 65 | "limit": "10 KB" 66 | } 67 | ], 68 | "keywords": [ 69 | "react", 70 | "tag", 71 | "tags", 72 | "input", 73 | "picker", 74 | "component", 75 | "minimal", 76 | "tiny", 77 | "lightweight" 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | 3 | import React, { useState } from "react"; 4 | import { useDidUpdateEffect } from "./use-did-update-effect"; 5 | 6 | import cc from "./classnames"; 7 | import Tag from "./tag"; 8 | 9 | export interface TagsInputProps { 10 | name?: string; 11 | placeHolder?: string; 12 | value?: string[]; 13 | onChange?: (tags: string[]) => void; 14 | onBlur?: any; 15 | separators?: string[]; 16 | disableBackspaceRemove?: boolean; 17 | onExisting?: (tag: string) => void; 18 | onRemoved?: (tag: string) => void; 19 | disabled?: boolean; 20 | isEditOnRemove?: boolean; 21 | beforeAddValidate?: (tag: string, existingTags: string[]) => boolean; 22 | onKeyUp?: (e: React.KeyboardEvent) => void; 23 | classNames?: { 24 | input?: string; 25 | tag?: string; 26 | }; 27 | } 28 | 29 | const defaultSeparators = ["Enter"]; 30 | 31 | export const TagsInput = ({ 32 | name, 33 | placeHolder, 34 | value, 35 | onChange, 36 | onBlur, 37 | separators, 38 | disableBackspaceRemove, 39 | onExisting, 40 | onRemoved, 41 | disabled, 42 | isEditOnRemove, 43 | beforeAddValidate, 44 | onKeyUp, 45 | classNames, 46 | }: TagsInputProps) => { 47 | const [tags, setTags] = useState(value || []); 48 | 49 | useDidUpdateEffect(() => { 50 | onChange && onChange(tags); 51 | }, [tags]); 52 | 53 | useDidUpdateEffect(() => { 54 | if (JSON.stringify(value) !== JSON.stringify(tags)) { 55 | setTags(value); 56 | } 57 | }, [value]); 58 | 59 | const handleOnKeyUp = e => { 60 | e.stopPropagation(); 61 | 62 | const text = e.target.value; 63 | 64 | if ( 65 | !text && 66 | !disableBackspaceRemove && 67 | tags.length && 68 | e.key === "Backspace" 69 | ) { 70 | e.target.value = isEditOnRemove ? `${tags.at(-1)} ` : ""; 71 | setTags([...tags.slice(0, -1)]); 72 | } 73 | 74 | if (text && (separators || defaultSeparators).includes(e.key)) { 75 | e.preventDefault(); 76 | if (beforeAddValidate && !beforeAddValidate(text, tags)) return; 77 | 78 | if (tags.includes(text)) { 79 | onExisting && onExisting(text); 80 | return; 81 | } 82 | setTags([...tags, text]); 83 | e.target.value = ""; 84 | } 85 | }; 86 | 87 | const onTagRemove = text => { 88 | setTags(tags.filter(tag => tag !== text)); 89 | onRemoved && onRemoved(text); 90 | }; 91 | 92 | return ( 93 |
94 | {tags.map(tag => ( 95 | 102 | ))} 103 | 104 | 114 |
115 | ); 116 | }; 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-tag-input-component 2 | 3 | lightweight component for tag(s) input 4 | 5 | [![GitHub Actions Status](https://github.com/harshzalavadiya/react-tag-input-component/workflows/CI/badge.svg)](https://github.com/harshzalavadiya/react-tag-input-component/actions) 6 | [![NPM](https://img.shields.io/npm/v/react-tag-input-component.svg)](https://npm.im/react-tag-input-component) 7 | [![gzip](https://badgen.net/bundlephobia/minzip/react-tag-input-component@latest)](https://bundlephobia.com/result?p=react-tag-input-component@latest) 8 | 9 | also see [multi select component](https://github.com/harshzalavadiya/react-multi-select-component) 10 | 11 | ## ✨ Features 12 | 13 | - 🍃 Lightweight (2KB including styles 😎) 14 | - 💅 Themeable 15 | - ✌ Written w/ TypeScript 16 | - 🗑️ Use Backspace to remove last tag 17 | 18 | ## 🔧 Installation 19 | 20 | ```bash 21 | npm i react-tag-input-component # npm 22 | yarn add react-tag-input-component # yarn 23 | ``` 24 | 25 | ## 📦 Example 26 | 27 | ![Example](https://user-images.githubusercontent.com/5774849/179722762-4d7feef6-52af-4662-ba97-129191fb4f26.gif) 28 | 29 | [![Edit react-tag-input-component](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/react-tag-input-component-rgf97?fontsize=14&hidenavigation=1&theme=dark) 30 | 31 | ```tsx 32 | import React, { useState } from "react"; 33 | import { TagsInput } from "react-tag-input-component"; 34 | 35 | const Example = () => { 36 | const [selected, setSelected] = useState(["papaya"]); 37 | 38 | return ( 39 |
40 |

Add Fruits

41 |
{JSON.stringify(selected)}
42 | 48 | press enter or comma to add new tag 49 |
50 | ); 51 | }; 52 | 53 | export default Example; 54 | ``` 55 | 56 | ## 👀 Props 57 | 58 | | Prop | Description | Type | Default | 59 | | ------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------- | --------------- | 60 | | `name` | value for name of input | `string` | | 61 | | `placeholder` | placeholder for text input | `string` | | 62 | | `value` | initial tags | `string[]` | `[]` | 63 | | `onChange` | onChange callback (added/removed) | `string[]` | | 64 | | `classNames` | className for styling input and tags (i.e {tag:'tag-cls', input: 'input-cls'}) | `object[tag, input]` | | 65 | | `onKeyUp` | input `onKeyUp` callback | `event` | | 66 | | `onBlur` | input `onBlur` callback | `event` | | 67 | | `separators` | when to add tag (i.e. `"Enter"`, `" "`) | `string[]` | `["Enter"]` | 68 | | `removers` | Remove last tag if textbox empty and `Backspace` is pressed | `string[]` | `["Backspace"]` | 69 | | `onExisting` | if tag is already added then callback | `(tag: string) => void` | | 70 | | `onRemoved` | on tag removed callback | `(tag: string) => void` | | 71 | | `beforeAddValidate` | Custom validation before adding tag | `(tag: string, existingTags: string[]) => boolean` | | 72 | | `isEditOnRemove` | Remove the tag but keep the word in the input to edit it on using Backscape Key | `boolean` | `false` | 73 | 74 | ## 💅 Themeing 75 | 76 | You can override CSS variables to customize the appearance 77 | 78 | ```css 79 | .rti--container { 80 | --rti-bg: "#fff", 81 | --rti-border: "#ccc", 82 | --rti-main: "#3182ce", 83 | --rti-radius: "0.375rem", 84 | --rti-s: "0.5rem", /* spacing */ 85 | --rti-tag: "#edf2f7", 86 | --rti-tag-remove: "#e53e3e", 87 | } 88 | ``` 89 | 90 | > use `!important` if CSS variables are not getting applied 91 | 92 | ## 🤠 Credits 93 | 94 | - [TypeScript](https://github.com/microsoft/typescript) 95 | - [TSDX](https://github.com/jaredpalmer/tsdx) 96 | - [Goober](https://github.com/cristianbote/goober) 97 | 98 | ## 📜 License 99 | 100 | MIT © [harshzalavadiya](https://github.com/harshzalavadiya) 101 | --------------------------------------------------------------------------------