├── .gitignore ├── .browserslistrc ├── assets ├── demo-simple.gif ├── demo-custom-styles.png └── banner.svg ├── .prettierrc ├── .editorconfig ├── .versionrc.json ├── CHANGELOG.md ├── LICENSE.md ├── package.json ├── gulpfile.js ├── src ├── index.html ├── main.css ├── main.js ├── tag-input.css └── tag-input.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 versions 2 | not dead 3 | > 0.2% 4 | not IE 11 5 | -------------------------------------------------------------------------------- /assets/demo-simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/accessible-components/tag-input/HEAD/assets/demo-simple.gif -------------------------------------------------------------------------------- /assets/demo-custom-styles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/accessible-components/tag-input/HEAD/assets/demo-custom-styles.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true, 5 | "jsxBracketSameLine": false, 6 | "arrowParens": "always", 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | charset = utf-8 10 | 11 | [{*.json,*.yml,*.toml}] 12 | indent_size = 2 -------------------------------------------------------------------------------- /.versionrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { "type": "feat", "section": "Features" }, 4 | { "type": "fix", "section": "Bug Fixes" }, 5 | { "type": "chore", "hidden": true }, 6 | { "type": "docs", "hidden": true }, 7 | { "type": "style", "hidden": true }, 8 | { "type": "refactor", "hidden": true }, 9 | { "type": "perf", "hidden": true }, 10 | { "type": "test", "hidden": true }, 11 | { "type": "ci", "hidden": true } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [0.2.0](https://github.com/accessible-components/tag-input/compare/v0.1.1...v0.2.0) (2020-11-24) 6 | 7 | 8 | ### Features 9 | 10 | * Add onInit public method ([478b4c6](https://github.com/accessible-components/tag-input/commit/478b4c6132cda5283182cbd003cf07438c761b50)) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * Fix bug with form submission ([687c93a](https://github.com/accessible-components/tag-input/commit/687c93a4cf676bbe54fed2bbeb8105eadc189af8)) 16 | 17 | ### [0.1.1](https://github.com/accessible-components/tag-input/compare/v0.1.0...v0.1.1) (2020-11-23) 18 | 19 | ## 0.1.0 (2020-11-23) 20 | 21 | 22 | ### Features 23 | 24 | * Add basic functionality ([17f7903](https://github.com/accessible-components/tag-input/commit/17f79035f11547d06ea94095c5726b256e2e583f)) 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright © 2020 Sergei Kriger, https://sergeikriger.com/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@accessible-components/tag-input", 3 | "version": "0.2.0", 4 | "private": false, 5 | "description": "Simple and accessible component for creating tags.", 6 | "author": { 7 | "name": "Sergei Kriger", 8 | "url": "https://sergeikriger.com/" 9 | }, 10 | "homepage": "https://github.com/accessible-components/tag-input", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/accessible-components/tag-input.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/accessible-components/tag-input/issues" 17 | }, 18 | "main": "./build/tag-input.js", 19 | "license": "MIT", 20 | "files": [ 21 | "build/tag-input.*" 22 | ], 23 | "keywords": [ 24 | "tags", 25 | "tag input", 26 | "accessibility", 27 | "a11y" 28 | ], 29 | "scripts": { 30 | "start": "gulp", 31 | "build": "gulp build", 32 | "serve:build": "gulp serveBuild", 33 | "clean": "gulp clean", 34 | "release": "standard-version" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.12.7", 38 | "@babel/preset-env": "^7.12.7", 39 | "browser-sync": "^2.26.13", 40 | "del": "^5.1.0", 41 | "gulp": "^4.0.2", 42 | "gulp-autoprefixer": "^7.0.1", 43 | "gulp-babel": "^8.0.0", 44 | "gulp-clean-css": "^4.3.0", 45 | "gulp-concat": "^2.6.1", 46 | "gulp-header": "^2.0.9", 47 | "gulp-rename": "^2.0.0", 48 | "gulp-replace": "^1.0.0", 49 | "gulp-terser": "^2.0.0", 50 | "standard-version": "^9.0.0" 51 | }, 52 | "resolutions": { 53 | "yargs-parser": "^20.2.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const { watch, series, src, dest } = require('gulp'); 2 | const autoprefixer = require('gulp-autoprefixer'); 3 | const del = require('del'); 4 | const bs = require('browser-sync'); 5 | const header = require('gulp-header'); 6 | const babel = require('gulp-babel'); 7 | const cleanCSS = require('gulp-clean-css'); 8 | const rename = require('gulp-rename'); 9 | const terser = require('gulp-terser'); 10 | const pkg = require('./package.json'); 11 | 12 | const server = bs.create(); 13 | const banner = ` 14 | /** 15 | * <%= pkg.name %> - <%= pkg.description %> 16 | * @version v<%= pkg.version %> 17 | * @link <%= pkg.homepage %> 18 | * @copyright 2020 <%= pkg.author.name %>, <%= pkg.author.url %> 19 | * @license <%= pkg.license %> 20 | */ 21 | `; 22 | 23 | const serve = (cb, dir, port) => { 24 | server.init({ 25 | server: { 26 | baseDir: dir, 27 | }, 28 | ui: false, 29 | notify: false, 30 | open: false, 31 | port: port, 32 | }); 33 | 34 | cb(); 35 | }; 36 | 37 | const serveDev = (cb) => { 38 | serve(cb, 'src', 8080); 39 | }; 40 | 41 | const serveBuild = (cb) => { 42 | serve(cb, 'build', 8081); 43 | }; 44 | 45 | const reload = (cb) => { 46 | server.reload(); 47 | cb(); 48 | }; 49 | 50 | const watchFiles = () => { 51 | return watch(['src/**/*.html', 'src/**/*.css', 'src/**/*.js'], reload); 52 | }; 53 | 54 | const html = () => { 55 | return src('src/index.html').pipe(dest('build')); 56 | }; 57 | 58 | const css = () => { 59 | return src('src/**/*.css') 60 | .pipe(autoprefixer()) 61 | .pipe(header(banner, { pkg: pkg })) 62 | .pipe(dest('build')); 63 | }; 64 | 65 | const mincss = () => { 66 | return src('src/tag-input.css') 67 | .pipe(autoprefixer()) 68 | .pipe(cleanCSS()) 69 | .pipe(header(banner, { pkg: pkg })) 70 | .pipe(rename({ suffix: '.min' })) 71 | .pipe(dest('build')); 72 | }; 73 | 74 | const js = () => { 75 | return src('src/**/*.js') 76 | .pipe( 77 | babel({ 78 | sourceType: 'script', 79 | presets: ['@babel/env'], 80 | retainLines: true, 81 | }) 82 | ) 83 | .pipe(header(banner, { pkg: pkg })) 84 | .pipe(dest('build')); 85 | }; 86 | 87 | const minjs = () => { 88 | return src('src/tag-input.js') 89 | .pipe( 90 | babel({ 91 | sourceType: 'script', 92 | presets: ['@babel/env'], 93 | retainLines: true, 94 | }) 95 | ) 96 | .pipe(terser()) 97 | .pipe(header(banner, { pkg: pkg })) 98 | .pipe(rename({ suffix: '.min' })) 99 | .pipe(dest('build')); 100 | }; 101 | 102 | const clean = () => del('build'); 103 | const build = series(clean, html, css, mincss, js, minjs); 104 | 105 | exports.clean = clean; 106 | exports.build = build; 107 | exports.serveBuild = series(build, serveBuild); 108 | exports.default = series(serveDev, watchFiles); 109 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tag Input | Accessible Components 7 | 8 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Tag Input

19 | 20 |

Simple and accessible component for creating tags.

21 | 22 |
23 |

Simple

24 |

Add, remove and edit tags.

25 |
26 |
You created tags.
27 |
28 | 29 |
30 |

Initial Tags

31 |

Pass tags to the tag input on the page load.

32 |
33 |
You created tags.
34 |
35 | 36 |
37 |

Disabled

38 |

Read only tags.

39 |
40 |
You created tags.
41 |
42 | 43 |
44 |

Control from Outside

45 |

Manage tags dynamically with JavaScript.

46 |
47 |
You created tags.
48 | 49 | 50 |
51 | 52 |
53 |

Custom Styles

54 |

Customize input styles to mach your weblite design.

55 |
56 |
You created tags.
57 |
58 | 59 |
60 |

In the Form

61 |

Place the input tag to the form and get tags on submit.

62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 | 70 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | background-color: #f5f5f5; 4 | color: #333; 5 | padding-top: 30px; 6 | } 7 | 8 | h1, 9 | h2, 10 | h3 { 11 | font-family: Raleway; 12 | margin-top: 0; 13 | } 14 | 15 | .content { 16 | max-width: 800px; 17 | margin: 0 auto; 18 | padding: 0 18px; 19 | } 20 | 21 | .demo { 22 | border-radius: 8px; 23 | box-shadow: 0 2px 12px 1px rgba(0, 0, 0, 0.2); 24 | padding: 32px 24px; 25 | font-size: 18px; 26 | line-height: 1.2; 27 | background-color: #fff; 28 | margin: 40px 0; 29 | } 30 | 31 | .results { 32 | margin-top: 24px; 33 | } 34 | 35 | .count { 36 | background-color: #e5f1ff; 37 | padding: 4px 6px; 38 | border-radius: 6px; 39 | } 40 | 41 | .button { 42 | font-size: inherit; 43 | font-family: inherit; 44 | color: #fff; 45 | background-color: #2370c3; 46 | border: none; 47 | padding: 12px 16px; 48 | border-radius: 4px; 49 | cursor: pointer; 50 | margin-top: 24px; 51 | } 52 | 53 | .button:hover, 54 | .button:focus { 55 | outline: none; 56 | background-color: #1c508a; 57 | } 58 | 59 | footer { 60 | text-align: center; 61 | padding: 24px 0; 62 | } 63 | 64 | .custom { 65 | font-family: monospace; 66 | background-color: #001ac5; 67 | color: #fff; 68 | border-radius: 0; 69 | } 70 | 71 | .custom .count { 72 | background-color: #89fcfe; 73 | color: #001ac5; 74 | border-radius: 0; 75 | } 76 | 77 | .custom h2 { 78 | font-family: monospace; 79 | } 80 | 81 | .custom .tag-input-container { 82 | color: #fff; 83 | } 84 | 85 | .custom .tag-input { 86 | /* input */ 87 | --text: #fff; 88 | --bg: #001AC5; 89 | --bd: #89FCFE; 90 | --bd-hover: #fff; 91 | --bd-focus: #fff; 92 | --bd-focus-light: transparent; 93 | 94 | /* tag */ 95 | --tag-text: #001AC5; 96 | --tag-bg: #89FCFE; 97 | --tag-bd: #89FCFE; 98 | --tag-remove-button: transparent; 99 | --tag-remove-icon: #001AC5; 100 | 101 | /* tag: hover */ 102 | --tag-hover-text: #001AC5; 103 | --tag-hover-bg: #fff; 104 | --tag-hover-bd: #fff; 105 | --tag-hover-remove-button: transparent; 106 | --tag-hover-remove-icon: #001AC5; 107 | --tag-hover-remove-button-hover: #001AC5; 108 | --tag-hover-remove-icon-hover: #fff; 109 | 110 | /* tag: selected */ 111 | --tag-selected-text: #001AC5; 112 | --tag-selected-bg: #fff; 113 | --tag-selected-bd: #fff; 114 | --tag-selected-remove-button: transparent; 115 | --tag-selected-remove-icon: #001AC5; 116 | 117 | /* tag: selected, hover */ 118 | --tag-selected-hover-text: #001AC5; 119 | --tag-selected-hover-bg: #fff; 120 | --tag-selected-hover-bd: #fff; 121 | --tag-selected-hover-remove-button: transparent; 122 | --tag-selected-hover-remove-icon: #001AC5; 123 | --tag-selected-hover-remove-button-hover: #001AC5; 124 | --tag-selected-hover-remove-icon-hover: #fff; 125 | 126 | /* tag: editable */ 127 | --tag-editable-text: #fff; 128 | --tag-editable-bg: #001AC5; 129 | --tag-editable-bd: #fff; 130 | 131 | border-top: none; 132 | font-family: monospace; 133 | border-radius: 0; 134 | padding: 0; 135 | } 136 | 137 | .custom .tag-input__tag { 138 | border-radius: 0; 139 | margin: 6px; 140 | margin-right: 0; 141 | padding: 2px 8px; 142 | } 143 | 144 | .custom .tag-input__remove-button { 145 | border-radius: 0; 146 | } 147 | 148 | .custom .tag-input__input { 149 | font-family: monospace; 150 | padding: 2px 8px; 151 | } 152 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // Simple 2 | const simple = document.getElementById('simple'); 3 | const simpleCount = simple.parentNode.querySelector('.count'); 4 | 5 | const simpleTagInput = new TagInput(simple, { 6 | label: 'Colors', 7 | placeholder: 'Add tags', 8 | 9 | onInit: (tags) => { 10 | simpleCount.textContent = tags.length; 11 | }, 12 | 13 | onTagAdd: (tag, tags) => { 14 | simpleCount.textContent = tags.length; 15 | }, 16 | onTagRemove: (tag, tags) => { 17 | simpleCount.textContent = tags.length; 18 | }, 19 | onTagUpdate: (oldTag, newTag, tags) => { 20 | simpleCount.textContent = tags.length; 21 | }, 22 | }); 23 | 24 | // With tags 25 | const tags = document.getElementById('tags'); 26 | const tagsCount = tags.parentNode.querySelector('.count'); 27 | 28 | const tagsTagInput = new TagInput(tags, { 29 | tags: ['jazz', 'blues', 'rock'], 30 | label: 'Genres', 31 | placeholder: 'Add genres', 32 | 33 | onInit: (tags) => { 34 | tagsCount.textContent = tags.length; 35 | }, 36 | 37 | onTagAdd: (tag, tags) => { 38 | tagsCount.textContent = tags.length; 39 | }, 40 | onTagRemove: (tag, tags) => { 41 | tagsCount.textContent = tags.length; 42 | }, 43 | onTagUpdate: (oldTag, newTag, tags) => { 44 | tagsCount.textContent = tags.length; 45 | }, 46 | }); 47 | 48 | // Disabled 49 | const disabled = document.getElementById('disabled'); 50 | const disabledCount = disabled.parentNode.querySelector('.count'); 51 | 52 | const disabledTagInput = new TagInput(disabled, { 53 | tags: ['traveling', 'reading', 'cooking'], 54 | label: 'Interests', 55 | placeholder: 'Add interests', 56 | disabled: true, 57 | 58 | onInit: (tags) => { 59 | disabledCount.textContent = tags.length; 60 | }, 61 | 62 | onTagAdd: (tag, tags) => { 63 | disabledCount.textContent = tags.length; 64 | }, 65 | onTagRemove: (tag, tags) => { 66 | disabledCount.textContent = tags.length; 67 | }, 68 | onTagUpdate: (oldTag, newTag, tags) => { 69 | disabledCount.textContent = tags.length; 70 | }, 71 | }); 72 | 73 | // Control from outside 74 | const control = document.getElementById('control'); 75 | const controlCount = control.parentNode.querySelector('.count'); 76 | 77 | const controlTagInput = new TagInput(control, { 78 | tags: ['public speaking', 'first impression'], 79 | label: 'Video tags', 80 | placeholder: 'Add interests', 81 | 82 | onInit: (tags) => { 83 | controlCount.textContent = tags.length; 84 | }, 85 | 86 | onTagAdd: (tag, tags) => { 87 | controlCount.textContent = tags.length; 88 | }, 89 | onTagRemove: (tag, tags) => { 90 | controlCount.textContent = tags.length; 91 | }, 92 | onTagUpdate: (oldTag, newTag, tags) => { 93 | controlCount.textContent = tags.length; 94 | }, 95 | }); 96 | 97 | control.parentNode.querySelectorAll('[data-tag]').forEach((el) => { 98 | el.addEventListener('click', (e) => { 99 | controlTagInput.addTag(e.target.dataset.tag); 100 | }); 101 | }); 102 | 103 | // Custom styles 104 | const styles = document.getElementById('styles'); 105 | const stylesCount = styles.parentNode.querySelector('.count'); 106 | 107 | const stylesTagInput = new TagInput(styles, { 108 | tags: ['html', 'css', 'javascript'], 109 | label: 'Skills', 110 | hiddenLabel: true, 111 | placeholder: ' ', 112 | 113 | onInit: (tags) => { 114 | stylesCount.textContent = tags.length; 115 | }, 116 | 117 | onTagAdd: (tag, tags) => { 118 | stylesCount.textContent = tags.length; 119 | }, 120 | onTagRemove: (tag, tags) => { 121 | stylesCount.textContent = tags.length; 122 | }, 123 | onTagUpdate: (oldTag, newTag, tags) => { 124 | stylesCount.textContent = tags.length; 125 | }, 126 | }); 127 | 128 | // Form 129 | const form = document.getElementById('form'); 130 | const formsTagInput = new TagInput(form, { 131 | tags: ['red', 'green', 'red', 'blue'], 132 | label: 'Colors', 133 | placeholder: 'Add colors', 134 | }); 135 | 136 | form.parentNode.addEventListener('submit', (e) => { 137 | e.preventDefault(); 138 | alert(`From submitted with tags ${e.target['tag-input'].value}`); 139 | }); 140 | -------------------------------------------------------------------------------- /src/tag-input.css: -------------------------------------------------------------------------------- 1 | .tag-input-container { 2 | --container-text: #152538; 3 | 4 | margin: 0; 5 | color: var(--container-text); 6 | } 7 | 8 | /* Label */ 9 | .tag-input-label { 10 | margin-bottom: 12px; 11 | display: block; 12 | color: inherit; 13 | text-align: left; 14 | } 15 | 16 | /* Tag input */ 17 | .tag-input * { 18 | box-sizing: border-box; 19 | } 20 | 21 | .tag-input { 22 | /* input */ 23 | --text: inherit; 24 | --bg: #fff; 25 | --bg-disabled: #f9f9f9; 26 | --bd: rgba(121, 121, 121, 0.23); 27 | --bd-hover: rgba(121, 121, 121, 0.4); 28 | --bd-focus: rgba(45, 146, 255, 0.7); 29 | --bd-focus-light: rgba(190, 221, 255, 0.5); 30 | 31 | /* tag */ 32 | --tag-text: #164172; 33 | --tag-bg: #e5f1ff; 34 | --tag-bd: #e5f1ff; 35 | --tag-remove-button: transparent; 36 | --tag-remove-icon: #2e91fd; 37 | 38 | /* tag: hover */ 39 | --tag-hover-text: #164172; 40 | --tag-hover-bg: #d1e6ff; 41 | --tag-hover-bd: #d1e6ff; 42 | --tag-hover-remove-button: transparent; 43 | --tag-hover-remove-icon: #2e91fd; 44 | --tag-hover-remove-button-hover: #b3d6ff; 45 | --tag-hover-remove-icon-hover: #2e91fd; 46 | 47 | /* tag: selected */ 48 | --tag-selected-text: #fff; 49 | --tag-selected-bg: #2e91fd; 50 | --tag-selected-bd: #2e91fd; 51 | --tag-selected-remove-button: transparent; 52 | --tag-selected-remove-icon: #fff; 53 | 54 | /* tag: selected, hover */ 55 | --tag-selected-hover-text: #fff; 56 | --tag-selected-hover-bg: #2e91fd; 57 | --tag-selected-hover-bd: #2e91fd; 58 | --tag-selected-hover-remove-button: transparent; 59 | --tag-selected-hover-remove-icon: #fff; 60 | --tag-selected-hover-remove-button-hover: #fff; 61 | --tag-selected-hover-remove-icon-hover: #2e91fd; 62 | 63 | /* tag: disabled */ 64 | --tag-disabled-text: #164172; 65 | --tag-disabled-bg: #e4e4e4; 66 | --tag-disabled-bd: #e4e4e4; 67 | 68 | /* tag: editable */ 69 | --tag-editable-text: #164172; 70 | --tag-editable-bg: #fff; 71 | --tag-editable-bd: #76aefa; 72 | 73 | display: flex; 74 | flex-wrap: wrap; 75 | align-content: flex-start; 76 | padding: 4px; 77 | background-color: var(--bg); 78 | font-size: 16px; 79 | font-family: sans-serif; 80 | line-height: 1.4; 81 | color: var(--text); 82 | border-radius: 4px; 83 | box-shadow: 0px 0px 0px 2px var(--bd); 84 | cursor: default; 85 | overflow-x: auto; 86 | } 87 | 88 | .tag-input:hover { 89 | box-shadow: 0px 0px 0px 2px var(--bd-hover); 90 | } 91 | 92 | .tag-input:focus-within { 93 | box-shadow: 0px 0px 0px 2px var(--bd-focus), 0px 0px 0px 4px var(--bd-focus-light); 94 | } 95 | 96 | .tag-input--disabled, 97 | .tag-input--disabled:hover, 98 | .tag-input--disabled:focus-within { 99 | background-color: var(--bg-disabled); 100 | box-shadow: 0px 0px 0px 2px var(--bd); 101 | } 102 | 103 | /* Tag */ 104 | .tag-input__tag { 105 | display: inline-flex; 106 | align-items: center; 107 | padding: 6px 12px; 108 | margin: 6px; 109 | border-radius: 6px; 110 | border: 2px solid var(--tag-bd); 111 | background-color: var(--tag-bg); 112 | color: var(--tag-text); 113 | } 114 | 115 | .tag-input__tag:hover { 116 | border: 2px solid var(--tag-hover-bd); 117 | background-color: var(--tag-hover-bg); 118 | color: var(--tag-hover-text); 119 | } 120 | 121 | .tag-input--disabled .tag-input__tag { 122 | border: 2px solid var(--tag-disabled-bd); 123 | background-color: var(--tag-disabled-bg); 124 | color: var(--tag-disabled-text); 125 | } 126 | 127 | .tag-input--disabled .tag-input__tag:hover { 128 | border: 2px solid var(--tag-disabled-bd); 129 | background-color: var(--tag-disabled-bg); 130 | } 131 | 132 | .tag-input__tag--selected { 133 | border: 2px solid var(--tag-selected-bd); 134 | background-color: var(--tag-selected-bg); 135 | color: var(--tag-selected-text); 136 | } 137 | 138 | .tag-input__tag--selected:hover { 139 | border: 2px solid var(--tag-selected-hover-bd); 140 | background-color: var(--tag-selected-hover-bg); 141 | color: var(--tag-selected-hover-text); 142 | } 143 | 144 | .tag-input__tag--editable, 145 | .tag-input__tag--editable:hover { 146 | background-color: var(--tag-editable-bg); 147 | border: 2px solid var(--tag-editable-bd); 148 | } 149 | 150 | /* Tag text */ 151 | .tag-input__tag--editable .tag-input__text { 152 | position: absolute; 153 | opacity: 0; 154 | z-index: -1; 155 | white-space: nowrap; 156 | } 157 | 158 | /* Tag edit */ 159 | .tag-input__edit { 160 | font-size: inherit; 161 | padding: 0; 162 | border: none; 163 | font-family: inherit; 164 | background-color: var(--tag-editable-bg); 165 | position: absolute; 166 | opacity: 0; 167 | z-index: -1; 168 | } 169 | 170 | .tag-input__edit:focus { 171 | outline: none; 172 | } 173 | 174 | .tag-input__tag--editable .tag-input__edit { 175 | position: static; 176 | opacity: 1; 177 | z-index: initial; 178 | color: var(--tag-editable-text); 179 | } 180 | 181 | /* Remove button */ 182 | .tag-input__remove-button { 183 | width: 18px; 184 | height: 18px; 185 | border: none; 186 | padding: 4px; 187 | border-radius: 50%; 188 | cursor: pointer; 189 | background-color: var(--tag-remove-button); 190 | display: inline-flex; 191 | align-items: center; 192 | justify-content: center; 193 | margin-left: 8px; 194 | margin-right: -4px; 195 | } 196 | 197 | .tag-input__tag:hover .tag-input__remove-button { 198 | background-color: var(--tag-hover-remove-button); 199 | } 200 | 201 | .tag-input__tag:hover .tag-input__remove-button:hover, 202 | .tag-input__tag:hover .tag-input__remove-button:focus { 203 | outline: none; 204 | background-color: var(--tag-hover-remove-button-hover); 205 | } 206 | 207 | .tag-input__tag--selected .tag-input__remove-button { 208 | background-color: var(--tag-selected-remove-button); 209 | } 210 | 211 | .tag-input__tag--selected:hover .tag-input__remove-button { 212 | background-color: var(--tag-selected-hover-remove-button); 213 | } 214 | 215 | .tag-input__tag--selected:hover .tag-input__remove-button:hover { 216 | background-color: var(--tag-selected-hover-remove-button-hover); 217 | } 218 | 219 | .tag-input__tag--editable .tag-input__remove-button { 220 | visibility: hidden; 221 | } 222 | 223 | /* Remove button icon */ 224 | .tag-input__remove-icon { 225 | width: 10px; 226 | height: 10px; 227 | display: block; 228 | } 229 | 230 | .tag-input__remove-icon line { 231 | stroke: var(--tag-remove-icon); 232 | } 233 | 234 | .tag-input__tag:hover .tag-input__remove-icon line { 235 | stroke: var(--tag-hover-remove-icon); 236 | } 237 | 238 | .tag-input__tag:hover .tag-input__remove-button:hover .tag-input__remove-icon line, 239 | .tag-input__tag:hover .tag-input__remove-button:focus .tag-input__remove-icon line { 240 | stroke: var(--tag-hover-remove-icon-hover); 241 | } 242 | 243 | .tag-input__tag--selected .tag-input__remove-icon line { 244 | stroke: var(--tag-selected-remove-icon); 245 | } 246 | 247 | .tag-input__tag--selected:hover .tag-input__remove-icon line { 248 | stroke: var(--tag-selected-hover-remove-icon); 249 | } 250 | 251 | .tag-input__tag--selected:hover .tag-input__remove-button:hover .tag-input__remove-icon line, 252 | .tag-input__tag--selected:hover .tag-input__remove-button:focus .tag-input__remove-icon line { 253 | stroke: var(--tag-selected-hover-remove-icon-hover); 254 | } 255 | 256 | /* Input */ 257 | .tag-input__input { 258 | font-size: inherit; 259 | line-height: 1.4; 260 | padding: var(--tag-padding); 261 | padding: 6px 8px 6px 0; 262 | margin: 6px; 263 | flex: 1; 264 | color: var(--text); 265 | background-color: var(--bg); 266 | border: 2px solid transparent; 267 | } 268 | 269 | .tag-input__input:focus { 270 | outline: none; 271 | } 272 | 273 | .tag-input__input:disabled { 274 | background-color: inherit; 275 | } 276 | -------------------------------------------------------------------------------- /assets/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Tag Input

2 | 3 |

4 | 5 | npm version 6 | 7 | 8 | License 9 | 10 | 11 | accessibility status 12 | 13 |

14 | 15 |
16 | 17 | Simple and accessible component for creating tags. Check out a [demo page](https://tag-input.netlify.app/). 18 | 19 | ## Features 20 | 21 | * Tags can be added both manually and dynamically. 22 | * Fully accessible for keyboard and assistive technologies. 23 | * Customizable styles. 24 | 25 | ![TagInput in action](./assets/demo-simple.gif) 26 | 27 | ## Browser support 28 | 29 | All modern browsers. _IE11_ is not supported. 30 | 31 | ### Tested in 32 | 33 | | OS | Browser | Screen reader | 34 | | -- | ------- | ------------- | 35 | | macOS | Safari | VoiceOver | 36 | | iOS | Safari | VoiceOver | 37 | | Windows | Chrome | JAWS | 38 | | Windows | Firefox | NVDA | 39 | | Android | Chrome | Talkback | 40 | 41 | ## Installation 42 | 43 | ### `npm` 44 | 45 | ``` 46 | npm install @accessible-components/tag-input 47 | ``` 48 | 49 | Then import `build/tag-input.min.js` and `build/tag-input.min.css` from `node_modules`. 50 | 51 | ### Static sites 52 | 53 | Copy `build/tag-input.min.js` and `build/tag-input.min.css` to your project and include them in your HTML: 54 | 55 | ```html 56 | 57 | ... 58 | 59 | ``` 60 | 61 | ### Locally 62 | 63 | ``` 64 | git clone https://github.com/accessible-components/tag-input.git 65 | cd tag-input/ 66 | npm install 67 | npm start 68 | ``` 69 | 70 | Open http://localhost:8080/ in your browser to open the demo page. 71 | 72 | ## Keyboard support 73 | 74 | Once focused on the input field, following keyboard shortcuts are available: 75 | 76 | | Shortcut | Input field | Tag selected | Action | 77 | | -------- | ----------- | ------------ | ------ | 78 | | `ArrowLeft`, `ArrowRight` | Empty | - | Navigate through tags if they exist. Selected tag is visually highlighted as well as anounced for screen reader users. | 79 | | `Delete` | Empty | No | Deletes the last tag. | 80 | | `Enter` | Empty | Yes | Makes selected tag editable. | 81 | | `ESC` | Empty | Yes | Resets edited tag (before it saved) to the prevous state. | 82 | | `Enter`, `Tab` | Filled | No | Creates a new tag. | 83 | 84 | ### Some thoughts to `Backspace` key 85 | 86 | Although, many similar tag input components use `Backspace` key for deleting tags (if an input field is empty), after testing `TagInput` with blind users we decided not to support this shortcut because of following reasons: 87 | 88 | * Many screen reader users once focused on the input field want to be sure that it's empty (often some sample text is already prefilled). For that instead of reading the content they simply delete it with `Backspace` key, so using this key for other actions may confuse screen reader users. 89 | 90 | * Some of the blind testers used devices with so called _Perkins Style Braille Keyboard_ where `SwipeLeft` triggers `Backspace`, wich may cause unexpected behaviour. 91 | 92 | For deleting tags use `Delete` key. 93 | 94 | ## Accessibility 95 | 96 | `TagInput` is fully accessible for a keyboard, screen readers and other assistive technologies. It was tested by people who use these technologies on everyday basis. 97 | 98 | ## Usage 99 | 100 | Create an empty element: 101 | 102 | ```html 103 |
104 | ``` 105 | 106 | Create `TagInput` instance: 107 | 108 | ```js 109 | const colors = document.getElementById('colors'); 110 | 111 | const colorsTagInput = new TagInput(colors, { 112 | tags: ['red', 'green', 'blue'], 113 | label: 'Colors', 114 | placeholder: 'Add colors', 115 | // other options 116 | 117 | onTagAdd: (tag, tags) => { 118 | // do something 119 | }, 120 | onTagRemove: (tag, tags) => { 121 | // do something 122 | }, 123 | onTagUpdate: (oldTag, newTag, tags) => { 124 | // do something 125 | }, 126 | }); 127 | ``` 128 | 129 | ### Custom styles 130 | 131 | You can easily adjust `TagInput` styles. 132 | 133 | ![TagInput in action](./assets/demo-custom-styles.png) 134 | 135 | To change colors you can simply update following CSS custom variables: 136 | 137 | ```css 138 | .tag-input { 139 | /* input */ 140 | --text: inherit; 141 | --bg: #fff; 142 | --bg-disabled: #f9f9f9; 143 | --bd: rgba(121, 121, 121, 0.23); 144 | --bd-hover: rgba(121, 121, 121, 0.4); 145 | --bd-focus: rgba(45, 146, 255, 0.7); 146 | --bd-focus-light: rgba(190, 221, 255, 0.5); 147 | 148 | /* tag */ 149 | --tag-text: #164172; 150 | --tag-bg: #e5f1ff; 151 | --tag-bd: #e5f1ff; 152 | --tag-remove-button: transparent; 153 | --tag-remove-icon: #2e91fd; 154 | 155 | ... 156 | } 157 | ``` 158 | 159 | > See all CSS properties in `tag-input.css`. 160 | 161 | To update other styles (`paddings`, `margins` etc.), you may simply override css styles. `TagInput` has following HTML structure: 162 | 163 | ```html 164 |
165 | 166 |
167 |
168 | red 169 | 170 | 173 |
174 |
175 | green 176 | 177 | 180 |
181 |
182 | blue 183 | 184 | 187 |
188 | 189 |
190 |
191 | ``` 192 | 193 | ## Options 194 | 195 | | Option | Type | Default | Description | 196 | | ------ | ---- | ------- | ----------- | 197 | | **tags** | `String[]` | `[]` | Tags provided on initialization. | 198 | | **prefix** | `Type` | `tag-input` | Unique prefix for class names and IDs inside the component. | 199 | | **disabled** | `Boolean` | `false` | `disabled` attribute for the input element. Also makes tags not editable. | 200 | | **name** | `String` | `tag-input` | `name` atribute for the input field to be accessed on form submit. | 201 | | **placeholder** | `String` | `Add tags` | `placeholder` attribute for the input element. | 202 | | **label** | `String` | `Tags` | Label text. | 203 | | **hiddenLabel** | `Boolean` | `false` | Hides the label visually, but keeps it accessible for screen readers. | 204 | | **onInit** | `Function` | `undefined` | Runs after tag input init. Parameters: `tags` (list of tags). | 205 | | **onTagAdd** | `Function` | `undefined` | Runs after a new tag was added. Parameters: `tag` (added tag), `tags` (list of tags after adding). | 206 | | **onTagUpdate** | `Function` | `undefined` | Runs after a tag was updated. Parameters: `oldTag` (a tag before update), `newTag` (a tag after update), `tags` (list of tags after updating). | 207 | | **onTagRemove** | `Function` | `undefined` | Runs after a tag was removed. Parameters: `tag` (removed tag), `tags` (list of tags after removing). | 208 | | **ariaTag** | `String` | `Tag {{TAG}}.` | Adds `aria-label` to the tag element. | 209 | | **ariaEditTag** | `String` | `Edit tag.` | Adds label text to the editable tag. | 210 | | **ariaDeleteTag** | `String` | `Delete tag {{TAG}}.` | Adds `aria-label` to the tag delete button. | 211 | | **ariaTagAdded** | `String` | `Tag {{TAG}} added.` | Will be pronounced to screen reader users after a new tag was created. | 212 | | **ariaTagUpdated** | `String` | `Tag updated to {{TAG}}.` | Will be pronounced to screen reader users after a tag was updated. | 213 | | **ariaTagDeleted** | `String` | `Tag {{TAG}} deleted.` | Will be pronounced to screen reader users after a tag was deleted. | 214 | | **ariaTagSelected** | `String` | `Tag {{TAG}} selected. Press enter to edit, delete to delete.` | Will be pronounced to screen reader users after a tag was selected (by bavigating with arrow keys). | 215 | | **ariaNoTagsSelected** | `String` | `No tags selected.` | Will be pronounced to screen reader users after a selected tag was unselected (by clicking on ESC key, for example). | 216 | | **ariaInputLabel** | `String` | `{{TAGS}} tags. Use left and right arrow keys to navigate, enter or tab to create, delete to delete tags.` | Will be pronounced to screen reader users once the input element is focused. | 217 | 218 | > Options with prefix `aria` add ARIA attributes to some elements with instructions and other valueable info for screen reader users. ARIA text is not visible. 219 | 220 | > Please keep placeholder text such as `{{TAG}}` or `{{TAGS}}` as it is. It will be replaced with proper text. 221 | 222 | ## Methods 223 | 224 | | Method | Argument | Return | Description | 225 | | ------ | ----------- | -------- | ------ | 226 | | **addTag** | `String` | `undefined` | Adds a new tag. No duplicates allowed. | 227 | | **removeTag** | `String` | `undefined` | Removes a tag if it exists. | 228 | | **getTags** | - | `String[]` | Gets the tag array. | 229 | 230 | ## Contributing 231 | 232 | Everyone is welcome to contribute. 233 | 234 | * If you found a bug or have an idea how to improve the component, please submit an [issue](https://github.com/accessible-components/tag-input/issues). 235 | * You may test our component on different devices and give us your feedback. 236 | 237 | ## Ideas for the next features 238 | 239 | * Min/max tags specified vis options. 240 | * Custom tag validation before `addTag` 241 | * Tag autocomplete 242 | * Custome _remove tag_ icon provided via options. 243 | * Translate ARIA and other text and provide downloadable JSON files with translations. 244 | 245 | ## License 246 | 247 | [MIT](LICENSE.md) 248 | -------------------------------------------------------------------------------- /src/tag-input.js: -------------------------------------------------------------------------------- 1 | (function (global) { 2 | const iconTmpl = ` 3 | 4 | 5 | 6 | 7 | `; 8 | 9 | const key = { 10 | ENTER: 13, 11 | ESC: 27, 12 | ARROW_LEFT: 37, 13 | ARROW_RIGHT: 39, 14 | TAB: 9, 15 | DELETE: 46, 16 | }; 17 | 18 | /** 19 | * TagInput constructor. 20 | * 21 | * @constructor 22 | * @param {HTMLElement} el Container element. 23 | * @param {object} options Options. 24 | */ 25 | function TagInput(el, options) { 26 | if (!el) { 27 | throw new Error('Element not found.'); 28 | } 29 | 30 | options = options || {}; 31 | 32 | this.settings = { 33 | tags: [], 34 | prefix: 'tag-input', 35 | disabled: false, 36 | name: 'tag-input', 37 | placeholder: 'Add tags', 38 | label: 'Tags', 39 | ariaTag: 'Tag {{TAG}}.', 40 | ariaEditTag: 'Edit tag.', 41 | ariaDeleteTag: 'Delete tag {{TAG}}.', 42 | ariaTagAdded: 'Tag {{TAG}} added.', 43 | ariaTagUpdated: 'Tag updated to {{TAG}}.', 44 | ariaTagDeleted: 'Tag {{TAG}} deleted.', 45 | ariaTagSelected: 'Tag {{TAG}} selected. Press enter to edit, delete to delete.', 46 | ariaNoTagsSelected: 'No tags selected.', 47 | ariaInputLabel: 48 | '{{TAGS}} tags. Use left and right arrow keys to navigate, enter or tab to create, delete to delete tags.', 49 | }; 50 | 51 | for (var setting in options) { 52 | if (options.hasOwnProperty(setting)) { 53 | this.settings[setting] = options[setting]; 54 | } 55 | } 56 | 57 | this.tags = []; 58 | this.id = rand(); 59 | this.value = ''; 60 | 61 | init.call(this, el); 62 | } 63 | 64 | /** 65 | * Adds a new tag. Skips 'onTagAdd' callback when running on init. 66 | * 67 | * @param {string} str New tag to be added. 68 | * @param {boolean} init Adding tags on init from settings (see init). 69 | * @returns {undefined} 70 | */ 71 | TagInput.prototype.addTag = function (str, init) { 72 | const { settings } = this; 73 | const tag = typeof str === 'string' ? sanitize(str) : null; 74 | 75 | if (!tag || this.tags.indexOf(tag) !== -1) { 76 | return; 77 | } 78 | 79 | // *** Tag *** 80 | const tagEl = document.createElement('div'); 81 | 82 | tagEl.classList.add(`${settings.prefix}__tag`); 83 | tagEl.setAttribute('aria-label', settings.ariaTag.replace('{{TAG}}', tag)); 84 | tagEl.dataset.tagValue = tag; 85 | 86 | // *** Tag text *** 87 | const textEl = document.createElement('span'); 88 | 89 | textEl.classList.add(`${settings.prefix}__text`); 90 | textEl.textContent = tag; 91 | textEl.setAttribute('aria-hidden', true); 92 | tagEl.appendChild(textEl); 93 | 94 | if (!settings.disabled) { 95 | textEl.addEventListener('dblclick', (e) => { 96 | makeTagEditable.call(this, e.target.parentNode.dataset.tagValue); 97 | }); 98 | } 99 | 100 | // *** Tag label *** 101 | const tagEditLabelEl = document.createElement('label'); 102 | const tagId = `tag-id-${rand()}`; 103 | 104 | tagEditLabelEl.htmlFor = tagId; 105 | tagEditLabelEl.textContent = settings.ariaEditTag; 106 | tagEditLabelEl.classList.add(`${settings.prefix}__edit-label`); 107 | tagEditLabelEl.setAttribute('aria-hidden', true); 108 | visuallyHide(tagEditLabelEl); 109 | tagEl.appendChild(tagEditLabelEl); 110 | 111 | // *** Tag edit *** 112 | const tagEditEl = document.createElement('input'); 113 | const handleTagEditKeyup = onTagEditKeyup.bind(this); 114 | const handleTagEditKeydown = onTagEditKeydown.bind(this); 115 | const handleTagEditInput = onTagEditInput.bind(this); 116 | 117 | tagEditEl.classList.add(`${settings.prefix}__edit`); 118 | tagEditEl.setAttribute('tabindex', -1); 119 | tagEditEl.setAttribute('type', 'text'); 120 | tagEditEl.setAttribute('aria-hidden', true); 121 | tagEditEl.value = tag; 122 | tagEditEl.id = tagId; 123 | tagEl.appendChild(tagEditEl); 124 | 125 | tagEditEl.addEventListener('focus', () => { 126 | document.addEventListener('keyup', handleTagEditKeyup); 127 | document.addEventListener('keydown', handleTagEditKeydown); 128 | document.addEventListener('input', handleTagEditInput); 129 | }); 130 | 131 | tagEditEl.addEventListener('blur', (e) => { 132 | document.removeEventListener('keyup', handleTagEditKeyup); 133 | document.removeEventListener('keydown', handleTagEditKeydown); 134 | document.removeEventListener('input', handleTagEditInput); 135 | 136 | // All clean up is done here to handle the case 137 | // when user clicks outside of tag-input. 138 | tagEl.classList.remove(`${settings.prefix}__tag--editable`); 139 | tagEditEl.setAttribute('aria-hidden', true); 140 | tagEditLabelEl.setAttribute('aria-hidden', true); 141 | 142 | if (e.target.value !== tagEl.dataset.tagValue) { 143 | textEl.textContent = tagEl.dataset.tagValue; 144 | tagEditEl.value = tagEl.dataset.tagValue; 145 | } 146 | 147 | resetSelected.call(this); 148 | }); 149 | 150 | // *** Remove button *** 151 | const { inputEl } = this; 152 | 153 | if (!settings.disabled) { 154 | const removeBtn = document.createElement('button'); 155 | const removeTag = this.removeTag.bind(this); 156 | const talk = say.bind(this); 157 | 158 | removeBtn.classList.add(`${settings.prefix}__remove-button`); 159 | removeBtn.setAttribute('tabindex', -1); 160 | removeBtn.setAttribute('type', 'button'); 161 | removeBtn.setAttribute('aria-label', settings.ariaDeleteTag.replace('{{TAG}}', tag)); 162 | removeBtn.innerHTML = iconTmpl; 163 | tagEl.appendChild(removeBtn); 164 | 165 | removeBtn.addEventListener('click', function () { 166 | const tag = this.parentNode.dataset.tagValue; 167 | 168 | removeTag(tag); 169 | talk(settings.ariaTagDeleted.replace('{{TAG}}', tag)); 170 | inputEl.focus(); 171 | }); 172 | 173 | // *** Close icon *** 174 | const iconEl = removeBtn.querySelector('svg'); 175 | iconEl.classList.add(`${settings.prefix}__remove-icon`); 176 | } 177 | 178 | // Add tag 179 | this.tagInput.insertBefore(tagEl, inputEl); 180 | this.tags.push(tag); 181 | setInputValue.call(this); 182 | updateInputLabel.call(this); 183 | 184 | // Run callback 185 | if (!init && settings.onTagAdd) { 186 | settings.onTagAdd(tag, this.tags); 187 | } 188 | }; 189 | 190 | /** 191 | * Removes a tag. 192 | * 193 | * @param {string} tag Tag. 194 | * @returns {undefined} 195 | */ 196 | TagInput.prototype.removeTag = function (tag) { 197 | const tagEl = getTagEl.call(this, tag); 198 | 199 | if (tagEl) { 200 | tagEl.remove(); 201 | this.tags = this.tags.filter((t) => t !== tag); 202 | setInputValue.call(this); 203 | updateInputLabel.call(this); 204 | 205 | if (this.settings.onTagRemove) { 206 | this.settings.onTagRemove(tag, this.tags); 207 | } 208 | } 209 | }; 210 | 211 | /** 212 | * Returns an array of tags. 213 | * 214 | * @returns {array} 215 | */ 216 | TagInput.prototype.getTags = function () { 217 | return this.tags; 218 | }; 219 | 220 | /** 221 | * Creates HTML elements. Adds event listeners. 222 | * 223 | * @param {HTMLElement} el Container element. 224 | * @returns {undefined} 225 | */ 226 | function init(el) { 227 | // *** Container *** 228 | el.classList.add(`${this.settings.prefix}-container`); 229 | 230 | // *** Label *** 231 | const labelEl = document.createElement('label'); 232 | 233 | labelEl.classList.add(`${this.settings.prefix}-label`); 234 | labelEl.htmlFor = `${this.settings.prefix}-${this.id}`; 235 | labelEl.textContent = this.settings.label; 236 | 237 | if (this.settings.hiddenLabel) { 238 | visuallyHide(labelEl); 239 | } 240 | 241 | el.appendChild(labelEl); 242 | 243 | // *** Hidden label *** 244 | // Holds some instructions for screen reader users. 245 | const labelHiddenEl = document.createElement('span'); 246 | 247 | visuallyHide(labelHiddenEl); 248 | labelEl.appendChild(labelHiddenEl); 249 | 250 | // *** Tag input *** 251 | const tagInputEl = document.createElement('div'); 252 | 253 | tagInputEl.classList.add(this.settings.prefix); 254 | this.tagInput = tagInputEl; 255 | 256 | if (this.settings.disabled) { 257 | tagInputEl.classList.add(`${this.settings.prefix}--disabled`); 258 | } 259 | 260 | el.appendChild(tagInputEl); 261 | 262 | // *** Input *** 263 | const inputEl = document.createElement('input'); 264 | const inputKeyupHandler = onInputKeyup.bind(this); 265 | const inputKeydownHandler = onInputKeydown.bind(this); 266 | 267 | inputEl.setAttribute('type', 'text'); 268 | inputEl.setAttribute('placeholder', this.settings.placeholder); 269 | inputEl.classList.add(`${this.settings.prefix}__input`); 270 | inputEl.id = `${this.settings.prefix}-${this.id}`; 271 | this.inputEl = inputEl; 272 | this.value = this.inputEl.value; 273 | 274 | if (this.settings.disabled) { 275 | inputEl.setAttribute('disabled', true); 276 | } 277 | 278 | inputEl.addEventListener('focus', () => { 279 | document.addEventListener('keyup', inputKeyupHandler); 280 | document.addEventListener('keydown', inputKeydownHandler); 281 | }); 282 | 283 | inputEl.addEventListener('blur', () => { 284 | document.removeEventListener('keyup', inputKeyupHandler); 285 | document.removeEventListener('keydown', inputKeydownHandler); 286 | 287 | resetSelected.call(this); 288 | }); 289 | 290 | this.tagInput.appendChild(inputEl); 291 | 292 | // *** Hidden input (holds tags value) *** 293 | const _inputEl = document.createElement('input'); 294 | 295 | _inputEl.setAttribute('type', 'hidden'); 296 | _inputEl.setAttribute('name', this.settings.name); 297 | this._inputEl = _inputEl; 298 | this.tagInput.appendChild(_inputEl); 299 | 300 | // *** Live region *** 301 | const liveRegionEl = document.createElement('span'); 302 | 303 | visuallyHide(liveRegionEl); 304 | liveRegionEl.id = `${this.settings.prefix}-live-region-${rand()}`; 305 | el.appendChild(liveRegionEl); 306 | 307 | // *** Tags *** 308 | this.settings.tags.forEach((tag) => { 309 | this.addTag(tag, true); 310 | }); 311 | 312 | if (this.settings.onInit) { 313 | this.settings.onInit(this.tags); 314 | } 315 | } 316 | 317 | /** 318 | * Handles keydowns from on the input element. 319 | * All "safe" actions handeled here, because it's faster than keyup. 320 | * 321 | * @param {event} e Keydown event. 322 | * @returns {undefined} 323 | */ 324 | function onInputKeydown(e) { 325 | switch (e.keyCode) { 326 | case key.TAB: 327 | case key.ENTER: 328 | if (e.target.value || this._selected) { 329 | e.preventDefault(); 330 | } 331 | break; 332 | 333 | case key.ARROW_LEFT: 334 | if (this.tags.length && !e.target.value) { 335 | const i = this.tags.indexOf(this._selected); 336 | const last = this.tags[this.tags.length - 1]; 337 | const previous = this.tags[i - 1]; 338 | 339 | if (previous) { 340 | setSelected.call(this, previous); 341 | say.call(this, this.settings.ariaTagSelected.replace('{{TAG}}', previous)); 342 | } else if (i !== 0) { 343 | setSelected.call(this, last); 344 | say.call(this, this.settings.ariaTagSelected.replace('{{TAG}}', last)); 345 | } 346 | } 347 | break; 348 | 349 | case key.ARROW_RIGHT: 350 | if (this.tags.length && !e.target.value) { 351 | const i = this.tags.indexOf(this._selected); 352 | const next = this.tags[i + 1]; 353 | 354 | if (next && i >= 0) { 355 | setSelected.call(this, next); 356 | say.call(this, this.settings.ariaTagSelected.replace('{{TAG}}', next)); 357 | } else if (i === this.tags.length - 1) { 358 | resetSelected.call(this); 359 | say.call(this, this.settings.ariaNoTagsSelected); 360 | } 361 | } 362 | break; 363 | 364 | case key.ESC: 365 | if (this._selected) { 366 | resetSelected.call(this); 367 | say.call(this, this.settings.ariaNoTagsSelected); 368 | } 369 | break; 370 | 371 | default: 372 | break; 373 | } 374 | } 375 | 376 | /** 377 | * Handles keyups on the input element. 378 | * 379 | * In comparison to keydown, keyup is more accessible, since it gives users 380 | * a possibility to move cursor away from the element clicked by mistake. 381 | * 382 | * @param {event} e Keyup event. 383 | * @returns {undefined} 384 | */ 385 | function onInputKeyup(e) { 386 | if (e.target.value) { 387 | resetSelected.call(this); 388 | } 389 | 390 | switch (e.keyCode) { 391 | case key.ENTER: 392 | if (e.target.value) { 393 | this.addTag(e.target.value); 394 | say.call(this, this.settings.ariaTagAdded.replace('{{TAG}}', e.target.value)); 395 | this.inputEl.value = ''; 396 | } else if (this._selected) { 397 | makeTagEditable.call(this, this._selected); 398 | } 399 | break; 400 | 401 | case key.TAB: 402 | if (e.target.value) { 403 | this.addTag(e.target.value); 404 | say.call(this, this.settings.ariaTagAdded.replace('{{TAG}}', e.target.value)); 405 | this.inputEl.value = ''; 406 | } 407 | break; 408 | 409 | case key.DELETE: 410 | if (!this.value) { 411 | if (this._selected) { 412 | this.removeTag(this._selected); 413 | say.call(this, this.settings.ariaTagDeleted.replace('{{TAG}}', this._selected)); 414 | resetSelected.call(this); 415 | } else if (this.tags.length) { 416 | const last = this.tags[this.tags.length - 1]; 417 | 418 | this.removeTag(last); 419 | say.call(this, this.settings.ariaTagDeleted.replace('{{TAG}}', last)); 420 | resetSelected.call(this); 421 | } 422 | } 423 | break; 424 | 425 | default: 426 | break; 427 | } 428 | 429 | this.value = this.inputEl.value; 430 | } 431 | 432 | /** 433 | * Handles keyups for an input element in the tag. 434 | * 435 | * @param {event} e Keydown event. 436 | * @returns {undefined} 437 | */ 438 | function onTagEditKeydown(e) { 439 | switch (e.keyCode) { 440 | // Prevents form submit 441 | case key.ENTER: 442 | e.preventDefault(); 443 | break; 444 | 445 | default: 446 | break; 447 | } 448 | } 449 | 450 | /** 451 | * Handles keyups for an input element in the tag. 452 | * 453 | * @param {event} e Keyup event. 454 | * @returns {undefined} 455 | */ 456 | function onTagEditKeyup(e) { 457 | const tagEl = e.target.parentNode; 458 | const tag = tagEl.dataset.tagValue; 459 | 460 | switch (e.keyCode) { 461 | case key.ESC: 462 | this.inputEl.focus(); 463 | break; 464 | 465 | case key.ENTER: 466 | const value = sanitize(e.target.value); 467 | 468 | if (!value) { 469 | this.removeTag(tagEl.dataset.tagValue); 470 | this.inputEl.focus(); 471 | say.call(this, this.settings.ariaTagDeleted.replace('{{TAG}}', tagEl.dataset.tagValue)); 472 | 473 | return; 474 | } 475 | 476 | this.tags = this.tags.map((t) => { 477 | return t === tag ? value : t; 478 | }); 479 | 480 | setInputValue.call(this); 481 | say.call(this, this.settings.ariaTagUpdated.replace('{{TAG}}', value)); 482 | tagEl.dataset.tagValue = value; 483 | this.inputEl.focus(); 484 | 485 | if (this.settings.onTagUpdate) { 486 | this.settings.onTagUpdate(tag, value, this.tags); 487 | } 488 | break; 489 | 490 | default: 491 | break; 492 | } 493 | } 494 | 495 | /** 496 | * Injects a text into a hidden non-editable tag, takes a width from it 497 | * and sets this width to the editable input element. 498 | * 499 | * @param {event} e Input event. 500 | * @returns {undefined} 501 | */ 502 | function onTagEditInput(e) { 503 | const tagEl = e.target.parentNode; 504 | const textEl = tagEl.querySelector(`.${this.settings.prefix}__text`); 505 | const editEl = tagEl.querySelector(`.${this.settings.prefix}__edit`); 506 | 507 | textEl.textContent = e.target.value; 508 | editEl.style.width = `${textEl.getBoundingClientRect().width}px`; 509 | } 510 | 511 | /** 512 | * Hides an element visually, but keeps it accessible for screen readers. 513 | * 514 | * @param {HTMLElement} el Element. 515 | * @returns {undefined} 516 | */ 517 | function visuallyHide(el) { 518 | el.style.clip = '1px, 1px, 1px, 1px'; 519 | el.style.height = '1px'; 520 | el.style.width = '1px'; 521 | el.style.overflow = 'hidden'; 522 | el.style.position = 'absolute'; 523 | el.style.whiteSpace = 'nowrap'; 524 | } 525 | 526 | /** 527 | * Prepares tags passed from the input field. 528 | * 529 | * @param {string} tag Tag. 530 | * @returns {string} 531 | */ 532 | function sanitize(tag) { 533 | return tag.trim(); 534 | } 535 | 536 | /** 537 | * Sets a value from tags to the hidden input field. 538 | * 539 | * @returns {undefined} 540 | */ 541 | function setInputValue() { 542 | this._inputEl.value = JSON.stringify(this.tags); 543 | } 544 | 545 | /** 546 | * Forces screen reader to pronounce the phrase. 547 | * This is a simplified version of https://github.com/Heydon/on-demand-live-region 548 | * 549 | * @param {string} phrase Phrase to be said by screen readers. 550 | * @returns {undefined} 551 | */ 552 | function say(phrase) { 553 | const container = this.tagInput.parentNode; 554 | const oldRegion = container.querySelector(`[id^="${this.settings.prefix}-live-region-"]`); 555 | 556 | if (oldRegion) { 557 | container.removeChild(oldRegion); 558 | } 559 | 560 | const newRegion = document.createElement('span'); 561 | 562 | newRegion.id = `${this.settings.prefix}-live-region-${rand()}`; 563 | newRegion.setAttribute('aria-live', 'assertive'); 564 | newRegion.setAttribute('role', 'alert'); 565 | visuallyHide(newRegion); 566 | container.appendChild(newRegion); 567 | newRegion.textContent = phrase; 568 | } 569 | 570 | /** 571 | * Updates hidden text for screen readers with meta information. 572 | * 573 | * @returns {undefined} 574 | */ 575 | function updateInputLabel() { 576 | const containerEl = this.tagInput.parentNode; 577 | const hiddenLabelEl = containerEl.querySelector(`.${this.settings.prefix}-label span`); 578 | 579 | hiddenLabelEl.textContent = `, ${this.settings.ariaInputLabel.replace( 580 | '{{TAGS}}', 581 | this.tags.length 582 | )}`; 583 | } 584 | 585 | /** 586 | * Makes a tag editable. Width for input is taken from non-editable span (see onTagEditInput). 587 | * 588 | * @param {string} tag Tag. 589 | * @returns {undefined} 590 | */ 591 | function makeTagEditable(tag) { 592 | const tagEl = getTagEl.call(this, tag); 593 | const textEl = tagEl.querySelector(`.${this.settings.prefix}__text`); 594 | const editEl = tagEl.querySelector(`.${this.settings.prefix}__edit`); 595 | const editLabelEl = tagEl.querySelector(`.${this.settings.prefix}__edit-label`); 596 | 597 | tagEl.classList.add(`${this.settings.prefix}__tag--editable`); 598 | editEl.style.width = `${textEl.getBoundingClientRect().width}px`; 599 | editEl.removeAttribute('aria-hidden'); 600 | editLabelEl.removeAttribute('aria-hidden'); 601 | 602 | editEl.focus(); 603 | } 604 | 605 | /** 606 | * Returns a tag element found by passed tag string. 607 | * 608 | * @param {string} tag Tag. 609 | * @returns {HTMLElement} 610 | */ 611 | function getTagEl(tag) { 612 | return this.tagInput.querySelector(`[data-tag-value="${tag}"]`); 613 | } 614 | 615 | /** 616 | * Sets selected tag. 617 | * 618 | * @param {string} tag Tag. 619 | * @returns {undefined} 620 | */ 621 | function setSelected(tag) { 622 | if (this._selected !== tag) { 623 | resetSelected.call(this); 624 | } 625 | 626 | const tagEl = getTagEl.call(this, tag); 627 | 628 | if (tagEl) { 629 | tagEl.classList.add(`${this.settings.prefix}__tag--selected`); 630 | this._selected = tag; 631 | } 632 | } 633 | 634 | /** 635 | * Resets selected tag. 636 | * 637 | * @returns {undefined} 638 | */ 639 | function resetSelected() { 640 | const selectedEl = this.tagInput.querySelector(`.${this.settings.prefix}__tag--selected`); 641 | 642 | if (selectedEl) { 643 | selectedEl.classList.remove(`${this.settings.prefix}__tag--selected`); 644 | } 645 | 646 | this._selected = null; 647 | } 648 | 649 | /** 650 | * Simple random number generator. 651 | * 652 | * @returns {number} 653 | */ 654 | function rand() { 655 | return Math.floor(Math.random() * 10000); 656 | } 657 | 658 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 659 | module.exports = TagInput; 660 | } else if (typeof define === 'function' && define.amd) { 661 | define('TagInput', [], function () { 662 | return TagInput; 663 | }); 664 | } else if (typeof global === 'object') { 665 | global.TagInput = TagInput; 666 | } 667 | })(this); 668 | --------------------------------------------------------------------------------